BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / equipment.rs

equipment.rs

equipment.rs 43.1 KiB Download Raw
1//! Rigid equipment, portable embedded assets and a bounded binary container.
2//! Local rotation uses Rz * Ry * Rx (degrees); attachment uses the animated
3//! joint world transform, never a skin matrix or inverse bind a second time.
4
5use std::collections::BTreeSet;
6use std::io::{Cursor, Read, Write};
7use std::path::Path;
8
9use crate::preview::{
10 animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls,
11};
12use crate::project::portable_relative;
13use serde::{Deserialize, Serialize};
14
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16pub const VERSION: u32 = 1;
17pub const MAX_FILE: usize = 32 * 1024 * 1024;
18const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_ASSET: usize = 8 * 1024 * 1024;
20const MAX_ASSETS: usize = 32;
21const MAX_VERTICES: usize = 80_000;
22/// Vertex budget when the rigid readers load a static (unskinned) *body*;
23/// same ceiling as the FBX body loader.
24pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000;
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct EmbeddedAsset {
29 pub path: String,
30 #[serde(skip)]
31 pub bytes: Vec<u8>,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct Mesh {
37 pub positions: Vec<[f32; 3]>,
38 pub normals: Vec<[f32; 3]>,
39 pub uvs: Vec<[f32; 2]>,
40 pub indices: Vec<u32>,
41 pub color: [f32; 4],
42 pub albedo: Option<usize>,
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct RigSignature {
48 pub paths: Vec<String>,
49 pub rest_world: Vec<Mat4>,
50}
51impl RigSignature {
52 pub fn of(body: &PreviewCharacter) -> Self {
53 Self {
54 paths: joint_paths(body),
55 rest_world: (0..body.joint_nodes.len())
56 .map(|j| animated_joint_world(body, "", 0.0, j).unwrap_or_else(identity))
57 .collect(),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct Attachment {
65 pub label: String,
66 /// Original OBJ/MTL/dependency payloads owned by this attachment.
67 pub source_assets: Vec<usize>,
68 pub bones: [Option<String>; 2],
69 pub translation: [f32; 3],
70 pub rotation_deg: [f32; 3],
71 pub meshes: Vec<Mesh>,
72 pub shiny: Option<usize>,
73 pub specular: f32,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Preset {
79 pub name: String,
80 pub provenance: String,
81 pub applies: [bool; 2],
82 pub rigs: [Option<RigSignature>; 2],
83 pub attachments: Vec<Attachment>,
84 pub assets: Vec<EmbeddedAsset>,
85}
86impl Preset {
87 pub fn new(name: String) -> Self {
88 Self {
89 name,
90 provenance: String::new(),
91 applies: [true, true],
92 rigs: [None, None],
93 attachments: Vec::new(),
94 assets: Vec::new(),
95 }
96 }
97 pub fn bind(
98 &mut self,
99 sex: usize,
100 body: &PreviewCharacter,
101 attachment: usize,
102 joint: usize,
103 ) -> Result<(), String> {
104 if sex > 1 {
105 return Err("ongeldig geslacht".into());
106 }
107 let paths = joint_paths(body);
108 let path = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();
109 let item = self
110 .attachments
111 .get_mut(attachment)
112 .ok_or("dit onderdeel bestaat niet")?;
113 item.bones[sex] = Some(path);
114 self.rigs[sex] = Some(RigSignature::of(body));
115 Ok(())
116 }
117 pub fn validate(&self) -> Result<(), String> {
118 self.validate_with(MAX_VERTICES)
119 }
120 fn validate_with(&self, max_vertices: usize) -> Result<(), String> {
121 if !valid_name(&self.name) {
122 return Err(
123 "equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),
124 );
125 }
126 if self.provenance.len() > 4096 || self.provenance.contains('\0') {
127 return Err("herkomst/licentie is te lang of bevat NUL".into());
128 }
129 if !self.applies.iter().any(|x| *x) {
130 return Err("kies minstens één geslacht".into());
131 }
132 if self.attachments.len() > 32 || self.assets.len() > MAX_ASSETS {
133 return Err("te veel onderdelen of bestanden".into());
134 }
135 let mut paths = BTreeSet::new();
136 let mut total = 0usize;
137 for asset in &self.assets {
138 portable_relative(&asset.path)?;
139 if asset.path.len() > 240 || asset.bytes.len() > MAX_ASSET || !paths.insert(&asset.path)
140 {
141 return Err("ongeldige, dubbele of te grote asset".into());
142 }
143 total = total
144 .checked_add(asset.bytes.len())
145 .ok_or("te veel assetdata")?;
146 }
147 if total > MAX_FILE {
148 return Err("te veel assetdata".into());
149 }
150 let mut texture_indices = BTreeSet::new();
151 let mut vertices = 0;
152 for rig in self.rigs.iter().flatten() {
153 if rig.paths.len() > 128
154 || rig.paths.len() != rig.rest_world.len()
155 || rig.paths.iter().any(|p| p.len() > 4096)
156 || rig.rest_world.iter().flatten().any(|x| !x.is_finite())
157 {
158 return Err("ongeldige rigbinding".into());
159 }
160 }
161 for attachment in &self.attachments {
162 if attachment.source_assets.len() > MAX_ASSETS
163 || attachment
164 .source_assets
165 .iter()
166 .any(|i| *i >= self.assets.len())
167 {
168 return Err("ongeldige bronassetverwijzing".into());
169 }
170 for sex in 0..2 {
171 if let Some(path) = &attachment.bones[sex] {
172 if !self.rigs[sex]
173 .as_ref()
174 .map(|rig| rig.paths.contains(path))
175 .unwrap_or(false)
176 {
177 return Err("attachment verwijst naar onbekend bot".into());
178 }
179 }
180 }
181 if attachment.label.len() > 128
182 || attachment.bones.iter().flatten().any(|p| p.len() > 4096)
183 || attachment
184 .translation
185 .iter()
186 .chain(attachment.rotation_deg.iter())
187 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
188 || !attachment.specular.is_finite()
189 || !(0.0..=1.0).contains(&attachment.specular)
190 || attachment.meshes.len() > 64
191 {
192 return Err("ongeldige attachment/transformatie".into());
193 }
194 if let Some(index) = attachment.shiny {
195 texture_indices.insert(index);
196 }
197 for mesh in &attachment.meshes {
198 vertices += mesh.positions.len();
199 if vertices > max_vertices
200 || mesh.indices.len() > max_vertices * 6
201 || mesh.indices.len() < 3
202 || mesh.indices.len() % 3 != 0
203 || mesh.positions.len() != mesh.normals.len()
204 || mesh.positions.len() != mesh.uvs.len()
205 || mesh.positions.is_empty()
206 || mesh
207 .indices
208 .iter()
209 .any(|i| *i as usize >= mesh.positions.len())
210 || mesh
211 .positions
212 .iter()
213 .flatten()
214 .chain(mesh.normals.iter().flatten())
215 .chain(mesh.uvs.iter().flatten())
216 .chain(mesh.color.iter())
217 .any(|x| !x.is_finite() || x.abs() > 1_000_000.0)
218 {
219 return Err("ongeldige of te grote equipmentmesh".into());
220 }
221 if let Some(index) = mesh.albedo {
222 texture_indices.insert(index);
223 }
224 }
225 }
226 let mut decoded = 0usize;
227 for index in texture_indices {
228 decoded += self.texture(index)?.rgba.len();
229 if decoded > 64 * 1024 * 1024 {
230 return Err("textures samen groter dan 64 MiB".into());
231 }
232 }
233 Ok(())
234 }
235 pub fn texture(&self, index: usize) -> Result<Texture, String> {
236 let asset = self
237 .assets
238 .get(index)
239 .ok_or("textureverwijzing ontbreekt")?;
240 decode_png(&asset.bytes)
241 }
242 /// Exact container byte cost; does not substitute for semantic validation.
243 pub fn encoded_len(&self) -> Result<usize, String> {
244 let meta = serde_json::to_vec(self).map_err(|e| e.to_string())?.len();
245 if meta > MAX_META {
246 return Err("presetmetadata is te groot".into());
247 }
248 let size = self
249 .assets
250 .iter()
251 .try_fold(20 + meta, |size, a| {
252 size.checked_add(4)?.checked_add(a.bytes.len())
253 })
254 .ok_or("preset te groot")?;
255 if size > MAX_FILE {
256 return Err("preset te groot".into());
257 }
258 Ok(size)
259 }
260 pub fn encode(&self) -> Result<Vec<u8>, String> {
261 self.validate()?;
262 let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;
263 if metadata.len() > MAX_META {
264 return Err("presetmetadata is te groot".into());
265 }
266 let mut payload = metadata;
267 let meta_len = payload.len();
268 for asset in &self.assets {
269 payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());
270 payload.extend_from_slice(&asset.bytes);
271 }
272 if payload.len() + 20 > MAX_FILE {
273 return Err("preset is te groot".into());
274 }
275 let mut out = MAGIC.to_vec();
276 out.extend_from_slice(&VERSION.to_le_bytes());
277 out.extend_from_slice(&(meta_len as u32).to_le_bytes());
278 out.extend_from_slice(&checksum(&payload).to_le_bytes());
279 out.extend(payload);
280 Ok(out)
281 }
282 pub fn decode(bytes: &[u8]) -> Result<Self, String> {
283 if bytes.len() < 20 || bytes.len() > MAX_FILE || &bytes[..8] != MAGIC {
284 return Err("ongeldig equipmentbestand of bestand te groot".into());
285 }
286 let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
287 if number(8) != VERSION {
288 return Err("onbekende equipmentversie".into());
289 }
290 let meta_len = number(12) as usize;
291 if meta_len > MAX_META
292 || meta_len > bytes.len() - 20
293 || checksum(&bytes[20..]) != number(16)
294 {
295 return Err("beschadigde equipmentdata".into());
296 }
297 let mut preset: Self =
298 serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;
299 if preset.assets.len() > MAX_ASSETS {
300 return Err("te veel assets".into());
301 }
302 let mut cursor = 20 + meta_len;
303 for asset in &mut preset.assets {
304 if cursor + 4 > bytes.len() {
305 return Err("afgebroken asset".into());
306 }
307 let len = number(cursor) as usize;
308 cursor += 4;
309 if len > MAX_ASSET || len > bytes.len() - cursor {
310 return Err("ongeldige assetlengte".into());
311 }
312 asset.bytes = bytes[cursor..cursor + len].to_vec();
313 cursor += len;
314 }
315 if cursor != bytes.len() {
316 return Err("onverwachte extra equipmentdata".into());
317 }
318 preset.validate()?;
319 Ok(preset)
320 }
321 pub fn read(path: &Path) -> Result<Self, String> {
322 if !path.is_file() {
323 return Err("kies een regulier presetbestand".into());
324 }
325 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
326 let mut bytes = Vec::new();
327 file.take((MAX_FILE + 1) as u64)
328 .read_to_end(&mut bytes)
329 .map_err(|e| e.to_string())?;
330 Self::decode(&bytes)
331 }
332}
333
334pub fn valid_name(name: &str) -> bool {
335 !name.is_empty()
336 && name.len() <= 64
337 && name != "body"
338 && name
339 .bytes()
340 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')
341}
342
343pub fn checksum(bytes: &[u8]) -> u32 {
344 let mut crc = !0u32;
345 for byte in bytes {
346 crc ^= u32::from(*byte);
347 for _ in 0..8 {
348 crc = (crc >> 1) ^ (0xedb88320u32 & (0u32.wrapping_sub(crc & 1)));
349 }
350 }
351 !crc
352}
353
354/// Immutable snapshot publish: a hard link atomically creates the destination
355/// without overwriting an older saved preset. Failure leaves old data intact.
356pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {
357 if bytes.len() > MAX_FILE {
358 return Err("snapshot te groot".into());
359 }
360 if path.exists() {
361 if !path.is_file() {
362 return Err("snapshot is geen regulier bestand".into());
363 }
364 let mut current = Vec::new();
365 std::fs::File::open(path)
366 .map_err(|e| e.to_string())?
367 .take((MAX_FILE + 1) as u64)
368 .read_to_end(&mut current)
369 .map_err(|e| e.to_string())?;
370 return if current == bytes {
371 Ok(())
372 } else {
373 Err("snapshotnaam bestaat met andere inhoud".into())
374 };
375 }
376 let stamp = std::time::SystemTime::now()
377 .duration_since(std::time::UNIX_EPOCH)
378 .unwrap_or_default()
379 .as_nanos();
380 let temp = path.with_extension(format!("ppe.part-{}-{stamp}", std::process::id()));
381 let result = (|| {
382 let mut file = std::fs::OpenOptions::new()
383 .write(true)
384 .create_new(true)
385 .open(&temp)
386 .map_err(|e| e.to_string())?;
387 file.write_all(bytes)
388 .and_then(|_| file.sync_all())
389 .map_err(|e| e.to_string())?;
390 std::fs::hard_link(&temp, path).map_err(|e| e.to_string())?;
391 #[cfg(unix)]
392 std::fs::File::open(path.parent().ok_or("snapshotmap ontbreekt")?)
393 .and_then(|dir| dir.sync_all())
394 .map_err(|e| e.to_string())?;
395 Ok(())
396 })();
397 let _ = std::fs::remove_file(temp);
398 result
399}
400
401#[derive(Debug, Clone, PartialEq)]
402pub struct Texture {
403 pub content_hash: u64,
404 pub width: u32,
405 pub height: u32,
406 pub rgba: Vec<u8>,
407}
408fn decode_png(bytes: &[u8]) -> Result<Texture, String> {
409 let mut decoder = png::Decoder::new(Cursor::new(bytes));
410 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
411 let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
412 let info = reader.info();
413 if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {
414 return Err("texture is groter dan 2048×2048".into());
415 }
416 let size = reader
417 .output_buffer_size()
418 .ok_or("ongeldige texturegrootte")?;
419 if size > 2048 * 2048 * 8 {
420 return Err("texture is te groot".into());
421 }
422 let mut data = vec![0; size];
423 let info = reader.next_frame(&mut data).map_err(|e| e.to_string())?;
424 let channels = info.color_type.samples();
425 let mut rgba = Vec::with_capacity(info.width as usize * info.height as usize * 4);
426 for pixel in data[..info.buffer_size()].chunks_exact(channels) {
427 match info.color_type {
428 png::ColorType::Grayscale => {
429 rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], 255])
430 }
431 png::ColorType::GrayscaleAlpha => {
432 rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]])
433 }
434 png::ColorType::Rgb => rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]),
435 png::ColorType::Rgba => rgba.extend_from_slice(pixel),
436 _ => return Err("onbekende texturecodering".into()),
437 }
438 }
439 use std::hash::{Hash, Hasher};
440 let mut hash = std::collections::hash_map::DefaultHasher::new();
441 rgba.hash(&mut hash);
442 Ok(Texture {
443 content_hash: hash.finish(),
444 width: info.width,
445 height: info.height,
446 rgba,
447 })
448}
449
450pub fn local_matrix(translation: [f32; 3], degrees: [f32; 3]) -> Mat4 {
451 let [x, y, z] = degrees.map(f32::to_radians);
452 let (sx, cx) = x.sin_cos();
453 let (sy, cy) = y.sin_cos();
454 let (sz, cz) = z.sin_cos();
455 let rx = [
456 1., 0., 0., 0., 0., cx, sx, 0., 0., -sx, cx, 0., 0., 0., 0., 1.,
457 ];
458 let ry = [
459 cy, 0., -sy, 0., 0., 1., 0., 0., sy, 0., cy, 0., 0., 0., 0., 1.,
460 ];
461 let rz = [
462 cz, sz, 0., 0., -sz, cz, 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
463 ];
464 let mut matrix = mul(rz, mul(ry, rx));
465 matrix[12] = translation[0];
466 matrix[13] = translation[1];
467 matrix[14] = translation[2];
468 matrix
469}
470
471#[derive(Debug, Clone)]
472pub struct Layer {
473 pub mesh: Mesh,
474 pub model: Mat4,
475 pub albedo: Option<std::sync::Arc<Texture>>,
476 pub shiny: Option<std::sync::Arc<Texture>>,
477 pub specular: f32,
478}
479
480#[derive(Default)]
481pub struct TextureCache {
482 entries: std::collections::VecDeque<(Vec<u8>, std::sync::Arc<Texture>)>,
483 bytes: usize,
484}
485impl TextureCache {
486 pub(crate) fn get(
487 &mut self,
488 preset: &Preset,
489 index: usize,
490 ) -> Result<std::sync::Arc<Texture>, String> {
491 let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;
492 if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {
493 return Ok(texture.clone());
494 }
495 let texture = std::sync::Arc::new(decode_png(bytes)?);
496 let cost = bytes.len() + texture.rgba.len();
497 while self.bytes + cost > 128 * 1024 * 1024 {
498 if let Some((source, image)) = self.entries.pop_front() {
499 self.bytes -= source.len() + image.rgba.len();
500 } else {
501 break;
502 }
503 }
504 self.bytes += cost;
505 self.entries.push_back((bytes.clone(), texture.clone()));
506 Ok(texture)
507 }
508}
509
510pub fn layers(
511 preset: &Preset,
512 sex: usize,
513 body: &PreviewCharacter,
514 controls: &PreviewControls,
515) -> Result<Vec<Layer>, String> {
516 layers_cached(preset, sex, body, controls, &mut TextureCache::default())
517}
518
519pub fn layers_cached(
520 preset: &Preset,
521 sex: usize,
522 body: &PreviewCharacter,
523 controls: &PreviewControls,
524 cache: &mut TextureCache,
525) -> Result<Vec<Layer>, String> {
526 if sex > 1 {
527 return Err("ongeldig geslacht".into());
528 }
529 if !preset.applies[sex] || preset.attachments.is_empty() {
530 return Ok(Vec::new());
531 }
532 let signature = RigSignature::of(body);
533 if preset.rigs[sex].as_ref() != Some(&signature) {
534 return Err("rig wijkt af: kies de botten opnieuw voor dit lichaam".into());
535 }
536 let paths = joint_paths(body);
537 let mut layers = Vec::new();
538 for item in &preset.attachments {
539 let path = item.bones[sex]
540 .as_deref()
541 .ok_or("kies een bot voor dit onderdeel en geslacht")?;
542 let joint = paths
543 .iter()
544 .position(|p| p == path)
545 .ok_or("het gekoppelde bot ontbreekt")?;
546 let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)
547 .ok_or("het gekoppelde bot ontbreekt")?;
548 let model = mul(world, local_matrix(item.translation, item.rotation_deg));
549 let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
550 for mesh in &item.meshes {
551 layers.push(Layer {
552 mesh: mesh.clone(),
553 model,
554 albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?,
555 shiny: shiny.clone(),
556 specular: item.specular,
557 });
558 }
559 }
560 Ok(layers)
561}
562
563/// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.
564/// No extraction directories, absolute references or parent traversal allowed.
565pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
566 import_obj_limited(preset, source, MAX_VERTICES)
567}
568
569pub(crate) fn import_obj_limited(
570 preset: &mut Preset,
571 source: &Path,
572 max_vertices: usize,
573) -> Result<usize, String> {
574 if source
575 .extension()
576 .and_then(|x| x.to_str())
577 .map(|x| !x.eq_ignore_ascii_case("obj"))
578 .unwrap_or(true)
579 {
580 return Err("kies een rigide OBJ-model met MTL/PNG-bijbestanden".into());
581 }
582 let root = source
583 .parent()
584 .ok_or("modelmap ontbreekt")?
585 .canonicalize()
586 .map_err(|e| e.to_string())?;
587 let name = source
588 .file_name()
589 .and_then(|x| x.to_str())
590 .ok_or("ongeldige modelnaam")?;
591 let mut candidate = preset.clone();
592 if candidate.attachments.len() >= 32 {
593 return Err("te veel onderdelen".into());
594 }
595 compact_assets(&mut candidate)?;
596 let prefix = unique_prefix(&candidate, "part");
597 let obj_bytes = read_asset(&root, name)?;
598 let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;
599 let mut dependencies = BTreeSet::new();
600 dependencies.insert(name.to_string());
601 for line in obj_text.lines() {
602 if let Some(rest) = line.trim().strip_prefix("mtllib ") {
603 let mtl = rest.trim();
604 dependencies.insert(mtl.to_string());
605 let bytes = read_asset(&root, mtl)?;
606 let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
607 for line in text.lines() {
608 let line = line.trim();
609 if line.starts_with("map_")
610 || line.starts_with("bump ")
611 || line.starts_with("disp ")
612 {
613 let (_, path) = line
614 .split_once(char::is_whitespace)
615 .ok_or("ongeldige textureverwijzing")?;
616 let relative = Path::new(mtl)
617 .parent()
618 .unwrap_or(Path::new(""))
619 .join(path.trim());
620 let relative = relative
621 .to_str()
622 .ok_or("ongeldig texturepad")?
623 .replace('\\', "/");
624 portable_relative(&relative)?;
625 dependencies.insert(relative);
626 }
627 }
628 }
629 }
630 if dependencies.len() + candidate.assets.len() > MAX_ASSETS {
631 return Err("te veel bijbestanden".into());
632 }
633 let mut asset_map = std::collections::BTreeMap::new();
634 let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();
635 for relative in dependencies {
636 let bytes = read_asset(&root, &relative)?;
637 total_bytes += bytes.len();
638 if total_bytes > MAX_FILE {
639 return Err("assets samen te groot".into());
640 }
641 let index = candidate.assets.len();
642 candidate.assets.push(EmbeddedAsset {
643 path: format!("{prefix}/{relative}"),
644 bytes,
645 });
646 asset_map.insert(relative, index);
647 }
648 // A checked callback prevents the parser from reading undeclared/unsafe files.
649 let mut input = std::io::BufReader::new(Cursor::new(obj_bytes));
650 let options = tobj::LoadOptions {
651 triangulate: true,
652 single_index: true,
653 ..Default::default()
654 };
655 let texture_paths = std::cell::RefCell::new(std::collections::BTreeMap::new());
656 let (models, materials) = tobj::load_obj_buf(&mut input, &options, |path| {
657 let relative = path.to_string_lossy().replace('\\', "/");
658 let index = asset_map
659 .get(&relative)
660 .ok_or(tobj::LoadError::OpenFileFailed)?;
661 let mut reader = std::io::BufReader::new(Cursor::new(&candidate.assets[*index].bytes));
662 let (materials, map) = tobj::load_mtl_buf(&mut reader)?;
663 for material in &materials {
664 if let Some(texture) = &material.diffuse_texture {
665 let relative = path
666 .parent()
667 .unwrap_or(Path::new(""))
668 .join(texture)
669 .to_string_lossy()
670 .replace('\\', "/");
671 texture_paths
672 .borrow_mut()
673 .insert(material.name.clone(), relative);
674 }
675 }
676 Ok((materials, map))
677 })
678 .map_err(|e| e.to_string())?;
679 let materials = materials.map_err(|e| e.to_string())?;
680 let texture_paths = texture_paths.into_inner();
681 let mut meshes = Vec::new();
682 let mut vertices: usize = candidate
683 .attachments
684 .iter()
685 .flat_map(|a| &a.meshes)
686 .map(|m| m.positions.len())
687 .sum();
688 for model in models {
689 let source = model.mesh;
690 vertices += source.positions.len() / 3;
691 if vertices > max_vertices || source.indices.len() > max_vertices * 6 {
692 return Err("equipmentmesh is te groot".into());
693 }
694 let positions: Vec<[f32; 3]> = source
695 .positions
696 .chunks_exact(3)
697 .map(|x| [x[0], x[1], x[2]])
698 .collect();
699 let mut normals: Vec<[f32; 3]> = source
700 .normals
701 .chunks_exact(3)
702 .map(|x| [x[0], x[1], x[2]])
703 .collect();
704 if normals.len() != positions.len() {
705 normals = compute_normals(&positions, &source.indices)?;
706 }
707 let mut uvs: Vec<[f32; 2]> = source
708 .texcoords
709 .chunks_exact(2)
710 .map(|x| [x[0], x[1]])
711 .collect();
712 if uvs.len() != positions.len() {
713 uvs = vec![[0.; 2]; positions.len()];
714 }
715 let material = source.material_id.and_then(|id| materials.get(id));
716 let color = material.and_then(|m| m.diffuse).unwrap_or([0.7, 0.7, 0.7]);
717 let albedo = material
718 .and_then(|m| texture_paths.get(&m.name))
719 .map(|path| {
720 asset_map
721 .get(path)
722 .copied()
723 .ok_or("ontbrekende diffuse texture")
724 })
725 .transpose()?;
726 meshes.push(Mesh {
727 positions,
728 normals,
729 uvs,
730 indices: source.indices,
731 color: [
732 color[0],
733 color[1],
734 color[2],
735 material.and_then(|m| m.dissolve).unwrap_or(1.),
736 ],
737 albedo,
738 });
739 }
740 if meshes.is_empty() {
741 return Err("het OBJ-bestand heeft geen meshes".into());
742 }
743 candidate.attachments.push(Attachment {
744 label: name.to_string(),
745 source_assets: asset_map.values().copied().collect(),
746 bones: [None, None],
747 translation: [0.; 3],
748 rotation_deg: [0.; 3],
749 meshes,
750 shiny: None,
751 specular: 0.25,
752 });
753 candidate.validate_with(max_vertices)?;
754 *preset = candidate;
755 Ok(preset.attachments.len() - 1)
756}
757
758/// Import a rigid equipment model, choosing the reader by extension (OBJ+MTL or FBX).
759pub fn import_model(preset: &mut Preset, source: &Path) -> Result<usize, String> {
760 match source
761 .extension()
762 .and_then(|x| x.to_str())
763 .map(|x| x.to_ascii_lowercase())
764 .as_deref()
765 {
766 Some("obj") => import_obj(preset, source),
767 Some("fbx") => import_fbx(preset, source),
768 _ => Err("kies een rigide OBJ- (met MTL/PNG) of FBX-model".into()),
769 }
770}
771
772/// Import a rigid (unskinned) FBX into the preset. ufbx parses only the
773/// in-memory bytes (no external file access); axis/unit normalisation to
774/// right-handed Y-up metres happens once, here, exactly like `fbx_scene`.
775/// Embedded PNG textures are copied into the preset; external PNG textures
776/// must be relative files inside the model folder and are embedded too.
777pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
778 import_fbx_limited(preset, source, MAX_VERTICES)
779}
780
781pub(crate) fn import_fbx_limited(
782 preset: &mut Preset,
783 source: &Path,
784 max_vertices: usize,
785) -> Result<usize, String> {
786 if source
787 .extension()
788 .and_then(|x| x.to_str())
789 .map(|x| !x.eq_ignore_ascii_case("fbx"))
790 .unwrap_or(true)
791 {
792 return Err("kies een rigide FBX-model".into());
793 }
794 let root = source
795 .parent()
796 .ok_or("modelmap ontbreekt")?
797 .canonicalize()
798 .map_err(|e| e.to_string())?;
799 let name = source
800 .file_name()
801 .and_then(|x| x.to_str())
802 .ok_or("ongeldige modelnaam")?;
803 let mut candidate = preset.clone();
804 if candidate.attachments.len() >= 32 {
805 return Err("te veel onderdelen".into());
806 }
807 compact_assets(&mut candidate)?;
808 let prefix = unique_prefix(&candidate, "part");
809 let fbx_bytes = read_asset(&root, name)?;
810 let opts = ufbx::LoadOpts {
811 target_axes: ufbx::axes_right_handed_y_up(),
812 target_unit_meters: 1.0,
813 space_conversion: ufbx::SpaceConversion::ModifyGeometry,
814 geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry,
815 generate_missing_normals: true,
816 normalize_normals: true,
817 load_external_files: false,
818 ..Default::default()
819 };
820 let scene = ufbx::load_memory(&fbx_bytes, opts)
821 .map_err(|e| format!("FBX laden mislukt: {}", e.description))?;
822 if scene.metadata.geometry_ignored {
823 return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into());
824 }
825 let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();
826 let mut push_asset =
827 |candidate: &mut Preset, path: String, bytes: Vec<u8>| -> Result<usize, String> {
828 total_bytes += bytes.len();
829 if total_bytes > MAX_FILE || candidate.assets.len() >= MAX_ASSETS {
830 return Err("assets samen te groot of te veel bijbestanden".into());
831 }
832 candidate.assets.push(EmbeddedAsset { path, bytes });
833 Ok(candidate.assets.len() - 1)
834 };
835 let source_index = push_asset(&mut candidate, format!("{prefix}/{name}"), fbx_bytes)?;
836 let mut source_assets = vec![source_index];
837 // Texture identity (element id) -> embedded asset index, so shared textures are stored once.
838 let mut texture_assets: std::collections::BTreeMap<u32, usize> = Default::default();
839 let mut vertices: usize = candidate
840 .attachments
841 .iter()
842 .flat_map(|a| &a.meshes)
843 .map(|m| m.positions.len())
844 .sum();
845 let mut meshes = Vec::new();
846 let mut tri = Vec::new();
847 for node in &scene.nodes {
848 let Some(mesh) = node.mesh.as_ref() else {
849 continue;
850 };
851 if mesh.num_triangles == 0 {
852 continue;
853 }
854 if !mesh.skin_deformers.is_empty() {
855 return Err(format!(
856 "FBX-mesh '{}' is geskinned; alleen rigide equipment wordt ondersteund",
857 node.element.name.trim()
858 ));
859 }
860 if !mesh.blend_deformers.is_empty() {
861 return Err(format!(
862 "FBX-mesh '{}' heeft morph targets; die worden niet ondersteund",
863 node.element.name.trim()
864 ));
865 }
866 let to_world = node.geometry_to_world;
867 let for_normals = ufbx::matrix_for_normals(&to_world);
868 let parts = mesh.material_parts.len().max(1);
869 for part in 0..parts {
870 let faces: Vec<u32> = if mesh.material_parts.is_empty() {
871 (0..mesh.faces.len() as u32).collect()
872 } else {
873 mesh.material_parts[part]
874 .face_indices
875 .iter()
876 .copied()
877 .collect()
878 };
879 if faces.is_empty() {
880 continue;
881 }
882 let material = if mesh.material_parts.is_empty() {
883 None
884 } else {
885 mesh.materials.get(part)
886 };
887 let mut positions = Vec::new();
888 let mut normals = Vec::new();
889 let mut uvs = Vec::new();
890 let mut indices = Vec::new();
891 for face in faces {
892 let face = mesh.faces[face as usize];
893 if face.num_indices < 3 {
894 continue;
895 }
896 let count = ufbx::triangulate_face_vec(&mut tri, mesh, face) as usize;
897 for t in 0..count {
898 let mut corners = [tri[t * 3], tri[t * 3 + 1], tri[t * 3 + 2]];
899 if mesh.reversed_winding {
900 corners.swap(1, 2);
901 }
902 for corner in corners {
903 let corner = corner as usize;
904 let p = ufbx::transform_position(&to_world, mesh.vertex_position[corner]);
905 let n = if mesh.vertex_normal.exists {
906 let n =
907 ufbx::transform_direction(&for_normals, mesh.vertex_normal[corner]);
908 let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt();
909 if len > 1e-12 {
910 [(n.x / len) as f32, (n.y / len) as f32, (n.z / len) as f32]
911 } else {
912 [0., 1., 0.]
913 }
914 } else {
915 [0., 1., 0.]
916 };
917 let uv = if mesh.vertex_uv.exists {
918 let uv = mesh.vertex_uv[corner];
919 [uv.x as f32, uv.y as f32]
920 } else {
921 [0., 0.]
922 };
923 indices.push(positions.len() as u32);
924 positions.push([p.x as f32, p.y as f32, p.z as f32]);
925 normals.push(n);
926 uvs.push(uv);
927 }
928 }
929 }
930 if positions.is_empty() {
931 continue;
932 }
933 vertices += positions.len();
934 if vertices > max_vertices {
935 return Err("equipmentmesh is te groot".into());
936 }
937 if positions
938 .iter()
939 .chain(&normals)
940 .any(|v| v.iter().any(|x| !x.is_finite()))
941 {
942 return Err("FBX-mesh bevat niet-eindige posities of normalen".into());
943 }
944 if !mesh.vertex_normal.exists {
945 normals = compute_normals(&positions, &indices)?;
946 }
947 let (mut color, texture) = match material {
948 None => ([0.7, 0.7, 0.7, 1.0], None),
949 Some(m) => {
950 let map = if m.pbr.base_color.has_value {
951 &m.pbr.base_color
952 } else {
953 &m.fbx.diffuse_color
954 };
955 let v = map.value_vec4;
956 // Same alpha rule as the FBX body loader (`fbx_scene`).
957 let alpha = if v.w == 0.0 { 1.0 } else { v.w as f32 };
958 (
959 [v.x as f32, v.y as f32, v.z as f32, alpha],
960 map.texture
961 .as_ref()
962 .or(m.fbx.diffuse_color.texture.as_ref()),
963 )
964 }
965 };
966 if !color.iter().all(|c| c.is_finite()) {
967 return Err("FBX-materiaal heeft een niet-eindige kleur".into());
968 }
969 let albedo = match texture {
970 None => None,
971 Some(texture) => {
972 let id = texture.element.element_id;
973 if let Some(index) = texture_assets.get(&id) {
974 Some(*index)
975 } else {
976 let (path, bytes) = if texture.content.size > 0 {
977 let label = Path::new(texture.relative_filename.trim())
978 .file_name()
979 .and_then(|n| n.to_str())
980 .filter(|n| !n.is_empty())
981 .map(|n| n.replace(['\\', ':'], "_"))
982 .unwrap_or_else(|| format!("texture-{id}.png"));
983 (
984 format!("{prefix}/embedded/{id}-{label}"),
985 texture.content.to_vec(),
986 )
987 } else {
988 let relative = texture.relative_filename.trim().replace('\\', "/");
989 if relative.is_empty() {
990 return Err(
991 "FBX-texture heeft geen relatief pad en is niet ingebed".into(),
992 );
993 }
994 portable_relative(&relative)
995 .map_err(|e| format!("FBX-texturepad geweigerd: {e}"))?;
996 let bytes = read_asset(&root, &relative)
997 .map_err(|e| format!("FBX-texture '{relative}': {e}"))?;
998 (format!("{prefix}/{relative}"), bytes)
999 };
1000 if bytes.len() > MAX_ASSET {
1001 return Err("FBX-texture is te groot".into());
1002 }
1003 decode_png(&bytes).map_err(|e| {
1004 format!("FBX-texture is geen bruikbare PNG (alleen PNG wordt ingesloten): {e}")
1005 })?;
1006 let index = push_asset(&mut candidate, path, bytes)?;
1007 texture_assets.insert(id, index);
1008 source_assets.push(index);
1009 Some(index)
1010 }
1011 }
1012 };
1013 if albedo.is_some() {
1014 // FBX semantics: a connected diffuse texture replaces the colour property.
1015 color = [1., 1., 1., color[3]];
1016 }
1017 meshes.push(Mesh {
1018 positions,
1019 normals,
1020 uvs,
1021 indices,
1022 color,
1023 albedo,
1024 });
1025 }
1026 }
1027 if meshes.is_empty() {
1028 return Err("het FBX-bestand heeft geen tekenbare meshes".into());
1029 }
1030 source_assets.sort_unstable();
1031 source_assets.dedup();
1032 candidate.attachments.push(Attachment {
1033 label: name.to_string(),
1034 source_assets,
1035 bones: [None, None],
1036 translation: [0.; 3],
1037 rotation_deg: [0.; 3],
1038 meshes,
1039 shiny: None,
1040 specular: 0.25,
1041 });
1042 candidate.validate_with(max_vertices)?;
1043 *preset = candidate;
1044 Ok(preset.attachments.len() - 1)
1045}
1046
1047pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {
1048 let root = source.parent().ok_or("texturemap ontbreekt")?;
1049 let name = source
1050 .file_name()
1051 .and_then(|x| x.to_str())
1052 .ok_or("ongeldige bestandsnaam")?;
1053 let bytes = read_asset(root, name)?;
1054 decode_png(&bytes)?;
1055 let mut candidate = preset.clone();
1056 let index = candidate.assets.len();
1057 candidate.assets.push(EmbeddedAsset {
1058 path: format!("{}/{name}", unique_prefix(&candidate, "shiny")),
1059 bytes,
1060 });
1061 candidate
1062 .attachments
1063 .get_mut(attachment)
1064 .ok_or("onderdeel ontbreekt")?
1065 .shiny = Some(index);
1066 compact_assets(&mut candidate)?;
1067 candidate.validate()?;
1068 *preset = candidate;
1069 Ok(())
1070}
1071
1072fn unique_prefix(preset: &Preset, kind: &str) -> String {
1073 (0..)
1074 .map(|i| format!("{kind}-{i}"))
1075 .find(|prefix| {
1076 !preset
1077 .assets
1078 .iter()
1079 .any(|a| a.path.split('/').next() == Some(prefix.as_str()))
1080 })
1081 .expect("finite asset list")
1082}
1083fn compact_assets(preset: &mut Preset) -> Result<(), String> {
1084 let used: BTreeSet<usize> = preset
1085 .attachments
1086 .iter()
1087 .flat_map(|a| {
1088 a.source_assets
1089 .iter()
1090 .copied()
1091 .chain(a.shiny)
1092 .chain(a.meshes.iter().filter_map(|m| m.albedo))
1093 })
1094 .collect();
1095 if used.iter().any(|i| *i >= preset.assets.len()) {
1096 return Err("ongeldige assetverwijzing".into());
1097 }
1098 let mut remap = vec![0; preset.assets.len()];
1099 let mut assets = Vec::new();
1100 for (i, asset) in preset.assets.iter().enumerate() {
1101 if used.contains(&i) {
1102 remap[i] = assets.len();
1103 assets.push(asset.clone());
1104 }
1105 }
1106 for a in &mut preset.attachments {
1107 for i in &mut a.source_assets {
1108 *i = remap[*i];
1109 }
1110 if let Some(i) = a.shiny.as_mut() {
1111 *i = remap[*i];
1112 }
1113 for mesh in &mut a.meshes {
1114 if let Some(i) = mesh.albedo.as_mut() {
1115 *i = remap[*i];
1116 }
1117 }
1118 }
1119 preset.assets = assets;
1120 Ok(())
1121}
1122/// Clear an override and release its no-longer-used embedded texture.
1123pub fn clear_shiny(preset: &mut Preset, index: usize) -> Result<(), String> {
1124 let mut candidate = preset.clone();
1125 candidate
1126 .attachments
1127 .get_mut(index)
1128 .ok_or("onderdeel ontbreekt")?
1129 .shiny = None;
1130 compact_assets(&mut candidate)?;
1131 *preset = candidate;
1132 Ok(())
1133}
1134/// Remove a part and its no-longer-used embedded assets, remapping all indices.
1135pub fn remove_attachment(preset: &mut Preset, index: usize) -> Result<(), String> {
1136 if index >= preset.attachments.len() {
1137 return Err("onderdeel ontbreekt".into());
1138 }
1139 let mut candidate = preset.clone();
1140 candidate.attachments.remove(index);
1141 compact_assets(&mut candidate)?;
1142 *preset = candidate;
1143 Ok(())
1144}
1145
1146fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
1147 portable_relative(relative)?;
1148 let root = root.canonicalize().map_err(|e| e.to_string())?;
1149 let path = root
1150 .join(relative)
1151 .canonicalize()
1152 .map_err(|e| e.to_string())?;
1153 if !path.starts_with(&root) {
1154 return Err("asset verlaat de modelmap".into());
1155 }
1156 if !path.is_file() {
1157 return Err("asset is geen regulier bestand".into());
1158 }
1159 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
1160 let mut bytes = Vec::new();
1161 file.take((MAX_ASSET + 1) as u64)
1162 .read_to_end(&mut bytes)
1163 .map_err(|e| e.to_string())?;
1164 if bytes.len() > MAX_ASSET {
1165 return Err("asset is te groot".into());
1166 }
1167 Ok(bytes)
1168}
1169fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Result<Vec<[f32; 3]>, String> {
1170 let mut normals = vec![[0.; 3]; positions.len()];
1171 for t in indices.chunks_exact(3) {
1172 let [a, b, c] = [t[0] as usize, t[1] as usize, t[2] as usize];
1173 if [a, b, c].iter().any(|i| *i >= positions.len()) {
1174 return Err("ongeldige OBJ-index".into());
1175 }
1176 let ab = std::array::from_fn::<_, 3, _>(|i| positions[b][i] - positions[a][i]);
1177 let ac = std::array::from_fn::<_, 3, _>(|i| positions[c][i] - positions[a][i]);
1178 let n = [
1179 ab[1] * ac[2] - ab[2] * ac[1],
1180 ab[2] * ac[0] - ab[0] * ac[2],
1181 ab[0] * ac[1] - ab[1] * ac[0],
1182 ];
1183 for vertex in [a, b, c] {
1184 for (i, value) in n.iter().enumerate() {
1185 normals[vertex][i] += value;
1186 }
1187 }
1188 }
1189 for normal in &mut normals {
1190 let length = normal.iter().map(|v| v * v).sum::<f32>().sqrt();
1191 if length > 1e-10 {
1192 for v in normal {
1193 *v /= length
1194 }
1195 }
1196 }
1197 Ok(normals)
1198}