BlueGit

golden-age paperdoll-renderer

Merge feature/equipment-presets: bounded binary presets and real layered placement

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
1e9b68378399782937ad42cc9881c5a7cbf2749e
19 files changed +2462 −108
README.md +20 −2
@@ -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 ![Werkelijke equipment-editor met geanimeerd lichaam en handgebonden OBJ](docs/screenshots/equipment-live.png)
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## Starten
5## Starten
6
6
7Rust 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.
7Rust 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```bash
9```bash
10cargo run
10cargo run
@@ -31,7 +31,25 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
31
31
32## Equipment en opslaan
32## Equipment en opslaan
33
33
34Vanuit 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/`.
34Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
35
361. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.
372. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.
383. **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.
394. 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.
405. **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.
416. **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
45Een 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
49cargo run --example equipment_fixture --locked
50```
51
52![Werkelijke equipment-editor met geanimeerd lichaam en handgebonden OBJ](docs/screenshots/equipment-live.png)
35
53
36Bij 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.
54Bij 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
design.md +24 −4
@@ -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
109The 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.
111The 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 metadata
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## 9. Equipment editor and binary presets
142## 9. Equipment editor and binary presets
141
143
142UI 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.
144The 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
146Persisted 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
148The 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
152All 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
162Metadata 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
144Live 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.
164Limits: 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
146Binary 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.
166Project 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 preview
168## 10. Rendered 2D preview
149
169
@@ -184,7 +204,7 @@ These requirements are durable even where the implementation is staged. They sup
184- 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
187Current 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.
207Current 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 gates
209## 12. Incremental feature plan and acceptance gates
190
210
docs/screenshots/equipment-live.png added Binary file +0 −0

The contents of this binary file are not shown.

examples/equipment_fixture.rs added +64 −0
@@ -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.
2use paperdoll_renderer::{
3 editor::EditorApp,
4 equipment, preview,
5 session::{Session, Sex},
6};
7use std::path::PathBuf;
8fn 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}
src/dialogs.rs +15 −5
@@ -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 backend
3//! 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
7use std::path::{Path, PathBuf};
7use 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(_) => dialog
131 DialogPurpose::EquipmentModel(_) => dialog
130 .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(_, _) => 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 .pick_file(),
142 .pick_file(),
133 }
143 }
134}
144}
src/editor.rs +358 −28
@@ -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
34impl EditorApp {
35impl 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) = 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 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 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 return;
262 return;
210 };
263 };
211 match adopt_file(&root, path, "assets/equipment") {
264 let mut preset = item
212 Ok(relative) => {
265 .preset
213 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
359impl eframe::App for EditorApp {
439impl 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 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 self.session.add_equipment();
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 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 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 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 = self
682 ui.label("Naam");
958 .session
683 ui.text_edit_singleline(&mut item.slug);
959 .equipment
684 });
960 .get(index)
685 let model = item
961 .and_then(|item| item.preset.as_ref());
686 .model
962 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
999fn 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#[cfg(test)]
1062#[cfg(test)]
733mod tests {
1063mod tests {
734 use super::*;
1064 use super::*;
src/equipment.rs added +891 −0
@@ -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 animated
3//! joint world transform, never a skin matrix or inverse bind a second time.
4
5use std::collections::BTreeSet;
6use std::io::{Cursor, Read, Write};
7use std::path::Path;
8
9use crate::preview::{
10 animated_joint_world, identity, joint_paths, mul, Mat4, PreviewCharacter, PreviewControls,
11};
12use crate::project::portable_relative;
13use serde::{Deserialize, Serialize};
14
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16pub const VERSION: u32 = 1;
17pub const MAX_FILE: usize = 32 * 1024 * 1024;
18const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_ASSET: usize = 8 * 1024 * 1024;
20const MAX_ASSETS: usize = 32;
21const MAX_VERTICES: usize = 80_000;
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub 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)]
33pub 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)]
44pub struct RigSignature {
45 pub paths: Vec<String>,
46 pub rest_world: Vec<Mat4>,
47}
48impl 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)]
61pub 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)]
75pub 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}
83impl 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
328pub 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
337pub 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.
350pub 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)]
396pub struct Texture {
397 pub content_hash: u64,
398 pub width: u32,
399 pub height: u32,
400 pub rgba: Vec<u8>,
401}
402fn 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
444pub 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)]
466pub 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)]
475pub struct TextureCache {
476 entries: std::collections::VecDeque<(Vec<u8>, std::sync::Arc<Texture>)>,
477 bytes: usize,
478}
479impl 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
500pub 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
509pub 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.
555pub 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
740pub 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
765fn 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}
776fn 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.
816pub 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.
828pub 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
839fn 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}
862fn 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}
src/lib.rs +1 −0
@@ -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
6pub mod dialogs;
6pub mod dialogs;
7pub mod editor;
7pub mod editor;
8pub mod equipment;
8pub mod file_assoc;
9pub mod file_assoc;
9pub mod foundation;
10pub mod foundation;
10pub mod icon;
11pub mod icon;
src/preview.rs +37 −0
@@ -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.
283pub 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
304pub 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
282pub fn joint_world_translation(
319pub fn joint_world_translation(
283 character: &PreviewCharacter,
320 character: &PreviewCharacter,
284 clip_name: &str,
321 clip_name: &str,
src/preview_gl.rs +330 −26
@@ -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 paperdoll
3//! The shaded mesh is a placement preview. It is not the unlit paperdoll
4//! 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 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 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
265impl 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
211impl GpuCache {
329impl 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;
379layout(location = 1) in vec3 a_normal;
598layout(location = 1) in vec3 a_normal;
380layout(location = 2) in vec4 a_joints;
599layout(location = 2) in vec4 a_joints;
381layout(location = 3) in vec4 a_weights;
600layout(location = 3) in vec4 a_weights;
601layout(location = 4) in vec2 a_uv;
602uniform mat4 u_model;
603out vec2 v_uv;
604out vec3 v_world;
382uniform mat4 u_view_proj;
605uniform mat4 u_view_proj;
383uniform mat4 u_bones[128];
606uniform mat4 u_bones[128];
384out vec3 v_normal;
607out 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() {
400const FRAGMENT: &str = r#"#version 330
624const FRAGMENT: &str = r#"#version 330
401in vec3 v_normal;
625in vec3 v_normal;
402uniform vec4 u_color;
626uniform vec4 u_color;
627uniform sampler2D u_albedo;
628uniform sampler2D u_shiny;
629uniform bool u_albedo_enabled;
630uniform bool u_shiny_enabled;
631uniform float u_specular;
632uniform vec3 u_eye;
633in vec2 v_uv;
634in vec3 v_world;
403out vec4 frag;
635out vec4 frag;
404void main() {
636void 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);
src/project.rs +63 −2
@@ -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
15pub const FORMAT_VERSION: u32 = 1;
15pub const FORMAT_VERSION: u32 = 1;
16pub const MANIFEST_NAME: &str = "character.ppd";
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_PROJECT_PRESETS: usize = 64;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
17pub const MAX_DIMENSION: u32 = 4096;
19pub const MAX_DIMENSION: u32 = 4096;
18pub const MAX_FPS: u32 = 240;
20pub const MAX_FPS: u32 = 240;
19pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
21pub 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 = manifest
370 let root = manifest
344 .parent()
371 .parent()
@@ -584,6 +611,9 @@ fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectErro
584 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 = path
617 let mut temporary_name = path
588 .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!(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 #[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");
src/session.rs +239 −41
@@ -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 @@
7use std::fs;
7use std::fs;
8use std::path::{Path, PathBuf};
8use std::path::{Path, PathBuf};
9
9
10use crate::equipment::Preset;
10use serde::{Deserialize, Serialize};
11use serde::{Deserialize, Serialize};
11
12
12use crate::project::{
13use 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)]
63struct EquipmentFile {
65struct 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)]
69pub struct EquipmentItem {
71pub 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)]
76pub struct Session {
80pub 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 index
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 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
493pub const MAX_PROJECT_PRESET_BYTES: usize = 64 * 1024 * 1024;
494const MAX_LEGACY_PRESET_BYTES: usize = 64 * 1024;
495
496fn 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}
444fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
510fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
511 read_equipment_with_budget(root, presets, MAX_PROJECT_PRESET_BYTES)
512}
513fn read_equipment_with_budget(
514 root: &Path,
515 presets: &[String],
516 mut budget: usize,
517) -> Vec<EquipmentItem> {
445 presets
518 presets
446 .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_FILE
461 return EquipmentItem {
539 } else {
462 slug: parsed.name,
540 MAX_LEGACY_PRESET_BYTES
463 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 = path
559 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
486fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {
575fn 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 = budget
592 .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 = 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 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
561fn valid_slug(slug: &str) -> bool {
695fn valid_slug(slug: &str) -> bool {
562 !slug.is_empty()
696 crate::equipment::valid_name(slug)
563 && slug != "body"
564 && slug
565 .chars()
566 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
567}
697}
568
698
569pub fn manifest_in(root: &Path) -> PathBuf {
699pub fn manifest_in(root: &Path) -> PathBuf {
@@ -585,6 +715,74 @@ mod tests {
585 path
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 #[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();
tests/equipment_presets.rs added +400 −0
@@ -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 @@
1use paperdoll_renderer::equipment::{self, Preset, RigSignature, MAGIC};
2use paperdoll_renderer::preview::{self, PreviewControls};
3use std::fs;
4use std::path::PathBuf;
5
6fn fixture(name: &str) -> PathBuf {
7 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
8 .join("tests/fixtures/equipment")
9 .join(name)
10}
11fn 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}
19fn 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}
37fn 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]
48fn 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]
81fn 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]
113fn 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]
132fn 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]
156fn 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.
180fn 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]
197fn 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]
219fn 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]
238fn 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]
256fn 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]
285fn 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]
335fn 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]
361fn 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]
387fn 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}
tests/fixtures/equipment/LICENSE added +1 −0
@@ -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 @@
1CC0-1.0. These original generated equipment fixture meshes, materials and textures are dedicated to the public domain.
tests/fixtures/equipment/checker.png added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/equipment/shiny.png added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/equipment/sword.mtl added +4 −0
@@ -0,0 +1,4 @@
1 newmtl checker
2 Kd 1 1 1
3 d 1
4 map_Kd checker.png
@@ -0,0 +1,4 @@
1newmtl checker
2Kd 1 1 1
3d 1
4map_Kd checker.png
tests/fixtures/equipment/sword.obj added +15 −0
@@ -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, metres
2mtllib sword.mtl
3o blade
4v -0.055 0.00 -0.10
5v 0.055 0.00 -0.10
6v 0.055 0.58 -0.10
7v -0.055 0.58 -0.10
8vt 0 0
9vt 1 0
10vt 1 1
11vt 0 1
12vn 0 0 -1
13usemtl checker
14f 1/1/1 3/3/1 2/2/1
15f 1/1/1 4/4/1 3/3/1
tests/fixtures/equipment/sword.ppe added Binary file +0 −0

The contents of this binary file are not shown.