Skinned equipment parts: per-bone links to character bones with name auto-fill, per-link offsets, .ppe v2 with v1 migration (Tim t1027u)
@@ -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
76
1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.76
1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.77
2. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.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.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. 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.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
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
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
**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
@@ -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]3
use eframe::App;3
use eframe::App;4
use paperdoll_renderer::{4
use 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
.skeleton120
.as_ref()121
.unwrap()122
.bones123
.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()))?;@@ -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(_) => dialog133
DialogPurpose::EquipmentModel(_) => dialog134
.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(),@@ -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
choice2564
choice2549
}2565
}2550
2566
2567
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a2568
/// 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 ui2595
.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
current2623
.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
error2666
}2667
2551
#[cfg(test)]2668
#[cfg(test)]2552
mod tests {2669
mod tests {2553
use super::*;2670
use super::*;@@ -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;13
use serde::{Deserialize, Serialize};13
use serde::{Deserialize, Serialize};14
14
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";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
pub const MAX_FILE: usize = 32 * 1024 * 1024;18
pub const MAX_FILE: usize = 32 * 1024 * 1024;18
const MAX_META: usize = 4 * 1024 * 1024;19
const MAX_META: usize = 4 * 1024 * 1024;19
const MAX_ASSET: usize = 8 * 1024 * 1024;20
const MAX_ASSET: usize = 8 * 1024 * 1024;20
const MAX_ASSETS: usize = 32;21
const MAX_ASSETS: usize = 32;21
const MAX_VERTICES: usize = 80_000;22
pub(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.24
pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000;25
pub(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() > 64204
|| (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
}1083
fn compact_assets(preset: &mut Preset) -> Result<(), String> {1127
pub(crate) fn compact_assets(preset: &mut Preset) -> Result<(), String> {1084
let used: BTreeSet<usize> = preset1128
let used: BTreeSet<usize> = preset1085
.attachments1129
.attachments1086
.iter()1130
.iter()@@ -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 to4
//! its dominant skin bone and is kept in that bone's rest space. Per sex, the5
//! equipment bones are linked to character bones; each link has its own6
//! position and rotation relative to the character bone. Unlinked bones follow7
//! 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() > 25670
|| 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 >= n93
|| !seen.insert(link.bone)94
|| link.target.len() > 409695
|| link96
.translation97
.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 = paths127
.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 = skeleton132
.bones133
.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
None164
}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 = source173
.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 = meshes191
.into_iter()192
.map(|mut m| {193
m.albedo = m.albedo.map(|i| base + i);194
m195
})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
label216
.chars()217
.map(|c| {218
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {219
c220
} 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> = rests274
.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() - 1320
}321
},322
),323
None => None,324
};325
let indices = model326
.indices327
.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
next361
});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
writer381
.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
out429
}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 = preset470
.attachments471
.get_mut(part)472
.ok_or("dit onderdeel bestaat niet")?;473
let skeleton = item474
.skeleton475
.as_ref()476
.ok_or("dit onderdeel heeft geen skelet")?;477
let rest = skeleton478
.bones479
.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> = preset517
.attachments518
.get(part)519
.and_then(|a| a.skeleton.as_ref())520
.ok_or("dit onderdeel heeft geen skelet")?521
.bones522
.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
0570
}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 tokens612
.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 tokens633
.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)); // exact677
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)); // partial682
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
}@@ -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"),@@ -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;16
pub mod digest;16
pub mod digest;17
pub mod editor;17
pub mod editor;18
pub mod equipment;18
pub mod equipment;19
pub mod equipment_rig;19
#[doc(hidden)]20
#[doc(hidden)]20
pub mod export_fixture;21
pub mod export_fixture;21
pub mod export_set;22
pub mod export_set;@@ -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());@@ -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();The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -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.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
out35
})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.mesh55
.positions56
.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
points65
.iter()66
.map(|p| {67
cloud68
.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
preset84
}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 = skeleton117
.bones118
.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::VERSION155
);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
}