BlueGit

golden-age paperdoll-renderer

feat: complete portable equipment presets and animated bone attachment editor

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
9b6204a1a32bdbcbd0b1f815947f287b9eeb1df7
Parent
9f535e7f
12 files changed +627 −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 +1 −1
@@ -48,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
48 48 }
49 49 let options = eframe::NativeOptions {
50 50 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),
51 ..Default::default()
51 ..paperdoll_renderer::preview_gl::native_options()
52 52 };
53 53 eframe::run_native(
54 54 "Paperdoll Equipment",
@@ -48,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
48 }
48 }
49 let options = eframe::NativeOptions {
49 let options = eframe::NativeOptions {
50 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),
50 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),
51 ..Default::default()
51 ..paperdoll_renderer::preview_gl::native_options()
52 };
52 };
53 eframe::run_native(
53 eframe::run_native(
54 "Paperdoll Equipment",
54 "Paperdoll Equipment",
src/dialogs.rs +3 −3
@@ -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
@@ -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
src/editor.rs +63 −17
@@ -182,24 +182,32 @@ impl EditorApp {
182 182 DialogPurpose::Body(sex) => self.take_model(sex, &path),
183 183 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
184 184 DialogPurpose::EquipmentShiny(index, part) => {
185 if let Some(preset) = self
185 if let Some(mut preset) = self
186 186 .session
187 187 .equipment
188 .get_mut(index)
189 .and_then(|i| i.preset.as_mut())
188 .get(index)
189 .and_then(|i| i.preset.clone())
190 190 {
191 if let Err(error) = crate::equipment::import_shiny(preset, part, &path) {
192 self.session.fail(error);
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),
193 196 }
194 197 }
195 198 }
196 199 DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
197 200 Ok(mut preset) => {
198 let base = preset.name.clone();
199 let mut n = 2;
200 while self.session.equipment.iter().any(|i| i.slug == preset.name) {
201 preset.name = format!("{base}-{n}");
202 n += 1;
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;
203 211 }
204 212 let index = self.session.equipment.len();
205 213 self.session.equipment.push(crate::session::EquipmentItem {
@@ -250,7 +258,7 @@ impl EditorApp {
250 258 } else {
251 259 self.preview_female.as_ref()
252 260 };
253 let Some(item) = self.session.equipment.get_mut(index) else {
261 let Some(item) = self.session.equipment.get(index) else {
254 262 return;
255 263 };
256 264 let mut preset = item
@@ -265,6 +273,11 @@ impl EditorApp {
265 273 let _ = preset.bind(sex, body, part, 0);
266 274 }
267 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];
268 281 item.model = Some(preset.attachments[part].label.clone());
269 282 item.legacy_path = None;
270 283 item.error = None;
@@ -727,11 +740,23 @@ impl EditorApp {
727 740 open = Some(index);
728 741 }
729 742 }
730 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 {
731 750 self.equipment_part = 0;
732 751 self.session.add_equipment();
733 752 }
734 if ui.button("Preset importeren").clicked() {
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 {
735 760 self.dialogs.start(DialogPurpose::ImportEquipment);
736 761 }
737 762 if let Some(index) = open {
@@ -796,6 +821,18 @@ impl EditorApp {
796 821 "Kies minstens één geslacht.",
797 822 );
798 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 }
799 836 if !preset.attachments.is_empty() {
800 837 self.equipment_part =
801 838 self.equipment_part.min(preset.attachments.len() - 1);
@@ -851,10 +888,15 @@ impl EditorApp {
851 888 self.equipment_part,
852 889 ));
853 890 }
854 if ui.button("Wis shinymap").clicked() {
855 attachment.shiny = None;
856 }
891 let clear_shiny = ui.button("Wis shinymap").clicked();
857 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 }
858 900 ui.label(format!(
859 901 "Bot: {}",
860 902 selected.as_deref().unwrap_or("nog niet gekoppeld")
@@ -884,7 +926,11 @@ impl EditorApp {
884 926 );
885 927 }
886 928 if ui.button("Verwijder onderdeel").clicked() {
887 preset.attachments.remove(self.equipment_part);
929 if let Err(error) =
930 crate::equipment::remove_attachment(preset, self.equipment_part)
931 {
932 item.error = Some(error);
933 }
888 934 self.equipment_part = self.equipment_part.saturating_sub(1);
889 935 }
890 936 }
@@ -182,24 +182,32 @@ impl EditorApp {
182 DialogPurpose::Body(sex) => self.take_model(sex, &path),
182 DialogPurpose::Body(sex) => self.take_model(sex, &path),
183 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
183 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
184 DialogPurpose::EquipmentShiny(index, part) => {
184 DialogPurpose::EquipmentShiny(index, part) => {
185 if let Some(preset) = self
185 if let Some(mut preset) = self
186 .session
186 .session
187 .equipment
187 .equipment
188 .get_mut(index)
188 .get(index)
189 .and_then(|i| i.preset.as_mut())
189 .and_then(|i| i.preset.clone())
190 {
190 {
191 if let Err(error) = crate::equipment::import_shiny(preset, part, &path) {
191 let result = crate::equipment::import_shiny(&mut preset, part, &path)
192 self.session.fail(error);
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),
193 }
196 }
194 }
197 }
195 }
198 }
196 DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
199 DialogPurpose::ImportEquipment => match crate::equipment::Preset::read(&path) {
197 Ok(mut preset) => {
200 Ok(mut preset) => {
198 let base = preset.name.clone();
201 let result = self
199 let mut n = 2;
202 .session
200 while self.session.equipment.iter().any(|i| i.slug == preset.name) {
203 .available_equipment_name(&preset.name)
201 preset.name = format!("{base}-{n}");
204 .and_then(|name| {
202 n += 1;
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;
203 }
211 }
204 let index = self.session.equipment.len();
212 let index = self.session.equipment.len();
205 self.session.equipment.push(crate::session::EquipmentItem {
213 self.session.equipment.push(crate::session::EquipmentItem {
@@ -250,7 +258,7 @@ impl EditorApp {
250 } else {
258 } else {
251 self.preview_female.as_ref()
259 self.preview_female.as_ref()
252 };
260 };
253 let Some(item) = self.session.equipment.get_mut(index) else {
261 let Some(item) = self.session.equipment.get(index) else {
254 return;
262 return;
255 };
263 };
256 let mut preset = item
264 let mut preset = item
@@ -265,6 +273,11 @@ impl EditorApp {
265 let _ = preset.bind(sex, body, part, 0);
273 let _ = preset.bind(sex, body, part, 0);
266 }
274 }
267 }
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];
268 item.model = Some(preset.attachments[part].label.clone());
281 item.model = Some(preset.attachments[part].label.clone());
269 item.legacy_path = None;
282 item.legacy_path = None;
270 item.error = None;
283 item.error = None;
@@ -727,11 +740,23 @@ impl EditorApp {
727 open = Some(index);
740 open = Some(index);
728 }
741 }
729 }
742 }
730 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 {
731 self.equipment_part = 0;
750 self.equipment_part = 0;
732 self.session.add_equipment();
751 self.session.add_equipment();
733 }
752 }
734 if ui.button("Preset importeren").clicked() {
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 {
735 self.dialogs.start(DialogPurpose::ImportEquipment);
760 self.dialogs.start(DialogPurpose::ImportEquipment);
736 }
761 }
737 if let Some(index) = open {
762 if let Some(index) = open {
@@ -796,6 +821,18 @@ impl EditorApp {
796 "Kies minstens één geslacht.",
821 "Kies minstens één geslacht.",
797 );
822 );
798 }
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 }
799 if !preset.attachments.is_empty() {
836 if !preset.attachments.is_empty() {
800 self.equipment_part =
837 self.equipment_part =
801 self.equipment_part.min(preset.attachments.len() - 1);
838 self.equipment_part.min(preset.attachments.len() - 1);
@@ -851,10 +888,15 @@ impl EditorApp {
851 self.equipment_part,
888 self.equipment_part,
852 ));
889 ));
853 }
890 }
854 if ui.button("Wis shinymap").clicked() {
891 let clear_shiny = ui.button("Wis shinymap").clicked();
855 attachment.shiny = None;
856 }
857 let selected = attachment.bones[sex].clone();
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 }
858 ui.label(format!(
900 ui.label(format!(
859 "Bot: {}",
901 "Bot: {}",
860 selected.as_deref().unwrap_or("nog niet gekoppeld")
902 selected.as_deref().unwrap_or("nog niet gekoppeld")
@@ -884,7 +926,11 @@ impl EditorApp {
884 );
926 );
885 }
927 }
886 if ui.button("Verwijder onderdeel").clicked() {
928 if ui.button("Verwijder onderdeel").clicked() {
887 preset.attachments.remove(self.equipment_part);
929 if let Err(error) =
930 crate::equipment::remove_attachment(preset, self.equipment_part)
931 {
932 item.error = Some(error);
933 }
888 self.equipment_part = self.equipment_part.saturating_sub(1);
934 self.equipment_part = self.equipment_part.saturating_sub(1);
889 }
935 }
890 }
936 }
src/equipment.rs +121 −3
@@ -60,6 +60,8 @@ impl RigSignature {
60 60 #[serde(deny_unknown_fields)]
61 61 pub struct Attachment {
62 62 pub label: String,
63 /// Original OBJ/MTL/dependency payloads owned by this attachment.
64 pub source_assets: Vec<usize>,
63 65 pub bones: [Option<String>; 2],
64 66 pub translation: [f32; 3],
65 67 pub rotation_deg: [f32; 3],
@@ -151,6 +153,14 @@ impl Preset {
151 153 }
152 154 }
153 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 }
154 164 for sex in 0..2 {
155 165 if let Some(path) = &attachment.bones[sex] {
156 166 if !self.rigs[sex]
@@ -223,6 +233,24 @@ impl Preset {
223 233 .ok_or("textureverwijzing ontbreekt")?;
224 234 decode_png(&asset.bytes)
225 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 }
226 254 pub fn encode(&self) -> Result<Vec<u8>, String> {
227 255 self.validate()?;
228 256 let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;
@@ -285,6 +313,9 @@ impl Preset {
285 313 Ok(preset)
286 314 }
287 315 pub fn read(path: &Path) -> Result<Self, String> {
316 if !path.is_file() {
317 return Err("kies een regulier presetbestand".into());
318 }
288 319 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
289 320 let mut bytes = Vec::new();
290 321 file.take((MAX_FILE + 1) as u64)
@@ -321,6 +352,9 @@ pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {
321 352 return Err("snapshot te groot".into());
322 353 }
323 354 if path.exists() {
355 if !path.is_file() {
356 return Err("snapshot is geen regulier bestand".into());
357 }
324 358 let mut current = Vec::new();
325 359 std::fs::File::open(path)
326 360 .map_err(|e| e.to_string())?
@@ -348,6 +382,10 @@ pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {
348 382 .and_then(|_| file.sync_all())
349 383 .map_err(|e| e.to_string())?;
350 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())?;
351 389 Ok(())
352 390 })();
353 391 let _ = std::fs::remove_file(temp);
@@ -478,7 +516,7 @@ pub fn layers_cached(
478 516 if sex > 1 {
479 517 return Err("ongeldig geslacht".into());
480 518 }
481 if !preset.applies[sex] {
519 if !preset.applies[sex] || preset.attachments.is_empty() {
482 520 return Ok(Vec::new());
483 521 }
484 522 let signature = RigSignature::of(body);
@@ -536,7 +574,8 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
536 574 if candidate.attachments.len() >= 32 {
537 575 return Err("te veel onderdelen".into());
538 576 }
539 let prefix = format!("part-{}", candidate.attachments.len());
577 compact_assets(&mut candidate)?;
578 let prefix = unique_prefix(&candidate, "part");
540 579 let obj_bytes = read_asset(&root, name)?;
541 580 let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;
542 581 let mut dependencies = BTreeSet::new();
@@ -685,6 +724,7 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
685 724 }
686 725 candidate.attachments.push(Attachment {
687 726 label: name.to_string(),
727 source_assets: asset_map.values().copied().collect(),
688 728 bones: [None, None],
689 729 translation: [0.; 3],
690 730 rotation_deg: [0.; 3],
@@ -708,7 +748,7 @@ pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Re
708 748 let mut candidate = preset.clone();
709 749 let index = candidate.assets.len();
710 750 candidate.assets.push(EmbeddedAsset {
711 path: format!("shiny-{index}/{name}"),
751 path: format!("{}/{name}", unique_prefix(&candidate, "shiny")),
712 752 bytes,
713 753 });
714 754 candidate
@@ -716,11 +756,86 @@ pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Re
716 756 .get_mut(attachment)
717 757 .ok_or("onderdeel ontbreekt")?
718 758 .shiny = Some(index);
759 compact_assets(&mut candidate)?;
719 760 candidate.validate()?;
720 761 *preset = candidate;
721 762 Ok(())
722 763 }
723 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
724 839 fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
725 840 portable_relative(relative)?;
726 841 let root = root.canonicalize().map_err(|e| e.to_string())?;
@@ -731,6 +846,9 @@ fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
731 846 if !path.starts_with(&root) {
732 847 return Err("asset verlaat de modelmap".into());
733 848 }
849 if !path.is_file() {
850 return Err("asset is geen regulier bestand".into());
851 }
734 852 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
735 853 let mut bytes = Vec::new();
736 854 file.take((MAX_ASSET + 1) as u64)
@@ -60,6 +60,8 @@ impl RigSignature {
60#[serde(deny_unknown_fields)]
60#[serde(deny_unknown_fields)]
61pub struct Attachment {
61pub struct Attachment {
62 pub label: String,
62 pub label: String,
63 /// Original OBJ/MTL/dependency payloads owned by this attachment.
64 pub source_assets: Vec<usize>,
63 pub bones: [Option<String>; 2],
65 pub bones: [Option<String>; 2],
64 pub translation: [f32; 3],
66 pub translation: [f32; 3],
65 pub rotation_deg: [f32; 3],
67 pub rotation_deg: [f32; 3],
@@ -151,6 +153,14 @@ impl Preset {
151 }
153 }
152 }
154 }
153 for attachment in &self.attachments {
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 }
154 for sex in 0..2 {
164 for sex in 0..2 {
155 if let Some(path) = &attachment.bones[sex] {
165 if let Some(path) = &attachment.bones[sex] {
156 if !self.rigs[sex]
166 if !self.rigs[sex]
@@ -223,6 +233,24 @@ impl Preset {
223 .ok_or("textureverwijzing ontbreekt")?;
233 .ok_or("textureverwijzing ontbreekt")?;
224 decode_png(&asset.bytes)
234 decode_png(&asset.bytes)
225 }
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 }
226 pub fn encode(&self) -> Result<Vec<u8>, String> {
254 pub fn encode(&self) -> Result<Vec<u8>, String> {
227 self.validate()?;
255 self.validate()?;
228 let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;
256 let metadata = serde_json::to_vec(self).map_err(|e| e.to_string())?;
@@ -285,6 +313,9 @@ impl Preset {
285 Ok(preset)
313 Ok(preset)
286 }
314 }
287 pub fn read(path: &Path) -> Result<Self, String> {
315 pub fn read(path: &Path) -> Result<Self, String> {
316 if !path.is_file() {
317 return Err("kies een regulier presetbestand".into());
318 }
288 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
319 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
289 let mut bytes = Vec::new();
320 let mut bytes = Vec::new();
290 file.take((MAX_FILE + 1) as u64)
321 file.take((MAX_FILE + 1) as u64)
@@ -321,6 +352,9 @@ pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {
321 return Err("snapshot te groot".into());
352 return Err("snapshot te groot".into());
322 }
353 }
323 if path.exists() {
354 if path.exists() {
355 if !path.is_file() {
356 return Err("snapshot is geen regulier bestand".into());
357 }
324 let mut current = Vec::new();
358 let mut current = Vec::new();
325 std::fs::File::open(path)
359 std::fs::File::open(path)
326 .map_err(|e| e.to_string())?
360 .map_err(|e| e.to_string())?
@@ -348,6 +382,10 @@ pub fn write_snapshot(path: &Path, bytes: &[u8]) -> Result<(), String> {
348 .and_then(|_| file.sync_all())
382 .and_then(|_| file.sync_all())
349 .map_err(|e| e.to_string())?;
383 .map_err(|e| e.to_string())?;
350 std::fs::hard_link(&temp, path).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())?;
351 Ok(())
389 Ok(())
352 })();
390 })();
353 let _ = std::fs::remove_file(temp);
391 let _ = std::fs::remove_file(temp);
@@ -478,7 +516,7 @@ pub fn layers_cached(
478 if sex > 1 {
516 if sex > 1 {
479 return Err("ongeldig geslacht".into());
517 return Err("ongeldig geslacht".into());
480 }
518 }
481 if !preset.applies[sex] {
519 if !preset.applies[sex] || preset.attachments.is_empty() {
482 return Ok(Vec::new());
520 return Ok(Vec::new());
483 }
521 }
484 let signature = RigSignature::of(body);
522 let signature = RigSignature::of(body);
@@ -536,7 +574,8 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
536 if candidate.attachments.len() >= 32 {
574 if candidate.attachments.len() >= 32 {
537 return Err("te veel onderdelen".into());
575 return Err("te veel onderdelen".into());
538 }
576 }
539 let prefix = format!("part-{}", candidate.attachments.len());
577 compact_assets(&mut candidate)?;
578 let prefix = unique_prefix(&candidate, "part");
540 let obj_bytes = read_asset(&root, name)?;
579 let obj_bytes = read_asset(&root, name)?;
541 let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;
580 let obj_text = std::str::from_utf8(&obj_bytes).map_err(|e| e.to_string())?;
542 let mut dependencies = BTreeSet::new();
581 let mut dependencies = BTreeSet::new();
@@ -685,6 +724,7 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
685 }
724 }
686 candidate.attachments.push(Attachment {
725 candidate.attachments.push(Attachment {
687 label: name.to_string(),
726 label: name.to_string(),
727 source_assets: asset_map.values().copied().collect(),
688 bones: [None, None],
728 bones: [None, None],
689 translation: [0.; 3],
729 translation: [0.; 3],
690 rotation_deg: [0.; 3],
730 rotation_deg: [0.; 3],
@@ -708,7 +748,7 @@ pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Re
708 let mut candidate = preset.clone();
748 let mut candidate = preset.clone();
709 let index = candidate.assets.len();
749 let index = candidate.assets.len();
710 candidate.assets.push(EmbeddedAsset {
750 candidate.assets.push(EmbeddedAsset {
711 path: format!("shiny-{index}/{name}"),
751 path: format!("{}/{name}", unique_prefix(&candidate, "shiny")),
712 bytes,
752 bytes,
713 });
753 });
714 candidate
754 candidate
@@ -716,11 +756,86 @@ pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Re
716 .get_mut(attachment)
756 .get_mut(attachment)
717 .ok_or("onderdeel ontbreekt")?
757 .ok_or("onderdeel ontbreekt")?
718 .shiny = Some(index);
758 .shiny = Some(index);
759 compact_assets(&mut candidate)?;
719 candidate.validate()?;
760 candidate.validate()?;
720 *preset = candidate;
761 *preset = candidate;
721 Ok(())
762 Ok(())
722}
763}
723
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
724fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
839fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
725 portable_relative(relative)?;
840 portable_relative(relative)?;
726 let root = root.canonicalize().map_err(|e| e.to_string())?;
841 let root = root.canonicalize().map_err(|e| e.to_string())?;
@@ -731,6 +846,9 @@ fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {
731 if !path.starts_with(&root) {
846 if !path.starts_with(&root) {
732 return Err("asset verlaat de modelmap".into());
847 return Err("asset verlaat de modelmap".into());
733 }
848 }
849 if !path.is_file() {
850 return Err("asset is geen regulier bestand".into());
851 }
734 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
852 let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
735 let mut bytes = Vec::new();
853 let mut bytes = Vec::new();
736 file.take((MAX_ASSET + 1) as u64)
854 file.take((MAX_ASSET + 1) as u64)
src/preview_gl.rs +83 −17
@@ -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.
@@ -740,6 +740,13 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
740 740 bones: vec![crate::preview::identity()],
741 741 view_proj: crate::preview::identity(),
742 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(),
743 750 };
744 751 let mut cache = GpuCache::default();
745 752 let read = || {
@@ -761,40 +768,99 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
761 768 gl.clear_depth_f32(0.0);
762 769 gl.depth_func(glow::GREATER);
763 770 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
764 cache.paint(gl, &info);
771 cache.paint(gl, &info, true);
765 772 let near = read();
766 773 if near[..3].iter().any(|c| *c < 250) {
767 774 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
768 775 }
769 776 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
770 cache.paint(gl, &info);
777 cache.paint(gl, &info, true);
771 778 let next = read();
772 779 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
773 780 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
774 781 }
775 782 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
776 cache.paint(gl, &info);
783 cache.paint(gl, &info, true);
777 784 let reverse = read();
778 785 if near != reverse {
779 786 return Err(format!(
780 787 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
781 788 ));
782 789 }
783 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:?}");
784 861 Ok(())
785 862 })();
786 if let Some(p) = cache.program {
787 gl.delete_program(p)
788 }
789 if let Some(v) = cache.vao {
790 gl.delete_vertex_array(v)
791 }
792 if let Some(v) = cache.vbo {
793 gl.delete_buffer(v)
794 }
795 if let Some(v) = cache.ebo {
796 gl.delete_buffer(v)
797 }
863 cache.destroy(gl);
798 864 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
799 865 gl.delete_framebuffer(framebuffer);
800 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.
@@ -740,6 +740,13 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
740 bones: vec![crate::preview::identity()],
740 bones: vec![crate::preview::identity()],
741 view_proj: crate::preview::identity(),
741 view_proj: crate::preview::identity(),
742 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(),
743 };
750 };
744 let mut cache = GpuCache::default();
751 let mut cache = GpuCache::default();
745 let read = || {
752 let read = || {
@@ -761,40 +768,99 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
761 gl.clear_depth_f32(0.0);
768 gl.clear_depth_f32(0.0);
762 gl.depth_func(glow::GREATER);
769 gl.depth_func(glow::GREATER);
763 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));
764 cache.paint(gl, &info);
771 cache.paint(gl, &info, true);
765 let near = read();
772 let near = read();
766 if near[..3].iter().any(|c| *c < 250) {
773 if near[..3].iter().any(|c| *c < 250) {
767 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
774 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
768 }
775 }
769 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
776 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
770 cache.paint(gl, &info);
777 cache.paint(gl, &info, true);
771 let next = read();
778 let next = read();
772 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
779 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
773 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
780 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
774 }
781 }
775 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));
776 cache.paint(gl, &info);
783 cache.paint(gl, &info, true);
777 let reverse = read();
784 let reverse = read();
778 if near != reverse {
785 if near != reverse {
779 return Err(format!(
786 return Err(format!(
780 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
787 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
781 ));
788 ));
782 }
789 }
783 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:?}");
784 Ok(())
861 Ok(())
785 })();
862 })();
786 if let Some(p) = cache.program {
863 cache.destroy(gl);
787 gl.delete_program(p)
788 }
789 if let Some(v) = cache.vao {
790 gl.delete_vertex_array(v)
791 }
792 if let Some(v) = cache.vbo {
793 gl.delete_buffer(v)
794 }
795 if let Some(v) = cache.ebo {
796 gl.delete_buffer(v)
797 }
798 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
864 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
799 gl.delete_framebuffer(framebuffer);
865 gl.delete_framebuffer(framebuffer);
800 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 +197 −59
@@ -61,6 +61,7 @@ pub enum AfterChoice {
61 61 }
62 62
63 63 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64 #[serde(deny_unknown_fields)]
64 65 struct EquipmentFile {
65 66 name: String,
66 67 model: Option<String>,
@@ -230,6 +231,42 @@ impl Session {
230 231 index
231 232 }
232 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
233 270 pub fn set_equipment_slug(&mut self, index: usize, slug: String) -> Result<(), String> {
234 271 let slug = slug.trim().to_string();
235 272 if !valid_slug(&slug) {
@@ -453,24 +490,62 @@ fn io_at(path: &Path, message: &str) -> ProjectError {
453 490 }
454 491 }
455 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 }
456 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> {
457 518 presets
458 519 .iter()
459 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 };
460 532 let file = match checked_project_path(root, path) {
461 533 Ok(path) => path,
462 Err(_) => {
463 return EquipmentItem {
464 preset: None,
465 error: Some("onveilig presetpad".into()),
466 slug: "onveilig".into(),
467 model: None,
468 legacy_path: Some(path.clone()),
469 }
470 }
534 Err(error) => return failure(error),
535 };
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),
471 545 };
472 if file.extension().and_then(|e| e.to_str()) == Some("ppe") {
473 return match Preset::read(&file) {
546 budget -= bytes.len();
547 if binary {
548 return match Preset::decode(&bytes) {
474 549 Ok(preset) => EquipmentItem {
475 550 slug: preset.name.clone(),
476 551 model: preset.attachments.first().map(|a| a.label.clone()),
@@ -478,52 +553,30 @@ fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
478 553 error: None,
479 554 legacy_path: None,
480 555 },
481 Err(error) => EquipmentItem {
482 slug: file
483 .file_stem()
484 .unwrap_or_default()
485 .to_string_lossy()
486 .into(),
487 model: None,
488 preset: None,
489 error: Some(error),
490 legacy_path: Some(path.clone()),
491 },
556 Err(error) => failure(error),
492 557 };
493 558 }
494 if let Ok(text) = fs::read_to_string(&file) {
495 if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {
496 if valid_slug(&parsed.name) {
497 return EquipmentItem {
498 preset: None,
499 error: None,
500 slug: parsed.name,
501 model: parsed.model,
502 legacy_path: None,
503 };
504 }
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 };
505 568 }
506 569 }
507 let slug = path
508 .rsplit('/')
509 .next()
510 .unwrap_or(path)
511 .split('.')
512 .next()
513 .unwrap_or("item")
514 .to_string();
515 EquipmentItem {
516 preset: None,
517 error: Some("legacy preset; kies een OBJ om te configureren".into()),
518 slug,
519 model: None,
520 legacy_path: Some(path.clone()),
521 }
570 failure("legacy preset; kies een OBJ om te configureren".into())
522 571 })
523 572 .collect()
524 573 }
525 574
526 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;
527 580 let mut paths = Vec::new();
528 581 let mut names = std::collections::BTreeSet::new();
529 582 // Validate every record before writing. Config files are immutable snapshots:
@@ -532,7 +585,12 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
532 585 if !names.insert(&item.slug) {
533 586 return Err("dubbele equipmentnaam".into());
534 587 }
535 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")?;
536 594 continue;
537 595 }
538 596 if !valid_slug(&item.slug) {
@@ -541,9 +599,25 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
541 599 if let Some(preset) = &item.preset {
542 600 let mut preset = preset.clone();
543 601 preset.name = item.slug.clone();
544 preset.validate()?;
545 } else if let Some(model) = &item.model {
546 portable_relative(model)?;
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")?;
547 621 }
548 622 }
549 623 for item in items {
@@ -578,8 +652,8 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
578 652 loop {
579 653 let relative = format!("presets/{}-{number}.json", item.slug);
580 654 let path = checked_project_path(root, &relative)?;
581 if let Ok(existing) = fs::read_to_string(&path) {
582 if existing == text {
655 if let Ok(existing) = read_limited(&path, MAX_LEGACY_PRESET_BYTES) {
656 if existing == text.as_bytes() {
583 657 paths.push(relative);
584 658 break;
585 659 }
@@ -619,11 +693,7 @@ fn next_slug(items: &[EquipmentItem]) -> String {
619 693 }
620 694
621 695 fn valid_slug(slug: &str) -> bool {
622 !slug.is_empty()
623 && slug != "body"
624 && slug
625 .chars()
626 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
696 crate::equipment::valid_name(slug)
627 697 }
628 698
629 699 pub fn manifest_in(root: &Path) -> PathBuf {
@@ -645,6 +715,74 @@ mod tests {
645 715 path
646 716 }
647 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
648 786 #[test]
649 787 fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
650 788 let root = scratch();
@@ -61,6 +61,7 @@ pub enum AfterChoice {
61}
61}
62
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
64struct EquipmentFile {
65struct EquipmentFile {
65 name: String,
66 name: String,
66 model: Option<String>,
67 model: Option<String>,
@@ -230,6 +231,42 @@ impl Session {
230 index
231 index
231 }
232 }
232
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
233 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> {
234 let slug = slug.trim().to_string();
271 let slug = slug.trim().to_string();
235 if !valid_slug(&slug) {
272 if !valid_slug(&slug) {
@@ -453,24 +490,62 @@ fn io_at(path: &Path, message: &str) -> ProjectError {
453 }
490 }
454}
491}
455
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}
456fn 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> {
457 presets
518 presets
458 .iter()
519 .iter()
459 .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 };
460 let file = match checked_project_path(root, path) {
532 let file = match checked_project_path(root, path) {
461 Ok(path) => path,
533 Ok(path) => path,
462 Err(_) => {
534 Err(error) => return failure(error),
463 return EquipmentItem {
535 };
464 preset: None,
536 let binary = file.extension().and_then(|e| e.to_str()) == Some("ppe");
465 error: Some("onveilig presetpad".into()),
537 let limit = budget.min(if binary {
466 slug: "onveilig".into(),
538 crate::equipment::MAX_FILE
467 model: None,
539 } else {
468 legacy_path: Some(path.clone()),
540 MAX_LEGACY_PRESET_BYTES
469 }
541 });
470 }
542 let bytes = match read_limited(&file, limit) {
543 Ok(bytes) => bytes,
544 Err(error) => return failure(error),
471 };
545 };
472 if file.extension().and_then(|e| e.to_str()) == Some("ppe") {
546 budget -= bytes.len();
473 return match Preset::read(&file) {
547 if binary {
548 return match Preset::decode(&bytes) {
474 Ok(preset) => EquipmentItem {
549 Ok(preset) => EquipmentItem {
475 slug: preset.name.clone(),
550 slug: preset.name.clone(),
476 model: preset.attachments.first().map(|a| a.label.clone()),
551 model: preset.attachments.first().map(|a| a.label.clone()),
@@ -478,52 +553,30 @@ fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {
478 error: None,
553 error: None,
479 legacy_path: None,
554 legacy_path: None,
480 },
555 },
481 Err(error) => EquipmentItem {
556 Err(error) => failure(error),
482 slug: file
483 .file_stem()
484 .unwrap_or_default()
485 .to_string_lossy()
486 .into(),
487 model: None,
488 preset: None,
489 error: Some(error),
490 legacy_path: Some(path.clone()),
491 },
492 };
557 };
493 }
558 }
494 if let Ok(text) = fs::read_to_string(&file) {
559 if let Ok(parsed) = serde_json::from_slice::<EquipmentFile>(&bytes) {
495 if let Ok(parsed) = serde_json::from_str::<EquipmentFile>(&text) {
560 if valid_slug(&parsed.name) {
496 if valid_slug(&parsed.name) {
561 return EquipmentItem {
497 return EquipmentItem {
562 preset: None,
498 preset: None,
563 error: None,
499 error: None,
564 slug: parsed.name,
500 slug: parsed.name,
565 model: parsed.model,
501 model: parsed.model,
566 legacy_path: None,
502 legacy_path: None,
567 };
503 };
504 }
505 }
568 }
506 }
569 }
507 let slug = path
570 failure("legacy preset; kies een OBJ om te configureren".into())
508 .rsplit('/')
509 .next()
510 .unwrap_or(path)
511 .split('.')
512 .next()
513 .unwrap_or("item")
514 .to_string();
515 EquipmentItem {
516 preset: None,
517 error: Some("legacy preset; kies een OBJ om te configureren".into()),
518 slug,
519 model: None,
520 legacy_path: Some(path.clone()),
521 }
522 })
571 })
523 .collect()
572 .collect()
524}
573}
525
574
526fn 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;
527 let mut paths = Vec::new();
580 let mut paths = Vec::new();
528 let mut names = std::collections::BTreeSet::new();
581 let mut names = std::collections::BTreeSet::new();
529 // Validate every record before writing. Config files are immutable snapshots:
582 // Validate every record before writing. Config files are immutable snapshots:
@@ -532,7 +585,12 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
532 if !names.insert(&item.slug) {
585 if !names.insert(&item.slug) {
533 return Err("dubbele equipmentnaam".into());
586 return Err("dubbele equipmentnaam".into());
534 }
587 }
535 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")?;
536 continue;
594 continue;
537 }
595 }
538 if !valid_slug(&item.slug) {
596 if !valid_slug(&item.slug) {
@@ -541,9 +599,25 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
541 if let Some(preset) = &item.preset {
599 if let Some(preset) = &item.preset {
542 let mut preset = preset.clone();
600 let mut preset = preset.clone();
543 preset.name = item.slug.clone();
601 preset.name = item.slug.clone();
544 preset.validate()?;
602 let length = preset.encode()?.len();
545 } else if let Some(model) = &item.model {
603 budget = budget
546 portable_relative(model)?;
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")?;
547 }
621 }
548 }
622 }
549 for item in items {
623 for item in items {
@@ -578,8 +652,8 @@ fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>,
578 loop {
652 loop {
579 let relative = format!("presets/{}-{number}.json", item.slug);
653 let relative = format!("presets/{}-{number}.json", item.slug);
580 let path = checked_project_path(root, &relative)?;
654 let path = checked_project_path(root, &relative)?;
581 if let Ok(existing) = fs::read_to_string(&path) {
655 if let Ok(existing) = read_limited(&path, MAX_LEGACY_PRESET_BYTES) {
582 if existing == text {
656 if existing == text.as_bytes() {
583 paths.push(relative);
657 paths.push(relative);
584 break;
658 break;
585 }
659 }
@@ -619,11 +693,7 @@ fn next_slug(items: &[EquipmentItem]) -> String {
619}
693}
620
694
621fn valid_slug(slug: &str) -> bool {
695fn valid_slug(slug: &str) -> bool {
622 !slug.is_empty()
696 crate::equipment::valid_name(slug)
623 && slug != "body"
624 && slug
625 .chars()
626 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
627}
697}
628
698
629pub fn manifest_in(root: &Path) -> PathBuf {
699pub fn manifest_in(root: &Path) -> PathBuf {
@@ -645,6 +715,74 @@ mod tests {
645 path
715 path
646 }
716 }
647
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
648 #[test]
786 #[test]
649 fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
787 fn new_project_copies_both_test_bodies_and_can_be_overwritten() {
650 let root = scratch();
788 let root = scratch();
tests/equipment_presets.rs +52 −0
@@ -49,6 +49,7 @@ fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() {
49 49 let p = preset();
50 50 let bytes = p.encode().unwrap();
51 51 assert_eq!(&bytes[..8], MAGIC);
52 assert_eq!(p.encoded_len().unwrap(), bytes.len());
52 53 let restored = Preset::decode(&bytes).unwrap();
53 54 assert_eq!(restored, p);
54 55 assert_eq!(restored.assets.len(), 4);
@@ -116,6 +117,15 @@ fn local_euler_order_and_translation_are_not_accumulated() {
116 117 assert!((a - b).abs() < 1e-5);
117 118 }
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 }
119 129 }
120 130
121 131 #[test]
@@ -346,3 +356,45 @@ fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {
346 356 }
347 357 assert!(Preset::decode(&untrusted(&p)).is_err());
348 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 }
@@ -49,6 +49,7 @@ fn binary_roundtrip_embeds_model_material_textures_and_both_rigs() {
49 let p = preset();
49 let p = preset();
50 let bytes = p.encode().unwrap();
50 let bytes = p.encode().unwrap();
51 assert_eq!(&bytes[..8], MAGIC);
51 assert_eq!(&bytes[..8], MAGIC);
52 assert_eq!(p.encoded_len().unwrap(), bytes.len());
52 let restored = Preset::decode(&bytes).unwrap();
53 let restored = Preset::decode(&bytes).unwrap();
53 assert_eq!(restored, p);
54 assert_eq!(restored, p);
54 assert_eq!(restored.assets.len(), 4);
55 assert_eq!(restored.assets.len(), 4);
@@ -116,6 +117,15 @@ fn local_euler_order_and_translation_are_not_accumulated() {
116 assert!((a - b).abs() < 1e-5);
117 assert!((a - b).abs() < 1e-5);
117 }
118 }
118 assert_eq!(m, equipment::local_matrix([1., 2., 3.], [0., 0., 90.]));
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 }
119}
129}
120
130
121#[test]
131#[test]
@@ -346,3 +356,45 @@ fn unsupported_texture_dimensions_and_asset_counts_are_bounded() {
346 }
356 }
347 assert!(Preset::decode(&untrusted(&p)).is_err());
357 assert!(Preset::decode(&untrusted(&p)).is_err());
348}
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/sword.ppe Binary file +0 −0

The contents of this binary file are not shown.