Per-sex equipment offsets and copy-on-enable (Tim t1092u)
Store rigid attach translation/rotation per sex, like the existing bone links. Enabling Male or Female on a preset that has no mapping yet copies the other sex's mapping and offsets for bones whose names exist on both bodies. .ppe container version 4; versions 1–3 still load, applying a shared offset to every enabled sex so existing atlases stay pixel-exact.
@@ -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**.76
1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.76
1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.77
2. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.77
2. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.78
3. **Add part (OBJ/FBX/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.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.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 are kept per sex; renders and the live preview use them the same way.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
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.81
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.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
**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
@@ -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 write160
160
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`).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
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.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` (`r600
600
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.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
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.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
## Decision 2026-11-08 (Tim t1032u): appimagetool from BlueGit, not GitHub610
## Decision 2026-11-08 (Tim t1032u): appimagetool from BlueGit, not GitHub@@ -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!(@@ -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);@@ -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);@@ -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 mapping2694
/// 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
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a2723
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a2662
/// character bone (optional; at least one), with its own position/rotation.2724
/// character bone (optional; at least one), with its own position/rotation.2663
fn skinned_links_ui(2725
fn 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
current2778
current2717
.and_then(|j| body.joint_names.get(j).cloned())2779
.and_then(|j| body.joint_names.get(j).cloned())@@ -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;13
use serde::{Deserialize, Serialize};13
use serde::{Deserialize, Serialize};14
14
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";16
/// Container version written; version 1 (rigid parts only) is still read.16
/// Container version written; versions 1–3 (shared rigid offsets) still load.17
pub const VERSION: u32 = 3;17
pub const VERSION: u32 = 4;18
pub const MAX_FILE: usize = 32 * 1024 * 1024;18
pub const MAX_FILE: usize = 32 * 1024 * 1024;19
const MAX_META: usize = 4 * 1024 * 1024;19
const MAX_META: usize = 4 * 1024 * 1024;20
const MAX_ASSET: usize = 8 * 1024 * 1024;20
const 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 parts151
/// that have no mapping on `to_sex` yet. Destination joints are taken from152
/// `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
|| attachment228
|| attachment202
.translation229
.translation203
.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
raw351
};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 the389
/// 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 unique408
/// 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> = body414
.joint_names415
.iter()416
.enumerate()417
.filter(|(_, name)| *name == src_name)418
.map(|(i, _)| i)419
.collect();420
if matches.is_empty() {421
matches = dest_paths422
.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_names437
.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 shared448
/// translation/rotation is applied to every enabled sex; a disabled sex keeps449
/// 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
applies460
.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 value485
.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 = value501
.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
pub fn valid_name(name: &str) -> bool {592
pub fn valid_name(name: &str) -> bool {354
!name.is_empty()593
!name.is_empty()355
&& name.len() <= 64594
&& 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,@@ -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, Strin198
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,@@ -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"),@@ -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);@@ -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
p35
p36
}36
}37
fn scratch() -> PathBuf {37
fn 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
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
out430
}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
body448
}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::VERSION469
);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 = male503
.joint_names504
.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
}@@ -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();The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -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
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
body201
}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
.skeleton211
.as_ref()212
.unwrap()213
.bones214
.iter()215
.position(|b| b.name == "Head")216
.unwrap();217
let hand = preset.attachments[0]218
.skeleton219
.as_ref()220
.unwrap()221
.bones222
.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() - 1284
);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
}