BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / equipment.rs

equipment.rs

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