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

35
53
36
54
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
37
55
@@ -4,7 +4,7 @@ Desktopeditor voor `.ppd`-projecten, modelimport en een geanimeerde 3D-preview.4
4
5
## Starten5
## Starten6
6
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity.7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity. Live 3D vereist OpenGL 3.3.8
8
9
```bash9
```bash10
cargo run10
cargo run@@ -31,7 +31,25 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert31
31
32
## Equipment en opslaan32
## Equipment en opslaan33
33
34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**. De configuratie bewaart een unieke naam (kleine letters, cijfers, `-` en `_`) en een gekozen model. **Verwijder** verwijdert de definitie uit de projectlijst. **Opslaan** bewaart het project en equipment-configuraties in `presets/`.34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.35
36
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.37
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.38
3. **Onderdeel toevoegen (OBJ)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.39
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.40
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.41
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.42
43
**Preset importeren** leest een `.ppe` uit bijvoorbeeld een ander project. Bij een andere rig moet je de botten opnieuw kiezen; er wordt niet stil geretarget. Onvolledige configuraties kunnen worden opgeslagen, maar onopgeloste onderdelen worden niet op een gegist bot getekend. **Verwijder onderdeel** haalt een onderdeel uit het werkpreset; **Verwijder equipmentdefinitie** haalt de definitie uit de projectlijst. Oude project-snapshots blijven voor herstel bewaard. Opslaan vereist een bestandssysteem met hardlinks; bij een fout wordt een bestaande snapshot niet overschreven.44
45
Een preset is begrensd op 32 MiB, 32 onderdelen/bestanden en textures van maximaal 2048×2048. Een project heeft maximaal 64 presets en 64 MiB aan presetbestanden. De volledige containerbeschrijving staat in `design.md`.46
47
```bash48
# Zelfbevattend CC0-zwaardvoorbeeld in een nieuw tijdelijk project49
cargo run --example equipment_fixture --locked50
```51
52
35
53
36
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.54
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.37
55
@@ -106,6 +106,8 @@ Use orthographic projection for aligned layering. Shared camera framing, pivot,
106
106
107
107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.
108
108
109
**Confirmed production Render behavior (next feature after equipment):** enumerate **all animation clips × all 36 angles × every frame sampled at project FPS**, processing one pose/view and its real passes/maps at a time. Do not stop at the currently selected preview clip, angle or frame. Deterministic proposed nesting is source clip order → pitch -30/0/+30 → yaw 0…330 → frame → pass/map order from §8. Replace the preview after each completed real pass, immediately and without added delays. This is a full production batch requirement; the currently shipped Render action is still explicitly a single shaded body still, not this batch.
110
109
111
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.
110
112
111
113
## 7. Export naming and metadata
@@ -139,11 +141,29 @@ Equipment pass hides the body from **all color, depth, normal and specular write
139
141
140
142
## 9. Equipment editor and binary presets
141
143
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.
144
The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import, deformation/weight painting and a rendered 2D compositor are not implemented by this feature.
145
146
Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
147
148
The equipment configuration draws the body plus all meshes in the selected preset once its attachments are configured through the actual Glow callback, sharing the native 24-bit depth attachment. Clear once for the body, never between equipment meshes; depth test/write makes self/body occlusion independent of submission order. Orbit/zoom and body animations continue to work. A grayscale/luminance PNG shinymap multiplies the scalar specular strength in placement shading; this is not a baked production shiny output map. The real GPU probe tests body/equipment front/back ordering, a sampled red albedo texture, alpha discard, and white-versus-black shinymap highlights. The generated CC0 sword OBJ/MTL/checker/shiny fixture and binary golden file are under `tests/fixtures/equipment/`; the example opens an actual seeded project, not a screenshot mock.
149
150
### Implemented `.ppe` container, version 1
151
152
All integers below are unsigned little-endian. The container is uncompressed (no Rust memory-layout serialization and no decompression stage):
153
154
| Bytes | Meaning |
155
|---|---|
156
| 0–7 | Exact magic `PPEQ\r\n\x1a\n` |
157
| 8–11 | Schema version, exactly 1 |
158
| 12–15 | UTF-8 JSON metadata length |
159
| 16–19 | IEEE CRC32 of the **entire payload** after byte 19 |
160
| 20… | Bounded metadata, then one `u32 length + raw bytes` chunk per asset in declared order |
161
162
Metadata stores the validated name, provenance, `[male, female]` applicability flags and optional rig signatures, parts with per-sex bone paths/XYZ transforms/scalar specular/shiny index, normalized meshes (positions/normals/UVs/u32 indices/material diffuse/albedo index), and logical asset names. Asset bytes are omitted from JSON and carried in their binary chunks. Each part explicitly owns the indices of its original OBJ/MTL/declared dependency payloads. Removing a part or clearing/replacing a shiny override compacts unused assets and remaps all retained indices; adding another part cannot collide with an existing logical prefix. The reader rejects unknown fields/versions, truncation, checksum mismatches, trailing bytes, invalid links/numerics/geometry and excessive resources. CRC is corruption detection, **not authentication**.
143
163
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.
164
Limits: 32 MiB per complete container; 4 MiB metadata; 32 attachments; 32 raw assets of at most 8 MiB each; 80,000 total normalized vertices; bounded mesh/index counts; textures at most 2048×2048 and 64 MiB decoded total per preset. The shared CPU texture cache is capped at 128 MiB. Logical asset names are portable relative paths of at most 240 bytes; imports canonicalize dependencies within the chosen model directory and reject traversal/absolute references and symlink escapes. Assets are never extracted to disk on decode. Provenance is at most 4096 UTF-8 bytes; names are unique lowercase ASCII slugs of at most 64 bytes, excluding `body`. Imported duplicate names get a valid bounded suffix. Projects allow at most 64 presets, 64 MiB aggregate encoded preset data, a 4 MiB manifest and 64 KiB legacy JSON records; aggregate checks run before installing imports and before writing saved snapshots, and bounded reads preserve unloaded/error references rather than dropping them.
145
165
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.
166
Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` snapshots, then atomically saves the `.ppd` references. Flush a unique sibling temporary file, publish with a no-overwrite hardlink, and flush the directory on Unix. A filesystem without hardlink support fails safely; no unsafe overwrite fallback. Existing identical snapshots are reused, conflicting bytes never overwrite an old snapshot. Failure of the final manifest save leaves old references and bytes intact; unreferenced snapshots remain recoverable. Earlier JSON equipment records remain readable and preserved until replaced with a configured binary preset. New snapshots round-trip without consulting external model/texture files.
147
167
148
168
## 10. Rendered 2D preview
149
169
@@ -184,7 +204,7 @@ These requirements are durable even where the implementation is staged. They sup
184
204
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
185
205
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
186
206
187
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
207
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Stage 4 equipment authoring is implemented as described in §9, with binary embedded OBJ presets and actual animated bone-attached placement preview; legacy JSON records remain readable. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
188
208
189
209
## 12. Incremental feature plan and acceptance gates
190
210
@@ -106,6 +106,8 @@ Use orthographic projection for aligned layering. Shared camera framing, pivot,106
106
107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.107
**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.108
108
109
**Confirmed production Render behavior (next feature after equipment):** enumerate **all animation clips × all 36 angles × every frame sampled at project FPS**, processing one pose/view and its real passes/maps at a time. Do not stop at the currently selected preview clip, angle or frame. Deterministic proposed nesting is source clip order → pitch -30/0/+30 → yaw 0…330 → frame → pass/map order from §8. Replace the preview after each completed real pass, immediately and without added delays. This is a full production batch requirement; the currently shipped Render action is still explicitly a single shaded body still, not this batch.110
109
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.111
The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.110
112
111
## 7. Export naming and metadata113
## 7. Export naming and metadata@@ -139,11 +141,29 @@ Equipment pass hides the body from **all color, depth, normal and specular write139
141
140
## 9. Equipment editor and binary presets142
## 9. Equipment editor and binary presets141
143
142
UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.144
The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import, deformation/weight painting and a rendered 2D compositor are not implemented by this feature.145
146
Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.147
148
The equipment configuration draws the body plus all meshes in the selected preset once its attachments are configured through the actual Glow callback, sharing the native 24-bit depth attachment. Clear once for the body, never between equipment meshes; depth test/write makes self/body occlusion independent of submission order. Orbit/zoom and body animations continue to work. A grayscale/luminance PNG shinymap multiplies the scalar specular strength in placement shading; this is not a baked production shiny output map. The real GPU probe tests body/equipment front/back ordering, a sampled red albedo texture, alpha discard, and white-versus-black shinymap highlights. The generated CC0 sword OBJ/MTL/checker/shiny fixture and binary golden file are under `tests/fixtures/equipment/`; the example opens an actual seeded project, not a screenshot mock.149
150
### Implemented `.ppe` container, version 1151
152
All integers below are unsigned little-endian. The container is uncompressed (no Rust memory-layout serialization and no decompression stage):153
154
| Bytes | Meaning |155
|---|---|156
| 0–7 | Exact magic `PPEQ\r\n\x1a\n` |157
| 8–11 | Schema version, exactly 1 |158
| 12–15 | UTF-8 JSON metadata length |159
| 16–19 | IEEE CRC32 of the **entire payload** after byte 19 |160
| 20… | Bounded metadata, then one `u32 length + raw bytes` chunk per asset in declared order |161
162
Metadata stores the validated name, provenance, `[male, female]` applicability flags and optional rig signatures, parts with per-sex bone paths/XYZ transforms/scalar specular/shiny index, normalized meshes (positions/normals/UVs/u32 indices/material diffuse/albedo index), and logical asset names. Asset bytes are omitted from JSON and carried in their binary chunks. Each part explicitly owns the indices of its original OBJ/MTL/declared dependency payloads. Removing a part or clearing/replacing a shiny override compacts unused assets and remaps all retained indices; adding another part cannot collide with an existing logical prefix. The reader rejects unknown fields/versions, truncation, checksum mismatches, trailing bytes, invalid links/numerics/geometry and excessive resources. CRC is corruption detection, **not authentication**.143
163
144
Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. The current viewport draws the body glTF only; equipment attachment and the orthographic export camera are later work, recorded here rather than in the README. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.164
Limits: 32 MiB per complete container; 4 MiB metadata; 32 attachments; 32 raw assets of at most 8 MiB each; 80,000 total normalized vertices; bounded mesh/index counts; textures at most 2048×2048 and 64 MiB decoded total per preset. The shared CPU texture cache is capped at 128 MiB. Logical asset names are portable relative paths of at most 240 bytes; imports canonicalize dependencies within the chosen model directory and reject traversal/absolute references and symlink escapes. Assets are never extracted to disk on decode. Provenance is at most 4096 UTF-8 bytes; names are unique lowercase ASCII slugs of at most 64 bytes, excluding `body`. Imported duplicate names get a valid bounded suffix. Projects allow at most 64 presets, 64 MiB aggregate encoded preset data, a 4 MiB manifest and 64 KiB legacy JSON records; aggregate checks run before installing imports and before writing saved snapshots, and bounded reads preserve unloaded/error references rather than dropping them.145
165
146
Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.166
Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` snapshots, then atomically saves the `.ppd` references. Flush a unique sibling temporary file, publish with a no-overwrite hardlink, and flush the directory on Unix. A filesystem without hardlink support fails safely; no unsafe overwrite fallback. Existing identical snapshots are reused, conflicting bytes never overwrite an old snapshot. Failure of the final manifest save leaves old references and bytes intact; unreferenced snapshots remain recoverable. Earlier JSON equipment records remain readable and preserved until replaced with a configured binary preset. New snapshots round-trip without consulting external model/texture files.147
167
148
## 10. Rendered 2D preview168
## 10. Rendered 2D preview149
169
@@ -184,7 +204,7 @@ These requirements are durable even where the implementation is staged. They sup184
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.204
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.185
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.205
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.186
206
187
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.207
Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Stage 4 equipment authoring is implemented as described in §9, with binary embedded OBJ presets and actual animated bone-attached placement preview; legacy JSON records remain readable. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.188
208
189
## 12. Incremental feature plan and acceptance gates209
## 12. Incremental feature plan and acceptance gates190
210
The contents of this binary file are not shown.
@@ -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",@@ -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 backend3
//! The native picker runs on a dedicated request thread; `poll` never waits.4
//! that returns before the choice is ready reports [`DialogPoll::Pending`]4
//! [`DialogPoll::Pending`] keeps the editor repainting until an answer arrives.5
//! and the editor keeps asking.5
//! Scripted backends exercise the same request/result boundary in tests.6
6
7
use std::path::{Path, PathBuf};7
use std::path::{Path, PathBuf};8
8
@@ -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) = self185
if let Some(mut preset) = self186
.session186
.session187
.equipment187
.equipment188
.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 = self199
let mut n = 2;202
.session200
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 = item264
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 ui744
.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 ui754
.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
}@@ -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)]61
pub struct Attachment {61
pub 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_ASSETS157
|| attachment158
.source_assets159
.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 = self243
.assets244
.iter()245
.try_fold(20 + meta, |size, a| {246
size.checked_add(4)?.checked_add(a.bytes.len())247
})248
.ok_or("preset te groot")?;249
if size > MAX_FILE {250
return Err("preset te groot".into());251
}252
Ok(size)253
}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) -> Re708
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
candidate754
candidate@@ -716,11 +756,86 @@ pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Re716
.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
765
fn unique_prefix(preset: &Preset, kind: &str) -> String {766
(0..)767
.map(|i| format!("{kind}-{i}"))768
.find(|prefix| {769
!preset770
.assets771
.iter()772
.any(|a| a.path.split('/').next() == Some(prefix.as_str()))773
})774
.expect("finite asset list")775
}776
fn compact_assets(preset: &mut Preset) -> Result<(), String> {777
let used: BTreeSet<usize> = preset778
.attachments779
.iter()780
.flat_map(|a| {781
a.source_assets782
.iter()783
.copied()784
.chain(a.shiny)785
.chain(a.meshes.iter().filter_map(|m| m.albedo))786
})787
.collect();788
if used.iter().any(|i| *i >= preset.assets.len()) {789
return Err("ongeldige assetverwijzing".into());790
}791
let mut remap = vec![0; preset.assets.len()];792
let mut assets = Vec::new();793
for (i, asset) in preset.assets.iter().enumerate() {794
if used.contains(&i) {795
remap[i] = assets.len();796
assets.push(asset.clone());797
}798
}799
for a in &mut preset.attachments {800
for i in &mut a.source_assets {801
*i = remap[*i];802
}803
if let Some(i) = a.shiny.as_mut() {804
*i = remap[*i];805
}806
for mesh in &mut a.meshes {807
if let Some(i) = mesh.albedo.as_mut() {808
*i = remap[*i];809
}810
}811
}812
preset.assets = assets;813
Ok(())814
}815
/// Clear an override and release its no-longer-used embedded texture.816
pub fn clear_shiny(preset: &mut Preset, index: usize) -> Result<(), String> {817
let mut candidate = preset.clone();818
candidate819
.attachments820
.get_mut(index)821
.ok_or("onderdeel ontbreekt")?822
.shiny = None;823
compact_assets(&mut candidate)?;824
*preset = candidate;825
Ok(())826
}827
/// Remove a part and its no-longer-used embedded assets, remapping all indices.828
pub fn remove_attachment(preset: &mut Preset, index: usize) -> Result<(), String> {829
if index >= preset.attachments.len() {830
return Err("onderdeel ontbreekt".into());831
}832
let mut candidate = preset.clone();833
candidate.attachments.remove(index);834
compact_assets(&mut candidate)?;835
*preset = candidate;836
Ok(())837
}838
724
fn read_asset(root: &Path, relative: &str) -> Result<Vec<u8>, String> {839
fn 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)@@ -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 paperdoll3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll4
//! 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);@@ -14,6 +14,8 @@ use serde_json::Value;
14
14
15
15
pub const FORMAT_VERSION: u32 = 1;
16
16
pub const MANIFEST_NAME: &str = "character.ppd";
17
pub const MAX_PROJECT_PRESETS: usize = 64;
18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
17
19
pub const MAX_DIMENSION: u32 = 4096;
18
20
pub const MAX_FPS: u32 = 240;
19
21
pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;
@@ -164,6 +166,11 @@ impl ProjectFile {
164
166
&mut issues,
165
167
);
166
168
let mut seen = std::collections::BTreeSet::new();
169
if self.equipment_presets.len() > MAX_PROJECT_PRESETS {
170
issues.push(format!(
171
"maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project"
172
));
173
}
167
174
for preset in &self.equipment_presets {
168
175
match portable_relative(preset) {
169
176
Ok(clean) => {
@@ -332,13 +339,33 @@ pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {
332
339
if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {
333
340
return Err(io_error(manifest, "kies een .ppd-bestand"));
334
341
}
335
let text = fs::read_to_string(manifest).map_err(|err| {
342
use std::io::Read;
343
let metadata = fs::metadata(manifest).map_err(|err| {
336
344
io_error(
337
345
manifest,
338
346
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),
339
347
)
340
348
})?;
341
let document = parse_manifest(&text)?;
349
if !metadata.is_file() {
350
return Err(io_error(manifest, "kies een regulier .ppd-bestand"));
351
}
352
let mut bytes = Vec::new();
353
fs::File::open(manifest)
354
.and_then(|file| {
355
file.take((MAX_MANIFEST_BYTES + 1) as u64)
356
.read_to_end(&mut bytes)
357
})
358
.map_err(|err| {
359
io_error(
360
manifest,
361
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),
362
)
363
})?;
364
if bytes.len() > MAX_MANIFEST_BYTES {
365
return Err(io_error(manifest, "projectmanifest groter dan 4 MiB"));
366
}
367
let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?;
368
let document = parse_manifest(text)?;
342
369
document.validate_shape()?;
343
370
let root = manifest
344
371
.parent()
@@ -584,6 +611,9 @@ fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectErro
584
611
let text = serde_json::to_string_pretty(document)
585
612
.map_err(|err| ProjectError::Json(err.to_string()))?
586
613
+ "\n";
614
if text.len() > MAX_MANIFEST_BYTES {
615
return Err(io_error(path, "projectmanifest groter dan 4 MiB"));
616
}
587
617
let mut temporary_name = path
588
618
.file_name()
589
619
.ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?
@@ -648,6 +678,37 @@ mod tests {
648
678
}
649
679
}
650
680
681
#[test]
682
fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() {
683
let root = temp_root("resource-limits");
684
let mut opened = create_project(&root, "Limits").unwrap();
685
let before = fs::read(&opened.manifest_path).unwrap();
686
opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1);
687
assert!(save_project(&opened).is_err());
688
assert!(write_manifest(&opened.manifest_path, &opened.document)
689
.unwrap_err()
690
.to_string()
691
.contains("4 MiB"));
692
assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);
693
let oversized = root.join("oversized.ppd");
694
fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap();
695
assert!(open_project(&oversized)
696
.unwrap_err()
697
.to_string()
698
.contains("4 MiB"));
699
opened.document.name = "Limits".into();
700
opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS)
701
.map(|i| format!("presets/item-{i}.ppe"))
702
.collect();
703
assert!(opened
704
.document
705
.validate_shape()
706
.unwrap_err()
707
.to_string()
708
.contains("64"));
709
fs::remove_dir_all(root).unwrap();
710
}
711
651
712
#[test]
652
713
fn roundtrip_keeps_null_slots_and_relative_paths() {
653
714
let root = temp_root("roundtrip");
@@ -14,6 +14,8 @@ use serde_json::Value;14
14
15
pub const FORMAT_VERSION: u32 = 1;15
pub const FORMAT_VERSION: u32 = 1;16
pub const MANIFEST_NAME: &str = "character.ppd";16
pub const MANIFEST_NAME: &str = "character.ppd";17
pub const MAX_PROJECT_PRESETS: usize = 64;18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;17
pub const MAX_DIMENSION: u32 = 4096;19
pub const MAX_DIMENSION: u32 = 4096;18
pub const MAX_FPS: u32 = 240;20
pub const MAX_FPS: u32 = 240;19
pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;21
pub const MAX_ESTIMATE_BYTES: u64 = 2 * 1024 * 1024 * 1024;@@ -164,6 +166,11 @@ impl ProjectFile {164
&mut issues,166
&mut issues,165
);167
);166
let mut seen = std::collections::BTreeSet::new();168
let mut seen = std::collections::BTreeSet::new();169
if self.equipment_presets.len() > MAX_PROJECT_PRESETS {170
issues.push(format!(171
"maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project"172
));173
}167
for preset in &self.equipment_presets {174
for preset in &self.equipment_presets {168
match portable_relative(preset) {175
match portable_relative(preset) {169
Ok(clean) => {176
Ok(clean) => {@@ -332,13 +339,33 @@ pub fn open_project(manifest: &Path) -> Result<OpenedProject, ProjectError> {332
if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {339
if manifest.extension().and_then(|ext| ext.to_str()) != Some("ppd") {333
return Err(io_error(manifest, "kies een .ppd-bestand"));340
return Err(io_error(manifest, "kies een .ppd-bestand"));334
}341
}335
let text = fs::read_to_string(manifest).map_err(|err| {342
use std::io::Read;343
let metadata = fs::metadata(manifest).map_err(|err| {336
io_error(344
io_error(337
manifest,345
manifest,338
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),346
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),339
)347
)340
})?;348
})?;341
let document = parse_manifest(&text)?;349
if !metadata.is_file() {350
return Err(io_error(manifest, "kies een regulier .ppd-bestand"));351
}352
let mut bytes = Vec::new();353
fs::File::open(manifest)354
.and_then(|file| {355
file.take((MAX_MANIFEST_BYTES + 1) as u64)356
.read_to_end(&mut bytes)357
})358
.map_err(|err| {359
io_error(360
manifest,361
&format!("openen is mislukt: {err}. Er is niets gewijzigd"),362
)363
})?;364
if bytes.len() > MAX_MANIFEST_BYTES {365
return Err(io_error(manifest, "projectmanifest groter dan 4 MiB"));366
}367
let text = std::str::from_utf8(&bytes).map_err(|err| io_error(manifest, &err.to_string()))?;368
let document = parse_manifest(text)?;342
document.validate_shape()?;369
document.validate_shape()?;343
let root = manifest370
let root = manifest344
.parent()371
.parent()@@ -584,6 +611,9 @@ fn write_manifest(path: &Path, document: &ProjectFile) -> Result<(), ProjectErro584
let text = serde_json::to_string_pretty(document)611
let text = serde_json::to_string_pretty(document)585
.map_err(|err| ProjectError::Json(err.to_string()))?612
.map_err(|err| ProjectError::Json(err.to_string()))?586
+ "\n";613
+ "\n";614
if text.len() > MAX_MANIFEST_BYTES {615
return Err(io_error(path, "projectmanifest groter dan 4 MiB"));616
}587
let mut temporary_name = path617
let mut temporary_name = path588
.file_name()618
.file_name()589
.ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?619
.ok_or_else(|| io_error(path, "het .ppd-bestand heeft geen naam"))?@@ -648,6 +678,37 @@ mod tests {648
}678
}649
}679
}650
680
681
#[test]682
fn bounded_manifest_read_save_and_preset_count_preserve_previous_file() {683
let root = temp_root("resource-limits");684
let mut opened = create_project(&root, "Limits").unwrap();685
let before = fs::read(&opened.manifest_path).unwrap();686
opened.document.name = "x".repeat(MAX_MANIFEST_BYTES + 1);687
assert!(save_project(&opened).is_err());688
assert!(write_manifest(&opened.manifest_path, &opened.document)689
.unwrap_err()690
.to_string()691
.contains("4 MiB"));692
assert_eq!(fs::read(&opened.manifest_path).unwrap(), before);693
let oversized = root.join("oversized.ppd");694
fs::write(&oversized, vec![b' '; MAX_MANIFEST_BYTES + 1]).unwrap();695
assert!(open_project(&oversized)696
.unwrap_err()697
.to_string()698
.contains("4 MiB"));699
opened.document.name = "Limits".into();700
opened.document.equipment_presets = (0..=MAX_PROJECT_PRESETS)701
.map(|i| format!("presets/item-{i}.ppe"))702
.collect();703
assert!(opened704
.document705
.validate_shape()706
.unwrap_err()707
.to_string()708
.contains("64"));709
fs::remove_dir_all(root).unwrap();710
}711
651
#[test]712
#[test]652
fn roundtrip_keeps_null_slots_and_relative_paths() {713
fn roundtrip_keeps_null_slots_and_relative_paths() {653
let root = temp_root("roundtrip");714
let root = temp_root("roundtrip");@@ -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)]64
struct EquipmentFile {65
struct EquipmentFile {65
name: String,66
name: String,66
model: Option<String>,67
model: Option<String>,@@ -230,6 +231,42 @@ impl Session {230
index231
index231
}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_BYTES243
.checked_sub(candidate.encoded_len()?)244
.ok_or("equipmentpresets samen groter dan 64 MiB")?;245
for (i, item) in self.equipment.iter().enumerate() {246
if Some(i) != replacement {247
if let Some(p) = &item.preset {248
budget = budget249
.checked_sub(p.encoded_len()?)250
.ok_or("equipmentpresets samen groter dan 64 MiB")?;251
}252
}253
}254
Ok(())255
}256
pub fn available_equipment_name(&self, base: &str) -> Result<String, String> {257
if !valid_slug(base) {258
return Err("ongeldige equipmentnaam".into());259
}260
let mut name = base.to_string();261
let mut n = 2;262
while self.equipment.iter().any(|i| i.slug == name) {263
let suffix = format!("-{n}");264
name = format!("{}{}", &base[..base.len().min(64 - suffix.len())], suffix);265
n += 1;266
}267
Ok(name)268
}269
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
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
fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {510
fn read_equipment(root: &Path, presets: &[String]) -> Vec<EquipmentItem> {511
read_equipment_with_budget(root, presets, MAX_PROJECT_PRESET_BYTES)512
}513
fn read_equipment_with_budget(514
root: &Path,515
presets: &[String],516
mut budget: usize,517
) -> Vec<EquipmentItem> {457
presets518
presets458
.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_FILE467
model: None,539
} else {468
legacy_path: Some(path.clone()),540
MAX_LEGACY_PRESET_BYTES469
}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: file483
.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 = path570
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
526
fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {575
fn write_equipment(root: &Path, items: &[EquipmentItem]) -> Result<Vec<String>, String> {576
if items.len() > crate::project::MAX_PROJECT_PRESETS {577
return Err("maximaal 64 equipmentpresets per project".into());578
}579
let mut budget = MAX_PROJECT_PRESET_BYTES;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 = budget592
.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 = budget546
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 = budget619
.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
621
fn valid_slug(slug: &str) -> bool {695
fn valid_slug(slug: &str) -> bool {622
!slug.is_empty()696
crate::equipment::valid_name(slug)623
&& slug != "body"624
&& slug625
.chars()626
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')627
}697
}628
698
629
pub fn manifest_in(root: &Path) -> PathBuf {699
pub fn manifest_in(root: &Path) -> PathBuf {@@ -645,6 +715,74 @@ mod tests {645
path715
path646
}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!(session733
.ensure_preset_budget(None, &p)734
.unwrap_err()735
.contains("64 MiB"));736
assert!(session.ensure_preset_budget(Some(0), &p).is_ok());737
assert_eq!(session.equipment.len(), 2);738
let base = "a".repeat(64);739
session.equipment[0].slug = base.clone();740
let unique = session.available_equipment_name(&base).unwrap();741
assert_eq!(unique.len(), 64);742
assert_ne!(unique, base);743
assert!(crate::equipment::valid_name(&unique));744
}745
746
#[test]747
fn preset_read_budget_preserves_unloaded_references() {748
let root = scratch();749
fs::create_dir_all(root.join("presets")).unwrap();750
let bytes = Preset::new("sword".into()).encode().unwrap();751
for name in ["one", "two"] {752
fs::write(root.join(format!("presets/{name}.ppe")), &bytes).unwrap();753
}754
let refs = vec!["presets/one.ppe".into(), "presets/two.ppe".into()];755
let items = read_equipment_with_budget(&root, &refs, bytes.len());756
assert!(items[0].preset.is_some());757
assert!(items[1].preset.is_none());758
assert!(items[1].error.as_ref().unwrap().contains("budget"));759
assert_eq!(items[1].legacy_path.as_ref(), Some(&refs[1]));760
fs::write(761
root.join("presets/old.json"),762
vec![b' '; MAX_LEGACY_PRESET_BYTES + 1],763
)764
.unwrap();765
assert!(read_equipment(&root, &["presets/old.json".into()])[0]766
.error767
.is_some());768
fs::remove_dir_all(root).unwrap();769
}770
#[test]771
fn too_many_presets_do_not_write_snapshots_or_replace_manifest() {772
let root = scratch();773
let mut session = Session::new();774
session.install_new_project(&root).unwrap();775
let before = fs::read(root.join(MANIFEST_NAME)).unwrap();776
for _ in 0..=crate::project::MAX_PROJECT_PRESETS {777
session.add_equipment();778
}779
assert!(session.save().unwrap_err().to_string().contains("64"));780
assert_eq!(fs::read(root.join(MANIFEST_NAME)).unwrap(), before);781
assert_eq!(fs::read_dir(root.join("presets")).unwrap().count(), 0);782
assert!(session.is_dirty());783
fs::remove_dir_all(root).unwrap();784
}785
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();@@ -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]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
}The contents of this binary file are not shown.