Merge feature/equipment-presets: bounded binary presets and real layered placement
@@ -4,7 +4,7 @@ Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.
4
4
5
5
## Starten
6
6
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity.
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity. Live 3D vereist OpenGL 3.3.
8
8
9
9
```bash
10
10
cargo run
@@ -31,7 +31,25 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
31
31
32
32
## Equipment en opslaan
33
33
34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**. De configuratie bewaart een unieke naam (kleine letters, cijfers, `-` en `_`) en een gekozen model. **Verwijder** verwijdert de definitie uit de projectlijst. **Opslaan** bewaart het project en equipment-configuraties in `presets/`.
34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
35
36
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.
37
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.
38
3. **Onderdeel toevoegen (OBJ)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.
39
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.
40
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.
41
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.
42
43
**Preset importeren** leest een `.ppe` uit bijvoorbeeld een ander project. Bij een andere rig moet je de botten opnieuw kiezen; er wordt niet stil geretarget. Onvolledige configuraties kunnen worden opgeslagen, maar onopgeloste onderdelen worden niet op een gegist bot getekend. **Verwijder onderdeel** haalt een onderdeel uit het werkpreset; **Verwijder equipmentdefinitie** haalt de definitie uit de projectlijst. Oude project-snapshots blijven voor herstel bewaard. Opslaan vereist een bestandssysteem met hardlinks; bij een fout wordt een bestaande snapshot niet overschreven.
44
45
Een preset is begrensd op 32 MiB, 32 onderdelen/bestanden en textures van maximaal 2048×2048. Een project heeft maximaal 64 presets en 64 MiB aan presetbestanden. De volledige containerbeschrijving staat in `design.md`.
46
47
```bash
48
# Zelfbevattend CC0-zwaardvoorbeeld in een nieuw tijdelijk project
49
cargo run --example equipment_fixture --locked
50
```
51
52

35
53
36
54
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
37
55
@@ -4,7 +4,7 @@ Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.4
4
5
## Starten5
## Starten6
6
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity.7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity. Live 3D vereist OpenGL 3.3.8
8
9
```bash9
```bash10
cargo run10
cargo run@@ -31,7 +31,25 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert31
31
32
## Equipment en opslaan32
## Equipment en opslaan33
33
34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**. De configuratie bewaart een unieke naam (kleine letters, cijfers, `-` en `_`) en een gekozen model. **Verwijder** verwijdert de definitie uit de projectlijst. **Opslaan** bewaart het project en equipment-configuraties in `presets/`.34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.35
36
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.37
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.38
3. **Onderdeel toevoegen (OBJ)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.39
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.40
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.41
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.42
43
**Preset importeren** leest een `.ppe` uit bijvoorbeeld een ander project. Bij een andere rig moet je de botten opnieuw kiezen; er wordt niet stil geretarget. Onvolledige configuraties kunnen worden opgeslagen, maar onopgeloste onderdelen worden niet op een gegist bot getekend. **Verwijder onderdeel** haalt een onderdeel uit het werkpreset; **Verwijder equipmentdefinitie** haalt de definitie uit de projectlijst. Oude project-snapshots blijven voor herstel bewaard. Opslaan vereist een bestandssysteem met hardlinks; bij een fout wordt een bestaande snapshot niet overschreven.44
45
Een preset is begrensd op 32 MiB, 32 onderdelen/bestanden en textures van maximaal 2048×2048. Een project heeft maximaal 64 presets en 64 MiB aan presetbestanden. De volledige containerbeschrijving staat in `design.md`.46
47
```bash48
# Zelfbevattend CC0-zwaardvoorbeeld in een nieuw tijdelijk project49
cargo run --example equipment_fixture --locked50
```51
52
35
53
36
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.54
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.37
55
@@ -106,6 +106,8 @@ Use orthographic projection for aligned layering. Shared camera framing, pivot,
106
106
107
107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.
108
108
109
**Confirmed production Render behavior (next feature after equipment):** enumerate **all animation clips × all 36 angles × every frame sampled at project FPS**, processing one pose/view and its real passes/maps at a time. Do not stop at the currently selected preview clip, angle or frame. Deterministic proposed nesting is source clip order → pitch -30/0/+30 → yaw 0…330 → frame → pass/map order from §8. Replace the preview after each completed real pass, immediately and without added delays. This is a full production batch requirement; the currently shipped Render action is still explicitly a single shaded body still, not this batch.
110
109
111
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.
110
112
111
113
## 7. Export naming and metadata
@@ -139,11 +141,29 @@ Equipment pass hides the body from **all color, depth, normal and specular write
139
141
140
142
## 9. Equipment editor and binary presets
141
143
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.
144
The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB 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 FBX/glTF import, deformation/weight painting and a rendered 2D compositor are not implemented by this feature.
145
146
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.
147
148
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.
149
150
### Implemented `.ppe` container, version 1
151
152
All integers below are unsigned little-endian. The container is uncompressed (no Rust memory-layout serialization and no decompression stage):
153
154
| Bytes | Meaning |
155
|---|---|
156
| 0–7 | Exact magic `PPEQ\r\n\x1a\n` |
157
| 8–11 | Schema version, exactly 1 |
158
| 12–15 | UTF-8 JSON metadata length |
159
| 16–19 | IEEE CRC32 of the **entire payload** after byte 19 |
160
| 20… | Bounded metadata, then one `u32 length + raw bytes` chunk per asset in declared order |
161
162
Metadata stores the validated name, provenance, `[male, female]` applicability flags and optional rig signatures, parts with per-sex bone paths/XYZ transforms/scalar specular/shiny index, normalized meshes (positions/normals/UVs/u32 indices/material diffuse/albedo index), and logical asset names. Asset bytes are omitted from JSON and carried in their binary chunks. Each part explicitly owns the indices of its original OBJ/MTL/declared dependency payloads. Removing a part or clearing/replacing a shiny override compacts unused assets and remaps all retained indices; adding another part cannot collide with an existing logical prefix. The reader rejects unknown fields/versions, truncation, checksum mismatches, trailing bytes, invalid links/numerics/geometry and excessive resources. CRC is corruption detection, **not authentication**.
143
163
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.
164
Limits: 32 MiB per complete container; 4 MiB metadata; 32 attachments; 32 raw assets of at most 8 MiB each; 80,000 total normalized vertices; bounded mesh/index counts; textures at most 2048×2048 and 64 MiB decoded total per preset. The shared CPU texture cache is capped at 128 MiB. Logical asset names are portable relative paths of at most 240 bytes; imports canonicalize dependencies within the chosen model directory and reject traversal/absolute references and symlink escapes. Assets are never extracted to disk on decode. Provenance is at most 4096 UTF-8 bytes; names are unique lowercase ASCII slugs of at most 64 bytes, excluding `body`. Imported duplicate names get a valid bounded suffix. Projects allow at most 64 presets, 64 MiB aggregate encoded preset data, a 4 MiB manifest and 64 KiB legacy JSON records; aggregate checks run before installing imports and before writing saved snapshots, and bounded reads preserve unloaded/error references rather than dropping them.
145
165
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.
166
Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` snapshots, then atomically saves the `.ppd` references. Flush a unique sibling temporary file, publish with a no-overwrite hardlink, and flush the directory on Unix. A filesystem without hardlink support fails safely; no unsafe overwrite fallback. Existing identical snapshots are reused, conflicting bytes never overwrite an old snapshot. Failure of the final manifest save leaves old references and bytes intact; unreferenced snapshots remain recoverable. Earlier JSON equipment records remain readable and preserved until replaced with a configured binary preset. New snapshots round-trip without consulting external model/texture files.
147
167
148
168
## 10. Rendered 2D preview
149
169
@@ -184,7 +204,7 @@ These requirements are durable even where the implementation is staged. They sup
184
204
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
185
205
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
186
206
187
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
207
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Stage 4 equipment authoring is implemented as described in §9, with binary embedded OBJ presets and actual animated bone-attached placement preview; legacy JSON records remain readable. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
188
208
189
209
## 12. Incremental feature plan and acceptance gates
190
210
@@ -106,6 +106,8 @@ Use orthographic projection for aligned layering. Shared camera framing, pivot,106
106
107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.108
108
109
**Confirmed production Render behavior (next feature after equipment):** enumerate **all animation clips × all 36 angles × every frame sampled at project FPS**, processing one pose/view and its real passes/maps at a time. Do not stop at the currently selected preview clip, angle or frame. Deterministic proposed nesting is source clip order → pitch -30/0/+30 → yaw 0…330 → frame → pass/map order from §8. Replace the preview after each completed real pass, immediately and without added delays. This is a full production batch requirement; the currently shipped Render action is still explicitly a single shaded body still, not this batch.110
109
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.111
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.110
112
111
## 7. Export naming and metadata113
## 7. Export naming and metadata@@ -139,11 +141,29 @@ Equipment pass hides the body from **all color, depth, normal and specular write139
141
140
## 9. Equipment editor and binary presets142
## 9. Equipment editor and binary presets141
143
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.144
The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB 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 FBX/glTF import, deformation/weight painting and a rendered 2D compositor are not implemented by this feature.145
146
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.147
148
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.149
150
### Implemented `.ppe` container, version 1151
152
All integers below are unsigned little-endian. The container is uncompressed (no Rust memory-layout serialization and no decompression stage):153
154
| Bytes | Meaning |155
|---|---|156
| 0–7 | Exact magic `PPEQ\r\n\x1a\n` |157
| 8–11 | Schema version, exactly 1 |158
| 12–15 | UTF-8 JSON metadata length |159
| 16–19 | IEEE CRC32 of the **entire payload** after byte 19 |160
| 20… | Bounded metadata, then one `u32 length + raw bytes` chunk per asset in declared order |161
162
Metadata stores the validated name, provenance, `[male, female]` applicability flags and optional rig signatures, parts with per-sex bone paths/XYZ transforms/scalar specular/shiny index, normalized meshes (positions/normals/UVs/u32 indices/material diffuse/albedo index), and logical asset names. Asset bytes are omitted from JSON and carried in their binary chunks. Each part explicitly owns the indices of its original OBJ/MTL/declared dependency payloads. Removing a part or clearing/replacing a shiny override compacts unused assets and remaps all retained indices; adding another part cannot collide with an existing logical prefix. The reader rejects unknown fields/versions, truncation, checksum mismatches, trailing bytes, invalid links/numerics/geometry and excessive resources. CRC is corruption detection, **not authentication**.143
163
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.164
Limits: 32 MiB per complete container; 4 MiB metadata; 32 attachments; 32 raw assets of at most 8 MiB each; 80,000 total normalized vertices; bounded mesh/index counts; textures at most 2048×2048 and 64 MiB decoded total per preset. The shared CPU texture cache is capped at 128 MiB. Logical asset names are portable relative paths of at most 240 bytes; imports canonicalize dependencies within the chosen model directory and reject traversal/absolute references and symlink escapes. Assets are never extracted to disk on decode. Provenance is at most 4096 UTF-8 bytes; names are unique lowercase ASCII slugs of at most 64 bytes, excluding `body`. Imported duplicate names get a valid bounded suffix. Projects allow at most 64 presets, 64 MiB aggregate encoded preset data, a 4 MiB manifest and 64 KiB legacy JSON records; aggregate checks run before installing imports and before writing saved snapshots, and bounded reads preserve unloaded/error references rather than dropping them.145
165
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.166
Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` snapshots, then atomically saves the `.ppd` references. Flush a unique sibling temporary file, publish with a no-overwrite hardlink, and flush the directory on Unix. A filesystem without hardlink support fails safely; no unsafe overwrite fallback. Existing identical snapshots are reused, conflicting bytes never overwrite an old snapshot. Failure of the final manifest save leaves old references and bytes intact; unreferenced snapshots remain recoverable. Earlier JSON equipment records remain readable and preserved until replaced with a configured binary preset. New snapshots round-trip without consulting external model/texture files.147
167
148
## 10. Rendered 2D preview168
## 10. Rendered 2D preview149
169
@@ -184,7 +204,7 @@ These requirements are durable even where the implementation is staged. They sup184
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.204
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.185
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.205
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.186
206
187
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.207
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Stage 4 equipment authoring is implemented as described in §9, with binary embedded OBJ presets and actual animated bone-attached placement preview; legacy JSON records remain readable. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.188
208
189
## 12. Incremental feature plan and acceptance gates209
## 12. Incremental feature plan and acceptance gates190
210
The contents of this binary file are not shown.
@@ -0,0 +1,64 @@
1
//! Prepare a self-contained example project and show its equipment editor.
2
use paperdoll_renderer::{
3
editor::EditorApp,
4
equipment, preview,
5
session::{Session, Sex},
6
};
7
use std::path::PathBuf;
8
fn main() -> Result<(), Box<dyn std::error::Error>> {
9
let args: Vec<String> = std::env::args().skip(1).collect();
10
let prepare_only = args.iter().any(|x| x == "--prepare-only");
11
let root = args
12
.iter()
13
.find(|x| !x.starts_with('-'))
14
.map(PathBuf::from)
15
.unwrap_or_else(|| {
16
std::env::temp_dir().join(format!(
17
"paperdoll-equipment-demo-{}",
18
std::time::SystemTime::now()
19
.duration_since(std::time::UNIX_EPOCH)
20
.unwrap()
21
.as_nanos()
22
))
23
});
24
let mut session = Session::new();
25
session.install_new_project(&root)?;
26
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");
27
let mut preset = equipment::Preset::new("sword".into());
28
preset.provenance = "CC0-1.0 generated fixture".into();
29
equipment::import_obj(&mut preset, &fixtures.join("sword.obj"))?;
30
equipment::import_shiny(&mut preset, 0, &fixtures.join("shiny.png"))?;
31
for (sex, name) in [(0, "test-body-male.glb"), (1, "test-body-female.glb")] {
32
let body = preview::load_gltf_file(&root.join("assets/body").join(name))?;
33
let joint = body
34
.joint_names
35
.iter()
36
.position(|n| n == "RightHand")
37
.ok_or("missing RightHand")?;
38
preset.bind(sex, &body, 0, joint)?;
39
}
40
preset.attachments[0].rotation_deg = [0., 0., -15.];
41
let index = session.add_equipment();
42
session.equipment[index].slug = "sword".into();
43
session.equipment[index].preset = Some(preset);
44
session.save()?;
45
println!("{}", root.join("character.ppd").display());
46
if prepare_only {
47
return Ok(());
48
}
49
let options = eframe::NativeOptions {
50
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),
51
..paperdoll_renderer::preview_gl::native_options()
52
};
53
eframe::run_native(
54
"Paperdoll Equipment",
55
options,
56
Box::new(move |_| {
57
let mut app = EditorApp::default();
58
app.open_path(root.join("character.ppd"));
59
app.open_equipment_configuration(0, Sex::Male);
60
Ok(Box::new(app))
61
}),
62
)?;
63
Ok(())
64
}
@@ -0,0 +1,64 @@1
//! Prepare a self-contained example project and show its equipment editor.2
use paperdoll_renderer::{3
editor::EditorApp,4
equipment, preview,5
session::{Session, Sex},6
};7
use std::path::PathBuf;8
fn main() -> Result<(), Box<dyn std::error::Error>> {9
let args: Vec<String> = std::env::args().skip(1).collect();10
let prepare_only = args.iter().any(|x| x == "--prepare-only");11
let root = args12
.iter()13
.find(|x| !x.starts_with('-'))14
.map(PathBuf::from)15
.unwrap_or_else(|| {16
std::env::temp_dir().join(format!(17
"paperdoll-equipment-demo-{}",18
std::time::SystemTime::now()19
.duration_since(std::time::UNIX_EPOCH)20
.unwrap()21
.as_nanos()22
))23
});24
let mut session = Session::new();25
session.install_new_project(&root)?;26
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");27
let mut preset = equipment::Preset::new("sword".into());28
preset.provenance = "CC0-1.0 generated fixture".into();29
equipment::import_obj(&mut preset, &fixtures.join("sword.obj"))?;30
equipment::import_shiny(&mut preset, 0, &fixtures.join("shiny.png"))?;31
for (sex, name) in [(0, "test-body-male.glb"), (1, "test-body-female.glb")] {32
let body = preview::load_gltf_file(&root.join("assets/body").join(name))?;33
let joint = body34
.joint_names35
.iter()36
.position(|n| n == "RightHand")37
.ok_or("missing RightHand")?;38
preset.bind(sex, &body, 0, joint)?;39
}40
preset.attachments[0].rotation_deg = [0., 0., -15.];41
let index = session.add_equipment();42
session.equipment[index].slug = "sword".into();43
session.equipment[index].preset = Some(preset);44
session.save()?;45
println!("{}", root.join("character.ppd").display());46
if prepare_only {47
return Ok(());48
}49
let options = eframe::NativeOptions {50
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),51
..paperdoll_renderer::preview_gl::native_options()52
};53
eframe::run_native(54
"Paperdoll Equipment",55
options,56
Box::new(move |_| {57
let mut app = EditorApp::default();58
app.open_path(root.join("character.ppd"));59
app.open_equipment_configuration(0, Sex::Male);60
Ok(Box::new(app))61
}),62
)?;63
Ok(())64
}@@ -1,8 +1,8 @@
1
1
//! One place starts file dialogs and collects the result.
2
2
//!
3
//! GTK dialogs block on the UI thread, so `poll` runs them there. A backend
4
//! that returns before the choice is ready reports [`DialogPoll::Pending`]
5
//! and the editor keeps asking.
3
//! The native picker runs on a dedicated request thread; `poll` never waits.
4
//! [`DialogPoll::Pending`] keeps the editor repainting until an answer arrives.
5
//! Scripted backends exercise the same request/result boundary in tests.
6
6
7
7
use std::path::{Path, PathBuf};
8
8
@@ -12,6 +12,8 @@ pub enum DialogPurpose {
12
12
LoadProject,
13
13
Body(crate::session::Sex),
14
14
EquipmentModel(usize),
15
EquipmentShiny(usize, usize),
16
ImportEquipment,
15
17
}
16
18
17
19
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -127,8 +129,16 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
127
129
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])
128
130
.pick_file(),
129
131
DialogPurpose::EquipmentModel(_) => dialog
130
.set_title("Equipmentmodel")
131
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])
132
.set_title("Rigide equipment toevoegen")
133
.add_filter("Wavefront OBJ", &["obj"])
134
.pick_file(),
135
DialogPurpose::EquipmentShiny(_, _) => dialog
136
.set_title("Shinymap")
137
.add_filter("PNG", &["png"])
138
.pick_file(),
139
DialogPurpose::ImportEquipment => dialog
140
.set_title("Equipment-preset importeren")
141
.add_filter("Paperdoll equipment", &["ppe"])
132
142
.pick_file(),
133
143
}
134
144
}
@@ -1,8 +1,8 @@1
//! One place starts file dialogs and collects the result.1
//! One place starts file dialogs and collects the result.2
//!2
//!3
//! GTK dialogs block on the UI thread, so `poll` runs them there. A backend3
//! The native picker runs on a dedicated request thread; `poll` never waits.4
//! that returns before the choice is ready reports [`DialogPoll::Pending`]4
//! [`DialogPoll::Pending`] keeps the editor repainting until an answer arrives.5
//! and the editor keeps asking.5
//! Scripted backends exercise the same request/result boundary in tests.6
6
7
use std::path::{Path, PathBuf};7
use std::path::{Path, PathBuf};8
8
@@ -12,6 +12,8 @@ pub enum DialogPurpose {12
LoadProject,12
LoadProject,13
Body(crate::session::Sex),13
Body(crate::session::Sex),14
EquipmentModel(usize),14
EquipmentModel(usize),15
EquipmentShiny(usize, usize),16
ImportEquipment,15
}17
}16
18
17
#[derive(Debug, Clone, PartialEq, Eq)]19
#[derive(Debug, Clone, PartialEq, Eq)]@@ -127,8 +129,16 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {127
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])129
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])128
.pick_file(),130
.pick_file(),129
DialogPurpose::EquipmentModel(_) => dialog131
DialogPurpose::EquipmentModel(_) => dialog130
.set_title("Equipmentmodel")132
.set_title("Rigide equipment toevoegen")131
.add_filter("Model", &["glb", "gltf", "fbx", "obj"])133
.add_filter("Wavefront OBJ", &["obj"])134
.pick_file(),135
DialogPurpose::EquipmentShiny(_, _) => dialog136
.set_title("Shinymap")137
.add_filter("PNG", &["png"])138
.pick_file(),139
DialogPurpose::ImportEquipment => dialog140
.set_title("Equipment-preset importeren")141
.add_filter("Paperdoll equipment", &["ppe"])132
.pick_file(),142
.pick_file(),133
}143
}134
}144
}@@ -29,6 +29,7 @@ pub struct EditorApp {
29
29
fps_text: String,
30
30
allow_close: bool,
31
31
dialog_pending: bool,
32
equipment_part: usize,
32
33
}
33
34
34
35
impl EditorApp {
@@ -49,6 +50,7 @@ impl EditorApp {
49
50
fps_text: "12".to_string(),
50
51
allow_close: false,
51
52
dialog_pending: false,
53
equipment_part: 0,
52
54
}
53
55
}
54
56
@@ -179,6 +181,47 @@ impl EditorApp {
179
181
},
180
182
DialogPurpose::Body(sex) => self.take_model(sex, &path),
181
183
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
184
DialogPurpose::EquipmentShiny(index, part) => {
185
if let Some(mut preset) = self
186
.session
187
.equipment
188
.get(index)
189
.and_then(|i| i.preset.clone())
190
{
191
let result = crate::equipment::import_shiny(&mut preset, part, &path)
192
.and_then(|_| self.session.ensure_preset_budget(Some(index), &preset));
193
match result {
194
Ok(()) => self.session.equipment[index].preset = Some(preset),
195
Err(error) => self.session.fail(error),
196
}
197
}
198
}
199
DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
200
Ok(mut preset) => {
201
let result = self
202
.session
203
.available_equipment_name(&preset.name)
204
.and_then(|name| {
205
preset.name = name;
206
self.session.ensure_preset_budget(None, &preset)
207
});
208
if let Err(error) = result {
209
self.session.fail(error);
210
return;
211
}
212
let index = self.session.equipment.len();
213
self.session.equipment.push(crate::session::EquipmentItem {
214
slug: preset.name.clone(),
215
model: preset.attachments.first().map(|p| p.label.clone()),
216
preset: Some(preset),
217
legacy_path: None,
218
error: None,
219
});
220
self.equipment_part = 0;
221
self.session.show_equipment(index);
222
}
223
Err(error) => self.session.fail(error),
224
},
182
225
}
183
226
}
184
227
}
@@ -205,19 +248,56 @@ impl EditorApp {
205
248
}
206
249
207
250
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
208
let Some(root) = self.session.project_root() else {
251
let sex = if self.session.active_body == Some(Sex::Female) {
252
1
253
} else {
254
0
255
};
256
let body = if sex == 0 {
257
self.preview_male.as_ref()
258
} else {
259
self.preview_female.as_ref()
260
};
261
let Some(item) = self.session.equipment.get(index) else {
209
262
return;
210
263
};
211
match adopt_file(&root, path, "assets/equipment") {
212
Ok(relative) => {
213
self.session.set_equipment_model(index, relative.clone());
214
self.session.notice = format!("{relative} hoort bij deze equipment.");
264
let mut preset = item
265
.preset
266
.clone()
267
.unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));
268
preset.name = item.slug.clone();
269
match crate::equipment::import_obj(&mut preset, path) {
270
Ok(part) => {
271
if let Some(body) = body {
272
if !body.joint_nodes.is_empty() {
273
let _ = preset.bind(sex, body, part, 0);
274
}
275
}
276
if let Err(error) = self.session.ensure_preset_budget(Some(index), &preset) {
277
self.session.fail(error);
278
return;
279
}
280
let item = &mut self.session.equipment[index];
281
item.model = Some(preset.attachments[part].label.clone());
282
item.legacy_path = None;
283
item.error = None;
284
item.preset = Some(preset);
285
self.equipment_part = part;
286
self.session.notice =
287
"Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."
288
.into();
215
289
self.session.notice_ok = true;
216
290
}
217
291
Err(error) => self.session.fail(error),
218
292
}
219
293
}
220
294
295
pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {
296
self.session.active_body = Some(sex);
297
self.session.show_equipment(index);
298
self.equipment_part = 0;
299
}
300
221
301
fn begin(&mut self, intent: Intent) {
222
302
if self.session.gate(intent) {
223
303
self.run_intent(intent);
@@ -357,6 +437,11 @@ impl Default for EditorApp {
357
437
}
358
438
359
439
impl eframe::App for EditorApp {
440
fn on_exit(&mut self, gl: Option<&glow::Context>) {
441
if let Some(gl) = gl {
442
self.viewport.destroy(gl);
443
}
444
}
360
445
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
361
446
let shell = self.session.shell_size();
362
447
if self.applied_shell != Some(shell) {
@@ -587,7 +672,7 @@ impl EditorApp {
587
672
PreviewSex::Female => self.preview_female.as_ref(),
588
673
};
589
674
if character.is_some() {
590
self.viewport.show(ui, &mut self.controls, character);
675
self.viewport.show(ui, &mut self.controls, character, &[]);
591
676
} else {
592
677
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
593
678
}
@@ -655,9 +740,25 @@ impl EditorApp {
655
740
open = Some(index);
656
741
}
657
742
}
658
if ui.button("Nieuw").clicked() {
743
if ui
744
.add_enabled(
745
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
746
egui::Button::new("Nieuw"),
747
)
748
.clicked()
749
{
750
self.equipment_part = 0;
659
751
self.session.add_equipment();
660
752
}
753
if ui
754
.add_enabled(
755
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
756
egui::Button::new("Preset importeren"),
757
)
758
.clicked()
759
{
760
self.dialogs.start(DialogPurpose::ImportEquipment);
761
}
661
762
if let Some(index) = open {
662
763
self.session.show_equipment(index);
663
764
}
@@ -669,31 +770,197 @@ impl EditorApp {
669
770
let Screen::Equipment(index) = self.session.screen else {
670
771
return;
671
772
};
773
let mut sex = if self.session.active_body == Some(Sex::Female) {
774
1
775
} else {
776
0
777
};
778
egui::SidePanel::left("equipment-config")
779
.default_width(330.0)
780
.resizable(true)
781
.show(ctx, |ui| {
782
if self.session.confirm.is_some() || self.dialog_pending {
783
ui.disable();
784
}
785
egui::ScrollArea::vertical().show(ui, |ui| {
786
ui.heading("Equipment-configuratie");
787
if ui.button("Terug naar equipment").clicked() {
788
self.session.show_equipment_list();
789
}
790
ui.horizontal(|ui| {
791
ui.selectable_value(&mut sex, 0, "Man");
792
ui.selectable_value(&mut sex, 1, "Vrouw");
793
});
794
self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
795
let Some(item) = self.session.equipment.get_mut(index) else {
796
return;
797
};
798
ui.label("Naam (kleine letters/cijfers/-/_)");
799
ui.text_edit_singleline(&mut item.slug);
800
if !crate::equipment::valid_name(&item.slug) {
801
ui.colored_label(
802
egui::Color32::LIGHT_RED,
803
"Ongeldige naam; 'body' is gereserveerd.",
804
);
805
}
806
if let Some(error) = &item.error {
807
ui.colored_label(egui::Color32::LIGHT_RED, error);
808
}
809
if ui.button("Onderdeel toevoegen (OBJ)").clicked() {
810
self.dialogs.start(DialogPurpose::EquipmentModel(index));
811
}
812
if let Some(preset) = &mut item.preset {
813
ui.horizontal(|ui| {
814
ui.label("Geldig voor:");
815
ui.checkbox(&mut preset.applies[0], "Man");
816
ui.checkbox(&mut preset.applies[1], "Vrouw");
817
});
818
if !preset.applies[0] && !preset.applies[1] {
819
ui.colored_label(
820
egui::Color32::LIGHT_RED,
821
"Kies minstens één geslacht.",
822
);
823
}
824
ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");
825
ui.add(
826
egui::TextEdit::multiline(&mut preset.provenance)
827
.desired_rows(2)
828
.char_limit(4096),
829
);
830
if preset.provenance.len() > 4096 {
831
ui.colored_label(
832
egui::Color32::LIGHT_RED,
833
"Herkomst/licentie is te lang.",
834
);
835
}
836
if !preset.attachments.is_empty() {
837
self.equipment_part =
838
self.equipment_part.min(preset.attachments.len() - 1);
839
egui::ComboBox::from_id_salt("equipment-part")
840
.selected_text(&preset.attachments[self.equipment_part].label)
841
.show_ui(ui, |ui| {
842
for (part, item) in preset.attachments.iter().enumerate() {
843
ui.selectable_value(
844
&mut self.equipment_part,
845
part,
846
&item.label,
847
);
848
}
849
});
850
let body = if sex == 0 {
851
self.preview_male.as_ref()
852
} else {
853
self.preview_female.as_ref()
854
};
855
let attachment = &mut preset.attachments[self.equipment_part];
856
ui.label("Positie relatief aan bot (XYZ)");
857
ui.horizontal(|ui| {
858
for value in &mut attachment.translation {
859
ui.add(
860
egui::DragValue::new(value)
861
.speed(0.005)
862
.range(-1000.0..=1000.0),
863
);
864
}
865
});
866
ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
867
ui.horizontal(|ui| {
868
for value in &mut attachment.rotation_deg {
869
ui.add(
870
egui::DragValue::new(value)
871
.speed(0.5)
872
.range(-3600.0..=3600.0),
873
);
874
}
875
});
876
ui.add(
877
egui::Slider::new(&mut attachment.specular, 0.0..=1.0)
878
.text("Glanssterkte"),
879
);
880
let shiny = attachment
881
.shiny
882
.and_then(|i| preset.assets.get(i))
883
.map(|a| a.path.clone());
884
ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
885
if ui.button("Kies shinymap (PNG)").clicked() {
886
self.dialogs.start(DialogPurpose::EquipmentShiny(
887
index,
888
self.equipment_part,
889
));
890
}
891
let clear_shiny = ui.button("Wis shinymap").clicked();
892
let selected = attachment.bones[sex].clone();
893
if clear_shiny {
894
if let Err(error) =
895
crate::equipment::clear_shiny(preset, self.equipment_part)
896
{
897
item.error = Some(error);
898
}
899
}
900
ui.label(format!(
901
"Bot: {}",
902
selected.as_deref().unwrap_or("nog niet gekoppeld")
903
));
904
if let Some(body) = body {
905
if preset.rigs[sex].as_ref()
906
!= Some(&crate::equipment::RigSignature::of(body))
907
{
908
ui.colored_label(
909
egui::Color32::LIGHT_RED,
910
"Rigbinding ontbreekt of wijkt af; selecteer een bot.",
911
);
912
}
913
ui.collapsing("Skeleton / botten", |ui| {
914
if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
915
if let Err(error) =
916
preset.bind(sex, body, self.equipment_part, joint)
917
{
918
ui.colored_label(egui::Color32::LIGHT_RED, error);
919
}
920
}
921
});
922
} else {
923
ui.colored_label(
924
egui::Color32::LIGHT_RED,
925
"Open een geskind glTF/GLB-lichaam voor deze rigbinding.",
926
);
927
}
928
if ui.button("Verwijder onderdeel").clicked() {
929
if let Err(error) =
930
crate::equipment::remove_attachment(preset, self.equipment_part)
931
{
932
item.error = Some(error);
933
}
934
self.equipment_part = self.equipment_part.saturating_sub(1);
935
}
936
}
937
}
938
if ui.button("Verwijder equipmentdefinitie").clicked() {
939
self.session.delete_equipment(index);
940
}
941
});
942
});
943
self.controls.sex = if sex == 0 {
944
PreviewSex::Male
945
} else {
946
PreviewSex::Female
947
};
672
948
egui::CentralPanel::default().show(ctx, |ui| {
673
949
if self.session.confirm.is_some() || self.dialog_pending {
674
950
ui.disable();
675
951
}
676
ui.heading("Equipment");
677
if ui.button("Terug").clicked() {
678
self.session.show_equipment_list();
679
}
680
if let Some(item) = self.session.equipment.get_mut(index) {
681
ui.horizontal(|ui| {
682
ui.label("Naam");
683
ui.text_edit_singleline(&mut item.slug);
684
});
685
let model = item
686
.model
687
.clone()
688
.unwrap_or_else(|| "geen model".to_string());
689
ui.label(model);
690
}
691
if ui.button("Kies model").clicked() {
692
self.dialogs.start(DialogPurpose::EquipmentModel(index));
693
}
694
if ui.button("Verwijder").clicked() {
695
self.session.delete_equipment(index);
696
}
952
let body = if sex == 0 {
953
self.preview_male.as_ref()
954
} else {
955
self.preview_female.as_ref()
956
};
957
let preset = self
958
.session
959
.equipment
960
.get(index)
961
.and_then(|item| item.preset.as_ref());
962
let presets: Vec<_> = preset.into_iter().collect();
963
self.viewport.show(ui, &mut self.controls, body, &presets);
697
964
});
698
965
}
699
966
@@ -729,6 +996,69 @@ impl EditorApp {
729
996
}
730
997
}
731
998
999
fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {
1000
fn node(
1001
ui: &mut egui::Ui,
1002
body: &PreviewCharacter,
1003
paths: &[String],
1004
index: usize,
1005
selected: Option<&str>,
1006
choice: &mut Option<usize>,
1007
depth: usize,
1008
) {
1009
if depth > 64 {
1010
return;
1011
}
1012
let joint = body.joint_nodes.iter().position(|i| *i == index);
1013
let children: Vec<_> = body
1014
.nodes
1015
.iter()
1016
.enumerate()
1017
.filter(|(_, n)| n.parent == Some(index))
1018
.map(|(i, _)| i)
1019
.collect();
1020
let label = if body.nodes[index].name.is_empty() {
1021
format!("node-{index}")
1022
} else {
1023
body.nodes[index].name.clone()
1024
};
1025
let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {
1026
if let Some(joint) = joint {
1027
if ui
1028
.selectable_label(
1029
selected == Some(paths[joint].as_str()),
1030
format!("Koppel {label}"),
1031
)
1032
.clicked()
1033
{
1034
*choice = Some(joint);
1035
}
1036
}
1037
};
1038
if children.is_empty() {
1039
select(ui, choice);
1040
} else {
1041
egui::CollapsingHeader::new(&label)
1042
.id_salt(index)
1043
.default_open(depth < 2)
1044
.show(ui, |ui| {
1045
select(ui, choice);
1046
for child in children {
1047
node(ui, body, paths, child, selected, choice, depth + 1);
1048
}
1049
});
1050
}
1051
}
1052
let mut choice = None;
1053
let paths = crate::preview::joint_paths(body);
1054
for (index, item) in body.nodes.iter().enumerate() {
1055
if item.parent.is_none() {
1056
node(ui, body, &paths, index, selected, &mut choice, 0);
1057
}
1058
}
1059
choice
1060
}
1061
732
1062
#[cfg(test)]
733
1063
mod tests {
734
1064
use super::*;
@@ -29,6 +29,7 @@ pub struct EditorApp {29
fps_text: String,29
fps_text: String,30
allow_close: bool,30
allow_close: bool,31
dialog_pending: bool,31
dialog_pending: bool,32
equipment_part: usize,32
}33
}33
34
34
impl EditorApp {35
impl EditorApp {@@ -49,6 +50,7 @@ impl EditorApp {49
fps_text: "12".to_string(),50
fps_text: "12".to_string(),50
allow_close: false,51
allow_close: false,51
dialog_pending: false,52
dialog_pending: false,53
equipment_part: 0,52
}54
}53
}55
}54
56
@@ -179,6 +181,47 @@ impl EditorApp {179
},181
},180
DialogPurpose::Body(sex) => self.take_model(sex, &path),182
DialogPurpose::Body(sex) => self.take_model(sex, &path),181
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),183
DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),184
DialogPurpose::EquipmentShiny(index, part) => {185
if let Some(mut preset) = self186
.session187
.equipment188
.get(index)189
.and_then(|i| i.preset.clone())190
{191
let result = crate::equipment::import_shiny(&mut preset, part, &path)192
.and_then(|_| self.session.ensure_preset_budget(Some(index), &preset));193
match result {194
Ok(()) => self.session.equipment[index].preset = Some(preset),195
Err(error) => self.session.fail(error),196
}197
}198
}199
DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {200
Ok(mut preset) => {201
let result = self202
.session203
.available_equipment_name(&preset.name)204
.and_then(|name| {205
preset.name = name;206
self.session.ensure_preset_budget(None, &preset)207
});208
if let Err(error) = result {209
self.session.fail(error);210
return;211
}212
let index = self.session.equipment.len();213
self.session.equipment.push(crate::session::EquipmentItem {214
slug: preset.name.clone(),215
model: preset.attachments.first().map(|p| p.label.clone()),216
preset: Some(preset),217
legacy_path: None,218
error: None,219
});220
self.equipment_part = 0;221
self.session.show_equipment(index);222
}223
Err(error) => self.session.fail(error),224
},182
}225
}183
}226
}184
}227
}@@ -205,19 +248,56 @@ impl EditorApp {205
}248
}206
249
207
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {250
fn take_equipment(&mut self, index: usize, path: &std::path::Path) {208
let Some(root) = self.session.project_root() else {251
let sex = if self.session.active_body == Some(Sex::Female) {252
1253
} else {254
0255
};256
let body = if sex == 0 {257
self.preview_male.as_ref()258
} else {259
self.preview_female.as_ref()260
};261
let Some(item) = self.session.equipment.get(index) else {209
return;262
return;210
};263
};211
match adopt_file(&root, path, "assets/equipment") {264
let mut preset = item212
Ok(relative) => {265
.preset213
self.session.set_equipment_model(index, relative.clone());266
.clone()214
self.session.notice = format!("{relative} hoort bij deze equipment.");267
.unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));268
preset.name = item.slug.clone();269
match crate::equipment::import_obj(&mut preset, path) {270
Ok(part) => {271
if let Some(body) = body {272
if !body.joint_nodes.is_empty() {273
let _ = preset.bind(sex, body, part, 0);274
}275
}276
if let Err(error) = self.session.ensure_preset_budget(Some(index), &preset) {277
self.session.fail(error);278
return;279
}280
let item = &mut self.session.equipment[index];281
item.model = Some(preset.attachments[part].label.clone());282
item.legacy_path = None;283
item.error = None;284
item.preset = Some(preset);285
self.equipment_part = part;286
self.session.notice =287
"Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot."288
.into();215
self.session.notice_ok = true;289
self.session.notice_ok = true;216
}290
}217
Err(error) => self.session.fail(error),291
Err(error) => self.session.fail(error),218
}292
}219
}293
}220
294
295
pub fn open_equipment_configuration(&mut self, index: usize, sex: Sex) {296
self.session.active_body = Some(sex);297
self.session.show_equipment(index);298
self.equipment_part = 0;299
}300
221
fn begin(&mut self, intent: Intent) {301
fn begin(&mut self, intent: Intent) {222
if self.session.gate(intent) {302
if self.session.gate(intent) {223
self.run_intent(intent);303
self.run_intent(intent);@@ -357,6 +437,11 @@ impl Default for EditorApp {357
}437
}358
438
359
impl eframe::App for EditorApp {439
impl eframe::App for EditorApp {440
fn on_exit(&mut self, gl: Option<&glow::Context>) {441
if let Some(gl) = gl {442
self.viewport.destroy(gl);443
}444
}360
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {445
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {361
let shell = self.session.shell_size();446
let shell = self.session.shell_size();362
if self.applied_shell != Some(shell) {447
if self.applied_shell != Some(shell) {@@ -587,7 +672,7 @@ impl EditorApp {587
PreviewSex::Female => self.preview_female.as_ref(),672
PreviewSex::Female => self.preview_female.as_ref(),588
};673
};589
if character.is_some() {674
if character.is_some() {590
self.viewport.show(ui, &mut self.controls, character);675
self.viewport.show(ui, &mut self.controls, character, &[]);591
} else {676
} else {592
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");677
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");593
}678
}@@ -655,9 +740,25 @@ impl EditorApp {655
open = Some(index);740
open = Some(index);656
}741
}657
}742
}658
if ui.button("Nieuw").clicked() {743
if ui744
.add_enabled(745
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,746
egui::Button::new("Nieuw"),747
)748
.clicked()749
{750
self.equipment_part = 0;659
self.session.add_equipment();751
self.session.add_equipment();660
}752
}753
if ui754
.add_enabled(755
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,756
egui::Button::new("Preset importeren"),757
)758
.clicked()759
{760
self.dialogs.start(DialogPurpose::ImportEquipment);761
}661
if let Some(index) = open {762
if let Some(index) = open {662
self.session.show_equipment(index);763
self.session.show_equipment(index);663
}764
}@@ -669,31 +770,197 @@ impl EditorApp {669
let Screen::Equipment(index) = self.session.screen else {770
let Screen::Equipment(index) = self.session.screen else {670
return;771
return;671
};772
};773
let mut sex = if self.session.active_body == Some(Sex::Female) {774
1775
} else {776
0777
};778
egui::SidePanel::left("equipment-config")779
.default_width(330.0)780
.resizable(true)781
.show(ctx, |ui| {782
if self.session.confirm.is_some() || self.dialog_pending {783
ui.disable();784
}785
egui::ScrollArea::vertical().show(ui, |ui| {786
ui.heading("Equipment-configuratie");787
if ui.button("Terug naar equipment").clicked() {788
self.session.show_equipment_list();789
}790
ui.horizontal(|ui| {791
ui.selectable_value(&mut sex, 0, "Man");792
ui.selectable_value(&mut sex, 1, "Vrouw");793
});794
self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });795
let Some(item) = self.session.equipment.get_mut(index) else {796
return;797
};798
ui.label("Naam (kleine letters/cijfers/-/_)");799
ui.text_edit_singleline(&mut item.slug);800
if !crate::equipment::valid_name(&item.slug) {801
ui.colored_label(802
egui::Color32::LIGHT_RED,803
"Ongeldige naam; 'body' is gereserveerd.",804
);805
}806
if let Some(error) = &item.error {807
ui.colored_label(egui::Color32::LIGHT_RED, error);808
}809
if ui.button("Onderdeel toevoegen (OBJ)").clicked() {810
self.dialogs.start(DialogPurpose::EquipmentModel(index));811
}812
if let Some(preset) = &mut item.preset {813
ui.horizontal(|ui| {814
ui.label("Geldig voor:");815
ui.checkbox(&mut preset.applies[0], "Man");816
ui.checkbox(&mut preset.applies[1], "Vrouw");817
});818
if !preset.applies[0] && !preset.applies[1] {819
ui.colored_label(820
egui::Color32::LIGHT_RED,821
"Kies minstens één geslacht.",822
);823
}824
ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");825
ui.add(826
egui::TextEdit::multiline(&mut preset.provenance)827
.desired_rows(2)828
.char_limit(4096),829
);830
if preset.provenance.len() > 4096 {831
ui.colored_label(832
egui::Color32::LIGHT_RED,833
"Herkomst/licentie is te lang.",834
);835
}836
if !preset.attachments.is_empty() {837
self.equipment_part =838
self.equipment_part.min(preset.attachments.len() - 1);839
egui::ComboBox::from_id_salt("equipment-part")840
.selected_text(&preset.attachments[self.equipment_part].label)841
.show_ui(ui, |ui| {842
for (part, item) in preset.attachments.iter().enumerate() {843
ui.selectable_value(844
&mut self.equipment_part,845
part,846
&item.label,847
);848
}849
});850
let body = if sex == 0 {851
self.preview_male.as_ref()852
} else {853
self.preview_female.as_ref()854
};855
let attachment = &mut preset.attachments[self.equipment_part];856
ui.label("Positie relatief aan bot (XYZ)");857
ui.horizontal(|ui| {858
for value in &mut attachment.translation {859
ui.add(860
egui::DragValue::new(value)861
.speed(0.005)862
.range(-1000.0..=1000.0),863
);864
}865
});866
ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");867
ui.horizontal(|ui| {868
for value in &mut attachment.rotation_deg {869
ui.add(870
egui::DragValue::new(value)871
.speed(0.5)872
.range(-3600.0..=3600.0),873
);874
}875
});876
ui.add(877
egui::Slider::new(&mut attachment.specular, 0.0..=1.0)878
.text("Glanssterkte"),879
);880
let shiny = attachment881
.shiny882
.and_then(|i| preset.assets.get(i))883
.map(|a| a.path.clone());884
ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));885
if ui.button("Kies shinymap (PNG)").clicked() {886
self.dialogs.start(DialogPurpose::EquipmentShiny(887
index,888
self.equipment_part,889
));890
}891
let clear_shiny = ui.button("Wis shinymap").clicked();892
let selected = attachment.bones[sex].clone();893
if clear_shiny {894
if let Err(error) =895
crate::equipment::clear_shiny(preset, self.equipment_part)896
{897
item.error = Some(error);898
}899
}900
ui.label(format!(901
"Bot: {}",902
selected.as_deref().unwrap_or("nog niet gekoppeld")903
));904
if let Some(body) = body {905
if preset.rigs[sex].as_ref()906
!= Some(&crate::equipment::RigSignature::of(body))907
{908
ui.colored_label(909
egui::Color32::LIGHT_RED,910
"Rigbinding ontbreekt of wijkt af; selecteer een bot.",911
);912
}913
ui.collapsing("Skeleton / botten", |ui| {914
if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {915
if let Err(error) =916
preset.bind(sex, body, self.equipment_part, joint)917
{918
ui.colored_label(egui::Color32::LIGHT_RED, error);919
}920
}921
});922
} else {923
ui.colored_label(924
egui::Color32::LIGHT_RED,925
"Open een geskind glTF/GLB-lichaam voor deze rigbinding.",926
);927
}928
if ui.button("Verwijder onderdeel").clicked() {929
if let Err(error) =930
crate::equipment::remove_attachment(preset, self.equipment_part)931
{932
item.error = Some(error);933
}934
self.equipment_part = self.equipment_part.saturating_sub(1);935
}936
}937
}938
if ui.button("Verwijder equipmentdefinitie").clicked() {939
self.session.delete_equipment(index);940
}941
});942
});943
self.controls.sex = if sex == 0 {944
PreviewSex::Male945
} else {946
PreviewSex::Female947
};672
egui::CentralPanel::default().show(ctx, |ui| {948
egui::CentralPanel::default().show(ctx, |ui| {673
if self.session.confirm.is_some() || self.dialog_pending {949
if self.session.confirm.is_some() || self.dialog_pending {674
ui.disable();950
ui.disable();675
}951
}676
ui.heading("Equipment");952
let body = if sex == 0 {677
if ui.button("Terug").clicked() {953
self.preview_male.as_ref()678
self.session.show_equipment_list();954
} else {679
}955
self.preview_female.as_ref()680
if let Some(item) = self.session.equipment.get_mut(index) {956
};681
ui.horizontal(|ui| {957
let preset = self682
ui.label("Naam");958
.session683
ui.text_edit_singleline(&mut item.slug);959
.equipment684
});960
.get(index)685
let model = item961
.and_then(|item| item.preset.as_ref());686
.model962
let presets: Vec<_> = preset.into_iter().collect();687
.clone()963
self.viewport.show(ui, &mut self.controls, body, &presets);688
.unwrap_or_else(|| "geen model".to_string());689
ui.label(model);690
}691
if ui.button("Kies model").clicked() {692
self.dialogs.start(DialogPurpose::EquipmentModel(index));693
}694
if ui.button("Verwijder").clicked() {695
self.session.delete_equipment(index);696
}697
});964
});698
}965
}699
966
@@ -729,6 +996,69 @@ impl EditorApp {729
}996
}730
}997
}731
998
999
fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>) -> Option<usize> {1000
fn node(1001
ui: &mut egui::Ui,1002
body: &PreviewCharacter,1003
paths: &[String],1004
index: usize,1005
selected: Option<&str>,1006
choice: &mut Option<usize>,1007
depth: usize,1008
) {1009
if depth > 64 {1010
return;1011
}1012
let joint = body.joint_nodes.iter().position(|i| *i == index);1013
let children: Vec<_> = body1014
.nodes1015
.iter()1016
.enumerate()1017
.filter(|(_, n)| n.parent == Some(index))1018
.map(|(i, _)| i)1019
.collect();1020
let label = if body.nodes[index].name.is_empty() {1021
format!("node-{index}")1022
} else {1023
body.nodes[index].name.clone()1024
};1025
let select = |ui: &mut egui::Ui, choice: &mut Option<usize>| {1026
if let Some(joint) = joint {1027
if ui1028
.selectable_label(1029
selected == Some(paths[joint].as_str()),1030
format!("Koppel {label}"),1031
)1032
.clicked()1033
{1034
*choice = Some(joint);1035
}1036
}1037
};1038
if children.is_empty() {1039
select(ui, choice);1040
} else {1041
egui::CollapsingHeader::new(&label)1042
.id_salt(index)1043
.default_open(depth < 2)1044
.show(ui, |ui| {1045
select(ui, choice);1046
for child in children {1047
node(ui, body, paths, child, selected, choice, depth + 1);1048
}1049
});1050
}1051
}1052
let mut choice = None;1053
let paths = crate::preview::joint_paths(body);1054
for (index, item) in body.nodes.iter().enumerate() {1055
if item.parent.is_none() {1056
node(ui, body, &paths, index, selected, &mut choice, 0);1057
}1058
}1059
choice1060
}1061
732
#[cfg(test)]1062
#[cfg(test)]733
mod tests {1063
mod tests {734
use super::*;1064
use super::*;@@ -0,0 +1,891 @@
1
//! Rigid equipment, portable embedded assets and a bounded binary container.
2
//! Local rotation uses Rz * Ry * Rx (degrees); attachment uses the animated
3
//! joint world transform, never a skin matrix or inverse bind a second time.
4
5
use std::collections::BTreeSet;
6
use std::io::{Cursor, Read, Write};
7
use std::path::Path;
8
9
use crate::preview::{
10
animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls,
11
};
12
use crate::project::portable_relative;
13
use serde::{Deserialize, Serialize};
14
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16
pub const VERSION: u32 = 1;
17
pub const MAX_FILE: usize = 32 * 1024 * 1024;
18
const MAX_META: usize = 4 * 1024 * 1024;
19
const MAX_ASSET: usize = 8 * 1024 * 1024;
20
const MAX_ASSETS: usize = 32;
21
const MAX_VERTICES: usize = 80_000;
22
23
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24
#[serde(deny_unknown_fields)]
25
pub struct EmbeddedAsset {
26
pub path: String,
27
#[serde(skip)]
28
pub bytes: Vec<u8>,
29
}
30
31
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32
#[serde(deny_unknown_fields)]
33
pub struct Mesh {
34
pub positions: Vec<[f32; 3]>,
35
pub normals: Vec<[f32; 3]>,
36
pub uvs: Vec<[f32; 2]>,
37
pub indices: Vec<u32>,
38
pub color: [f32; 4],
39
pub albedo: Option<usize>,
40
}
41
42
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43
#[serde(deny_unknown_fields)]
44
pub struct RigSignature {
45
pub paths: Vec<String>,
46
pub rest_world: Vec<Mat4>,
47
}
48
impl RigSignature {
49
pub fn of(body: &PreviewCharacter) -> Self {
50
Self {
51
paths: joint_paths(body),
52
rest_world: (0..body.joint_nodes.len())
53
.map(|j| animated_joint_world(body, "", 0.0, j).unwrap_or_else(identity))
54
.collect(),
55
}
56
}
57
}
58
59
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60
#[serde(deny_unknown_fields)]
61
pub struct Attachment {
62
pub label: String,
63
/// Original OBJ/MTL/dependency payloads owned by this attachment.
64
pub source_assets: Vec<usize>,
65
pub bones: [Option<String>; 2],
66
pub translation: [f32; 3],
67
pub rotation_deg: [f32; 3],
68
pub meshes: Vec<Mesh>,
69
pub shiny: Option<usize>,
70
pub specular: f32,
71
}
72
73
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74
#[serde(deny_unknown_fields)]
75
pub struct Preset {
76
pub name: String,
77
pub provenance: String,
78
pub applies: [bool; 2],
79
pub rigs: [Option<RigSignature>; 2],
80
pub attachments: Vec<Attachment>,
81
pub assets: Vec<EmbeddedAsset>,
82
}
83
impl Preset {
84
pub fn new(name: String) -> Self {
85
Self {
86
name,
87
provenance: String::new(),
88
applies: [true, true],
89
rigs: [None, None],
90
attachments: Vec::new(),
91
assets: Vec::new(),
92
}
93
}
94
pub fn bind(
95
&mut self,
96
sex: usize,
97
body: &PreviewCharacter,
98
attachment: usize,
99
joint: usize,
100
) -> Result<(), String> {
101
if sex > 1 {
102
return Err("ongeldig geslacht".into());
103
}
104
let paths = joint_paths(body);
105
let path = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();
106
let item = self
107
.attachments
108
.get_mut(attachment)
109
.ok_or("dit onderdeel bestaat niet")?;
110
item.bones[sex] = Some(path);
111
self.rigs[sex] = Some(RigSignature::of(body));
112
Ok(())
113
}
114
pub fn validate(&self) -> Result<(), String> {
115
if !valid_name(&self.name) {
116
return Err(
117
"equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),
118
);
119
}
120
if self.provenance.len() > 4096 || self.provenance.contains('\0') {
121
return Err("herkomst/licentie is te lang of bevat NUL".into());
122
}
123
if !self.applies.iter().any(|x| *x) {
124
return Err("kies minstens één geslacht".into());
125
}
126
if self.attachments.len() > 32 || self.assets.len() > MAX_ASSETS {
127
return Err("te veel onderdelen of bestanden".into());
128
}
129
let mut paths = BTreeSet::new();
130
let mut total = 0usize;
131
for asset in &self.assets {
132
portable_relative(&asset.path)?;
133
if asset.path.len() > 240 || asset.bytes.len() > MAX_ASSET || !paths.insert(&asset.path)
134
{
135
return Err("ongeldige, dubbele of te grote asset".into());
136
}
137
total = total
138
.checked_add(asset.bytes.len())
139
.ok_or("te veel assetdata")?;
140
}
141
if total > MAX_FILE {
142
return Err("te veel assetdata".into());
143
}
144
let mut texture_indices = BTreeSet::new();
145
let mut vertices = 0;
146
for rig in self.rigs.iter().flatten() {
147
if rig.paths.len() > 128
148
|| rig.paths.len() != rig.rest_world.len()
149
|| rig.paths.iter().any(|p| p.len() > 4096)
150
|| rig.rest_world.iter().flatten().any(|x| !x.is_finite())
151
{
152
return Err("ongeldige rigbinding".into());
153
}
154
}
155
for attachment in &self.attachments {
156
if attachment.source_assets.len() > MAX_ASSETS
157
|| attachment
158
.source_assets
159
.iter()
160
.any(|i| *i >= self.assets.len())
161
{
162
return Err("ongeldige bronassetverwijzing".into());
163
}
164
for sex in 0..2 {
165
if let Some(path) = &attachment.bones[sex] {
166
if !self.rigs[sex]
167
.as_ref()
168
.map(|rig| rig.paths.contains(path))
169
.unwrap_or(false)
170
{
171
return Err("attachment verwijst naar onbekend bot".into());
172
}
173
}
174
}
175
if attachment.label.len() > 128
176
|| attachment.bones.iter().flatten().any(|p| p.len() > 4096)
177
|| attachment
178
.translation
179
.iter()
180
.chain(attachment.rotation_deg.iter())
181
.any(|x| !x.is_finite() || x.abs() > 100_000.0)
182
|| !attachment.specular.is_finite()
183
|| !(0.0..=1.0).contains(&attachment.specular)
184
|| attachment.meshes.len() > 64
185
{
186
return Err("ongeldige attachment/transformatie".into());
187
}
188
if let Some(index) = attachment.shiny {
189
texture_indices.insert(index);
190
}
191
for mesh in &attachment.meshes {
192
vertices += mesh.positions.len();
193
if vertices > MAX_VERTICES
194
|| mesh.indices.len() > MAX_VERTICES * 6
195
|| mesh.indices.len() < 3
196
|| mesh.indices.len() % 3 != 0
197
|| mesh.positions.len() != mesh.normals.len()
198
|| mesh.positions.len() != mesh.uvs.len()
199
|| mesh.positions.is_empty()
200
|| mesh
201
.indices
202
.iter()
203
.any(|i| *i as usize >= mesh.positions.len())
204
|| mesh
205
.positions
206
.iter()
207
.flatten()
208
.chain(mesh.normals.iter().flatten())
209
.chain(mesh.uvs.iter().flatten())
210
.chain(mesh.color.iter())
211
.any(|x| !x.is_finite() || x.abs() > 1_000_000.0)
212
{
213
return Err("ongeldige of te grote equipmentmesh".into());
214
}
215
if let Some(index) = mesh.albedo {
216
texture_indices.insert(index);
217
}
218
}
219
}
220
let mut decoded = 0usize;
221
for index in texture_indices {
222
decoded += self.texture(index)?.rgba.len();
223
if decoded > 64 * 1024 * 1024 {
224
return Err("textures samen groter dan 64 MiB".into());
225
}
226
}
227
Ok(())
228
}
229
pub fn texture(&self, index: usize) -> Result<Texture, String> {
230
let asset = self
231
.assets
232
.get(index)
233
.ok_or("textureverwijzing ontbreekt")?;
234
decode_png(&asset.bytes)
235
}
236
/// Exact container byte cost; does not substitute for semantic validation.
237
pub fn encoded_len(&self) -> Result<usize, String> {
238
let meta = serde_json::to_vec(self).map_err(|e| e.to_string())?.len();
239
if meta > MAX_META {
240
return Err("presetmetadata is te groot".into());
241
}
242
let size = self
243
.assets
244
.iter()
245
.try_fold(20 + meta, |size, a| {
246
size.checked_add(4)?.checked_add(a.bytes.len())
247
})
248
.ok_or("preset te groot")?;
249
if size > MAX_FILE {
250
return Err("preset te groot".into());
251
}
252
Ok(size)
253
}
254
pub fn encode(&self) -> Result<Vec<u8>, String> {
255
self.validate()?;
256
let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;
257
if metadata.len() > MAX_META {
258
return Err("presetmetadata is te groot".into());
259
}
260
let mut payload = metadata;
261
let meta_len = payload.len();
262
for asset in &self.assets {
263
payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());
264
payload.extend_from_slice(&asset.bytes);
265
}
266
if payload.len() + 20 > MAX_FILE {
267
return Err("preset is te groot".into());
268
}
269
let mut out = MAGIC.to_vec();
270
out.extend_from_slice(&VERSION.to_le_bytes());
271
out.extend_from_slice(&(meta_len as u32).to_le_bytes());
272
out.extend_from_slice(&checksum(&payload).to_le_bytes());
273
out.extend(payload);
274
Ok(out)
275
}
276
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
277
if bytes.len() < 20 || bytes.len() > MAX_FILE || &bytes[..8] != MAGIC {
278
return Err("ongeldig equipmentbestand of bestand te groot".into());
279
}
280
let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());
281
if number(8) != VERSION {
282
return Err("onbekende equipmentversie".into());
283
}
284
let meta_len = number(12) as usize;
285
if meta_len > MAX_META
286
|| meta_len > bytes.len() - 20
287
|| checksum(&bytes[20..]) != number(16)
288
{
289
return Err("beschadigde equipmentdata".into());
290
}
291
let mut preset: Self =
292
serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;
293
if preset.assets.len() > MAX_ASSETS {
294
return Err("te veel assets".into());
295
}
296
let mut cursor = 20 + meta_len;
297
for asset in &mut preset.assets {
298
if cursor + 4 > bytes.len() {
299
return Err("afgebroken asset".into());
300
}
301
let len = number(cursor) as usize;
302
cursor += 4;
303
if len > MAX_ASSET || len > bytes.len() - cursor {
304
return Err("ongeldige assetlengte".into());
305
}
306
asset.bytes = bytes[cursor..cursor + len].to_vec();
307
cursor += len;
308
}
309
if cursor != bytes.len() {
310
return Err("onverwachte extra equipmentdata".into());
311
}
312
preset.validate()?;
313
Ok(preset)
314
}
315
pub fn read(path: &Path) -> Result<Self, String> {
316
if !path.is_file() {
317
return Err("kies een regulier presetbestand".into());
318
}
319
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
320
let mut bytes = Vec::new();
321
file.take((MAX_FILE + 1) as u64)
322
.read_to_end(&mut bytes)
323
.map_err(|e| e.to_string())?;
324
Self::decode(&bytes)
325
}
326
}
327
328
pub fn valid_name(name: &str) -> bool {
329
!name.is_empty()
330
&& name.len() <= 64
331
&& name != "body"
332
&& name
333
.bytes()
334
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')
335
}
336
337
pub fn checksum(bytes: &[u8]) -> u32 {
338
let mut crc = !0u32;
339
for byte in bytes {
340
crc ^= u32::from(*byte);
341
for _ in 0..8 {
342
crc = (crc >> 1) ^ (0xedb88320u32 & (0u32.wrapping_sub(crc & 1)));
343
}
344
}
345
!crc
346
}
347
348
/// Immutable snapshot publish: a hard link atomically creates the destination
349
/// without overwriting an older saved preset. Failure leaves old data intact.
350
pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {
351
if bytes.len() > MAX_FILE {
352
return Err("snapshot te groot".into());
353
}
354
if path.exists() {
355
if !path.is_file() {
356
return Err("snapshot is geen regulier bestand".into());
357
}
358
let mut current = Vec::new();
359
std::fs::File::open(path)
360
.map_err(|e| e.to_string())?
361
.take((MAX_FILE + 1) as u64)
362
.read_to_end(&mut current)
363
.map_err(|e| e.to_string())?;
364
return if current == bytes {
365
Ok(())
366
} else {
367
Err("snapshotnaam bestaat met andere inhoud".into())
368
};
369
}
370
let stamp = std::time::SystemTime::now()
371
.duration_since(std::time::UNIX_EPOCH)
372
.unwrap_or_default()
373
.as_nanos();
374
let temp = path.with_extension(format!("ppe.part-{}-{stamp}", std::process::id()));
375
let result = (|| {
376
let mut file = std::fs::OpenOptions::new()
377
.write(true)
378
.create_new(true)
379
.open(&temp)
380
.map_err(|e| e.to_string())?;
381
file.write_all(bytes)
382
.and_then(|_| file.sync_all())
383
.map_err(|e| e.to_string())?;
384
std::fs::hard_link(&temp, path).map_err(|e| e.to_string())?;
385
#[cfg(unix)]
386
std::fs::File::open(path.parent().ok_or("snapshotmap ontbreekt")?)
387
.and_then(|dir| dir.sync_all())
388
.map_err(|e| e.to_string())?;
389
Ok(())
390
})();
391
let _ = std::fs::remove_file(temp);
392
result
393
}
394
395
#[derive(Debug, Clone, PartialEq)]
396
pub struct Texture {
397
pub content_hash: u64,
398
pub width: u32,
399
pub height: u32,
400
pub rgba: Vec<u8>,
401
}
402
fn decode_png(bytes: &[u8]) -> Result<Texture, String> {
403
let mut decoder = png::Decoder::new(Cursor::new(bytes));
404
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
405
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
406
let info = reader.info();
407
if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {
408
return Err("texture is groter dan 2048×2048".into());
409
}
410
let size = reader
411
.output_buffer_size()
412
.ok_or("ongeldige texturegrootte")?;
413
if size > 2048 * 2048 * 8 {
414
return Err("texture is te groot".into());
415
}
416
let mut data = vec![0; size];
417
let info = reader.next_frame(&mut data).map_err(|e| e.to_string())?;
418
let channels = info.color_type.samples();
419
let mut rgba = Vec::with_capacity(info.width as usize * info.height as usize * 4);
420
for pixel in data[..info.buffer_size()].chunks_exact(channels) {
421
match info.color_type {
422
png::ColorType::Grayscale => {
423
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], 255])
424
}
425
png::ColorType::GrayscaleAlpha => {
426
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]])
427
}
428
png::ColorType::Rgb => rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]),
429
png::ColorType::Rgba => rgba.extend_from_slice(pixel),
430
_ => return Err("onbekende texturecodering".into()),
431
}
432
}
433
use std::hash::{Hash, Hasher};
434
let mut hash = std::collections::hash_map::DefaultHasher::new();
435
rgba.hash(&mut hash);
436
Ok(Texture {
437
content_hash: hash.finish(),
438
width: info.width,
439
height: info.height,
440
rgba,
441
})
442
}
443
444
pub fn local_matrix(translation: [f32; 3], degrees: [f32; 3]) -> Mat4 {
445
let [x, y, z] = degrees.map(f32::to_radians);
446
let (sx, cx) = x.sin_cos();
447
let (sy, cy) = y.sin_cos();
448
let (sz, cz) = z.sin_cos();
449
let rx = [
450
1., 0., 0., 0., 0., cx, sx, 0., 0., -sx, cx, 0., 0., 0., 0., 1.,
451
];
452
let ry = [
453
cy, 0., -sy, 0., 0., 1., 0., 0., sy, 0., cy, 0., 0., 0., 0., 1.,
454
];
455
let rz = [
456
cz, sz, 0., 0., -sz, cz, 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
457
];
458
let mut matrix = mul(rz, mul(ry, rx));
459
matrix[12] = translation[0];
460
matrix[13] = translation[1];
461
matrix[14] = translation[2];
462
matrix
463
}
464
465
#[derive(Debug, Clone)]
466
pub struct Layer {
467
pub mesh: Mesh,
468
pub model: Mat4,
469
pub albedo: Option<std::sync::Arc<Texture>>,
470
pub shiny: Option<std::sync::Arc<Texture>>,
471
pub specular: f32,
472
}
473
474
#[derive(Default)]
475
pub struct TextureCache {
476
entries: std::collections::VecDeque<(Vec<u8>, std::sync::Arc<Texture>)>,
477
bytes: usize,
478
}
479
impl TextureCache {
480
fn get(&mut self, preset: &Preset, index: usize) -> Result<std::sync::Arc<Texture>, String> {
481
let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;
482
if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {
483
return Ok(texture.clone());
484
}
485
let texture = std::sync::Arc::new(decode_png(bytes)?);
486
let cost = bytes.len() + texture.rgba.len();
487
while self.bytes + cost > 128 * 1024 * 1024 {
488
if let Some((source, image)) = self.entries.pop_front() {
489
self.bytes -= source.len() + image.rgba.len();
490
} else {
491
break;
492
}
493
}
494
self.bytes += cost;
495
self.entries.push_back((bytes.clone(), texture.clone()));
496
Ok(texture)
497
}
498
}
499
500
pub fn layers(
501
preset: &Preset,
502
sex: usize,
503
body: &PreviewCharacter,
504
controls: &PreviewControls,
505
) -> Result<Vec<Layer>, String> {
506
layers_cached(preset, sex, body, controls, &mut TextureCache::default())
507
}
508
509
pub fn layers_cached(
510
preset: &Preset,
511
sex: usize,
512
body: &PreviewCharacter,
513
controls: &PreviewControls,
514
cache: &mut TextureCache,
515
) -> Result<Vec<Layer>, String> {
516
if sex > 1 {
517
return Err("ongeldig geslacht".into());
518
}
519
if !preset.applies[sex] || preset.attachments.is_empty() {
520
return Ok(Vec::new());
521
}
522
let signature = RigSignature::of(body);
523
if preset.rigs[sex].as_ref() != Some(&signature) {
524
return Err("rig wijkt af: kies de botten opnieuw voor dit lichaam".into());
525
}
526
let paths = joint_paths(body);
527
let mut layers = Vec::new();
528
for item in &preset.attachments {
529
let path = item.bones[sex]
530
.as_deref()
531
.ok_or("kies een bot voor dit onderdeel en geslacht")?;
532
let joint = paths
533
.iter()
534
.position(|p| p == path)
535
.ok_or("het gekoppelde bot ontbreekt")?;
536
let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)
537
.ok_or("het gekoppelde bot ontbreekt")?;
538
let model = mul(world, local_matrix(item.translation, item.rotation_deg));
539
let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;
540
for mesh in &item.meshes {
541
layers.push(Layer {
542
mesh: mesh.clone(),
543
model,
544
albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?,
545
shiny: shiny.clone(),
546
specular: item.specular,
547
});
548
}
549
}
550
Ok(layers)
551
}
552
553
/// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.
554
/// No extraction directories, absolute references or parent traversal allowed.
555
pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
556
if source
557
.extension()
558
.and_then(|x| x.to_str())
559
.map(|x| !x.eq_ignore_ascii_case("obj"))
560
.unwrap_or(true)
561
{
562
return Err("kies een rigide OBJ-model met MTL/PNG-bijbestanden".into());
563
}
564
let root = source
565
.parent()
566
.ok_or("modelmap ontbreekt")?
567
.canonicalize()
568
.map_err(|e| e.to_string())?;
569
let name = source
570
.file_name()
571
.and_then(|x| x.to_str())
572
.ok_or("ongeldige modelnaam")?;
573
let mut candidate = preset.clone();
574
if candidate.attachments.len() >= 32 {
575
return Err("te veel onderdelen".into());
576
}
577
compact_assets(&mut candidate)?;
578
let prefix = unique_prefix(&candidate, "part");
579
let obj_bytes = read_asset(&root, name)?;
580
let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;
581
let mut dependencies = BTreeSet::new();
582
dependencies.insert(name.to_string());
583
for line in obj_text.lines() {
584
if let Some(rest) = line.trim().strip_prefix("mtllib ") {
585
let mtl = rest.trim();
586
dependencies.insert(mtl.to_string());
587
let bytes = read_asset(&root, mtl)?;
588
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
589
for line in text.lines() {
590
let line = line.trim();
591
if line.starts_with("map_")
592
|| line.starts_with("bump ")
593
|| line.starts_with("disp ")
594
{
595
let (_, path) = line
596
.split_once(char::is_whitespace)
597
.ok_or("ongeldige textureverwijzing")?;
598
let relative = Path::new(mtl)
599
.parent()
600
.unwrap_or(Path::new(""))
601
.join(path.trim());
602
let relative = relative
603
.to_str()
604
.ok_or("ongeldig texturepad")?
605
.replace('\\', "/");
606
portable_relative(&relative)?;
607
dependencies.insert(relative);
608
}
609
}
610
}
611
}
612
if dependencies.len() + candidate.assets.len() > MAX_ASSETS {
613
return Err("te veel bijbestanden".into());
614
}
615
let mut asset_map = std::collections::BTreeMap::new();
616
let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();
617
for relative in dependencies {
618
let bytes = read_asset(&root, &relative)?;
619
total_bytes += bytes.len();
620
if total_bytes > MAX_FILE {
621
return Err("assets samen te groot".into());
622
}
623
let index = candidate.assets.len();
624
candidate.assets.push(EmbeddedAsset {
625
path: format!("{prefix}/{relative}"),
626
bytes,
627
});
628
asset_map.insert(relative, index);
629
}
630
// A checked callback prevents the parser from reading undeclared/unsafe files.
631
let mut input = std::io::BufReader::new(Cursor::new(obj_bytes));
632
let options = tobj::LoadOptions {
633
triangulate: true,
634
single_index: true,
635
..Default::default()
636
};
637
let texture_paths = std::cell::RefCell::new(std::collections::BTreeMap::new());
638
let (models, materials) = tobj::load_obj_buf(&mut input, &options, |path| {
639
let relative = path.to_string_lossy().replace('\\', "/");
640
let index = asset_map
641
.get(&relative)
642
.ok_or(tobj::LoadError::OpenFileFailed)?;
643
let mut reader = std::io::BufReader::new(Cursor::new(&candidate.assets[*index].bytes));
644
let (materials, map) = tobj::load_mtl_buf(&mut reader)?;
645
for material in &materials {
646
if let Some(texture) = &material.diffuse_texture {
647
let relative = path
648
.parent()
649
.unwrap_or(Path::new(""))
650
.join(texture)
651
.to_string_lossy()
652
.replace('\\', "/");
653
texture_paths
654
.borrow_mut()
655
.insert(material.name.clone(), relative);
656
}
657
}
658
Ok((materials, map))
659
})
660
.map_err(|e| e.to_string())?;
661
let materials = materials.map_err(|e| e.to_string())?;
662
let texture_paths = texture_paths.into_inner();
663
let mut meshes = Vec::new();
664
let mut vertices: usize = candidate
665
.attachments
666
.iter()
667
.flat_map(|a| &a.meshes)
668
.map(|m| m.positions.len())
669
.sum();
670
for model in models {
671
let source = model.mesh;
672
vertices += source.positions.len() / 3;
673
if vertices > MAX_VERTICES || source.indices.len() > MAX_VERTICES * 6 {
674
return Err("equipmentmesh is te groot".into());
675
}
676
let positions: Vec<[f32; 3]> = source
677
.positions
678
.chunks_exact(3)
679
.map(|x| [x[0], x[1], x[2]])
680
.collect();
681
let mut normals: Vec<[f32; 3]> = source
682
.normals
683
.chunks_exact(3)
684
.map(|x| [x[0], x[1], x[2]])
685
.collect();
686
if normals.len() != positions.len() {
687
normals = compute_normals(&positions, &source.indices)?;
688
}
689
let mut uvs: Vec<[f32; 2]> = source
690
.texcoords
691
.chunks_exact(2)
692
.map(|x| [x[0], x[1]])
693
.collect();
694
if uvs.len() != positions.len() {
695
uvs = vec![[0.; 2]; positions.len()];
696
}
697
let material = source.material_id.and_then(|id| materials.get(id));
698
let color = material.and_then(|m| m.diffuse).unwrap_or([0.7, 0.7, 0.7]);
699
let albedo = material
700
.and_then(|m| texture_paths.get(&m.name))
701
.map(|path| {
702
asset_map
703
.get(path)
704
.copied()
705
.ok_or("ontbrekende diffuse texture")
706
})
707
.transpose()?;
708
meshes.push(Mesh {
709
positions,
710
normals,
711
uvs,
712
indices: source.indices,
713
color: [
714
color[0],
715
color[1],
716
color[2],
717
material.and_then(|m| m.dissolve).unwrap_or(1.),
718
],
719
albedo,
720
});
721
}
722
if meshes.is_empty() {
723
return Err("het OBJ-bestand heeft geen meshes".into());
724
}
725
candidate.attachments.push(Attachment {
726
label: name.to_string(),
727
source_assets: asset_map.values().copied().collect(),
728
bones: [None, None],
729
translation: [0.; 3],
730
rotation_deg: [0.; 3],
731
meshes,
732
shiny: None,
733
specular: 0.25,
734
});
735
candidate.validate()?;
736
*preset = candidate;
737
Ok(preset.attachments.len() - 1)
738
}
739
740
pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {
741
let root = source.parent().ok_or("texturemap ontbreekt")?;
742
let name = source
743
.file_name()
744
.and_then(|x| x.to_str())
745
.ok_or("ongeldige bestandsnaam")?;
746
let bytes = read_asset(root, name)?;
747
decode_png(&bytes)?;
748
let mut candidate = preset.clone();
749
let index = candidate.assets.len();
750
candidate.assets.push(EmbeddedAsset {
751
path: format!("{}/{name}", unique_prefix(&candidate, "shiny")),
752
bytes,
753
});
754
candidate
755
.attachments
756
.get_mut(attachment)
757
.ok_or("onderdeel ontbreekt")?
758
.shiny = Some(index);
759
compact_assets(&mut candidate)?;
760
candidate.validate()?;
761
*preset = candidate;
762
Ok(())
763
}
764
765
fn unique_prefix(preset: &Preset, kind: &str) -> String {
766
(0..)
767
.map(|i| format!("{kind}-{i}"))
768
.find(|prefix| {
769
!preset
770
.assets
771
.iter()
772
.any(|a| a.path.split('/').next() == Some(prefix.as_str()))
773
})
774
.expect("finite asset list")
775
}
776
fn compact_assets(preset: &mut Preset) -> Result<(), String> {
777
let used: BTreeSet<usize> = preset
778
.attachments
779
.iter()
780
.flat_map(|a| {
781
a.source_assets
782
.iter()
783
.copied()
784
.chain(a.shiny)
785
.chain(a.meshes.iter().filter_map(|m| m.albedo))
786
})
787
.collect();
788
if used.iter().any(|i| *i >= preset.assets.len()) {
789
return Err("ongeldige assetverwijzing".into());
790
}
791
let mut remap = vec![0; preset.assets.len()];
792
let mut assets = Vec::new();
793
for (i, asset) in preset.assets.iter().enumerate() {
794
if used.contains(&i) {
795
remap[i] = assets.len();
796
assets.push(asset.clone());
797
}
798
}
799
for a in &mut preset.attachments {
800
for i in &mut a.source_assets {
801
*i = remap[*i];
802
}
803
if let Some(i) = a.shiny.as_mut() {
804
*i = remap[*i];
805
}
806
for mesh in &mut a.meshes {
807
if let Some(i) = mesh.albedo.as_mut() {
808
*i = remap[*i];
809
}
810
}
811
}
812
preset.assets = assets;
813
Ok(())
814
}
815
/// Clear an override and release its no-longer-used embedded texture.
816
pub fn clear_shiny(preset: &mut Preset, index: usize) -> Result<(), String> {
817
let mut candidate = preset.clone();
818
candidate
819
.attachments
820
.get_mut(index)
821
.ok_or("onderdeel ontbreekt")?
822
.shiny = None;
823
compact_assets(&mut candidate)?;
824
*preset = candidate;
825
Ok(())
826
}
827
/// Remove a part and its no-longer-used embedded assets, remapping all indices.
828
pub fn remove_attachment(preset: &mut Preset, index: usize) -> Result<(), String> {
829
if index >= preset.attachments.len() {
830
return Err("onderdeel ontbreekt".into());
831
}
832
let mut candidate = preset.clone();
833
candidate.attachments.remove(index);
834
compact_assets(&mut candidate)?;
835
*preset = candidate;
836
Ok(())
837
}
838
839
fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
840
portable_relative(relative)?;
841
let root = root.canonicalize().map_err(|e| e.to_string())?;
842
let path = root
843
.join(relative)
844
.canonicalize()
845
.map_err(|e| e.to_string())?;
846
if !path.starts_with(&root) {
847
return Err("asset verlaat de modelmap".into());
848
}
849
if !path.is_file() {
850
return Err("asset is geen regulier bestand".into());
851
}
852
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
853
let mut bytes = Vec::new();
854
file.take((MAX_ASSET + 1) as u64)
855
.read_to_end(&mut bytes)
856
.map_err(|e| e.to_string())?;
857
if bytes.len() > MAX_ASSET {
858
return Err("asset is te groot".into());
859
}
860
Ok(bytes)
861
}
862
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Result<Vec<[f32; 3]>, String> {
863
let mut normals = vec![[0.; 3]; positions.len()];
864
for t in indices.chunks_exact(3) {
865
let [a, b, c] = [t[0] as usize, t[1] as usize, t[2] as usize];
866
if [a, b, c].iter().any(|i| *i >= positions.len()) {
867
return Err("ongeldige OBJ-index".into());
868
}
869
let ab = std::array::from_fn::<_, 3, _>(|i| positions[b][i] - positions[a][i]);
870
let ac = std::array::from_fn::<_, 3, _>(|i| positions[c][i] - positions[a][i]);
871
let n = [
872
ab[1] * ac[2] - ab[2] * ac[1],
873
ab[2] * ac[0] - ab[0] * ac[2],
874
ab[0] * ac[1] - ab[1] * ac[0],
875
];
876
for vertex in [a, b, c] {
877
for (i, value) in n.iter().enumerate() {
878
normals[vertex][i] += value;
879
}
880
}
881
}
882
for normal in &mut normals {
883
let length = normal.iter().map(|v| v * v).sum::<f32>().sqrt();
884
if length > 1e-10 {
885
for v in normal {
886
*v /= length
887
}
888
}
889
}
890
Ok(normals)
891
}
@@ -0,0 +1,891 @@1
//! Rigid equipment, portable embedded assets and a bounded binary container.2
//! Local rotation uses Rz * Ry * Rx (degrees); attachment uses the animated3
//! joint world transform, never a skin matrix or inverse bind a second time.4
5
use std::collections::BTreeSet;6
use std::io::{Cursor, Read, Write};7
use std::path::Path;8
9
use crate::preview::{10
animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls,11
};12
use crate::project::portable_relative;13
use serde::{Deserialize, Serialize};14
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";16
pub const VERSION: u32 = 1;17
pub const MAX_FILE: usize = 32 * 1024 * 1024;18
const MAX_META: usize = 4 * 1024 * 1024;19
const MAX_ASSET: usize = 8 * 1024 * 1024;20
const MAX_ASSETS: usize = 32;21
const MAX_VERTICES: usize = 80_000;22
23
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]24
#[serde(deny_unknown_fields)]25
pub struct EmbeddedAsset {26
pub path: String,27
#[serde(skip)]28
pub bytes: Vec<u8>,29
}30
31
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]32
#[serde(deny_unknown_fields)]33
pub struct Mesh {34
pub positions: Vec<[f32; 3]>,35
pub normals: Vec<[f32; 3]>,36
pub uvs: Vec<[f32; 2]>,37
pub indices: Vec<u32>,38
pub color: [f32; 4],39
pub albedo: Option<usize>,40
}41
42
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]43
#[serde(deny_unknown_fields)]44
pub struct RigSignature {45
pub paths: Vec<String>,46
pub rest_world: Vec<Mat4>,47
}48
impl RigSignature {49
pub fn of(body: &PreviewCharacter) -> Self {50
Self {51
paths: joint_paths(body),52
rest_world: (0..body.joint_nodes.len())53
.map(|j| animated_joint_world(body, "", 0.0, j).unwrap_or_else(identity))54
.collect(),55
}56
}57
}58
59
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]60
#[serde(deny_unknown_fields)]61
pub struct Attachment {62
pub label: String,63
/// Original OBJ/MTL/dependency payloads owned by this attachment.64
pub source_assets: Vec<usize>,65
pub bones: [Option<String>; 2],66
pub translation: [f32; 3],67
pub rotation_deg: [f32; 3],68
pub meshes: Vec<Mesh>,69
pub shiny: Option<usize>,70
pub specular: f32,71
}72
73
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]74
#[serde(deny_unknown_fields)]75
pub struct Preset {76
pub name: String,77
pub provenance: String,78
pub applies: [bool; 2],79
pub rigs: [Option<RigSignature>; 2],80
pub attachments: Vec<Attachment>,81
pub assets: Vec<EmbeddedAsset>,82
}83
impl Preset {84
pub fn new(name: String) -> Self {85
Self {86
name,87
provenance: String::new(),88
applies: [true, true],89
rigs: [None, None],90
attachments: Vec::new(),91
assets: Vec::new(),92
}93
}94
pub fn bind(95
&mut self,96
sex: usize,97
body: &PreviewCharacter,98
attachment: usize,99
joint: usize,100
) -> Result<(), String> {101
if sex > 1 {102
return Err("ongeldig geslacht".into());103
}104
let paths = joint_paths(body);105
let path = paths.get(joint).ok_or("dit bot bestaat niet")?.clone();106
let item = self107
.attachments108
.get_mut(attachment)109
.ok_or("dit onderdeel bestaat niet")?;110
item.bones[sex] = Some(path);111
self.rigs[sex] = Some(RigSignature::of(body));112
Ok(())113
}114
pub fn validate(&self) -> Result<(), String> {115
if !valid_name(&self.name) {116
return Err(117
"equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),118
);119
}120
if self.provenance.len() > 4096 || self.provenance.contains('\0') {121
return Err("herkomst/licentie is te lang of bevat NUL".into());122
}123
if !self.applies.iter().any(|x| *x) {124
return Err("kies minstens één geslacht".into());125
}126
if self.attachments.len() > 32 || self.assets.len() > MAX_ASSETS {127
return Err("te veel onderdelen of bestanden".into());128
}129
let mut paths = BTreeSet::new();130
let mut total = 0usize;131
for asset in &self.assets {132
portable_relative(&asset.path)?;133
if asset.path.len() > 240 || asset.bytes.len() > MAX_ASSET || !paths.insert(&asset.path)134
{135
return Err("ongeldige, dubbele of te grote asset".into());136
}137
total = total138
.checked_add(asset.bytes.len())139
.ok_or("te veel assetdata")?;140
}141
if total > MAX_FILE {142
return Err("te veel assetdata".into());143
}144
let mut texture_indices = BTreeSet::new();145
let mut vertices = 0;146
for rig in self.rigs.iter().flatten() {147
if rig.paths.len() > 128148
|| rig.paths.len() != rig.rest_world.len()149
|| rig.paths.iter().any(|p| p.len() > 4096)150
|| rig.rest_world.iter().flatten().any(|x| !x.is_finite())151
{152
return Err("ongeldige rigbinding".into());153
}154
}155
for attachment in &self.attachments {156
if attachment.source_assets.len() > MAX_ASSETS157
|| attachment158
.source_assets159
.iter()160
.any(|i| *i >= self.assets.len())161
{162
return Err("ongeldige bronassetverwijzing".into());163
}164
for sex in 0..2 {165
if let Some(path) = &attachment.bones[sex] {166
if !self.rigs[sex]167
.as_ref()168
.map(|rig| rig.paths.contains(path))169
.unwrap_or(false)170
{171
return Err("attachment verwijst naar onbekend bot".into());172
}173
}174
}175
if attachment.label.len() > 128176
|| attachment.bones.iter().flatten().any(|p| p.len() > 4096)177
|| attachment178
.translation179
.iter()180
.chain(attachment.rotation_deg.iter())181
.any(|x| !x.is_finite() || x.abs() > 100_000.0)182
|| !attachment.specular.is_finite()183
|| !(0.0..=1.0).contains(&attachment.specular)184
|| attachment.meshes.len() > 64185
{186
return Err("ongeldige attachment/transformatie".into());187
}188
if let Some(index) = attachment.shiny {189
texture_indices.insert(index);190
}191
for mesh in &attachment.meshes {192
vertices += mesh.positions.len();193
if vertices > MAX_VERTICES194
|| mesh.indices.len() > MAX_VERTICES * 6195
|| mesh.indices.len() < 3196
|| mesh.indices.len() % 3 != 0197
|| mesh.positions.len() != mesh.normals.len()198
|| mesh.positions.len() != mesh.uvs.len()199
|| mesh.positions.is_empty()200
|| mesh201
.indices202
.iter()203
.any(|i| *i as usize >= mesh.positions.len())204
|| mesh205
.positions206
.iter()207
.flatten()208
.chain(mesh.normals.iter().flatten())209
.chain(mesh.uvs.iter().flatten())210
.chain(mesh.color.iter())211
.any(|x| !x.is_finite() || x.abs() > 1_000_000.0)212
{213
return Err("ongeldige of te grote equipmentmesh".into());214
}215
if let Some(index) = mesh.albedo {216
texture_indices.insert(index);217
}218
}219
}220
let mut decoded = 0usize;221
for index in texture_indices {222
decoded += self.texture(index)?.rgba.len();223
if decoded > 64 * 1024 * 1024 {224
return Err("textures samen groter dan 64 MiB".into());225
}226
}227
Ok(())228
}229
pub fn texture(&self, index: usize) -> Result<Texture, String> {230
let asset = self231
.assets232
.get(index)233
.ok_or("textureverwijzing ontbreekt")?;234
decode_png(&asset.bytes)235
}236
/// Exact container byte cost; does not substitute for semantic validation.237
pub fn encoded_len(&self) -> Result<usize, String> {238
let meta = serde_json::to_vec(self).map_err(|e| e.to_string())?.len();239
if meta > MAX_META {240
return Err("presetmetadata is te groot".into());241
}242
let size = self243
.assets244
.iter()245
.try_fold(20 + meta, |size, a| {246
size.checked_add(4)?.checked_add(a.bytes.len())247
})248
.ok_or("preset te groot")?;249
if size > MAX_FILE {250
return Err("preset te groot".into());251
}252
Ok(size)253
}254
pub fn encode(&self) -> Result<Vec<u8>, String> {255
self.validate()?;256
let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;257
if metadata.len() > MAX_META {258
return Err("presetmetadata is te groot".into());259
}260
let mut payload = metadata;261
let meta_len = payload.len();262
for asset in &self.assets {263
payload.extend_from_slice(&(asset.bytes.len() as u32).to_le_bytes());264
payload.extend_from_slice(&asset.bytes);265
}266
if payload.len() + 20 > MAX_FILE {267
return Err("preset is te groot".into());268
}269
let mut out = MAGIC.to_vec();270
out.extend_from_slice(&VERSION.to_le_bytes());271
out.extend_from_slice(&(meta_len as u32).to_le_bytes());272
out.extend_from_slice(&checksum(&payload).to_le_bytes());273
out.extend(payload);274
Ok(out)275
}276
pub fn decode(bytes: &[u8]) -> Result<Self, String> {277
if bytes.len() < 20 || bytes.len() > MAX_FILE || &bytes[..8] != MAGIC {278
return Err("ongeldig equipmentbestand of bestand te groot".into());279
}280
let number = |offset| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap());281
if number(8) != VERSION {282
return Err("onbekende equipmentversie".into());283
}284
let meta_len = number(12) as usize;285
if meta_len > MAX_META286
|| meta_len > bytes.len() - 20287
|| checksum(&bytes[20..]) != number(16)288
{289
return Err("beschadigde equipmentdata".into());290
}291
let mut preset: Self =292
serde_json::from_slice(&bytes[20..20 + meta_len]).map_err(|e| e.to_string())?;293
if preset.assets.len() > MAX_ASSETS {294
return Err("te veel assets".into());295
}296
let mut cursor = 20 + meta_len;297
for asset in &mut preset.assets {298
if cursor + 4 > bytes.len() {299
return Err("afgebroken asset".into());300
}301
let len = number(cursor) as usize;302
cursor += 4;303
if len > MAX_ASSET || len > bytes.len() - cursor {304
return Err("ongeldige assetlengte".into());305
}306
asset.bytes = bytes[cursor..cursor + len].to_vec();307
cursor += len;308
}309
if cursor != bytes.len() {310
return Err("onverwachte extra equipmentdata".into());311
}312
preset.validate()?;313
Ok(preset)314
}315
pub fn read(path: &Path) -> Result<Self, String> {316
if !path.is_file() {317
return Err("kies een regulier presetbestand".into());318
}319
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;320
let mut bytes = Vec::new();321
file.take((MAX_FILE + 1) as u64)322
.read_to_end(&mut bytes)323
.map_err(|e| e.to_string())?;324
Self::decode(&bytes)325
}326
}327
328
pub fn valid_name(name: &str) -> bool {329
!name.is_empty()330
&& name.len() <= 64331
&& name != "body"332
&& name333
.bytes()334
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_')335
}336
337
pub fn checksum(bytes: &[u8]) -> u32 {338
let mut crc = !0u32;339
for byte in bytes {340
crc ^= u32::from(*byte);341
for _ in 0..8 {342
crc = (crc >> 1) ^ (0xedb88320u32 & (0u32.wrapping_sub(crc & 1)));343
}344
}345
!crc346
}347
348
/// Immutable snapshot publish: a hard link atomically creates the destination349
/// without overwriting an older saved preset. Failure leaves old data intact.350
pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {351
if bytes.len() > MAX_FILE {352
return Err("snapshot te groot".into());353
}354
if path.exists() {355
if !path.is_file() {356
return Err("snapshot is geen regulier bestand".into());357
}358
let mut current = Vec::new();359
std::fs::File::open(path)360
.map_err(|e| e.to_string())?361
.take((MAX_FILE + 1) as u64)362
.read_to_end(&mut current)363
.map_err(|e| e.to_string())?;364
return if current == bytes {365
Ok(())366
} else {367
Err("snapshotnaam bestaat met andere inhoud".into())368
};369
}370
let stamp = std::time::SystemTime::now()371
.duration_since(std::time::UNIX_EPOCH)372
.unwrap_or_default()373
.as_nanos();374
let temp = path.with_extension(format!("ppe.part-{}-{stamp}", std::process::id()));375
let result = (|| {376
let mut file = std::fs::OpenOptions::new()377
.write(true)378
.create_new(true)379
.open(&temp)380
.map_err(|e| e.to_string())?;381
file.write_all(bytes)382
.and_then(|_| file.sync_all())383
.map_err(|e| e.to_string())?;384
std::fs::hard_link(&temp, path).map_err(|e| e.to_string())?;385
#[cfg(unix)]386
std::fs::File::open(path.parent().ok_or("snapshotmap ontbreekt")?)387
.and_then(|dir| dir.sync_all())388
.map_err(|e| e.to_string())?;389
Ok(())390
})();391
let _ = std::fs::remove_file(temp);392
result393
}394
395
#[derive(Debug, Clone, PartialEq)]396
pub struct Texture {397
pub content_hash: u64,398
pub width: u32,399
pub height: u32,400
pub rgba: Vec<u8>,401
}402
fn decode_png(bytes: &[u8]) -> Result<Texture, String> {403
let mut decoder = png::Decoder::new(Cursor::new(bytes));404
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);405
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;406
let info = reader.info();407
if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {408
return Err("texture is groter dan 2048×2048".into());409
}410
let size = reader411
.output_buffer_size()412
.ok_or("ongeldige texturegrootte")?;413
if size > 2048 * 2048 * 8 {414
return Err("texture is te groot".into());415
}416
let mut data = vec![0; size];417
let info = reader.next_frame(&mut data).map_err(|e| e.to_string())?;418
let channels = info.color_type.samples();419
let mut rgba = Vec::with_capacity(info.width as usize * info.height as usize * 4);420
for pixel in data[..info.buffer_size()].chunks_exact(channels) {421
match info.color_type {422
png::ColorType::Grayscale => {423
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], 255])424
}425
png::ColorType::GrayscaleAlpha => {426
rgba.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]])427
}428
png::ColorType::Rgb => rgba.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]),429
png::ColorType::Rgba => rgba.extend_from_slice(pixel),430
_ => return Err("onbekende texturecodering".into()),431
}432
}433
use std::hash::{Hash, Hasher};434
let mut hash = std::collections::hash_map::DefaultHasher::new();435
rgba.hash(&mut hash);436
Ok(Texture {437
content_hash: hash.finish(),438
width: info.width,439
height: info.height,440
rgba,441
})442
}443
444
pub fn local_matrix(translation: [f32; 3], degrees: [f32; 3]) -> Mat4 {445
let [x, y, z] = degrees.map(f32::to_radians);446
let (sx, cx) = x.sin_cos();447
let (sy, cy) = y.sin_cos();448
let (sz, cz) = z.sin_cos();449
let rx = [450
1., 0., 0., 0., 0., cx, sx, 0., 0., -sx, cx, 0., 0., 0., 0., 1.,451
];452
let ry = [453
cy, 0., -sy, 0., 0., 1., 0., 0., sy, 0., cy, 0., 0., 0., 0., 1.,454
];455
let rz = [456
cz, sz, 0., 0., -sz, cz, 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,457
];458
let mut matrix = mul(rz, mul(ry, rx));459
matrix[12] = translation[0];460
matrix[13] = translation[1];461
matrix[14] = translation[2];462
matrix463
}464
465
#[derive(Debug, Clone)]466
pub struct Layer {467
pub mesh: Mesh,468
pub model: Mat4,469
pub albedo: Option<std::sync::Arc<Texture>>,470
pub shiny: Option<std::sync::Arc<Texture>>,471
pub specular: f32,472
}473
474
#[derive(Default)]475
pub struct TextureCache {476
entries: std::collections::VecDeque<(Vec<u8>, std::sync::Arc<Texture>)>,477
bytes: usize,478
}479
impl TextureCache {480
fn get(&mut self, preset: &Preset, index: usize) -> Result<std::sync::Arc<Texture>, String> {481
let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;482
if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {483
return Ok(texture.clone());484
}485
let texture = std::sync::Arc::new(decode_png(bytes)?);486
let cost = bytes.len() + texture.rgba.len();487
while self.bytes + cost > 128 * 1024 * 1024 {488
if let Some((source, image)) = self.entries.pop_front() {489
self.bytes -= source.len() + image.rgba.len();490
} else {491
break;492
}493
}494
self.bytes += cost;495
self.entries.push_back((bytes.clone(), texture.clone()));496
Ok(texture)497
}498
}499
500
pub fn layers(501
preset: &Preset,502
sex: usize,503
body: &PreviewCharacter,504
controls: &PreviewControls,505
) -> Result<Vec<Layer>, String> {506
layers_cached(preset, sex, body, controls, &mut TextureCache::default())507
}508
509
pub fn layers_cached(510
preset: &Preset,511
sex: usize,512
body: &PreviewCharacter,513
controls: &PreviewControls,514
cache: &mut TextureCache,515
) -> Result<Vec<Layer>, String> {516
if sex > 1 {517
return Err("ongeldig geslacht".into());518
}519
if !preset.applies[sex] || preset.attachments.is_empty() {520
return Ok(Vec::new());521
}522
let signature = RigSignature::of(body);523
if preset.rigs[sex].as_ref() != Some(&signature) {524
return Err("rig wijkt af: kies de botten opnieuw voor dit lichaam".into());525
}526
let paths = joint_paths(body);527
let mut layers = Vec::new();528
for item in &preset.attachments {529
let path = item.bones[sex]530
.as_deref()531
.ok_or("kies een bot voor dit onderdeel en geslacht")?;532
let joint = paths533
.iter()534
.position(|p| p == path)535
.ok_or("het gekoppelde bot ontbreekt")?;536
let world = animated_joint_world(body, &controls.clip_name, controls.time, joint)537
.ok_or("het gekoppelde bot ontbreekt")?;538
let model = mul(world, local_matrix(item.translation, item.rotation_deg));539
let shiny = item.shiny.map(|i| cache.get(preset, i)).transpose()?;540
for mesh in &item.meshes {541
layers.push(Layer {542
mesh: mesh.clone(),543
model,544
albedo: mesh.albedo.map(|i| cache.get(preset, i)).transpose()?,545
shiny: shiny.clone(),546
specular: item.specular,547
});548
}549
}550
Ok(layers)551
}552
553
/// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.554
/// No extraction directories, absolute references or parent traversal allowed.555
pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {556
if source557
.extension()558
.and_then(|x| x.to_str())559
.map(|x| !x.eq_ignore_ascii_case("obj"))560
.unwrap_or(true)561
{562
return Err("kies een rigide OBJ-model met MTL/PNG-bijbestanden".into());563
}564
let root = source565
.parent()566
.ok_or("modelmap ontbreekt")?567
.canonicalize()568
.map_err(|e| e.to_string())?;569
let name = source570
.file_name()571
.and_then(|x| x.to_str())572
.ok_or("ongeldige modelnaam")?;573
let mut candidate = preset.clone();574
if candidate.attachments.len() >= 32 {575
return Err("te veel onderdelen".into());576
}577
compact_assets(&mut candidate)?;578
let prefix = unique_prefix(&candidate, "part");579
let obj_bytes = read_asset(&root, name)?;580
let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;581
let mut dependencies = BTreeSet::new();582
dependencies.insert(name.to_string());583
for line in obj_text.lines() {584
if let Some(rest) = line.trim().strip_prefix("mtllib ") {585
let mtl = rest.trim();586
dependencies.insert(mtl.to_string());587
let bytes = read_asset(&root, mtl)?;588
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;589
for line in text.lines() {590
let line = line.trim();591
if line.starts_with("map_")592
|| line.starts_with("bump ")593
|| line.starts_with("disp ")594
{595
let (_, path) = line596
.split_once(char::is_whitespace)597
.ok_or("ongeldige textureverwijzing")?;598
let relative = Path::new(mtl)599
.parent()600
.unwrap_or(Path::new(""))601
.join(path.trim());602
let relative = relative603
.to_str()604
.ok_or("ongeldig texturepad")?605
.replace('\\', "/");606
portable_relative(&relative)?;607
dependencies.insert(relative);608
}609
}610
}611
}612
if dependencies.len() + candidate.assets.len() > MAX_ASSETS {613
return Err("te veel bijbestanden".into());614
}615
let mut asset_map = std::collections::BTreeMap::new();616
let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();617
for relative in dependencies {618
let bytes = read_asset(&root, &relative)?;619
total_bytes += bytes.len();620
if total_bytes > MAX_FILE {621
return Err("assets samen te groot".into());622
}623
let index = candidate.assets.len();624
candidate.assets.push(EmbeddedAsset {625
path: format!("{prefix}/{relative}"),626
bytes,627
});628
asset_map.insert(relative, index);629
}630
// A checked callback prevents the parser from reading undeclared/unsafe files.631
let mut input = std::io::BufReader::new(Cursor::new(obj_bytes));632
let options = tobj::LoadOptions {633
triangulate: true,634
single_index: true,635
..Default::default()636
};637
let texture_paths = std::cell::RefCell::new(std::collections::BTreeMap::new());638
let (models, materials) = tobj::load_obj_buf(&mut input, &options, |path| {639
let relative = path.to_string_lossy().replace('\\', "/");640
let index = asset_map641
.get(&relative)642
.ok_or(tobj::LoadError::OpenFileFailed)?;643
let mut reader = std::io::BufReader::new(Cursor::new(&candidate.assets[*index].bytes));644
let (materials, map) = tobj::load_mtl_buf(&mut reader)?;645
for material in &materials {646
if let Some(texture) = &material.diffuse_texture {647
let relative = path648
.parent()649
.unwrap_or(Path::new(""))650
.join(texture)651
.to_string_lossy()652
.replace('\\', "/");653
texture_paths654
.borrow_mut()655
.insert(material.name.clone(), relative);656
}657
}658
Ok((materials, map))659
})660
.map_err(|e| e.to_string())?;661
let materials = materials.map_err(|e| e.to_string())?;662
let texture_paths = texture_paths.into_inner();663
let mut meshes = Vec::new();664
let mut vertices: usize = candidate665
.attachments666
.iter()667
.flat_map(|a| &a.meshes)668
.map(|m| m.positions.len())669
.sum();670
for model in models {671
let source = model.mesh;672
vertices += source.positions.len() / 3;673
if vertices > MAX_VERTICES || source.indices.len() > MAX_VERTICES * 6 {674
return Err("equipmentmesh is te groot".into());675
}676
let positions: Vec<[f32; 3]> = source677
.positions678
.chunks_exact(3)679
.map(|x| [x[0], x[1], x[2]])680
.collect();681
let mut normals: Vec<[f32; 3]> = source682
.normals683
.chunks_exact(3)684
.map(|x| [x[0], x[1], x[2]])685
.collect();686
if normals.len() != positions.len() {687
normals = compute_normals(&positions, &source.indices)?;688
}689
let mut uvs: Vec<[f32; 2]> = source690
.texcoords691
.chunks_exact(2)692
.map(|x| [x[0], x[1]])693
.collect();694
if uvs.len() != positions.len() {695
uvs = vec![[0.; 2]; positions.len()];696
}697
let material = source.material_id.and_then(|id| materials.get(id));698
let color = material.and_then(|m| m.diffuse).unwrap_or([0.7, 0.7, 0.7]);699
let albedo = material700
.and_then(|m| texture_paths.get(&m.name))701
.map(|path| {702
asset_map703
.get(path)704
.copied()705
.ok_or("ontbrekende diffuse texture")706
})707
.transpose()?;708
meshes.push(Mesh {709
positions,710
normals,711
uvs,712
indices: source.indices,713
color: [714
color[0],715
color[1],716
color[2],717
material.and_then(|m| m.dissolve).unwrap_or(1.),718
],719
albedo,720
});721
}722
if meshes.is_empty() {723
return Err("het OBJ-bestand heeft geen meshes".into());724
}725
candidate.attachments.push(Attachment {726
label: name.to_string(),727
source_assets: asset_map.values().copied().collect(),728
bones: [None, None],729
translation: [0.; 3],730
rotation_deg: [0.; 3],731
meshes,732
shiny: None,733
specular: 0.25,734
});735
candidate.validate()?;736
*preset = candidate;737
Ok(preset.attachments.len() - 1)738
}739
740
pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {741
let root = source.parent().ok_or("texturemap ontbreekt")?;742
let name = source743
.file_name()744
.and_then(|x| x.to_str())745
.ok_or("ongeldige bestandsnaam")?;746
let bytes = read_asset(root, name)?;747
decode_png(&bytes)?;748
let mut candidate = preset.clone();749
let index = candidate.assets.len();750
candidate.assets.push(EmbeddedAsset {751
path: format!("{}/{name}", unique_prefix(&candidate, "shiny")),752
bytes,753
});754
candidate755
.attachments756
.get_mut(attachment)757
.ok_or("onderdeel ontbreekt")?758
.shiny = Some(index);759
compact_assets(&mut candidate)?;760
candidate.validate()?;761
*preset = candidate;762
Ok(())763
}764
765
fn unique_prefix(preset: &Preset, kind: &str) -> String {766
(0..)767
.map(|i| format!("{kind}-{i}"))768
.find(|prefix| {769
!preset770
.assets771
.iter()772
.any(|a| a.path.split('/').next() == Some(prefix.as_str()))773
})774
.expect("finite asset list")775
}776
fn compact_assets(preset: &mut Preset) -> Result<(), String> {777
let used: BTreeSet<usize> = preset778
.attachments779
.iter()780
.flat_map(|a| {781
a.source_assets782
.iter()783
.copied()784
.chain(a.shiny)785
.chain(a.meshes.iter().filter_map(|m| m.albedo))786
})787
.collect();788
if used.iter().any(|i| *i >= preset.assets.len()) {789
return Err("ongeldige assetverwijzing".into());790
}791
let mut remap = vec![0; preset.assets.len()];792
let mut assets = Vec::new();793
for (i, asset) in preset.assets.iter().enumerate() {794
if used.contains(&i) {795
remap[i] = assets.len();796
assets.push(asset.clone());797
}798
}799
for a in &mut preset.attachments {800
for i in &mut a.source_assets {801
*i = remap[*i];802
}803
if let Some(i) = a.shiny.as_mut() {804
*i = remap[*i];805
}806
for mesh in &mut a.meshes {807
if let Some(i) = mesh.albedo.as_mut() {808
*i = remap[*i];809
}810
}811
}812
preset.assets = assets;813
Ok(())814
}815
/// Clear an override and release its no-longer-used embedded texture.816
pub fn clear_shiny(preset: &mut Preset, index: usize) -> Result<(), String> {817
let mut candidate = preset.clone();818
candidate819
.attachments820
.get_mut(index)821
.ok_or("onderdeel ontbreekt")?822
.shiny = None;823
compact_assets(&mut candidate)?;824
*preset = candidate;825
Ok(())826
}827
/// Remove a part and its no-longer-used embedded assets, remapping all indices.828
pub fn remove_attachment(preset: &mut Preset, index: usize) -> Result<(), String> {829
if index >= preset.attachments.len() {830
return Err("onderdeel ontbreekt".into());831
}832
let mut candidate = preset.clone();833
candidate.attachments.remove(index);834
compact_assets(&mut candidate)?;835
*preset = candidate;836
Ok(())837
}838
839
fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {840
portable_relative(relative)?;841
let root = root.canonicalize().map_err(|e| e.to_string())?;842
let path = root843
.join(relative)844
.canonicalize()845
.map_err(|e| e.to_string())?;846
if !path.starts_with(&root) {847
return Err("asset verlaat de modelmap".into());848
}849
if !path.is_file() {850
return Err("asset is geen regulier bestand".into());851
}852
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;853
let mut bytes = Vec::new();854
file.take((MAX_ASSET + 1) as u64)855
.read_to_end(&mut bytes)856
.map_err(|e| e.to_string())?;857
if bytes.len() > MAX_ASSET {858
return Err("asset is te groot".into());859
}860
Ok(bytes)861
}862
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Result<Vec<[f32; 3]>, String> {863
let mut normals = vec![[0.; 3]; positions.len()];864
for t in indices.chunks_exact(3) {865
let [a, b, c] = [t[0] as usize, t[1] as usize, t[2] as usize];866
if [a, b, c].iter().any(|i| *i >= positions.len()) {867
return Err("ongeldige OBJ-index".into());868
}869
let ab = std::array::from_fn::<_, 3, _>(|i| positions[b][i] - positions[a][i]);870
let ac = std::array::from_fn::<_, 3, _>(|i| positions[c][i] - positions[a][i]);871
let n = [872
ab[1] * ac[2] - ab[2] * ac[1],873
ab[2] * ac[0] - ab[0] * ac[2],874
ab[0] * ac[1] - ab[1] * ac[0],875
];876
for vertex in [a, b, c] {877
for (i, value) in n.iter().enumerate() {878
normals[vertex][i] += value;879
}880
}881
}882
for normal in &mut normals {883
let length = normal.iter().map(|v| v * v).sum::<f32>().sqrt();884
if length > 1e-10 {885
for v in normal {886
*v /= length887
}888
}889
}890
Ok(normals)891
}@@ -5,6 +5,7 @@
5
5
6
6
pub mod dialogs;
7
7
pub mod editor;
8
pub mod equipment;
8
9
pub mod file_assoc;
9
10
pub mod foundation;
10
11
pub mod icon;
@@ -5,6 +5,7 @@5
5
6
pub mod dialogs;6
pub mod dialogs;7
pub mod editor;7
pub mod editor;8
pub mod equipment;8
pub mod file_assoc;9
pub mod file_assoc;9
pub mod foundation;10
pub mod foundation;10
pub mod icon;11
pub mod icon;@@ -279,6 +279,43 @@ pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time:
279
279
.collect()
280
280
}
281
281
282
/// Stable hierarchy path includes node indices to disambiguate duplicate names.
283
pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {
284
character
285
.joint_nodes
286
.iter()
287
.map(|&node| {
288
let mut names = Vec::new();
289
let mut cursor = Some(node);
290
while let Some(index) = cursor {
291
let item = &character.nodes[index];
292
names.push(format!("{}[{}]", item.name, index));
293
cursor = item.parent;
294
if names.len() > character.nodes.len() {
295
break;
296
}
297
}
298
names.reverse();
299
names.join("/")
300
})
301
.collect()
302
}
303
304
pub fn animated_joint_world(
305
character: &PreviewCharacter,
306
clip: &str,
307
time: f32,
308
joint: usize,
309
) -> Option<Mat4> {
310
let locals = sample_local_matrices(character, clip, time);
311
let worlds = world_matrices(&character.nodes, &locals);
312
character
313
.joint_nodes
314
.get(joint)
315
.and_then(|node| worlds.get(*node))
316
.copied()
317
}
318
282
319
pub fn joint_world_translation(
283
320
character: &PreviewCharacter,
284
321
clip_name: &str,
@@ -279,6 +279,43 @@ pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time:279
.collect()279
.collect()280
}280
}281
281
282
/// Stable hierarchy path includes node indices to disambiguate duplicate names.283
pub fn joint_paths(character: &PreviewCharacter) -> Vec<String> {284
character285
.joint_nodes286
.iter()287
.map(|&node| {288
let mut names = Vec::new();289
let mut cursor = Some(node);290
while let Some(index) = cursor {291
let item = &character.nodes[index];292
names.push(format!("{}[{}]", item.name, index));293
cursor = item.parent;294
if names.len() > character.nodes.len() {295
break;296
}297
}298
names.reverse();299
names.join("/")300
})301
.collect()302
}303
304
pub fn animated_joint_world(305
character: &PreviewCharacter,306
clip: &str,307
time: f32,308
joint: usize,309
) -> Option<Mat4> {310
let locals = sample_local_matrices(character, clip, time);311
let worlds = world_matrices(&character.nodes, &locals);312
character313
.joint_nodes314
.get(joint)315
.and_then(|node| worlds.get(*node))316
.copied()317
}318
282
pub fn joint_world_translation(319
pub fn joint_world_translation(283
character: &PreviewCharacter,320
character: &PreviewCharacter,284
clip_name: &str,321
clip_name: &str,@@ -1,4 +1,4 @@
1
//! OpenGL viewport for one skinned glTF body.
1
//! OpenGL viewport for a skinned glTF body and rigid textured equipment.
2
2
//!
3
3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll
4
4
//! diffuse/depth/normal export.
@@ -43,11 +43,18 @@ impl Viewport {
43
43
}
44
44
}
45
45
46
pub fn destroy(&self, gl: &glow::Context) {
47
if let Ok(mut cache) = self.cache.lock() {
48
unsafe { cache.destroy(gl) }
49
}
50
}
51
46
52
pub fn show(
47
53
&self,
48
54
ui: &mut egui::Ui,
49
55
controls: &mut PreviewControls,
50
56
character: Option<&PreviewCharacter>,
57
presets: &[&crate::equipment::Preset],
51
58
) {
52
59
ui.horizontal_wrapped(|ui| {
53
60
if let Some(character) = character {
@@ -139,8 +146,33 @@ impl Viewport {
139
146
perspective(38.0, aspect, 0.05, 40.0),
140
147
view_matrix(eye, target),
141
148
);
149
let mut cache = self.cache.lock().expect("preview gpu lock");
150
let mut diagnostics = Vec::new();
151
let mut equipment = Vec::new();
152
let sex = if controls.sex == crate::preview::PreviewSex::Male {
153
0
154
} else {
155
1
156
};
157
for preset in presets {
158
match crate::equipment::layers_cached(
159
preset,
160
sex,
161
character,
162
controls,
163
&mut cache.texture_cache,
164
) {
165
Ok(layers) => {
166
for layer in layers {
167
equipment.push(PendingFrame::from_layer(layer, view_proj, eye));
168
}
169
}
170
Err(error) => {
171
diagnostics.push(error);
172
}
173
}
174
}
142
175
{
143
let mut cache = self.cache.lock().expect("preview gpu lock");
144
176
cache.pending = Some(PendingFrame {
145
177
generation: character_generation(character),
146
178
positions: character.positions.clone(),
@@ -151,18 +183,45 @@ impl Viewport {
151
183
bones,
152
184
view_proj,
153
185
color: character.base_color,
186
model: crate::preview::identity(),
187
albedo: None,
188
shiny: None,
189
specular: 0.0,
190
eye,
191
equipment,
192
uvs: Vec::new(),
154
193
});
155
194
}
195
drop(cache);
156
196
let cache = Arc::clone(&self.cache);
157
197
let callback = CallbackFn::new(move |info, painter| {
158
198
if let Ok(mut cache) = cache.lock() {
159
unsafe { cache.paint(painter.gl(), &info) };
199
unsafe { cache.paint(painter.gl(), &info, true) };
160
200
}
161
201
});
162
202
ui.painter().add(egui::PaintCallback {
163
203
rect,
164
204
callback: Arc::new(callback),
165
205
});
206
if let Ok(cache) = self.cache.lock() {
207
if let Some(error) = &cache.error {
208
diagnostics.push(error.clone());
209
}
210
for layer in &cache.layers {
211
if let Some(error) = &layer.error {
212
diagnostics.push(error.clone());
213
}
214
}
215
}
216
if !diagnostics.is_empty() {
217
ui.painter().text(
218
rect.left_top() + egui::vec2(12., 12.),
219
egui::Align2::LEFT_TOP,
220
diagnostics.join("\n"),
221
egui::FontId::proportional(14.),
222
egui::Color32::LIGHT_RED,
223
);
224
}
166
225
}
167
226
}
168
227
@@ -194,6 +253,62 @@ struct PendingFrame {
194
253
bones: Vec<crate::preview::Mat4>,
195
254
view_proj: crate::preview::Mat4,
196
255
color: [f32; 4],
256
model: crate::preview::Mat4,
257
eye: [f32; 3],
258
albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
259
shiny: Option<std::sync::Arc<crate::equipment::Texture>>,
260
specular: f32,
261
equipment: Vec<PendingFrame>,
262
uvs: Vec<[f32; 2]>,
263
}
264
265
impl PendingFrame {
266
fn from_layer(
267
layer: crate::equipment::Layer,
268
view_proj: crate::preview::Mat4,
269
eye: [f32; 3],
270
) -> Self {
271
use std::hash::{Hash, Hasher};
272
let mut hash = std::collections::hash_map::DefaultHasher::new();
273
for v in &layer.mesh.positions {
274
v.map(f32::to_bits).hash(&mut hash);
275
}
276
for v in &layer.mesh.normals {
277
v.map(f32::to_bits).hash(&mut hash);
278
}
279
for v in &layer.mesh.uvs {
280
v.map(f32::to_bits).hash(&mut hash);
281
}
282
layer.mesh.indices.hash(&mut hash);
283
if let Some(t) = &layer.albedo {
284
t.content_hash.hash(&mut hash);
285
t.width.hash(&mut hash);
286
t.height.hash(&mut hash);
287
}
288
if let Some(t) = &layer.shiny {
289
t.content_hash.hash(&mut hash);
290
t.width.hash(&mut hash);
291
t.height.hash(&mut hash);
292
}
293
Self {
294
generation: hash.finish(),
295
positions: layer.mesh.positions,
296
normals: layer.mesh.normals,
297
indices: layer.mesh.indices,
298
uvs: layer.mesh.uvs,
299
color: layer.mesh.color,
300
joints: Vec::new(),
301
weights: Vec::new(),
302
bones: Vec::new(),
303
model: layer.model,
304
view_proj,
305
eye,
306
albedo: layer.albedo,
307
shiny: layer.shiny,
308
specular: layer.specular,
309
equipment: Vec::new(),
310
}
311
}
197
312
}
198
313
199
314
#[derive(Default)]
@@ -206,10 +321,13 @@ struct GpuCache {
206
321
index_count: i32,
207
322
pending: Option<PendingFrame>,
208
323
error: Option<String>,
324
textures: [Option<glow::Texture>; 2],
325
layers: Vec<GpuCache>,
326
texture_cache: crate::equipment::TextureCache,
209
327
}
210
328
211
329
impl GpuCache {
212
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {
330
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo, clear: bool) {
213
331
let Some(frame) = self.pending.clone() else {
214
332
return;
215
333
};
@@ -249,10 +367,40 @@ impl GpuCache {
249
367
gl.depth_func(glow::LESS);
250
368
gl.disable(glow::BLEND);
251
369
gl.clear_color(0.08, 0.09, 0.12, 1.0);
252
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
370
if clear {
371
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
372
}
253
373
gl.use_program(Some(program));
254
374
let view = gl.get_uniform_location(program, "u_view_proj");
255
375
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);
376
gl.uniform_matrix_4_f32_slice(
377
gl.get_uniform_location(program, "u_model").as_ref(),
378
false,
379
&frame.model,
380
);
381
gl.uniform_3_f32(
382
gl.get_uniform_location(program, "u_eye").as_ref(),
383
frame.eye[0],
384
frame.eye[1],
385
frame.eye[2],
386
);
387
gl.uniform_1_f32(
388
gl.get_uniform_location(program, "u_specular").as_ref(),
389
frame.specular,
390
);
391
for (unit, name, present) in [
392
(0, "u_albedo", frame.albedo.is_some()),
393
(1, "u_shiny", frame.shiny.is_some()),
394
] {
395
gl.active_texture(glow::TEXTURE0 + unit as u32);
396
gl.bind_texture(glow::TEXTURE_2D, self.textures[unit]);
397
gl.uniform_1_i32(gl.get_uniform_location(program, name).as_ref(), unit as i32);
398
gl.uniform_1_i32(
399
gl.get_uniform_location(program, &format!("{name}_enabled"))
400
.as_ref(),
401
i32::from(present),
402
);
403
}
256
404
let color = gl.get_uniform_location(program, "u_color");
257
405
gl.uniform_4_f32(
258
406
color.as_ref(),
@@ -271,11 +419,46 @@ impl GpuCache {
271
419
gl.bind_vertex_array(self.vao);
272
420
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);
273
421
gl.bind_vertex_array(None);
422
while self.layers.len() > frame.equipment.len() {
423
if let Some(mut layer) = self.layers.pop() {
424
layer.destroy(gl);
425
}
426
}
427
while self.layers.len() < frame.equipment.len() {
428
self.layers.push(GpuCache::default());
429
}
430
for (cache, frame) in self.layers.iter_mut().zip(frame.equipment) {
431
cache.pending = Some(frame);
432
cache.paint(gl, info, false);
433
}
434
gl.active_texture(glow::TEXTURE0);
274
435
gl.disable(glow::DEPTH_TEST);
275
436
gl.disable(glow::SCISSOR_TEST);
276
437
gl.enable(glow::BLEND);
277
438
}
278
439
440
unsafe fn destroy(&mut self, gl: &glow::Context) {
441
for layer in &mut self.layers {
442
layer.destroy(gl);
443
}
444
for texture in &mut self.textures {
445
if let Some(t) = texture.take() {
446
gl.delete_texture(t)
447
}
448
}
449
if let Some(v) = self.vao.take() {
450
gl.delete_vertex_array(v)
451
}
452
if let Some(v) = self.vbo.take() {
453
gl.delete_buffer(v)
454
}
455
if let Some(v) = self.ebo.take() {
456
gl.delete_buffer(v)
457
}
458
if let Some(p) = self.program.take() {
459
gl.delete_program(p)
460
}
461
}
279
462
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {
280
463
if self.program.is_some() {
281
464
return Ok(());
@@ -292,7 +475,7 @@ impl GpuCache {
292
475
}
293
476
294
477
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {
295
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);
478
let mut interleaved = Vec::with_capacity(frame.positions.len() * 16);
296
479
for index in 0..frame.positions.len() {
297
480
let position = frame.positions[index];
298
481
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);
@@ -311,6 +494,7 @@ impl GpuCache {
311
494
joint[3] as f32,
312
495
]);
313
496
interleaved.extend_from_slice(&weight);
497
interleaved.extend_from_slice(&frame.uvs.get(index).copied().unwrap_or([0.; 2]));
314
498
}
315
499
gl.bind_vertex_array(self.vao);
316
500
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
@@ -325,11 +509,46 @@ impl GpuCache {
325
509
bytemuck_u32(&frame.indices),
326
510
glow::DYNAMIC_DRAW,
327
511
);
328
let stride = 14 * 4;
512
let stride = 16 * 4;
329
513
enable_float(gl, 0, 3, stride, 0);
330
514
enable_float(gl, 1, 3, stride, 3 * 4);
331
515
enable_float(gl, 2, 4, stride, 6 * 4);
332
516
enable_float(gl, 3, 4, stride, 10 * 4);
517
enable_float(gl, 4, 2, stride, 14 * 4);
518
for (unit, texture) in [&frame.albedo, &frame.shiny].iter().enumerate() {
519
if let Some(old) = self.textures[unit].take() {
520
gl.delete_texture(old);
521
}
522
if let Some(texture) = texture {
523
let id = gl.create_texture().map_err(|e| e.to_string())?;
524
self.textures[unit] = Some(id);
525
gl.active_texture(glow::TEXTURE0 + unit as u32);
526
gl.bind_texture(glow::TEXTURE_2D, Some(id));
527
gl.tex_parameter_i32(
528
glow::TEXTURE_2D,
529
glow::TEXTURE_MIN_FILTER,
530
glow::NEAREST as i32,
531
);
532
gl.tex_parameter_i32(
533
glow::TEXTURE_2D,
534
glow::TEXTURE_MAG_FILTER,
535
glow::NEAREST as i32,
536
);
537
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
538
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
539
gl.tex_image_2d(
540
glow::TEXTURE_2D,
541
0,
542
glow::RGBA8 as i32,
543
texture.width as i32,
544
texture.height as i32,
545
0,
546
glow::RGBA,
547
glow::UNSIGNED_BYTE,
548
Some(&texture.rgba),
549
);
550
}
551
}
333
552
self.uploaded = frame.generation;
334
553
self.index_count = frame.indices.len() as i32;
335
554
Ok(())
@@ -379,6 +598,10 @@ layout(location = 0) in vec3 a_pos;
379
598
layout(location = 1) in vec3 a_normal;
380
599
layout(location = 2) in vec4 a_joints;
381
600
layout(location = 3) in vec4 a_weights;
601
layout(location = 4) in vec2 a_uv;
602
uniform mat4 u_model;
603
out vec2 v_uv;
604
out vec3 v_world;
382
605
uniform mat4 u_view_proj;
383
606
uniform mat4 u_bones[128];
384
607
out vec3 v_normal;
@@ -391,8 +614,9 @@ void main() {
391
614
+ a_weights.y * u_bones[j1]
392
615
+ a_weights.z * u_bones[j2]
393
616
+ a_weights.w * u_bones[j3];
394
vec4 world = skin * vec4(a_pos, 1.0);
395
v_normal = mat3(skin) * a_normal;
617
vec4 world = u_model * skin * vec4(a_pos, 1.0);
618
v_uv = vec2(a_uv.x,1.0-a_uv.y); v_world=world.xyz;
619
v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;
396
620
gl_Position = u_view_proj * world;
397
621
}
398
622
"#;
@@ -400,12 +624,26 @@ void main() {
400
624
const FRAGMENT: &str = r#"#version 330
401
625
in vec3 v_normal;
402
626
uniform vec4 u_color;
627
uniform sampler2D u_albedo;
628
uniform sampler2D u_shiny;
629
uniform bool u_albedo_enabled;
630
uniform bool u_shiny_enabled;
631
uniform float u_specular;
632
uniform vec3 u_eye;
633
in vec2 v_uv;
634
in vec3 v_world;
403
635
out vec4 frag;
404
636
void main() {
405
637
vec3 n = normalize(v_normal);
406
638
vec3 light = normalize(vec3(0.35, 0.85, 0.4));
407
639
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);
408
frag = vec4(u_color.rgb * shade, 1.0);
640
vec4 color=u_color;
641
if(u_albedo_enabled) color*=texture(u_albedo,v_uv);
642
if(color.a<0.5) discard;
643
float strength=u_specular;
644
if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));
645
float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;
646
frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);
409
647
}
410
648
"#;
411
649
@@ -502,6 +740,13 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
502
740
bones: vec![crate::preview::identity()],
503
741
view_proj: crate::preview::identity(),
504
742
color: [1.; 4],
743
model: crate::preview::identity(),
744
eye: [0., 0., -3.],
745
albedo: None,
746
shiny: None,
747
specular: 0.,
748
uvs: vec![[0.; 2]; 6],
749
equipment: Vec::new(),
505
750
};
506
751
let mut cache = GpuCache::default();
507
752
let read = || {
@@ -523,40 +768,99 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
523
768
gl.clear_depth_f32(0.0);
524
769
gl.depth_func(glow::GREATER);
525
770
cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
526
cache.paint(gl, &info);
771
cache.paint(gl, &info, true);
527
772
let near = read();
528
773
if near[..3].iter().any(|c| *c < 250) {
529
774
return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
530
775
}
531
776
cache.pending = Some(make_frame(vec![3, 4, 5], 2));
532
cache.paint(gl, &info);
777
cache.paint(gl, &info, true);
533
778
let next = read();
534
779
if next[..3].iter().any(|c| !(69..=73).contains(c)) {
535
780
return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
536
781
}
537
782
cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
538
cache.paint(gl, &info);
783
cache.paint(gl, &info, true);
539
784
let reverse = read();
540
785
if near != reverse {
541
786
return Err(format!(
542
787
"draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
543
788
));
544
789
}
545
println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}");
790
let mut body = make_frame(vec![0, 1, 2], 4);
791
let mut behind = make_frame(vec![3, 4, 5], 5);
792
behind.color = [1., 0., 0., 1.];
793
body.equipment.push(behind);
794
cache.pending = Some(body);
795
cache.paint(gl, &info, true);
796
let hidden = read();
797
if hidden != near {
798
return Err(format!("equipment wist body-depth: {hidden:?}"));
799
}
800
let mut body = make_frame(vec![3, 4, 5], 6);
801
let mut front = make_frame(vec![0, 1, 2], 7);
802
front.albedo = Some(Arc::new(crate::equipment::Texture {
803
content_hash: 1,
804
width: 1,
805
height: 1,
806
rgba: vec![255, 0, 0, 255],
807
}));
808
body.equipment.push(front.clone());
809
cache.pending = Some(body.clone());
810
cache.paint(gl, &info, true);
811
let textured = read();
812
if textured[0] < 250 || textured[1] > 2 || textured[2] > 2 {
813
return Err(format!("voorste equipmenttexture ontbreekt: {textured:?}"));
814
}
815
front.generation = 8;
816
front.albedo = Some(Arc::new(crate::equipment::Texture {
817
content_hash: 2,
818
width: 1,
819
height: 1,
820
rgba: vec![255, 0, 0, 0],
821
}));
822
body.equipment = vec![front];
823
cache.pending = Some(body);
824
cache.paint(gl, &info, true);
825
let cutout = read();
826
if cutout != next {
827
return Err(format!("alpha-cutout bedekt body: {cutout:?}"));
828
}
829
let mut body = make_frame(vec![3, 4, 5], 9);
830
let mut front = make_frame(vec![0, 1, 2], 10);
831
front.color = [0., 0., 0., 1.];
832
front.specular = 1.;
833
front.eye = [35000., 85000., 40000.];
834
front.shiny = Some(Arc::new(crate::equipment::Texture {
835
content_hash: 3,
836
width: 1,
837
height: 1,
838
rgba: vec![255, 255, 255, 255],
839
}));
840
body.equipment = vec![front.clone()];
841
cache.pending = Some(body.clone());
842
cache.paint(gl, &info, true);
843
let shiny = read();
844
front.generation = 11;
845
front.shiny = Some(Arc::new(crate::equipment::Texture {
846
content_hash: 4,
847
width: 1,
848
height: 1,
849
rgba: vec![0, 0, 0, 255],
850
}));
851
body.equipment = vec![front];
852
cache.pending = Some(body);
853
cache.paint(gl, &info, true);
854
let matte = read();
855
if shiny[..3].iter().any(|c| *c < 250) || matte[..3] != [0, 0, 0] {
856
return Err(format!(
857
"shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}"
858
));
859
}
860
println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}");
546
861
Ok(())
547
862
})();
548
if let Some(p) = cache.program {
549
gl.delete_program(p)
550
}
551
if let Some(v) = cache.vao {
552
gl.delete_vertex_array(v)
553
}
554
if let Some(v) = cache.vbo {
555
gl.delete_buffer(v)
556
}
557
if let Some(v) = cache.ebo {
558
gl.delete_buffer(v)
559
}
863
cache.destroy(gl);
560
864
gl.bind_framebuffer(glow::FRAMEBUFFER, None);
561
865
gl.delete_framebuffer(framebuffer);
562
866
gl.delete_texture(texture);
@@ -1,4 +1,4 @@1
//! OpenGL viewport for one skinned glTF body.1
//! OpenGL viewport for a skinned glTF body and rigid textured equipment.2
//!2
//!3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll4
//! diffuse/depth/normal export.4
//! diffuse/depth/normal export.@@ -43,11 +43,18 @@ impl Viewport {43
}43
}44
}44
}45
45
46
pub fn destroy(&self, gl: &glow::Context) {47
if let Ok(mut cache) = self.cache.lock() {48
unsafe { cache.destroy(gl) }49
}50
}51
46
pub fn show(52
pub fn show(47
&self,53
&self,48
ui: &mut egui::Ui,54
ui: &mut egui::Ui,49
controls: &mut PreviewControls,55
controls: &mut PreviewControls,50
character: Option<&PreviewCharacter>,56
character: Option<&PreviewCharacter>,57
presets: &[&crate::equipment::Preset],51
) {58
) {52
ui.horizontal_wrapped(|ui| {59
ui.horizontal_wrapped(|ui| {53
if let Some(character) = character {60
if let Some(character) = character {@@ -139,8 +146,33 @@ impl Viewport {139
perspective(38.0, aspect, 0.05, 40.0),146
perspective(38.0, aspect, 0.05, 40.0),140
view_matrix(eye, target),147
view_matrix(eye, target),141
);148
);149
let mut cache = self.cache.lock().expect("preview gpu lock");150
let mut diagnostics = Vec::new();151
let mut equipment = Vec::new();152
let sex = if controls.sex == crate::preview::PreviewSex::Male {153
0154
} else {155
1156
};157
for preset in presets {158
match crate::equipment::layers_cached(159
preset,160
sex,161
character,162
controls,163
&mut cache.texture_cache,164
) {165
Ok(layers) => {166
for layer in layers {167
equipment.push(PendingFrame::from_layer(layer, view_proj, eye));168
}169
}170
Err(error) => {171
diagnostics.push(error);172
}173
}174
}142
{175
{143
let mut cache = self.cache.lock().expect("preview gpu lock");144
cache.pending = Some(PendingFrame {176
cache.pending = Some(PendingFrame {145
generation: character_generation(character),177
generation: character_generation(character),146
positions: character.positions.clone(),178
positions: character.positions.clone(),@@ -151,18 +183,45 @@ impl Viewport {151
bones,183
bones,152
view_proj,184
view_proj,153
color: character.base_color,185
color: character.base_color,186
model: crate::preview::identity(),187
albedo: None,188
shiny: None,189
specular: 0.0,190
eye,191
equipment,192
uvs: Vec::new(),154
});193
});155
}194
}195
drop(cache);156
let cache = Arc::clone(&self.cache);196
let cache = Arc::clone(&self.cache);157
let callback = CallbackFn::new(move |info, painter| {197
let callback = CallbackFn::new(move |info, painter| {158
if let Ok(mut cache) = cache.lock() {198
if let Ok(mut cache) = cache.lock() {159
unsafe { cache.paint(painter.gl(), &info) };199
unsafe { cache.paint(painter.gl(), &info, true) };160
}200
}161
});201
});162
ui.painter().add(egui::PaintCallback {202
ui.painter().add(egui::PaintCallback {163
rect,203
rect,164
callback: Arc::new(callback),204
callback: Arc::new(callback),165
});205
});206
if let Ok(cache) = self.cache.lock() {207
if let Some(error) = &cache.error {208
diagnostics.push(error.clone());209
}210
for layer in &cache.layers {211
if let Some(error) = &layer.error {212
diagnostics.push(error.clone());213
}214
}215
}216
if !diagnostics.is_empty() {217
ui.painter().text(218
rect.left_top() + egui::vec2(12., 12.),219
egui::Align2::LEFT_TOP,220
diagnostics.join("\n"),221
egui::FontId::proportional(14.),222
egui::Color32::LIGHT_RED,223
);224
}166
}225
}167
}226
}168
227
@@ -194,6 +253,62 @@ struct PendingFrame {194
bones: Vec<crate::preview::Mat4>,253
bones: Vec<crate::preview::Mat4>,195
view_proj: crate::preview::Mat4,254
view_proj: crate::preview::Mat4,196
color: [f32; 4],255
color: [f32; 4],256
model: crate::preview::Mat4,257
eye: [f32; 3],258
albedo: Option<std::sync::Arc<crate::equipment::Texture>>,259
shiny: Option<std::sync::Arc<crate::equipment::Texture>>,260
specular: f32,261
equipment: Vec<PendingFrame>,262
uvs: Vec<[f32; 2]>,263
}264
265
impl PendingFrame {266
fn from_layer(267
layer: crate::equipment::Layer,268
view_proj: crate::preview::Mat4,269
eye: [f32; 3],270
) -> Self {271
use std::hash::{Hash, Hasher};272
let mut hash = std::collections::hash_map::DefaultHasher::new();273
for v in &layer.mesh.positions {274
v.map(f32::to_bits).hash(&mut hash);275
}276
for v in &layer.mesh.normals {277
v.map(f32::to_bits).hash(&mut hash);278
}279
for v in &layer.mesh.uvs {280
v.map(f32::to_bits).hash(&mut hash);281
}282
layer.mesh.indices.hash(&mut hash);283
if let Some(t) = &layer.albedo {284
t.content_hash.hash(&mut hash);285
t.width.hash(&mut hash);286
t.height.hash(&mut hash);287
}288
if let Some(t) = &layer.shiny {289
t.content_hash.hash(&mut hash);290
t.width.hash(&mut hash);291
t.height.hash(&mut hash);292
}293
Self {294
generation: hash.finish(),295
positions: layer.mesh.positions,296
normals: layer.mesh.normals,297
indices: layer.mesh.indices,298
uvs: layer.mesh.uvs,299
color: layer.mesh.color,300
joints: Vec::new(),301
weights: Vec::new(),302
bones: Vec::new(),303
model: layer.model,304
view_proj,305
eye,306
albedo: layer.albedo,307
shiny: layer.shiny,308
specular: layer.specular,309
equipment: Vec::new(),310
}311
}197
}312
}198
313
199
#[derive(Default)]314
#[derive(Default)]@@ -206,10 +321,13 @@ struct GpuCache {206
index_count: i32,321
index_count: i32,207
pending: Option<PendingFrame>,322
pending: Option<PendingFrame>,208
error: Option<String>,323
error: Option<String>,324
textures: [Option<glow::Texture>; 2],325
layers: Vec<GpuCache>,326
texture_cache: crate::equipment::TextureCache,209
}327
}210
328
211
impl GpuCache {329
impl GpuCache {212
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo) {330
unsafe fn paint(&mut self, gl: &glow::Context, info: &egui::PaintCallbackInfo, clear: bool) {213
let Some(frame) = self.pending.clone() else {331
let Some(frame) = self.pending.clone() else {214
return;332
return;215
};333
};@@ -249,10 +367,40 @@ impl GpuCache {249
gl.depth_func(glow::LESS);367
gl.depth_func(glow::LESS);250
gl.disable(glow::BLEND);368
gl.disable(glow::BLEND);251
gl.clear_color(0.08, 0.09, 0.12, 1.0);369
gl.clear_color(0.08, 0.09, 0.12, 1.0);252
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);370
if clear {371
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);372
}253
gl.use_program(Some(program));373
gl.use_program(Some(program));254
let view = gl.get_uniform_location(program, "u_view_proj");374
let view = gl.get_uniform_location(program, "u_view_proj");255
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);375
gl.uniform_matrix_4_f32_slice(view.as_ref(), false, &frame.view_proj);376
gl.uniform_matrix_4_f32_slice(377
gl.get_uniform_location(program, "u_model").as_ref(),378
false,379
&frame.model,380
);381
gl.uniform_3_f32(382
gl.get_uniform_location(program, "u_eye").as_ref(),383
frame.eye[0],384
frame.eye[1],385
frame.eye[2],386
);387
gl.uniform_1_f32(388
gl.get_uniform_location(program, "u_specular").as_ref(),389
frame.specular,390
);391
for (unit, name, present) in [392
(0, "u_albedo", frame.albedo.is_some()),393
(1, "u_shiny", frame.shiny.is_some()),394
] {395
gl.active_texture(glow::TEXTURE0 + unit as u32);396
gl.bind_texture(glow::TEXTURE_2D, self.textures[unit]);397
gl.uniform_1_i32(gl.get_uniform_location(program, name).as_ref(), unit as i32);398
gl.uniform_1_i32(399
gl.get_uniform_location(program, &format!("{name}_enabled"))400
.as_ref(),401
i32::from(present),402
);403
}256
let color = gl.get_uniform_location(program, "u_color");404
let color = gl.get_uniform_location(program, "u_color");257
gl.uniform_4_f32(405
gl.uniform_4_f32(258
color.as_ref(),406
color.as_ref(),@@ -271,11 +419,46 @@ impl GpuCache {271
gl.bind_vertex_array(self.vao);419
gl.bind_vertex_array(self.vao);272
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);420
gl.draw_elements(glow::TRIANGLES, self.index_count, glow::UNSIGNED_INT, 0);273
gl.bind_vertex_array(None);421
gl.bind_vertex_array(None);422
while self.layers.len() > frame.equipment.len() {423
if let Some(mut layer) = self.layers.pop() {424
layer.destroy(gl);425
}426
}427
while self.layers.len() < frame.equipment.len() {428
self.layers.push(GpuCache::default());429
}430
for (cache, frame) in self.layers.iter_mut().zip(frame.equipment) {431
cache.pending = Some(frame);432
cache.paint(gl, info, false);433
}434
gl.active_texture(glow::TEXTURE0);274
gl.disable(glow::DEPTH_TEST);435
gl.disable(glow::DEPTH_TEST);275
gl.disable(glow::SCISSOR_TEST);436
gl.disable(glow::SCISSOR_TEST);276
gl.enable(glow::BLEND);437
gl.enable(glow::BLEND);277
}438
}278
439
440
unsafe fn destroy(&mut self, gl: &glow::Context) {441
for layer in &mut self.layers {442
layer.destroy(gl);443
}444
for texture in &mut self.textures {445
if let Some(t) = texture.take() {446
gl.delete_texture(t)447
}448
}449
if let Some(v) = self.vao.take() {450
gl.delete_vertex_array(v)451
}452
if let Some(v) = self.vbo.take() {453
gl.delete_buffer(v)454
}455
if let Some(v) = self.ebo.take() {456
gl.delete_buffer(v)457
}458
if let Some(p) = self.program.take() {459
gl.delete_program(p)460
}461
}279
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {462
unsafe fn ensure_program(&mut self, gl: &glow::Context) -> Result<(), String> {280
if self.program.is_some() {463
if self.program.is_some() {281
return Ok(());464
return Ok(());@@ -292,7 +475,7 @@ impl GpuCache {292
}475
}293
476
294
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {477
unsafe fn upload(&mut self, gl: &glow::Context, frame: &PendingFrame) -> Result<(), String> {295
let mut interleaved = Vec::with_capacity(frame.positions.len() * 14);478
let mut interleaved = Vec::with_capacity(frame.positions.len() * 16);296
for index in 0..frame.positions.len() {479
for index in 0..frame.positions.len() {297
let position = frame.positions[index];480
let position = frame.positions[index];298
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);481
let normal = frame.normals.get(index).copied().unwrap_or([0.0, 1.0, 0.0]);@@ -311,6 +494,7 @@ impl GpuCache {311
joint[3] as f32,494
joint[3] as f32,312
]);495
]);313
interleaved.extend_from_slice(&weight);496
interleaved.extend_from_slice(&weight);497
interleaved.extend_from_slice(&frame.uvs.get(index).copied().unwrap_or([0.; 2]));314
}498
}315
gl.bind_vertex_array(self.vao);499
gl.bind_vertex_array(self.vao);316
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);500
gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);@@ -325,11 +509,46 @@ impl GpuCache {325
bytemuck_u32(&frame.indices),509
bytemuck_u32(&frame.indices),326
glow::DYNAMIC_DRAW,510
glow::DYNAMIC_DRAW,327
);511
);328
let stride = 14 * 4;512
let stride = 16 * 4;329
enable_float(gl, 0, 3, stride, 0);513
enable_float(gl, 0, 3, stride, 0);330
enable_float(gl, 1, 3, stride, 3 * 4);514
enable_float(gl, 1, 3, stride, 3 * 4);331
enable_float(gl, 2, 4, stride, 6 * 4);515
enable_float(gl, 2, 4, stride, 6 * 4);332
enable_float(gl, 3, 4, stride, 10 * 4);516
enable_float(gl, 3, 4, stride, 10 * 4);517
enable_float(gl, 4, 2, stride, 14 * 4);518
for (unit, texture) in [&frame.albedo, &frame.shiny].iter().enumerate() {519
if let Some(old) = self.textures[unit].take() {520
gl.delete_texture(old);521
}522
if let Some(texture) = texture {523
let id = gl.create_texture().map_err(|e| e.to_string())?;524
self.textures[unit] = Some(id);525
gl.active_texture(glow::TEXTURE0 + unit as u32);526
gl.bind_texture(glow::TEXTURE_2D, Some(id));527
gl.tex_parameter_i32(528
glow::TEXTURE_2D,529
glow::TEXTURE_MIN_FILTER,530
glow::NEAREST as i32,531
);532
gl.tex_parameter_i32(533
glow::TEXTURE_2D,534
glow::TEXTURE_MAG_FILTER,535
glow::NEAREST as i32,536
);537
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);538
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);539
gl.tex_image_2d(540
glow::TEXTURE_2D,541
0,542
glow::RGBA8 as i32,543
texture.width as i32,544
texture.height as i32,545
0,546
glow::RGBA,547
glow::UNSIGNED_BYTE,548
Some(&texture.rgba),549
);550
}551
}333
self.uploaded = frame.generation;552
self.uploaded = frame.generation;334
self.index_count = frame.indices.len() as i32;553
self.index_count = frame.indices.len() as i32;335
Ok(())554
Ok(())@@ -379,6 +598,10 @@ layout(location = 0) in vec3 a_pos;379
layout(location = 1) in vec3 a_normal;598
layout(location = 1) in vec3 a_normal;380
layout(location = 2) in vec4 a_joints;599
layout(location = 2) in vec4 a_joints;381
layout(location = 3) in vec4 a_weights;600
layout(location = 3) in vec4 a_weights;601
layout(location = 4) in vec2 a_uv;602
uniform mat4 u_model;603
out vec2 v_uv;604
out vec3 v_world;382
uniform mat4 u_view_proj;605
uniform mat4 u_view_proj;383
uniform mat4 u_bones[128];606
uniform mat4 u_bones[128];384
out vec3 v_normal;607
out vec3 v_normal;@@ -391,8 +614,9 @@ void main() {391
+ a_weights.y * u_bones[j1]614
+ a_weights.y * u_bones[j1]392
+ a_weights.z * u_bones[j2]615
+ a_weights.z * u_bones[j2]393
+ a_weights.w * u_bones[j3];616
+ a_weights.w * u_bones[j3];394
vec4 world = skin * vec4(a_pos, 1.0);617
vec4 world = u_model * skin * vec4(a_pos, 1.0);395
v_normal = mat3(skin) * a_normal;618
v_uv = vec2(a_uv.x,1.0-a_uv.y); v_world=world.xyz;619
v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;396
gl_Position = u_view_proj * world;620
gl_Position = u_view_proj * world;397
}621
}398
"#;622
"#;@@ -400,12 +624,26 @@ void main() {400
const FRAGMENT: &str = r#"#version 330624
const FRAGMENT: &str = r#"#version 330401
in vec3 v_normal;625
in vec3 v_normal;402
uniform vec4 u_color;626
uniform vec4 u_color;627
uniform sampler2D u_albedo;628
uniform sampler2D u_shiny;629
uniform bool u_albedo_enabled;630
uniform bool u_shiny_enabled;631
uniform float u_specular;632
uniform vec3 u_eye;633
in vec2 v_uv;634
in vec3 v_world;403
out vec4 frag;635
out vec4 frag;404
void main() {636
void main() {405
vec3 n = normalize(v_normal);637
vec3 n = normalize(v_normal);406
vec3 light = normalize(vec3(0.35, 0.85, 0.4));638
vec3 light = normalize(vec3(0.35, 0.85, 0.4));407
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);639
float shade = 0.28 + 0.72 * max(dot(n, light), 0.0);408
frag = vec4(u_color.rgb * shade, 1.0);640
vec4 color=u_color;641
if(u_albedo_enabled) color*=texture(u_albedo,v_uv);642
if(color.a<0.5) discard;643
float strength=u_specular;644
if(u_shiny_enabled) strength*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));645
float highlight=pow(max(dot(reflect(-light,n),normalize(u_eye-v_world)),0.0),32.0)*strength;646
frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);409
}647
}410
"#;648
"#;411
649
@@ -502,6 +740,13 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {502
bones: vec![crate::preview::identity()],740
bones: vec![crate::preview::identity()],503
view_proj: crate::preview::identity(),741
view_proj: crate::preview::identity(),504
color: [1.; 4],742
color: [1.; 4],743
model: crate::preview::identity(),744
eye: [0., 0., -3.],745
albedo: None,746
shiny: None,747
specular: 0.,748
uvs: vec![[0.; 2]; 6],749
equipment: Vec::new(),505
};750
};506
let mut cache = GpuCache::default();751
let mut cache = GpuCache::default();507
let read = || {752
let read = || {@@ -523,40 +768,99 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {523
gl.clear_depth_f32(0.0);768
gl.clear_depth_f32(0.0);524
gl.depth_func(glow::GREATER);769
gl.depth_func(glow::GREATER);525
cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));770
cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));526
cache.paint(gl, &info);771
cache.paint(gl, &info, true);527
let near = read();772
let near = read();528
if near[..3].iter().any(|c| *c < 250) {773
if near[..3].iter().any(|c| *c < 250) {529
return Err(format!("achterste vlak overschrijft voorste: {near:?}"));774
return Err(format!("achterste vlak overschrijft voorste: {near:?}"));530
}775
}531
cache.pending = Some(make_frame(vec![3, 4, 5], 2));776
cache.pending = Some(make_frame(vec![3, 4, 5], 2));532
cache.paint(gl, &info);777
cache.paint(gl, &info, true);533
let next = read();778
let next = read();534
if next[..3].iter().any(|c| !(69..=73).contains(c)) {779
if next[..3].iter().any(|c| !(69..=73).contains(c)) {535
return Err(format!("depth niet gewist voor volgende frame: {next:?}"));780
return Err(format!("depth niet gewist voor volgende frame: {next:?}"));536
}781
}537
cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));782
cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));538
cache.paint(gl, &info);783
cache.paint(gl, &info, true);539
let reverse = read();784
let reverse = read();540
if near != reverse {785
if near != reverse {541
return Err(format!(786
return Err(format!(542
"draw-order afhankelijke occlusie: {near:?} / {reverse:?}"787
"draw-order afhankelijke occlusie: {near:?} / {reverse:?}"543
));788
));544
}789
}545
println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}");790
let mut body = make_frame(vec![0, 1, 2], 4);791
let mut behind = make_frame(vec![3, 4, 5], 5);792
behind.color = [1., 0., 0., 1.];793
body.equipment.push(behind);794
cache.pending = Some(body);795
cache.paint(gl, &info, true);796
let hidden = read();797
if hidden != near {798
return Err(format!("equipment wist body-depth: {hidden:?}"));799
}800
let mut body = make_frame(vec![3, 4, 5], 6);801
let mut front = make_frame(vec![0, 1, 2], 7);802
front.albedo = Some(Arc::new(crate::equipment::Texture {803
content_hash: 1,804
width: 1,805
height: 1,806
rgba: vec![255, 0, 0, 255],807
}));808
body.equipment.push(front.clone());809
cache.pending = Some(body.clone());810
cache.paint(gl, &info, true);811
let textured = read();812
if textured[0] < 250 || textured[1] > 2 || textured[2] > 2 {813
return Err(format!("voorste equipmenttexture ontbreekt: {textured:?}"));814
}815
front.generation = 8;816
front.albedo = Some(Arc::new(crate::equipment::Texture {817
content_hash: 2,818
width: 1,819
height: 1,820
rgba: vec![255, 0, 0, 0],821
}));822
body.equipment = vec![front];823
cache.pending = Some(body);824
cache.paint(gl, &info, true);825
let cutout = read();826
if cutout != next {827
return Err(format!("alpha-cutout bedekt body: {cutout:?}"));828
}829
let mut body = make_frame(vec![3, 4, 5], 9);830
let mut front = make_frame(vec![0, 1, 2], 10);831
front.color = [0., 0., 0., 1.];832
front.specular = 1.;833
front.eye = [35000., 85000., 40000.];834
front.shiny = Some(Arc::new(crate::equipment::Texture {835
content_hash: 3,836
width: 1,837
height: 1,838
rgba: vec![255, 255, 255, 255],839
}));840
body.equipment = vec![front.clone()];841
cache.pending = Some(body.clone());842
cache.paint(gl, &info, true);843
let shiny = read();844
front.generation = 11;845
front.shiny = Some(Arc::new(crate::equipment::Texture {846
content_hash: 4,847
width: 1,848
height: 1,849
rgba: vec![0, 0, 0, 255],850
}));851
body.equipment = vec![front];852
cache.pending = Some(body);853
cache.paint(gl, &info, true);854
let matte = read();855
if shiny[..3].iter().any(|c| *c < 250) || matte[..3] != [0, 0, 0] {856
return Err(format!(857
"shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}"858
));859
}860
println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}");546
Ok(())861
Ok(())547
})();862
})();548
if let Some(p) = cache.program {863
cache.destroy(gl);549
gl.delete_program(p)550
}551
if let Some(v) = cache.vao {552
gl.delete_vertex_array(v)553
}554
if let Some(v) = cache.vbo {555
gl.delete_buffer(v)556
}557
if let Some(v) = cache.ebo {558
gl.delete_buffer(v)559
}560
gl.bind_framebuffer(glow::FRAMEBUFFER, None);864
gl.bind_framebuffer(glow::FRAMEBUFFER, None);561
gl.delete_framebuffer(framebuffer);865
gl.delete_framebuffer(framebuffer);562
gl.delete_texture(texture);866
gl.delete_texture(texture);@@ -14,6 +14,8 @@ use serde_json::Value;
14
14
15
15
pub const FORMAT_VERSION: u32 = 1;
16
16
pub const MANIFEST_NAME: &str = "character.ppd";
17
pub const MAX_PROJECT_PRESETS: usize = 64;
18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
17
19
pub const MAX_DIMENSION: u32 = 4096;
18
20
pub const MAX_FPS: u32 = 240;
19
21
pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
@@ -164,6 +166,11 @@ impl ProjectFile {
164
166
&mut issues,
165
167
);
166
168
let mut seen = std::collections::BTreeSet::new();
169
if self.equipment_presets.len() > MAX_PROJECT_PRESETS {
170
issues.push(format!(
171
"maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project"
172
));
173
}
167
174
for preset in &self.equipment_presets {
168
175
match portable_relative(preset) {
169
176
Ok(clean) => {
@@ -332,13 +339,33 @@ pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {
332
339
if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
333
340
return Err(io_error(manifest, "kies een .ppd-bestand"));
334
341
}
335
let text = fs::read_to_string(manifest).map_err(|err| {
342
use std::io::Read;
343
let metadata = fs::metadata(manifest).map_err(|err| {
336
344
io_error(
337
345
manifest,
338
346
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),
339
347
)
340
348
})?;
341
let document = parse_manifest(&text)?;
349
if !metadata.is_file() {
350
return Err(io_error(manifest, "kies een regulier .ppd-bestand"));
351
}
352
let mut bytes = Vec::new();
353
fs::File::open(manifest)
354
.and_then(|file| {
355
file.take((MAX_MANIFEST_BYTES + 1) as u64)
356
.read_to_end(&mut bytes)
357
})
358
.map_err(|err| {
359
io_error(
360
manifest,
361
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),
362
)
363
})?;
364
if bytes.len() > MAX_MANIFEST_BYTES {
365
return Err(io_error(manifest, "projectmanifest groter dan 4 MiB"));
366
}
367
let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?;
368
let document = parse_manifest(text)?;
342
369
document.validate_shape()?;
343
370
let root = manifest
344
371
.parent()
@@ -584,6 +611,9 @@ fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectErro
584
611
let text = serde_json::to_string_pretty(document)
585
612
.map_err(|err| ProjectError::Json(err.to_string()))?
586
613
+ "\n";
614
if text.len() > MAX_MANIFEST_BYTES {
615
return Err(io_error(path, "projectmanifest groter dan 4 MiB"));
616
}
587
617
let mut temporary_name = path
588
618
.file_name()
589
619
.ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
@@ -648,6 +678,37 @@ mod tests {
648
678
}
649
679
}
650
680
681
#[test]
682
fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() {
683
let root = temp_root("resource-limits");
684
let mut opened = create_project(&root, "Limits").unwrap();
685
let before = fs::read(&opened.manifest_path).unwrap();
686
opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1);
687
assert!(save_project(&opened).is_err());
688
assert!(write_manifest(&opened.manifest_path, &opened.document)
689
.unwrap_err()
690
.to_string()
691
.contains("4 MiB"));
692
assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
693
let oversized = root.join("oversized.ppd");
694
fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap();
695
assert!(open_project(&oversized)
696
.unwrap_err()
697
.to_string()
698
.contains("4 MiB"));
699
opened.document.name = "Limits".into();
700
opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS)
701
.map(|i| format!("presets/item-{i}.ppe"))
702
.collect();
703
assert!(opened
704
.document
705
.validate_shape()
706
.unwrap_err()
707
.to_string()
708
.contains("64"));
709
fs::remove_dir_all(root).unwrap();
710
}
711
651
712
#[test]
652
713
fn roundtrip_keeps_null_slots_and_relative_paths() {
653
714
let root = temp_root("roundtrip");
@@ -14,6 +14,8 @@ use serde_json::Value;14
14
15
pub const FORMAT_VERSION: u32 = 1;15
pub const FORMAT_VERSION: u32 = 1;16
pub const MANIFEST_NAME: &str = "character.ppd";16
pub const MANIFEST_NAME: &str = "character.ppd";17
pub const MAX_PROJECT_PRESETS: usize = 64;18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;17
pub const MAX_DIMENSION: u32 = 4096;19
pub const MAX_DIMENSION: u32 = 4096;18
pub const MAX_FPS: u32 = 240;20
pub const MAX_FPS: u32 = 240;19
pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;21
pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;@@ -164,6 +166,11 @@ impl ProjectFile {164
&mut issues,166
&mut issues,165
);167
);166
let mut seen = std::collections::BTreeSet::new();168
let mut seen = std::collections::BTreeSet::new();169
if self.equipment_presets.len() > MAX_PROJECT_PRESETS {170
issues.push(format!(171
"maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project"172
));173
}167
for preset in &self.equipment_presets {174
for preset in &self.equipment_presets {168
match portable_relative(preset) {175
match portable_relative(preset) {169
Ok(clean) => {176
Ok(clean) => {@@ -332,13 +339,33 @@ pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {332
if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {339
if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {333
return Err(io_error(manifest, "kies een .ppd-bestand"));340
return Err(io_error(manifest, "kies een .ppd-bestand"));334
}341
}335
let text = fs::read_to_string(manifest).map_err(|err| {342
use std::io::Read;343
let metadata = fs::metadata(manifest).map_err(|err| {336
io_error(344
io_error(337
manifest,345
manifest,338
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),346
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),339
)347
)340
})?;348
})?;341
let document = parse_manifest(&text)?;349
if !metadata.is_file() {350
return Err(io_error(manifest, "kies een regulier .ppd-bestand"));351
}352
let mut bytes = Vec::new();353
fs::File::open(manifest)354
.and_then(|file| {355
file.take((MAX_MANIFEST_BYTES + 1) as u64)356
.read_to_end(&mut bytes)357
})358
.map_err(|err| {359
io_error(360
manifest,361
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),362
)363
})?;364
if bytes.len() > MAX_MANIFEST_BYTES {365
return Err(io_error(manifest, "projectmanifest groter dan 4 MiB"));366
}367
let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?;368
let document = parse_manifest(text)?;342
document.validate_shape()?;369
document.validate_shape()?;343
let root = manifest370
let root = manifest344
.parent()371
.parent()@@ -584,6 +611,9 @@ fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectErro584
let text = serde_json::to_string_pretty(document)611
let text = serde_json::to_string_pretty(document)585
.map_err(|err| ProjectError::Json(err.to_string()))?612
.map_err(|err| ProjectError::Json(err.to_string()))?586
+ "\n";613
+ "\n";614
if text.len() > MAX_MANIFEST_BYTES {615
return Err(io_error(path, "projectmanifest groter dan 4 MiB"));616
}587
let mut temporary_name = path617
let mut temporary_name = path588
.file_name()618
.file_name()589
.ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?619
.ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?@@ -648,6 +678,37 @@ mod tests {648
}678
}649
}679
}650
680
681
#[test]682
fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() {683
let root = temp_root("resource-limits");684
let mut opened = create_project(&root, "Limits").unwrap();685
let before = fs::read(&opened.manifest_path).unwrap();686
opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1);687
assert!(save_project(&opened).is_err());688
assert!(write_manifest(&opened.manifest_path, &opened.document)689
.unwrap_err()690
.to_string()691
.contains("4 MiB"));692
assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);693
let oversized = root.join("oversized.ppd");694
fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap();695
assert!(open_project(&oversized)696
.unwrap_err()697
.to_string()698
.contains("4 MiB"));699
opened.document.name = "Limits".into();700
opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS)701
.map(|i| format!("presets/item-{i}.ppe"))702
.collect();703
assert!(opened704
.document705
.validate_shape()706
.unwrap_err()707
.to_string()708
.contains("64"));709
fs::remove_dir_all(root).unwrap();710
}711
651
#[test]712
#[test]652
fn roundtrip_keeps_null_slots_and_relative_paths() {713
fn roundtrip_keeps_null_slots_and_relative_paths() {653
let root = temp_root("roundtrip");714
let root = temp_root("roundtrip");@@ -7,6 +7,7 @@
7
7
use std::fs;
8
8
use std::path::{Path, PathBuf};
9
9
10
use crate::equipment::Preset;
10
11
use serde::{Deserialize, Serialize};
11
12
12
13
use crate::project::{
@@ -60,19 +61,22 @@ pub enum AfterChoice {
60
61
}
61
62
62
63
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64
#[serde(deny_unknown_fields)]
63
65
struct EquipmentFile {
64
66
name: String,
65
67
model: Option<String>,
66
68
}
67
69
68
#[derive(Debug, Clone, PartialEq, Eq)]
70
#[derive(Debug, Clone, PartialEq)]
69
71
pub struct EquipmentItem {
70
72
pub slug: String,
71
73
pub model: Option<String>,
72
74
pub legacy_path: Option<String>,
75
pub preset: Option<Preset>,
76
pub error: Option<String>,
73
77
}
74
78
75
#[derive(Debug, Clone, PartialEq, Eq)]
79
#[derive(Debug, Clone, PartialEq)]
76
80
pub struct Session {
77
81
pub screen: Screen,
78
82
pub active_body: Option<Sex>,
@@ -216,6 +220,8 @@ impl Session {
216
220
pub fn add_equipment(&mut self) -> usize {
217
221
let slug = next_slug(&self.equipment);
218
222
self.equipment.push(EquipmentItem {
223
preset: Some(Preset::new(slug.clone())),
224
error: None,
219
225
slug,
220
226
model: None,
221
227
legacy_path: None,
@@ -225,6 +231,42 @@ impl Session {
225
231
index
226
232
}
227
233
234
pub fn ensure_preset_budget(
235
&self,
236
replacement: Option<usize>,
237
candidate: &Preset,
238
) -> Result<(), String> {
239
if replacement.is_none() && self.equipment.len() >= crate::project::MAX_PROJECT_PRESETS {
240
return Err("maximaal 64 equipmentpresets per project".into());
241
}
242
let mut budget = MAX_PROJECT_PRESET_BYTES
243
.checked_sub(candidate.encoded_len()?)
244
.ok_or("equipmentpresets samen groter dan 64 MiB")?;
245
for (i, item) in self.equipment.iter().enumerate() {
246
if Some(i) != replacement {
247
if let Some(p) = &item.preset {
248
budget = budget
249
.checked_sub(p.encoded_len()?)
250
.ok_or("equipmentpresets samen groter dan 64 MiB")?;
251
}
252
}
253
}
254
Ok(())
255
}
256
pub fn available_equipment_name(&self, base: &str) -> Result<String, String> {
257
if !valid_slug(base) {
258
return Err("ongeldige equipmentnaam".into());
259
}
260
let mut name = base.to_string();
261
let mut n = 2;
262
while self.equipment.iter().any(|i| i.slug == name) {
263
let suffix = format!("-{n}");
264
name = format!("{}{}", &base[..base.len().min(64 - suffix.len())], suffix);
265
n += 1;
266
}
267
Ok(name)
268
}
269
228
270
pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {
229
271
let slug = slug.trim().to_string();
230
272
if !valid_slug(&slug) {
@@ -252,6 +294,8 @@ impl Session {
252
294
if let Some(item) = self.equipment.get_mut(index) {
253
295
item.model = Some(relative);
254
296
item.legacy_path = None;
297
item.preset = None;
298
item.error = None;
255
299
}
256
300
}
257
301
@@ -321,6 +365,11 @@ impl Session {
321
365
disk_issues: Vec::new(),
322
366
};
323
367
save_project(&candidate)?;
368
for item in &mut self.equipment {
369
if let Some(preset) = &mut item.preset {
370
preset.name = item.slug.clone();
371
}
372
}
324
373
self.opened = Some(candidate);
325
374
self.note_saved();
326
375
self.ok("Opgeslagen.");
@@ -441,49 +490,93 @@ fn io_at(path: &Path, message: &str) -> ProjectError {
441
490
}
442
491
}
443
492
493
pub const MAX_PROJECT_PRESET_BYTES: usize = 64 * 1024 * 1024;
494
const MAX_LEGACY_PRESET_BYTES: usize = 64 * 1024;
495
496
fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {
497
use std::io::Read;
498
if !path.is_file() {
499
return Err("kies een regulier bestand".into());
500
}
501
let mut bytes = Vec::new();
502
fs::File::open(path)
503
.and_then(|file| file.take((limit + 1) as u64).read_to_end(&mut bytes))
504
.map_err(|e| e.to_string())?;
505
if bytes.len() > limit {
506
return Err("presetbestand overschrijdt het bestands-/projectbudget".into());
507
}
508
Ok(bytes)
509
}
444
510
fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
511
read_equipment_with_budget(root, presets, MAX_PROJECT_PRESET_BYTES)
512
}
513
fn read_equipment_with_budget(
514
root: &Path,
515
presets: &[String],
516
mut budget: usize,
517
) -> Vec<EquipmentItem> {
445
518
presets
446
519
.iter()
447
520
.map(|path| {
521
let failure = |error: String| EquipmentItem {
522
preset: None,
523
error: Some(error),
524
slug: Path::new(path)
525
.file_stem()
526
.unwrap_or_default()
527
.to_string_lossy()
528
.into(),
529
model: None,
530
legacy_path: Some(path.clone()),
531
};
448
532
let file = match checked_project_path(root, path) {
449
533
Ok(path) => path,
450
Err(_) => {
451
return EquipmentItem {
452
slug: "onveilig".into(),
453
model: None,
454
legacy_path: Some(path.clone()),
455
}
456
}
534
Err(error) => return failure(error),
457
535
};
458
if let Ok(text) = fs::read_to_string(&file) {
459
if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {
460
if valid_slug(&parsed.name) {
461
return EquipmentItem {
462
slug: parsed.name,
463
model: parsed.model,
464
legacy_path: None,
465
};
466
}
467
}
536
let binary = file.extension().and_then(|e| e.to_str()) == Some("ppe");
537
let limit = budget.min(if binary {
538
crate::equipment::MAX_FILE
539
} else {
540
MAX_LEGACY_PRESET_BYTES
541
});
542
let bytes = match read_limited(&file, limit) {
543
Ok(bytes) => bytes,
544
Err(error) => return failure(error),
545
};
546
budget -= bytes.len();
547
if binary {
548
return match Preset::decode(&bytes) {
549
Ok(preset) => EquipmentItem {
550
slug: preset.name.clone(),
551
model: preset.attachments.first().map(|a| a.label.clone()),
552
preset: Some(preset),
553
error: None,
554
legacy_path: None,
555
},
556
Err(error) => failure(error),
557
};
468
558
}
469
let slug = path
470
.rsplit('/')
471
.next()
472
.unwrap_or(path)
473
.split('.')
474
.next()
475
.unwrap_or("item")
476
.to_string();
477
EquipmentItem {
478
slug,
479
model: None,
480
legacy_path: Some(path.clone()),
559
if let Ok(parsed) = serde_json::from_slice::<EquipmentFile>(&bytes) {
560
if valid_slug(&parsed.name) {
561
return EquipmentItem {
562
preset: None,
563
error: None,
564
slug: parsed.name,
565
model: parsed.model,
566
legacy_path: None,
567
};
568
}
481
569
}
570
failure("legacy preset; kies een OBJ om te configureren".into())
482
571
})
483
572
.collect()
484
573
}
485
574
486
575
fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {
576
if items.len() > crate::project::MAX_PROJECT_PRESETS {
577
return Err("maximaal 64 equipmentpresets per project".into());
578
}
579
let mut budget = MAX_PROJECT_PRESET_BYTES;
487
580
let mut paths = Vec::new();
488
581
let mut names = std::collections::BTreeSet::new();
489
582
// Validate every record before writing. Config files are immutable snapshots:
@@ -492,17 +585,58 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
492
585
if !names.insert(&item.slug) {
493
586
return Err("dubbele equipmentnaam".into());
494
587
}
495
if item.legacy_path.is_some() && item.model.is_none() {
588
if let Some(legacy) = item.legacy_path.as_ref().filter(|_| item.model.is_none()) {
589
let path = checked_project_path(root, legacy)?;
590
let length = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
591
budget = budget
592
.checked_sub(usize::try_from(length).map_err(|_| "preset te groot")?)
593
.ok_or("equipmentpresets samen groter dan 64 MiB")?;
496
594
continue;
497
595
}
498
596
if !valid_slug(&item.slug) {
499
597
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));
500
598
}
501
if let Some(model) = &item.model {
502
portable_relative(model)?;
599
if let Some(preset) = &item.preset {
600
let mut preset = preset.clone();
601
preset.name = item.slug.clone();
602
let length = preset.encode()?.len();
603
budget = budget
604
.checked_sub(length)
605
.ok_or("equipmentpresets samen groter dan 64 MiB")?;
606
} else {
607
if let Some(model) = &item.model {
608
portable_relative(model)?;
609
}
610
let text = serde_json::to_vec_pretty(&EquipmentFile {
611
name: item.slug.clone(),
612
model: item.model.clone(),
613
})
614
.map_err(|e| e.to_string())?;
615
if text.len() > MAX_LEGACY_PRESET_BYTES {
616
return Err("legacy preset groter dan 64 KiB".into());
617
}
618
budget = budget
619
.checked_sub(text.len())
620
.ok_or("equipmentpresets samen groter dan 64 MiB")?;
503
621
}
504
622
}
505
623
for item in items {
624
if let Some(preset) = &item.preset {
625
let mut preset = preset.clone();
626
preset.name = item.slug.clone();
627
let bytes = preset.encode()?;
628
let relative = format!(
629
"presets/{}-{:08x}-{}.ppe",
630
item.slug,
631
crate::equipment::checksum(&bytes),
632
bytes.len()
633
);
634
let path = checked_project_path(root, &relative)?;
635
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
636
crate::equipment::write_snapshot(&path, &bytes)?;
637
paths.push(relative);
638
continue;
639
}
506
640
if item.model.is_none() {
507
641
if let Some(legacy) = &item.legacy_path {
508
642
paths.push(legacy.clone());
@@ -518,8 +652,8 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
518
652
loop {
519
653
let relative = format!("presets/{}-{number}.json", item.slug);
520
654
let path = checked_project_path(root, &relative)?;
521
if let Ok(existing) = fs::read_to_string(&path) {
522
if existing == text {
655
if let Ok(existing) = read_limited(&path, MAX_LEGACY_PRESET_BYTES) {
656
if existing == text.as_bytes() {
523
657
paths.push(relative);
524
658
break;
525
659
}
@@ -559,11 +693,7 @@ fn next_slug(items: &[EquipmentItem]) -> String {
559
693
}
560
694
561
695
fn valid_slug(slug: &str) -> bool {
562
!slug.is_empty()
563
&& slug != "body"
564
&& slug
565
.chars()
566
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
696
crate::equipment::valid_name(slug)
567
697
}
568
698
569
699
pub fn manifest_in(root: &Path) -> PathBuf {
@@ -585,6 +715,74 @@ mod tests {
585
715
path
586
716
}
587
717
718
#[test]
719
fn editing_budget_and_import_names_are_bounded_before_install() {
720
let mut session = Session::new();
721
let mut p = Preset::new("sword".into());
722
for i in 0..3 {
723
p.assets.push(crate::equipment::EmbeddedAsset {
724
path: format!("raw-{i}"),
725
bytes: vec![0; 8 * 1024 * 1024],
726
});
727
}
728
for _ in 0..2 {
729
let i = session.add_equipment();
730
session.equipment[i].preset = Some(p.clone());
731
}
732
assert!(session
733
.ensure_preset_budget(None, &p)
734
.unwrap_err()
735
.contains("64 MiB"));
736
assert!(session.ensure_preset_budget(Some(0), &p).is_ok());
737
assert_eq!(session.equipment.len(), 2);
738
let base = "a".repeat(64);
739
session.equipment[0].slug = base.clone();
740
let unique = session.available_equipment_name(&base).unwrap();
741
assert_eq!(unique.len(), 64);
742
assert_ne!(unique, base);
743
assert!(crate::equipment::valid_name(&unique));
744
}
745
746
#[test]
747
fn preset_read_budget_preserves_unloaded_references() {
748
let root = scratch();
749
fs::create_dir_all(root.join("presets")).unwrap();
750
let bytes = Preset::new("sword".into()).encode().unwrap();
751
for name in ["one", "two"] {
752
fs::write(root.join(format!("presets/{name}.ppe")), &bytes).unwrap();
753
}
754
let refs = vec!["presets/one.ppe".into(), "presets/two.ppe".into()];
755
let items = read_equipment_with_budget(&root, &refs, bytes.len());
756
assert!(items[0].preset.is_some());
757
assert!(items[1].preset.is_none());
758
assert!(items[1].error.as_ref().unwrap().contains("budget"));
759
assert_eq!(items[1].legacy_path.as_ref(), Some(&refs[1]));
760
fs::write(
761
root.join("presets/old.json"),
762
vec![b' '; MAX_LEGACY_PRESET_BYTES + 1],
763
)
764
.unwrap();
765
assert!(read_equipment(&root, &["presets/old.json".into()])[0]
766
.error
767
.is_some());
768
fs::remove_dir_all(root).unwrap();
769
}
770
#[test]
771
fn too_many_presets_do_not_write_snapshots_or_replace_manifest() {
772
let root = scratch();
773
let mut session = Session::new();
774
session.install_new_project(&root).unwrap();
775
let before = fs::read(root.join(MANIFEST_NAME)).unwrap();
776
for _ in 0..=crate::project::MAX_PROJECT_PRESETS {
777
session.add_equipment();
778
}
779
assert!(session.save().unwrap_err().to_string().contains("64"));
780
assert_eq!(fs::read(root.join(MANIFEST_NAME)).unwrap(), before);
781
assert_eq!(fs::read_dir(root.join("presets")).unwrap().count(), 0);
782
assert!(session.is_dirty());
783
fs::remove_dir_all(root).unwrap();
784
}
785
588
786
#[test]
589
787
fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
590
788
let root = scratch();
@@ -7,6 +7,7 @@7
use std::fs;7
use std::fs;8
use std::path::{Path, PathBuf};8
use std::path::{Path, PathBuf};9
9
10
use crate::equipment::Preset;10
use serde::{Deserialize, Serialize};11
use serde::{Deserialize, Serialize};11
12
12
use crate::project::{13
use crate::project::{@@ -60,19 +61,22 @@ pub enum AfterChoice {60
}61
}61
62
62
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]63
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]64
#[serde(deny_unknown_fields)]63
struct EquipmentFile {65
struct EquipmentFile {64
name: String,66
name: String,65
model: Option<String>,67
model: Option<String>,66
}68
}67
69
68
#[derive(Debug, Clone, PartialEq, Eq)]70
#[derive(Debug, Clone, PartialEq)]69
pub struct EquipmentItem {71
pub struct EquipmentItem {70
pub slug: String,72
pub slug: String,71
pub model: Option<String>,73
pub model: Option<String>,72
pub legacy_path: Option<String>,74
pub legacy_path: Option<String>,75
pub preset: Option<Preset>,76
pub error: Option<String>,73
}77
}74
78
75
#[derive(Debug, Clone, PartialEq, Eq)]79
#[derive(Debug, Clone, PartialEq)]76
pub struct Session {80
pub struct Session {77
pub screen: Screen,81
pub screen: Screen,78
pub active_body: Option<Sex>,82
pub active_body: Option<Sex>,@@ -216,6 +220,8 @@ impl Session {216
pub fn add_equipment(&mut self) -> usize {220
pub fn add_equipment(&mut self) -> usize {217
let slug = next_slug(&self.equipment);221
let slug = next_slug(&self.equipment);218
self.equipment.push(EquipmentItem {222
self.equipment.push(EquipmentItem {223
preset: Some(Preset::new(slug.clone())),224
error: None,219
slug,225
slug,220
model: None,226
model: None,221
legacy_path: None,227
legacy_path: None,@@ -225,6 +231,42 @@ impl Session {225
index231
index226
}232
}227
233
234
pub fn ensure_preset_budget(235
&self,236
replacement: Option<usize>,237
candidate: &Preset,238
) -> Result<(), String> {239
if replacement.is_none() && self.equipment.len() >= crate::project::MAX_PROJECT_PRESETS {240
return Err("maximaal 64 equipmentpresets per project".into());241
}242
let mut budget = MAX_PROJECT_PRESET_BYTES243
.checked_sub(candidate.encoded_len()?)244
.ok_or("equipmentpresets samen groter dan 64 MiB")?;245
for (i, item) in self.equipment.iter().enumerate() {246
if Some(i) != replacement {247
if let Some(p) = &item.preset {248
budget = budget249
.checked_sub(p.encoded_len()?)250
.ok_or("equipmentpresets samen groter dan 64 MiB")?;251
}252
}253
}254
Ok(())255
}256
pub fn available_equipment_name(&self, base: &str) -> Result<String, String> {257
if !valid_slug(base) {258
return Err("ongeldige equipmentnaam".into());259
}260
let mut name = base.to_string();261
let mut n = 2;262
while self.equipment.iter().any(|i| i.slug == name) {263
let suffix = format!("-{n}");264
name = format!("{}{}", &base[..base.len().min(64 - suffix.len())], suffix);265
n += 1;266
}267
Ok(name)268
}269
228
pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {270
pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {229
let slug = slug.trim().to_string();271
let slug = slug.trim().to_string();230
if !valid_slug(&slug) {272
if !valid_slug(&slug) {@@ -252,6 +294,8 @@ impl Session {252
if let Some(item) = self.equipment.get_mut(index) {294
if let Some(item) = self.equipment.get_mut(index) {253
item.model = Some(relative);295
item.model = Some(relative);254
item.legacy_path = None;296
item.legacy_path = None;297
item.preset = None;298
item.error = None;255
}299
}256
}300
}257
301
@@ -321,6 +365,11 @@ impl Session {321
disk_issues: Vec::new(),365
disk_issues: Vec::new(),322
};366
};323
save_project(&candidate)?;367
save_project(&candidate)?;368
for item in &mut self.equipment {369
if let Some(preset) = &mut item.preset {370
preset.name = item.slug.clone();371
}372
}324
self.opened = Some(candidate);373
self.opened = Some(candidate);325
self.note_saved();374
self.note_saved();326
self.ok("Opgeslagen.");375
self.ok("Opgeslagen.");@@ -441,49 +490,93 @@ fn io_at(path: &Path, message: &str) -> ProjectError {441
}490
}442
}491
}443
492
493
pub const MAX_PROJECT_PRESET_BYTES: usize = 64 * 1024 * 1024;494
const MAX_LEGACY_PRESET_BYTES: usize = 64 * 1024;495
496
fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {497
use std::io::Read;498
if !path.is_file() {499
return Err("kies een regulier bestand".into());500
}501
let mut bytes = Vec::new();502
fs::File::open(path)503
.and_then(|file| file.take((limit + 1) as u64).read_to_end(&mut bytes))504
.map_err(|e| e.to_string())?;505
if bytes.len() > limit {506
return Err("presetbestand overschrijdt het bestands-/projectbudget".into());507
}508
Ok(bytes)509
}444
fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {510
fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {511
read_equipment_with_budget(root, presets, MAX_PROJECT_PRESET_BYTES)512
}513
fn read_equipment_with_budget(514
root: &Path,515
presets: &[String],516
mut budget: usize,517
) -> Vec<EquipmentItem> {445
presets518
presets446
.iter()519
.iter()447
.map(|path| {520
.map(|path| {521
let failure = |error: String| EquipmentItem {522
preset: None,523
error: Some(error),524
slug: Path::new(path)525
.file_stem()526
.unwrap_or_default()527
.to_string_lossy()528
.into(),529
model: None,530
legacy_path: Some(path.clone()),531
};448
let file = match checked_project_path(root, path) {532
let file = match checked_project_path(root, path) {449
Ok(path) => path,533
Ok(path) => path,450
Err(_) => {534
Err(error) => return failure(error),451
return EquipmentItem {452
slug: "onveilig".into(),453
model: None,454
legacy_path: Some(path.clone()),455
}456
}457
};535
};458
if let Ok(text) = fs::read_to_string(&file) {536
let binary = file.extension().and_then(|e| e.to_str()) == Some("ppe");459
if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {537
let limit = budget.min(if binary {460
if valid_slug(&parsed.name) {538
crate::equipment::MAX_FILE461
return EquipmentItem {539
} else {462
slug: parsed.name,540
MAX_LEGACY_PRESET_BYTES463
model: parsed.model,541
});464
legacy_path: None,542
let bytes = match read_limited(&file, limit) {465
};543
Ok(bytes) => bytes,466
}544
Err(error) => return failure(error),467
}545
};546
budget -= bytes.len();547
if binary {548
return match Preset::decode(&bytes) {549
Ok(preset) => EquipmentItem {550
slug: preset.name.clone(),551
model: preset.attachments.first().map(|a| a.label.clone()),552
preset: Some(preset),553
error: None,554
legacy_path: None,555
},556
Err(error) => failure(error),557
};468
}558
}469
let slug = path559
if let Ok(parsed) = serde_json::from_slice::<EquipmentFile>(&bytes) {470
.rsplit('/')560
if valid_slug(&parsed.name) {471
.next()561
return EquipmentItem {472
.unwrap_or(path)562
preset: None,473
.split('.')563
error: None,474
.next()564
slug: parsed.name,475
.unwrap_or("item")565
model: parsed.model,476
.to_string();566
legacy_path: None,477
EquipmentItem {567
};478
slug,568
}479
model: None,480
legacy_path: Some(path.clone()),481
}569
}570
failure("legacy preset; kies een OBJ om te configureren".into())482
})571
})483
.collect()572
.collect()484
}573
}485
574
486
fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {575
fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {576
if items.len() > crate::project::MAX_PROJECT_PRESETS {577
return Err("maximaal 64 equipmentpresets per project".into());578
}579
let mut budget = MAX_PROJECT_PRESET_BYTES;487
let mut paths = Vec::new();580
let mut paths = Vec::new();488
let mut names = std::collections::BTreeSet::new();581
let mut names = std::collections::BTreeSet::new();489
// Validate every record before writing. Config files are immutable snapshots:582
// Validate every record before writing. Config files are immutable snapshots:@@ -492,17 +585,58 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,492
if !names.insert(&item.slug) {585
if !names.insert(&item.slug) {493
return Err("dubbele equipmentnaam".into());586
return Err("dubbele equipmentnaam".into());494
}587
}495
if item.legacy_path.is_some() && item.model.is_none() {588
if let Some(legacy) = item.legacy_path.as_ref().filter(|_| item.model.is_none()) {589
let path = checked_project_path(root, legacy)?;590
let length = fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);591
budget = budget592
.checked_sub(usize::try_from(length).map_err(|_| "preset te groot")?)593
.ok_or("equipmentpresets samen groter dan 64 MiB")?;496
continue;594
continue;497
}595
}498
if !valid_slug(&item.slug) {596
if !valid_slug(&item.slug) {499
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));597
return Err(format!("equipment '{}' heeft geen geldige naam", item.slug));500
}598
}501
if let Some(model) = &item.model {599
if let Some(preset) = &item.preset {502
portable_relative(model)?;600
let mut preset = preset.clone();601
preset.name = item.slug.clone();602
let length = preset.encode()?.len();603
budget = budget604
.checked_sub(length)605
.ok_or("equipmentpresets samen groter dan 64 MiB")?;606
} else {607
if let Some(model) = &item.model {608
portable_relative(model)?;609
}610
let text = serde_json::to_vec_pretty(&EquipmentFile {611
name: item.slug.clone(),612
model: item.model.clone(),613
})614
.map_err(|e| e.to_string())?;615
if text.len() > MAX_LEGACY_PRESET_BYTES {616
return Err("legacy preset groter dan 64 KiB".into());617
}618
budget = budget619
.checked_sub(text.len())620
.ok_or("equipmentpresets samen groter dan 64 MiB")?;503
}621
}504
}622
}505
for item in items {623
for item in items {624
if let Some(preset) = &item.preset {625
let mut preset = preset.clone();626
preset.name = item.slug.clone();627
let bytes = preset.encode()?;628
let relative = format!(629
"presets/{}-{:08x}-{}.ppe",630
item.slug,631
crate::equipment::checksum(&bytes),632
bytes.len()633
);634
let path = checked_project_path(root, &relative)?;635
fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;636
crate::equipment::write_snapshot(&path, &bytes)?;637
paths.push(relative);638
continue;639
}506
if item.model.is_none() {640
if item.model.is_none() {507
if let Some(legacy) = &item.legacy_path {641
if let Some(legacy) = &item.legacy_path {508
paths.push(legacy.clone());642
paths.push(legacy.clone());@@ -518,8 +652,8 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,518
loop {652
loop {519
let relative = format!("presets/{}-{number}.json", item.slug);653
let relative = format!("presets/{}-{number}.json", item.slug);520
let path = checked_project_path(root, &relative)?;654
let path = checked_project_path(root, &relative)?;521
if let Ok(existing) = fs::read_to_string(&path) {655
if let Ok(existing) = read_limited(&path, MAX_LEGACY_PRESET_BYTES) {522
if existing == text {656
if existing == text.as_bytes() {523
paths.push(relative);657
paths.push(relative);524
break;658
break;525
}659
}@@ -559,11 +693,7 @@ fn next_slug(items: &[EquipmentItem]) -> String {559
}693
}560
694
561
fn valid_slug(slug: &str) -> bool {695
fn valid_slug(slug: &str) -> bool {562
!slug.is_empty()696
crate::equipment::valid_name(slug)563
&& slug != "body"564
&& slug565
.chars()566
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')567
}697
}568
698
569
pub fn manifest_in(root: &Path) -> PathBuf {699
pub fn manifest_in(root: &Path) -> PathBuf {@@ -585,6 +715,74 @@ mod tests {585
path715
path586
}716
}587
717
718
#[test]719
fn editing_budget_and_import_names_are_bounded_before_install() {720
let mut session = Session::new();721
let mut p = Preset::new("sword".into());722
for i in 0..3 {723
p.assets.push(crate::equipment::EmbeddedAsset {724
path: format!("raw-{i}"),725
bytes: vec![0; 8 * 1024 * 1024],726
});727
}728
for _ in 0..2 {729
let i = session.add_equipment();730
session.equipment[i].preset = Some(p.clone());731
}732
assert!(session733
.ensure_preset_budget(None, &p)734
.unwrap_err()735
.contains("64 MiB"));736
assert!(session.ensure_preset_budget(Some(0), &p).is_ok());737
assert_eq!(session.equipment.len(), 2);738
let base = "a".repeat(64);739
session.equipment[0].slug = base.clone();740
let unique = session.available_equipment_name(&base).unwrap();741
assert_eq!(unique.len(), 64);742
assert_ne!(unique, base);743
assert!(crate::equipment::valid_name(&unique));744
}745
746
#[test]747
fn preset_read_budget_preserves_unloaded_references() {748
let root = scratch();749
fs::create_dir_all(root.join("presets")).unwrap();750
let bytes = Preset::new("sword".into()).encode().unwrap();751
for name in ["one", "two"] {752
fs::write(root.join(format!("presets/{name}.ppe")), &bytes).unwrap();753
}754
let refs = vec!["presets/one.ppe".into(), "presets/two.ppe".into()];755
let items = read_equipment_with_budget(&root, &refs, bytes.len());756
assert!(items[0].preset.is_some());757
assert!(items[1].preset.is_none());758
assert!(items[1].error.as_ref().unwrap().contains("budget"));759
assert_eq!(items[1].legacy_path.as_ref(), Some(&refs[1]));760
fs::write(761
root.join("presets/old.json"),762
vec![b' '; MAX_LEGACY_PRESET_BYTES + 1],763
)764
.unwrap();765
assert!(read_equipment(&root, &["presets/old.json".into()])[0]766
.error767
.is_some());768
fs::remove_dir_all(root).unwrap();769
}770
#[test]771
fn too_many_presets_do_not_write_snapshots_or_replace_manifest() {772
let root = scratch();773
let mut session = Session::new();774
session.install_new_project(&root).unwrap();775
let before = fs::read(root.join(MANIFEST_NAME)).unwrap();776
for _ in 0..=crate::project::MAX_PROJECT_PRESETS {777
session.add_equipment();778
}779
assert!(session.save().unwrap_err().to_string().contains("64"));780
assert_eq!(fs::read(root.join(MANIFEST_NAME)).unwrap(), before);781
assert_eq!(fs::read_dir(root.join("presets")).unwrap().count(), 0);782
assert!(session.is_dirty());783
fs::remove_dir_all(root).unwrap();784
}785
588
#[test]786
#[test]589
fn new_project_copies_both_test_bodies_and_can_be_overwritten() {787
fn new_project_copies_both_test_bodies_and_can_be_overwritten() {590
let root = scratch();788
let root = scratch();@@ -0,0 +1,400 @@
1
use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC};
2
use paperdoll_renderer::preview::{self, PreviewControls};
3
use std::fs;
4
use std::path::PathBuf;
5
6
fn fixture(name: &str) -> PathBuf {
7
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
8
.join("tests/fixtures/equipment")
9
.join(name)
10
}
11
fn fixture_body(female: bool) -> preview::PreviewCharacter {
12
preview::load_gltf_file(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if female {
13
"tests/fixtures/bodies/test-body-female.glb"
14
} else {
15
"tests/fixtures/bodies/test-body-male.glb"
16
}))
17
.unwrap()
18
}
19
fn preset() -> Preset {
20
let mut p = Preset::new("sword".into());
21
p.provenance = "CC0-1.0 generated test fixture".into();
22
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
23
equipment::import_shiny(&mut p, 0, &fixture("shiny.png")).unwrap();
24
for sex in 0..2 {
25
let body = fixture_body(sex == 1);
26
let joint = body
27
.joint_names
28
.iter()
29
.position(|n| n == "RightHand")
30
.unwrap();
31
p.bind(sex, &body, 0, joint).unwrap();
32
}
33
p.attachments[0].translation = [0.02, -0.02, -0.03];
34
p.attachments[0].rotation_deg = [5., 0., -15.];
35
p
36
}
37
fn scratch() -> PathBuf {
38
std::env::temp_dir().join(format!(
39
"paperdoll-equipment-{}",
40
std::time::SystemTime::now()
41
.duration_since(std::time::UNIX_EPOCH)
42
.unwrap()
43
.as_nanos()
44
))
45
}
46
47
#[test]
48
fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() {
49
let p = preset();
50
let bytes = p.encode().unwrap();
51
assert_eq!(&bytes[..8], MAGIC);
52
assert_eq!(p.encoded_len().unwrap(), bytes.len());
53
let restored = Preset::decode(&bytes).unwrap();
54
assert_eq!(restored, p);
55
assert_eq!(restored.assets.len(), 4);
56
assert_eq!(
57
restored
58
.texture(restored.attachments[0].meshes[0].albedo.unwrap())
59
.unwrap()
60
.width,
61
2
62
);
63
assert_eq!(restored.attachments[0].meshes[0].indices.len(), 6);
64
assert!(restored
65
.assets
66
.iter()
67
.any(|a| a.bytes == fs::read(fixture("sword.obj")).unwrap()));
68
assert!(restored.rigs.iter().all(Option::is_some));
69
let snapshot = fixture("sword.ppe");
70
if std::env::var_os("PAPERDOLL_WRITE_PRESET").is_some() {
71
fs::write(&snapshot, &bytes).unwrap();
72
}
73
assert_eq!(
74
fs::read(snapshot).unwrap(),
75
bytes,
76
"binary preset fixture changed"
77
);
78
}
79
80
#[test]
81
fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {
82
let p = preset();
83
let body = fixture_body(false);
84
let joint = body
85
.joint_names
86
.iter()
87
.position(|n| n == "RightHand")
88
.unwrap();
89
let mut controls = PreviewControls {
90
time: 0.0,
91
clip_name: "walk".into(),
92
..Default::default()
93
};
94
let first = equipment::layers(&p, 0, &body, &controls).unwrap();
95
let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();
96
let expected = preview::mul(
97
world,
98
equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg),
99
);
100
assert_eq!(first[0].model, expected);
101
assert_ne!(
102
world,
103
preview::sample_skin_matrices(&body, "walk", 0.)[joint]
104
);
105
controls.time = 0.25;
106
let second = equipment::layers(&p, 0, &body, &controls).unwrap();
107
assert_ne!(first[0].model, second[0].model);
108
let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap();
109
assert!(!female.is_empty());
110
}
111
112
#[test]
113
fn local_euler_order_and_translation_are_not_accumulated() {
114
let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]);
115
let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]];
116
for (a, b) in point.into_iter().zip([1., 3., 3.]) {
117
assert!((a - b).abs() < 1e-5);
118
}
119
assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));
120
let m = equipment::local_matrix([10., 20., 30.], [90., 90., 90.]);
121
let v = [1., 2., 3.];
122
for i in 0..3 {
123
let actual = m[i] * v[0] + m[4 + i] * v[1] + m[8 + i] * v[2] + m[12 + i];
124
assert!(
125
(actual - [13., 22., 29.][i]).abs() < 1e-4,
126
"multi-axis Euler order"
127
);
128
}
129
}
130
131
#[test]
132
fn incompatible_rig_and_missing_joint_require_explicit_remapping() {
133
let mut p = preset();
134
let mut changed = fixture_body(false);
135
changed.nodes[0].translation[0] += 0.01;
136
assert!(
137
equipment::layers(&p, 0, &changed, &PreviewControls::default())
138
.unwrap_err()
139
.contains("rig")
140
);
141
p.attachments[0].bones[0] = Some("missing".into());
142
assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err());
143
p.applies[0] = false;
144
assert!(
145
equipment::layers(&p, 0, &changed, &PreviewControls::default())
146
.unwrap()
147
.is_empty()
148
);
149
assert_ne!(
150
RigSignature::of(&fixture_body(false)),
151
RigSignature::of(&fixture_body(true))
152
);
153
}
154
155
#[test]
156
fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {
157
let p = preset();
158
let valid = p.encode().unwrap();
159
for n in [0, 7, 19, valid.len() - 1] {
160
assert!(Preset::decode(&valid[..n]).is_err());
161
}
162
let mut damaged = valid.clone();
163
*damaged.last_mut().unwrap() ^= 1;
164
assert!(Preset::decode(&damaged).is_err());
165
let mut damaged = valid.clone();
166
damaged[8..12].copy_from_slice(&2u32.to_le_bytes());
167
assert!(Preset::decode(&damaged).is_err());
168
let mut damaged = valid.clone();
169
damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());
170
assert!(Preset::decode(&damaged).is_err());
171
let mut damaged = valid.clone();
172
damaged.push(0);
173
let crc = equipment::checksum(&damaged[20..]);
174
damaged[16..20].copy_from_slice(&crc.to_le_bytes());
175
assert!(Preset::decode(&damaged).is_err());
176
assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926);
177
}
178
179
// Build a malicious but correctly checksummed container without encoder validation.
180
fn untrusted(p: &Preset) -> Vec<u8> {
181
let metadata = serde_json::to_vec(p).unwrap();
182
let n = metadata.len();
183
let mut payload = metadata;
184
for a in &p.assets {
185
payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes());
186
payload.extend(&a.bytes)
187
}
188
let mut out = MAGIC.to_vec();
189
out.extend(1u32.to_le_bytes());
190
out.extend((n as u32).to_le_bytes());
191
out.extend(equipment::checksum(&payload).to_le_bytes());
192
out.extend(payload);
193
out
194
}
195
196
#[test]
197
fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {
198
for name in ["body", "Hello", "with space", "../escape", ""] {
199
let mut p = preset();
200
p.name = name.into();
201
assert!(p.encode().is_err());
202
assert!(Preset::decode(&untrusted(&p)).is_err());
203
}
204
let mut p = preset();
205
p.assets[0].path = "../escape".into();
206
assert!(Preset::decode(&untrusted(&p)).is_err());
207
let mut p = preset();
208
p.assets[0].path = "/absolute".into();
209
assert!(p.encode().is_err());
210
let mut p = preset();
211
p.attachments[0].translation[0] = f32::NAN;
212
assert!(p.encode().is_err());
213
let mut p = preset();
214
p.attachments[0].meshes[0].indices[0] = u32::MAX;
215
assert!(Preset::decode(&untrusted(&p)).is_err());
216
}
217
218
#[test]
219
fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() {
220
let root = scratch();
221
fs::create_dir_all(&root).unwrap();
222
let path = root.join("bad.obj");
223
fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap();
224
let mut p = preset();
225
let saved = p.clone();
226
assert!(equipment::import_obj(&mut p, &path).is_err());
227
assert_eq!(saved, p);
228
#[cfg(unix)]
229
{
230
std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap();
231
fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap();
232
assert!(equipment::import_obj(&mut p, &path).is_err());
233
}
234
fs::remove_dir_all(root).unwrap();
235
}
236
237
#[test]
238
fn atomic_snapshot_never_overwrites_previous_valid_data() {
239
let root = scratch();
240
fs::create_dir_all(&root).unwrap();
241
let path = root.join("sword.ppe");
242
let bytes = preset().encode().unwrap();
243
equipment::write_snapshot(&path, &bytes).unwrap();
244
equipment::write_snapshot(&path, &bytes).unwrap();
245
assert!(equipment::write_snapshot(&path, b"different").is_err());
246
assert_eq!(fs::read(&path).unwrap(), bytes);
247
assert!(fs::read_dir(&root).unwrap().all(|entry| !entry
248
.unwrap()
249
.file_name()
250
.to_string_lossy()
251
.contains("part-")));
252
fs::remove_dir_all(root).unwrap();
253
}
254
255
#[test]
256
fn binary_project_reload_keeps_payload_after_source_disappears() {
257
let root = scratch();
258
let mut session = paperdoll_renderer::session::Session::new();
259
session.install_new_project(&root).unwrap();
260
let index = session.add_equipment();
261
session.equipment[index].slug = "sword".into();
262
session.equipment[index].preset = Some(preset());
263
session.save().unwrap();
264
let manifest = root.join("character.ppd");
265
let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();
266
assert!(reference.ends_with(".ppe"));
267
let mut again = paperdoll_renderer::session::Session::new();
268
again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap());
269
let loaded = again.equipment[0].preset.as_ref().unwrap();
270
assert_eq!(loaded.name, "sword");
271
assert!(
272
!equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default())
273
.unwrap()
274
.is_empty()
275
);
276
let previous = fs::read(&manifest).unwrap();
277
again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;
278
again.set_render(0, 128, 12);
279
assert!(again.save().is_err());
280
assert_eq!(fs::read(&manifest).unwrap(), previous);
281
fs::remove_dir_all(root).unwrap();
282
}
283
284
#[test]
285
fn translated_rotated_parent_uses_the_complete_hierarchy_once() {
286
let mut body = fixture_body(false);
287
body.nodes = vec![
288
preview::SceneNode {
289
name: "root".into(),
290
parent: None,
291
translation: [3., 0., 0.],
292
rotation: [
293
0.,
294
0.,
295
std::f32::consts::FRAC_1_SQRT_2,
296
std::f32::consts::FRAC_1_SQRT_2,
297
],
298
scale: [1.; 3],
299
},
300
preview::SceneNode {
301
name: "shoulder".into(),
302
parent: Some(0),
303
translation: [0., 2., 0.],
304
rotation: [0., 0., 0., 1.],
305
scale: [1.; 3],
306
},
307
preview::SceneNode {
308
name: "hand".into(),
309
parent: Some(1),
310
translation: [0., 0., 1.],
311
rotation: [0., 0., 0., 1.],
312
scale: [1.; 3],
313
},
314
];
315
body.joint_nodes = vec![2];
316
body.joint_names = vec!["hand".into()];
317
body.clips.clear();
318
let mut preset = preset();
319
preset.rigs = [None, None];
320
preset.applies = [true, false];
321
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];
324
preset.bind(0, &body, 0, 0).unwrap();
325
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]]
327
.into_iter()
328
.zip([0.7, 0.2, 1.4])
329
{
330
assert!((value - expected).abs() < 1e-5, "{value} != {expected}");
331
}
332
}
333
334
#[test]
335
fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {
336
let mut p = preset();
337
let mut data = Vec::new();
338
{
339
let mut encoder = png::Encoder::new(&mut data, 2049, 1);
340
encoder.set_color(png::ColorType::Grayscale);
341
encoder.set_depth(png::BitDepth::Eight);
342
let mut writer = encoder.write_header().unwrap();
343
writer.write_image_data(&vec![0; 2049]).unwrap();
344
}
345
let i = p.attachments[0].shiny.unwrap();
346
p.assets[i].bytes = data;
347
assert!(p.encode().is_err());
348
assert!(Preset::decode(&untrusted(&p)).is_err());
349
let mut p = preset();
350
while p.assets.len() < 33 {
351
let n = p.assets.len();
352
p.assets.push(equipment::EmbeddedAsset {
353
path: format!("asset-{n}"),
354
bytes: Vec::new(),
355
});
356
}
357
assert!(Preset::decode(&untrusted(&p)).is_err());
358
}
359
360
#[test]
361
fn multipart_remove_readd_and_shiny_replacement_remap_embedded_assets() {
362
let mut p = preset();
363
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
364
equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();
365
assert_eq!(p.assets.len(), 8);
366
equipment::remove_attachment(&mut p, 0).unwrap();
367
assert_eq!(p.assets.len(), 4);
368
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();
369
for _ in 0..40 {
370
equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();
371
}
372
assert_eq!(p.assets.len(), 8);
373
let bytes = p.encode().unwrap();
374
assert_eq!(Preset::decode(&bytes).unwrap(), p);
375
assert!(p.attachments.iter().all(|a| a.source_assets.len() == 3));
376
equipment::clear_shiny(&mut p, 1).unwrap();
377
assert_eq!(p.assets.len(), 7);
378
assert!(p.attachments[1].shiny.is_none());
379
p.validate().unwrap();
380
let before = p.clone();
381
assert!(equipment::remove_attachment(&mut p, 999).is_err());
382
assert_eq!(p, before);
383
p.attachments[0].source_assets[0] = usize::MAX;
384
assert!(Preset::decode(&untrusted(&p)).is_err());
385
}
386
#[test]
387
fn non_regular_preset_and_snapshot_targets_fail_without_reading() {
388
let root = scratch();
389
fs::create_dir_all(&root).unwrap();
390
assert!(Preset::read(&root).is_err());
391
assert!(equipment::write_snapshot(&root, b"bytes").is_err());
392
#[cfg(unix)]
393
{
394
let socket = root.join("socket.ppe");
395
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
396
assert!(Preset::read(&socket).is_err());
397
drop(listener);
398
}
399
fs::remove_dir_all(root).unwrap();
400
}
@@ -0,0 +1,400 @@1
use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC};2
use paperdoll_renderer::preview::{self, PreviewControls};3
use std::fs;4
use std::path::PathBuf;5
6
fn fixture(name: &str) -> PathBuf {7
PathBuf::from(env!("CARGO_MANIFEST_DIR"))8
.join("tests/fixtures/equipment")9
.join(name)10
}11
fn fixture_body(female: bool) -> preview::PreviewCharacter {12
preview::load_gltf_file(&PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(if female {13
"tests/fixtures/bodies/test-body-female.glb"14
} else {15
"tests/fixtures/bodies/test-body-male.glb"16
}))17
.unwrap()18
}19
fn preset() -> Preset {20
let mut p = Preset::new("sword".into());21
p.provenance = "CC0-1.0 generated test fixture".into();22
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();23
equipment::import_shiny(&mut p, 0, &fixture("shiny.png")).unwrap();24
for sex in 0..2 {25
let body = fixture_body(sex == 1);26
let joint = body27
.joint_names28
.iter()29
.position(|n| n == "RightHand")30
.unwrap();31
p.bind(sex, &body, 0, joint).unwrap();32
}33
p.attachments[0].translation = [0.02, -0.02, -0.03];34
p.attachments[0].rotation_deg = [5., 0., -15.];35
p36
}37
fn scratch() -> PathBuf {38
std::env::temp_dir().join(format!(39
"paperdoll-equipment-{}",40
std::time::SystemTime::now()41
.duration_since(std::time::UNIX_EPOCH)42
.unwrap()43
.as_nanos()44
))45
}46
47
#[test]48
fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() {49
let p = preset();50
let bytes = p.encode().unwrap();51
assert_eq!(&bytes[..8], MAGIC);52
assert_eq!(p.encoded_len().unwrap(), bytes.len());53
let restored = Preset::decode(&bytes).unwrap();54
assert_eq!(restored, p);55
assert_eq!(restored.assets.len(), 4);56
assert_eq!(57
restored58
.texture(restored.attachments[0].meshes[0].albedo.unwrap())59
.unwrap()60
.width,61
262
);63
assert_eq!(restored.attachments[0].meshes[0].indices.len(), 6);64
assert!(restored65
.assets66
.iter()67
.any(|a| a.bytes == fs::read(fixture("sword.obj")).unwrap()));68
assert!(restored.rigs.iter().all(Option::is_some));69
let snapshot = fixture("sword.ppe");70
if std::env::var_os("PAPERDOLL_WRITE_PRESET").is_some() {71
fs::write(&snapshot, &bytes).unwrap();72
}73
assert_eq!(74
fs::read(snapshot).unwrap(),75
bytes,76
"binary preset fixture changed"77
);78
}79
80
#[test]81
fn attachment_uses_animated_joint_world_not_inverse_bind_twice() {82
let p = preset();83
let body = fixture_body(false);84
let joint = body85
.joint_names86
.iter()87
.position(|n| n == "RightHand")88
.unwrap();89
let mut controls = PreviewControls {90
time: 0.0,91
clip_name: "walk".into(),92
..Default::default()93
};94
let first = equipment::layers(&p, 0, &body, &controls).unwrap();95
let world = preview::animated_joint_world(&body, "walk", 0., joint).unwrap();96
let expected = preview::mul(97
world,98
equipment::local_matrix(p.attachments[0].translation, p.attachments[0].rotation_deg),99
);100
assert_eq!(first[0].model, expected);101
assert_ne!(102
world,103
preview::sample_skin_matrices(&body, "walk", 0.)[joint]104
);105
controls.time = 0.25;106
let second = equipment::layers(&p, 0, &body, &controls).unwrap();107
assert_ne!(first[0].model, second[0].model);108
let female = equipment::layers(&p, 1, &fixture_body(true), &controls).unwrap();109
assert!(!female.is_empty());110
}111
112
#[test]113
fn local_euler_order_and_translation_are_not_accumulated() {114
let m = equipment::local_matrix([1., 2., 3.], [0., 0., 90.]);115
let point = [m[0] + m[12], m[1] + m[13], m[2] + m[14]];116
for (a, b) in point.into_iter().zip([1., 3., 3.]) {117
assert!((a - b).abs() < 1e-5);118
}119
assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));120
let m = equipment::local_matrix([10., 20., 30.], [90., 90., 90.]);121
let v = [1., 2., 3.];122
for i in 0..3 {123
let actual = m[i] * v[0] + m[4 + i] * v[1] + m[8 + i] * v[2] + m[12 + i];124
assert!(125
(actual - [13., 22., 29.][i]).abs() < 1e-4,126
"multi-axis Euler order"127
);128
}129
}130
131
#[test]132
fn incompatible_rig_and_missing_joint_require_explicit_remapping() {133
let mut p = preset();134
let mut changed = fixture_body(false);135
changed.nodes[0].translation[0] += 0.01;136
assert!(137
equipment::layers(&p, 0, &changed, &PreviewControls::default())138
.unwrap_err()139
.contains("rig")140
);141
p.attachments[0].bones[0] = Some("missing".into());142
assert!(equipment::layers(&p, 0, &fixture_body(false), &PreviewControls::default()).is_err());143
p.applies[0] = false;144
assert!(145
equipment::layers(&p, 0, &changed, &PreviewControls::default())146
.unwrap()147
.is_empty()148
);149
assert_ne!(150
RigSignature::of(&fixture_body(false)),151
RigSignature::of(&fixture_body(true))152
);153
}154
155
#[test]156
fn corrupt_truncated_unsupported_and_excessive_containers_are_rejected() {157
let p = preset();158
let valid = p.encode().unwrap();159
for n in [0, 7, 19, valid.len() - 1] {160
assert!(Preset::decode(&valid[..n]).is_err());161
}162
let mut damaged = valid.clone();163
*damaged.last_mut().unwrap() ^= 1;164
assert!(Preset::decode(&damaged).is_err());165
let mut damaged = valid.clone();166
damaged[8..12].copy_from_slice(&2u32.to_le_bytes());167
assert!(Preset::decode(&damaged).is_err());168
let mut damaged = valid.clone();169
damaged[12..16].copy_from_slice(&u32::MAX.to_le_bytes());170
assert!(Preset::decode(&damaged).is_err());171
let mut damaged = valid.clone();172
damaged.push(0);173
let crc = equipment::checksum(&damaged[20..]);174
damaged[16..20].copy_from_slice(&crc.to_le_bytes());175
assert!(Preset::decode(&damaged).is_err());176
assert_eq!(equipment::checksum(b"123456789"), 0xcbf43926);177
}178
179
// Build a malicious but correctly checksummed container without encoder validation.180
fn untrusted(p: &Preset) -> Vec<u8> {181
let metadata = serde_json::to_vec(p).unwrap();182
let n = metadata.len();183
let mut payload = metadata;184
for a in &p.assets {185
payload.extend_from_slice(&(a.bytes.len() as u32).to_le_bytes());186
payload.extend(&a.bytes)187
}188
let mut out = MAGIC.to_vec();189
out.extend(1u32.to_le_bytes());190
out.extend((n as u32).to_le_bytes());191
out.extend(equipment::checksum(&payload).to_le_bytes());192
out.extend(payload);193
out194
}195
196
#[test]197
fn traversal_invalid_names_nonfinite_and_invalid_geometry_are_rejected() {198
for name in ["body", "Hello", "with space", "../escape", ""] {199
let mut p = preset();200
p.name = name.into();201
assert!(p.encode().is_err());202
assert!(Preset::decode(&untrusted(&p)).is_err());203
}204
let mut p = preset();205
p.assets[0].path = "../escape".into();206
assert!(Preset::decode(&untrusted(&p)).is_err());207
let mut p = preset();208
p.assets[0].path = "/absolute".into();209
assert!(p.encode().is_err());210
let mut p = preset();211
p.attachments[0].translation[0] = f32::NAN;212
assert!(p.encode().is_err());213
let mut p = preset();214
p.attachments[0].meshes[0].indices[0] = u32::MAX;215
assert!(Preset::decode(&untrusted(&p)).is_err());216
}217
218
#[test]219
fn dependency_escape_and_failed_import_leave_existing_preset_unchanged() {220
let root = scratch();221
fs::create_dir_all(&root).unwrap();222
let path = root.join("bad.obj");223
fs::write(&path, "mtllib ../outside.mtl\nv 0 0 0\n").unwrap();224
let mut p = preset();225
let saved = p.clone();226
assert!(equipment::import_obj(&mut p, &path).is_err());227
assert_eq!(saved, p);228
#[cfg(unix)]229
{230
std::os::unix::fs::symlink(fixture("sword.mtl"), root.join("outside.mtl")).unwrap();231
fs::write(&path, "mtllib outside.mtl\nv 0 0 0\n").unwrap();232
assert!(equipment::import_obj(&mut p, &path).is_err());233
}234
fs::remove_dir_all(root).unwrap();235
}236
237
#[test]238
fn atomic_snapshot_never_overwrites_previous_valid_data() {239
let root = scratch();240
fs::create_dir_all(&root).unwrap();241
let path = root.join("sword.ppe");242
let bytes = preset().encode().unwrap();243
equipment::write_snapshot(&path, &bytes).unwrap();244
equipment::write_snapshot(&path, &bytes).unwrap();245
assert!(equipment::write_snapshot(&path, b"different").is_err());246
assert_eq!(fs::read(&path).unwrap(), bytes);247
assert!(fs::read_dir(&root).unwrap().all(|entry| !entry248
.unwrap()249
.file_name()250
.to_string_lossy()251
.contains("part-")));252
fs::remove_dir_all(root).unwrap();253
}254
255
#[test]256
fn binary_project_reload_keeps_payload_after_source_disappears() {257
let root = scratch();258
let mut session = paperdoll_renderer::session::Session::new();259
session.install_new_project(&root).unwrap();260
let index = session.add_equipment();261
session.equipment[index].slug = "sword".into();262
session.equipment[index].preset = Some(preset());263
session.save().unwrap();264
let manifest = root.join("character.ppd");265
let reference = session.opened.as_ref().unwrap().document.equipment_presets[0].clone();266
assert!(reference.ends_with(".ppe"));267
let mut again = paperdoll_renderer::session::Session::new();268
again.install_loaded(paperdoll_renderer::project::open_project(&manifest).unwrap());269
let loaded = again.equipment[0].preset.as_ref().unwrap();270
assert_eq!(loaded.name, "sword");271
assert!(272
!equipment::layers(loaded, 0, &fixture_body(false), &PreviewControls::default())273
.unwrap()274
.is_empty()275
);276
let previous = fs::read(&manifest).unwrap();277
again.equipment[0].preset.as_mut().unwrap().attachments[0].translation[0] = 0.2;278
again.set_render(0, 128, 12);279
assert!(again.save().is_err());280
assert_eq!(fs::read(&manifest).unwrap(), previous);281
fs::remove_dir_all(root).unwrap();282
}283
284
#[test]285
fn translated_rotated_parent_uses_the_complete_hierarchy_once() {286
let mut body = fixture_body(false);287
body.nodes = vec![288
preview::SceneNode {289
name: "root".into(),290
parent: None,291
translation: [3., 0., 0.],292
rotation: [293
0.,294
0.,295
std::f32::consts::FRAC_1_SQRT_2,296
std::f32::consts::FRAC_1_SQRT_2,297
],298
scale: [1.; 3],299
},300
preview::SceneNode {301
name: "shoulder".into(),302
parent: Some(0),303
translation: [0., 2., 0.],304
rotation: [0., 0., 0., 1.],305
scale: [1.; 3],306
},307
preview::SceneNode {308
name: "hand".into(),309
parent: Some(1),310
translation: [0., 0., 1.],311
rotation: [0., 0., 0., 1.],312
scale: [1.; 3],313
},314
];315
body.joint_nodes = vec![2];316
body.joint_names = vec!["hand".into()];317
body.clips.clear();318
let mut preset = preset();319
preset.rigs = [None, None];320
preset.applies = [true, false];321
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];324
preset.bind(0, &body, 0, 0).unwrap();325
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]]327
.into_iter()328
.zip([0.7, 0.2, 1.4])329
{330
assert!((value - expected).abs() < 1e-5, "{value} != {expected}");331
}332
}333
334
#[test]335
fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {336
let mut p = preset();337
let mut data = Vec::new();338
{339
let mut encoder = png::Encoder::new(&mut data, 2049, 1);340
encoder.set_color(png::ColorType::Grayscale);341
encoder.set_depth(png::BitDepth::Eight);342
let mut writer = encoder.write_header().unwrap();343
writer.write_image_data(&vec![0; 2049]).unwrap();344
}345
let i = p.attachments[0].shiny.unwrap();346
p.assets[i].bytes = data;347
assert!(p.encode().is_err());348
assert!(Preset::decode(&untrusted(&p)).is_err());349
let mut p = preset();350
while p.assets.len() < 33 {351
let n = p.assets.len();352
p.assets.push(equipment::EmbeddedAsset {353
path: format!("asset-{n}"),354
bytes: Vec::new(),355
});356
}357
assert!(Preset::decode(&untrusted(&p)).is_err());358
}359
360
#[test]361
fn multipart_remove_readd_and_shiny_replacement_remap_embedded_assets() {362
let mut p = preset();363
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();364
equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();365
assert_eq!(p.assets.len(), 8);366
equipment::remove_attachment(&mut p, 0).unwrap();367
assert_eq!(p.assets.len(), 4);368
equipment::import_obj(&mut p, &fixture("sword.obj")).unwrap();369
for _ in 0..40 {370
equipment::import_shiny(&mut p, 1, &fixture("shiny.png")).unwrap();371
}372
assert_eq!(p.assets.len(), 8);373
let bytes = p.encode().unwrap();374
assert_eq!(Preset::decode(&bytes).unwrap(), p);375
assert!(p.attachments.iter().all(|a| a.source_assets.len() == 3));376
equipment::clear_shiny(&mut p, 1).unwrap();377
assert_eq!(p.assets.len(), 7);378
assert!(p.attachments[1].shiny.is_none());379
p.validate().unwrap();380
let before = p.clone();381
assert!(equipment::remove_attachment(&mut p, 999).is_err());382
assert_eq!(p, before);383
p.attachments[0].source_assets[0] = usize::MAX;384
assert!(Preset::decode(&untrusted(&p)).is_err());385
}386
#[test]387
fn non_regular_preset_and_snapshot_targets_fail_without_reading() {388
let root = scratch();389
fs::create_dir_all(&root).unwrap();390
assert!(Preset::read(&root).is_err());391
assert!(equipment::write_snapshot(&root, b"bytes").is_err());392
#[cfg(unix)]393
{394
let socket = root.join("socket.ppe");395
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();396
assert!(Preset::read(&socket).is_err());397
drop(listener);398
}399
fs::remove_dir_all(root).unwrap();400
}@@ -0,0 +1 @@
1
CC0-1.0. These original generated equipment fixture meshes, materials and textures are dedicated to the public domain.
@@ -0,0 +1 @@1
CC0-1.0. These original generated equipment fixture meshes, materials and textures are dedicated to the public domain.The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -0,0 +1,4 @@
1
newmtl checker
2
Kd 1 1 1
3
d 1
4
map_Kd checker.png
@@ -0,0 +1,4 @@1
newmtl checker2
Kd 1 1 13
d 14
map_Kd checker.png@@ -0,0 +1,15 @@
1
# CC0 rigid attachment fixture, metres
2
mtllib sword.mtl
3
o blade
4
v -0.055 0.00 -0.10
5
v 0.055 0.00 -0.10
6
v 0.055 0.58 -0.10
7
v -0.055 0.58 -0.10
8
vt 0 0
9
vt 1 0
10
vt 1 1
11
vt 0 1
12
vn 0 0 -1
13
usemtl checker
14
f 1/1/1 3/3/1 2/2/1
15
f 1/1/1 4/4/1 3/3/1
@@ -0,0 +1,15 @@1
# CC0 rigid attachment fixture, metres2
mtllib sword.mtl3
o blade4
v -0.055 0.00 -0.105
v 0.055 0.00 -0.106
v 0.055 0.58 -0.107
v -0.055 0.58 -0.108
vt 0 09
vt 1 010
vt 1 111
vt 0 112
vn 0 0 -113
usemtl checker14
f 1/1/1 3/3/1 2/2/115
f 1/1/1 4/4/1 3/3/1The contents of this binary file are not shown.