BlueGit

golden-age paperdoll-renderer

Skinned equipment parts: per-bone links to character bones with name auto-fill, per-link offsets, .ppe v2 with v1 migration (Tim t1027u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-08 · 6 days ago

Hash
b2f3f94c5cbbe1595104234639169777980d76eb
Parent
fdb3425f
13 files changed +1161 −25
README.md +4 −3
@@ -75,10 +75,11 @@ From a body, **Equipment** opens the list. Choose a definition or **New**.
75 75
76 76 1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
77 77 2. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
78 3. **Add part (OBJ/FBX)** opens the system file picker. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Skinned FBX meshes, morph targets and non-PNG textures are refused with a message. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
79 4. Expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.
78 3. **Add part (OBJ/FBX/glTF)** opens the system file picker; you choose the model first. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Morph targets and non-PNG textures are refused with a message. A model with its own skeleton (skinned glTF/GLB or FBX) becomes a *skinned part*, see step 4b. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
79 4. For a rigid part (no skeleton): expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.
80 4b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links are kept per sex; renders and the live preview use them the same way.
80 81 5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
81 6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings, sex flags and origin). No source paths outside the project are needed to reload them.
82 6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags and origin; container version 2, version 1 presets still load and are written as version 2 on the next save). No source paths outside the project are needed to reload them.
82 83
83 84 **Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.
84 85
@@ -75,10 +75,11 @@ From a body, **Equipment** opens the list. Choose a definition or **New**.
75
75
761. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
761. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
772. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
772. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
783. **Add part (OBJ/FBX)** opens the system file picker. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Skinned FBX meshes, morph targets and non-PNG textures are refused with a message. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
783. **Add part (OBJ/FBX/glTF)** opens the system file picker; you choose the model first. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Morph targets and non-PNG textures are refused with a message. A model with its own skeleton (skinned glTF/GLB or FBX) becomes a *skinned part*, see step 4b. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
794. Expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.
794. For a rigid part (no skeleton): expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.
804b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links are kept per sex; renders and the live preview use them the same way.
805. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
815. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
816. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings, sex flags and origin). No source paths outside the project are needed to reload them.
826. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags and origin; container version 2, version 1 presets still load and are written as version 2 on the next save). No source paths outside the project are needed to reload them.
82
83
83**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.
84**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.
84
85
examples/production_fixture.rs +33 −3
@@ -1,5 +1,5 @@
1 1 //! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2 //! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]
2 //! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]
3 3 use eframe::App;
4 4 use paperdoll_renderer::{
5 5 editor::EditorApp,
@@ -48,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
48 48 let a = std::env::args().skip(1).collect::<Vec<_>>();
49 49 if a.len() < 7 {
50 50 return Err(
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]".into(),
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]".into(),
52 52 );
53 53 }
54 54 let mut root = PathBuf::from(&a[0]);
@@ -75,7 +75,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
75 75 _ => return Err("invalid sex".into()),
76 76 };
77 77 let equipment = a.get(7).is_some_and(|v| v == "equipment");
78 let open_equipment = equipment || default_hair;
78 let skinned = a.get(7).is_some_and(|v| v == "skinned-equipment");
79 let open_equipment = equipment || default_hair || skinned;
79 80 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {
80 81 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
81 82 .height_milli = value.parse()?;
@@ -104,6 +105,35 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
104 105 s.equipment[i].slug = "sword".into();
105 106 s.equipment[i].preset = Some(p);
106 107 }
108 if skinned {
109 // Tim t1027u: the male test body itself as a skinned equipment part,
110 // bones auto-linked by name, the Head link lifted by 15 cm.
111 let mut p = equipment::Preset::new("suit".into());
112 p.provenance = "CC0 test body used as skinned equipment".into();
113 let part = equipment::import_model(&mut p, &PathBuf::from(&a[1]))?;
114 for (i, sex) in [(0, Sex::Male), (1, Sex::Female)] {
115 let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;
116 let n = paperdoll_renderer::equipment_rig::auto_link(&mut p, part, i, &b)?;
117 println!("LINKS {sex:?}: {n}");
118 let head = p.attachments[part]
119 .skeleton
120 .as_ref()
121 .unwrap()
122 .bones
123 .iter()
124 .position(|x| x.name == "Head")
125 .ok_or("Head absent")?;
126 if let Some(link) = p.attachments[part].bone_links[i]
127 .iter_mut()
128 .find(|l| l.bone == head)
129 {
130 link.translation[1] += 0.15;
131 }
132 }
133 let i = s.add_equipment();
134 s.equipment[i].slug = "suit".into();
135 s.equipment[i].preset = Some(p);
136 }
107 137 s.save()?;
108 138 for sex in [Sex::Male, Sex::Female] {
109 139 let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;
@@ -1,5 +1,5 @@
1//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
1//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]
2//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]
3use eframe::App;
3use eframe::App;
4use paperdoll_renderer::{
4use paperdoll_renderer::{
5 editor::EditorApp,
5 editor::EditorApp,
@@ -48,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
48 let a = std::env::args().skip(1).collect::<Vec<_>>();
48 let a = std::env::args().skip(1).collect::<Vec<_>>();
49 if a.len() < 7 {
49 if a.len() < 7 {
50 return Err(
50 return Err(
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]".into(),
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]".into(),
52 );
52 );
53 }
53 }
54 let mut root = PathBuf::from(&a[0]);
54 let mut root = PathBuf::from(&a[0]);
@@ -75,7 +75,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
75 _ => return Err("invalid sex".into()),
75 _ => return Err("invalid sex".into()),
76 };
76 };
77 let equipment = a.get(7).is_some_and(|v| v == "equipment");
77 let equipment = a.get(7).is_some_and(|v| v == "equipment");
78 let open_equipment = equipment || default_hair;
78 let skinned = a.get(7).is_some_and(|v| v == "skinned-equipment");
79 let open_equipment = equipment || default_hair || skinned;
79 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {
80 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {
80 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
81 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
81 .height_milli = value.parse()?;
82 .height_milli = value.parse()?;
@@ -104,6 +105,35 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
104 s.equipment[i].slug = "sword".into();
105 s.equipment[i].slug = "sword".into();
105 s.equipment[i].preset = Some(p);
106 s.equipment[i].preset = Some(p);
106 }
107 }
108 if skinned {
109 // Tim t1027u: the male test body itself as a skinned equipment part,
110 // bones auto-linked by name, the Head link lifted by 15 cm.
111 let mut p = equipment::Preset::new("suit".into());
112 p.provenance = "CC0 test body used as skinned equipment".into();
113 let part = equipment::import_model(&mut p, &PathBuf::from(&a[1]))?;
114 for (i, sex) in [(0, Sex::Male), (1, Sex::Female)] {
115 let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;
116 let n = paperdoll_renderer::equipment_rig::auto_link(&mut p, part, i, &b)?;
117 println!("LINKS {sex:?}: {n}");
118 let head = p.attachments[part]
119 .skeleton
120 .as_ref()
121 .unwrap()
122 .bones
123 .iter()
124 .position(|x| x.name == "Head")
125 .ok_or("Head absent")?;
126 if let Some(link) = p.attachments[part].bone_links[i]
127 .iter_mut()
128 .find(|l| l.bone == head)
129 {
130 link.translation[1] += 0.15;
131 }
132 }
133 let i = s.add_equipment();
134 s.equipment[i].slug = "suit".into();
135 s.equipment[i].preset = Some(p);
136 }
107 s.save()?;
137 s.save()?;
108 for sex in [Sex::Male, Sex::Female] {
138 for sex in [Sex::Male, Sex::Female] {
109 let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;
139 let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;
src/dialogs.rs +3 −2
@@ -131,8 +131,9 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
131 131 .add_filter("Model", &["glb", "gltf", "fbx", "obj"])
132 132 .pick_file(),
133 133 DialogPurpose::EquipmentModel(_) => dialog
134 .set_title("Rigide equipment toevoegen")
135 .add_filter("Rigide model", &["obj", "fbx"])
134 .set_title("Equipment toevoegen")
135 .add_filter("Model", &["obj", "fbx", "glb", "gltf"])
136 .add_filter("glTF/GLB", &["glb", "gltf"])
136 137 .add_filter("Wavefront OBJ", &["obj"])
137 138 .add_filter("FBX", &["fbx"])
138 139 .pick_file(),
@@ -131,8 +131,9 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
131 .add_filter("Model", &["glb", "gltf", "fbx", "obj"])
131 .add_filter("Model", &["glb", "gltf", "fbx", "obj"])
132 .pick_file(),
132 .pick_file(),
133 DialogPurpose::EquipmentModel(_) => dialog
133 DialogPurpose::EquipmentModel(_) => dialog
134 .set_title("Rigide equipment toevoegen")
134 .set_title("Equipment toevoegen")
135 .add_filter("Rigide model", &["obj", "fbx"])
135 .add_filter("Model", &["obj", "fbx", "glb", "gltf"])
136 .add_filter("glTF/GLB", &["glb", "gltf"])
136 .add_filter("Wavefront OBJ", &["obj"])
137 .add_filter("Wavefront OBJ", &["obj"])
137 .add_filter("FBX", &["fbx"])
138 .add_filter("FBX", &["fbx"])
138 .pick_file(),
139 .pick_file(),
src/editor.rs +121 −4
@@ -525,7 +525,10 @@ impl EditorApp {
525 525 match crate::equipment::import_model(&mut preset, path) {
526 526 Ok(part) => {
527 527 if let Some(body) = body {
528 if !body.joint_nodes.is_empty() {
528 if body.joint_nodes.is_empty() {
529 } else if preset.attachments[part].skeleton.is_some() {
530 let _ = crate::equipment_rig::auto_link(&mut preset, part, sex, body);
531 } else {
529 532 let _ = preset.bind(sex, body, part, 0);
530 533 }
531 534 }
@@ -537,11 +540,15 @@ impl EditorApp {
537 540 item.model = Some(preset.attachments[part].label.clone());
538 541 item.legacy_path = None;
539 542 item.error = None;
543 let skinned = preset.attachments[part].skeleton.is_some();
540 544 item.preset = Some(preset);
541 545 self.equipment_part = part;
542 self.session.notice =
546 self.session.notice = if skinned {
547 "Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling."
548 } else {
543 549 "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
544 .into();
550 }
551 .into();
545 552 self.session.notice_ok = true;
546 553 }
547 554 Err(error) => self.session.fail(error),
@@ -1776,7 +1783,7 @@ impl EditorApp {
1776 1783 if let Some(error) = &item.error {
1777 1784 ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));
1778 1785 }
1779 if ui.button(crate::i18n::t("Onderdeel toevoegen (OBJ/FBX)")).clicked() {
1786 if ui.button(crate::i18n::t("Onderdeel toevoegen (OBJ/FBX/glTF)")).clicked() {
1780 1787 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1781 1788 }
1782 1789 if let Some(preset) = &mut item.preset {
@@ -1822,7 +1829,9 @@ impl EditorApp {
1822 1829 } else {
1823 1830 self.preview_female.as_ref()
1824 1831 };
1832 let skinned = preset.attachments[self.equipment_part].skeleton.is_some();
1825 1833 let attachment = &mut preset.attachments[self.equipment_part];
1834 if !skinned {
1826 1835 ui.label(crate::i18n::t("Positie relatief aan bot (XYZ)"));
1827 1836 ui.horizontal(|ui| {
1828 1837 for value in &mut attachment.translation {
@@ -1843,6 +1852,7 @@ impl EditorApp {
1843 1852 );
1844 1853 }
1845 1854 });
1855 }
1846 1856 ui.add(
1847 1857 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1848 1858 .text(crate::i18n::t("Glanssterkte")),
@@ -1867,6 +1877,7 @@ impl EditorApp {
1867 1877 item.error = Some(error);
1868 1878 }
1869 1879 }
1880 if !skinned {
1870 1881 ui.label(crate::i18n::t(format!(
1871 1882 "Bot: {}",
1872 1883 selected.as_deref().unwrap_or("nog niet gekoppeld")
@@ -1905,6 +1916,11 @@ impl EditorApp {
1905 1916 crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."),
1906 1917 );
1907 1918 }
1919 } else if let Some(error) =
1920 skinned_links_ui(ui, preset, self.equipment_part, sex, body)
1921 {
1922 item.error = Some(error);
1923 }
1908 1924 if ui.button(crate::i18n::t("Verwijder onderdeel")).clicked() {
1909 1925 if let Err(error) =
1910 1926 crate::equipment::remove_attachment(preset, self.equipment_part)
@@ -2548,6 +2564,107 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)
2548 2564 choice
2549 2565 }
2550 2566
2567 /// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2568 /// character bone (optional; at least one), with its own position/rotation.
2569 fn skinned_links_ui(
2570 ui: &mut egui::Ui,
2571 preset: &mut crate::equipment::Preset,
2572 part: usize,
2573 sex: usize,
2574 body: Option<&PreviewCharacter>,
2575 ) -> Option<String> {
2576 let Some(body) = body.filter(|b| !b.is_static()) else {
2577 ui.colored_label(
2578 egui::Color32::LIGHT_RED,
2579 crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."),
2580 );
2581 return None;
2582 };
2583 let mut error = None;
2584 let skeleton = preset.attachments[part].skeleton.clone()?;
2585 ui.label(crate::i18n::t(
2586 "Botkoppeling: kies per equipmentbot een karakterbot (minstens één). Niet-gekoppelde botten volgen hun dichtstbijzijnde gekoppelde ouder.",
2587 ));
2588 if preset.attachments[part].bone_links[sex].is_empty() {
2589 ui.colored_label(
2590 egui::Color32::LIGHT_RED,
2591 crate::i18n::t("Koppel minstens één equipmentbot."),
2592 );
2593 }
2594 if ui
2595 .button(crate::i18n::t("Automatisch invullen op naam"))
2596 .clicked()
2597 {
2598 match crate::equipment_rig::auto_link(preset, part, sex, body) {
2599 Ok(_) => {}
2600 Err(e) => error = Some(e),
2601 }
2602 }
2603 egui::ScrollArea::vertical()
2604 .id_salt("skinned-links")
2605 .max_height(320.0)
2606 .show(ui, |ui| {
2607 for (bone, info) in skeleton.bones.iter().enumerate() {
2608 let link = preset.attachments[part].bone_links[sex]
2609 .iter()
2610 .position(|l| l.bone == bone);
2611 let current = link.and_then(|i| {
2612 let target = &preset.attachments[part].bone_links[sex][i].target;
2613 crate::preview::joint_paths(body)
2614 .iter()
2615 .position(|p| p == target)
2616 });
2617 let mut choice = current;
2618 ui.horizontal(|ui| {
2619 ui.label(&info.name);
2620 egui::ComboBox::from_id_salt(("skinned-link", part, bone))
2621 .selected_text(
2622 current
2623 .and_then(|j| body.joint_names.get(j).cloned())
2624 .unwrap_or_else(|| crate::i18n::t("volgt ouder")),
2625 )
2626 .show_ui(ui, |ui| {
2627 ui.selectable_value(&mut choice, None, crate::i18n::t("volgt ouder"));
2628 for (j, name) in body.joint_names.iter().enumerate() {
2629 ui.selectable_value(&mut choice, Some(j), name);
2630 }
2631 });
2632 });
2633 if choice != current {
2634 if let Err(e) =
2635 crate::equipment_rig::set_link(preset, part, sex, body, bone, choice)
2636 {
2637 error = Some(e);
2638 }
2639 }
2640 if let Some(link) = preset.attachments[part].bone_links[sex]
2641 .iter_mut()
2642 .find(|l| l.bone == bone)
2643 {
2644 ui.horizontal(|ui| {
2645 ui.label(crate::i18n::t("Positie"));
2646 for value in &mut link.translation {
2647 ui.add(
2648 egui::DragValue::new(value)
2649 .speed(0.005)
2650 .range(-1000.0..=1000.0),
2651 );
2652 }
2653 ui.label(crate::i18n::t("Rotatie°"));
2654 for value in &mut link.rotation_deg {
2655 ui.add(
2656 egui::DragValue::new(value)
2657 .speed(0.5)
2658 .range(-3600.0..=3600.0),
2659 );
2660 }
2661 });
2662 }
2663 }
2664 });
2665 error
2666 }
2667
2551 2668 #[cfg(test)]
2552 2669 mod tests {
2553 2670 use super::*;
@@ -525,7 +525,10 @@ impl EditorApp {
525 match crate::equipment::import_model(&mut preset, path) {
525 match crate::equipment::import_model(&mut preset, path) {
526 Ok(part) => {
526 Ok(part) => {
527 if let Some(body) = body {
527 if let Some(body) = body {
528 if !body.joint_nodes.is_empty() {
528 if body.joint_nodes.is_empty() {
529 } else if preset.attachments[part].skeleton.is_some() {
530 let _ = crate::equipment_rig::auto_link(&mut preset, part, sex, body);
531 } else {
529 let _ = preset.bind(sex, body, part, 0);
532 let _ = preset.bind(sex, body, part, 0);
530 }
533 }
531 }
534 }
@@ -537,11 +540,15 @@ impl EditorApp {
537 item.model = Some(preset.attachments[part].label.clone());
540 item.model = Some(preset.attachments[part].label.clone());
538 item.legacy_path = None;
541 item.legacy_path = None;
539 item.error = None;
542 item.error = None;
543 let skinned = preset.attachments[part].skeleton.is_some();
540 item.preset = Some(preset);
544 item.preset = Some(preset);
541 self.equipment_part = part;
545 self.equipment_part = part;
542 self.session.notice =
546 self.session.notice = if skinned {
547 "Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling."
548 } else {
543 "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
549 "Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
544 .into();
550 }
551 .into();
545 self.session.notice_ok = true;
552 self.session.notice_ok = true;
546 }
553 }
547 Err(error) => self.session.fail(error),
554 Err(error) => self.session.fail(error),
@@ -1776,7 +1783,7 @@ impl EditorApp {
1776 if let Some(error) = &item.error {
1783 if let Some(error) = &item.error {
1777 ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));
1784 ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));
1778 }
1785 }
1779 if ui.button(crate::i18n::t("Onderdeel toevoegen (OBJ/FBX)")).clicked() {
1786 if ui.button(crate::i18n::t("Onderdeel toevoegen (OBJ/FBX/glTF)")).clicked() {
1780 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1787 self.dialogs.start(DialogPurpose::EquipmentModel(index));
1781 }
1788 }
1782 if let Some(preset) = &mut item.preset {
1789 if let Some(preset) = &mut item.preset {
@@ -1822,7 +1829,9 @@ impl EditorApp {
1822 } else {
1829 } else {
1823 self.preview_female.as_ref()
1830 self.preview_female.as_ref()
1824 };
1831 };
1832 let skinned = preset.attachments[self.equipment_part].skeleton.is_some();
1825 let attachment = &mut preset.attachments[self.equipment_part];
1833 let attachment = &mut preset.attachments[self.equipment_part];
1834 if !skinned {
1826 ui.label(crate::i18n::t("Positie relatief aan bot (XYZ)"));
1835 ui.label(crate::i18n::t("Positie relatief aan bot (XYZ)"));
1827 ui.horizontal(|ui| {
1836 ui.horizontal(|ui| {
1828 for value in &mut attachment.translation {
1837 for value in &mut attachment.translation {
@@ -1843,6 +1852,7 @@ impl EditorApp {
1843 );
1852 );
1844 }
1853 }
1845 });
1854 });
1855 }
1846 ui.add(
1856 ui.add(
1847 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1857 egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
1848 .text(crate::i18n::t("Glanssterkte")),
1858 .text(crate::i18n::t("Glanssterkte")),
@@ -1867,6 +1877,7 @@ impl EditorApp {
1867 item.error = Some(error);
1877 item.error = Some(error);
1868 }
1878 }
1869 }
1879 }
1880 if !skinned {
1870 ui.label(crate::i18n::t(format!(
1881 ui.label(crate::i18n::t(format!(
1871 "Bot: {}",
1882 "Bot: {}",
1872 selected.as_deref().unwrap_or("nog niet gekoppeld")
1883 selected.as_deref().unwrap_or("nog niet gekoppeld")
@@ -1905,6 +1916,11 @@ impl EditorApp {
1905 crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."),
1916 crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."),
1906 );
1917 );
1907 }
1918 }
1919 } else if let Some(error) =
1920 skinned_links_ui(ui, preset, self.equipment_part, sex, body)
1921 {
1922 item.error = Some(error);
1923 }
1908 if ui.button(crate::i18n::t("Verwijder onderdeel")).clicked() {
1924 if ui.button(crate::i18n::t("Verwijder onderdeel")).clicked() {
1909 if let Err(error) =
1925 if let Err(error) =
1910 crate::equipment::remove_attachment(preset, self.equipment_part)
1926 crate::equipment::remove_attachment(preset, self.equipment_part)
@@ -2548,6 +2564,107 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)
2548 choice
2564 choice
2549}
2565}
2550
2566
2567/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2568/// character bone (optional; at least one), with its own position/rotation.
2569fn skinned_links_ui(
2570 ui: &mut egui::Ui,
2571 preset: &mut crate::equipment::Preset,
2572 part: usize,
2573 sex: usize,
2574 body: Option<&PreviewCharacter>,
2575) -> Option<String> {
2576 let Some(body) = body.filter(|b| !b.is_static()) else {
2577 ui.colored_label(
2578 egui::Color32::LIGHT_RED,
2579 crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."),
2580 );
2581 return None;
2582 };
2583 let mut error = None;
2584 let skeleton = preset.attachments[part].skeleton.clone()?;
2585 ui.label(crate::i18n::t(
2586 "Botkoppeling: kies per equipmentbot een karakterbot (minstens één). Niet-gekoppelde botten volgen hun dichtstbijzijnde gekoppelde ouder.",
2587 ));
2588 if preset.attachments[part].bone_links[sex].is_empty() {
2589 ui.colored_label(
2590 egui::Color32::LIGHT_RED,
2591 crate::i18n::t("Koppel minstens één equipmentbot."),
2592 );
2593 }
2594 if ui
2595 .button(crate::i18n::t("Automatisch invullen op naam"))
2596 .clicked()
2597 {
2598 match crate::equipment_rig::auto_link(preset, part, sex, body) {
2599 Ok(_) => {}
2600 Err(e) => error = Some(e),
2601 }
2602 }
2603 egui::ScrollArea::vertical()
2604 .id_salt("skinned-links")
2605 .max_height(320.0)
2606 .show(ui, |ui| {
2607 for (bone, info) in skeleton.bones.iter().enumerate() {
2608 let link = preset.attachments[part].bone_links[sex]
2609 .iter()
2610 .position(|l| l.bone == bone);
2611 let current = link.and_then(|i| {
2612 let target = &preset.attachments[part].bone_links[sex][i].target;
2613 crate::preview::joint_paths(body)
2614 .iter()
2615 .position(|p| p == target)
2616 });
2617 let mut choice = current;
2618 ui.horizontal(|ui| {
2619 ui.label(&info.name);
2620 egui::ComboBox::from_id_salt(("skinned-link", part, bone))
2621 .selected_text(
2622 current
2623 .and_then(|j| body.joint_names.get(j).cloned())
2624 .unwrap_or_else(|| crate::i18n::t("volgt ouder")),
2625 )
2626 .show_ui(ui, |ui| {
2627 ui.selectable_value(&mut choice, None, crate::i18n::t("volgt ouder"));
2628 for (j, name) in body.joint_names.iter().enumerate() {
2629 ui.selectable_value(&mut choice, Some(j), name);
2630 }
2631 });
2632 });
2633 if choice != current {
2634 if let Err(e) =
2635 crate::equipment_rig::set_link(preset, part, sex, body, bone, choice)
2636 {
2637 error = Some(e);
2638 }
2639 }
2640 if let Some(link) = preset.attachments[part].bone_links[sex]
2641 .iter_mut()
2642 .find(|l| l.bone == bone)
2643 {
2644 ui.horizontal(|ui| {
2645 ui.label(crate::i18n::t("Positie"));
2646 for value in &mut link.translation {
2647 ui.add(
2648 egui::DragValue::new(value)
2649 .speed(0.005)
2650 .range(-1000.0..=1000.0),
2651 );
2652 }
2653 ui.label(crate::i18n::t("Rotatie°"));
2654 for value in &mut link.rotation_deg {
2655 ui.add(
2656 egui::DragValue::new(value)
2657 .speed(0.5)
2658 .range(-3600.0..=3600.0),
2659 );
2660 }
2661 });
2662 }
2663 }
2664 });
2665 error
2666}
2667
2551#[cfg(test)]
2668#[cfg(test)]
2552mod tests {
2669mod tests {
2553 use super::*;
2670 use super::*;
src/equipment.rs +51 −7
@@ -13,12 +13,13 @@ use crate::project::portable_relative;
13 13 use serde::{Deserialize, Serialize};
14 14
15 15 pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16 pub const VERSION: u32 = 1;
16 /// Container version written; version 1 (rigid parts only) is still read.
17 pub const VERSION: u32 = 2;
17 18 pub const MAX_FILE: usize = 32 * 1024 * 1024;
18 19 const MAX_META: usize = 4 * 1024 * 1024;
19 20 const MAX_ASSET: usize = 8 * 1024 * 1024;
20 21 const MAX_ASSETS: usize = 32;
21 const MAX_VERTICES: usize = 80_000;
22 pub(crate) const MAX_VERTICES: usize = 80_000;
22 23 /// Vertex budget when the rigid readers load a static (unskinned) *body*;
23 24 /// same ceiling as the FBX body loader.
24 25 pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000;
@@ -40,6 +41,9 @@ pub struct Mesh {
40 41 pub indices: Vec<u32>,
41 42 pub color: [f32; 4],
42 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>,
43 47 }
44 48
45 49 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -71,6 +75,12 @@ pub struct Attachment {
71 75 pub meshes: Vec<Mesh>,
72 76 pub shiny: Option<usize>,
73 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],
74 84 }
75 85
76 86 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -112,6 +122,10 @@ impl Preset {
112 122 .ok_or("dit onderdeel bestaat niet")?;
113 123 item.bones[sex] = Some(path);
114 124 self.rigs[sex] = Some(RigSignature::of(body));
125 let paths = &self.rigs[sex].as_ref().unwrap().paths;
126 for a in &mut self.attachments {
127 a.bone_links[sex].retain(|l| paths.contains(&l.target));
128 }
115 129 Ok(())
116 130 }
117 131 pub fn validate(&self) -> Result<(), String> {
@@ -187,10 +201,11 @@ impl Preset {
187 201 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
188 202 || !attachment.specular.is_finite()
189 203 || !(0.0..=1.0).contains(&attachment.specular)
190 || attachment.meshes.len() > 64
204 || (attachment.skeleton.is_none() && attachment.meshes.len() > 64)
191 205 {
192 206 return Err("ongeldige attachment/transformatie".into());
193 207 }
208 crate::equipment_rig::validate_part(attachment, &self.rigs)?;
194 209 if let Some(index) = attachment.shiny {
195 210 texture_indices.insert(index);
196 211 }
@@ -284,7 +299,7 @@ impl Preset {
284 299 return Err("ongeldig equipmentbestand of bestand te groot".into());
285 300 }
286 301 let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
287 if number(8) != VERSION {
302 if !(1..=VERSION).contains(&number(8)) {
288 303 return Err("onbekende equipmentversie".into());
289 304 }
290 305 let meta_len = number(12) as usize;
@@ -536,6 +551,25 @@ pub fn layers_cached(
536 551 let paths = joint_paths(body);
537 552 let mut layers = Vec::new();
538 553 for item in &preset.attachments {
554 if let Some(skeleton) = &item.skeleton {
555 let models = crate::equipment_rig::bone_models(
556 skeleton,
557 &item.bone_links[sex],
558 &paths,
559 |joint| animated_joint_world(body, &controls.clip_name, controls.time, joint),
560 )?;
561 let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
562 for mesh in &item.meshes {
563 layers.push(Layer {
564 mesh: mesh.clone(),
565 model: models[mesh.bone.unwrap_or(0)],
566 albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?,
567 shiny: shiny.clone(),
568 specular: item.specular,
569 });
570 }
571 continue;
572 }
539 573 let path = item.bones[sex]
540 574 .as_deref()
541 575 .ok_or("kies een bot voor dit onderdeel en geslacht")?;
@@ -735,6 +769,7 @@ pub(crate) fn import_obj_limited(
735 769 material.and_then(|m| m.dissolve).unwrap_or(1.),
736 770 ],
737 771 albedo,
772 bone: None,
738 773 });
739 774 }
740 775 if meshes.is_empty() {
@@ -749,6 +784,8 @@ pub(crate) fn import_obj_limited(
749 784 meshes,
750 785 shiny: None,
751 786 specular: 0.25,
787 skeleton: None,
788 bone_links: [Vec::new(), Vec::new()],
752 789 });
753 790 candidate.validate_with(max_vertices)?;
754 791 *preset = candidate;
@@ -764,8 +801,12 @@ pub fn import_model(preset: &mut Preset, source: &Path) -> Result<usize, String>
764 801 .as_deref()
765 802 {
766 803 Some("obj") => import_obj(preset, source),
767 Some("fbx") => import_fbx(preset, source),
768 _ => Err("kies een rigide OBJ- (met MTL/PNG) of FBX-model".into()),
804 Some("gltf" | "glb") => crate::equipment_rig::import_skinned(preset, source),
805 Some("fbx") => match crate::equipment_rig::import_skinned(preset, source) {
806 Err(error) if error == crate::equipment_rig::NOT_SKINNED => import_fbx(preset, source),
807 other => other,
808 },
809 _ => Err("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model".into()),
769 810 }
770 811 }
771 812
@@ -1021,6 +1062,7 @@ pub(crate) fn import_fbx_limited(
1021 1062 indices,
1022 1063 color,
1023 1064 albedo,
1065 bone: None,
1024 1066 });
1025 1067 }
1026 1068 }
@@ -1038,6 +1080,8 @@ pub(crate) fn import_fbx_limited(
1038 1080 meshes,
1039 1081 shiny: None,
1040 1082 specular: 0.25,
1083 skeleton: None,
1084 bone_links: [Vec::new(), Vec::new()],
1041 1085 });
1042 1086 candidate.validate_with(max_vertices)?;
1043 1087 *preset = candidate;
@@ -1080,7 +1124,7 @@ fn unique_prefix(preset: &Preset, kind: &str) -> String {
1080 1124 })
1081 1125 .expect("finite asset list")
1082 1126 }
1083 fn compact_assets(preset: &mut Preset) -> Result<(), String> {
1127 pub(crate) fn compact_assets(preset: &mut Preset) -> Result<(), String> {
1084 1128 let used: BTreeSet<usize> = preset
1085 1129 .attachments
1086 1130 .iter()
@@ -13,12 +13,13 @@ use crate::project::portable_relative;
13use serde::{Deserialize, Serialize};
13use serde::{Deserialize, Serialize};
14
14
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16pub const VERSION: u32 = 1;
16/// Container version written; version 1 (rigid parts only) is still read.
17pub const VERSION: u32 = 2;
17pub const MAX_FILE: usize = 32 * 1024 * 1024;
18pub const MAX_FILE: usize = 32 * 1024 * 1024;
18const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_ASSET: usize = 8 * 1024 * 1024;
20const MAX_ASSET: usize = 8 * 1024 * 1024;
20const MAX_ASSETS: usize = 32;
21const MAX_ASSETS: usize = 32;
21const MAX_VERTICES: usize = 80_000;
22pub(crate) const MAX_VERTICES: usize = 80_000;
22/// Vertex budget when the rigid readers load a static (unskinned) *body*;
23/// Vertex budget when the rigid readers load a static (unskinned) *body*;
23/// same ceiling as the FBX body loader.
24/// same ceiling as the FBX body loader.
24pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000;
25pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000;
@@ -40,6 +41,9 @@ pub struct Mesh {
40 pub indices: Vec<u32>,
41 pub indices: Vec<u32>,
41 pub color: [f32; 4],
42 pub color: [f32; 4],
42 pub albedo: Option<usize>,
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>,
43}
47}
44
48
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -71,6 +75,12 @@ pub struct Attachment {
71 pub meshes: Vec<Mesh>,
75 pub meshes: Vec<Mesh>,
72 pub shiny: Option<usize>,
76 pub shiny: Option<usize>,
73 pub specular: f32,
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],
74}
84}
75
85
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -112,6 +122,10 @@ impl Preset {
112 .ok_or("dit onderdeel bestaat niet")?;
122 .ok_or("dit onderdeel bestaat niet")?;
113 item.bones[sex] = Some(path);
123 item.bones[sex] = Some(path);
114 self.rigs[sex] = Some(RigSignature::of(body));
124 self.rigs[sex] = Some(RigSignature::of(body));
125 let paths = &self.rigs[sex].as_ref().unwrap().paths;
126 for a in &mut self.attachments {
127 a.bone_links[sex].retain(|l| paths.contains(&l.target));
128 }
115 Ok(())
129 Ok(())
116 }
130 }
117 pub fn validate(&self) -> Result<(), String> {
131 pub fn validate(&self) -> Result<(), String> {
@@ -187,10 +201,11 @@ impl Preset {
187 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
201 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
188 || !attachment.specular.is_finite()
202 || !attachment.specular.is_finite()
189 || !(0.0..=1.0).contains(&attachment.specular)
203 || !(0.0..=1.0).contains(&attachment.specular)
190 || attachment.meshes.len() > 64
204 || (attachment.skeleton.is_none() && attachment.meshes.len() > 64)
191 {
205 {
192 return Err("ongeldige attachment/transformatie".into());
206 return Err("ongeldige attachment/transformatie".into());
193 }
207 }
208 crate::equipment_rig::validate_part(attachment, &self.rigs)?;
194 if let Some(index) = attachment.shiny {
209 if let Some(index) = attachment.shiny {
195 texture_indices.insert(index);
210 texture_indices.insert(index);
196 }
211 }
@@ -284,7 +299,7 @@ impl Preset {
284 return Err("ongeldig equipmentbestand of bestand te groot".into());
299 return Err("ongeldig equipmentbestand of bestand te groot".into());
285 }
300 }
286 let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
301 let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
287 if number(8) != VERSION {
302 if !(1..=VERSION).contains(&number(8)) {
288 return Err("onbekende equipmentversie".into());
303 return Err("onbekende equipmentversie".into());
289 }
304 }
290 let meta_len = number(12) as usize;
305 let meta_len = number(12) as usize;
@@ -536,6 +551,25 @@ pub fn layers_cached(
536 let paths = joint_paths(body);
551 let paths = joint_paths(body);
537 let mut layers = Vec::new();
552 let mut layers = Vec::new();
538 for item in &preset.attachments {
553 for item in &preset.attachments {
554 if let Some(skeleton) = &item.skeleton {
555 let models = crate::equipment_rig::bone_models(
556 skeleton,
557 &item.bone_links[sex],
558 &paths,
559 |joint| animated_joint_world(body, &controls.clip_name, controls.time, joint),
560 )?;
561 let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
562 for mesh in &item.meshes {
563 layers.push(Layer {
564 mesh: mesh.clone(),
565 model: models[mesh.bone.unwrap_or(0)],
566 albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?,
567 shiny: shiny.clone(),
568 specular: item.specular,
569 });
570 }
571 continue;
572 }
539 let path = item.bones[sex]
573 let path = item.bones[sex]
540 .as_deref()
574 .as_deref()
541 .ok_or("kies een bot voor dit onderdeel en geslacht")?;
575 .ok_or("kies een bot voor dit onderdeel en geslacht")?;
@@ -735,6 +769,7 @@ pub(crate) fn import_obj_limited(
735 material.and_then(|m| m.dissolve).unwrap_or(1.),
769 material.and_then(|m| m.dissolve).unwrap_or(1.),
736 ],
770 ],
737 albedo,
771 albedo,
772 bone: None,
738 });
773 });
739 }
774 }
740 if meshes.is_empty() {
775 if meshes.is_empty() {
@@ -749,6 +784,8 @@ pub(crate) fn import_obj_limited(
749 meshes,
784 meshes,
750 shiny: None,
785 shiny: None,
751 specular: 0.25,
786 specular: 0.25,
787 skeleton: None,
788 bone_links: [Vec::new(), Vec::new()],
752 });
789 });
753 candidate.validate_with(max_vertices)?;
790 candidate.validate_with(max_vertices)?;
754 *preset = candidate;
791 *preset = candidate;
@@ -764,8 +801,12 @@ pub fn import_model(preset: &mut Preset, source: &Path) -> Result<usize, String>
764 .as_deref()
801 .as_deref()
765 {
802 {
766 Some("obj") => import_obj(preset, source),
803 Some("obj") => import_obj(preset, source),
767 Some("fbx") => import_fbx(preset, source),
804 Some("gltf" | "glb") => crate::equipment_rig::import_skinned(preset, source),
768 _ => Err("kies een rigide OBJ- (met MTL/PNG) of FBX-model".into()),
805 Some("fbx") => match crate::equipment_rig::import_skinned(preset, source) {
806 Err(error) if error == crate::equipment_rig::NOT_SKINNED => import_fbx(preset, source),
807 other => other,
808 },
809 _ => Err("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model".into()),
769 }
810 }
770}
811}
771
812
@@ -1021,6 +1062,7 @@ pub(crate) fn import_fbx_limited(
1021 indices,
1062 indices,
1022 color,
1063 color,
1023 albedo,
1064 albedo,
1065 bone: None,
1024 });
1066 });
1025 }
1067 }
1026 }
1068 }
@@ -1038,6 +1080,8 @@ pub(crate) fn import_fbx_limited(
1038 meshes,
1080 meshes,
1039 shiny: None,
1081 shiny: None,
1040 specular: 0.25,
1082 specular: 0.25,
1083 skeleton: None,
1084 bone_links: [Vec::new(), Vec::new()],
1041 });
1085 });
1042 candidate.validate_with(max_vertices)?;
1086 candidate.validate_with(max_vertices)?;
1043 *preset = candidate;
1087 *preset = candidate;
@@ -1080,7 +1124,7 @@ fn unique_prefix(preset: &Preset, kind: &str) -> String {
1080 })
1124 })
1081 .expect("finite asset list")
1125 .expect("finite asset list")
1082}
1126}
1083fn compact_assets(preset: &mut Preset) -> Result<(), String> {
1127pub(crate) fn compact_assets(preset: &mut Preset) -> Result<(), String> {
1084 let used: BTreeSet<usize> = preset
1128 let used: BTreeSet<usize> = preset
1085 .attachments
1129 .attachments
1086 .iter()
1130 .iter()
src/equipment_rig.rs added +745 −0
@@ -0,0 +1,745 @@
1 //! Equipment with its own skeleton (Tim t1027u).
2 //!
3 //! A skinned equipment model is stored rigid per bone: every vertex belongs to
4 //! its dominant skin bone and is kept in that bone's rest space. Per sex, the
5 //! equipment bones are linked to character bones; each link has its own
6 //! position and rotation relative to the character bone. Unlinked bones follow
7 //! their nearest linked ancestor (or the first link) rigidly.
8
9 use std::path::Path;
10
11 use serde::{Deserialize, Serialize};
12
13 use crate::equipment::{local_matrix, Attachment, EmbeddedAsset, Mesh, Preset, RigSignature};
14 use crate::preview::{animated_joint_world, joint_paths, mul, Mat4, PreviewCharacter};
15
16 /// Error of [`import_skinned`] for a model without a skin.
17 pub const NOT_SKINNED: &str =
18 "het model heeft geen skelet; kies OBJ of FBX voor een rigide onderdeel";
19
20 const MAX_BONES: usize = 256;
21 const MAX_SKINNED_MESHES: usize = 1024;
22
23 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24 #[serde(deny_unknown_fields)]
25 pub struct EquipBone {
26 pub name: String,
27 pub parent: Option<usize>,
28 /// Rest-pose world matrix in the equipment model's space.
29 pub rest_world: Mat4,
30 }
31
32 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33 #[serde(deny_unknown_fields)]
34 pub struct Skeleton {
35 pub bones: Vec<EquipBone>,
36 }
37
38 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39 #[serde(deny_unknown_fields)]
40 pub struct BoneLink {
41 /// Equipment bone index.
42 pub bone: usize,
43 /// Character joint path (as in [`joint_paths`]).
44 pub target: String,
45 pub translation: [f32; 3],
46 pub rotation_deg: [f32; 3],
47 }
48
49 pub fn links_empty(links: &[Vec<BoneLink>; 2]) -> bool {
50 links.iter().all(Vec::is_empty)
51 }
52
53 /// Structural checks for the skinned fields of one part.
54 pub(crate) fn validate_part(
55 part: &Attachment,
56 rigs: &[Option<RigSignature>; 2],
57 ) -> Result<(), String> {
58 let Some(skeleton) = &part.skeleton else {
59 if !links_empty(&part.bone_links) || part.meshes.iter().any(|m| m.bone.is_some()) {
60 return Err("botkoppeling zonder skelet".into());
61 }
62 return Ok(());
63 };
64 let n = skeleton.bones.len();
65 if n == 0 || n > MAX_BONES || part.meshes.len() > MAX_SKINNED_MESHES {
66 return Err("ongeldig equipmentskelet".into());
67 }
68 for (i, bone) in skeleton.bones.iter().enumerate() {
69 if bone.name.len() > 256
70 || bone.rest_world.iter().any(|x| !x.is_finite())
71 || bone.parent.is_some_and(|p| p >= n || p == i)
72 {
73 return Err("ongeldig equipmentskelet".into());
74 }
75 // Acyclic: a parent walk ends within n steps.
76 let mut cursor = bone.parent;
77 let mut steps = 0;
78 while let Some(p) = cursor {
79 steps += 1;
80 if steps > n {
81 return Err("equipmentskelet bevat een lus".into());
82 }
83 cursor = skeleton.bones[p].parent;
84 }
85 }
86 if part.meshes.iter().any(|m| m.bone.is_none_or(|b| b >= n)) {
87 return Err("mesh verwijst naar onbekend equipmentbot".into());
88 }
89 for (sex, links) in part.bone_links.iter().enumerate() {
90 let mut seen = std::collections::BTreeSet::new();
91 for link in links {
92 if link.bone >= n
93 || !seen.insert(link.bone)
94 || link.target.len() > 4096
95 || link
96 .translation
97 .iter()
98 .chain(link.rotation_deg.iter())
99 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
100 {
101 return Err("ongeldige botkoppeling".into());
102 }
103 if !rigs[sex]
104 .as_ref()
105 .is_some_and(|rig| rig.paths.contains(&link.target))
106 {
107 return Err("botkoppeling verwijst naar onbekend bot".into());
108 }
109 }
110 }
111 Ok(())
112 }
113
114 /// Model matrix per equipment bone for the current pose.
115 pub fn bone_models(
116 skeleton: &Skeleton,
117 links: &[BoneLink],
118 paths: &[String],
119 world_of: impl Fn(usize) -> Option<Mat4>,
120 ) -> Result<Vec<Mat4>, String> {
121 if links.is_empty() {
122 return Err("koppel minstens één equipmentbot aan een karakterbot".into());
123 }
124 let mut anchors = Vec::with_capacity(links.len());
125 for link in links {
126 let joint = paths
127 .iter()
128 .position(|p| p == &link.target)
129 .ok_or("het gekoppelde bot ontbreekt")?;
130 let world = world_of(joint).ok_or("het gekoppelde bot ontbreekt")?;
131 let rest = skeleton
132 .bones
133 .get(link.bone)
134 .ok_or("ongeldige botkoppeling")?
135 .rest_world;
136 anchors.push(mul(
137 mul(world, local_matrix(link.translation, link.rotation_deg)),
138 inverse(rest),
139 ));
140 }
141 Ok((0..skeleton.bones.len())
142 .map(|bone| {
143 let anchor = nearest_link(skeleton, links, bone).unwrap_or(0);
144 mul(anchors[anchor], skeleton.bones[bone].rest_world)
145 })
146 .collect())
147 }
148
149 /// Index into `links` of the bone itself or its nearest linked ancestor.
150 pub fn nearest_link(skeleton: &Skeleton, links: &[BoneLink], bone: usize) -> Option<usize> {
151 let mut cursor = Some(bone);
152 let mut steps = 0;
153 while let Some(b) = cursor {
154 if let Some(i) = links.iter().position(|l| l.bone == b) {
155 return Some(i);
156 }
157 steps += 1;
158 if steps > skeleton.bones.len() {
159 return None;
160 }
161 cursor = skeleton.bones.get(b)?.parent;
162 }
163 None
164 }
165
166 /// Import a skinned glTF/GLB/FBX as one skinned part.
167 pub fn import_skinned(preset: &mut Preset, source: &Path) -> Result<usize, String> {
168 let model = crate::preview::load_body_file(source)?;
169 if model.is_static() || model.joint_nodes.is_empty() {
170 return Err(NOT_SKINNED.into());
171 }
172 let label = source
173 .file_name()
174 .and_then(|n| n.to_str())
175 .ok_or("ongeldige bestandsnaam")?
176 .to_string();
177 let part = skinned_part(&model)?;
178 let mut candidate = preset.clone();
179 let (meshes, assets) = part;
180 let base = candidate.assets.len();
181 let mut source_assets = Vec::new();
182 for (i, asset) in assets.into_iter().enumerate() {
183 let mut path = format!("{}/texture{i}.png", sanitize(&label));
184 while candidate.assets.iter().any(|a| a.path == path) {
185 path = format!("_{path}");
186 }
187 source_assets.push(base + i);
188 candidate.assets.push(EmbeddedAsset { path, bytes: asset });
189 }
190 let meshes = meshes
191 .into_iter()
192 .map(|mut m| {
193 m.albedo = m.albedo.map(|i| base + i);
194 m
195 })
196 .collect();
197 candidate.attachments.push(Attachment {
198 label,
199 source_assets,
200 bones: [None, None],
201 translation: [0.; 3],
202 rotation_deg: [0.; 3],
203 meshes,
204 shiny: None,
205 specular: 0.25,
206 skeleton: Some(skeleton_of(&model)),
207 bone_links: [Vec::new(), Vec::new()],
208 });
209 candidate.validate()?;
210 *preset = candidate;
211 Ok(preset.attachments.len() - 1)
212 }
213
214 fn sanitize(label: &str) -> String {
215 label
216 .chars()
217 .map(|c| {
218 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
219 c
220 } else {
221 '_'
222 }
223 })
224 .collect()
225 }
226
227 pub fn skeleton_of(model: &PreviewCharacter) -> Skeleton {
228 let bones = (0..model.joint_nodes.len())
229 .map(|j| {
230 let mut cursor = model.nodes[model.joint_nodes[j]].parent;
231 let mut parent = None;
232 let mut steps = 0;
233 while let Some(node) = cursor {
234 if let Some(p) = model.joint_nodes.iter().position(|n| *n == node) {
235 parent = Some(p);
236 break;
237 }
238 steps += 1;
239 if steps > model.nodes.len() {
240 break;
241 }
242 cursor = model.nodes[node].parent;
243 }
244 EquipBone {
245 name: model.joint_names.get(j).cloned().unwrap_or_default(),
246 parent,
247 rest_world: rest_world(model, j),
248 }
249 })
250 .collect();
251 Skeleton { bones }
252 }
253
254 fn rest_world(model: &PreviewCharacter, joint: usize) -> Mat4 {
255 animated_joint_world(model, "", 0.0, joint).unwrap_or_else(crate::preview::identity)
256 }
257
258 type PartData = (Vec<Mesh>, Vec<Vec<u8>>);
259
260 /// Rest-pose skinning, split per surface and dominant bone, vertices in bone space.
261 fn skinned_part(model: &PreviewCharacter) -> Result<PartData, String> {
262 let n = model.positions.len();
263 if n == 0 || n > crate::equipment::MAX_VERTICES {
264 return Err("het equipmentmodel heeft geen of te veel vertices".into());
265 }
266 if model.joint_nodes.len() > MAX_BONES {
267 return Err("te veel botten in het equipmentmodel".into());
268 }
269 let rests: Vec<Mat4> = (0..model.joint_nodes.len())
270 .map(|j| rest_world(model, j))
271 .collect();
272 let to_bone: Vec<Mat4> = rests.iter().map(|m| inverse(*m)).collect();
273 let skins: Vec<Mat4> = rests
274 .iter()
275 .zip(&model.inverse_binds)
276 .map(|(r, ib)| mul(*r, *ib))
277 .collect();
278 let mut rest_pos = Vec::with_capacity(n);
279 let mut rest_nrm = Vec::with_capacity(n);
280 let mut dominant = Vec::with_capacity(n);
281 for v in 0..n {
282 let joints = model.joints.get(v).copied().unwrap_or([0; 4]);
283 let weights = model.weights.get(v).copied().unwrap_or([1., 0., 0., 0.]);
284 let mut m = [0f32; 16];
285 let mut total = 0.;
286 for (j, w) in joints.iter().zip(weights) {
287 let skin = skins.get(*j as usize).ok_or("ongeldige skinindex")?;
288 for k in 0..16 {
289 m[k] += skin[k] * w;
290 }
291 total += w;
292 }
293 if total <= 1e-6 {
294 m = skins[0];
295 }
296 rest_pos.push(apply(&m, model.positions[v], 1.));
297 rest_nrm.push(apply(
298 &m,
299 model.normals.get(v).copied().unwrap_or([0., 1., 0.]),
300 0.,
301 ));
302 let best = (0..4)
303 .max_by(|a, b| weights[*a].total_cmp(&weights[*b]))
304 .unwrap_or(0);
305 dominant.push(joints[best] as usize);
306 }
307 // Textures: PNG-encode each distinct decoded albedo once.
308 let mut assets: Vec<Vec<u8>> = Vec::new();
309 let mut hashes: Vec<u64> = Vec::new();
310 let mut meshes = Vec::new();
311 for surface in &model.surfaces {
312 let albedo = match &surface.albedo {
313 Some(texture) => Some(
314 match hashes.iter().position(|h| *h == texture.content_hash) {
315 Some(i) => i,
316 None => {
317 assets.push(encode_png(texture)?);
318 hashes.push(texture.content_hash);
319 assets.len() - 1
320 }
321 },
322 ),
323 None => None,
324 };
325 let indices = model
326 .indices
327 .get(surface.first..surface.first + surface.count)
328 .ok_or("ongeldige surface")?;
329 let mut groups: std::collections::BTreeMap<usize, Vec<u32>> = Default::default();
330 for t in indices.chunks_exact(3) {
331 // The triangle goes to the most common dominant bone of its corners.
332 let d = [0, 1, 2].map(|k| dominant[t[k] as usize]);
333 let bone = if d[1] == d[2] { d[1] } else { d[0] };
334 groups.entry(bone).or_default().extend_from_slice(t);
335 }
336 for (bone, tri) in groups {
337 let mut remap = std::collections::HashMap::new();
338 let mut mesh = Mesh {
339 positions: Vec::new(),
340 normals: Vec::new(),
341 uvs: Vec::new(),
342 indices: Vec::new(),
343 color: surface.color,
344 albedo,
345 bone: Some(bone),
346 };
347 for v in tri {
348 let next = mesh.positions.len() as u32;
349 let index = *remap.entry(v).or_insert_with(|| {
350 let v = v as usize;
351 mesh.positions.push(apply(&to_bone[bone], rest_pos[v], 1.));
352 let nv = apply(&to_bone[bone], rest_nrm[v], 0.);
353 let l = (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2])
354 .sqrt()
355 .max(1e-9);
356 mesh.normals.push(nv.map(|x| x / l));
357 // Equipment meshes use the OBJ convention (origin bottom-left).
358 let uv = model.uvs.get(v).copied().unwrap_or([0., 0.]);
359 mesh.uvs.push([uv[0], 1. - uv[1]]);
360 next
361 });
362 mesh.indices.push(index);
363 }
364 meshes.push(mesh);
365 }
366 }
367 if meshes.is_empty() {
368 return Err("het equipmentmodel heeft geen tekenbare meshes".into());
369 }
370 Ok((meshes, assets))
371 }
372
373 fn encode_png(texture: &crate::equipment::Texture) -> Result<Vec<u8>, String> {
374 let mut out = Vec::new();
375 {
376 let mut encoder = png::Encoder::new(&mut out, texture.width, texture.height);
377 encoder.set_color(png::ColorType::Rgba);
378 encoder.set_depth(png::BitDepth::Eight);
379 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
380 writer
381 .write_image_data(&texture.rgba)
382 .map_err(|e| e.to_string())?;
383 }
384 Ok(out)
385 }
386
387 fn apply(m: &Mat4, p: [f32; 3], w: f32) -> [f32; 3] {
388 [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i] * w)
389 }
390
391 /// General 4x4 inverse (column-major), Gauss-Jordan in f64.
392 pub fn inverse(m: Mat4) -> Mat4 {
393 let mut a = [[0f64; 8]; 4];
394 for r in 0..4 {
395 for c in 0..4 {
396 a[r][c] = m[c * 4 + r] as f64;
397 }
398 a[r][4 + r] = 1.;
399 }
400 for c in 0..4 {
401 let p = (c..4)
402 .max_by(|&x, &y| a[x][c].abs().total_cmp(&a[y][c].abs()))
403 .unwrap_or(c);
404 a.swap(c, p);
405 let d = a[c][c];
406 if d.abs() < 1e-12 {
407 return crate::preview::identity();
408 }
409 for value in a[c].iter_mut() {
410 *value /= d;
411 }
412 for r in 0..4 {
413 if r != c {
414 let f = a[r][c];
415 let pivot = a[c];
416 for (value, p) in a[r].iter_mut().zip(pivot) {
417 *value -= f * p;
418 }
419 }
420 }
421 }
422 let mut out = [0f32; 16];
423 for r in 0..4 {
424 for c in 0..4 {
425 out[c * 4 + r] = a[r][4 + c] as f32;
426 }
427 }
428 out
429 }
430
431 /// Translation and Rz·Ry·Rx degrees of a rigid matrix (inverse of `local_matrix`).
432 pub fn decompose(m: &Mat4) -> ([f32; 3], [f32; 3]) {
433 let col = |c: usize| {
434 let v = [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]];
435 let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
436 v.map(|x| x / l)
437 };
438 let (c0, c1, c2) = (col(0), col(1), col(2));
439 let (r00, r10, r20) = (c0[0], c0[1], c0[2]);
440 let (r21, r22) = (c1[2], c2[2]);
441 let y = (-r20).clamp(-1., 1.).asin();
442 let (x, z) = if y.cos().abs() > 1e-5 {
443 (r21.atan2(r22), r10.atan2(r00))
444 } else {
445 (0., (-c1[0]).atan2(c1[1]))
446 };
447 let clean = |v: f32| if v.abs() < 1e-5 { 0. } else { v };
448 (
449 [m[12], m[13], m[14]].map(clean),
450 [x, y, z].map(|a| clean(a.to_degrees())),
451 )
452 }
453
454 /// Link equipment `bone` of `part` to character `joint` (or unlink with `None`),
455 /// with the offset that keeps the bone at its rest placement.
456 pub fn set_link(
457 preset: &mut Preset,
458 part: usize,
459 sex: usize,
460 body: &PreviewCharacter,
461 bone: usize,
462 joint: Option<usize>,
463 ) -> Result<(), String> {
464 if sex > 1 {
465 return Err("ongeldig geslacht".into());
466 }
467 let signature = RigSignature::of(body);
468 let paths = joint_paths(body);
469 let item = preset
470 .attachments
471 .get_mut(part)
472 .ok_or("dit onderdeel bestaat niet")?;
473 let skeleton = item
474 .skeleton
475 .as_ref()
476 .ok_or("dit onderdeel heeft geen skelet")?;
477 let rest = skeleton
478 .bones
479 .get(bone)
480 .ok_or("dit equipmentbot bestaat niet")?
481 .rest_world;
482 let links = &mut item.bone_links[sex];
483 links.retain(|l| l.bone != bone);
484 if let Some(joint) = joint {
485 let target = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();
486 let char_rest = signature.rest_world[joint];
487 let (translation, rotation_deg) = decompose(&mul(inverse(char_rest), rest));
488 links.push(BoneLink {
489 bone,
490 target,
491 translation,
492 rotation_deg,
493 });
494 links.sort_by_key(|l| l.bone);
495 }
496 // Keep the rig signature current; drop links to bones of an older rig.
497 if preset.rigs[sex].as_ref() != Some(&signature) {
498 preset.rigs[sex] = Some(signature);
499 for a in &mut preset.attachments {
500 a.bone_links[sex].retain(|l| paths.contains(&l.target));
501 if a.bones[sex].as_ref().is_some_and(|p| !paths.contains(p)) {
502 a.bones[sex] = None;
503 }
504 }
505 }
506 Ok(())
507 }
508
509 /// Fill unlinked equipment bones by name; returns how many links were added.
510 pub fn auto_link(
511 preset: &mut Preset,
512 part: usize,
513 sex: usize,
514 body: &PreviewCharacter,
515 ) -> Result<usize, String> {
516 let names: Vec<String> = preset
517 .attachments
518 .get(part)
519 .and_then(|a| a.skeleton.as_ref())
520 .ok_or("dit onderdeel heeft geen skelet")?
521 .bones
522 .iter()
523 .map(|b| b.name.clone())
524 .collect();
525 let mut added = 0;
526 for (bone, name) in names.iter().enumerate() {
527 if preset.attachments[part].bone_links[sex]
528 .iter()
529 .any(|l| l.bone == bone)
530 {
531 continue;
532 }
533 if let Some(joint) = best_match(name, &body.joint_names) {
534 set_link(preset, part, sex, body, bone, Some(joint))?;
535 added += 1;
536 }
537 }
538 Ok(added)
539 }
540
541 /// Best character bone for an equipment bone name (see design.md t1027u).
542 pub fn best_match(name: &str, candidates: &[String]) -> Option<usize> {
543 let mut best: Option<(u8, usize)> = None;
544 for (i, candidate) in candidates.iter().enumerate() {
545 let score = match_score(name, candidate);
546 if score > 0 && best.is_none_or(|(s, _)| score > s) {
547 best = Some((score, i));
548 }
549 }
550 best.map(|(_, i)| i)
551 }
552
553 /// 3 exact (case-insensitive), 2 equal after normalising, 1 partial, 0 none.
554 pub fn match_score(a: &str, b: &str) -> u8 {
555 if a.eq_ignore_ascii_case(b) {
556 return 3;
557 }
558 let (ca, sa) = normalize(a);
559 let (cb, sb) = normalize(b);
560 if ca.is_empty() || cb.is_empty() || sa != sb {
561 return 0;
562 }
563 if ca == cb {
564 return 2;
565 }
566 if ca.len().min(cb.len()) >= 3 && (ca.contains(&cb) || cb.contains(&ca)) {
567 return 1;
568 }
569 0
570 }
571
572 /// Normalised core name and side tag (`Some('l')`/`Some('r')`).
573 pub fn normalize(name: &str) -> (String, Option<char>) {
574 let mut rest = name;
575 if let Some(i) = rest.rfind([':', '|']) {
576 rest = &rest[i + 1..];
577 }
578 let lower = rest.to_ascii_lowercase();
579 for prefix in ["mixamorig", "bip001", "bip01", "def-", "def_", "def."] {
580 if lower.starts_with(prefix) {
581 rest = &rest[prefix.len()..];
582 break;
583 }
584 }
585 // Tokens on separators, camelCase and letter/digit boundaries.
586 let mut tokens: Vec<String> = Vec::new();
587 let mut current = String::new();
588 let mut previous: Option<char> = None;
589 for ch in rest.chars() {
590 if !ch.is_ascii_alphanumeric() {
591 if !current.is_empty() {
592 tokens.push(std::mem::take(&mut current));
593 }
594 previous = None;
595 continue;
596 }
597 if let Some(p) = previous {
598 let boundary = (p.is_ascii_lowercase() && ch.is_ascii_uppercase())
599 || (p.is_ascii_alphabetic() && ch.is_ascii_digit())
600 || (p.is_ascii_digit() && ch.is_ascii_alphabetic());
601 if boundary && !current.is_empty() {
602 tokens.push(std::mem::take(&mut current));
603 }
604 }
605 current.push(ch.to_ascii_lowercase());
606 previous = Some(ch);
607 }
608 if !current.is_empty() {
609 tokens.push(current);
610 }
611 while tokens
612 .last()
613 .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit()))
614 {
615 tokens.pop();
616 }
617 let side_of = |t: &str| match t {
618 "l" | "left" => Some('l'),
619 "r" | "right" => Some('r'),
620 _ => None,
621 };
622 let mut side = None;
623 if tokens.len() > 1 {
624 if let Some(s) = side_of(&tokens[0]) {
625 side = Some(s);
626 tokens.remove(0);
627 } else if let Some(s) = side_of(tokens.last().unwrap()) {
628 side = Some(s);
629 tokens.pop();
630 }
631 }
632 while tokens
633 .last()
634 .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit()))
635 {
636 tokens.pop();
637 }
638 (tokens.concat(), side)
639 }
640
641 #[cfg(test)]
642 mod tests {
643 use super::*;
644
645 #[test]
646 fn names_normalise_prefixes_sides_and_numeric_suffixes() {
647 assert_eq!(normalize("mixamorig:LeftArm"), ("arm".into(), Some('l')));
648 assert_eq!(normalize("Armature|Arm.L"), ("arm".into(), Some('l')));
649 assert_eq!(normalize("hand_r.001"), ("hand".into(), Some('r')));
650 assert_eq!(
651 normalize("Bip01 R UpperArm"),
652 ("upperarm".into(), Some('r'))
653 );
654 assert_eq!(normalize("DEF-spine.003"), ("spine".into(), None));
655 assert_eq!(normalize("Head"), ("head".into(), None));
656 assert_eq!(
657 normalize("RightHandIndex1"),
658 ("handindex".into(), Some('r'))
659 );
660 }
661
662 #[test]
663 fn match_prefers_exact_then_normalised_then_partial_and_respects_sides() {
664 let body: Vec<String> = [
665 "Hips",
666 "Spine",
667 "Spine1",
668 "LeftUpLeg",
669 "RightUpLeg",
670 "LeftHand",
671 "RightHand",
672 "Head",
673 ]
674 .map(String::from)
675 .to_vec();
676 assert_eq!(best_match("Spine1", &body), Some(2)); // exact
677 assert_eq!(best_match("mixamorig:Spine1", &body), Some(1)); // normalised: first "spine"
678 assert_eq!(best_match("hand.R", &body), Some(6));
679 assert_eq!(best_match("Armature|hand_l", &body), Some(5));
680 assert_eq!(best_match("upleg.L", &body), Some(3));
681 assert_eq!(best_match("head_end", &body), Some(7)); // partial
682 assert_eq!(best_match("tail", &body), None);
683 assert_eq!(match_score("LeftHand", "RightHand"), 0);
684 }
685
686 #[test]
687 fn decompose_inverts_local_matrix() {
688 for (t, r) in [
689 ([0.1, -0.37, 2.0], [10.0, 20.0, 30.0]),
690 ([0.0, 0.0, 0.0], [-45.0, 80.0, 170.0]),
691 ([1.0, 2.0, 3.0], [0.0, 0.0, 0.0]),
692 ] {
693 let m = local_matrix(t, r);
694 let (t2, r2) = decompose(&m);
695 let m2 = local_matrix(t2, r2);
696 for k in 0..16 {
697 assert!((m[k] - m2[k]).abs() < 1e-4, "{t:?} {r:?} -> {t2:?} {r2:?}");
698 }
699 }
700 }
701
702 #[test]
703 fn unlinked_bones_follow_nearest_linked_ancestor_then_first_link() {
704 let id = crate::preview::identity();
705 let at = |x: f32| local_matrix([x, 0., 0.], [0.; 3]);
706 let skeleton = Skeleton {
707 bones: vec![
708 EquipBone {
709 name: "root".into(),
710 parent: None,
711 rest_world: id,
712 },
713 EquipBone {
714 name: "a".into(),
715 parent: Some(0),
716 rest_world: at(1.),
717 },
718 EquipBone {
719 name: "b".into(),
720 parent: Some(1),
721 rest_world: at(2.),
722 },
723 EquipBone {
724 name: "c".into(),
725 parent: Some(0),
726 rest_world: at(3.),
727 },
728 ],
729 };
730 let links = vec![BoneLink {
731 bone: 1,
732 target: "j".into(),
733 translation: [0.; 3],
734 rotation_deg: [0.; 3],
735 }];
736 assert_eq!(nearest_link(&skeleton, &links, 2), Some(0));
737 assert_eq!(nearest_link(&skeleton, &links, 3), None);
738 let paths = vec!["j".to_string()];
739 let models = bone_models(&skeleton, &links, &paths, |_| Some(at(10.))).unwrap();
740 // Linked bone sits on the joint; its child keeps its rest offset (+1).
741 assert!((models[1][12] - 10.).abs() < 1e-5);
742 assert!((models[2][12] - 11.).abs() < 1e-5);
743 assert!(bone_models(&skeleton, &[], &paths, |_| Some(id)).is_err());
744 }
745 }
@@ -0,0 +1,745 @@
1//! Equipment with its own skeleton (Tim t1027u).
2//!
3//! A skinned equipment model is stored rigid per bone: every vertex belongs to
4//! its dominant skin bone and is kept in that bone's rest space. Per sex, the
5//! equipment bones are linked to character bones; each link has its own
6//! position and rotation relative to the character bone. Unlinked bones follow
7//! their nearest linked ancestor (or the first link) rigidly.
8
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::equipment::{local_matrix, Attachment, EmbeddedAsset, Mesh, Preset, RigSignature};
14use crate::preview::{animated_joint_world, joint_paths, mul, Mat4, PreviewCharacter};
15
16/// Error of [`import_skinned`] for a model without a skin.
17pub const NOT_SKINNED: &str =
18 "het model heeft geen skelet; kies OBJ of FBX voor een rigide onderdeel";
19
20const MAX_BONES: usize = 256;
21const MAX_SKINNED_MESHES: usize = 1024;
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct EquipBone {
26 pub name: String,
27 pub parent: Option<usize>,
28 /// Rest-pose world matrix in the equipment model's space.
29 pub rest_world: Mat4,
30}
31
32#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Skeleton {
35 pub bones: Vec<EquipBone>,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct BoneLink {
41 /// Equipment bone index.
42 pub bone: usize,
43 /// Character joint path (as in [`joint_paths`]).
44 pub target: String,
45 pub translation: [f32; 3],
46 pub rotation_deg: [f32; 3],
47}
48
49pub fn links_empty(links: &[Vec<BoneLink>; 2]) -> bool {
50 links.iter().all(Vec::is_empty)
51}
52
53/// Structural checks for the skinned fields of one part.
54pub(crate) fn validate_part(
55 part: &Attachment,
56 rigs: &[Option<RigSignature>; 2],
57) -> Result<(), String> {
58 let Some(skeleton) = &part.skeleton else {
59 if !links_empty(&part.bone_links) || part.meshes.iter().any(|m| m.bone.is_some()) {
60 return Err("botkoppeling zonder skelet".into());
61 }
62 return Ok(());
63 };
64 let n = skeleton.bones.len();
65 if n == 0 || n > MAX_BONES || part.meshes.len() > MAX_SKINNED_MESHES {
66 return Err("ongeldig equipmentskelet".into());
67 }
68 for (i, bone) in skeleton.bones.iter().enumerate() {
69 if bone.name.len() > 256
70 || bone.rest_world.iter().any(|x| !x.is_finite())
71 || bone.parent.is_some_and(|p| p >= n || p == i)
72 {
73 return Err("ongeldig equipmentskelet".into());
74 }
75 // Acyclic: a parent walk ends within n steps.
76 let mut cursor = bone.parent;
77 let mut steps = 0;
78 while let Some(p) = cursor {
79 steps += 1;
80 if steps > n {
81 return Err("equipmentskelet bevat een lus".into());
82 }
83 cursor = skeleton.bones[p].parent;
84 }
85 }
86 if part.meshes.iter().any(|m| m.bone.is_none_or(|b| b >= n)) {
87 return Err("mesh verwijst naar onbekend equipmentbot".into());
88 }
89 for (sex, links) in part.bone_links.iter().enumerate() {
90 let mut seen = std::collections::BTreeSet::new();
91 for link in links {
92 if link.bone >= n
93 || !seen.insert(link.bone)
94 || link.target.len() > 4096
95 || link
96 .translation
97 .iter()
98 .chain(link.rotation_deg.iter())
99 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
100 {
101 return Err("ongeldige botkoppeling".into());
102 }
103 if !rigs[sex]
104 .as_ref()
105 .is_some_and(|rig| rig.paths.contains(&link.target))
106 {
107 return Err("botkoppeling verwijst naar onbekend bot".into());
108 }
109 }
110 }
111 Ok(())
112}
113
114/// Model matrix per equipment bone for the current pose.
115pub fn bone_models(
116 skeleton: &Skeleton,
117 links: &[BoneLink],
118 paths: &[String],
119 world_of: impl Fn(usize) -> Option<Mat4>,
120) -> Result<Vec<Mat4>, String> {
121 if links.is_empty() {
122 return Err("koppel minstens één equipmentbot aan een karakterbot".into());
123 }
124 let mut anchors = Vec::with_capacity(links.len());
125 for link in links {
126 let joint = paths
127 .iter()
128 .position(|p| p == &link.target)
129 .ok_or("het gekoppelde bot ontbreekt")?;
130 let world = world_of(joint).ok_or("het gekoppelde bot ontbreekt")?;
131 let rest = skeleton
132 .bones
133 .get(link.bone)
134 .ok_or("ongeldige botkoppeling")?
135 .rest_world;
136 anchors.push(mul(
137 mul(world, local_matrix(link.translation, link.rotation_deg)),
138 inverse(rest),
139 ));
140 }
141 Ok((0..skeleton.bones.len())
142 .map(|bone| {
143 let anchor = nearest_link(skeleton, links, bone).unwrap_or(0);
144 mul(anchors[anchor], skeleton.bones[bone].rest_world)
145 })
146 .collect())
147}
148
149/// Index into `links` of the bone itself or its nearest linked ancestor.
150pub fn nearest_link(skeleton: &Skeleton, links: &[BoneLink], bone: usize) -> Option<usize> {
151 let mut cursor = Some(bone);
152 let mut steps = 0;
153 while let Some(b) = cursor {
154 if let Some(i) = links.iter().position(|l| l.bone == b) {
155 return Some(i);
156 }
157 steps += 1;
158 if steps > skeleton.bones.len() {
159 return None;
160 }
161 cursor = skeleton.bones.get(b)?.parent;
162 }
163 None
164}
165
166/// Import a skinned glTF/GLB/FBX as one skinned part.
167pub fn import_skinned(preset: &mut Preset, source: &Path) -> Result<usize, String> {
168 let model = crate::preview::load_body_file(source)?;
169 if model.is_static() || model.joint_nodes.is_empty() {
170 return Err(NOT_SKINNED.into());
171 }
172 let label = source
173 .file_name()
174 .and_then(|n| n.to_str())
175 .ok_or("ongeldige bestandsnaam")?
176 .to_string();
177 let part = skinned_part(&model)?;
178 let mut candidate = preset.clone();
179 let (meshes, assets) = part;
180 let base = candidate.assets.len();
181 let mut source_assets = Vec::new();
182 for (i, asset) in assets.into_iter().enumerate() {
183 let mut path = format!("{}/texture{i}.png", sanitize(&label));
184 while candidate.assets.iter().any(|a| a.path == path) {
185 path = format!("_{path}");
186 }
187 source_assets.push(base + i);
188 candidate.assets.push(EmbeddedAsset { path, bytes: asset });
189 }
190 let meshes = meshes
191 .into_iter()
192 .map(|mut m| {
193 m.albedo = m.albedo.map(|i| base + i);
194 m
195 })
196 .collect();
197 candidate.attachments.push(Attachment {
198 label,
199 source_assets,
200 bones: [None, None],
201 translation: [0.; 3],
202 rotation_deg: [0.; 3],
203 meshes,
204 shiny: None,
205 specular: 0.25,
206 skeleton: Some(skeleton_of(&model)),
207 bone_links: [Vec::new(), Vec::new()],
208 });
209 candidate.validate()?;
210 *preset = candidate;
211 Ok(preset.attachments.len() - 1)
212}
213
214fn sanitize(label: &str) -> String {
215 label
216 .chars()
217 .map(|c| {
218 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
219 c
220 } else {
221 '_'
222 }
223 })
224 .collect()
225}
226
227pub fn skeleton_of(model: &PreviewCharacter) -> Skeleton {
228 let bones = (0..model.joint_nodes.len())
229 .map(|j| {
230 let mut cursor = model.nodes[model.joint_nodes[j]].parent;
231 let mut parent = None;
232 let mut steps = 0;
233 while let Some(node) = cursor {
234 if let Some(p) = model.joint_nodes.iter().position(|n| *n == node) {
235 parent = Some(p);
236 break;
237 }
238 steps += 1;
239 if steps > model.nodes.len() {
240 break;
241 }
242 cursor = model.nodes[node].parent;
243 }
244 EquipBone {
245 name: model.joint_names.get(j).cloned().unwrap_or_default(),
246 parent,
247 rest_world: rest_world(model, j),
248 }
249 })
250 .collect();
251 Skeleton { bones }
252}
253
254fn rest_world(model: &PreviewCharacter, joint: usize) -> Mat4 {
255 animated_joint_world(model, "", 0.0, joint).unwrap_or_else(crate::preview::identity)
256}
257
258type PartData = (Vec<Mesh>, Vec<Vec<u8>>);
259
260/// Rest-pose skinning, split per surface and dominant bone, vertices in bone space.
261fn skinned_part(model: &PreviewCharacter) -> Result<PartData, String> {
262 let n = model.positions.len();
263 if n == 0 || n > crate::equipment::MAX_VERTICES {
264 return Err("het equipmentmodel heeft geen of te veel vertices".into());
265 }
266 if model.joint_nodes.len() > MAX_BONES {
267 return Err("te veel botten in het equipmentmodel".into());
268 }
269 let rests: Vec<Mat4> = (0..model.joint_nodes.len())
270 .map(|j| rest_world(model, j))
271 .collect();
272 let to_bone: Vec<Mat4> = rests.iter().map(|m| inverse(*m)).collect();
273 let skins: Vec<Mat4> = rests
274 .iter()
275 .zip(&model.inverse_binds)
276 .map(|(r, ib)| mul(*r, *ib))
277 .collect();
278 let mut rest_pos = Vec::with_capacity(n);
279 let mut rest_nrm = Vec::with_capacity(n);
280 let mut dominant = Vec::with_capacity(n);
281 for v in 0..n {
282 let joints = model.joints.get(v).copied().unwrap_or([0; 4]);
283 let weights = model.weights.get(v).copied().unwrap_or([1., 0., 0., 0.]);
284 let mut m = [0f32; 16];
285 let mut total = 0.;
286 for (j, w) in joints.iter().zip(weights) {
287 let skin = skins.get(*j as usize).ok_or("ongeldige skinindex")?;
288 for k in 0..16 {
289 m[k] += skin[k] * w;
290 }
291 total += w;
292 }
293 if total <= 1e-6 {
294 m = skins[0];
295 }
296 rest_pos.push(apply(&m, model.positions[v], 1.));
297 rest_nrm.push(apply(
298 &m,
299 model.normals.get(v).copied().unwrap_or([0., 1., 0.]),
300 0.,
301 ));
302 let best = (0..4)
303 .max_by(|a, b| weights[*a].total_cmp(&weights[*b]))
304 .unwrap_or(0);
305 dominant.push(joints[best] as usize);
306 }
307 // Textures: PNG-encode each distinct decoded albedo once.
308 let mut assets: Vec<Vec<u8>> = Vec::new();
309 let mut hashes: Vec<u64> = Vec::new();
310 let mut meshes = Vec::new();
311 for surface in &model.surfaces {
312 let albedo = match &surface.albedo {
313 Some(texture) => Some(
314 match hashes.iter().position(|h| *h == texture.content_hash) {
315 Some(i) => i,
316 None => {
317 assets.push(encode_png(texture)?);
318 hashes.push(texture.content_hash);
319 assets.len() - 1
320 }
321 },
322 ),
323 None => None,
324 };
325 let indices = model
326 .indices
327 .get(surface.first..surface.first + surface.count)
328 .ok_or("ongeldige surface")?;
329 let mut groups: std::collections::BTreeMap<usize, Vec<u32>> = Default::default();
330 for t in indices.chunks_exact(3) {
331 // The triangle goes to the most common dominant bone of its corners.
332 let d = [0, 1, 2].map(|k| dominant[t[k] as usize]);
333 let bone = if d[1] == d[2] { d[1] } else { d[0] };
334 groups.entry(bone).or_default().extend_from_slice(t);
335 }
336 for (bone, tri) in groups {
337 let mut remap = std::collections::HashMap::new();
338 let mut mesh = Mesh {
339 positions: Vec::new(),
340 normals: Vec::new(),
341 uvs: Vec::new(),
342 indices: Vec::new(),
343 color: surface.color,
344 albedo,
345 bone: Some(bone),
346 };
347 for v in tri {
348 let next = mesh.positions.len() as u32;
349 let index = *remap.entry(v).or_insert_with(|| {
350 let v = v as usize;
351 mesh.positions.push(apply(&to_bone[bone], rest_pos[v], 1.));
352 let nv = apply(&to_bone[bone], rest_nrm[v], 0.);
353 let l = (nv[0] * nv[0] + nv[1] * nv[1] + nv[2] * nv[2])
354 .sqrt()
355 .max(1e-9);
356 mesh.normals.push(nv.map(|x| x / l));
357 // Equipment meshes use the OBJ convention (origin bottom-left).
358 let uv = model.uvs.get(v).copied().unwrap_or([0., 0.]);
359 mesh.uvs.push([uv[0], 1. - uv[1]]);
360 next
361 });
362 mesh.indices.push(index);
363 }
364 meshes.push(mesh);
365 }
366 }
367 if meshes.is_empty() {
368 return Err("het equipmentmodel heeft geen tekenbare meshes".into());
369 }
370 Ok((meshes, assets))
371}
372
373fn encode_png(texture: &crate::equipment::Texture) -> Result<Vec<u8>, String> {
374 let mut out = Vec::new();
375 {
376 let mut encoder = png::Encoder::new(&mut out, texture.width, texture.height);
377 encoder.set_color(png::ColorType::Rgba);
378 encoder.set_depth(png::BitDepth::Eight);
379 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
380 writer
381 .write_image_data(&texture.rgba)
382 .map_err(|e| e.to_string())?;
383 }
384 Ok(out)
385}
386
387fn apply(m: &Mat4, p: [f32; 3], w: f32) -> [f32; 3] {
388 [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i] * w)
389}
390
391/// General 4x4 inverse (column-major), Gauss-Jordan in f64.
392pub fn inverse(m: Mat4) -> Mat4 {
393 let mut a = [[0f64; 8]; 4];
394 for r in 0..4 {
395 for c in 0..4 {
396 a[r][c] = m[c * 4 + r] as f64;
397 }
398 a[r][4 + r] = 1.;
399 }
400 for c in 0..4 {
401 let p = (c..4)
402 .max_by(|&x, &y| a[x][c].abs().total_cmp(&a[y][c].abs()))
403 .unwrap_or(c);
404 a.swap(c, p);
405 let d = a[c][c];
406 if d.abs() < 1e-12 {
407 return crate::preview::identity();
408 }
409 for value in a[c].iter_mut() {
410 *value /= d;
411 }
412 for r in 0..4 {
413 if r != c {
414 let f = a[r][c];
415 let pivot = a[c];
416 for (value, p) in a[r].iter_mut().zip(pivot) {
417 *value -= f * p;
418 }
419 }
420 }
421 }
422 let mut out = [0f32; 16];
423 for r in 0..4 {
424 for c in 0..4 {
425 out[c * 4 + r] = a[r][4 + c] as f32;
426 }
427 }
428 out
429}
430
431/// Translation and Rz·Ry·Rx degrees of a rigid matrix (inverse of `local_matrix`).
432pub fn decompose(m: &Mat4) -> ([f32; 3], [f32; 3]) {
433 let col = |c: usize| {
434 let v = [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]];
435 let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
436 v.map(|x| x / l)
437 };
438 let (c0, c1, c2) = (col(0), col(1), col(2));
439 let (r00, r10, r20) = (c0[0], c0[1], c0[2]);
440 let (r21, r22) = (c1[2], c2[2]);
441 let y = (-r20).clamp(-1., 1.).asin();
442 let (x, z) = if y.cos().abs() > 1e-5 {
443 (r21.atan2(r22), r10.atan2(r00))
444 } else {
445 (0., (-c1[0]).atan2(c1[1]))
446 };
447 let clean = |v: f32| if v.abs() < 1e-5 { 0. } else { v };
448 (
449 [m[12], m[13], m[14]].map(clean),
450 [x, y, z].map(|a| clean(a.to_degrees())),
451 )
452}
453
454/// Link equipment `bone` of `part` to character `joint` (or unlink with `None`),
455/// with the offset that keeps the bone at its rest placement.
456pub fn set_link(
457 preset: &mut Preset,
458 part: usize,
459 sex: usize,
460 body: &PreviewCharacter,
461 bone: usize,
462 joint: Option<usize>,
463) -> Result<(), String> {
464 if sex > 1 {
465 return Err("ongeldig geslacht".into());
466 }
467 let signature = RigSignature::of(body);
468 let paths = joint_paths(body);
469 let item = preset
470 .attachments
471 .get_mut(part)
472 .ok_or("dit onderdeel bestaat niet")?;
473 let skeleton = item
474 .skeleton
475 .as_ref()
476 .ok_or("dit onderdeel heeft geen skelet")?;
477 let rest = skeleton
478 .bones
479 .get(bone)
480 .ok_or("dit equipmentbot bestaat niet")?
481 .rest_world;
482 let links = &mut item.bone_links[sex];
483 links.retain(|l| l.bone != bone);
484 if let Some(joint) = joint {
485 let target = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();
486 let char_rest = signature.rest_world[joint];
487 let (translation, rotation_deg) = decompose(&mul(inverse(char_rest), rest));
488 links.push(BoneLink {
489 bone,
490 target,
491 translation,
492 rotation_deg,
493 });
494 links.sort_by_key(|l| l.bone);
495 }
496 // Keep the rig signature current; drop links to bones of an older rig.
497 if preset.rigs[sex].as_ref() != Some(&signature) {
498 preset.rigs[sex] = Some(signature);
499 for a in &mut preset.attachments {
500 a.bone_links[sex].retain(|l| paths.contains(&l.target));
501 if a.bones[sex].as_ref().is_some_and(|p| !paths.contains(p)) {
502 a.bones[sex] = None;
503 }
504 }
505 }
506 Ok(())
507}
508
509/// Fill unlinked equipment bones by name; returns how many links were added.
510pub fn auto_link(
511 preset: &mut Preset,
512 part: usize,
513 sex: usize,
514 body: &PreviewCharacter,
515) -> Result<usize, String> {
516 let names: Vec<String> = preset
517 .attachments
518 .get(part)
519 .and_then(|a| a.skeleton.as_ref())
520 .ok_or("dit onderdeel heeft geen skelet")?
521 .bones
522 .iter()
523 .map(|b| b.name.clone())
524 .collect();
525 let mut added = 0;
526 for (bone, name) in names.iter().enumerate() {
527 if preset.attachments[part].bone_links[sex]
528 .iter()
529 .any(|l| l.bone == bone)
530 {
531 continue;
532 }
533 if let Some(joint) = best_match(name, &body.joint_names) {
534 set_link(preset, part, sex, body, bone, Some(joint))?;
535 added += 1;
536 }
537 }
538 Ok(added)
539}
540
541/// Best character bone for an equipment bone name (see design.md t1027u).
542pub fn best_match(name: &str, candidates: &[String]) -> Option<usize> {
543 let mut best: Option<(u8, usize)> = None;
544 for (i, candidate) in candidates.iter().enumerate() {
545 let score = match_score(name, candidate);
546 if score > 0 && best.is_none_or(|(s, _)| score > s) {
547 best = Some((score, i));
548 }
549 }
550 best.map(|(_, i)| i)
551}
552
553/// 3 exact (case-insensitive), 2 equal after normalising, 1 partial, 0 none.
554pub fn match_score(a: &str, b: &str) -> u8 {
555 if a.eq_ignore_ascii_case(b) {
556 return 3;
557 }
558 let (ca, sa) = normalize(a);
559 let (cb, sb) = normalize(b);
560 if ca.is_empty() || cb.is_empty() || sa != sb {
561 return 0;
562 }
563 if ca == cb {
564 return 2;
565 }
566 if ca.len().min(cb.len()) >= 3 && (ca.contains(&cb) || cb.contains(&ca)) {
567 return 1;
568 }
569 0
570}
571
572/// Normalised core name and side tag (`Some('l')`/`Some('r')`).
573pub fn normalize(name: &str) -> (String, Option<char>) {
574 let mut rest = name;
575 if let Some(i) = rest.rfind([':', '|']) {
576 rest = &rest[i + 1..];
577 }
578 let lower = rest.to_ascii_lowercase();
579 for prefix in ["mixamorig", "bip001", "bip01", "def-", "def_", "def."] {
580 if lower.starts_with(prefix) {
581 rest = &rest[prefix.len()..];
582 break;
583 }
584 }
585 // Tokens on separators, camelCase and letter/digit boundaries.
586 let mut tokens: Vec<String> = Vec::new();
587 let mut current = String::new();
588 let mut previous: Option<char> = None;
589 for ch in rest.chars() {
590 if !ch.is_ascii_alphanumeric() {
591 if !current.is_empty() {
592 tokens.push(std::mem::take(&mut current));
593 }
594 previous = None;
595 continue;
596 }
597 if let Some(p) = previous {
598 let boundary = (p.is_ascii_lowercase() && ch.is_ascii_uppercase())
599 || (p.is_ascii_alphabetic() && ch.is_ascii_digit())
600 || (p.is_ascii_digit() && ch.is_ascii_alphabetic());
601 if boundary && !current.is_empty() {
602 tokens.push(std::mem::take(&mut current));
603 }
604 }
605 current.push(ch.to_ascii_lowercase());
606 previous = Some(ch);
607 }
608 if !current.is_empty() {
609 tokens.push(current);
610 }
611 while tokens
612 .last()
613 .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit()))
614 {
615 tokens.pop();
616 }
617 let side_of = |t: &str| match t {
618 "l" | "left" => Some('l'),
619 "r" | "right" => Some('r'),
620 _ => None,
621 };
622 let mut side = None;
623 if tokens.len() > 1 {
624 if let Some(s) = side_of(&tokens[0]) {
625 side = Some(s);
626 tokens.remove(0);
627 } else if let Some(s) = side_of(tokens.last().unwrap()) {
628 side = Some(s);
629 tokens.pop();
630 }
631 }
632 while tokens
633 .last()
634 .is_some_and(|t| t.chars().all(|c| c.is_ascii_digit()))
635 {
636 tokens.pop();
637 }
638 (tokens.concat(), side)
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn names_normalise_prefixes_sides_and_numeric_suffixes() {
647 assert_eq!(normalize("mixamorig:LeftArm"), ("arm".into(), Some('l')));
648 assert_eq!(normalize("Armature|Arm.L"), ("arm".into(), Some('l')));
649 assert_eq!(normalize("hand_r.001"), ("hand".into(), Some('r')));
650 assert_eq!(
651 normalize("Bip01 R UpperArm"),
652 ("upperarm".into(), Some('r'))
653 );
654 assert_eq!(normalize("DEF-spine.003"), ("spine".into(), None));
655 assert_eq!(normalize("Head"), ("head".into(), None));
656 assert_eq!(
657 normalize("RightHandIndex1"),
658 ("handindex".into(), Some('r'))
659 );
660 }
661
662 #[test]
663 fn match_prefers_exact_then_normalised_then_partial_and_respects_sides() {
664 let body: Vec<String> = [
665 "Hips",
666 "Spine",
667 "Spine1",
668 "LeftUpLeg",
669 "RightUpLeg",
670 "LeftHand",
671 "RightHand",
672 "Head",
673 ]
674 .map(String::from)
675 .to_vec();
676 assert_eq!(best_match("Spine1", &body), Some(2)); // exact
677 assert_eq!(best_match("mixamorig:Spine1", &body), Some(1)); // normalised: first "spine"
678 assert_eq!(best_match("hand.R", &body), Some(6));
679 assert_eq!(best_match("Armature|hand_l", &body), Some(5));
680 assert_eq!(best_match("upleg.L", &body), Some(3));
681 assert_eq!(best_match("head_end", &body), Some(7)); // partial
682 assert_eq!(best_match("tail", &body), None);
683 assert_eq!(match_score("LeftHand", "RightHand"), 0);
684 }
685
686 #[test]
687 fn decompose_inverts_local_matrix() {
688 for (t, r) in [
689 ([0.1, -0.37, 2.0], [10.0, 20.0, 30.0]),
690 ([0.0, 0.0, 0.0], [-45.0, 80.0, 170.0]),
691 ([1.0, 2.0, 3.0], [0.0, 0.0, 0.0]),
692 ] {
693 let m = local_matrix(t, r);
694 let (t2, r2) = decompose(&m);
695 let m2 = local_matrix(t2, r2);
696 for k in 0..16 {
697 assert!((m[k] - m2[k]).abs() < 1e-4, "{t:?} {r:?} -> {t2:?} {r2:?}");
698 }
699 }
700 }
701
702 #[test]
703 fn unlinked_bones_follow_nearest_linked_ancestor_then_first_link() {
704 let id = crate::preview::identity();
705 let at = |x: f32| local_matrix([x, 0., 0.], [0.; 3]);
706 let skeleton = Skeleton {
707 bones: vec![
708 EquipBone {
709 name: "root".into(),
710 parent: None,
711 rest_world: id,
712 },
713 EquipBone {
714 name: "a".into(),
715 parent: Some(0),
716 rest_world: at(1.),
717 },
718 EquipBone {
719 name: "b".into(),
720 parent: Some(1),
721 rest_world: at(2.),
722 },
723 EquipBone {
724 name: "c".into(),
725 parent: Some(0),
726 rest_world: at(3.),
727 },
728 ],
729 };
730 let links = vec![BoneLink {
731 bone: 1,
732 target: "j".into(),
733 translation: [0.; 3],
734 rotation_deg: [0.; 3],
735 }];
736 assert_eq!(nearest_link(&skeleton, &links, 2), Some(0));
737 assert_eq!(nearest_link(&skeleton, &links, 3), None);
738 let paths = vec!["j".to_string()];
739 let models = bone_models(&skeleton, &links, &paths, |_| Some(at(10.))).unwrap();
740 // Linked bone sits on the joint; its child keeps its rest offset (+1).
741 assert!((models[1][12] - 10.).abs() < 1e-5);
742 assert!((models[2][12] - 11.).abs() < 1e-5);
743 assert!(bone_models(&skeleton, &[], &paths, |_| Some(id)).is_err());
744 }
745}
src/i18n_table.rs +26 −0
@@ -161,6 +161,32 @@ pub const TABLE: &[(&str, &str)] = &[
161 161 ("Glow-context ontbreekt", "Glow context missing"),
162 162 ("Batch opgeslagen: {path}", "Batch saved: {path}"),
163 163 ("Render klaar", "Render finished"),
164 ("Equipment toevoegen", "Add equipment"),
165 ("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model", "choose an OBJ (with MTL/PNG), FBX or skinned glTF/GLB model"),
166 ("Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling.", "Part with skeleton embedded; bones linked by name. Check the bone links."),
167 ("Onderdeel toevoegen (OBJ/FBX/glTF)", "Add part (OBJ/FBX/glTF)"),
168 ("het model heeft geen skelet; kies OBJ of FBX voor een rigide onderdeel", "the model has no skeleton; choose OBJ or FBX for a rigid part"),
169 ("botkoppeling zonder skelet", "bone links without a skeleton"),
170 ("ongeldig equipmentskelet", "invalid equipment skeleton"),
171 ("equipmentskelet bevat een lus", "equipment skeleton contains a cycle"),
172 ("mesh verwijst naar onbekend equipmentbot", "mesh refers to an unknown equipment bone"),
173 ("ongeldige botkoppeling", "invalid bone link"),
174 ("botkoppeling verwijst naar onbekend bot", "bone link refers to an unknown bone"),
175 ("koppel minstens één equipmentbot aan een karakterbot", "link at least one equipment bone to a character bone"),
176 ("het equipmentmodel heeft geen of te veel vertices", "the equipment model has no or too many vertices"),
177 ("te veel botten in het equipmentmodel", "too many bones in the equipment model"),
178 ("ongeldige skinindex", "invalid skin index"),
179 ("ongeldige surface", "invalid surface"),
180 ("het equipmentmodel heeft geen tekenbare meshes", "the equipment model has no drawable meshes"),
181 ("dit onderdeel heeft geen skelet", "this part has no skeleton"),
182 ("dit equipmentbot bestaat niet", "this equipment bone does not exist"),
183 ("Botkoppeling: kies per equipmentbot een karakterbot (minstens één). Niet-gekoppelde botten volgen hun dichtstbijzijnde gekoppelde ouder.", "Bone links: choose a character bone per equipment bone (at least one). Unlinked bones follow their nearest linked parent."),
184 ("Koppel minstens één equipmentbot.", "Link at least one equipment bone."),
185 ("Automatisch invullen op naam", "Auto-fill by name"),
186 ("volgt ouder", "follows parent"),
187 ("Positie", "Position"),
188 ("Rotatie°", "Rotation°"),
189 ("glTF/GLB", "glTF/GLB"),
164 190 ("Render niet gelukt", "Render did not succeed"),
165 191 ("De renderworker stopte zonder eindresultaat.", "The render worker stopped without a final result."),
166 192 ("Annuleer render", "Cancel render"),
@@ -161,6 +161,32 @@ pub const TABLE: &[(&str, &str)] = &[
161 ("Glow-context ontbreekt", "Glow context missing"),
161 ("Glow-context ontbreekt", "Glow context missing"),
162 ("Batch opgeslagen: {path}", "Batch saved: {path}"),
162 ("Batch opgeslagen: {path}", "Batch saved: {path}"),
163 ("Render klaar", "Render finished"),
163 ("Render klaar", "Render finished"),
164 ("Equipment toevoegen", "Add equipment"),
165 ("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model", "choose an OBJ (with MTL/PNG), FBX or skinned glTF/GLB model"),
166 ("Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling.", "Part with skeleton embedded; bones linked by name. Check the bone links."),
167 ("Onderdeel toevoegen (OBJ/FBX/glTF)", "Add part (OBJ/FBX/glTF)"),
168 ("het model heeft geen skelet; kies OBJ of FBX voor een rigide onderdeel", "the model has no skeleton; choose OBJ or FBX for a rigid part"),
169 ("botkoppeling zonder skelet", "bone links without a skeleton"),
170 ("ongeldig equipmentskelet", "invalid equipment skeleton"),
171 ("equipmentskelet bevat een lus", "equipment skeleton contains a cycle"),
172 ("mesh verwijst naar onbekend equipmentbot", "mesh refers to an unknown equipment bone"),
173 ("ongeldige botkoppeling", "invalid bone link"),
174 ("botkoppeling verwijst naar onbekend bot", "bone link refers to an unknown bone"),
175 ("koppel minstens één equipmentbot aan een karakterbot", "link at least one equipment bone to a character bone"),
176 ("het equipmentmodel heeft geen of te veel vertices", "the equipment model has no or too many vertices"),
177 ("te veel botten in het equipmentmodel", "too many bones in the equipment model"),
178 ("ongeldige skinindex", "invalid skin index"),
179 ("ongeldige surface", "invalid surface"),
180 ("het equipmentmodel heeft geen tekenbare meshes", "the equipment model has no drawable meshes"),
181 ("dit onderdeel heeft geen skelet", "this part has no skeleton"),
182 ("dit equipmentbot bestaat niet", "this equipment bone does not exist"),
183 ("Botkoppeling: kies per equipmentbot een karakterbot (minstens één). Niet-gekoppelde botten volgen hun dichtstbijzijnde gekoppelde ouder.", "Bone links: choose a character bone per equipment bone (at least one). Unlinked bones follow their nearest linked parent."),
184 ("Koppel minstens één equipmentbot.", "Link at least one equipment bone."),
185 ("Automatisch invullen op naam", "Auto-fill by name"),
186 ("volgt ouder", "follows parent"),
187 ("Positie", "Position"),
188 ("Rotatie°", "Rotation°"),
189 ("glTF/GLB", "glTF/GLB"),
164 ("Render niet gelukt", "Render did not succeed"),
190 ("Render niet gelukt", "Render did not succeed"),
165 ("De renderworker stopte zonder eindresultaat.", "The render worker stopped without a final result."),
191 ("De renderworker stopte zonder eindresultaat.", "The render worker stopped without a final result."),
166 ("Annuleer render", "Cancel render"),
192 ("Annuleer render", "Cancel render"),
src/lib.rs +1 −0
@@ -16,6 +16,7 @@ pub mod dialogs;
16 16 pub mod digest;
17 17 pub mod editor;
18 18 pub mod equipment;
19 pub mod equipment_rig;
19 20 #[doc(hidden)]
20 21 pub mod export_fixture;
21 22 pub mod export_set;
@@ -16,6 +16,7 @@ pub mod dialogs;
16pub mod digest;
16pub mod digest;
17pub mod editor;
17pub mod editor;
18pub mod equipment;
18pub mod equipment;
19pub mod equipment_rig;
19#[doc(hidden)]
20#[doc(hidden)]
20pub mod export_fixture;
21pub mod export_fixture;
21pub mod export_set;
22pub mod export_set;
tests/equipment_presets.rs +1 −1
@@ -163,7 +163,7 @@ fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {
163 163 *damaged.last_mut().unwrap() ^= 1;
164 164 assert!(Preset::decode(&damaged).is_err());
165 165 let mut damaged = valid.clone();
166 damaged[8..12].copy_from_slice(&2u32.to_le_bytes());
166 damaged[8..12].copy_from_slice(&(equipment::VERSION + 1).to_le_bytes());
167 167 assert!(Preset::decode(&damaged).is_err());
168 168 let mut damaged = valid.clone();
169 169 damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
@@ -163,7 +163,7 @@ fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {
163 *damaged.last_mut().unwrap() ^= 1;
163 *damaged.last_mut().unwrap() ^= 1;
164 assert!(Preset::decode(&damaged).is_err());
164 assert!(Preset::decode(&damaged).is_err());
165 let mut damaged = valid.clone();
165 let mut damaged = valid.clone();
166 damaged[8..12].copy_from_slice(&2u32.to_le_bytes());
166 damaged[8..12].copy_from_slice(&(equipment::VERSION + 1).to_le_bytes());
167 assert!(Preset::decode(&damaged).is_err());
167 assert!(Preset::decode(&damaged).is_err());
168 let mut damaged = valid.clone();
168 let mut damaged = valid.clone();
169 damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
169 damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
tests/fbx_integration.rs +9 −5
@@ -305,11 +305,15 @@ fn skinned_garbage_or_unsupported_equipment_is_rejected() {
305 305 let dir = scratch("garbage");
306 306 std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap();
307 307 assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err());
308 assert!(equipment::import_model(
309 &mut preset,
310 &repo("tests/fixtures/bodies/test-body-male.glb")
311 )
312 .is_err());
308 // Tim t1027u: a skinned model is now a skinned part with its skeleton.
309 for skinned in [
310 "tests/fixtures/bodies/test-body-male.glb",
311 "tests/fixtures/bodies/test-body-male.fbx",
312 ] {
313 let mut ok = Preset::new("suit".into());
314 let part = equipment::import_model(&mut ok, &repo(skinned)).unwrap();
315 assert!(ok.attachments[part].skeleton.is_some(), "{skinned}");
316 }
313 317 assert!(preset.attachments.is_empty() && preset.assets.is_empty());
314 318 // OBJ still goes through the same dispatcher.
315 319 equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
@@ -305,11 +305,15 @@ fn skinned_garbage_or_unsupported_equipment_is_rejected() {
305 let dir = scratch("garbage");
305 let dir = scratch("garbage");
306 std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap();
306 std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap();
307 assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err());
307 assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err());
308 assert!(equipment::import_model(
308 // Tim t1027u: a skinned model is now a skinned part with its skeleton.
309 &mut preset,
309 for skinned in [
310 &repo("tests/fixtures/bodies/test-body-male.glb")
310 "tests/fixtures/bodies/test-body-male.glb",
311 )
311 "tests/fixtures/bodies/test-body-male.fbx",
312 .is_err());
312 ] {
313 let mut ok = Preset::new("suit".into());
314 let part = equipment::import_model(&mut ok, &repo(skinned)).unwrap();
315 assert!(ok.attachments[part].skeleton.is_some(), "{skinned}");
316 }
313 assert!(preset.attachments.is_empty() && preset.assets.is_empty());
317 assert!(preset.attachments.is_empty() && preset.assets.is_empty());
314 // OBJ still goes through the same dispatcher.
318 // OBJ still goes through the same dispatcher.
315 equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
319 equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
tests/fixtures/equipment/sword-v1.ppe added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/equipment/sword.ppe Binary file +0 −0

The contents of this binary file are not shown.

tests/skinned_equipment.rs added +167 −0
@@ -0,0 +1,167 @@
1 //! Tim t1027u: skinned equipment parts with per-bone links to the character.
2 use paperdoll_renderer::equipment::{self, Preset};
3 use paperdoll_renderer::equipment_rig;
4 use paperdoll_renderer::preview::{self, PreviewControls};
5 use std::path::PathBuf;
6
7 fn body_path() -> PathBuf {
8 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
9 }
10
11 fn apply(m: &[f32; 16], p: [f32; 3]) -> [f32; 3] {
12 [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i])
13 }
14
15 /// The body skinned on the CPU (linear blend) at a clip time.
16 fn skinned_body(body: &preview::PreviewCharacter, clip: &str, t: f32) -> Vec<[f32; 3]> {
17 let skins: Vec<[f32; 16]> = (0..body.joint_nodes.len())
18 .map(|j| {
19 preview::mul(
20 preview::animated_joint_world(body, clip, t, j).unwrap(),
21 body.inverse_binds[j],
22 )
23 })
24 .collect();
25 (0..body.positions.len())
26 .map(|v| {
27 let mut out = [0f32; 3];
28 for (j, w) in body.joints[v].iter().zip(body.weights[v]) {
29 let p = apply(&skins[*j as usize], body.positions[v]);
30 for k in 0..3 {
31 out[k] += p[k] * w;
32 }
33 }
34 out
35 })
36 .collect()
37 }
38
39 fn equipment_points(
40 preset: &Preset,
41 body: &preview::PreviewCharacter,
42 clip: &str,
43 t: f32,
44 ) -> Vec<[f32; 3]> {
45 let controls = PreviewControls {
46 clip_name: clip.into(),
47 time: t,
48 ..Default::default()
49 };
50 equipment::layers(preset, 0, body, &controls)
51 .unwrap()
52 .iter()
53 .flat_map(|l| {
54 l.mesh
55 .positions
56 .iter()
57 .map(|p| apply(&l.model, *p))
58 .collect::<Vec<_>>()
59 })
60 .collect()
61 }
62
63 fn max_nearest(points: &[[f32; 3]], cloud: &[[f32; 3]]) -> f32 {
64 points
65 .iter()
66 .map(|p| {
67 cloud
68 .iter()
69 .map(|q| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f32>())
70 .fold(f32::MAX, f32::min)
71 .sqrt()
72 })
73 .fold(0., f32::max)
74 }
75
76 fn mapped_preset(body: &preview::PreviewCharacter) -> Preset {
77 let mut preset = Preset::new("suit".into());
78 preset.applies = [true, false];
79 let part = equipment::import_model(&mut preset, &body_path()).unwrap();
80 assert!(preset.attachments[part].skeleton.is_some());
81 let added = equipment_rig::auto_link(&mut preset, part, 0, body).unwrap();
82 assert_eq!(added, body.joint_nodes.len(), "every bone name matches");
83 preset
84 }
85
86 #[test]
87 fn skinned_part_follows_the_character_skeleton_in_rest_and_animation() {
88 let body = preview::load_gltf_file(&body_path()).unwrap();
89 let preset = mapped_preset(&body);
90 // Same rig: every auto link keeps the rest placement with a zero offset.
91 for link in &preset.attachments[0].bone_links[0] {
92 assert!(link.translation.iter().all(|x| x.abs() < 1e-3), "{link:?}");
93 assert!(link.rotation_deg.iter().all(|x| x.abs() < 0.05), "{link:?}");
94 }
95 let rest = equipment_points(&preset, &body, "", 0.0);
96 assert!(max_nearest(&rest, &skinned_body(&body, "", 0.0)) < 1e-3);
97 let clip = &body.clips[0];
98 let t = clip.duration * 0.5;
99 let moving = equipment_points(&preset, &body, &clip.name, t);
100 let reference = skinned_body(&body, &clip.name, t);
101 // Rigid per dominant bone: close to linear blend skinning, never far off.
102 assert!(
103 max_nearest(&moving, &reference) < 0.03,
104 "{}",
105 max_nearest(&moving, &reference)
106 );
107 // Moving differs from rest, so the links really animate.
108 assert!(max_nearest(&moving, &rest) > 0.01);
109 }
110
111 #[test]
112 fn per_link_offset_moves_only_that_bone_and_its_unlinked_children() {
113 let body = preview::load_gltf_file(&body_path()).unwrap();
114 let mut preset = mapped_preset(&body);
115 let skeleton = preset.attachments[0].skeleton.clone().unwrap();
116 let head = skeleton
117 .bones
118 .iter()
119 .position(|b| b.name == "Head")
120 .unwrap();
121 // Lift only the Head link by 10 cm.
122 let links = &mut preset.attachments[0].bone_links[0];
123 let link = links.iter_mut().find(|l| l.bone == head).unwrap();
124 link.translation[1] += 0.1;
125 let controls = PreviewControls::default();
126 let layers = equipment::layers(&preset, 0, &body, &controls).unwrap();
127 let rest = equipment_rig::bone_models(&skeleton, &[], &[], |_| None);
128 assert!(rest.is_err(), "no links is an error");
129 for layer in &layers {
130 let bone = layer.mesh.bone.unwrap();
131 let expected = skeleton.bones[bone].rest_world;
132 let lifted = bone == head;
133 let dy = layer.model[13] - expected[13];
134 if lifted {
135 assert!(dy > 0.05, "head lifted");
136 } else {
137 assert!(dy.abs() < 1e-3, "bone {} moved", skeleton.bones[bone].name);
138 }
139 }
140 }
141
142 #[test]
143 fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() {
144 let old = std::fs::read(
145 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v1.ppe"),
146 )
147 .unwrap();
148 assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1);
149 let migrated = Preset::decode(&old).unwrap();
150 assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none()));
151 let bytes = migrated.encode().unwrap();
152 assert_eq!(
153 u32::from_le_bytes(bytes[8..12].try_into().unwrap()),
154 equipment::VERSION
155 );
156 assert_eq!(Preset::decode(&bytes).unwrap(), migrated);
157
158 let body = preview::load_gltf_file(&body_path()).unwrap();
159 let mut preset = mapped_preset(&body);
160 preset.attachments[0].bone_links[0][0].rotation_deg = [5., 10., 15.];
161 let again = Preset::decode(&preset.encode().unwrap()).unwrap();
162 assert_eq!(again, preset);
163 // Links to an unknown bone are rejected.
164 let mut bad = preset.clone();
165 bad.attachments[0].bone_links[0][0].target = "nope".into();
166 assert!(bad.encode().is_err());
167 }
@@ -0,0 +1,167 @@
1//! Tim t1027u: skinned equipment parts with per-bone links to the character.
2use paperdoll_renderer::equipment::{self, Preset};
3use paperdoll_renderer::equipment_rig;
4use paperdoll_renderer::preview::{self, PreviewControls};
5use std::path::PathBuf;
6
7fn body_path() -> PathBuf {
8 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
9}
10
11fn apply(m: &[f32; 16], p: [f32; 3]) -> [f32; 3] {
12 [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i])
13}
14
15/// The body skinned on the CPU (linear blend) at a clip time.
16fn skinned_body(body: &preview::PreviewCharacter, clip: &str, t: f32) -> Vec<[f32; 3]> {
17 let skins: Vec<[f32; 16]> = (0..body.joint_nodes.len())
18 .map(|j| {
19 preview::mul(
20 preview::animated_joint_world(body, clip, t, j).unwrap(),
21 body.inverse_binds[j],
22 )
23 })
24 .collect();
25 (0..body.positions.len())
26 .map(|v| {
27 let mut out = [0f32; 3];
28 for (j, w) in body.joints[v].iter().zip(body.weights[v]) {
29 let p = apply(&skins[*j as usize], body.positions[v]);
30 for k in 0..3 {
31 out[k] += p[k] * w;
32 }
33 }
34 out
35 })
36 .collect()
37}
38
39fn equipment_points(
40 preset: &Preset,
41 body: &preview::PreviewCharacter,
42 clip: &str,
43 t: f32,
44) -> Vec<[f32; 3]> {
45 let controls = PreviewControls {
46 clip_name: clip.into(),
47 time: t,
48 ..Default::default()
49 };
50 equipment::layers(preset, 0, body, &controls)
51 .unwrap()
52 .iter()
53 .flat_map(|l| {
54 l.mesh
55 .positions
56 .iter()
57 .map(|p| apply(&l.model, *p))
58 .collect::<Vec<_>>()
59 })
60 .collect()
61}
62
63fn max_nearest(points: &[[f32; 3]], cloud: &[[f32; 3]]) -> f32 {
64 points
65 .iter()
66 .map(|p| {
67 cloud
68 .iter()
69 .map(|q| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f32>())
70 .fold(f32::MAX, f32::min)
71 .sqrt()
72 })
73 .fold(0., f32::max)
74}
75
76fn mapped_preset(body: &preview::PreviewCharacter) -> Preset {
77 let mut preset = Preset::new("suit".into());
78 preset.applies = [true, false];
79 let part = equipment::import_model(&mut preset, &body_path()).unwrap();
80 assert!(preset.attachments[part].skeleton.is_some());
81 let added = equipment_rig::auto_link(&mut preset, part, 0, body).unwrap();
82 assert_eq!(added, body.joint_nodes.len(), "every bone name matches");
83 preset
84}
85
86#[test]
87fn skinned_part_follows_the_character_skeleton_in_rest_and_animation() {
88 let body = preview::load_gltf_file(&body_path()).unwrap();
89 let preset = mapped_preset(&body);
90 // Same rig: every auto link keeps the rest placement with a zero offset.
91 for link in &preset.attachments[0].bone_links[0] {
92 assert!(link.translation.iter().all(|x| x.abs() < 1e-3), "{link:?}");
93 assert!(link.rotation_deg.iter().all(|x| x.abs() < 0.05), "{link:?}");
94 }
95 let rest = equipment_points(&preset, &body, "", 0.0);
96 assert!(max_nearest(&rest, &skinned_body(&body, "", 0.0)) < 1e-3);
97 let clip = &body.clips[0];
98 let t = clip.duration * 0.5;
99 let moving = equipment_points(&preset, &body, &clip.name, t);
100 let reference = skinned_body(&body, &clip.name, t);
101 // Rigid per dominant bone: close to linear blend skinning, never far off.
102 assert!(
103 max_nearest(&moving, &reference) < 0.03,
104 "{}",
105 max_nearest(&moving, &reference)
106 );
107 // Moving differs from rest, so the links really animate.
108 assert!(max_nearest(&moving, &rest) > 0.01);
109}
110
111#[test]
112fn per_link_offset_moves_only_that_bone_and_its_unlinked_children() {
113 let body = preview::load_gltf_file(&body_path()).unwrap();
114 let mut preset = mapped_preset(&body);
115 let skeleton = preset.attachments[0].skeleton.clone().unwrap();
116 let head = skeleton
117 .bones
118 .iter()
119 .position(|b| b.name == "Head")
120 .unwrap();
121 // Lift only the Head link by 10 cm.
122 let links = &mut preset.attachments[0].bone_links[0];
123 let link = links.iter_mut().find(|l| l.bone == head).unwrap();
124 link.translation[1] += 0.1;
125 let controls = PreviewControls::default();
126 let layers = equipment::layers(&preset, 0, &body, &controls).unwrap();
127 let rest = equipment_rig::bone_models(&skeleton, &[], &[], |_| None);
128 assert!(rest.is_err(), "no links is an error");
129 for layer in &layers {
130 let bone = layer.mesh.bone.unwrap();
131 let expected = skeleton.bones[bone].rest_world;
132 let lifted = bone == head;
133 let dy = layer.model[13] - expected[13];
134 if lifted {
135 assert!(dy > 0.05, "head lifted");
136 } else {
137 assert!(dy.abs() < 1e-3, "bone {} moved", skeleton.bones[bone].name);
138 }
139 }
140}
141
142#[test]
143fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() {
144 let old = std::fs::read(
145 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v1.ppe"),
146 )
147 .unwrap();
148 assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1);
149 let migrated = Preset::decode(&old).unwrap();
150 assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none()));
151 let bytes = migrated.encode().unwrap();
152 assert_eq!(
153 u32::from_le_bytes(bytes[8..12].try_into().unwrap()),
154 equipment::VERSION
155 );
156 assert_eq!(Preset::decode(&bytes).unwrap(), migrated);
157
158 let body = preview::load_gltf_file(&body_path()).unwrap();
159 let mut preset = mapped_preset(&body);
160 preset.attachments[0].bone_links[0][0].rotation_deg = [5., 10., 15.];
161 let again = Preset::decode(&preset.encode().unwrap()).unwrap();
162 assert_eq!(again, preset);
163 // Links to an unknown bone are rejected.
164 let mut bad = preset.clone();
165 bad.attachments[0].bone_links[0][0].target = "nope".into();
166 assert!(bad.encode().is_err());
167}