BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / equipment.rs

equipment.rs

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