//! Equipment with its own skeleton (Tim t1027u). //! //! A skinned equipment model is stored rigid per bone: every vertex belongs to //! its dominant skin bone and is kept in that bone's rest space. Per sex, the //! equipment bones are linked to character bones; each link has its own //! position and rotation relative to the character bone. Unlinked bones follow //! their nearest linked ancestor (or the first link) rigidly. use std::path::Path; use serde::{Deserialize, Serialize}; use crate::equipment::{local_matrix, Attachment, EmbeddedAsset, Mesh, Preset, RigSignature}; use crate::preview::{animated_joint_world, joint_paths, mul, Mat4, PreviewCharacter}; /// Error of [`import_skinned`] for a model without a skin. pub const NOT_SKINNED: &str = "het model heeft geen skelet; kies OBJ of FBX voor een rigide onderdeel"; const MAX_BONES: usize = 256; const MAX_SKINNED_MESHES: usize = 1024; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct EquipBone { pub name: String, pub parent: Option, /// Rest-pose world matrix in the equipment model's space. pub rest_world: Mat4, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Skeleton { pub bones: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct BoneLink { /// Equipment bone index. pub bone: usize, /// Character joint path (as in [`joint_paths`]). pub target: String, pub translation: [f32; 3], pub rotation_deg: [f32; 3], } pub fn links_empty(links: &[Vec; 2]) -> bool { links.iter().all(Vec::is_empty) } /// Structural checks for the skinned fields of one part. pub(crate) fn validate_part( part: &Attachment, rigs: &[Option; 2], ) -> Result<(), String> { let Some(skeleton) = &part.skeleton else { if !links_empty(&part.bone_links) || part.meshes.iter().any(|m| m.bone.is_some()) { return Err("botkoppeling zonder skelet".into()); } return Ok(()); }; let n = skeleton.bones.len(); if n == 0 || n > MAX_BONES || part.meshes.len() > MAX_SKINNED_MESHES { return Err("ongeldig equipmentskelet".into()); } for (i, bone) in skeleton.bones.iter().enumerate() { if bone.name.len() > 256 || bone.rest_world.iter().any(|x| !x.is_finite()) || bone.parent.is_some_and(|p| p >= n || p == i) { return Err("ongeldig equipmentskelet".into()); } // Acyclic: a parent walk ends within n steps. let mut cursor = bone.parent; let mut steps = 0; while let Some(p) = cursor { steps += 1; if steps > n { return Err("equipmentskelet bevat een lus".into()); } cursor = skeleton.bones[p].parent; } } if part.meshes.iter().any(|m| m.bone.is_none_or(|b| b >= n)) { return Err("mesh verwijst naar onbekend equipmentbot".into()); } for (sex, links) in part.bone_links.iter().enumerate() { let mut seen = std::collections::BTreeSet::new(); for link in links { if link.bone >= n || !seen.insert(link.bone) || link.target.len() > 4096 || link .translation .iter() .chain(link.rotation_deg.iter()) .any(|x| !x.is_finite() || x.abs() > 100_000.0) { return Err("ongeldige botkoppeling".into()); } if !rigs[sex] .as_ref() .is_some_and(|rig| rig.paths.contains(&link.target)) { return Err("botkoppeling verwijst naar onbekend bot".into()); } } } Ok(()) } /// Model matrix per equipment bone for the current pose. pub fn bone_models( skeleton: &Skeleton, links: &[BoneLink], paths: &[String], world_of: impl Fn(usize) -> Option, ) -> Result, String> { if links.is_empty() { return Err("koppel minstens één equipmentbot aan een karakterbot".into()); } let mut anchors = Vec::with_capacity(links.len()); for link in links { let joint = paths .iter() .position(|p| p == &link.target) .ok_or("het gekoppelde bot ontbreekt")?; let world = world_of(joint).ok_or("het gekoppelde bot ontbreekt")?; let rest = skeleton .bones .get(link.bone) .ok_or("ongeldige botkoppeling")? .rest_world; anchors.push(mul( mul(world, local_matrix(link.translation, link.rotation_deg)), inverse(rest), )); } Ok((0..skeleton.bones.len()) .map(|bone| { let anchor = nearest_link(skeleton, links, bone).unwrap_or(0); mul(anchors[anchor], skeleton.bones[bone].rest_world) }) .collect()) } /// Index into `links` of the bone itself or its nearest linked ancestor. pub fn nearest_link(skeleton: &Skeleton, links: &[BoneLink], bone: usize) -> Option { let mut cursor = Some(bone); let mut steps = 0; while let Some(b) = cursor { if let Some(i) = links.iter().position(|l| l.bone == b) { return Some(i); } steps += 1; if steps > skeleton.bones.len() { return None; } cursor = skeleton.bones.get(b)?.parent; } None } /// Import a skinned glTF/GLB/FBX as one skinned part. pub fn import_skinned(preset: &mut Preset, source: &Path) -> Result { let model = crate::preview::load_body_file(source)?; if model.is_static() || model.joint_nodes.is_empty() { return Err(NOT_SKINNED.into()); } let label = source .file_name() .and_then(|n| n.to_str()) .ok_or("ongeldige bestandsnaam")? .to_string(); let part = skinned_part(&model)?; let mut candidate = preset.clone(); let (meshes, assets) = part; let base = candidate.assets.len(); let mut source_assets = Vec::new(); for (i, asset) in assets.into_iter().enumerate() { let mut path = format!("{}/texture{i}.png", sanitize(&label)); while candidate.assets.iter().any(|a| a.path == path) { path = format!("_{path}"); } source_assets.push(base + i); candidate.assets.push(EmbeddedAsset { path, bytes: asset }); } let meshes = meshes .into_iter() .map(|mut m| { m.albedo = m.albedo.map(|i| base + i); m }) .collect(); candidate.attachments.push(Attachment { label, source_assets, bones: [None, None], translation: [[0.; 3]; 2], rotation_deg: [[0.; 3]; 2], meshes, shiny: None, specular: 0.25, skeleton: Some(skeleton_of(&model)), bone_links: [Vec::new(), Vec::new()], }); candidate.validate()?; *preset = candidate; Ok(preset.attachments.len() - 1) } fn sanitize(label: &str) -> String { label .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' } }) .collect() } pub fn skeleton_of(model: &PreviewCharacter) -> Skeleton { let bones = (0..model.joint_nodes.len()) .map(|j| { let mut cursor = model.nodes[model.joint_nodes[j]].parent; let mut parent = None; let mut steps = 0; while let Some(node) = cursor { if let Some(p) = model.joint_nodes.iter().position(|n| *n == node) { parent = Some(p); break; } steps += 1; if steps > model.nodes.len() { break; } cursor = model.nodes[node].parent; } EquipBone { name: model.joint_names.get(j).cloned().unwrap_or_default(), parent, rest_world: rest_world(model, j), } }) .collect(); Skeleton { bones } } fn rest_world(model: &PreviewCharacter, joint: usize) -> Mat4 { animated_joint_world(model, "", 0.0, joint).unwrap_or_else(crate::preview::identity) } type PartData = (Vec, Vec>); /// Rest-pose skinning, split per surface and dominant bone, vertices in bone space. fn skinned_part(model: &PreviewCharacter) -> Result { let n = model.positions.len(); if n == 0 || n > crate::equipment::MAX_VERTICES { return Err("het equipmentmodel heeft geen of te veel vertices".into()); } if model.joint_nodes.len() > MAX_BONES { return Err("te veel botten in het equipmentmodel".into()); } let rests: Vec = (0..model.joint_nodes.len()) .map(|j| rest_world(model, j)) .collect(); let to_bone: Vec = rests.iter().map(|m| inverse(*m)).collect(); let skins: Vec = rests .iter() .zip(&model.inverse_binds) .map(|(r, ib)| mul(*r, *ib)) .collect(); let mut rest_pos = Vec::with_capacity(n); let mut rest_nrm = Vec::with_capacity(n); let mut dominant = Vec::with_capacity(n); for v in 0..n { let joints = model.joints.get(v).copied().unwrap_or([0; 4]); let weights = model.weights.get(v).copied().unwrap_or([1., 0., 0., 0.]); let mut m = [0f32; 16]; let mut total = 0.; for (j, w) in joints.iter().zip(weights) { let skin = skins.get(*j as usize).ok_or("ongeldige skinindex")?; for k in 0..16 { m[k] += skin[k] * w; } total += w; } if total <= 1e-6 { m = skins[0]; } rest_pos.push(apply(&m, model.positions[v], 1.)); rest_nrm.push(apply( &m, model.normals.get(v).copied().unwrap_or([0., 1., 0.]), 0., )); let best = (0..4) .max_by(|a, b| weights[*a].total_cmp(&weights[*b])) .unwrap_or(0); dominant.push(joints[best] as usize); } // Textures: PNG-encode each distinct decoded albedo once. let mut assets: Vec> = Vec::new(); let mut hashes: Vec = Vec::new(); let mut meshes = Vec::new(); for surface in &model.surfaces { let albedo = match &surface.albedo { Some(texture) => Some( match hashes.iter().position(|h| *h == texture.content_hash) { Some(i) => i, None => { assets.push(encode_png(texture)?); hashes.push(texture.content_hash); assets.len() - 1 } }, ), None => None, }; let indices = model .indices .get(surface.first..surface.first + surface.count) .ok_or("ongeldige surface")?; let mut groups: std::collections::BTreeMap> = Default::default(); for t in indices.chunks_exact(3) { // The triangle goes to the most common dominant bone of its corners. let d = [0, 1, 2].map(|k| dominant[t[k] as usize]); let bone = if d[1] == d[2] { d[1] } else { d[0] }; groups.entry(bone).or_default().extend_from_slice(t); } for (bone, tri) in groups { let mut remap = std::collections::HashMap::new(); let mut mesh = Mesh { positions: Vec::new(), normals: Vec::new(), uvs: Vec::new(), indices: Vec::new(), color: surface.color, albedo, bone: Some(bone), }; for v in tri { let next = mesh.positions.len() as u32; let index = *remap.entry(v).or_insert_with(|| { let v = v as usize; mesh.positions.push(apply(&to_bone[bone], rest_pos[v], 1.)); let nv = apply(&to_bone[bone], rest_nrm[v], 0.); let l = (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2]) .sqrt() .max(1e-9); mesh.normals.push(nv.map(|x| x / l)); // Equipment meshes use the OBJ convention (origin bottom-left). let uv = model.uvs.get(v).copied().unwrap_or([0., 0.]); mesh.uvs.push([uv[0], 1. - uv[1]]); next }); mesh.indices.push(index); } meshes.push(mesh); } } if meshes.is_empty() { return Err("het equipmentmodel heeft geen tekenbare meshes".into()); } Ok((meshes, assets)) } fn encode_png(texture: &crate::equipment::Texture) -> Result, String> { let mut out = Vec::new(); { let mut encoder = png::Encoder::new(&mut out, texture.width, texture.height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&texture.rgba) .map_err(|e| e.to_string())?; } Ok(out) } fn apply(m: &Mat4, p: [f32; 3], w: f32) -> [f32; 3] { [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i] * w) } /// General 4x4 inverse (column-major), Gauss-Jordan in f64. pub fn inverse(m: Mat4) -> Mat4 { let mut a = [[0f64; 8]; 4]; for r in 0..4 { for c in 0..4 { a[r][c] = m[c * 4 + r] as f64; } a[r][4 + r] = 1.; } for c in 0..4 { let p = (c..4) .max_by(|&x, &y| a[x][c].abs().total_cmp(&a[y][c].abs())) .unwrap_or(c); a.swap(c, p); let d = a[c][c]; if d.abs() < 1e-12 { return crate::preview::identity(); } for value in a[c].iter_mut() { *value /= d; } for r in 0..4 { if r != c { let f = a[r][c]; let pivot = a[c]; for (value, p) in a[r].iter_mut().zip(pivot) { *value -= f * p; } } } } let mut out = [0f32; 16]; for r in 0..4 { for c in 0..4 { out[c * 4 + r] = a[r][4 + c] as f32; } } out } /// Translation and Rz·Ry·Rx degrees of a rigid matrix (inverse of `local_matrix`). pub fn decompose(m: &Mat4) -> ([f32; 3], [f32; 3]) { let col = |c: usize| { let v = [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]]; let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12); v.map(|x| x / l) }; let (c0, c1, c2) = (col(0), col(1), col(2)); let (r00, r10, r20) = (c0[0], c0[1], c0[2]); let (r21, r22) = (c1[2], c2[2]); let y = (-r20).clamp(-1., 1.).asin(); let (x, z) = if y.cos().abs() > 1e-5 { (r21.atan2(r22), r10.atan2(r00)) } else { (0., (-c1[0]).atan2(c1[1])) }; let clean = |v: f32| if v.abs() < 1e-5 { 0. } else { v }; ( [m[12], m[13], m[14]].map(clean), [x, y, z].map(|a| clean(a.to_degrees())), ) } /// Link equipment `bone` of `part` to character `joint` (or unlink with `None`), /// with the offset that keeps the bone at its rest placement. pub fn set_link( preset: &mut Preset, part: usize, sex: usize, body: &PreviewCharacter, bone: usize, joint: Option, ) -> Result<(), String> { if sex > 1 { return Err("ongeldig geslacht".into()); } let signature = RigSignature::of(body); let paths = joint_paths(body); let item = preset .attachments .get_mut(part) .ok_or("dit onderdeel bestaat niet")?; let skeleton = item .skeleton .as_ref() .ok_or("dit onderdeel heeft geen skelet")?; let rest = skeleton .bones .get(bone) .ok_or("dit equipmentbot bestaat niet")? .rest_world; let links = &mut item.bone_links[sex]; links.retain(|l| l.bone != bone); if let Some(joint) = joint { let target = paths.get(joint).ok_or("dit bot bestaat niet")?.clone(); let char_rest = signature.rest_world[joint]; let (translation, rotation_deg) = decompose(&mul(inverse(char_rest), rest)); links.push(BoneLink { bone, target, translation, rotation_deg, }); links.sort_by_key(|l| l.bone); } // Keep the rig signature current; drop links to bones of an older rig. if preset.rigs[sex].as_ref() != Some(&signature) { preset.rigs[sex] = Some(signature); for a in &mut preset.attachments { a.bone_links[sex].retain(|l| paths.contains(&l.target)); if a.bones[sex].as_ref().is_some_and(|p| !paths.contains(p)) { a.bones[sex] = None; } } } Ok(()) } /// Fill unlinked equipment bones by name; returns how many links were added. pub fn auto_link( preset: &mut Preset, part: usize, sex: usize, body: &PreviewCharacter, ) -> Result { let names: Vec = preset .attachments .get(part) .and_then(|a| a.skeleton.as_ref()) .ok_or("dit onderdeel heeft geen skelet")? .bones .iter() .map(|b| b.name.clone()) .collect(); let mut added = 0; for (bone, name) in names.iter().enumerate() { if preset.attachments[part].bone_links[sex] .iter() .any(|l| l.bone == bone) { continue; } if let Some(joint) = best_match(name, &body.joint_names) { set_link(preset, part, sex, body, bone, Some(joint))?; added += 1; } } Ok(added) } /// Best character bone for an equipment bone name (see design.md t1027u). pub fn best_match(name: &str, candidates: &[String]) -> Option { let mut best: Option<(u8, usize)> = None; for (i, candidate) in candidates.iter().enumerate() { let score = match_score(name, candidate); if score > 0 && best.is_none_or(|(s, _)| score > s) { best = Some((score, i)); } } best.map(|(_, i)| i) } /// 3 exact (case-insensitive), 2 equal after normalising, 1 partial, 0 none. pub fn match_score(a: &str, b: &str) -> u8 { if a.eq_ignore_ascii_case(b) { return 3; } let (ca, sa) = normalize(a); let (cb, sb) = normalize(b); if ca.is_empty() || cb.is_empty() || sa != sb { return 0; } if ca == cb { return 2; } if ca.len().min(cb.len()) >= 3 && (ca.contains(&cb) || cb.contains(&ca)) { return 1; } 0 } /// Normalised core name and side tag (`Some('l')`/`Some('r')`). pub fn normalize(name: &str) -> (String, Option) { let mut rest = name; if let Some(i) = rest.rfind([':', '|']) { rest = &rest[i + 1..]; } let lower = rest.to_ascii_lowercase(); for prefix in ["mixamorig", "bip001", "bip01", "def-", "def_", "def."] { if lower.starts_with(prefix) { rest = &rest[prefix.len()..]; break; } } // Tokens on separators, camelCase and letter/digit boundaries. let mut tokens: Vec = Vec::new(); let mut current = String::new(); let mut previous: Option = None; for ch in rest.chars() { if !ch.is_ascii_alphanumeric() { if !current.is_empty() { tokens.push(std::mem::take(&mut current)); } previous = None; continue; } if let Some(p) = previous { let boundary = (p.is_ascii_lowercase() && ch.is_ascii_uppercase()) || (p.is_ascii_alphabetic() && ch.is_ascii_digit()) || (p.is_ascii_digit() && ch.is_ascii_alphabetic()); if boundary && !current.is_empty() { tokens.push(std::mem::take(&mut current)); } } current.push(ch.to_ascii_lowercase()); previous = Some(ch); } if !current.is_empty() { tokens.push(current); } while tokens .last() .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit())) { tokens.pop(); } let side_of = |t: &str| match t { "l" | "left" => Some('l'), "r" | "right" => Some('r'), _ => None, }; let mut side = None; if tokens.len() > 1 { if let Some(s) = side_of(&tokens[0]) { side = Some(s); tokens.remove(0); } else if let Some(s) = side_of(tokens.last().unwrap()) { side = Some(s); tokens.pop(); } } while tokens .last() .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit())) { tokens.pop(); } (tokens.concat(), side) } #[cfg(test)] mod tests { use super::*; #[test] fn names_normalise_prefixes_sides_and_numeric_suffixes() { assert_eq!(normalize("mixamorig:LeftArm"), ("arm".into(), Some('l'))); assert_eq!(normalize("Armature|Arm.L"), ("arm".into(), Some('l'))); assert_eq!(normalize("hand_r.001"), ("hand".into(), Some('r'))); assert_eq!( normalize("Bip01 R UpperArm"), ("upperarm".into(), Some('r')) ); assert_eq!(normalize("DEF-spine.003"), ("spine".into(), None)); assert_eq!(normalize("Head"), ("head".into(), None)); assert_eq!( normalize("RightHandIndex1"), ("handindex".into(), Some('r')) ); } #[test] fn match_prefers_exact_then_normalised_then_partial_and_respects_sides() { let body: Vec = [ "Hips", "Spine", "Spine1", "LeftUpLeg", "RightUpLeg", "LeftHand", "RightHand", "Head", ] .map(String::from) .to_vec(); assert_eq!(best_match("Spine1", &body), Some(2)); // exact assert_eq!(best_match("mixamorig:Spine1", &body), Some(1)); // normalised: first "spine" assert_eq!(best_match("hand.R", &body), Some(6)); assert_eq!(best_match("Armature|hand_l", &body), Some(5)); assert_eq!(best_match("upleg.L", &body), Some(3)); assert_eq!(best_match("head_end", &body), Some(7)); // partial assert_eq!(best_match("tail", &body), None); assert_eq!(match_score("LeftHand", "RightHand"), 0); } #[test] fn decompose_inverts_local_matrix() { for (t, r) in [ ([0.1, -0.37, 2.0], [10.0, 20.0, 30.0]), ([0.0, 0.0, 0.0], [-45.0, 80.0, 170.0]), ([1.0, 2.0, 3.0], [0.0, 0.0, 0.0]), ] { let m = local_matrix(t, r); let (t2, r2) = decompose(&m); let m2 = local_matrix(t2, r2); for k in 0..16 { assert!((m[k] - m2[k]).abs() < 1e-4, "{t:?} {r:?} -> {t2:?} {r2:?}"); } } } #[test] fn unlinked_bones_follow_nearest_linked_ancestor_then_first_link() { let id = crate::preview::identity(); let at = |x: f32| local_matrix([x, 0., 0.], [0.; 3]); let skeleton = Skeleton { bones: vec![ EquipBone { name: "root".into(), parent: None, rest_world: id, }, EquipBone { name: "a".into(), parent: Some(0), rest_world: at(1.), }, EquipBone { name: "b".into(), parent: Some(1), rest_world: at(2.), }, EquipBone { name: "c".into(), parent: Some(0), rest_world: at(3.), }, ], }; let links = vec![BoneLink { bone: 1, target: "j".into(), translation: [0.; 3], rotation_deg: [0.; 3], }]; assert_eq!(nearest_link(&skeleton, &links, 2), Some(0)); assert_eq!(nearest_link(&skeleton, &links, 3), None); let paths = vec!["j".to_string()]; let models = bone_models(&skeleton, &links, &paths, |_| Some(at(10.))).unwrap(); // Linked bone sits on the joint; its child keeps its rest offset (+1). assert!((models[1][12] - 10.).abs() < 1e-5); assert!((models[2][12] - 11.).abs() < 1e-5); assert!(bone_models(&skeleton, &[], &paths, |_| Some(id)).is_err()); } }