//! Rigid equipment, portable embedded assets and a bounded binary container. //! Local rotation uses Rz * Ry * Rx (degrees); attachment uses the animated //! joint world transform, never a skin matrix or inverse bind a second time. use std::collections::BTreeSet; use std::io::{Cursor, Read, Write}; use std::path::Path; use crate::preview::{ animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls, }; use crate::project::portable_relative; use serde::{Deserialize, Serialize}; pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n"; /// Container version written; versions 1–3 (shared rigid offsets) still load. pub const VERSION: u32 = 4; pub const MAX_FILE: usize = 32 * 1024 * 1024; const MAX_META: usize = 4 * 1024 * 1024; const MAX_ASSET: usize = 8 * 1024 * 1024; const MAX_ASSETS: usize = 32; pub(crate) const MAX_VERTICES: usize = 80_000; /// Vertex budget when the rigid readers load a static (unskinned) *body*; /// same ceiling as the FBX body loader. pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct EmbeddedAsset { pub path: String, #[serde(skip)] pub bytes: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Mesh { pub positions: Vec<[f32; 3]>, pub normals: Vec<[f32; 3]>, pub uvs: Vec<[f32; 2]>, pub indices: Vec, pub color: [f32; 4], pub albedo: Option, /// Skinned part: the equipment bone whose rest space holds the vertices. #[serde(default, skip_serializing_if = "Option::is_none")] pub bone: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RigSignature { pub paths: Vec, pub rest_world: Vec, } impl RigSignature { pub fn of(body: &PreviewCharacter) -> Self { Self { paths: joint_paths(body), rest_world: (0..body.joint_nodes.len()) .map(|j| animated_joint_world(body, "", 0.0, j).unwrap_or_else(identity)) .collect(), } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Attachment { pub label: String, /// Original OBJ/MTL/dependency payloads owned by this attachment. pub source_assets: Vec, pub bones: [Option; 2], /// Per sex: rigid (single-bone) attach offset. pub translation: [[f32; 3]; 2], /// Per sex: rigid (single-bone) Euler degrees, Rz·Ry·Rx. pub rotation_deg: [[f32; 3]; 2], pub meshes: Vec, pub shiny: Option, pub specular: f32, /// Skeleton of a skinned part (Tim t1027u); `None` for rigid parts. #[serde(default, skip_serializing_if = "Option::is_none")] pub skeleton: Option, /// Per sex: equipment bone → character bone links of a skinned part. #[serde(default, skip_serializing_if = "crate::equipment_rig::links_empty")] pub bone_links: [Vec; 2], } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Preset { pub name: String, pub provenance: String, pub applies: [bool; 2], pub rigs: [Option; 2], pub attachments: Vec, pub assets: Vec, /// Semi-transparency allowed for this layer (t1031u; default off = binary alpha). #[serde(default)] pub semi_transparency: bool, } impl Preset { pub fn new(name: String) -> Self { Self { name, provenance: String::new(), applies: [true, true], rigs: [None, None], attachments: Vec::new(), assets: Vec::new(), semi_transparency: false, } } pub fn bind( &mut self, sex: usize, body: &PreviewCharacter, attachment: usize, joint: usize, ) -> Result<(), String> { if sex > 1 { return Err("ongeldig geslacht".into()); } let paths = joint_paths(body); let path = paths.get(joint).ok_or("dit bot bestaat niet")?.clone(); let item = self .attachments .get_mut(attachment) .ok_or("dit onderdeel bestaat niet")?; item.bones[sex] = Some(path); self.rigs[sex] = Some(RigSignature::of(body)); let paths = &self.rigs[sex].as_ref().unwrap().paths; for a in &mut self.attachments { a.bone_links[sex].retain(|l| paths.contains(&l.target)); } Ok(()) } /// Whether this part already has a bone mapping for `sex`. pub fn attachment_mapped(attachment: &Attachment, sex: usize) -> bool { if sex > 1 { return false; } if attachment.skeleton.is_some() { !attachment.bone_links[sex].is_empty() } else { attachment.bones[sex].is_some() } } /// Copy bone mappings and offsets from `from_sex` onto `to_sex` for parts /// that have no mapping on `to_sex` yet. Destination joints are taken from /// `to_body` when the source joint name also exists there. pub fn copy_gender_mapping( &mut self, from_sex: usize, to_sex: usize, to_body: &PreviewCharacter, ) -> Result { copy_gender_mapping(self, from_sex, to_sex, to_body) } pub fn validate(&self) -> Result<(), String> { self.validate_with(MAX_VERTICES) } fn validate_with(&self, max_vertices: usize) -> Result<(), String> { if !valid_name(&self.name) { return Err( "equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(), ); } if self.provenance.len() > 4096 || self.provenance.contains('\0') { return Err("herkomst/licentie is te lang of bevat NUL".into()); } if !self.applies.iter().any(|x| *x) { return Err("kies minstens één geslacht".into()); } if self.attachments.len() > 32 || self.assets.len() > MAX_ASSETS { return Err("te veel onderdelen of bestanden".into()); } let mut paths = BTreeSet::new(); let mut total = 0usize; for asset in &self.assets { portable_relative(&asset.path)?; if asset.path.len() > 240 || asset.bytes.len() > MAX_ASSET || !paths.insert(&asset.path) { return Err("ongeldige, dubbele of te grote asset".into()); } total = total .checked_add(asset.bytes.len()) .ok_or("te veel assetdata")?; } if total > MAX_FILE { return Err("te veel assetdata".into()); } let mut texture_indices = BTreeSet::new(); let mut vertices = 0; for rig in self.rigs.iter().flatten() { if rig.paths.len() > 128 || rig.paths.len() != rig.rest_world.len() || rig.paths.iter().any(|p| p.len() > 4096) || rig.rest_world.iter().flatten().any(|x| !x.is_finite()) { return Err("ongeldige rigbinding".into()); } } for attachment in &self.attachments { if attachment.source_assets.len() > MAX_ASSETS || attachment .source_assets .iter() .any(|i| *i >= self.assets.len()) { return Err("ongeldige bronassetverwijzing".into()); } for sex in 0..2 { if let Some(path) = &attachment.bones[sex] { if !self.rigs[sex] .as_ref() .map(|rig| rig.paths.contains(path)) .unwrap_or(false) { return Err("attachment verwijst naar onbekend bot".into()); } } } if attachment.label.len() > 128 || attachment.bones.iter().flatten().any(|p| p.len() > 4096) || attachment .translation .iter() .flatten() .chain(attachment.rotation_deg.iter().flatten()) .any(|x| !x.is_finite() || x.abs() > 100_000.0) || !attachment.specular.is_finite() || !(0.0..=1.0).contains(&attachment.specular) || (attachment.skeleton.is_none() && attachment.meshes.len() > 64) { return Err("ongeldige attachment/transformatie".into()); } crate::equipment_rig::validate_part(attachment, &self.rigs)?; if let Some(index) = attachment.shiny { texture_indices.insert(index); } for mesh in &attachment.meshes { vertices += mesh.positions.len(); if vertices > max_vertices || mesh.indices.len() > max_vertices * 6 || mesh.indices.len() < 3 || mesh.indices.len() % 3 != 0 || mesh.positions.len() != mesh.normals.len() || mesh.positions.len() != mesh.uvs.len() || mesh.positions.is_empty() || mesh .indices .iter() .any(|i| *i as usize >= mesh.positions.len()) || mesh .positions .iter() .flatten() .chain(mesh.normals.iter().flatten()) .chain(mesh.uvs.iter().flatten()) .chain(mesh.color.iter()) .any(|x| !x.is_finite() || x.abs() > 1_000_000.0) { return Err("ongeldige of te grote equipmentmesh".into()); } if let Some(index) = mesh.albedo { texture_indices.insert(index); } } } let mut decoded = 0usize; for index in texture_indices { decoded += self.texture(index)?.rgba.len(); if decoded > 64 * 1024 * 1024 { return Err("textures samen groter dan 64 MiB".into()); } } Ok(()) } pub fn texture(&self, index: usize) -> Result { let asset = self .assets .get(index) .ok_or("textureverwijzing ontbreekt")?; decode_png(&asset.bytes) } /// Exact container byte cost; does not substitute for semantic validation. pub fn encoded_len(&self) -> Result { let meta = serde_json::to_vec(self).map_err(|e| e.to_string())?.len(); if meta > MAX_META { return Err("presetmetadata is te groot".into()); } let size = self .assets .iter() .try_fold(20 + meta, |size, a| { size.checked_add(4)?.checked_add(a.bytes.len()) }) .ok_or("preset te groot")?; if size > MAX_FILE { return Err("preset te groot".into()); } Ok(size) } pub fn encode(&self) -> Result, String> { self.validate()?; let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?; if metadata.len() > MAX_META { return Err("presetmetadata is te groot".into()); } let mut payload = metadata; let meta_len = payload.len(); for asset in &self.assets { payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes()); payload.extend_from_slice(&asset.bytes); } if payload.len() + 20 > MAX_FILE { return Err("preset is te groot".into()); } let mut out = MAGIC.to_vec(); out.extend_from_slice(&VERSION.to_le_bytes()); out.extend_from_slice(&(meta_len as u32).to_le_bytes()); out.extend_from_slice(&checksum(&payload).to_le_bytes()); out.extend(payload); Ok(out) } pub fn decode(bytes: &[u8]) -> Result { if bytes.len() < 20 || bytes.len() > MAX_FILE || &bytes[..8] != MAGIC { return Err("ongeldig equipmentbestand of bestand te groot".into()); } let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); if !(1..=VERSION).contains(&number(8)) { return Err("onbekende equipmentversie".into()); } let meta_len = number(12) as usize; if meta_len > MAX_META || meta_len > bytes.len() - 20 || checksum(&bytes[20..]) != number(16) { return Err("beschadigde equipmentdata".into()); } let version = number(8); let raw: serde_json::Value = serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?; let raw = if version < 4 { migrate_preset_json(version, raw)? } else { raw }; let mut preset: Self = serde_json::from_value(raw).map_err(|e| e.to_string())?; if preset.assets.len() > MAX_ASSETS { return Err("te veel assets".into()); } let mut cursor = 20 + meta_len; for asset in &mut preset.assets { if cursor + 4 > bytes.len() { return Err("afgebroken asset".into()); } let len = number(cursor) as usize; cursor += 4; if len > MAX_ASSET || len > bytes.len() - cursor { return Err("ongeldige assetlengte".into()); } asset.bytes = bytes[cursor..cursor + len].to_vec(); cursor += len; } if cursor != bytes.len() { return Err("onverwachte extra equipmentdata".into()); } preset.validate()?; Ok(preset) } pub fn read(path: &Path) -> Result { if !path.is_file() { return Err("kies een regulier presetbestand".into()); } let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let mut bytes = Vec::new(); file.take((MAX_FILE + 1) as u64) .read_to_end(&mut bytes) .map_err(|e| e.to_string())?; Self::decode(&bytes) } } /// Result of [`copy_gender_mapping`]: how many source mappings landed on the /// destination body, and how many had no bone of the same name. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct CopyGenderMapping { pub mapped: usize, pub unmatched: usize, } /// Last joint name in a hierarchical path (`"Hips[0]/Hand[12]"` → `"Hand"`). pub fn joint_leaf_name(path: &str) -> &str { let last = path.rsplit('/').next().unwrap_or(path); last.rsplit_once('[').map(|(name, _)| name).unwrap_or(last) } /// Joint names along a path, without the `[node-index]` suffixes. pub fn path_joint_names(path: &str) -> Vec<&str> { path.split('/').map(joint_leaf_name).collect() } /// Destination joint whose name matches the source path, preferring a unique /// name and otherwise the longest shared ancestor-name chain. pub fn joint_by_name(body: &PreviewCharacter, source_path: &str) -> Option { let src_names = path_joint_names(source_path); let src_name = src_names.last().copied().filter(|n| !n.is_empty())?; let dest_paths = joint_paths(body); let mut matches: Vec = body .joint_names .iter() .enumerate() .filter(|(_, name)| *name == src_name) .map(|(i, _)| i) .collect(); if matches.is_empty() { matches = dest_paths .iter() .enumerate() .filter(|(_, p)| joint_leaf_name(p) == src_name) .map(|(i, _)| i) .collect(); } if matches.is_empty() { return None; } if matches.len() == 1 { return Some(matches[0]); } matches.sort_by_key(|&i| { let dest = path_joint_names(&dest_paths[i]); let common = src_names .iter() .rev() .zip(dest.iter().rev()) .take_while(|(a, b)| a == b) .count(); std::cmp::Reverse((common, dest.len() == src_names.len())) }); Some(matches[0]) } /// Expand version 1–3 shared rigid offsets to a per-sex pair. The shared /// translation/rotation is applied to every enabled sex; a disabled sex keeps /// a zero offset (and whatever bone path it already had). pub fn migrate_preset_json( version: u32, mut value: serde_json::Value, ) -> Result { if version >= 4 { return Ok(value); } let applies = value.get("applies").cloned(); let enabled = |sex: usize| { applies .as_ref() .and_then(|a| a.as_array()) .and_then(|a| a.get(sex)) .and_then(|v| v.as_bool()) .unwrap_or(true) }; let Some(attachments) = value.get_mut("attachments").and_then(|a| a.as_array_mut()) else { return Ok(value); }; for attachment in attachments { expand_vec3_per_sex(attachment, "translation", enabled)?; expand_vec3_per_sex(attachment, "rotation_deg", enabled)?; } Ok(value) } fn expand_vec3_per_sex( attachment: &mut serde_json::Value, key: &str, enabled: impl Fn(usize) -> bool, ) -> Result<(), String> { let Some(value) = attachment.get(key) else { return Ok(()); }; if value .as_array() .is_some_and(|items| items.first().is_some_and(serde_json::Value::is_array)) { return Ok(()); } let vec3 = parse_vec3(value, key)?; let zero = [0.0f32; 3]; attachment[key] = serde_json::json!([ if enabled(0) { vec3 } else { zero }, if enabled(1) { vec3 } else { zero }, ]); Ok(()) } fn parse_vec3(value: &serde_json::Value, key: &str) -> Result<[f32; 3], String> { let items = value .as_array() .ok_or_else(|| format!("{key} is ongeldig"))?; if items.len() != 3 { return Err(format!("{key} is ongeldig")); } let mut out = [0.0f32; 3]; for (i, item) in items.iter().enumerate() { out[i] = item.as_f64().ok_or_else(|| format!("{key} is ongeldig"))? as f32; } Ok(out) } /// Copy already-authored mappings from one sex onto a newly enabled sex. pub fn copy_gender_mapping( preset: &mut Preset, from_sex: usize, to_sex: usize, to_body: &PreviewCharacter, ) -> Result { if from_sex > 1 || to_sex > 1 { return Err("ongeldig geslacht".into()); } if from_sex == to_sex { return Ok(CopyGenderMapping::default()); } if to_body.is_static() || to_body.joint_nodes.is_empty() { return Err( "kies een geskind lichaam voor dit geslacht om de botkoppeling over te nemen".into(), ); } let dest_paths = joint_paths(to_body); let mut outcome = CopyGenderMapping::default(); let mut copied = false; for index in 0..preset.attachments.len() { if Preset::attachment_mapped(&preset.attachments[index], to_sex) { continue; } if !Preset::attachment_mapped(&preset.attachments[index], from_sex) { continue; } let item = &preset.attachments[index]; if item.skeleton.is_some() { let mut links = Vec::new(); for link in &item.bone_links[from_sex] { match joint_by_name(to_body, &link.target) { Some(joint) => { links.push(crate::equipment_rig::BoneLink { bone: link.bone, target: dest_paths[joint].clone(), translation: link.translation, rotation_deg: link.rotation_deg, }); outcome.mapped += 1; } None => outcome.unmatched += 1, } } if !links.is_empty() { preset.attachments[index].bone_links[to_sex] = links; copied = true; } } else if let Some(path) = item.bones[from_sex].clone() { let translation = item.translation[from_sex]; let rotation_deg = item.rotation_deg[from_sex]; if let Some(joint) = joint_by_name(to_body, &path) { let item = &mut preset.attachments[index]; item.bones[to_sex] = Some(dest_paths[joint].clone()); item.translation[to_sex] = translation; item.rotation_deg[to_sex] = rotation_deg; outcome.mapped += 1; copied = true; } else { outcome.unmatched += 1; } } } if copied { preset.rigs[to_sex] = Some(RigSignature::of(to_body)); for attachment in &mut preset.attachments { attachment.bone_links[to_sex].retain(|l| dest_paths.contains(&l.target)); if attachment.bones[to_sex] .as_ref() .is_some_and(|p| !dest_paths.contains(p)) { attachment.bones[to_sex] = None; } } } Ok(outcome) } pub fn valid_name(name: &str) -> bool { !name.is_empty() && name.len() <= 64 && name != "body" && name .bytes() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_') } pub fn checksum(bytes: &[u8]) -> u32 { let mut crc = !0u32; for byte in bytes { crc ^= u32::from(*byte); for _ in 0..8 { crc = (crc >> 1) ^ (0xedb88320u32 & (0u32.wrapping_sub(crc & 1))); } } !crc } /// Immutable snapshot publish: a hard link atomically creates the destination /// without overwriting an older saved preset. Failure leaves old data intact. pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> { if bytes.len() > MAX_FILE { return Err("snapshot te groot".into()); } if path.exists() { if !path.is_file() { return Err("snapshot is geen regulier bestand".into()); } let mut current = Vec::new(); std::fs::File::open(path) .map_err(|e| e.to_string())? .take((MAX_FILE + 1) as u64) .read_to_end(&mut current) .map_err(|e| e.to_string())?; return if current == bytes { Ok(()) } else { Err("snapshotnaam bestaat met andere inhoud".into()) }; } let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let temp = path.with_extension(format!("ppe.part-{}-{stamp}", std::process::id())); let result = (|| { let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&temp) .map_err(|e| e.to_string())?; file.write_all(bytes) .and_then(|_| file.sync_all()) .map_err(|e| e.to_string())?; std::fs::hard_link(&temp, path).map_err(|e| e.to_string())?; #[cfg(unix)] std::fs::File::open(path.parent().ok_or("snapshotmap ontbreekt")?) .and_then(|dir| dir.sync_all()) .map_err(|e| e.to_string())?; Ok(()) })(); let _ = std::fs::remove_file(temp); result } #[derive(Debug, Clone, PartialEq)] pub struct Texture { pub content_hash: u64, pub width: u32, pub height: u32, pub rgba: Vec, } fn decode_png(bytes: &[u8]) -> Result { let mut decoder = png::Decoder::new(Cursor::new(bytes)); decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); let mut reader = decoder.read_info().map_err(|e| e.to_string())?; let info = reader.info(); if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 { return Err("texture is groter dan 2048×2048".into()); } let size = reader .output_buffer_size() .ok_or("ongeldige texturegrootte")?; if size > 2048 * 2048 * 8 { return Err("texture is te groot".into()); } let mut data = vec![0; size]; let info = reader.next_frame(&mut data).map_err(|e| e.to_string())?; let channels = info.color_type.samples(); let mut rgba = Vec::with_capacity(info.width as usize * info.height as usize * 4); for pixel in data[..info.buffer_size()].chunks_exact(channels) { match info.color_type { png::ColorType::Grayscale => { rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], 255]) } png::ColorType::GrayscaleAlpha => { rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]) } png::ColorType::Rgb => rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]), png::ColorType::Rgba => rgba.extend_from_slice(pixel), _ => return Err("onbekende texturecodering".into()), } } use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); rgba.hash(&mut hash); Ok(Texture { content_hash: hash.finish(), width: info.width, height: info.height, rgba, }) } pub fn local_matrix(translation: [f32; 3], degrees: [f32; 3]) -> Mat4 { let [x, y, z] = degrees.map(f32::to_radians); let (sx, cx) = x.sin_cos(); let (sy, cy) = y.sin_cos(); let (sz, cz) = z.sin_cos(); let rx = [ 1., 0., 0., 0., 0., cx, sx, 0., 0., -sx, cx, 0., 0., 0., 0., 1., ]; let ry = [ cy, 0., -sy, 0., 0., 1., 0., 0., sy, 0., cy, 0., 0., 0., 0., 1., ]; let rz = [ cz, sz, 0., 0., -sz, cz, 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., ]; let mut matrix = mul(rz, mul(ry, rx)); matrix[12] = translation[0]; matrix[13] = translation[1]; matrix[14] = translation[2]; matrix } #[derive(Debug, Clone)] pub struct Layer { pub mesh: Mesh, pub model: Mat4, pub albedo: Option>, pub shiny: Option>, pub specular: f32, } #[derive(Default)] pub struct TextureCache { entries: std::collections::VecDeque<(Vec, std::sync::Arc)>, bytes: usize, } impl TextureCache { pub(crate) fn get( &mut self, preset: &Preset, index: usize, ) -> Result, String> { let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes; if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) { return Ok(texture.clone()); } let texture = std::sync::Arc::new(decode_png(bytes)?); let cost = bytes.len() + texture.rgba.len(); while self.bytes + cost > 128 * 1024 * 1024 { if let Some((source, image)) = self.entries.pop_front() { self.bytes -= source.len() + image.rgba.len(); } else { break; } } self.bytes += cost; self.entries.push_back((bytes.clone(), texture.clone())); Ok(texture) } } pub fn layers( preset: &Preset, sex: usize, body: &PreviewCharacter, controls: &PreviewControls, ) -> Result, String> { layers_cached(preset, sex, body, controls, &mut TextureCache::default()) } pub fn layers_cached( preset: &Preset, sex: usize, body: &PreviewCharacter, controls: &PreviewControls, cache: &mut TextureCache, ) -> Result, String> { if sex > 1 { return Err("ongeldig geslacht".into()); } if !preset.applies[sex] || preset.attachments.is_empty() { return Ok(Vec::new()); } let signature = RigSignature::of(body); if preset.rigs[sex].as_ref() != Some(&signature) { return Err("rig wijkt af: kies de botten opnieuw voor dit lichaam".into()); } let paths = joint_paths(body); let mut layers = Vec::new(); for item in &preset.attachments { if let Some(skeleton) = &item.skeleton { let models = crate::equipment_rig::bone_models( skeleton, &item.bone_links[sex], &paths, |joint| animated_joint_world(body, &controls.clip_name, controls.time, joint), )?; let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?; for mesh in &item.meshes { layers.push(Layer { mesh: mesh.clone(), model: models[mesh.bone.unwrap_or(0)], albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?, shiny: shiny.clone(), specular: item.specular, }); } continue; } let path = item.bones[sex] .as_deref() .ok_or("kies een bot voor dit onderdeel en geslacht")?; let joint = paths .iter() .position(|p| p == path) .ok_or("het gekoppelde bot ontbreekt")?; let world = animated_joint_world(body, &controls.clip_name, controls.time, joint) .ok_or("het gekoppelde bot ontbreekt")?; let model = mul( world, local_matrix(item.translation[sex], item.rotation_deg[sex]), ); let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?; for mesh in &item.meshes { layers.push(Layer { mesh: mesh.clone(), model, albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?, shiny: shiny.clone(), specular: item.specular, }); } } Ok(layers) } /// Import an OBJ and its declared MTL/PNG dependencies into the preset itself. /// No extraction directories, absolute references or parent traversal allowed. pub fn import_obj(preset: &mut Preset, source: &Path) -> Result { import_obj_limited(preset, source, MAX_VERTICES) } pub(crate) fn import_obj_limited( preset: &mut Preset, source: &Path, max_vertices: usize, ) -> Result { if source .extension() .and_then(|x| x.to_str()) .map(|x| !x.eq_ignore_ascii_case("obj")) .unwrap_or(true) { return Err("kies een rigide OBJ-model met MTL/PNG-bijbestanden".into()); } let root = source .parent() .ok_or("modelmap ontbreekt")? .canonicalize() .map_err(|e| e.to_string())?; let name = source .file_name() .and_then(|x| x.to_str()) .ok_or("ongeldige modelnaam")?; let mut candidate = preset.clone(); if candidate.attachments.len() >= 32 { return Err("te veel onderdelen".into()); } compact_assets(&mut candidate)?; let prefix = unique_prefix(&candidate, "part"); let obj_bytes = read_asset(&root, name)?; let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?; let mut dependencies = BTreeSet::new(); dependencies.insert(name.to_string()); for line in obj_text.lines() { if let Some(rest) = line.trim().strip_prefix("mtllib ") { let mtl = rest.trim(); dependencies.insert(mtl.to_string()); let bytes = read_asset(&root, mtl)?; let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?; for line in text.lines() { let line = line.trim(); if line.starts_with("map_") || line.starts_with("bump ") || line.starts_with("disp ") { let (_, path) = line .split_once(char::is_whitespace) .ok_or("ongeldige textureverwijzing")?; let relative = Path::new(mtl) .parent() .unwrap_or(Path::new("")) .join(path.trim()); let relative = relative .to_str() .ok_or("ongeldig texturepad")? .replace('\\', "/"); portable_relative(&relative)?; dependencies.insert(relative); } } } } if dependencies.len() + candidate.assets.len() > MAX_ASSETS { return Err("te veel bijbestanden".into()); } let mut asset_map = std::collections::BTreeMap::new(); let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum(); for relative in dependencies { let bytes = read_asset(&root, &relative)?; total_bytes += bytes.len(); if total_bytes > MAX_FILE { return Err("assets samen te groot".into()); } let index = candidate.assets.len(); candidate.assets.push(EmbeddedAsset { path: format!("{prefix}/{relative}"), bytes, }); asset_map.insert(relative, index); } // A checked callback prevents the parser from reading undeclared/unsafe files. let mut input = std::io::BufReader::new(Cursor::new(obj_bytes)); let options = tobj::LoadOptions { triangulate: true, single_index: true, ..Default::default() }; let texture_paths = std::cell::RefCell::new(std::collections::BTreeMap::new()); let (models, materials) = tobj::load_obj_buf(&mut input, &options, |path| { let relative = path.to_string_lossy().replace('\\', "/"); let index = asset_map .get(&relative) .ok_or(tobj::LoadError::OpenFileFailed)?; let mut reader = std::io::BufReader::new(Cursor::new(&candidate.assets[*index].bytes)); let (materials, map) = tobj::load_mtl_buf(&mut reader)?; for material in &materials { if let Some(texture) = &material.diffuse_texture { let relative = path .parent() .unwrap_or(Path::new("")) .join(texture) .to_string_lossy() .replace('\\', "/"); texture_paths .borrow_mut() .insert(material.name.clone(), relative); } } Ok((materials, map)) }) .map_err(|e| e.to_string())?; let materials = materials.map_err(|e| e.to_string())?; let texture_paths = texture_paths.into_inner(); let mut meshes = Vec::new(); let mut vertices: usize = candidate .attachments .iter() .flat_map(|a| &a.meshes) .map(|m| m.positions.len()) .sum(); for model in models { let source = model.mesh; vertices += source.positions.len() / 3; if vertices > max_vertices || source.indices.len() > max_vertices * 6 { return Err("equipmentmesh is te groot".into()); } let positions: Vec<[f32; 3]> = source .positions .chunks_exact(3) .map(|x| [x[0], x[1], x[2]]) .collect(); let mut normals: Vec<[f32; 3]> = source .normals .chunks_exact(3) .map(|x| [x[0], x[1], x[2]]) .collect(); if normals.len() != positions.len() { normals = compute_normals(&positions, &source.indices)?; } let mut uvs: Vec<[f32; 2]> = source .texcoords .chunks_exact(2) .map(|x| [x[0], x[1]]) .collect(); if uvs.len() != positions.len() { uvs = vec![[0.; 2]; positions.len()]; } let material = source.material_id.and_then(|id| materials.get(id)); let color = material.and_then(|m| m.diffuse).unwrap_or([0.7, 0.7, 0.7]); let albedo = material .and_then(|m| texture_paths.get(&m.name)) .map(|path| { asset_map .get(path) .copied() .ok_or("ontbrekende diffuse texture") }) .transpose()?; meshes.push(Mesh { positions, normals, uvs, indices: source.indices, color: [ color[0], color[1], color[2], material.and_then(|m| m.dissolve).unwrap_or(1.), ], albedo, bone: None, }); } if meshes.is_empty() { return Err("het OBJ-bestand heeft geen meshes".into()); } candidate.attachments.push(Attachment { label: name.to_string(), source_assets: asset_map.values().copied().collect(), bones: [None, None], translation: [[0.; 3]; 2], rotation_deg: [[0.; 3]; 2], meshes, shiny: None, specular: 0.25, skeleton: None, bone_links: [Vec::new(), Vec::new()], }); candidate.validate_with(max_vertices)?; *preset = candidate; Ok(preset.attachments.len() - 1) } /// Import a rigid equipment model, choosing the reader by extension (OBJ+MTL or FBX). pub fn import_model(preset: &mut Preset, source: &Path) -> Result { match source .extension() .and_then(|x| x.to_str()) .map(|x| x.to_ascii_lowercase()) .as_deref() { Some("obj") => import_obj(preset, source), Some("gltf" | "glb") => crate::equipment_rig::import_skinned(preset, source), Some("fbx") => match crate::equipment_rig::import_skinned(preset, source) { Err(error) if error == crate::equipment_rig::NOT_SKINNED => import_fbx(preset, source), other => other, }, _ => Err("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model".into()), } } /// Import a rigid (unskinned) FBX into the preset. ufbx parses only the /// in-memory bytes (no external file access); axis/unit normalisation to /// right-handed Y-up metres happens once, here, exactly like `fbx_scene`. /// Embedded PNG textures are copied into the preset; external PNG textures /// must be relative files inside the model folder and are embedded too. pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result { import_fbx_limited(preset, source, MAX_VERTICES) } pub(crate) fn import_fbx_limited( preset: &mut Preset, source: &Path, max_vertices: usize, ) -> Result { if source .extension() .and_then(|x| x.to_str()) .map(|x| !x.eq_ignore_ascii_case("fbx")) .unwrap_or(true) { return Err("kies een rigide FBX-model".into()); } let root = source .parent() .ok_or("modelmap ontbreekt")? .canonicalize() .map_err(|e| e.to_string())?; let name = source .file_name() .and_then(|x| x.to_str()) .ok_or("ongeldige modelnaam")?; let mut candidate = preset.clone(); if candidate.attachments.len() >= 32 { return Err("te veel onderdelen".into()); } compact_assets(&mut candidate)?; let prefix = unique_prefix(&candidate, "part"); let fbx_bytes = read_asset(&root, name)?; let opts = ufbx::LoadOpts { target_axes: ufbx::axes_right_handed_y_up(), target_unit_meters: 1.0, space_conversion: ufbx::SpaceConversion::ModifyGeometry, geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry, generate_missing_normals: true, normalize_normals: true, load_external_files: false, ..Default::default() }; let scene = ufbx::load_memory(&fbx_bytes, opts) .map_err(|e| format!("FBX laden mislukt: {}", e.description))?; if scene.metadata.geometry_ignored { return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into()); } let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum(); let mut push_asset = |candidate: &mut Preset, path: String, bytes: Vec| -> Result { total_bytes += bytes.len(); if total_bytes > MAX_FILE || candidate.assets.len() >= MAX_ASSETS { return Err("assets samen te groot of te veel bijbestanden".into()); } candidate.assets.push(EmbeddedAsset { path, bytes }); Ok(candidate.assets.len() - 1) }; let source_index = push_asset(&mut candidate, format!("{prefix}/{name}"), fbx_bytes)?; let mut source_assets = vec![source_index]; // Texture identity (element id) -> embedded asset index, so shared textures are stored once. let mut texture_assets: std::collections::BTreeMap = Default::default(); let mut vertices: usize = candidate .attachments .iter() .flat_map(|a| &a.meshes) .map(|m| m.positions.len()) .sum(); let mut meshes = Vec::new(); let mut tri = Vec::new(); for node in &scene.nodes { let Some(mesh) = node.mesh.as_ref() else { continue; }; if mesh.num_triangles == 0 { continue; } if !mesh.skin_deformers.is_empty() { return Err(format!( "FBX-mesh '{}' is geskinned; alleen rigide equipment wordt ondersteund", node.element.name.trim() )); } if !mesh.blend_deformers.is_empty() { return Err(format!( "FBX-mesh '{}' heeft morph targets; die worden niet ondersteund", node.element.name.trim() )); } let to_world = node.geometry_to_world; let for_normals = ufbx::matrix_for_normals(&to_world); let parts = mesh.material_parts.len().max(1); for part in 0..parts { let faces: Vec = if mesh.material_parts.is_empty() { (0..mesh.faces.len() as u32).collect() } else { mesh.material_parts[part] .face_indices .iter() .copied() .collect() }; if faces.is_empty() { continue; } let material = if mesh.material_parts.is_empty() { None } else { mesh.materials.get(part) }; let mut positions = Vec::new(); let mut normals = Vec::new(); let mut uvs = Vec::new(); let mut indices = Vec::new(); for face in faces { let face = mesh.faces[face as usize]; if face.num_indices < 3 { continue; } let count = ufbx::triangulate_face_vec(&mut tri, mesh, face) as usize; for t in 0..count { let mut corners = [tri[t * 3], tri[t * 3 + 1], tri[t * 3 + 2]]; if mesh.reversed_winding { corners.swap(1, 2); } for corner in corners { let corner = corner as usize; let p = ufbx::transform_position(&to_world, mesh.vertex_position[corner]); let n = if mesh.vertex_normal.exists { let n = ufbx::transform_direction(&for_normals, mesh.vertex_normal[corner]); let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt(); if len > 1e-12 { [(n.x / len) as f32, (n.y / len) as f32, (n.z / len) as f32] } else { [0., 1., 0.] } } else { [0., 1., 0.] }; let uv = if mesh.vertex_uv.exists { let uv = mesh.vertex_uv[corner]; [uv.x as f32, uv.y as f32] } else { [0., 0.] }; indices.push(positions.len() as u32); positions.push([p.x as f32, p.y as f32, p.z as f32]); normals.push(n); uvs.push(uv); } } } if positions.is_empty() { continue; } vertices += positions.len(); if vertices > max_vertices { return Err("equipmentmesh is te groot".into()); } if positions .iter() .chain(&normals) .any(|v| v.iter().any(|x| !x.is_finite())) { return Err("FBX-mesh bevat niet-eindige posities of normalen".into()); } if !mesh.vertex_normal.exists { normals = compute_normals(&positions, &indices)?; } let (mut color, texture) = match material { None => ([0.7, 0.7, 0.7, 1.0], None), Some(m) => { let map = if m.pbr.base_color.has_value { &m.pbr.base_color } else { &m.fbx.diffuse_color }; let v = map.value_vec4; // Same alpha rule as the FBX body loader (`fbx_scene`). let alpha = if v.w == 0.0 { 1.0 } else { v.w as f32 }; ( [v.x as f32, v.y as f32, v.z as f32, alpha], map.texture .as_ref() .or(m.fbx.diffuse_color.texture.as_ref()), ) } }; if !color.iter().all(|c| c.is_finite()) { return Err("FBX-materiaal heeft een niet-eindige kleur".into()); } let albedo = match texture { None => None, Some(texture) => { let id = texture.element.element_id; if let Some(index) = texture_assets.get(&id) { Some(*index) } else { let (path, bytes) = if texture.content.size > 0 { let label = Path::new(texture.relative_filename.trim()) .file_name() .and_then(|n| n.to_str()) .filter(|n| !n.is_empty()) .map(|n| n.replace(['\\', ':'], "_")) .unwrap_or_else(|| format!("texture-{id}.png")); ( format!("{prefix}/embedded/{id}-{label}"), texture.content.to_vec(), ) } else { let relative = texture.relative_filename.trim().replace('\\', "/"); if relative.is_empty() { return Err( "FBX-texture heeft geen relatief pad en is niet ingebed".into(), ); } portable_relative(&relative) .map_err(|e| format!("FBX-texturepad geweigerd: {e}"))?; let bytes = read_asset(&root, &relative) .map_err(|e| format!("FBX-texture '{relative}': {e}"))?; (format!("{prefix}/{relative}"), bytes) }; if bytes.len() > MAX_ASSET { return Err("FBX-texture is te groot".into()); } decode_png(&bytes).map_err(|e| { format!("FBX-texture is geen bruikbare PNG (alleen PNG wordt ingesloten): {e}") })?; let index = push_asset(&mut candidate, path, bytes)?; texture_assets.insert(id, index); source_assets.push(index); Some(index) } } }; if albedo.is_some() { // FBX semantics: a connected diffuse texture replaces the colour property. color = [1., 1., 1., color[3]]; } meshes.push(Mesh { positions, normals, uvs, indices, color, albedo, bone: None, }); } } if meshes.is_empty() { return Err("het FBX-bestand heeft geen tekenbare meshes".into()); } source_assets.sort_unstable(); source_assets.dedup(); candidate.attachments.push(Attachment { label: name.to_string(), source_assets, bones: [None, None], translation: [[0.; 3]; 2], rotation_deg: [[0.; 3]; 2], meshes, shiny: None, specular: 0.25, skeleton: None, bone_links: [Vec::new(), Vec::new()], }); candidate.validate_with(max_vertices)?; *preset = candidate; Ok(preset.attachments.len() - 1) } pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> { let root = source.parent().ok_or("texturemap ontbreekt")?; let name = source .file_name() .and_then(|x| x.to_str()) .ok_or("ongeldige bestandsnaam")?; let bytes = read_asset(root, name)?; decode_png(&bytes)?; let mut candidate = preset.clone(); let index = candidate.assets.len(); candidate.assets.push(EmbeddedAsset { path: format!("{}/{name}", unique_prefix(&candidate, "shiny")), bytes, }); candidate .attachments .get_mut(attachment) .ok_or("onderdeel ontbreekt")? .shiny = Some(index); compact_assets(&mut candidate)?; candidate.validate()?; *preset = candidate; Ok(()) } fn unique_prefix(preset: &Preset, kind: &str) -> String { (0..) .map(|i| format!("{kind}-{i}")) .find(|prefix| { !preset .assets .iter() .any(|a| a.path.split('/').next() == Some(prefix.as_str())) }) .expect("finite asset list") } pub(crate) fn compact_assets(preset: &mut Preset) -> Result<(), String> { let used: BTreeSet = preset .attachments .iter() .flat_map(|a| { a.source_assets .iter() .copied() .chain(a.shiny) .chain(a.meshes.iter().filter_map(|m| m.albedo)) }) .collect(); if used.iter().any(|i| *i >= preset.assets.len()) { return Err("ongeldige assetverwijzing".into()); } let mut remap = vec![0; preset.assets.len()]; let mut assets = Vec::new(); for (i, asset) in preset.assets.iter().enumerate() { if used.contains(&i) { remap[i] = assets.len(); assets.push(asset.clone()); } } for a in &mut preset.attachments { for i in &mut a.source_assets { *i = remap[*i]; } if let Some(i) = a.shiny.as_mut() { *i = remap[*i]; } for mesh in &mut a.meshes { if let Some(i) = mesh.albedo.as_mut() { *i = remap[*i]; } } } preset.assets = assets; Ok(()) } /// Clear an override and release its no-longer-used embedded texture. pub fn clear_shiny(preset: &mut Preset, index: usize) -> Result<(), String> { let mut candidate = preset.clone(); candidate .attachments .get_mut(index) .ok_or("onderdeel ontbreekt")? .shiny = None; compact_assets(&mut candidate)?; *preset = candidate; Ok(()) } /// Remove a part and its no-longer-used embedded assets, remapping all indices. pub fn remove_attachment(preset: &mut Preset, index: usize) -> Result<(), String> { if index >= preset.attachments.len() { return Err("onderdeel ontbreekt".into()); } let mut candidate = preset.clone(); candidate.attachments.remove(index); compact_assets(&mut candidate)?; *preset = candidate; Ok(()) } fn read_asset(root: &Path, relative: &str) -> Result, String> { portable_relative(relative)?; let root = root.canonicalize().map_err(|e| e.to_string())?; let path = root .join(relative) .canonicalize() .map_err(|e| e.to_string())?; if !path.starts_with(&root) { return Err("asset verlaat de modelmap".into()); } if !path.is_file() { return Err("asset is geen regulier bestand".into()); } let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let mut bytes = Vec::new(); file.take((MAX_ASSET + 1) as u64) .read_to_end(&mut bytes) .map_err(|e| e.to_string())?; if bytes.len() > MAX_ASSET { return Err("asset is te groot".into()); } Ok(bytes) } fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Result, String> { let mut normals = vec![[0.; 3]; positions.len()]; for t in indices.chunks_exact(3) { let [a, b, c] = [t[0] as usize, t[1] as usize, t[2] as usize]; if [a, b, c].iter().any(|i| *i >= positions.len()) { return Err("ongeldige OBJ-index".into()); } let ab = std::array::from_fn::<_, 3, _>(|i| positions[b][i] - positions[a][i]); let ac = std::array::from_fn::<_, 3, _>(|i| positions[c][i] - positions[a][i]); let n = [ ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0], ]; for vertex in [a, b, c] { for (i, value) in n.iter().enumerate() { normals[vertex][i] += value; } } } for normal in &mut normals { let length = normal.iter().map(|v| v * v).sum::().sqrt(); if length > 1e-10 { for v in normal { *v /= length } } } Ok(normals) }