BlueGit

golden-age paperdoll-renderer

Merge feature/per-gender-equipment-offsets (Tim t1092u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-12 · 1 day ago

Hash
dcb77258ac4bbba48c4349ffe90754544f64cc2a
Pipeline
Passed
15 files changed +648 −38
README.md +3 −3
@@ -76,10 +76,10 @@ From a body, **Equipment** opens the list. Choose a definition or **New**.
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 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.
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 and its own position/rotation. The Male / Female switch in the equipment editor chooses which sex you are editing.
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, positions and rotations are kept per sex; renders and the live preview use the mapping of the body's sex. When you enable Male or Female on a preset that has no mapping for that sex yet, the other sex's mapping and offsets are copied for every bone whose name exists on both body skeletons. Bones without a matching name stay unlinked; if nothing could be copied, the editor warns and you choose the bones yourself.
81 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.
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, the semi-transparency setting and origin; container version 3, version 1–2 presets still load (semi-transparency off) and are written as version 3 on the next save). 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, the semi-transparency setting and origin; container version 4, versions 1–3 still load (shared rigid offsets are applied to every enabled sex; semi-transparency off on versions 1–2) and are written as version 4 on the next save). No source paths outside the project are needed to reload them.
83 83
84 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.
85 85
@@ -76,10 +76,10 @@ From a body, **Equipment** opens the list. Choose a definition or **New**.
761. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
761. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
772. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
772. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
783. **Add part (OBJ/FBX/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.
783. **Add part (OBJ/FBX/glTF)** opens the system file picker; you choose the model first. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Morph targets and non-PNG textures are refused with a message. A model with its own skeleton (skinned glTF/GLB or FBX) becomes a *skinned part*, see step 4b. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
794. 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.
794. For a rigid part (no skeleton): expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding and its own position/rotation. The Male / Female switch in the equipment editor chooses which sex you are editing.
804b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links are kept per sex; renders and the live preview use them the same way.
804b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links, positions and rotations are kept per sex; renders and the live preview use the mapping of the body's sex. When you enable Male or Female on a preset that has no mapping for that sex yet, the other sex's mapping and offsets are copied for every bone whose name exists on both body skeletons. Bones without a matching name stay unlinked; if nothing could be copied, the editor warns and you choose the bones yourself.
815. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
815. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
826. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags, the semi-transparency setting and origin; container version 3, version 1–2 presets still load (semi-transparency off) and are written as version 3 on the next save). No source paths outside the project are needed to reload them.
826. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags, the semi-transparency setting and origin; container version 4, versions 1–3 still load (shared rigid offsets are applied to every enabled sex; semi-transparency off on versions 1–2) and are written as version 4 on the next save). No source paths outside the project are needed to reload them.
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**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.
85
85
design.md +6 −1
@@ -160,7 +160,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write
160 160
161 161 The equipment editor is implemented for **rigid OBJ + MTL + PNG and rigid FBX (embedded or relative PNG)** parts over a skinned glTF/GLB or FBX body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
162 162
163 Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
163 Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part, and its own rigid attach offset/rotation. Skinned per-bone links already store translation and rotation per sex. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. When a sex is enabled on a preset and that sex has no mapping yet, the already configured sex's mapping and offsets are copied for every bone whose name exists on both body skeletons (the destination joint path is taken from the newly enabled sex's body). Non-matching bones stay unmapped; if none could be copied, the editor warns and still requires at least one mapped bone before the part renders. After that the two sexes are edited independently. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
164 164
165 165 The equipment configuration draws the body plus all meshes in the selected preset once its attachments are configured through the actual Glow callback, sharing the native 24-bit depth attachment. Clear once for the body, never between equipment meshes; depth test/write makes self/body occlusion independent of submission order. Orbit/zoom and body animations continue to work. A grayscale/luminance PNG shinymap multiplies the scalar specular strength in placement shading; this is not a baked production shiny output map. The real GPU probe tests body/equipment front/back ordering, a sampled red albedo texture, alpha discard, and white-versus-black shinymap highlights. The generated CC0 sword OBJ/MTL/checker/shiny fixture and binary golden file are under `tests/fixtures/equipment/`; the example opens an actual seeded project, not a screenshot mock.
166 166
@@ -600,6 +600,11 @@ Each layer has a setting *semi-transparency allowed*: the body in the `.ppd` (`r
600 600
601 601 The setting is part of the render contract: `render.json` has `semi_transparency`, so a run is only resumed with the same setting. The `.ppda` (format 5) records it per layer as `semi_transparency`. Older `.ppda` files without the field are treated as semi-transparent, which they were. The `.ppe` container goes to version 3 (versions 1–2 still load, with the setting off). The `.ppd` stays at version 4, which is not released yet and now carries the trim and semi-transparency settings.
602 602
603 ### Per-sex equipment offsets (Tim t1092u)
604
605 Rigid attach `translation` / `rotation_deg` and the already per-sex bone links are stored independently for male and female on each part. The `.ppe` container goes to version 4. Versions 1–3 still load: a single shared rigid offset is applied to every *enabled* sex (a disabled sex keeps a zero offset). The next save writes version 4. Rendering and compositing use the offsets of the body's sex, so existing projects stay pixel-identical after migration. The `.ppd` schema is unchanged: bindings live only in the `.ppe`.
606
607
603 608 The v0.1.4 tag (f26d9fa) was pushed before this addition. Its pipeline (#604) was stopped before the release and AUR jobs (the AppImage tool download failed with HTTP 504), so nothing was published for v0.1.4. Tags are never deleted or moved, so this feature ships in a later version.
604 609
605 610 ## Decision 2026-11-08 (Tim t1032u): appimagetool from BlueGit, not GitHub
@@ -160,7 +160,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write
160
160
161The equipment editor is implemented for **rigid OBJ + MTL + PNG and rigid FBX (embedded or relative PNG)** parts over a skinned glTF/GLB or FBX body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
161The equipment editor is implemented for **rigid OBJ + MTL + PNG and rigid FBX (embedded or relative PNG)** parts over a skinned glTF/GLB or FBX body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
162
162
163Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
163Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part, and its own rigid attach offset/rotation. Skinned per-bone links already store translation and rotation per sex. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. When a sex is enabled on a preset and that sex has no mapping yet, the already configured sex's mapping and offsets are copied for every bone whose name exists on both body skeletons (the destination joint path is taken from the newly enabled sex's body). Non-matching bones stay unmapped; if none could be copied, the editor warns and still requires at least one mapped bone before the part renders. After that the two sexes are edited independently. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
164
164
165The equipment configuration draws the body plus all meshes in the selected preset once its attachments are configured through the actual Glow callback, sharing the native 24-bit depth attachment. Clear once for the body, never between equipment meshes; depth test/write makes self/body occlusion independent of submission order. Orbit/zoom and body animations continue to work. A grayscale/luminance PNG shinymap multiplies the scalar specular strength in placement shading; this is not a baked production shiny output map. The real GPU probe tests body/equipment front/back ordering, a sampled red albedo texture, alpha discard, and white-versus-black shinymap highlights. The generated CC0 sword OBJ/MTL/checker/shiny fixture and binary golden file are under `tests/fixtures/equipment/`; the example opens an actual seeded project, not a screenshot mock.
165The equipment configuration draws the body plus all meshes in the selected preset once its attachments are configured through the actual Glow callback, sharing the native 24-bit depth attachment. Clear once for the body, never between equipment meshes; depth test/write makes self/body occlusion independent of submission order. Orbit/zoom and body animations continue to work. A grayscale/luminance PNG shinymap multiplies the scalar specular strength in placement shading; this is not a baked production shiny output map. The real GPU probe tests body/equipment front/back ordering, a sampled red albedo texture, alpha discard, and white-versus-black shinymap highlights. The generated CC0 sword OBJ/MTL/checker/shiny fixture and binary golden file are under `tests/fixtures/equipment/`; the example opens an actual seeded project, not a screenshot mock.
166
166
@@ -600,6 +600,11 @@ Each layer has a setting *semi-transparency allowed*: the body in the `.ppd` (`r
600
600
601The setting is part of the render contract: `render.json` has `semi_transparency`, so a run is only resumed with the same setting. The `.ppda` (format 5) records it per layer as `semi_transparency`. Older `.ppda` files without the field are treated as semi-transparent, which they were. The `.ppe` container goes to version 3 (versions 1–2 still load, with the setting off). The `.ppd` stays at version 4, which is not released yet and now carries the trim and semi-transparency settings.
601The setting is part of the render contract: `render.json` has `semi_transparency`, so a run is only resumed with the same setting. The `.ppda` (format 5) records it per layer as `semi_transparency`. Older `.ppda` files without the field are treated as semi-transparent, which they were. The `.ppe` container goes to version 3 (versions 1–2 still load, with the setting off). The `.ppd` stays at version 4, which is not released yet and now carries the trim and semi-transparency settings.
602
602
603### Per-sex equipment offsets (Tim t1092u)
604
605Rigid attach `translation` / `rotation_deg` and the already per-sex bone links are stored independently for male and female on each part. The `.ppe` container goes to version 4. Versions 1–3 still load: a single shared rigid offset is applied to every *enabled* sex (a disabled sex keeps a zero offset). The next save writes version 4. Rendering and compositing use the offsets of the body's sex, so existing projects stay pixel-identical after migration. The `.ppd` schema is unchanged: bindings live only in the `.ppe`.
606
607
603The v0.1.4 tag (f26d9fa) was pushed before this addition. Its pipeline (#604) was stopped before the release and AUR jobs (the AppImage tool download failed with HTTP 504), so nothing was published for v0.1.4. Tags are never deleted or moved, so this feature ships in a later version.
608The v0.1.4 tag (f26d9fa) was pushed before this addition. Its pipeline (#604) was stopped before the release and AUR jobs (the AppImage tool download failed with HTTP 504), so nothing was published for v0.1.4. Tags are never deleted or moved, so this feature ships in a later version.
604
609
605## Decision 2026-11-08 (Tim t1032u): appimagetool from BlueGit, not GitHub
610## Decision 2026-11-08 (Tim t1032u): appimagetool from BlueGit, not GitHub
examples/bake_female_hair.rs +2 −2
@@ -118,8 +118,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
118 118 .ok_or("head missing in edited body")?;
119 119 preset.bind(1, &edited, part, head_edited)?;
120 120 // Tim t1023u defaults: position relative to the bone, rotation, gloss.
121 preset.attachments[part].translation = [0.0, -0.370, 0.0];
122 preset.attachments[part].rotation_deg = [0.0, 0.0, 0.0];
121 preset.attachments[part].translation[1] = [0.0, -0.370, 0.0];
122 preset.attachments[part].rotation_deg[1] = [0.0, 0.0, 0.0];
123 123 preset.attachments[part].specular = 0.62;
124 124 std::fs::write(&a[3], preset.encode()?)?;
125 125 println!(
@@ -118,8 +118,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
118 .ok_or("head missing in edited body")?;
118 .ok_or("head missing in edited body")?;
119 preset.bind(1, &edited, part, head_edited)?;
119 preset.bind(1, &edited, part, head_edited)?;
120 // Tim t1023u defaults: position relative to the bone, rotation, gloss.
120 // Tim t1023u defaults: position relative to the bone, rotation, gloss.
121 preset.attachments[part].translation = [0.0, -0.370, 0.0];
121 preset.attachments[part].translation[1] = [0.0, -0.370, 0.0];
122 preset.attachments[part].rotation_deg = [0.0, 0.0, 0.0];
122 preset.attachments[part].rotation_deg[1] = [0.0, 0.0, 0.0];
123 preset.attachments[part].specular = 0.62;
123 preset.attachments[part].specular = 0.62;
124 std::fs::write(&a[3], preset.encode()?)?;
124 std::fs::write(&a[3], preset.encode()?)?;
125 println!(
125 println!(
examples/equipment_fixture.rs +1 −1
@@ -37,7 +37,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
37 37 .ok_or("missing RightHand")?;
38 38 preset.bind(sex, &body, 0, joint)?;
39 39 }
40 preset.attachments[0].rotation_deg = [0., 0., -15.];
40 preset.attachments[0].rotation_deg = [[0., 0., -15.]; 2];
41 41 let index = session.add_equipment();
42 42 session.equipment[index].slug = "sword".into();
43 43 session.equipment[index].preset = Some(preset);
@@ -37,7 +37,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
37 .ok_or("missing RightHand")?;
37 .ok_or("missing RightHand")?;
38 preset.bind(sex, &body, 0, joint)?;
38 preset.bind(sex, &body, 0, joint)?;
39 }
39 }
40 preset.attachments[0].rotation_deg = [0., 0., -15.];
40 preset.attachments[0].rotation_deg = [[0., 0., -15.]; 2];
41 let index = session.add_equipment();
41 let index = session.add_equipment();
42 session.equipment[index].slug = "sword".into();
42 session.equipment[index].slug = "sword".into();
43 session.equipment[index].preset = Some(preset);
43 session.equipment[index].preset = Some(preset);
examples/fbx_live_fixture.rs +1 −1
@@ -244,7 +244,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
244 244 .ok_or("RightHand ontbreekt")?;
245 245 preset.bind(0, &body, 0, joint)?;
246 246 preset.applies = [true, false];
247 preset.attachments[0].rotation_deg = [0., 0., -15.];
247 preset.attachments[0].rotation_deg[0] = [0., 0., -15.];
248 248 let i = s.add_equipment();
249 249 s.equipment[i].slug = "fbxsword".into();
250 250 s.equipment[i].preset = Some(preset);
@@ -244,7 +244,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
244 .ok_or("RightHand ontbreekt")?;
244 .ok_or("RightHand ontbreekt")?;
245 preset.bind(0, &body, 0, joint)?;
245 preset.bind(0, &body, 0, joint)?;
246 preset.applies = [true, false];
246 preset.applies = [true, false];
247 preset.attachments[0].rotation_deg = [0., 0., -15.];
247 preset.attachments[0].rotation_deg[0] = [0., 0., -15.];
248 let i = s.add_equipment();
248 let i = s.add_equipment();
249 s.equipment[i].slug = "fbxsword".into();
249 s.equipment[i].slug = "fbxsword".into();
250 s.equipment[i].preset = Some(preset);
250 s.equipment[i].preset = Some(preset);
src/editor.rs +69 −7
@@ -53,6 +53,8 @@ pub struct EditorApp {
53 53 new_form: Option<NewProjectForm>,
54 54 settings_open: bool,
55 55 equipment_part: usize,
56 /// Warning after enabling a sex that could not inherit a mapping.
57 equipment_gender_notice: Option<String>,
56 58 preview2d: Preview2dState,
57 59 preview2d_catalog: Vec<CatalogEntry>,
58 60 /// Runs under `renders/` without a valid `.ppda` (reason shown on hover).
@@ -149,6 +151,7 @@ impl EditorApp {
149 151 new_form: None,
150 152 settings_open: false,
151 153 equipment_part: 0,
154 equipment_gender_notice: None,
152 155 preview2d: Preview2dState::default(),
153 156 preview2d_catalog: Vec::new(),
154 157 preview2d_skipped: Vec::new(),
@@ -1854,9 +1857,30 @@ impl EditorApp {
1854 1857 if let Some(preset) = &mut item.preset {
1855 1858 ui.horizontal(|ui| {
1856 1859 ui.label(crate::i18n::t("Geldig voor:"));
1857 ui.checkbox(&mut preset.applies[0], crate::i18n::t("Man"));
1858 ui.checkbox(&mut preset.applies[1], crate::i18n::t("Vrouw"));
1860 let mut applies = preset.applies;
1861 ui.checkbox(&mut applies[0], crate::i18n::t("Man"));
1862 ui.checkbox(&mut applies[1], crate::i18n::t("Vrouw"));
1863 if applies != preset.applies {
1864 for (to_sex, enabled) in applies.iter().copied().enumerate() {
1865 if enabled && !preset.applies[to_sex] {
1866 let body = if to_sex == 0 {
1867 self.preview_male.as_ref()
1868 } else {
1869 self.preview_female.as_ref()
1870 };
1871 self.equipment_gender_notice =
1872 inherit_gender_mapping(preset, to_sex, body);
1873 }
1874 }
1875 preset.applies = applies;
1876 }
1859 1877 });
1878 ui.label(crate::i18n::t(
1879 "Positie, rotatie en botkoppeling gelden per geslacht.",
1880 ));
1881 if let Some(notice) = &self.equipment_gender_notice {
1882 ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(notice));
1883 }
1860 1884 ui.checkbox(
1861 1885 &mut preset.semi_transparency,
1862 1886 crate::i18n::t("Semi-transparantie toegestaan"),
@@ -1901,9 +1925,13 @@ impl EditorApp {
1901 1925 let skinned = preset.attachments[self.equipment_part].skeleton.is_some();
1902 1926 let attachment = &mut preset.attachments[self.equipment_part];
1903 1927 if !skinned {
1904 ui.label(crate::i18n::t("Positie relatief aan bot (XYZ)"));
1928 ui.label(crate::i18n::t(if sex == 0 {
1929 "Positie relatief aan bot (XYZ) — Man"
1930 } else {
1931 "Positie relatief aan bot (XYZ) — Vrouw"
1932 }));
1905 1933 ui.horizontal(|ui| {
1906 for value in &mut attachment.translation {
1934 for value in &mut attachment.translation[sex] {
1907 1935 ui.add(
1908 1936 egui::DragValue::new(value)
1909 1937 .speed(0.005)
@@ -1911,9 +1939,13 @@ impl EditorApp {
1911 1939 );
1912 1940 }
1913 1941 });
1914 ui.label(crate::i18n::t("Rotatie in graden XYZ (Rz·Ry·Rx)"));
1942 ui.label(crate::i18n::t(if sex == 0 {
1943 "Rotatie in graden XYZ (Rz·Ry·Rx) — Man"
1944 } else {
1945 "Rotatie in graden XYZ (Rz·Ry·Rx) — Vrouw"
1946 }));
1915 1947 ui.horizontal(|ui| {
1916 for value in &mut attachment.rotation_deg {
1948 for value in &mut attachment.rotation_deg[sex] {
1917 1949 ui.add(
1918 1950 egui::DragValue::new(value)
1919 1951 .speed(0.5)
@@ -2658,6 +2690,36 @@ pub fn semi_transparency_for(
2658 2690 preset.map_or(render.semi_transparency_body, |p| p.semi_transparency)
2659 2691 }
2660 2692
2693 /// When a sex is enabled and has no mapping yet, copy the other sex's mapping
2694 /// for bones whose names exist on both bodies (Tim t1092u).
2695 fn inherit_gender_mapping(
2696 preset: &mut crate::equipment::Preset,
2697 to_sex: usize,
2698 to_body: Option<&PreviewCharacter>,
2699 ) -> Option<String> {
2700 let from_sex = 1 - to_sex;
2701 let needed = preset.attachments.iter().any(|attachment| {
2702 crate::equipment::Preset::attachment_mapped(attachment, from_sex)
2703 && !crate::equipment::Preset::attachment_mapped(attachment, to_sex)
2704 });
2705 if !needed {
2706 return None;
2707 }
2708 let Some(body) = to_body.filter(|body| !body.is_static()) else {
2709 return Some(
2710 "Open een geskind glTF/GLB- of FBX-lichaam voor dit geslacht om de botkoppeling over te nemen."
2711 .into(),
2712 );
2713 };
2714 match preset.copy_gender_mapping(from_sex, to_sex, body) {
2715 Ok(outcome) if outcome.mapped == 0 && outcome.unmatched > 0 => {
2716 Some("Geen bot met dezelfde naam op het andere lichaam; kies de botten opnieuw.".into())
2717 }
2718 Ok(_) => None,
2719 Err(error) => Some(error),
2720 }
2721 }
2722
2661 2723 /// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2662 2724 /// character bone (optional; at least one), with its own position/rotation.
2663 2725 fn skinned_links_ui(
@@ -2711,7 +2773,7 @@ fn skinned_links_ui(
2711 2773 let mut choice = current;
2712 2774 ui.horizontal(|ui| {
2713 2775 ui.label(&info.name);
2714 egui::ComboBox::from_id_salt(("skinned-link", part, bone))
2776 egui::ComboBox::from_id_salt(("skinned-link", part, sex, bone))
2715 2777 .selected_text(
2716 2778 current
2717 2779 .and_then(|j| body.joint_names.get(j).cloned())
@@ -53,6 +53,8 @@ pub struct EditorApp {
53 new_form: Option<NewProjectForm>,
53 new_form: Option<NewProjectForm>,
54 settings_open: bool,
54 settings_open: bool,
55 equipment_part: usize,
55 equipment_part: usize,
56 /// Warning after enabling a sex that could not inherit a mapping.
57 equipment_gender_notice: Option<String>,
56 preview2d: Preview2dState,
58 preview2d: Preview2dState,
57 preview2d_catalog: Vec<CatalogEntry>,
59 preview2d_catalog: Vec<CatalogEntry>,
58 /// Runs under `renders/` without a valid `.ppda` (reason shown on hover).
60 /// Runs under `renders/` without a valid `.ppda` (reason shown on hover).
@@ -149,6 +151,7 @@ impl EditorApp {
149 new_form: None,
151 new_form: None,
150 settings_open: false,
152 settings_open: false,
151 equipment_part: 0,
153 equipment_part: 0,
154 equipment_gender_notice: None,
152 preview2d: Preview2dState::default(),
155 preview2d: Preview2dState::default(),
153 preview2d_catalog: Vec::new(),
156 preview2d_catalog: Vec::new(),
154 preview2d_skipped: Vec::new(),
157 preview2d_skipped: Vec::new(),
@@ -1854,9 +1857,30 @@ impl EditorApp {
1854 if let Some(preset) = &mut item.preset {
1857 if let Some(preset) = &mut item.preset {
1855 ui.horizontal(|ui| {
1858 ui.horizontal(|ui| {
1856 ui.label(crate::i18n::t("Geldig voor:"));
1859 ui.label(crate::i18n::t("Geldig voor:"));
1857 ui.checkbox(&mut preset.applies[0], crate::i18n::t("Man"));
1860 let mut applies = preset.applies;
1858 ui.checkbox(&mut preset.applies[1], crate::i18n::t("Vrouw"));
1861 ui.checkbox(&mut applies[0], crate::i18n::t("Man"));
1862 ui.checkbox(&mut applies[1], crate::i18n::t("Vrouw"));
1863 if applies != preset.applies {
1864 for (to_sex, enabled) in applies.iter().copied().enumerate() {
1865 if enabled && !preset.applies[to_sex] {
1866 let body = if to_sex == 0 {
1867 self.preview_male.as_ref()
1868 } else {
1869 self.preview_female.as_ref()
1870 };
1871 self.equipment_gender_notice =
1872 inherit_gender_mapping(preset, to_sex, body);
1873 }
1874 }
1875 preset.applies = applies;
1876 }
1859 });
1877 });
1878 ui.label(crate::i18n::t(
1879 "Positie, rotatie en botkoppeling gelden per geslacht.",
1880 ));
1881 if let Some(notice) = &self.equipment_gender_notice {
1882 ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(notice));
1883 }
1860 ui.checkbox(
1884 ui.checkbox(
1861 &mut preset.semi_transparency,
1885 &mut preset.semi_transparency,
1862 crate::i18n::t("Semi-transparantie toegestaan"),
1886 crate::i18n::t("Semi-transparantie toegestaan"),
@@ -1901,9 +1925,13 @@ impl EditorApp {
1901 let skinned = preset.attachments[self.equipment_part].skeleton.is_some();
1925 let skinned = preset.attachments[self.equipment_part].skeleton.is_some();
1902 let attachment = &mut preset.attachments[self.equipment_part];
1926 let attachment = &mut preset.attachments[self.equipment_part];
1903 if !skinned {
1927 if !skinned {
1904 ui.label(crate::i18n::t("Positie relatief aan bot (XYZ)"));
1928 ui.label(crate::i18n::t(if sex == 0 {
1929 "Positie relatief aan bot (XYZ) — Man"
1930 } else {
1931 "Positie relatief aan bot (XYZ) — Vrouw"
1932 }));
1905 ui.horizontal(|ui| {
1933 ui.horizontal(|ui| {
1906 for value in &mut attachment.translation {
1934 for value in &mut attachment.translation[sex] {
1907 ui.add(
1935 ui.add(
1908 egui::DragValue::new(value)
1936 egui::DragValue::new(value)
1909 .speed(0.005)
1937 .speed(0.005)
@@ -1911,9 +1939,13 @@ impl EditorApp {
1911 );
1939 );
1912 }
1940 }
1913 });
1941 });
1914 ui.label(crate::i18n::t("Rotatie in graden XYZ (Rz·Ry·Rx)"));
1942 ui.label(crate::i18n::t(if sex == 0 {
1943 "Rotatie in graden XYZ (Rz·Ry·Rx) — Man"
1944 } else {
1945 "Rotatie in graden XYZ (Rz·Ry·Rx) — Vrouw"
1946 }));
1915 ui.horizontal(|ui| {
1947 ui.horizontal(|ui| {
1916 for value in &mut attachment.rotation_deg {
1948 for value in &mut attachment.rotation_deg[sex] {
1917 ui.add(
1949 ui.add(
1918 egui::DragValue::new(value)
1950 egui::DragValue::new(value)
1919 .speed(0.5)
1951 .speed(0.5)
@@ -2658,6 +2690,36 @@ pub fn semi_transparency_for(
2658 preset.map_or(render.semi_transparency_body, |p| p.semi_transparency)
2690 preset.map_or(render.semi_transparency_body, |p| p.semi_transparency)
2659}
2691}
2660
2692
2693/// When a sex is enabled and has no mapping yet, copy the other sex's mapping
2694/// for bones whose names exist on both bodies (Tim t1092u).
2695fn inherit_gender_mapping(
2696 preset: &mut crate::equipment::Preset,
2697 to_sex: usize,
2698 to_body: Option<&PreviewCharacter>,
2699) -> Option<String> {
2700 let from_sex = 1 - to_sex;
2701 let needed = preset.attachments.iter().any(|attachment| {
2702 crate::equipment::Preset::attachment_mapped(attachment, from_sex)
2703 && !crate::equipment::Preset::attachment_mapped(attachment, to_sex)
2704 });
2705 if !needed {
2706 return None;
2707 }
2708 let Some(body) = to_body.filter(|body| !body.is_static()) else {
2709 return Some(
2710 "Open een geskind glTF/GLB- of FBX-lichaam voor dit geslacht om de botkoppeling over te nemen."
2711 .into(),
2712 );
2713 };
2714 match preset.copy_gender_mapping(from_sex, to_sex, body) {
2715 Ok(outcome) if outcome.mapped == 0 && outcome.unmatched > 0 => {
2716 Some("Geen bot met dezelfde naam op het andere lichaam; kies de botten opnieuw.".into())
2717 }
2718 Ok(_) => None,
2719 Err(error) => Some(error),
2720 }
2721}
2722
2661/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2723/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2662/// character bone (optional; at least one), with its own position/rotation.
2724/// character bone (optional; at least one), with its own position/rotation.
2663fn skinned_links_ui(
2725fn skinned_links_ui(
@@ -2711,7 +2773,7 @@ fn skinned_links_ui(
2711 let mut choice = current;
2773 let mut choice = current;
2712 ui.horizontal(|ui| {
2774 ui.horizontal(|ui| {
2713 ui.label(&info.name);
2775 ui.label(&info.name);
2714 egui::ComboBox::from_id_salt(("skinned-link", part, bone))
2776 egui::ComboBox::from_id_salt(("skinned-link", part, sex, bone))
2715 .selected_text(
2777 .selected_text(
2716 current
2778 current
2717 .and_then(|j| body.joint_names.get(j).cloned())
2779 .and_then(|j| body.joint_names.get(j).cloned())
src/equipment.rs +253 −11
@@ -13,8 +13,8 @@ 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 /// Container version written; version 1 (rigid parts only) is still read.
17 pub const VERSION: u32 = 3;
16 /// Container version written; versions 1–3 (shared rigid offsets) still load.
17 pub const VERSION: u32 = 4;
18 18 pub const MAX_FILE: usize = 32 * 1024 * 1024;
19 19 const MAX_META: usize = 4 * 1024 * 1024;
20 20 const MAX_ASSET: usize = 8 * 1024 * 1024;
@@ -70,8 +70,10 @@ pub struct Attachment {
70 70 /// Original OBJ/MTL/dependency payloads owned by this attachment.
71 71 pub source_assets: Vec<usize>,
72 72 pub bones: [Option<String>; 2],
73 pub translation: [f32; 3],
74 pub rotation_deg: [f32; 3],
73 /// Per sex: rigid (single-bone) attach offset.
74 pub translation: [[f32; 3]; 2],
75 /// Per sex: rigid (single-bone) Euler degrees, Rz·Ry·Rx.
76 pub rotation_deg: [[f32; 3]; 2],
75 77 pub meshes: Vec<Mesh>,
76 78 pub shiny: Option<usize>,
77 79 pub specular: f32,
@@ -132,6 +134,31 @@ impl Preset {
132 134 }
133 135 Ok(())
134 136 }
137
138 /// Whether this part already has a bone mapping for `sex`.
139 pub fn attachment_mapped(attachment: &Attachment, sex: usize) -> bool {
140 if sex > 1 {
141 return false;
142 }
143 if attachment.skeleton.is_some() {
144 !attachment.bone_links[sex].is_empty()
145 } else {
146 attachment.bones[sex].is_some()
147 }
148 }
149
150 /// Copy bone mappings and offsets from `from_sex` onto `to_sex` for parts
151 /// that have no mapping on `to_sex` yet. Destination joints are taken from
152 /// `to_body` when the source joint name also exists there.
153 pub fn copy_gender_mapping(
154 &mut self,
155 from_sex: usize,
156 to_sex: usize,
157 to_body: &PreviewCharacter,
158 ) -> Result<CopyGenderMapping, String> {
159 copy_gender_mapping(self, from_sex, to_sex, to_body)
160 }
161
135 162 pub fn validate(&self) -> Result<(), String> {
136 163 self.validate_with(MAX_VERTICES)
137 164 }
@@ -201,7 +228,8 @@ impl Preset {
201 228 || attachment
202 229 .translation
203 230 .iter()
204 .chain(attachment.rotation_deg.iter())
231 .flatten()
232 .chain(attachment.rotation_deg.iter().flatten())
205 233 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
206 234 || !attachment.specular.is_finite()
207 235 || !(0.0..=1.0).contains(&attachment.specular)
@@ -313,8 +341,15 @@ impl Preset {
313 341 {
314 342 return Err("beschadigde equipmentdata".into());
315 343 }
316 let mut preset: Self =
344 let version = number(8);
345 let raw: serde_json::Value =
317 346 serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;
347 let raw = if version < 4 {
348 migrate_preset_json(version, raw)?
349 } else {
350 raw
351 };
352 let mut preset: Self = serde_json::from_value(raw).map_err(|e| e.to_string())?;
318 353 if preset.assets.len() > MAX_ASSETS {
319 354 return Err("te veel assets".into());
320 355 }
@@ -350,6 +385,210 @@ impl Preset {
350 385 }
351 386 }
352 387
388 /// Result of [`copy_gender_mapping`]: how many source mappings landed on the
389 /// destination body, and how many had no bone of the same name.
390 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
391 pub struct CopyGenderMapping {
392 pub mapped: usize,
393 pub unmatched: usize,
394 }
395
396 /// Last joint name in a hierarchical path (`"Hips[0]/Hand[12]"` → `"Hand"`).
397 pub fn joint_leaf_name(path: &str) -> &str {
398 let last = path.rsplit('/').next().unwrap_or(path);
399 last.rsplit_once('[').map(|(name, _)| name).unwrap_or(last)
400 }
401
402 /// Joint names along a path, without the `[node-index]` suffixes.
403 pub fn path_joint_names(path: &str) -> Vec<&str> {
404 path.split('/').map(joint_leaf_name).collect()
405 }
406
407 /// Destination joint whose name matches the source path, preferring a unique
408 /// name and otherwise the longest shared ancestor-name chain.
409 pub fn joint_by_name(body: &PreviewCharacter, source_path: &str) -> Option<usize> {
410 let src_names = path_joint_names(source_path);
411 let src_name = src_names.last().copied().filter(|n| !n.is_empty())?;
412 let dest_paths = joint_paths(body);
413 let mut matches: Vec<usize> = body
414 .joint_names
415 .iter()
416 .enumerate()
417 .filter(|(_, name)| *name == src_name)
418 .map(|(i, _)| i)
419 .collect();
420 if matches.is_empty() {
421 matches = dest_paths
422 .iter()
423 .enumerate()
424 .filter(|(_, p)| joint_leaf_name(p) == src_name)
425 .map(|(i, _)| i)
426 .collect();
427 }
428 if matches.is_empty() {
429 return None;
430 }
431 if matches.len() == 1 {
432 return Some(matches[0]);
433 }
434 matches.sort_by_key(|&i| {
435 let dest = path_joint_names(&dest_paths[i]);
436 let common = src_names
437 .iter()
438 .rev()
439 .zip(dest.iter().rev())
440 .take_while(|(a, b)| a == b)
441 .count();
442 std::cmp::Reverse((common, dest.len() == src_names.len()))
443 });
444 Some(matches[0])
445 }
446
447 /// Expand version 1–3 shared rigid offsets to a per-sex pair. The shared
448 /// translation/rotation is applied to every enabled sex; a disabled sex keeps
449 /// a zero offset (and whatever bone path it already had).
450 pub fn migrate_preset_json(
451 version: u32,
452 mut value: serde_json::Value,
453 ) -> Result<serde_json::Value, String> {
454 if version >= 4 {
455 return Ok(value);
456 }
457 let applies = value.get("applies").cloned();
458 let enabled = |sex: usize| {
459 applies
460 .as_ref()
461 .and_then(|a| a.as_array())
462 .and_then(|a| a.get(sex))
463 .and_then(|v| v.as_bool())
464 .unwrap_or(true)
465 };
466 let Some(attachments) = value.get_mut("attachments").and_then(|a| a.as_array_mut()) else {
467 return Ok(value);
468 };
469 for attachment in attachments {
470 expand_vec3_per_sex(attachment, "translation", enabled)?;
471 expand_vec3_per_sex(attachment, "rotation_deg", enabled)?;
472 }
473 Ok(value)
474 }
475
476 fn expand_vec3_per_sex(
477 attachment: &mut serde_json::Value,
478 key: &str,
479 enabled: impl Fn(usize) -> bool,
480 ) -> Result<(), String> {
481 let Some(value) = attachment.get(key) else {
482 return Ok(());
483 };
484 if value
485 .as_array()
486 .is_some_and(|items| items.first().is_some_and(serde_json::Value::is_array))
487 {
488 return Ok(());
489 }
490 let vec3 = parse_vec3(value, key)?;
491 let zero = [0.0f32; 3];
492 attachment[key] = serde_json::json!([
493 if enabled(0) { vec3 } else { zero },
494 if enabled(1) { vec3 } else { zero },
495 ]);
496 Ok(())
497 }
498
499 fn parse_vec3(value: &serde_json::Value, key: &str) -> Result<[f32; 3], String> {
500 let items = value
501 .as_array()
502 .ok_or_else(|| format!("{key} is ongeldig"))?;
503 if items.len() != 3 {
504 return Err(format!("{key} is ongeldig"));
505 }
506 let mut out = [0.0f32; 3];
507 for (i, item) in items.iter().enumerate() {
508 out[i] = item.as_f64().ok_or_else(|| format!("{key} is ongeldig"))? as f32;
509 }
510 Ok(out)
511 }
512
513 /// Copy already-authored mappings from one sex onto a newly enabled sex.
514 pub fn copy_gender_mapping(
515 preset: &mut Preset,
516 from_sex: usize,
517 to_sex: usize,
518 to_body: &PreviewCharacter,
519 ) -> Result<CopyGenderMapping, String> {
520 if from_sex > 1 || to_sex > 1 {
521 return Err("ongeldig geslacht".into());
522 }
523 if from_sex == to_sex {
524 return Ok(CopyGenderMapping::default());
525 }
526 if to_body.is_static() || to_body.joint_nodes.is_empty() {
527 return Err(
528 "kies een geskind lichaam voor dit geslacht om de botkoppeling over te nemen".into(),
529 );
530 }
531 let dest_paths = joint_paths(to_body);
532 let mut outcome = CopyGenderMapping::default();
533 let mut copied = false;
534 for index in 0..preset.attachments.len() {
535 if Preset::attachment_mapped(&preset.attachments[index], to_sex) {
536 continue;
537 }
538 if !Preset::attachment_mapped(&preset.attachments[index], from_sex) {
539 continue;
540 }
541 let item = &preset.attachments[index];
542 if item.skeleton.is_some() {
543 let mut links = Vec::new();
544 for link in &item.bone_links[from_sex] {
545 match joint_by_name(to_body, &link.target) {
546 Some(joint) => {
547 links.push(crate::equipment_rig::BoneLink {
548 bone: link.bone,
549 target: dest_paths[joint].clone(),
550 translation: link.translation,
551 rotation_deg: link.rotation_deg,
552 });
553 outcome.mapped += 1;
554 }
555 None => outcome.unmatched += 1,
556 }
557 }
558 if !links.is_empty() {
559 preset.attachments[index].bone_links[to_sex] = links;
560 copied = true;
561 }
562 } else if let Some(path) = item.bones[from_sex].clone() {
563 let translation = item.translation[from_sex];
564 let rotation_deg = item.rotation_deg[from_sex];
565 if let Some(joint) = joint_by_name(to_body, &path) {
566 let item = &mut preset.attachments[index];
567 item.bones[to_sex] = Some(dest_paths[joint].clone());
568 item.translation[to_sex] = translation;
569 item.rotation_deg[to_sex] = rotation_deg;
570 outcome.mapped += 1;
571 copied = true;
572 } else {
573 outcome.unmatched += 1;
574 }
575 }
576 }
577 if copied {
578 preset.rigs[to_sex] = Some(RigSignature::of(to_body));
579 for attachment in &mut preset.attachments {
580 attachment.bone_links[to_sex].retain(|l| dest_paths.contains(&l.target));
581 if attachment.bones[to_sex]
582 .as_ref()
583 .is_some_and(|p| !dest_paths.contains(p))
584 {
585 attachment.bones[to_sex] = None;
586 }
587 }
588 }
589 Ok(outcome)
590 }
591
353 592 pub fn valid_name(name: &str) -> bool {
354 593 !name.is_empty()
355 594 && name.len() <= 64
@@ -583,7 +822,10 @@ pub fn layers_cached(
583 822 .ok_or("het gekoppelde bot ontbreekt")?;
584 823 let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)
585 824 .ok_or("het gekoppelde bot ontbreekt")?;
586 let model = mul(world, local_matrix(item.translation, item.rotation_deg));
825 let model = mul(
826 world,
827 local_matrix(item.translation[sex], item.rotation_deg[sex]),
828 );
587 829 let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
588 830 for mesh in &item.meshes {
589 831 layers.push(Layer {
@@ -783,8 +1025,8 @@ pub(crate) fn import_obj_limited(
783 1025 label: name.to_string(),
784 1026 source_assets: asset_map.values().copied().collect(),
785 1027 bones: [None, None],
786 translation: [0.; 3],
787 rotation_deg: [0.; 3],
1028 translation: [[0.; 3]; 2],
1029 rotation_deg: [[0.; 3]; 2],
788 1030 meshes,
789 1031 shiny: None,
790 1032 specular: 0.25,
@@ -1079,8 +1321,8 @@ pub(crate) fn import_fbx_limited(
1079 1321 label: name.to_string(),
1080 1322 source_assets,
1081 1323 bones: [None, None],
1082 translation: [0.; 3],
1083 rotation_deg: [0.; 3],
1324 translation: [[0.; 3]; 2],
1325 rotation_deg: [[0.; 3]; 2],
1084 1326 meshes,
1085 1327 shiny: None,
1086 1328 specular: 0.25,
@@ -13,8 +13,8 @@ use crate::project::portable_relative;
13use serde::{Deserialize, Serialize};
13use serde::{Deserialize, Serialize};
14
14
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16/// Container version written; version 1 (rigid parts only) is still read.
16/// Container version written; versions 1–3 (shared rigid offsets) still load.
17pub const VERSION: u32 = 3;
17pub const VERSION: u32 = 4;
18pub const MAX_FILE: usize = 32 * 1024 * 1024;
18pub const MAX_FILE: usize = 32 * 1024 * 1024;
19const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_META: usize = 4 * 1024 * 1024;
20const MAX_ASSET: usize = 8 * 1024 * 1024;
20const MAX_ASSET: usize = 8 * 1024 * 1024;
@@ -70,8 +70,10 @@ pub struct Attachment {
70 /// Original OBJ/MTL/dependency payloads owned by this attachment.
70 /// Original OBJ/MTL/dependency payloads owned by this attachment.
71 pub source_assets: Vec<usize>,
71 pub source_assets: Vec<usize>,
72 pub bones: [Option<String>; 2],
72 pub bones: [Option<String>; 2],
73 pub translation: [f32; 3],
73 /// Per sex: rigid (single-bone) attach offset.
74 pub rotation_deg: [f32; 3],
74 pub translation: [[f32; 3]; 2],
75 /// Per sex: rigid (single-bone) Euler degrees, Rz·Ry·Rx.
76 pub rotation_deg: [[f32; 3]; 2],
75 pub meshes: Vec<Mesh>,
77 pub meshes: Vec<Mesh>,
76 pub shiny: Option<usize>,
78 pub shiny: Option<usize>,
77 pub specular: f32,
79 pub specular: f32,
@@ -132,6 +134,31 @@ impl Preset {
132 }
134 }
133 Ok(())
135 Ok(())
134 }
136 }
137
138 /// Whether this part already has a bone mapping for `sex`.
139 pub fn attachment_mapped(attachment: &Attachment, sex: usize) -> bool {
140 if sex > 1 {
141 return false;
142 }
143 if attachment.skeleton.is_some() {
144 !attachment.bone_links[sex].is_empty()
145 } else {
146 attachment.bones[sex].is_some()
147 }
148 }
149
150 /// Copy bone mappings and offsets from `from_sex` onto `to_sex` for parts
151 /// that have no mapping on `to_sex` yet. Destination joints are taken from
152 /// `to_body` when the source joint name also exists there.
153 pub fn copy_gender_mapping(
154 &mut self,
155 from_sex: usize,
156 to_sex: usize,
157 to_body: &PreviewCharacter,
158 ) -> Result<CopyGenderMapping, String> {
159 copy_gender_mapping(self, from_sex, to_sex, to_body)
160 }
161
135 pub fn validate(&self) -> Result<(), String> {
162 pub fn validate(&self) -> Result<(), String> {
136 self.validate_with(MAX_VERTICES)
163 self.validate_with(MAX_VERTICES)
137 }
164 }
@@ -201,7 +228,8 @@ impl Preset {
201 || attachment
228 || attachment
202 .translation
229 .translation
203 .iter()
230 .iter()
204 .chain(attachment.rotation_deg.iter())
231 .flatten()
232 .chain(attachment.rotation_deg.iter().flatten())
205 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
233 .any(|x| !x.is_finite() || x.abs() > 100_000.0)
206 || !attachment.specular.is_finite()
234 || !attachment.specular.is_finite()
207 || !(0.0..=1.0).contains(&attachment.specular)
235 || !(0.0..=1.0).contains(&attachment.specular)
@@ -313,8 +341,15 @@ impl Preset {
313 {
341 {
314 return Err("beschadigde equipmentdata".into());
342 return Err("beschadigde equipmentdata".into());
315 }
343 }
316 let mut preset: Self =
344 let version = number(8);
345 let raw: serde_json::Value =
317 serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;
346 serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;
347 let raw = if version < 4 {
348 migrate_preset_json(version, raw)?
349 } else {
350 raw
351 };
352 let mut preset: Self = serde_json::from_value(raw).map_err(|e| e.to_string())?;
318 if preset.assets.len() > MAX_ASSETS {
353 if preset.assets.len() > MAX_ASSETS {
319 return Err("te veel assets".into());
354 return Err("te veel assets".into());
320 }
355 }
@@ -350,6 +385,210 @@ impl Preset {
350 }
385 }
351}
386}
352
387
388/// Result of [`copy_gender_mapping`]: how many source mappings landed on the
389/// destination body, and how many had no bone of the same name.
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
391pub struct CopyGenderMapping {
392 pub mapped: usize,
393 pub unmatched: usize,
394}
395
396/// Last joint name in a hierarchical path (`"Hips[0]/Hand[12]"` → `"Hand"`).
397pub fn joint_leaf_name(path: &str) -> &str {
398 let last = path.rsplit('/').next().unwrap_or(path);
399 last.rsplit_once('[').map(|(name, _)| name).unwrap_or(last)
400}
401
402/// Joint names along a path, without the `[node-index]` suffixes.
403pub fn path_joint_names(path: &str) -> Vec<&str> {
404 path.split('/').map(joint_leaf_name).collect()
405}
406
407/// Destination joint whose name matches the source path, preferring a unique
408/// name and otherwise the longest shared ancestor-name chain.
409pub fn joint_by_name(body: &PreviewCharacter, source_path: &str) -> Option<usize> {
410 let src_names = path_joint_names(source_path);
411 let src_name = src_names.last().copied().filter(|n| !n.is_empty())?;
412 let dest_paths = joint_paths(body);
413 let mut matches: Vec<usize> = body
414 .joint_names
415 .iter()
416 .enumerate()
417 .filter(|(_, name)| *name == src_name)
418 .map(|(i, _)| i)
419 .collect();
420 if matches.is_empty() {
421 matches = dest_paths
422 .iter()
423 .enumerate()
424 .filter(|(_, p)| joint_leaf_name(p) == src_name)
425 .map(|(i, _)| i)
426 .collect();
427 }
428 if matches.is_empty() {
429 return None;
430 }
431 if matches.len() == 1 {
432 return Some(matches[0]);
433 }
434 matches.sort_by_key(|&i| {
435 let dest = path_joint_names(&dest_paths[i]);
436 let common = src_names
437 .iter()
438 .rev()
439 .zip(dest.iter().rev())
440 .take_while(|(a, b)| a == b)
441 .count();
442 std::cmp::Reverse((common, dest.len() == src_names.len()))
443 });
444 Some(matches[0])
445}
446
447/// Expand version 1–3 shared rigid offsets to a per-sex pair. The shared
448/// translation/rotation is applied to every enabled sex; a disabled sex keeps
449/// a zero offset (and whatever bone path it already had).
450pub fn migrate_preset_json(
451 version: u32,
452 mut value: serde_json::Value,
453) -> Result<serde_json::Value, String> {
454 if version >= 4 {
455 return Ok(value);
456 }
457 let applies = value.get("applies").cloned();
458 let enabled = |sex: usize| {
459 applies
460 .as_ref()
461 .and_then(|a| a.as_array())
462 .and_then(|a| a.get(sex))
463 .and_then(|v| v.as_bool())
464 .unwrap_or(true)
465 };
466 let Some(attachments) = value.get_mut("attachments").and_then(|a| a.as_array_mut()) else {
467 return Ok(value);
468 };
469 for attachment in attachments {
470 expand_vec3_per_sex(attachment, "translation", enabled)?;
471 expand_vec3_per_sex(attachment, "rotation_deg", enabled)?;
472 }
473 Ok(value)
474}
475
476fn expand_vec3_per_sex(
477 attachment: &mut serde_json::Value,
478 key: &str,
479 enabled: impl Fn(usize) -> bool,
480) -> Result<(), String> {
481 let Some(value) = attachment.get(key) else {
482 return Ok(());
483 };
484 if value
485 .as_array()
486 .is_some_and(|items| items.first().is_some_and(serde_json::Value::is_array))
487 {
488 return Ok(());
489 }
490 let vec3 = parse_vec3(value, key)?;
491 let zero = [0.0f32; 3];
492 attachment[key] = serde_json::json!([
493 if enabled(0) { vec3 } else { zero },
494 if enabled(1) { vec3 } else { zero },
495 ]);
496 Ok(())
497}
498
499fn parse_vec3(value: &serde_json::Value, key: &str) -> Result<[f32; 3], String> {
500 let items = value
501 .as_array()
502 .ok_or_else(|| format!("{key} is ongeldig"))?;
503 if items.len() != 3 {
504 return Err(format!("{key} is ongeldig"));
505 }
506 let mut out = [0.0f32; 3];
507 for (i, item) in items.iter().enumerate() {
508 out[i] = item.as_f64().ok_or_else(|| format!("{key} is ongeldig"))? as f32;
509 }
510 Ok(out)
511}
512
513/// Copy already-authored mappings from one sex onto a newly enabled sex.
514pub fn copy_gender_mapping(
515 preset: &mut Preset,
516 from_sex: usize,
517 to_sex: usize,
518 to_body: &PreviewCharacter,
519) -> Result<CopyGenderMapping, String> {
520 if from_sex > 1 || to_sex > 1 {
521 return Err("ongeldig geslacht".into());
522 }
523 if from_sex == to_sex {
524 return Ok(CopyGenderMapping::default());
525 }
526 if to_body.is_static() || to_body.joint_nodes.is_empty() {
527 return Err(
528 "kies een geskind lichaam voor dit geslacht om de botkoppeling over te nemen".into(),
529 );
530 }
531 let dest_paths = joint_paths(to_body);
532 let mut outcome = CopyGenderMapping::default();
533 let mut copied = false;
534 for index in 0..preset.attachments.len() {
535 if Preset::attachment_mapped(&preset.attachments[index], to_sex) {
536 continue;
537 }
538 if !Preset::attachment_mapped(&preset.attachments[index], from_sex) {
539 continue;
540 }
541 let item = &preset.attachments[index];
542 if item.skeleton.is_some() {
543 let mut links = Vec::new();
544 for link in &item.bone_links[from_sex] {
545 match joint_by_name(to_body, &link.target) {
546 Some(joint) => {
547 links.push(crate::equipment_rig::BoneLink {
548 bone: link.bone,
549 target: dest_paths[joint].clone(),
550 translation: link.translation,
551 rotation_deg: link.rotation_deg,
552 });
553 outcome.mapped += 1;
554 }
555 None => outcome.unmatched += 1,
556 }
557 }
558 if !links.is_empty() {
559 preset.attachments[index].bone_links[to_sex] = links;
560 copied = true;
561 }
562 } else if let Some(path) = item.bones[from_sex].clone() {
563 let translation = item.translation[from_sex];
564 let rotation_deg = item.rotation_deg[from_sex];
565 if let Some(joint) = joint_by_name(to_body, &path) {
566 let item = &mut preset.attachments[index];
567 item.bones[to_sex] = Some(dest_paths[joint].clone());
568 item.translation[to_sex] = translation;
569 item.rotation_deg[to_sex] = rotation_deg;
570 outcome.mapped += 1;
571 copied = true;
572 } else {
573 outcome.unmatched += 1;
574 }
575 }
576 }
577 if copied {
578 preset.rigs[to_sex] = Some(RigSignature::of(to_body));
579 for attachment in &mut preset.attachments {
580 attachment.bone_links[to_sex].retain(|l| dest_paths.contains(&l.target));
581 if attachment.bones[to_sex]
582 .as_ref()
583 .is_some_and(|p| !dest_paths.contains(p))
584 {
585 attachment.bones[to_sex] = None;
586 }
587 }
588 }
589 Ok(outcome)
590}
591
353pub fn valid_name(name: &str) -> bool {
592pub fn valid_name(name: &str) -> bool {
354 !name.is_empty()
593 !name.is_empty()
355 && name.len() <= 64
594 && name.len() <= 64
@@ -583,7 +822,10 @@ pub fn layers_cached(
583 .ok_or("het gekoppelde bot ontbreekt")?;
822 .ok_or("het gekoppelde bot ontbreekt")?;
584 let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)
823 let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)
585 .ok_or("het gekoppelde bot ontbreekt")?;
824 .ok_or("het gekoppelde bot ontbreekt")?;
586 let model = mul(world, local_matrix(item.translation, item.rotation_deg));
825 let model = mul(
826 world,
827 local_matrix(item.translation[sex], item.rotation_deg[sex]),
828 );
587 let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
829 let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
588 for mesh in &item.meshes {
830 for mesh in &item.meshes {
589 layers.push(Layer {
831 layers.push(Layer {
@@ -783,8 +1025,8 @@ pub(crate) fn import_obj_limited(
783 label: name.to_string(),
1025 label: name.to_string(),
784 source_assets: asset_map.values().copied().collect(),
1026 source_assets: asset_map.values().copied().collect(),
785 bones: [None, None],
1027 bones: [None, None],
786 translation: [0.; 3],
1028 translation: [[0.; 3]; 2],
787 rotation_deg: [0.; 3],
1029 rotation_deg: [[0.; 3]; 2],
788 meshes,
1030 meshes,
789 shiny: None,
1031 shiny: None,
790 specular: 0.25,
1032 specular: 0.25,
@@ -1079,8 +1321,8 @@ pub(crate) fn import_fbx_limited(
1079 label: name.to_string(),
1321 label: name.to_string(),
1080 source_assets,
1322 source_assets,
1081 bones: [None, None],
1323 bones: [None, None],
1082 translation: [0.; 3],
1324 translation: [[0.; 3]; 2],
1083 rotation_deg: [0.; 3],
1325 rotation_deg: [[0.; 3]; 2],
1084 meshes,
1326 meshes,
1085 shiny: None,
1327 shiny: None,
1086 specular: 0.25,
1328 specular: 0.25,
src/equipment_rig.rs +2 −2
@@ -198,8 +198,8 @@ pub fn import_skinned(preset: &mut Preset, source: &Path) -> Result<usize, Strin
198 198 label,
199 199 source_assets,
200 200 bones: [None, None],
201 translation: [0.; 3],
202 rotation_deg: [0.; 3],
201 translation: [[0.; 3]; 2],
202 rotation_deg: [[0.; 3]; 2],
203 203 meshes,
204 204 shiny: None,
205 205 specular: 0.25,
@@ -198,8 +198,8 @@ pub fn import_skinned(preset: &mut Preset, source: &Path) -> Result<usize, Strin
198 label,
198 label,
199 source_assets,
199 source_assets,
200 bones: [None, None],
200 bones: [None, None],
201 translation: [0.; 3],
201 translation: [[0.; 3]; 2],
202 rotation_deg: [0.; 3],
202 rotation_deg: [[0.; 3]; 2],
203 meshes,
203 meshes,
204 shiny: None,
204 shiny: None,
205 specular: 0.25,
205 specular: 0.25,
src/i18n_table.rs +9 −0
@@ -250,7 +250,16 @@ pub const TABLE: &[(&str, &str)] = &[
250 250 ("Herkomst / licentie (max. 4096 UTF-8 bytes)", "Origin / license (max. 4096 UTF-8 bytes)"),
251 251 ("Herkomst/licentie is te lang.", "Origin/license is too long."),
252 252 ("Positie relatief aan bot (XYZ)", "Position relative to bone (XYZ)"),
253 ("Positie relatief aan bot (XYZ) — Man", "Position relative to bone (XYZ) — Male"),
254 ("Positie relatief aan bot (XYZ) — Vrouw", "Position relative to bone (XYZ) — Female"),
253 255 ("Rotatie in graden XYZ (Rz·Ry·Rx)", "Rotation in degrees XYZ (Rz·Ry·Rx)"),
256 ("Rotatie in graden XYZ (Rz·Ry·Rx) — Man", "Rotation in degrees XYZ (Rz·Ry·Rx) — Male"),
257 ("Rotatie in graden XYZ (Rz·Ry·Rx) — Vrouw", "Rotation in degrees XYZ (Rz·Ry·Rx) — Female"),
258 ("Positie, rotatie en botkoppeling gelden per geslacht.", "Position, rotation and bone links apply per sex."),
259 ("Open een geskind glTF/GLB- of FBX-lichaam voor dit geslacht om de botkoppeling over te nemen.", "Open a skinned glTF/GLB or FBX body for this sex to copy the bone mapping."),
260 ("Geen bot met dezelfde naam op het andere lichaam; kies de botten opnieuw.", "No bone with the same name on the other body; choose the bones again."),
261 ("kies een geskind lichaam voor dit geslacht om de botkoppeling over te nemen", "choose a skinned body for this sex to copy the bone mapping"),
262 ("{key} is ongeldig", "{key} is invalid"),
254 263 ("Glanssterkte zonder texture", "Gloss strength without texture"),
255 264 ("Kies shinymap (PNG)", "Choose shiny map (PNG)"),
256 265 ("Wis shinymap", "Clear shiny map"),
@@ -250,7 +250,16 @@ pub const TABLE: &[(&str, &str)] = &[
250 ("Herkomst / licentie (max. 4096 UTF-8 bytes)", "Origin / license (max. 4096 UTF-8 bytes)"),
250 ("Herkomst / licentie (max. 4096 UTF-8 bytes)", "Origin / license (max. 4096 UTF-8 bytes)"),
251 ("Herkomst/licentie is te lang.", "Origin/license is too long."),
251 ("Herkomst/licentie is te lang.", "Origin/license is too long."),
252 ("Positie relatief aan bot (XYZ)", "Position relative to bone (XYZ)"),
252 ("Positie relatief aan bot (XYZ)", "Position relative to bone (XYZ)"),
253 ("Positie relatief aan bot (XYZ) — Man", "Position relative to bone (XYZ) — Male"),
254 ("Positie relatief aan bot (XYZ) — Vrouw", "Position relative to bone (XYZ) — Female"),
253 ("Rotatie in graden XYZ (Rz·Ry·Rx)", "Rotation in degrees XYZ (Rz·Ry·Rx)"),
255 ("Rotatie in graden XYZ (Rz·Ry·Rx)", "Rotation in degrees XYZ (Rz·Ry·Rx)"),
256 ("Rotatie in graden XYZ (Rz·Ry·Rx) — Man", "Rotation in degrees XYZ (Rz·Ry·Rx) — Male"),
257 ("Rotatie in graden XYZ (Rz·Ry·Rx) — Vrouw", "Rotation in degrees XYZ (Rz·Ry·Rx) — Female"),
258 ("Positie, rotatie en botkoppeling gelden per geslacht.", "Position, rotation and bone links apply per sex."),
259 ("Open een geskind glTF/GLB- of FBX-lichaam voor dit geslacht om de botkoppeling over te nemen.", "Open a skinned glTF/GLB or FBX body for this sex to copy the bone mapping."),
260 ("Geen bot met dezelfde naam op het andere lichaam; kies de botten opnieuw.", "No bone with the same name on the other body; choose the bones again."),
261 ("kies een geskind lichaam voor dit geslacht om de botkoppeling over te nemen", "choose a skinned body for this sex to copy the bone mapping"),
262 ("{key} is ongeldig", "{key} is invalid"),
254 ("Glanssterkte zonder texture", "Gloss strength without texture"),
263 ("Glanssterkte zonder texture", "Gloss strength without texture"),
255 ("Kies shinymap (PNG)", "Choose shiny map (PNG)"),
264 ("Kies shinymap (PNG)", "Choose shiny map (PNG)"),
256 ("Wis shinymap", "Clear shiny map"),
265 ("Wis shinymap", "Clear shiny map"),
src/session.rs +3 −2
@@ -925,8 +925,9 @@ mod tests {
925 925 };
926 926 // Tim t1023u defaults for the bundled hair.
927 927 let part = &hair.attachments[0];
928 assert_eq!(part.translation, [0.0, -0.370, 0.0]);
929 assert_eq!(part.rotation_deg, [0.0, 0.0, 0.0]);
928 assert_eq!(part.translation[1], [0.0, -0.370, 0.0]);
929 assert_eq!(part.translation[0], [0.0, 0.0, 0.0]);
930 assert_eq!(part.rotation_deg, [[0.0, 0.0, 0.0]; 2]);
930 931 assert_eq!(part.specular, 0.62);
931 932 let layers = crate::equipment::layers(hair, 1, &female, &controls).unwrap();
932 933 assert_eq!(layers.len(), 1);
@@ -925,8 +925,9 @@ mod tests {
925 };
925 };
926 // Tim t1023u defaults for the bundled hair.
926 // Tim t1023u defaults for the bundled hair.
927 let part = &hair.attachments[0];
927 let part = &hair.attachments[0];
928 assert_eq!(part.translation, [0.0, -0.370, 0.0]);
928 assert_eq!(part.translation[1], [0.0, -0.370, 0.0]);
929 assert_eq!(part.rotation_deg, [0.0, 0.0, 0.0]);
929 assert_eq!(part.translation[0], [0.0, 0.0, 0.0]);
930 assert_eq!(part.rotation_deg, [[0.0, 0.0, 0.0]; 2]);
930 assert_eq!(part.specular, 0.62);
931 assert_eq!(part.specular, 0.62);
931 let layers = crate::equipment::layers(hair, 1, &female, &controls).unwrap();
932 let layers = crate::equipment::layers(hair, 1, &female, &controls).unwrap();
932 assert_eq!(layers.len(), 1);
933 assert_eq!(layers.len(), 1);
tests/equipment_presets.rs +165 −7
@@ -30,8 +30,8 @@ fn preset() -> Preset {
30 30 .unwrap();
31 31 p.bind(sex, &body, 0, joint).unwrap();
32 32 }
33 p.attachments[0].translation = [0.02, -0.02, -0.03];
34 p.attachments[0].rotation_deg = [5., 0., -15.];
33 p.attachments[0].translation = [[0.02, -0.02, -0.03]; 2];
34 p.attachments[0].rotation_deg = [[5., 0., -15.]; 2];
35 35 p
36 36 }
37 37 fn scratch() -> PathBuf {
@@ -95,7 +95,10 @@ fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {
95 95 let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();
96 96 let expected = preview::mul(
97 97 world,
98 equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg),
98 equipment::local_matrix(
99 p.attachments[0].translation[0],
100 p.attachments[0].rotation_deg[0],
101 ),
99 102 );
100 103 assert_eq!(first[0].model, expected);
101 104 assert_ne!(
@@ -208,7 +211,7 @@ fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {
208 211 p.assets[0].path = "/absolute".into();
209 212 assert!(p.encode().is_err());
210 213 let mut p = preset();
211 p.attachments[0].translation[0] = f32::NAN;
214 p.attachments[0].translation[0][0] = f32::NAN;
212 215 assert!(p.encode().is_err());
213 216 let mut p = preset();
214 217 p.attachments[0].meshes[0].indices[0] = u32::MAX;
@@ -274,7 +277,7 @@ fn binary_project_reload_keeps_payload_after_source_disappears() {
274 277 .is_empty()
275 278 );
276 279 let previous = fs::read(&manifest).unwrap();
277 again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;
280 again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0][0] = 0.2;
278 281 again.set_render(0, 128, 12);
279 282 assert!(again.save().is_err());
280 283 assert_eq!(fs::read(&manifest).unwrap(), previous);
@@ -319,8 +322,8 @@ fn translated_rotated_parent_uses_the_complete_hierarchy_once() {
319 322 preset.rigs = [None, None];
320 323 preset.applies = [true, false];
321 324 preset.attachments[0].bones = [None, None];
322 preset.attachments[0].translation = [0.2, 0.3, 0.4];
323 preset.attachments[0].rotation_deg = [0.; 3];
325 preset.attachments[0].translation = [[0.2, 0.3, 0.4]; 2];
326 preset.attachments[0].rotation_deg = [[0.; 3]; 2];
324 327 preset.bind(0, &body, 0, 0).unwrap();
325 328 let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();
326 329 for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]
@@ -398,3 +401,158 @@ fn non_regular_preset_and_snapshot_targets_fail_without_reading() {
398 401 }
399 402 fs::remove_dir_all(root).unwrap();
400 403 }
404
405 fn pack_legacy(
406 preset: &Preset,
407 version: u32,
408 applies: [bool; 2],
409 translation: [f32; 3],
410 rotation_deg: [f32; 3],
411 ) -> Vec<u8> {
412 let mut value = serde_json::to_value(preset).expect("preset json");
413 value["applies"] = serde_json::json!([applies[0], applies[1]]);
414 for attachment in value["attachments"].as_array_mut().unwrap() {
415 attachment["translation"] = serde_json::json!(translation);
416 attachment["rotation_deg"] = serde_json::json!(rotation_deg);
417 }
418 let metadata = serde_json::to_vec(&value).unwrap();
419 let mut payload = metadata.clone();
420 for asset in &preset.assets {
421 payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());
422 payload.extend_from_slice(&asset.bytes);
423 }
424 let mut out = MAGIC.to_vec();
425 out.extend_from_slice(&version.to_le_bytes());
426 out.extend_from_slice(&(metadata.len() as u32).to_le_bytes());
427 out.extend_from_slice(&equipment::checksum(&payload).to_le_bytes());
428 out.extend(payload);
429 out
430 }
431
432 fn rename_joint(
433 mut body: preview::PreviewCharacter,
434 from: &str,
435 to: &str,
436 ) -> preview::PreviewCharacter {
437 for name in &mut body.joint_names {
438 if name == from {
439 *name = to.to_string();
440 }
441 }
442 for node in &mut body.nodes {
443 if node.name == from {
444 node.name = to.to_string();
445 }
446 }
447 body
448 }
449
450 #[test]
451 fn versions_1_to_3_apply_shared_offsets_to_enabled_sexes_and_round_trip() {
452 let offset = [0.02, -0.02, -0.03];
453 let rotation = [5.0, 0.0, -15.0];
454 for name in ["sword-v1.ppe", "sword-v2.ppe", "sword-v3.ppe"] {
455 let old = fs::read(fixture(name)).unwrap();
456 let version = u32::from_le_bytes(old[8..12].try_into().unwrap());
457 assert!(
458 (1..=3).contains(&version),
459 "{name} should be a legacy fixture, got {version}"
460 );
461 let migrated = Preset::decode(&old).unwrap();
462 assert_eq!(migrated.applies, [true, true]);
463 assert_eq!(migrated.attachments[0].translation, [offset; 2]);
464 assert_eq!(migrated.attachments[0].rotation_deg, [rotation; 2]);
465 let bytes = migrated.encode().unwrap();
466 assert_eq!(
467 u32::from_le_bytes(bytes[8..12].try_into().unwrap()),
468 equipment::VERSION
469 );
470 assert_eq!(Preset::decode(&bytes).unwrap(), migrated);
471 let male = fixture_body(false);
472 let female = fixture_body(true);
473 let controls = PreviewControls::default();
474 let layers_m = equipment::layers(&migrated, 0, &male, &controls).unwrap();
475 let layers_f = equipment::layers(&migrated, 1, &female, &controls).unwrap();
476 assert!(!layers_m.is_empty());
477 assert!(!layers_f.is_empty());
478 }
479 }
480
481 #[test]
482 fn version_3_shared_offset_fills_only_enabled_sex() {
483 let mut source = preset();
484 source.applies = [true, false];
485 source.attachments[0].bones[1] = None;
486 source.rigs[1] = None;
487 let old = pack_legacy(&source, 3, [true, false], [0.2, 0.3, 0.4], [1.0, 2.0, 3.0]);
488 assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 3);
489 let migrated = Preset::decode(&old).unwrap();
490 assert_eq!(migrated.applies, [true, false]);
491 assert_eq!(migrated.attachments[0].translation[0], [0.2, 0.3, 0.4]);
492 assert_eq!(migrated.attachments[0].rotation_deg[0], [1.0, 2.0, 3.0]);
493 assert_eq!(migrated.attachments[0].translation[1], [0.0, 0.0, 0.0]);
494 assert_eq!(migrated.attachments[0].rotation_deg[1], [0.0, 0.0, 0.0]);
495 assert!(migrated.attachments[0].bones[1].is_none());
496 }
497
498 #[test]
499 fn copy_rigid_mapping_matching_names_partial_and_none() {
500 let male = fixture_body(false);
501 let female = fixture_body(true);
502 let joint = male
503 .joint_names
504 .iter()
505 .position(|n| n == "RightHand")
506 .unwrap();
507 let mut preset = Preset::new("helm".into());
508 equipment::import_obj(&mut preset, &fixture("sword.obj")).unwrap();
509 preset.applies = [true, false];
510 preset.bind(0, &male, 0, joint).unwrap();
511 preset.attachments[0].translation[0] = [0.11, 0.22, 0.33];
512 preset.attachments[0].rotation_deg[0] = [4.0, 5.0, 6.0];
513
514 // Same names: the female RightHand path is used, offsets are copied.
515 preset.applies[1] = true;
516 let copied = preset.copy_gender_mapping(0, 1, &female).unwrap();
517 assert_eq!(copied.mapped, 1);
518 assert_eq!(copied.unmatched, 0);
519 let female_hand = preview::joint_paths(&female)
520 .into_iter()
521 .find(|p| equipment::joint_leaf_name(p) == "RightHand")
522 .unwrap();
523 assert_eq!(
524 preset.attachments[0].bones[1].as_deref(),
525 Some(female_hand.as_str())
526 );
527 assert_eq!(preset.attachments[0].translation[1], [0.11, 0.22, 0.33]);
528 assert_eq!(preset.attachments[0].rotation_deg[1], [4.0, 5.0, 6.0]);
529 assert_eq!(
530 equipment::joint_leaf_name(preset.attachments[0].bones[1].as_ref().unwrap()),
531 "RightHand"
532 );
533
534 // Existing female mapping is left alone.
535 preset.attachments[0].translation[1] = [9.0, 9.0, 9.0];
536 let again = preset.copy_gender_mapping(0, 1, &female).unwrap();
537 assert_eq!(again, equipment::CopyGenderMapping::default());
538 assert_eq!(preset.attachments[0].translation[1], [9.0, 9.0, 9.0]);
539
540 // No overlap: dest body has no RightHand.
541 let mut empty = Preset::new("helm".into());
542 equipment::import_obj(&mut empty, &fixture("sword.obj")).unwrap();
543 empty.bind(0, &male, 0, joint).unwrap();
544 empty.attachments[0].translation[0] = [0.11, 0.22, 0.33];
545 let renamed = rename_joint(female.clone(), "RightHand", "OtherHand");
546 let none = empty.copy_gender_mapping(0, 1, &renamed).unwrap();
547 assert_eq!(none.mapped, 0);
548 assert_eq!(none.unmatched, 1);
549 assert!(empty.attachments[0].bones[1].is_none());
550 assert_eq!(empty.attachments[0].translation[1], [0.0, 0.0, 0.0]);
551
552 // Independent save/load of the two sexes.
553 preset.attachments[0].translation[0] = [1.0, 0.0, 0.0];
554 preset.attachments[0].translation[1] = [0.0, 1.0, 0.0];
555 let restored = Preset::decode(&preset.encode().unwrap()).unwrap();
556 assert_eq!(restored.attachments[0].translation[0], [1.0, 0.0, 0.0]);
557 assert_eq!(restored.attachments[0].translation[1], [0.0, 1.0, 0.0]);
558 }
@@ -30,8 +30,8 @@ fn preset() -> Preset {
30 .unwrap();
30 .unwrap();
31 p.bind(sex, &body, 0, joint).unwrap();
31 p.bind(sex, &body, 0, joint).unwrap();
32 }
32 }
33 p.attachments[0].translation = [0.02, -0.02, -0.03];
33 p.attachments[0].translation = [[0.02, -0.02, -0.03]; 2];
34 p.attachments[0].rotation_deg = [5., 0., -15.];
34 p.attachments[0].rotation_deg = [[5., 0., -15.]; 2];
35 p
35 p
36}
36}
37fn scratch() -> PathBuf {
37fn scratch() -> PathBuf {
@@ -95,7 +95,10 @@ fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {
95 let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();
95 let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();
96 let expected = preview::mul(
96 let expected = preview::mul(
97 world,
97 world,
98 equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg),
98 equipment::local_matrix(
99 p.attachments[0].translation[0],
100 p.attachments[0].rotation_deg[0],
101 ),
99 );
102 );
100 assert_eq!(first[0].model, expected);
103 assert_eq!(first[0].model, expected);
101 assert_ne!(
104 assert_ne!(
@@ -208,7 +211,7 @@ fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {
208 p.assets[0].path = "/absolute".into();
211 p.assets[0].path = "/absolute".into();
209 assert!(p.encode().is_err());
212 assert!(p.encode().is_err());
210 let mut p = preset();
213 let mut p = preset();
211 p.attachments[0].translation[0] = f32::NAN;
214 p.attachments[0].translation[0][0] = f32::NAN;
212 assert!(p.encode().is_err());
215 assert!(p.encode().is_err());
213 let mut p = preset();
216 let mut p = preset();
214 p.attachments[0].meshes[0].indices[0] = u32::MAX;
217 p.attachments[0].meshes[0].indices[0] = u32::MAX;
@@ -274,7 +277,7 @@ fn binary_project_reload_keeps_payload_after_source_disappears() {
274 .is_empty()
277 .is_empty()
275 );
278 );
276 let previous = fs::read(&manifest).unwrap();
279 let previous = fs::read(&manifest).unwrap();
277 again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;
280 again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0][0] = 0.2;
278 again.set_render(0, 128, 12);
281 again.set_render(0, 128, 12);
279 assert!(again.save().is_err());
282 assert!(again.save().is_err());
280 assert_eq!(fs::read(&manifest).unwrap(), previous);
283 assert_eq!(fs::read(&manifest).unwrap(), previous);
@@ -319,8 +322,8 @@ fn translated_rotated_parent_uses_the_complete_hierarchy_once() {
319 preset.rigs = [None, None];
322 preset.rigs = [None, None];
320 preset.applies = [true, false];
323 preset.applies = [true, false];
321 preset.attachments[0].bones = [None, None];
324 preset.attachments[0].bones = [None, None];
322 preset.attachments[0].translation = [0.2, 0.3, 0.4];
325 preset.attachments[0].translation = [[0.2, 0.3, 0.4]; 2];
323 preset.attachments[0].rotation_deg = [0.; 3];
326 preset.attachments[0].rotation_deg = [[0.; 3]; 2];
324 preset.bind(0, &body, 0, 0).unwrap();
327 preset.bind(0, &body, 0, 0).unwrap();
325 let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();
328 let layer = equipment::layers(&preset, 0, &body, &PreviewControls::default()).unwrap();
326 for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]
329 for (value, expected) in [layer[0].model[12], layer[0].model[13], layer[0].model[14]]
@@ -398,3 +401,158 @@ fn non_regular_preset_and_snapshot_targets_fail_without_reading() {
398 }
401 }
399 fs::remove_dir_all(root).unwrap();
402 fs::remove_dir_all(root).unwrap();
400}
403}
404
405fn pack_legacy(
406 preset: &Preset,
407 version: u32,
408 applies: [bool; 2],
409 translation: [f32; 3],
410 rotation_deg: [f32; 3],
411) -> Vec<u8> {
412 let mut value = serde_json::to_value(preset).expect("preset json");
413 value["applies"] = serde_json::json!([applies[0], applies[1]]);
414 for attachment in value["attachments"].as_array_mut().unwrap() {
415 attachment["translation"] = serde_json::json!(translation);
416 attachment["rotation_deg"] = serde_json::json!(rotation_deg);
417 }
418 let metadata = serde_json::to_vec(&value).unwrap();
419 let mut payload = metadata.clone();
420 for asset in &preset.assets {
421 payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());
422 payload.extend_from_slice(&asset.bytes);
423 }
424 let mut out = MAGIC.to_vec();
425 out.extend_from_slice(&version.to_le_bytes());
426 out.extend_from_slice(&(metadata.len() as u32).to_le_bytes());
427 out.extend_from_slice(&equipment::checksum(&payload).to_le_bytes());
428 out.extend(payload);
429 out
430}
431
432fn rename_joint(
433 mut body: preview::PreviewCharacter,
434 from: &str,
435 to: &str,
436) -> preview::PreviewCharacter {
437 for name in &mut body.joint_names {
438 if name == from {
439 *name = to.to_string();
440 }
441 }
442 for node in &mut body.nodes {
443 if node.name == from {
444 node.name = to.to_string();
445 }
446 }
447 body
448}
449
450#[test]
451fn versions_1_to_3_apply_shared_offsets_to_enabled_sexes_and_round_trip() {
452 let offset = [0.02, -0.02, -0.03];
453 let rotation = [5.0, 0.0, -15.0];
454 for name in ["sword-v1.ppe", "sword-v2.ppe", "sword-v3.ppe"] {
455 let old = fs::read(fixture(name)).unwrap();
456 let version = u32::from_le_bytes(old[8..12].try_into().unwrap());
457 assert!(
458 (1..=3).contains(&version),
459 "{name} should be a legacy fixture, got {version}"
460 );
461 let migrated = Preset::decode(&old).unwrap();
462 assert_eq!(migrated.applies, [true, true]);
463 assert_eq!(migrated.attachments[0].translation, [offset; 2]);
464 assert_eq!(migrated.attachments[0].rotation_deg, [rotation; 2]);
465 let bytes = migrated.encode().unwrap();
466 assert_eq!(
467 u32::from_le_bytes(bytes[8..12].try_into().unwrap()),
468 equipment::VERSION
469 );
470 assert_eq!(Preset::decode(&bytes).unwrap(), migrated);
471 let male = fixture_body(false);
472 let female = fixture_body(true);
473 let controls = PreviewControls::default();
474 let layers_m = equipment::layers(&migrated, 0, &male, &controls).unwrap();
475 let layers_f = equipment::layers(&migrated, 1, &female, &controls).unwrap();
476 assert!(!layers_m.is_empty());
477 assert!(!layers_f.is_empty());
478 }
479}
480
481#[test]
482fn version_3_shared_offset_fills_only_enabled_sex() {
483 let mut source = preset();
484 source.applies = [true, false];
485 source.attachments[0].bones[1] = None;
486 source.rigs[1] = None;
487 let old = pack_legacy(&source, 3, [true, false], [0.2, 0.3, 0.4], [1.0, 2.0, 3.0]);
488 assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 3);
489 let migrated = Preset::decode(&old).unwrap();
490 assert_eq!(migrated.applies, [true, false]);
491 assert_eq!(migrated.attachments[0].translation[0], [0.2, 0.3, 0.4]);
492 assert_eq!(migrated.attachments[0].rotation_deg[0], [1.0, 2.0, 3.0]);
493 assert_eq!(migrated.attachments[0].translation[1], [0.0, 0.0, 0.0]);
494 assert_eq!(migrated.attachments[0].rotation_deg[1], [0.0, 0.0, 0.0]);
495 assert!(migrated.attachments[0].bones[1].is_none());
496}
497
498#[test]
499fn copy_rigid_mapping_matching_names_partial_and_none() {
500 let male = fixture_body(false);
501 let female = fixture_body(true);
502 let joint = male
503 .joint_names
504 .iter()
505 .position(|n| n == "RightHand")
506 .unwrap();
507 let mut preset = Preset::new("helm".into());
508 equipment::import_obj(&mut preset, &fixture("sword.obj")).unwrap();
509 preset.applies = [true, false];
510 preset.bind(0, &male, 0, joint).unwrap();
511 preset.attachments[0].translation[0] = [0.11, 0.22, 0.33];
512 preset.attachments[0].rotation_deg[0] = [4.0, 5.0, 6.0];
513
514 // Same names: the female RightHand path is used, offsets are copied.
515 preset.applies[1] = true;
516 let copied = preset.copy_gender_mapping(0, 1, &female).unwrap();
517 assert_eq!(copied.mapped, 1);
518 assert_eq!(copied.unmatched, 0);
519 let female_hand = preview::joint_paths(&female)
520 .into_iter()
521 .find(|p| equipment::joint_leaf_name(p) == "RightHand")
522 .unwrap();
523 assert_eq!(
524 preset.attachments[0].bones[1].as_deref(),
525 Some(female_hand.as_str())
526 );
527 assert_eq!(preset.attachments[0].translation[1], [0.11, 0.22, 0.33]);
528 assert_eq!(preset.attachments[0].rotation_deg[1], [4.0, 5.0, 6.0]);
529 assert_eq!(
530 equipment::joint_leaf_name(preset.attachments[0].bones[1].as_ref().unwrap()),
531 "RightHand"
532 );
533
534 // Existing female mapping is left alone.
535 preset.attachments[0].translation[1] = [9.0, 9.0, 9.0];
536 let again = preset.copy_gender_mapping(0, 1, &female).unwrap();
537 assert_eq!(again, equipment::CopyGenderMapping::default());
538 assert_eq!(preset.attachments[0].translation[1], [9.0, 9.0, 9.0]);
539
540 // No overlap: dest body has no RightHand.
541 let mut empty = Preset::new("helm".into());
542 equipment::import_obj(&mut empty, &fixture("sword.obj")).unwrap();
543 empty.bind(0, &male, 0, joint).unwrap();
544 empty.attachments[0].translation[0] = [0.11, 0.22, 0.33];
545 let renamed = rename_joint(female.clone(), "RightHand", "OtherHand");
546 let none = empty.copy_gender_mapping(0, 1, &renamed).unwrap();
547 assert_eq!(none.mapped, 0);
548 assert_eq!(none.unmatched, 1);
549 assert!(empty.attachments[0].bones[1].is_none());
550 assert_eq!(empty.attachments[0].translation[1], [0.0, 0.0, 0.0]);
551
552 // Independent save/load of the two sexes.
553 preset.attachments[0].translation[0] = [1.0, 0.0, 0.0];
554 preset.attachments[0].translation[1] = [0.0, 1.0, 0.0];
555 let restored = Preset::decode(&preset.encode().unwrap()).unwrap();
556 assert_eq!(restored.attachments[0].translation[0], [1.0, 0.0, 0.0]);
557 assert_eq!(restored.attachments[0].translation[1], [0.0, 1.0, 0.0]);
558}
tests/fbx_integration.rs +1 −1
@@ -334,7 +334,7 @@ fn fbx_equipment_on_fbx_body_follows_the_animated_hand_in_production() {
334 334 let mut preset = Preset::new("fbxsword".into());
335 335 equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap();
336 336 preset.bind(0, &body, 0, joint).unwrap();
337 preset.attachments[0].rotation_deg = [0., 0., -15.];
337 preset.attachments[0].rotation_deg[0] = [0., 0., -15.];
338 338 let attack = clip_named(&body, "attack");
339 339 let job = job(body, Some(preset.clone()));
340 340 let mut cache = equipment::TextureCache::default();
@@ -334,7 +334,7 @@ fn fbx_equipment_on_fbx_body_follows_the_animated_hand_in_production() {
334 let mut preset = Preset::new("fbxsword".into());
334 let mut preset = Preset::new("fbxsword".into());
335 equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap();
335 equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap();
336 preset.bind(0, &body, 0, joint).unwrap();
336 preset.bind(0, &body, 0, joint).unwrap();
337 preset.attachments[0].rotation_deg = [0., 0., -15.];
337 preset.attachments[0].rotation_deg[0] = [0., 0., -15.];
338 let attack = clip_named(&body, "attack");
338 let attack = clip_named(&body, "attack");
339 let job = job(body, Some(preset.clone()));
339 let job = job(body, Some(preset.clone()));
340 let mut cache = equipment::TextureCache::default();
340 let mut cache = equipment::TextureCache::default();
tests/fixtures/equipment/sword-v3.ppe added Binary file +0 −0

The contents of this binary file are not shown.

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

The contents of this binary file are not shown.

tests/skinned_equipment.rs +133 −0
@@ -173,3 +173,136 @@ fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() {
173 173 bad.attachments[0].bone_links[0][0].target = "nope".into();
174 174 assert!(bad.encode().is_err());
175 175 }
176
177 fn female_body() -> preview::PreviewCharacter {
178 preview::load_gltf_file(
179 &PathBuf::from(env!("CARGO_MANIFEST_DIR"))
180 .join("tests/fixtures/bodies/test-body-female.glb"),
181 )
182 .unwrap()
183 }
184
185 fn rename_joint(
186 mut body: preview::PreviewCharacter,
187 from: &str,
188 to: &str,
189 ) -> preview::PreviewCharacter {
190 for name in &mut body.joint_names {
191 if name == from {
192 *name = to.to_string();
193 }
194 }
195 for node in &mut body.nodes {
196 if node.name == from {
197 node.name = to.to_string();
198 }
199 }
200 body
201 }
202
203 #[test]
204 fn copy_skinned_links_matching_partial_and_no_overlap() {
205 let male = preview::load_gltf_file(&body_path()).unwrap();
206 let female = female_body();
207 let mut preset = mapped_preset(&male);
208 // Distinct authored offsets on two links so we can see the copy.
209 let head = preset.attachments[0]
210 .skeleton
211 .as_ref()
212 .unwrap()
213 .bones
214 .iter()
215 .position(|b| b.name == "Head")
216 .unwrap();
217 let hand = preset.attachments[0]
218 .skeleton
219 .as_ref()
220 .unwrap()
221 .bones
222 .iter()
223 .position(|b| b.name == "RightHand")
224 .unwrap();
225 for link in &mut preset.attachments[0].bone_links[0] {
226 if link.bone == head {
227 link.translation = [0.0, 0.1, 0.0];
228 }
229 if link.bone == hand {
230 link.rotation_deg = [0.0, 15.0, 0.0];
231 }
232 }
233 assert!(preset.attachments[0].bone_links[1].is_empty());
234
235 // Full name overlap.
236 let copied = preset.copy_gender_mapping(0, 1, &female).unwrap();
237 assert_eq!(copied.unmatched, 0);
238 assert_eq!(copied.mapped, preset.attachments[0].bone_links[0].len());
239 assert_eq!(
240 preset.attachments[0].bone_links[0].len(),
241 preset.attachments[0].bone_links[1].len()
242 );
243 let female_paths = preview::joint_paths(&female);
244 for src in &preset.attachments[0].bone_links[0] {
245 let dest = preset.attachments[0].bone_links[1]
246 .iter()
247 .find(|l| l.bone == src.bone)
248 .expect("copied link");
249 assert_eq!(dest.translation, src.translation);
250 assert_eq!(dest.rotation_deg, src.rotation_deg);
251 assert!(female_paths.contains(&dest.target));
252 assert_eq!(
253 equipment::joint_leaf_name(&dest.target),
254 equipment::joint_leaf_name(&src.target)
255 );
256 }
257 // Independent after the copy: changing male does not change female.
258 preset.attachments[0].bone_links[0]
259 .iter_mut()
260 .find(|l| l.bone == head)
261 .unwrap()
262 .translation = [3.0, 0.0, 0.0];
263 let female_head = preset.attachments[0].bone_links[1]
264 .iter()
265 .find(|l| l.bone == head)
266 .unwrap();
267 assert_eq!(female_head.translation, [0.0, 0.1, 0.0]);
268 let restored = Preset::decode(&preset.encode().unwrap()).unwrap();
269 assert_eq!(restored, preset);
270
271 // Partial overlap: female is missing Head; other links still copy.
272 let mut partial = mapped_preset(&male);
273 partial.attachments[0].bone_links[0]
274 .iter_mut()
275 .find(|l| l.bone == head)
276 .unwrap()
277 .translation = [0.0, 0.1, 0.0];
278 let no_head = rename_joint(female.clone(), "Head", "Skull");
279 let outcome = partial.copy_gender_mapping(0, 1, &no_head).unwrap();
280 assert_eq!(outcome.unmatched, 1);
281 assert_eq!(
282 outcome.mapped,
283 partial.attachments[0].bone_links[0].len() - 1
284 );
285 assert!(partial.attachments[0].bone_links[1]
286 .iter()
287 .all(|l| equipment::joint_leaf_name(&l.target) != "Head"));
288 assert!(partial.attachments[0].bone_links[1]
289 .iter()
290 .any(|l| equipment::joint_leaf_name(&l.target) == "RightHand"));
291
292 // No overlap at all: rename every female joint.
293 let mut unique = female.clone();
294 for (i, name) in unique.joint_names.iter_mut().enumerate() {
295 *name = format!("OnlyFemale{i}");
296 }
297 for (i, node) in unique.nodes.iter_mut().enumerate() {
298 if male.joint_names.iter().any(|n| n == &node.name) {
299 node.name = format!("OnlyFemaleNode{i}");
300 }
301 }
302 let mut none = mapped_preset(&male);
303 let outcome = none.copy_gender_mapping(0, 1, &unique).unwrap();
304 assert_eq!(outcome.mapped, 0);
305 assert_eq!(outcome.unmatched, none.attachments[0].bone_links[0].len());
306 assert!(none.attachments[0].bone_links[1].is_empty());
307 assert!(none.rigs[1].is_none());
308 }
@@ -173,3 +173,136 @@ fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() {
173 bad.attachments[0].bone_links[0][0].target = "nope".into();
173 bad.attachments[0].bone_links[0][0].target = "nope".into();
174 assert!(bad.encode().is_err());
174 assert!(bad.encode().is_err());
175}
175}
176
177fn female_body() -> preview::PreviewCharacter {
178 preview::load_gltf_file(
179 &PathBuf::from(env!("CARGO_MANIFEST_DIR"))
180 .join("tests/fixtures/bodies/test-body-female.glb"),
181 )
182 .unwrap()
183}
184
185fn rename_joint(
186 mut body: preview::PreviewCharacter,
187 from: &str,
188 to: &str,
189) -> preview::PreviewCharacter {
190 for name in &mut body.joint_names {
191 if name == from {
192 *name = to.to_string();
193 }
194 }
195 for node in &mut body.nodes {
196 if node.name == from {
197 node.name = to.to_string();
198 }
199 }
200 body
201}
202
203#[test]
204fn copy_skinned_links_matching_partial_and_no_overlap() {
205 let male = preview::load_gltf_file(&body_path()).unwrap();
206 let female = female_body();
207 let mut preset = mapped_preset(&male);
208 // Distinct authored offsets on two links so we can see the copy.
209 let head = preset.attachments[0]
210 .skeleton
211 .as_ref()
212 .unwrap()
213 .bones
214 .iter()
215 .position(|b| b.name == "Head")
216 .unwrap();
217 let hand = preset.attachments[0]
218 .skeleton
219 .as_ref()
220 .unwrap()
221 .bones
222 .iter()
223 .position(|b| b.name == "RightHand")
224 .unwrap();
225 for link in &mut preset.attachments[0].bone_links[0] {
226 if link.bone == head {
227 link.translation = [0.0, 0.1, 0.0];
228 }
229 if link.bone == hand {
230 link.rotation_deg = [0.0, 15.0, 0.0];
231 }
232 }
233 assert!(preset.attachments[0].bone_links[1].is_empty());
234
235 // Full name overlap.
236 let copied = preset.copy_gender_mapping(0, 1, &female).unwrap();
237 assert_eq!(copied.unmatched, 0);
238 assert_eq!(copied.mapped, preset.attachments[0].bone_links[0].len());
239 assert_eq!(
240 preset.attachments[0].bone_links[0].len(),
241 preset.attachments[0].bone_links[1].len()
242 );
243 let female_paths = preview::joint_paths(&female);
244 for src in &preset.attachments[0].bone_links[0] {
245 let dest = preset.attachments[0].bone_links[1]
246 .iter()
247 .find(|l| l.bone == src.bone)
248 .expect("copied link");
249 assert_eq!(dest.translation, src.translation);
250 assert_eq!(dest.rotation_deg, src.rotation_deg);
251 assert!(female_paths.contains(&dest.target));
252 assert_eq!(
253 equipment::joint_leaf_name(&dest.target),
254 equipment::joint_leaf_name(&src.target)
255 );
256 }
257 // Independent after the copy: changing male does not change female.
258 preset.attachments[0].bone_links[0]
259 .iter_mut()
260 .find(|l| l.bone == head)
261 .unwrap()
262 .translation = [3.0, 0.0, 0.0];
263 let female_head = preset.attachments[0].bone_links[1]
264 .iter()
265 .find(|l| l.bone == head)
266 .unwrap();
267 assert_eq!(female_head.translation, [0.0, 0.1, 0.0]);
268 let restored = Preset::decode(&preset.encode().unwrap()).unwrap();
269 assert_eq!(restored, preset);
270
271 // Partial overlap: female is missing Head; other links still copy.
272 let mut partial = mapped_preset(&male);
273 partial.attachments[0].bone_links[0]
274 .iter_mut()
275 .find(|l| l.bone == head)
276 .unwrap()
277 .translation = [0.0, 0.1, 0.0];
278 let no_head = rename_joint(female.clone(), "Head", "Skull");
279 let outcome = partial.copy_gender_mapping(0, 1, &no_head).unwrap();
280 assert_eq!(outcome.unmatched, 1);
281 assert_eq!(
282 outcome.mapped,
283 partial.attachments[0].bone_links[0].len() - 1
284 );
285 assert!(partial.attachments[0].bone_links[1]
286 .iter()
287 .all(|l| equipment::joint_leaf_name(&l.target) != "Head"));
288 assert!(partial.attachments[0].bone_links[1]
289 .iter()
290 .any(|l| equipment::joint_leaf_name(&l.target) == "RightHand"));
291
292 // No overlap at all: rename every female joint.
293 let mut unique = female.clone();
294 for (i, name) in unique.joint_names.iter_mut().enumerate() {
295 *name = format!("OnlyFemale{i}");
296 }
297 for (i, node) in unique.nodes.iter_mut().enumerate() {
298 if male.joint_names.iter().any(|n| n == &node.name) {
299 node.name = format!("OnlyFemaleNode{i}");
300 }
301 }
302 let mut none = mapped_preset(&male);
303 let outcome = none.copy_gender_mapping(0, 1, &unique).unwrap();
304 assert_eq!(outcome.mapped, 0);
305 assert_eq!(outcome.unmatched, none.attachments[0].bone_links[0].len());
306 assert!(none.attachments[0].bone_links[1].is_empty());
307 assert!(none.rigs[1].is_none());
308}