Merge feature/ppda-atlases: 60 views, atlases + .ppda, Preview2d via .ppda, fps 4 (9b)
@@ -23,9 +23,9 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
23
23
24
24
- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
25
25
- Kies een animatieclip en gebruik **Pauze / Speel**.
26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
28
- Stel breedte, hoogte en fps in de Bodies-weergave in.
26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 60 hoeken (5 pitches × 12 yaws). Na de losse maps schrijft hij per maptype een atlas plus een `.ppda`-bestand (zie *Atlassen en .ppda*). De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
28
- Stel breedte, hoogte en fps in de Bodies-weergave in. Nieuwe projecten beginnen op 4 fps; een bestaand `.ppd` houdt zijn opgeslagen fps.
29
29
- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.
30
30
31
31

@@ -37,24 +37,28 @@ Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL
37
37
- OBJ en FBX lopen door dezelfde rigide lezer als equipment (FBX met dezelfde as-/eenheidsnormalisatie, één keer); bij glTF worden de node-transformaties van de scène één keer in de geometrie verwerkt (gespiegelde transformaties draaien de driehoeksvolgorde om). Animaties in een glTF zonder skin worden niet toegepast; de melding noemt hoeveel.
38
38
- De bodyweergave meldt dat het lichaam statisch is. De animatiekeuze en **Speel/Pauze** zijn uitgeschakeld.
39
39
- **Pivot Y:** zonder nekbot gebruikt de camera **Centrum Y** als orbitpunt (diagnose *Statisch lichaam zonder skeleton*); een handmatige **Pivot Y** werkt zoals bij geskinde lichamen. Vaste renderhoogte/zoom per lichaam blijft gelijk.
40
- **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 36 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie; de set opent via `export_set` en de 2D-preview.
40
- **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 60 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie, plus atlassen en `.ppda`; de set opent via `export_set` en de 2D-preview.
41
41
- Equipment heeft een bot nodig: in de equipment-editor zijn **Skeleton / botten** en **Render equipment · alle hoeken** uitgeschakeld met uitleg, en de live view tekent geen equipment op een statisch lichaam. Een equipmentrender op een statisch lichaam wordt geweigerd.
42
42
43
43
## 2D-preview
44
44
45
Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:
45
Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide exports in `renders/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.
46
46
47
47
- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
48
48
- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
49
- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
49
- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −60 / −30 / 0 / +30 / +60 met klemmen (start op 0). Hoekwijzigingen behouden de animatietijd.
50
50
- Kies weergavestand **Resultaat** (standaard; belichte compositie), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** of **Alleen specular map**. Mapstanden tonen de diepte-gecompositeerde winnende laag onbelicht (depth als grijswaarde, normal als opgeslagen RGB). Playback, hoek en equipment werken in elke stand; licht alleen in Resultaat.
51
51
- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); azimut wrapt over 360° zonder klem, elevatie blijft −80…+80. Loslaten, focusverlies of pointer-annulering stopt de beweging. Belichting is alleen voor Resultaat; opgeslagen diffuse blijft unlit albedo.
52
52
- Playback loopt over exact `frames / fps` seconden, zodat ieder geëxporteerd frame (inclusief het laatste) even lang duurt (`1/fps`).
53
53
- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
54
54
55

56
55
57
```bash
56
# Native GUI-smoke met schema-3 fixture-export (Xvfb)
58
# Native GUI-smoke (Xvfb): synthetische export, losse PNG's verwijderd, preview via .ppda/atlassen
57
59
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
60
# Zelfde smoke op een bestaand project met echte exports
61
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live
58
62
```
59
63
60
64
## Equipment en opslaan
@@ -88,10 +92,22 @@ De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de e
88
92
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.
89
93
90
94
```text
91
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
95
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
96
renders/run-<tijd>-<pid>/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
97
renders/run-<tijd>-<pid>/male-body-atlas.ppda
92
98
```
93
99
94
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
100
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 4) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) betekent een volledige set. Exports met een ouder schema worden door de lezers geweigerd. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
101
102
### Atlassen en .ppda
103
104
Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.
105
106
- Een cel is exact de doelresolutie, zonder rand of schaling. Iedere rij is één (clip, frame) in bronvolgorde. De 60 kolommen zijn pitch −60→+60 en daarbinnen yaw 0→330. De vier atlassen hebben dezelfde celcoördinaten. Diffuse houdt het sRGB-kenmerk; depth blijft 16-bit grijs+alpha.
107
- De atlassen worden rij voor rij uit de geverifieerde losse PNG's opgebouwd, atomair gepubliceerd en overschrijven niets. Staat er na een crash al een atlas, dan blijft die staan als hij byte-gelijk is aan de nieuwe; anders wordt hij als `.corrupt-aNNN-K` bewaard en opnieuw geschreven.
108
- Wordt een atlas groter dan 16384 pixels in breedte of hoogte, dan weigert de render vóór de start met een duidelijke melding (verlaag fps, resolutie of animatieduur). Atlassen worden nooit gesplitst.
109
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 1, `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels).
110
- De bibliotheeklezer `atlas::AtlasSet` weigert onbekende velden of versies, bestandsnamen die niet de verwachte naam in dezelfde map zijn, symlinks, afwijkende atlasafmetingen of kleurtypes, celcoördinaten die niet op het raster liggen en SHA-256-fouten. `load()` decodeert de vier atlassen met dezelfde pixelregels als de losse maps; `cell()` knipt één hoek eruit. `atlas::cross_check_run` vergelijkt iedere atlascel met de losse PNG.
95
111
96
112
### Hervatten
97
113
@@ -100,13 +116,14 @@ Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaange
100
116
- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
101
117
- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
102
118
- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.
119
- Na de losse maps worden de atlassen en `.ppda` geschreven (zie hierboven).
103
120
- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven.
104
121
105
122

106
123
107
124
## 2D depth-compositor (bibliotheek)
108
125
109
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris via `export_set`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).
126
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris, gelezen uit `export_set` of uit de `.ppda`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).
110
127
111
128
`relight` / `compose_and_relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. Er is geen randlicht: de preview toont alleen deze relight in stand Resultaat. Opgeslagen diffuse blijft elders onbelichte albedo.
112
129
@@ -124,10 +141,15 @@ let layers = [
124
141
LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
125
142
];
126
143
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
144
145
// Zelfde lagen via de consumentenvorm (.ppda + atlassen):
146
use paperdoll_renderer::atlas::AtlasSet;
147
let body = AtlasSet::open_run(&body_run_dir)?.load()?;
148
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
127
149
```
128
150
129
151
130
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 36 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
152
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
131
153
132
154
## Openen met
133
155
@@ -146,7 +168,9 @@ cargo clippy --workspace --all-targets --locked -- -D warnings
146
168
cargo test --workspace --locked
147
169
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
148
170
xvfb-run -a cargo run --example preview_depth_probe --locked
149
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set-controle
171
# Productie op echte GPU (body of equipment); controleert daarna iedere atlascel tegen de losse PNG
172
xvfb-run -a cargo run --example production_fixture --locked -- /tmp/prod tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb male 32 48 4
173
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set- en atlascontrole
150
174
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2
151
175
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
152
176
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
@@ -23,9 +23,9 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert23
23
24
- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.24
- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.25
- Kies een animatieclip en gebruik **Pauze / Speel**.25
- Kies een animatieclip en gebruik **Pauze / Speel**.26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 36 hoeken. De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 60 hoeken (5 pitches × 12 yaws). Na de losse maps schrijft hij per maptype een atlas plus een `.ppda`-bestand (zie *Atlassen en .ppda*). De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.28
- Stel breedte, hoogte en fps in de Bodies-weergave in.28
- Stel breedte, hoogte en fps in de Bodies-weergave in. Nieuwe projecten beginnen op 4 fps; een bestaand `.ppd` houdt zijn opgeslagen fps.29
- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.29
- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.30
30
31
31
@@ -37,24 +37,28 @@ Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL37
- OBJ en FBX lopen door dezelfde rigide lezer als equipment (FBX met dezelfde as-/eenheidsnormalisatie, één keer); bij glTF worden de node-transformaties van de scène één keer in de geometrie verwerkt (gespiegelde transformaties draaien de driehoeksvolgorde om). Animaties in een glTF zonder skin worden niet toegepast; de melding noemt hoeveel.37
- OBJ en FBX lopen door dezelfde rigide lezer als equipment (FBX met dezelfde as-/eenheidsnormalisatie, één keer); bij glTF worden de node-transformaties van de scène één keer in de geometrie verwerkt (gespiegelde transformaties draaien de driehoeksvolgorde om). Animaties in een glTF zonder skin worden niet toegepast; de melding noemt hoeveel.38
- De bodyweergave meldt dat het lichaam statisch is. De animatiekeuze en **Speel/Pauze** zijn uitgeschakeld.38
- De bodyweergave meldt dat het lichaam statisch is. De animatiekeuze en **Speel/Pauze** zijn uitgeschakeld.39
- **Pivot Y:** zonder nekbot gebruikt de camera **Centrum Y** als orbitpunt (diagnose *Statisch lichaam zonder skeleton*); een handmatige **Pivot Y** werkt zoals bij geskinde lichamen. Vaste renderhoogte/zoom per lichaam blijft gelijk.39
- **Pivot Y:** zonder nekbot gebruikt de camera **Centrum Y** als orbitpunt (diagnose *Statisch lichaam zonder skeleton*); een handmatige **Pivot Y** werkt zoals bij geskinde lichamen. Vaste renderhoogte/zoom per lichaam blijft gelijk.40
- **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 36 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie; de set opent via `export_set` en de 2D-preview.40
- **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 60 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie, plus atlassen en `.ppda`; de set opent via `export_set` en de 2D-preview.41
- Equipment heeft een bot nodig: in de equipment-editor zijn **Skeleton / botten** en **Render equipment · alle hoeken** uitgeschakeld met uitleg, en de live view tekent geen equipment op een statisch lichaam. Een equipmentrender op een statisch lichaam wordt geweigerd.41
- Equipment heeft een bot nodig: in de equipment-editor zijn **Skeleton / botten** en **Render equipment · alle hoeken** uitgeschakeld met uitleg, en de live view tekent geen equipment op een statisch lichaam. Een equipmentrender op een statisch lichaam wordt geweigerd.42
42
43
## 2D-preview43
## 2D-preview44
44
45
Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:45
Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide exports in `renders/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.46
46
47
- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.47
- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.48
- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.48
- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.49
- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.49
- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −60 / −30 / 0 / +30 / +60 met klemmen (start op 0). Hoekwijzigingen behouden de animatietijd.50
- Kies weergavestand **Resultaat** (standaard; belichte compositie), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** of **Alleen specular map**. Mapstanden tonen de diepte-gecompositeerde winnende laag onbelicht (depth als grijswaarde, normal als opgeslagen RGB). Playback, hoek en equipment werken in elke stand; licht alleen in Resultaat.50
- Kies weergavestand **Resultaat** (standaard; belichte compositie), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** of **Alleen specular map**. Mapstanden tonen de diepte-gecompositeerde winnende laag onbelicht (depth als grijswaarde, normal als opgeslagen RGB). Playback, hoek en equipment werken in elke stand; licht alleen in Resultaat.51
- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); azimut wrapt over 360° zonder klem, elevatie blijft −80…+80. Loslaten, focusverlies of pointer-annulering stopt de beweging. Belichting is alleen voor Resultaat; opgeslagen diffuse blijft unlit albedo.51
- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); azimut wrapt over 360° zonder klem, elevatie blijft −80…+80. Loslaten, focusverlies of pointer-annulering stopt de beweging. Belichting is alleen voor Resultaat; opgeslagen diffuse blijft unlit albedo.52
- Playback loopt over exact `frames / fps` seconden, zodat ieder geëxporteerd frame (inclusief het laatste) even lang duurt (`1/fps`).52
- Playback loopt over exact `frames / fps` seconden, zodat ieder geëxporteerd frame (inclusief het laatste) even lang duurt (`1/fps`).53
- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.53
- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.54
54
55
56
55
```bash57
```bash56
# Native GUI-smoke met schema-3 fixture-export (Xvfb)58
# Native GUI-smoke (Xvfb): synthetische export, losse PNG's verwijderd, preview via .ppda/atlassen57
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke59
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke60
# Zelfde smoke op een bestaand project met echte exports61
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live58
```62
```59
63
60
## Equipment en opslaan64
## Equipment en opslaan@@ -88,10 +92,22 @@ De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de e88
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.92
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineaire 16-bit grayscale+alpha met gemeenschappelijke near/far; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.89
93
90
```text94
```text91
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png95
renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-60_y330_diffuse.png96
renders/run-<tijd>-<pid>/male-body-diffuse-atlas.png (en -normal, -depth, -specular)97
renders/run-<tijd>-<pid>/male-body-atlas.ppda92
```98
```93
99
94
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.100
Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 4) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) betekent een volledige set. Exports met een ouder schema worden door de lezers geweigerd. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.101
102
### Atlassen en .ppda103
104
Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.105
106
- Een cel is exact de doelresolutie, zonder rand of schaling. Iedere rij is één (clip, frame) in bronvolgorde. De 60 kolommen zijn pitch −60→+60 en daarbinnen yaw 0→330. De vier atlassen hebben dezelfde celcoördinaten. Diffuse houdt het sRGB-kenmerk; depth blijft 16-bit grijs+alpha.107
- De atlassen worden rij voor rij uit de geverifieerde losse PNG's opgebouwd, atomair gepubliceerd en overschrijven niets. Staat er na een crash al een atlas, dan blijft die staan als hij byte-gelijk is aan de nieuwe; anders wordt hij als `.corrupt-aNNN-K` bewaard en opnieuw geschreven.108
- Wordt een atlas groter dan 16384 pixels in breedte of hoogte, dan weigert de render vóór de start met een duidelijke melding (verlaag fps, resolutie of animatieduur). Atlassen worden nooit gesplitst.109
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 1, `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels).110
- De bibliotheeklezer `atlas::AtlasSet` weigert onbekende velden of versies, bestandsnamen die niet de verwachte naam in dezelfde map zijn, symlinks, afwijkende atlasafmetingen of kleurtypes, celcoördinaten die niet op het raster liggen en SHA-256-fouten. `load()` decodeert de vier atlassen met dezelfde pixelregels als de losse maps; `cell()` knipt één hoek eruit. `atlas::cross_check_run` vergelijkt iedere atlascel met de losse PNG.95
111
96
### Hervatten112
### Hervatten97
113
@@ -100,13 +116,14 @@ Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaange100
- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.116
- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.101
- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.117
- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.102
- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.118
- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.119
- Na de losse maps worden de atlassen en `.ppda` geschreven (zie hierboven).103
- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven.120
- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven.104
121
105
122
106
123
107
## 2D depth-compositor (bibliotheek)124
## 2D depth-compositor (bibliotheek)108
125
109
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris via `export_set`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).126
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris, gelezen uit `export_set` of uit de `.ppda`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).110
127
111
`relight` / `compose_and_relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. Er is geen randlicht: de preview toont alleen deze relight in stand Resultaat. Opgeslagen diffuse blijft elders onbelichte albedo.128
`relight` / `compose_and_relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. Er is geen randlicht: de preview toont alleen deze relight in stand Resultaat. Opgeslagen diffuse blijft elders onbelichte albedo.112
129
@@ -124,10 +141,15 @@ let layers = [124
LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,141
LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,125
];142
];126
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;143
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;144
145
// Zelfde lagen via de consumentenvorm (.ppda + atlassen):146
use paperdoll_renderer::atlas::AtlasSet;147
let body = AtlasSet::open_run(&body_run_dir)?.load()?;148
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;127
```149
```128
150
129
151
130
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 36 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.152
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.131
153
132
## Openen met154
## Openen met133
155
@@ -146,7 +168,9 @@ cargo clippy --workspace --all-targets --locked -- -D warnings146
cargo test --workspace --locked168
cargo test --workspace --locked147
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo169
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo148
xvfb-run -a cargo run --example preview_depth_probe --locked170
xvfb-run -a cargo run --example preview_depth_probe --locked149
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set-controle171
# Productie op echte GPU (body of equipment); controleert daarna iedere atlascel tegen de losse PNG172
xvfb-run -a cargo run --example production_fixture --locked -- /tmp/prod tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb male 32 48 4173
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set- en atlascontrole150
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2174
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2151
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200175
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200152
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male176
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male@@ -22,7 +22,7 @@ BlueGit destination requested: group **Golden Age**, proposed slug `golden-age`;
22
22
| R01 | Rust application with user-selected male/female models | Open/save missing and populated slots; later manual desktop smoke test |
23
23
| R02 | Discover all supported embedded animation clips | Synthetic licensed fixture inventory, including unnamed/duplicate clips |
24
24
| R03 | Body and equipment modes | Export body plus isolated equipment from identical scene state |
25
| R04 | Three pitches times twelve yaw positions | Exactly 36 unique orientations; yaw starts facing viewer |
25
| R04 | Five pitches times twelve yaw positions (t955u; was three) | Exactly 60 unique orientations; yaw starts facing viewer |
26
26
| R05 | Initial geometry render at 8x target dimensions and configured FPS | Explicit render manifest and dimension/frame-count tests |
27
27
| R06 | Edge/detail-guided downsampling | Image fixtures retain thin details better than baseline without alpha fringes |
28
28
| R07 | Target-resolution diffuse, depth, normal maps | Round-trip decoding, finite normalized normals and shared depth contract |
@@ -33,7 +33,7 @@ BlueGit destination requested: group **Golden Age**, proposed slug `golden-age`;
33
33
| R12 | Equipment set name lowercase without spaces | Validation; reject reserved body name and path traversal |
34
34
| R13 | Equipment-only rendering with shinymap | No body occlusion/write contamination; specular fixture |
35
35
| R14 | Folder plus .ppd manifest describing assets, presets, outputs | Portable relative paths and safe atomic saves |
36
| R15 | 2D looping animation, sex/equipment/clip selection and 36-angle navigation | State-machine and depth-compositing tests |
36
| R15 | 2D looping animation, sex/equipment/clip selection and 60-angle navigation | State-machine and depth-compositing tests |
37
37
| R16 | Held light-control buttons rotate continuously in degrees | Delta-time tests including key/button release/focus loss |
38
38
| R17 | Per-feature branch, tests, non-FF master merge, no initial MRs | Two-parent merge topology and CI checks |
39
39
| R18 | Tag tests gate AppImage, deb, Arch package, Windows build and release | Packaging smoke tests and complete-artifact release gate |
@@ -104,13 +104,13 @@ Paths use `/` and stay inside the project directory. Save writes/fsyncs a tempor
104
104
105
105
## 6. Camera, axes, alignment and animation sampling
106
106
107
Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
107
Required views: pitches −60, −30, 0, +30, +60 degrees (t955u; originally −30/0/+30); yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
108
108
109
109
**Implemented fixed framing (Tim t952u):** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. The **orbit pivot** is independent of the vertical image center: target is `(0, pivot_y/1000, 0)` with camera distance **10**. `pivot_y_milli = None` resolves the unique rest-pose neck joint (name tokens such as `Neck`, `mixamorigNeck`, `Bip001 Neck`, `CC_Base_Neck`); missing/ambiguous necks fall back to `center_y_milli` with a UI diagnostic; an explicit `Some` value is a manual override. Vertical world extent is `height_milli/1000`, horizontal extent vertical × target aspect. Orthographic Y translation `(pivot − center) / half_height` keeps zero-pitch image framing (feet / image center) stable when the pivot is raised to the neck. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. Body, equipment layered preview, fixed live frame and production `camera()` all share `camera::resolve` + `camera::matrices`. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center/pivot in the body view; automatic animation envelopes are not promised.
110
110
111
111
**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start (including resolved pivot and `pivot_source`); later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing (including optional manual pivot) in `.ppd`.
112
112
113
**Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.
113
**Implemented batch nesting:** source clip order → sampled frame → pitch **−60,−30,0,+30,+60** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.
114
114
115
115
Highres is exactly **8× width AND height** (64× pixels); targets currently **1…256 per axis**, highres max **2048 per axis**. Larger projects may be opened but their batch render is explicitly rejected, never silently reduced to 1×. A batch is limited to 500000 PNGs and 16 GiB estimated uncompressed target data. Render streams one pose/view at a time; no whole-batch image allocation. GPU service is one offscreen draw/readback unit per native UI update, with real depth24 attachment/test/write/clear; skinning, detail/resolve and PNG writes run on a background worker. The GUI keeps updating; a large individual GPU unit can still take a frame. The latest-pass mailbox retains **one** completed image, one progress update, and a terminal event; no unbounded preview backlog. No sleeps, pass playback delay or preview acknowledgment. Fast passes can be visually skipped, never faked.
116
116
@@ -126,9 +126,9 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
126
126
127
127
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.
128
128
129
`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
129
`render.json` (output metadata schema **4** since t955u — adds `pitches`/`yaws` and the atlas record; schema 3 introduced identity/checksums; separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 4) is published ONLY after all PNGs complete and the atlases/`.ppda` are written, and contains a matching `identity` object, `png_sha256` (relative path → hex digest) for every file and `atlas` (`ppda`, `ppda_sha256`, `maps.<map>.{file,sha256}`); cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
130
130
131
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.
131
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 4 matches (pitches/yaws exactly −60…+60 × 0…330, atlas names match sex/set), identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <4), incomplete or corrupted sets are rejected. `ExportRun` is the loose-PNG reader; atlas bytes are verified by the `.ppda` reader (`atlas::AtlasSet`). The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.
132
132
133
133
**Implemented resume (`resume` module, decision 7 November 2026):** every run directory holds `run.lock` (exclusive OS file lock held by the writing worker for the whole attempt; released by the OS on a crash) and an append-only `progress.jsonl` with one `{"path","sha256"}` line per published target PNG, appended and fsynced *after* the PNG's no-clobber publication. A selected incomplete run (no `complete.json`; marker `cancelled.json`, `failed.json` or none = crash) is resumable only when it is a direct `run-*` child of the output directory, its lock is free and its stored `render.json` equals, as JSON, exactly the document the current job would write (project id/name, sex/set, body path+SHA-256, optional equipment SHA-256, target, FPS, highres scale, resolved framing/pivot/pivot_source, near/far/camera, conventions, clip inventory, pass/map lists and counts). Any difference refuses the resume and names the differing top-level fields; the run is left untouched. Before rendering, every planned PNG is classified: journaled with a matching on-disk SHA-256 → kept and never rewritten (same inode); absent → regenerated; corrupt, oversized or non-regular (e.g. symlink) → moved aside with a no-clobber hard link to `<name>.corrupt-aNNN-K` and regenerated (never deleted); a directory in place of a PNG refuses the resume; an existing file without a journal entry (crash between publication and journal append) is compared with the regenerated bytes and kept when identical, otherwise moved aside. Journal lines naming a path outside the plan refuse the resume; torn/unparseable lines only make that file unverified. Images whose four maps are all verified skip the GPU; progress starts at the verified pass count and labels them *al aanwezig, checksums kloppen*. A previous top-level `cancelled.json`/`failed.json` is archived as `attempt-NNN-<marker>.json` (attempt 0 = original run), so a resumed run can be cancelled again (new `cancelled.json` with `attempt`, `completed_passes`, `png_files_done`) and `export_set` still opens it once complete. `resume.jsonl` appends one record per resume attempt (verified/missing/quarantined/unjournaled/damaged-journal counts and archived marker). `complete.json` is written only when all planned PNGs have verified checksums and is identical in form to an uninterrupted run. Foreign `.part-*` temporaries are never deleted; atomic writes now use per-write unique temp names (`part-<pid>-<seq>`) so a stale temp from a crashed process with a reused PID cannot block publication. Other runs are never opened for writing. Runs created before this change have no journal: all their existing PNGs are treated as unjournaled and verified by byte comparison with regenerated output. The editor lists matching incomplete runs (same sex/set, newest first, at most 20) under **Onderbroken renders** in the body and equipment views; mismatched or locked runs are shown with the reason and no **Hervat** action. Real Xvfb/Mesa proof: an `abort()`-crashed run, resumed, cancelled again, damaged and resumed to completion produced a `png_sha256` map identical to an uninterrupted run of the same project, while the earlier completed run stayed byte-identical (`examples/resume_fixture.rs`).
134
134
@@ -184,9 +184,9 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
184
184
185
185
## 10. Rendered 2D preview
186
186
187
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation over exactly `frames/fps` seconds so every frame lasts `1/fps`; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the frame with aspect-preserving nearest-neighbor letterboxing. View modes: **Resultaat** (relit composite), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map**, **Alleen specular map** (map modes show the depth-composited winning-layer map unlit). Light azimuth wraps full 360°; elevation stays −80…+80; light affects Resultaat only. Rim/edge lighting is removed (t958u). The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
187
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the validating `.ppda`/atlas reader (`atlas::AtlasSet`; identity + SHA-256), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation over exactly `frames/fps` seconds so every frame lasts `1/fps`; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the frame with aspect-preserving nearest-neighbor letterboxing. View modes: **Resultaat** (relit composite), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map**, **Alleen specular map** (map modes show the depth-composited winning-layer map unlit). Light azimuth wraps full 360°; elevation stays −80…+80; light affects Resultaat only. Rim/edge lighting is removed (t958u). The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
188
188
189
Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
189
Left/right controls wrap among twelve yaw values. Up/down selects one of five pitch rows (−60…+60, start 0) with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
190
190
191
191
Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` / `compose_and_relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). Rim/edge lighting was removed (Tim t958u). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u).
192
192
@@ -198,7 +198,7 @@ After every merge, `README.md` describes only the current behavior: what the pro
198
198
199
199
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
200
200
201
Live 3D preview, as built: the body screen draws the skinned glTF/GLB or FBX mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ or FBX equipment (§9); FBX bodies are drawn from the normalized `fbx_scene`. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
201
Live 3D preview, as built: the body screen draws the skinned glTF/GLB or FBX mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches −60/−30/0/+30/+60 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ or FBX equipment (§9); FBX bodies are drawn from the normalized `fbx_scene`. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
202
202
203
203
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
204
204
@@ -221,7 +221,7 @@ These requirements are durable even where the implementation is staged. They sup
221
221
- 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.
222
222
- 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.
223
223
224
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback (view modes Resultaat/map-only, full-circle light azimuth, loop on frames/fps; rim removed per t958u) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
224
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 60 views with the six real pass previews and four target maps described above, then per-map atlases plus `.ppda`, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback (view modes Resultaat/map-only, full-circle light azimuth, loop on frames/fps; rim removed per t958u) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
225
225
226
226
## 12. Incremental feature plan and acceptance gates
227
227
@@ -230,7 +230,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
230
230
2. Normalized import and animation inventory: glTF/GLB fixtures first, required OBJ+MTL/textures and FBX adapter proof; format-specific truthful UI/errors and skeleton/clip inventories; integration fixture tests before declaring format support.
231
231
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
232
232
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
233
5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
233
5. Shared batch camera/sampling and raw buffers: 60 views (t955u; was 36), common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
234
234
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (No rim accents; preview uses relight only — t958u.)
235
235
7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping on frames/fps, sex/equipment/clip selection, view modes (Resultaat + map-only), discrete view controls, continuous held light controls with full-circle azimuth; deterministic state tests and GUI smoke.
236
236
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
@@ -360,6 +360,16 @@ Bepaal ook welk 3D-model format goed is; liefst ondersteunen we natuurlijk meerd
360
360
- **Default framerate:** new projects default to **4 fps** instead of 12. Existing `.ppd` files keep their stored `fps`.
361
361
- **`.ppda` as the consumer format (Tim t957u):** the `.ppda` plus its atlases is the primary format for consumers. The editor's 2D previewer loads an export through the `.ppda` and its atlases rather than the loose PNGs, and the eventual game renderer uses the same format. The loose PNGs stay available as export output. The `.ppda` reader validates the version, filenames (no paths outside the export folder), atlas dimensions, cell bounds and SHA-256 before use.
362
362
363
**Implemented (9b, `atlas` module):**
364
- Production plans 60 views per sample (`preview::PRODUCTION_PITCHES` × `production_yaws()`); `render.json`/`complete.json` are output schema 4. New projects default to `project::DEFAULT_FPS = 4`. The legacy `.ppd` shape-validation memory heuristic keeps its factor 36 so 256×256 targets stay valid (production streams one view at a time).
365
- Location: atlases and `.ppda` sit in the run root (`renders/run-…/male-body-diffuse-atlas.png`, `…/male-body-atlas.ppda`); one run holds one sex/set.
366
- Write order after the last loose PNG is verified (fresh or resumed run): four atlases (diffuse, normal, depth, specular) → `.ppda` → `complete.json` (lists the atlas/`.ppda` names + SHA-256). Each atlas is streamed one cell row at a time from the checksum-verified loose PNGs (re-decoded under the map pixel contract), hashed while written, published via temp file + fsync + no-clobber hard link. If an atlas or `.ppda` already exists (crash after publication), it is kept when byte-identical to the regenerated output (encoding is deterministic) and otherwise moved aside as `<name>.corrupt-aNNN-K`. Cancellation is checked per row; `cancelled.json`/`failed.json` follow the normal rules.
367
- Limit: `MAX_ATLAS_DIM = 16384` per side. `production::contract` refuses the job before rendering when 60 × cell width or rows × cell height would exceed it; atlases are never split.
368
- Diffuse atlas carries the sRGB chunk like the loose diffuse maps; depth atlas is 16-bit gray+alpha; normal/specular RGBA8.
369
- `.ppda` fields as listed above, with two additions needed by consumers: `identity` (body/equipment SHA-256, framing, pivot-source kind, near/far, camera distance, normal and depth encoding) so body and equipment atlases can be checked for compositing compatibility without the run manifests, and per-clip `token`; each frame is `{index, views:[{pitch,yaw,x,y,w,h}×60]}`; `maps.<map>` also records `width`/`height`. All structs use `deny_unknown_fields`.
370
- Reader `atlas::AtlasSet::open`/`open_run` (exactly one `*-atlas.ppda` per folder): bounded read, strict JSON, version 1, name = `<sex>-<settype>-atlas.ppda`, sex/settype/project/identity/fps/cell/pitch/yaw checks, every view must equal the deterministic grid cell (implies in-bounds), map file names must equal `<sex>-<settype>-<map>-atlas.png` (no paths), regular non-symlink files inside the folder, SHA-256, PNG header size/colour type. `load()` decodes all four with the loose-map pixel rules (2 GiB decoded budget per set); `LoadedAtlas::cell` crops one view. `CompatKey::from_ppda` + `assert_compatible_keys` and `LayerMaps::from_atlas` feed the compositor. `atlas::cross_check_run` compares every cell with its loose PNG (used by the GPU proofs).
371
- Preview2d discovers runs through `AtlasSet::open_run` (runs without a valid `.ppda`, including older schema-3 runs, are skipped and counted with reasons), decodes the selected body + equipment once and caches them until the selection or catalog changes.
372
363
373
## Decision 2026-11-07 (Tim t958u): full-circle light azimuth, rim lighting removed
364
374
365
375
- **Light azimuth:** in the 2D previewer, holding the light buttons rotates the azimuth through the full 360 degrees and keeps going (wrapping modulo 360, no clamp). Elevation keeps its −80…+80 clamp.
@@ -22,7 +22,7 @@ BlueGit destination requested: group **Golden Age**, proposed slug `golden-age`;22
| R01 | Rust application with user-selected male/female models | Open/save missing and populated slots; later manual desktop smoke test |22
| R01 | Rust application with user-selected male/female models | Open/save missing and populated slots; later manual desktop smoke test |23
| R02 | Discover all supported embedded animation clips | Synthetic licensed fixture inventory, including unnamed/duplicate clips |23
| R02 | Discover all supported embedded animation clips | Synthetic licensed fixture inventory, including unnamed/duplicate clips |24
| R03 | Body and equipment modes | Export body plus isolated equipment from identical scene state |24
| R03 | Body and equipment modes | Export body plus isolated equipment from identical scene state |25
| R04 | Three pitches times twelve yaw positions | Exactly 36 unique orientations; yaw starts facing viewer |25
| R04 | Five pitches times twelve yaw positions (t955u; was three) | Exactly 60 unique orientations; yaw starts facing viewer |26
| R05 | Initial geometry render at 8x target dimensions and configured FPS | Explicit render manifest and dimension/frame-count tests |26
| R05 | Initial geometry render at 8x target dimensions and configured FPS | Explicit render manifest and dimension/frame-count tests |27
| R06 | Edge/detail-guided downsampling | Image fixtures retain thin details better than baseline without alpha fringes |27
| R06 | Edge/detail-guided downsampling | Image fixtures retain thin details better than baseline without alpha fringes |28
| R07 | Target-resolution diffuse, depth, normal maps | Round-trip decoding, finite normalized normals and shared depth contract |28
| R07 | Target-resolution diffuse, depth, normal maps | Round-trip decoding, finite normalized normals and shared depth contract |@@ -33,7 +33,7 @@ BlueGit destination requested: group **Golden Age**, proposed slug `golden-age`;33
| R12 | Equipment set name lowercase without spaces | Validation; reject reserved body name and path traversal |33
| R12 | Equipment set name lowercase without spaces | Validation; reject reserved body name and path traversal |34
| R13 | Equipment-only rendering with shinymap | No body occlusion/write contamination; specular fixture |34
| R13 | Equipment-only rendering with shinymap | No body occlusion/write contamination; specular fixture |35
| R14 | Folder plus .ppd manifest describing assets, presets, outputs | Portable relative paths and safe atomic saves |35
| R14 | Folder plus .ppd manifest describing assets, presets, outputs | Portable relative paths and safe atomic saves |36
| R15 | 2D looping animation, sex/equipment/clip selection and 36-angle navigation | State-machine and depth-compositing tests |36
| R15 | 2D looping animation, sex/equipment/clip selection and 60-angle navigation | State-machine and depth-compositing tests |37
| R16 | Held light-control buttons rotate continuously in degrees | Delta-time tests including key/button release/focus loss |37
| R16 | Held light-control buttons rotate continuously in degrees | Delta-time tests including key/button release/focus loss |38
| R17 | Per-feature branch, tests, non-FF master merge, no initial MRs | Two-parent merge topology and CI checks |38
| R17 | Per-feature branch, tests, non-FF master merge, no initial MRs | Two-parent merge topology and CI checks |39
| R18 | Tag tests gate AppImage, deb, Arch package, Windows build and release | Packaging smoke tests and complete-artifact release gate |39
| R18 | Tag tests gate AppImage, deb, Arch package, Windows build and release | Packaging smoke tests and complete-artifact release gate |@@ -104,13 +104,13 @@ Paths use `/` and stay inside the project directory. Save writes/fsyncs a tempor104
104
105
## 6. Camera, axes, alignment and animation sampling105
## 6. Camera, axes, alignment and animation sampling106
106
107
Required views: pitches -30, 0, +30 degrees; yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.107
Required views: pitches −60, −30, 0, +30, +60 degrees (t955u; originally −30/0/+30); yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.108
108
109
**Implemented fixed framing (Tim t952u):** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. The **orbit pivot** is independent of the vertical image center: target is `(0, pivot_y/1000, 0)` with camera distance **10**. `pivot_y_milli = None` resolves the unique rest-pose neck joint (name tokens such as `Neck`, `mixamorigNeck`, `Bip001 Neck`, `CC_Base_Neck`); missing/ambiguous necks fall back to `center_y_milli` with a UI diagnostic; an explicit `Some` value is a manual override. Vertical world extent is `height_milli/1000`, horizontal extent vertical × target aspect. Orthographic Y translation `(pivot − center) / half_height` keeps zero-pitch image framing (feet / image center) stable when the pivot is raised to the neck. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. Body, equipment layered preview, fixed live frame and production `camera()` all share `camera::resolve` + `camera::matrices`. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center/pivot in the body view; automatic animation envelopes are not promised.109
**Implemented fixed framing (Tim t952u):** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; positive yaw follows the existing orbit convention. The **orbit pivot** is independent of the vertical image center: target is `(0, pivot_y/1000, 0)` with camera distance **10**. `pivot_y_milli = None` resolves the unique rest-pose neck joint (name tokens such as `Neck`, `mixamorigNeck`, `Bip001 Neck`, `CC_Base_Neck`); missing/ambiguous necks fall back to `center_y_milli` with a UI diagnostic; an explicit `Some` value is a manual override. Vertical world extent is `height_milli/1000`, horizontal extent vertical × target aspect. Orthographic Y translation `(pivot − center) / half_height` keeps zero-pitch image framing (feet / image center) stable when the pivot is raised to the neck. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. Body, equipment layered preview, fixed live frame and production `camera()` all share `camera::resolve` + `camera::matrices`. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center/pivot in the body view; automatic animation envelopes are not promised.110
110
111
**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start (including resolved pivot and `pivot_source`); later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing (including optional manual pivot) in `.ppd`.111
**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start (including resolved pivot and `pivot_source`); later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing (including optional manual pivot) in `.ppd`.112
112
113
**Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.113
**Implemented batch nesting:** source clip order → sampled frame → pitch **−60,−30,0,+30,+60** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.114
114
115
Highres is exactly **8× width AND height** (64× pixels); targets currently **1…256 per axis**, highres max **2048 per axis**. Larger projects may be opened but their batch render is explicitly rejected, never silently reduced to 1×. A batch is limited to 500000 PNGs and 16 GiB estimated uncompressed target data. Render streams one pose/view at a time; no whole-batch image allocation. GPU service is one offscreen draw/readback unit per native UI update, with real depth24 attachment/test/write/clear; skinning, detail/resolve and PNG writes run on a background worker. The GUI keeps updating; a large individual GPU unit can still take a frame. The latest-pass mailbox retains **one** completed image, one progress update, and a terminal event; no unbounded preview backlog. No sleeps, pass playback delay or preview acknowledgment. Fast passes can be visually skipped, never faked.115
Highres is exactly **8× width AND height** (64× pixels); targets currently **1…256 per axis**, highres max **2048 per axis**. Larger projects may be opened but their batch render is explicitly rejected, never silently reduced to 1×. A batch is limited to 500000 PNGs and 16 GiB estimated uncompressed target data. Render streams one pose/view at a time; no whole-batch image allocation. GPU service is one offscreen draw/readback unit per native UI update, with real depth24 attachment/test/write/clear; skinning, detail/resolve and PNG writes run on a background worker. The GUI keeps updating; a large individual GPU unit can still take a frame. The latest-pass mailbox retains **one** completed image, one progress update, and a terminal event; no unbounded preview backlog. No sleeps, pass playback delay or preview acknowledgment. Fast passes can be visually skipped, never faked.116
116
@@ -126,9 +126,9 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/126
126
127
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.127
`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.128
128
129
`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.129
`render.json` (output metadata schema **4** since t955u — adds `pitches`/`yaws` and the atlas record; schema 3 introduced identity/checksums; separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 4) is published ONLY after all PNGs complete and the atlases/`.ppda` are written, and contains a matching `identity` object, `png_sha256` (relative path → hex digest) for every file and `atlas` (`ppda`, `ppda_sha256`, `maps.<map>.{file,sha256}`); cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.130
130
131
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.131
**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 4 matches (pitches/yaws exactly −60…+60 × 0…330, atlas names match sex/set), identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <4), incomplete or corrupted sets are rejected. `ExportRun` is the loose-PNG reader; atlas bytes are verified by the `.ppda` reader (`atlas::AtlasSet`). The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.132
132
133
**Implemented resume (`resume` module, decision 7 November 2026):** every run directory holds `run.lock` (exclusive OS file lock held by the writing worker for the whole attempt; released by the OS on a crash) and an append-only `progress.jsonl` with one `{"path","sha256"}` line per published target PNG, appended and fsynced *after* the PNG's no-clobber publication. A selected incomplete run (no `complete.json`; marker `cancelled.json`, `failed.json` or none = crash) is resumable only when it is a direct `run-*` child of the output directory, its lock is free and its stored `render.json` equals, as JSON, exactly the document the current job would write (project id/name, sex/set, body path+SHA-256, optional equipment SHA-256, target, FPS, highres scale, resolved framing/pivot/pivot_source, near/far/camera, conventions, clip inventory, pass/map lists and counts). Any difference refuses the resume and names the differing top-level fields; the run is left untouched. Before rendering, every planned PNG is classified: journaled with a matching on-disk SHA-256 → kept and never rewritten (same inode); absent → regenerated; corrupt, oversized or non-regular (e.g. symlink) → moved aside with a no-clobber hard link to `<name>.corrupt-aNNN-K` and regenerated (never deleted); a directory in place of a PNG refuses the resume; an existing file without a journal entry (crash between publication and journal append) is compared with the regenerated bytes and kept when identical, otherwise moved aside. Journal lines naming a path outside the plan refuse the resume; torn/unparseable lines only make that file unverified. Images whose four maps are all verified skip the GPU; progress starts at the verified pass count and labels them *al aanwezig, checksums kloppen*. A previous top-level `cancelled.json`/`failed.json` is archived as `attempt-NNN-<marker>.json` (attempt 0 = original run), so a resumed run can be cancelled again (new `cancelled.json` with `attempt`, `completed_passes`, `png_files_done`) and `export_set` still opens it once complete. `resume.jsonl` appends one record per resume attempt (verified/missing/quarantined/unjournaled/damaged-journal counts and archived marker). `complete.json` is written only when all planned PNGs have verified checksums and is identical in form to an uninterrupted run. Foreign `.part-*` temporaries are never deleted; atomic writes now use per-write unique temp names (`part-<pid>-<seq>`) so a stale temp from a crashed process with a reused PID cannot block publication. Other runs are never opened for writing. Runs created before this change have no journal: all their existing PNGs are treated as unjournaled and verified by byte comparison with regenerated output. The editor lists matching incomplete runs (same sex/set, newest first, at most 20) under **Onderbroken renders** in the body and equipment views; mismatched or locked runs are shown with the reason and no **Hervat** action. Real Xvfb/Mesa proof: an `abort()`-crashed run, resumed, cancelled again, damaged and resumed to completion produced a `png_sha256` map identical to an uninterrupted run of the same project, while the earlier completed run stayed byte-identical (`examples/resume_fixture.rs`).133
**Implemented resume (`resume` module, decision 7 November 2026):** every run directory holds `run.lock` (exclusive OS file lock held by the writing worker for the whole attempt; released by the OS on a crash) and an append-only `progress.jsonl` with one `{"path","sha256"}` line per published target PNG, appended and fsynced *after* the PNG's no-clobber publication. A selected incomplete run (no `complete.json`; marker `cancelled.json`, `failed.json` or none = crash) is resumable only when it is a direct `run-*` child of the output directory, its lock is free and its stored `render.json` equals, as JSON, exactly the document the current job would write (project id/name, sex/set, body path+SHA-256, optional equipment SHA-256, target, FPS, highres scale, resolved framing/pivot/pivot_source, near/far/camera, conventions, clip inventory, pass/map lists and counts). Any difference refuses the resume and names the differing top-level fields; the run is left untouched. Before rendering, every planned PNG is classified: journaled with a matching on-disk SHA-256 → kept and never rewritten (same inode); absent → regenerated; corrupt, oversized or non-regular (e.g. symlink) → moved aside with a no-clobber hard link to `<name>.corrupt-aNNN-K` and regenerated (never deleted); a directory in place of a PNG refuses the resume; an existing file without a journal entry (crash between publication and journal append) is compared with the regenerated bytes and kept when identical, otherwise moved aside. Journal lines naming a path outside the plan refuse the resume; torn/unparseable lines only make that file unverified. Images whose four maps are all verified skip the GPU; progress starts at the verified pass count and labels them *al aanwezig, checksums kloppen*. A previous top-level `cancelled.json`/`failed.json` is archived as `attempt-NNN-<marker>.json` (attempt 0 = original run), so a resumed run can be cancelled again (new `cancelled.json` with `attempt`, `completed_passes`, `png_files_done`) and `export_set` still opens it once complete. `resume.jsonl` appends one record per resume attempt (verified/missing/quarantined/unjournaled/damaged-journal counts and archived marker). `complete.json` is written only when all planned PNGs have verified checksums and is identical in form to an uninterrupted run. Foreign `.part-*` temporaries are never deleted; atomic writes now use per-write unique temp names (`part-<pid>-<seq>`) so a stale temp from a crashed process with a reused PID cannot block publication. Other runs are never opened for writing. Runs created before this change have no journal: all their existing PNGs are treated as unjournaled and verified by byte comparison with regenerated output. The editor lists matching incomplete runs (same sex/set, newest first, at most 20) under **Onderbroken renders** in the body and equipment views; mismatched or locked runs are shown with the reason and no **Hervat** action. Real Xvfb/Mesa proof: an `abort()`-crashed run, resumed, cancelled again, damaged and resumed to completion produced a `png_sha256` map identical to an uninterrupted run of the same project, while the earlier completed run stayed byte-identical (`examples/resume_fixture.rs`).134
134
@@ -184,9 +184,9 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s184
184
185
## 10. Rendered 2D preview185
## 10. Rendered 2D preview186
186
187
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation over exactly `frames/fps` seconds so every frame lasts `1/fps`; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the frame with aspect-preserving nearest-neighbor letterboxing. View modes: **Resultaat** (relit composite), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map**, **Alleen specular map** (map modes show the depth-composited winning-layer map unlit). Light azimuth wraps full 360°; elevation stays −80…+80; light affects Resultaat only. Rim/edge lighting is removed (t958u). The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.187
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the validating `.ppda`/atlas reader (`atlas::AtlasSet`; identity + SHA-256), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation over exactly `frames/fps` seconds so every frame lasts `1/fps`; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the frame with aspect-preserving nearest-neighbor letterboxing. View modes: **Resultaat** (relit composite), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map**, **Alleen specular map** (map modes show the depth-composited winning-layer map unlit). Light azimuth wraps full 360°; elevation stays −80…+80; light affects Resultaat only. Rim/edge lighting is removed (t958u). The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.188
188
189
Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.189
Left/right controls wrap among twelve yaw values. Up/down selects one of five pitch rows (−60…+60, start 0) with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.190
190
191
Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` / `compose_and_relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). Rim/edge lighting was removed (Tim t958u). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u).191
Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` / `compose_and_relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). Rim/edge lighting was removed (Tim t958u). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u).192
192
@@ -198,7 +198,7 @@ After every merge, `README.md` describes only the current behavior: what the pro198
198
199
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.199
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.200
200
201
Live 3D preview, as built: the body screen draws the skinned glTF/GLB or FBX mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ or FBX equipment (§9); FBX bodies are drawn from the normalized `fbx_scene`. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.201
Live 3D preview, as built: the body screen draws the skinned glTF/GLB or FBX mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches −60/−30/0/+30/+60 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ or FBX equipment (§9); FBX bodies are drawn from the normalized `fbx_scene`. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.202
202
203
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.203
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.204
204
@@ -221,7 +221,7 @@ These requirements are durable even where the implementation is staged. They sup221
- 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.221
- 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.222
- 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.222
- 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.223
223
224
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback (view modes Resultaat/map-only, full-circle light azimuth, loop on frames/fps; rim removed per t958u) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.224
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 60 views with the six real pass previews and four target maps described above, then per-map atlases plus `.ppda`, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback (view modes Resultaat/map-only, full-circle light azimuth, loop on frames/fps; rim removed per t958u) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.225
225
226
## 12. Incremental feature plan and acceptance gates226
## 12. Incremental feature plan and acceptance gates227
227
@@ -230,7 +230,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p230
2. Normalized import and animation inventory: glTF/GLB fixtures first, required OBJ+MTL/textures and FBX adapter proof; format-specific truthful UI/errors and skeleton/clip inventories; integration fixture tests before declaring format support.230
2. Normalized import and animation inventory: glTF/GLB fixtures first, required OBJ+MTL/textures and FBX adapter proof; format-specific truthful UI/errors and skeleton/clip inventories; integration fixture tests before declaring format support.231
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.231
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.232
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.232
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.233
5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.233
5. Shared batch camera/sampling and raw buffers: 60 views (t955u; was 36), common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.234
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (No rim accents; preview uses relight only — t958u.)234
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (No rim accents; preview uses relight only — t958u.)235
7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping on frames/fps, sex/equipment/clip selection, view modes (Resultaat + map-only), discrete view controls, continuous held light controls with full-circle azimuth; deterministic state tests and GUI smoke.235
7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping on frames/fps, sex/equipment/clip selection, view modes (Resultaat + map-only), discrete view controls, continuous held light controls with full-circle azimuth; deterministic state tests and GUI smoke.236
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.236
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.@@ -360,6 +360,16 @@ Bepaal ook welk 3D-model format goed is; liefst ondersteunen we natuurlijk meerd360
- **Default framerate:** new projects default to **4 fps** instead of 12. Existing `.ppd` files keep their stored `fps`.360
- **Default framerate:** new projects default to **4 fps** instead of 12. Existing `.ppd` files keep their stored `fps`.361
- **`.ppda` as the consumer format (Tim t957u):** the `.ppda` plus its atlases is the primary format for consumers. The editor's 2D previewer loads an export through the `.ppda` and its atlases rather than the loose PNGs, and the eventual game renderer uses the same format. The loose PNGs stay available as export output. The `.ppda` reader validates the version, filenames (no paths outside the export folder), atlas dimensions, cell bounds and SHA-256 before use.361
- **`.ppda` as the consumer format (Tim t957u):** the `.ppda` plus its atlases is the primary format for consumers. The editor's 2D previewer loads an export through the `.ppda` and its atlases rather than the loose PNGs, and the eventual game renderer uses the same format. The loose PNGs stay available as export output. The `.ppda` reader validates the version, filenames (no paths outside the export folder), atlas dimensions, cell bounds and SHA-256 before use.362
362
363
**Implemented (9b, `atlas` module):**364
- Production plans 60 views per sample (`preview::PRODUCTION_PITCHES` × `production_yaws()`); `render.json`/`complete.json` are output schema 4. New projects default to `project::DEFAULT_FPS = 4`. The legacy `.ppd` shape-validation memory heuristic keeps its factor 36 so 256×256 targets stay valid (production streams one view at a time).365
- Location: atlases and `.ppda` sit in the run root (`renders/run-…/male-body-diffuse-atlas.png`, `…/male-body-atlas.ppda`); one run holds one sex/set.366
- Write order after the last loose PNG is verified (fresh or resumed run): four atlases (diffuse, normal, depth, specular) → `.ppda` → `complete.json` (lists the atlas/`.ppda` names + SHA-256). Each atlas is streamed one cell row at a time from the checksum-verified loose PNGs (re-decoded under the map pixel contract), hashed while written, published via temp file + fsync + no-clobber hard link. If an atlas or `.ppda` already exists (crash after publication), it is kept when byte-identical to the regenerated output (encoding is deterministic) and otherwise moved aside as `<name>.corrupt-aNNN-K`. Cancellation is checked per row; `cancelled.json`/`failed.json` follow the normal rules.367
- Limit: `MAX_ATLAS_DIM = 16384` per side. `production::contract` refuses the job before rendering when 60 × cell width or rows × cell height would exceed it; atlases are never split.368
- Diffuse atlas carries the sRGB chunk like the loose diffuse maps; depth atlas is 16-bit gray+alpha; normal/specular RGBA8.369
- `.ppda` fields as listed above, with two additions needed by consumers: `identity` (body/equipment SHA-256, framing, pivot-source kind, near/far, camera distance, normal and depth encoding) so body and equipment atlases can be checked for compositing compatibility without the run manifests, and per-clip `token`; each frame is `{index, views:[{pitch,yaw,x,y,w,h}×60]}`; `maps.<map>` also records `width`/`height`. All structs use `deny_unknown_fields`.370
- Reader `atlas::AtlasSet::open`/`open_run` (exactly one `*-atlas.ppda` per folder): bounded read, strict JSON, version 1, name = `<sex>-<settype>-atlas.ppda`, sex/settype/project/identity/fps/cell/pitch/yaw checks, every view must equal the deterministic grid cell (implies in-bounds), map file names must equal `<sex>-<settype>-<map>-atlas.png` (no paths), regular non-symlink files inside the folder, SHA-256, PNG header size/colour type. `load()` decodes all four with the loose-map pixel rules (2 GiB decoded budget per set); `LoadedAtlas::cell` crops one view. `CompatKey::from_ppda` + `assert_compatible_keys` and `LayerMaps::from_atlas` feed the compositor. `atlas::cross_check_run` compares every cell with its loose PNG (used by the GPU proofs).371
- Preview2d discovers runs through `AtlasSet::open_run` (runs without a valid `.ppda`, including older schema-3 runs, are skipped and counted with reasons), decodes the selected body + equipment once and caches them until the selection or catalog changes.372
363
## Decision 2026-11-07 (Tim t958u): full-circle light azimuth, rim lighting removed373
## Decision 2026-11-07 (Tim t958u): full-circle light azimuth, rim lighting removed364
374
365
- **Light azimuth:** in the 2D previewer, holding the light buttons rotates the azimuth through the full 360 degrees and keeps going (wrapping modulo 360, no clamp). Elevation keeps its −80…+80 clamp.375
- **Light azimuth:** in the 2D previewer, holding the light buttons rotates the azimuth through the full 360 degrees and keeps going (wrapping modulo 360, no clamp). Elevation keeps its −80…+80 clamp.The contents of this binary file are not shown.
@@ -154,7 +154,8 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str
154
154
continue;
155
155
}
156
156
let name = path.file_name().unwrap().to_string_lossy().to_string();
157
if !name.ends_with(".png") {
157
if !name.ends_with(".png") || name.ends_with("-atlas.png") {
158
// Atlases are verified cell-by-cell by atlas::cross_check_run.
158
159
continue;
159
160
}
160
161
let map = name
@@ -190,7 +191,9 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str
190
191
}
191
192
}
192
193
}
194
let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)?;
193
195
runs.push(serde_json::json!({
196
"atlas": atlas,
194
197
"dir": dir.display().to_string(),
195
198
"png_files": run.complete.png_files,
196
199
"maps": counts,
@@ -154,7 +154,8 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str154
continue;154
continue;155
}155
}156
let name = path.file_name().unwrap().to_string_lossy().to_string();156
let name = path.file_name().unwrap().to_string_lossy().to_string();157
if !name.ends_with(".png") {157
if !name.ends_with(".png") || name.ends_with("-atlas.png") {158
// Atlases are verified cell-by-cell by atlas::cross_check_run.158
continue;159
continue;159
}160
}160
let map = name161
let map = name@@ -190,7 +191,9 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str190
}191
}191
}192
}192
}193
}194
let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)?;193
runs.push(serde_json::json!({195
runs.push(serde_json::json!({196
"atlas": atlas,194
"dir": dir.display().to_string(),197
"dir": dir.display().to_string(),195
"png_files": run.complete.png_files,198
"png_files": run.complete.png_files,196
"maps": counts,199
"maps": counts,@@ -1,16 +1,13 @@
1
//! Native GUI smoke for the rendered 2D preview tab (schema-3 export fixtures).
2
//! Usage: preview2d_smoke OUTPUT_DIR
1
//! Native GUI smoke for the rendered 2D preview tab: it loads a synthetic
2
//! export through its `.ppda` + atlases (loose PNGs are deleted first).
3
//! Usage: preview2d_smoke OUTPUT_DIR [EXISTING_PROJECT_DIR]
3
4
4
5
use eframe::App;
5
use paperdoll_renderer::digest;
6
6
use paperdoll_renderer::editor::EditorApp;
7
use paperdoll_renderer::export_set::complete_identity_json;
7
use paperdoll_renderer::export_fixture::{Pixel, SyntheticRun};
8
8
use paperdoll_renderer::preview2d::discover_completed_runs;
9
use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
10
use paperdoll_renderer::project::CameraFraming;
11
9
use paperdoll_renderer::render_job::ImageBuf;
12
10
use paperdoll_renderer::session::Session;
13
use std::collections::BTreeMap;
14
11
use std::fs;
15
12
use std::path::{Path, PathBuf};
16
13
use std::time::{Duration, Instant};
@@ -21,6 +18,8 @@ struct Smoke {
21
18
started: Instant,
22
19
frames: u64,
23
20
done: bool,
21
shot_requested: bool,
22
shot_saved: bool,
24
23
}
25
24
26
25
impl App for Smoke {
@@ -30,13 +29,44 @@ impl App for Smoke {
30
29
if self.frames == 2 {
31
30
self.editor.open_preview2d();
32
31
}
32
if self.frames == 4 {
33
// Steepest pitch row (+60) and yaw 30 prove the five-row atlas layout.
34
let state = self.editor.preview2d_state_mut();
35
state.step_pitch(2);
36
state.step_yaw(1);
37
}
38
ctx.request_repaint();
39
if self.frames >= 30 && !self.shot_saved {
40
if !self.shot_requested {
41
self.shot_requested = true;
42
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);
43
} else if let Some(image) = ctx.input(|i| {
44
i.raw.events.iter().find_map(|e| match e {
45
eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),
46
_ => None,
47
})
48
}) {
49
let [w, h] = image.size;
50
let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
51
write_png(
52
&self.out.join("preview2d-gui.png"),
53
&ImageBuf {
54
width: w as u32,
55
height: h as u32,
56
rgba,
57
},
58
)
59
.expect("png");
60
self.shot_saved = true;
61
}
62
}
33
63
let ready = self.editor.preview2d_image().is_some()
34
64
|| self.editor.preview2d_state().diagnostic.is_some();
35
let timeout = self.started.elapsed() > Duration::from_secs(8);
65
let timeout = self.started.elapsed() > Duration::from_secs(20);
36
66
if self.done {
37
67
return;
38
68
}
39
if (self.frames >= 12 && ready) || timeout {
69
if (self.shot_saved && ready) || timeout {
40
70
self.done = true;
41
71
let state = self.editor.preview2d_state();
42
72
let image_ok = if let Some(img) = self.editor.preview2d_image() {
@@ -46,7 +76,8 @@ impl App for Smoke {
46
76
false
47
77
};
48
78
let value = serde_json::json!({
49
"success": image_ok,
79
"success": image_ok && self.shot_saved,
80
"screenshot": self.shot_saved,
50
81
"gui_updates": self.frames,
51
82
"sex": state.sex_token(),
52
83
"clip": state.clip_token,
@@ -83,173 +114,26 @@ fn write_png(path: &Path, img: &ImageBuf) -> Result<(), String> {
83
114
.map_err(|e| e.to_string())
84
115
}
85
116
86
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {
87
let data = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
88
let path = root.join(name);
89
let temp = path.with_extension(format!("part-{}", std::process::id()));
90
fs::write(&temp, &data).map_err(|e| e.to_string())?;
91
fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
92
let _ = fs::remove_file(temp);
93
Ok(())
94
}
95
96
#[allow(clippy::too_many_arguments)]
97
fn publish_pose(
98
root: &Path,
99
sex: &str,
100
set: &str,
101
clip: &str,
102
frame: u32,
103
pitch: i32,
104
yaw: u32,
105
diffuse: [u8; 4],
106
depth_gray: u16,
107
) -> Result<BTreeMap<String, String>, String> {
108
let mut map = BTreeMap::new();
109
let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
110
let normal = [128u8, 128, 255, 255];
111
let specular = [0u8, 0, 0, 255];
112
for (rel, kind) in [
113
(format!("{base}_diffuse.png"), "diffuse"),
114
(format!("{base}_depth.png"), "depth"),
115
(format!("{base}_normal.png"), "normal"),
116
(format!("{base}_specular.png"), "specular"),
117
] {
118
let path = Path::new(&rel);
119
match kind {
120
"depth" => {
121
write_depth(
122
root,
123
path,
124
2,
125
2,
126
&[
127
depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, depth_gray, 65535,
128
],
129
)?;
130
}
131
"normal" => {
132
write_rgba(
133
root,
134
path,
135
&ImageBuf {
136
width: 2,
137
height: 2,
138
rgba: normal.repeat(4),
139
},
140
)?;
141
}
142
"specular" => {
143
write_rgba(
144
root,
145
path,
146
&ImageBuf {
147
width: 2,
148
height: 2,
149
rgba: specular.repeat(4),
150
},
151
)?;
152
}
153
_ => {
154
write_rgba(
155
root,
156
path,
157
&ImageBuf {
158
width: 2,
159
height: 2,
160
rgba: diffuse.repeat(4),
161
},
162
)?;
163
}
164
}
165
let bytes = fs::read(root.join(&rel)).map_err(|e| e.to_string())?;
166
map.insert(rel, digest::sha256_hex(&bytes));
167
}
168
Ok(map)
169
}
170
117
/// Body export: 2 frames, every view gets a distinct colour from its
118
/// pitch/yaw, written with real loose PNGs + atlases + .ppda.
171
119
fn install_body_export(project: &Path) -> Result<(), String> {
172
let run = project.join("renders/run-preview2d-body");
173
fs::create_dir_all(&run).map_err(|e| e.to_string())?;
174
let body_bytes = b"preview2d-smoke-body";
175
let body_hash = digest::sha256_hex(body_bytes);
176
let clips = vec![serde_json::json!({
177
"name": "walk",
178
"token": "clip000-walk",
179
"frames": 2,
180
"duration": 0.1667
181
})];
182
let mut checksums = BTreeMap::new();
183
for frame in [0u32, 1] {
184
for pitch in [-30i32, 0, 30] {
185
for yaw in [0u32, 30, 90] {
186
let tint = [40u8.wrapping_add(frame as u8 * 80), 90, 140, 255];
187
checksums.extend(publish_pose(
188
&run,
189
"male",
190
"body",
191
"clip000-walk",
192
frame,
193
pitch,
194
yaw,
195
tint,
196
25000 - frame as u16 * 1000,
197
)?);
198
}
199
}
200
}
201
let png_files = checksums.len() as u32;
202
let render = serde_json::json!({
203
"format_version": 3,
204
"status": "incomplete",
205
"project": {"id": "smoke-preview2d", "name": "Preview2d Smoke"},
206
"sex": "male",
207
"set": "body",
208
"body_model": {"path": "assets/body/test-body-male.glb", "sha256": body_hash},
209
"equipment_preset": null,
210
"target": [2, 2],
211
"fps": 12,
212
"highres_scale": 8,
213
"framing": CameraFraming {
214
height_milli: 1800,
215
center_y_milli: 900,
216
pivot_y_milli: Some(1500),
120
let mut spec = SyntheticRun::body("smoke-preview2d", "male", b"preview2d-smoke-body");
121
spec.target = [2, 2];
122
spec.clips = vec![("walk".into(), 2, 0.5)];
123
spec.write(
124
&project.join("renders/run-preview2d-body"),
125
|_, frame, pitch, yaw| Pixel {
126
diffuse: [
127
40u8.wrapping_add(frame as u8 * 80),
128
(pitch + 90) as u8,
129
(yaw / 2) as u8,
130
255,
131
],
132
normal: [128, 128, 255, 255],
133
specular: [0, 0, 0, 255],
134
depth: 25000 - frame as u16 * 1000,
217
135
},
218
"pivot_source": {"kind": "manual"},
219
"near": NEAR,
220
"far": FAR,
221
"camera_distance": 10.0,
222
"yaw_zero": "camera on -Z",
223
"normal_space": "view (+Y up), PNG rows top-down",
224
"pitches": [-30, 0, 30],
225
"yaw_step": 30,
226
"sampling": "smoke",
227
"alpha": "straight",
228
"diffuse": "unlit",
229
"depth": "16-bit",
230
"specular": "scalar",
231
"pass_order": PASSES,
232
"saved_maps": ["diffuse", "depth", "normal", "specular"],
233
"clips": clips,
234
"images": 2 * 36,
235
"passes": 2 * 36 * 6,
236
"png_files": png_files
237
});
238
write_atomic_json(&run, "render.json", &render)?;
239
let identity = complete_identity_json(&render)?;
240
write_atomic_json(
241
&run,
242
"complete.json",
243
&serde_json::json!({
244
"format_version": 3,
245
"status": "complete",
246
"identity": identity,
247
"passes": 2 * 36 * 6,
248
"png_files": png_files,
249
"png_sha256": checksums
250
}),
251
)?;
252
Ok(())
136
)
253
137
}
254
138
255
139
fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -259,16 +143,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
259
143
.ok_or("usage: preview2d_smoke OUTPUT_DIR")?,
260
144
);
261
145
fs::create_dir_all(&out)?;
262
let project = out.join("project");
263
let mut session = Session::new();
264
session.install_new_project(&project)?;
265
session.save()?;
266
install_body_export(&project)?;
146
// Optional second argument: an existing project with real production
147
// exports (its loose PNGs are left alone).
148
let project = match std::env::args().nth(2) {
149
Some(existing) => PathBuf::from(existing),
150
None => {
151
let project = out.join("project");
152
let mut session = Session::new();
153
session.install_new_project(&project)?;
154
session.save()?;
155
install_body_export(&project)?;
156
// Prove the preview reads the atlases: remove every loose PNG.
157
fs::remove_dir_all(project.join("renders/run-preview2d-body/male"))?;
158
project
159
}
160
};
267
161
let catalog = discover_completed_runs(&project);
268
162
fs::write(
269
163
out.join("catalog.json"),
270
164
serde_json::to_vec_pretty(&serde_json::json!({
271
165
"count": catalog.len(),
166
"fps": catalog.iter().map(|e| e.fps).collect::<Vec<_>>(),
272
167
"sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),
273
168
}))?,
274
169
)?;
@@ -291,6 +186,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
291
186
started: Instant::now(),
292
187
frames: 0,
293
188
done: false,
189
shot_requested: false,
190
shot_saved: false,
294
191
}) as Box<dyn App>)
295
192
}),
296
193
)
@@ -1,16 +1,13 @@1
//! Native GUI smoke for the rendered 2D preview tab (schema-3 export fixtures).1
//! Native GUI smoke for the rendered 2D preview tab: it loads a synthetic2
//! Usage: preview2d_smoke OUTPUT_DIR2
//! export through its `.ppda` + atlases (loose PNGs are deleted first).3
//! Usage: preview2d_smoke OUTPUT_DIR [EXISTING_PROJECT_DIR]3
4
4
use eframe::App;5
use eframe::App;5
use paperdoll_renderer::digest;6
use paperdoll_renderer::editor::EditorApp;6
use paperdoll_renderer::editor::EditorApp;7
use paperdoll_renderer::export_set::complete_identity_json;7
use paperdoll_renderer::export_fixture::{Pixel, SyntheticRun};8
use paperdoll_renderer::preview2d::discover_completed_runs;8
use paperdoll_renderer::preview2d::discover_completed_runs;9
use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES};10
use paperdoll_renderer::project::CameraFraming;11
use paperdoll_renderer::render_job::ImageBuf;9
use paperdoll_renderer::render_job::ImageBuf;12
use paperdoll_renderer::session::Session;10
use paperdoll_renderer::session::Session;13
use std::collections::BTreeMap;14
use std::fs;11
use std::fs;15
use std::path::{Path, PathBuf};12
use std::path::{Path, PathBuf};16
use std::time::{Duration, Instant};13
use std::time::{Duration, Instant};@@ -21,6 +18,8 @@ struct Smoke {21
started: Instant,18
started: Instant,22
frames: u64,19
frames: u64,23
done: bool,20
done: bool,21
shot_requested: bool,22
shot_saved: bool,24
}23
}25
24
26
impl App for Smoke {25
impl App for Smoke {@@ -30,13 +29,44 @@ impl App for Smoke {30
if self.frames == 2 {29
if self.frames == 2 {31
self.editor.open_preview2d();30
self.editor.open_preview2d();32
}31
}32
if self.frames == 4 {33
// Steepest pitch row (+60) and yaw 30 prove the five-row atlas layout.34
let state = self.editor.preview2d_state_mut();35
state.step_pitch(2);36
state.step_yaw(1);37
}38
ctx.request_repaint();39
if self.frames >= 30 && !self.shot_saved {40
if !self.shot_requested {41
self.shot_requested = true;42
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);43
} else if let Some(image) = ctx.input(|i| {44
i.raw.events.iter().find_map(|e| match e {45
eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),46
_ => None,47
})48
}) {49
let [w, h] = image.size;50
let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();51
write_png(52
&self.out.join("preview2d-gui.png"),53
&ImageBuf {54
width: w as u32,55
height: h as u32,56
rgba,57
},58
)59
.expect("png");60
self.shot_saved = true;61
}62
}33
let ready = self.editor.preview2d_image().is_some()63
let ready = self.editor.preview2d_image().is_some()34
|| self.editor.preview2d_state().diagnostic.is_some();64
|| self.editor.preview2d_state().diagnostic.is_some();35
let timeout = self.started.elapsed() > Duration::from_secs(8);65
let timeout = self.started.elapsed() > Duration::from_secs(20);36
if self.done {66
if self.done {37
return;67
return;38
}68
}39
if (self.frames >= 12 && ready) || timeout {69
if (self.shot_saved && ready) || timeout {40
self.done = true;70
self.done = true;41
let state = self.editor.preview2d_state();71
let state = self.editor.preview2d_state();42
let image_ok = if let Some(img) = self.editor.preview2d_image() {72
let image_ok = if let Some(img) = self.editor.preview2d_image() {@@ -46,7 +76,8 @@ impl App for Smoke {46
false76
false47
};77
};48
let value = serde_json::json!({78
let value = serde_json::json!({49
"success": image_ok,79
"success": image_ok && self.shot_saved,80
"screenshot": self.shot_saved,50
"gui_updates": self.frames,81
"gui_updates": self.frames,51
"sex": state.sex_token(),82
"sex": state.sex_token(),52
"clip": state.clip_token,83
"clip": state.clip_token,@@ -83,173 +114,26 @@ fn write_png(path: &Path, img: &ImageBuf) -> Result<(), String> {83
.map_err(|e| e.to_string())114
.map_err(|e| e.to_string())84
}115
}85
116
86
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {117
/// Body export: 2 frames, every view gets a distinct colour from its87
let data = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;118
/// pitch/yaw, written with real loose PNGs + atlases + .ppda.88
let path = root.join(name);89
let temp = path.with_extension(format!("part-{}", std::process::id()));90
fs::write(&temp, &data).map_err(|e| e.to_string())?;91
fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;92
let _ = fs::remove_file(temp);93
Ok(())94
}95
96
#[allow(clippy::too_many_arguments)]97
fn publish_pose(98
root: &Path,99
sex: &str,100
set: &str,101
clip: &str,102
frame: u32,103
pitch: i32,104
yaw: u32,105
diffuse: [u8; 4],106
depth_gray: u16,107
) -> Result<BTreeMap<String, String>, String> {108
let mut map = BTreeMap::new();109
let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");110
let normal = [128u8, 128, 255, 255];111
let specular = [0u8, 0, 0, 255];112
for (rel, kind) in [113
(format!("{base}_diffuse.png"), "diffuse"),114
(format!("{base}_depth.png"), "depth"),115
(format!("{base}_normal.png"), "normal"),116
(format!("{base}_specular.png"), "specular"),117
] {118
let path = Path::new(&rel);119
match kind {120
"depth" => {121
write_depth(122
root,123
path,124
2,125
2,126
&[127
depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, depth_gray, 65535,128
],129
)?;130
}131
"normal" => {132
write_rgba(133
root,134
path,135
&ImageBuf {136
width: 2,137
height: 2,138
rgba: normal.repeat(4),139
},140
)?;141
}142
"specular" => {143
write_rgba(144
root,145
path,146
&ImageBuf {147
width: 2,148
height: 2,149
rgba: specular.repeat(4),150
},151
)?;152
}153
_ => {154
write_rgba(155
root,156
path,157
&ImageBuf {158
width: 2,159
height: 2,160
rgba: diffuse.repeat(4),161
},162
)?;163
}164
}165
let bytes = fs::read(root.join(&rel)).map_err(|e| e.to_string())?;166
map.insert(rel, digest::sha256_hex(&bytes));167
}168
Ok(map)169
}170
171
fn install_body_export(project: &Path) -> Result<(), String> {119
fn install_body_export(project: &Path) -> Result<(), String> {172
let run = project.join("renders/run-preview2d-body");120
let mut spec = SyntheticRun::body("smoke-preview2d", "male", b"preview2d-smoke-body");173
fs::create_dir_all(&run).map_err(|e| e.to_string())?;121
spec.target = [2, 2];174
let body_bytes = b"preview2d-smoke-body";122
spec.clips = vec![("walk".into(), 2, 0.5)];175
let body_hash = digest::sha256_hex(body_bytes);123
spec.write(176
let clips = vec![serde_json::json!({124
&project.join("renders/run-preview2d-body"),177
"name": "walk",125
|_, frame, pitch, yaw| Pixel {178
"token": "clip000-walk",126
diffuse: [179
"frames": 2,127
40u8.wrapping_add(frame as u8 * 80),180
"duration": 0.1667128
(pitch + 90) as u8,181
})];129
(yaw / 2) as u8,182
let mut checksums = BTreeMap::new();130
255,183
for frame in [0u32, 1] {131
],184
for pitch in [-30i32, 0, 30] {132
normal: [128, 128, 255, 255],185
for yaw in [0u32, 30, 90] {133
specular: [0, 0, 0, 255],186
let tint = [40u8.wrapping_add(frame as u8 * 80), 90, 140, 255];134
depth: 25000 - frame as u16 * 1000,187
checksums.extend(publish_pose(188
&run,189
"male",190
"body",191
"clip000-walk",192
frame,193
pitch,194
yaw,195
tint,196
25000 - frame as u16 * 1000,197
)?);198
}199
}200
}201
let png_files = checksums.len() as u32;202
let render = serde_json::json!({203
"format_version": 3,204
"status": "incomplete",205
"project": {"id": "smoke-preview2d", "name": "Preview2d Smoke"},206
"sex": "male",207
"set": "body",208
"body_model": {"path": "assets/body/test-body-male.glb", "sha256": body_hash},209
"equipment_preset": null,210
"target": [2, 2],211
"fps": 12,212
"highres_scale": 8,213
"framing": CameraFraming {214
height_milli: 1800,215
center_y_milli: 900,216
pivot_y_milli: Some(1500),217
},135
},218
"pivot_source": {"kind": "manual"},136
)219
"near": NEAR,220
"far": FAR,221
"camera_distance": 10.0,222
"yaw_zero": "camera on -Z",223
"normal_space": "view (+Y up), PNG rows top-down",224
"pitches": [-30, 0, 30],225
"yaw_step": 30,226
"sampling": "smoke",227
"alpha": "straight",228
"diffuse": "unlit",229
"depth": "16-bit",230
"specular": "scalar",231
"pass_order": PASSES,232
"saved_maps": ["diffuse", "depth", "normal", "specular"],233
"clips": clips,234
"images": 2 * 36,235
"passes": 2 * 36 * 6,236
"png_files": png_files237
});238
write_atomic_json(&run, "render.json", &render)?;239
let identity = complete_identity_json(&render)?;240
write_atomic_json(241
&run,242
"complete.json",243
&serde_json::json!({244
"format_version": 3,245
"status": "complete",246
"identity": identity,247
"passes": 2 * 36 * 6,248
"png_files": png_files,249
"png_sha256": checksums250
}),251
)?;252
Ok(())253
}137
}254
138
255
fn main() -> Result<(), Box<dyn std::error::Error>> {139
fn main() -> Result<(), Box<dyn std::error::Error>> {@@ -259,16 +143,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {259
.ok_or("usage: preview2d_smoke OUTPUT_DIR")?,143
.ok_or("usage: preview2d_smoke OUTPUT_DIR")?,260
);144
);261
fs::create_dir_all(&out)?;145
fs::create_dir_all(&out)?;262
let project = out.join("project");146
// Optional second argument: an existing project with real production263
let mut session = Session::new();147
// exports (its loose PNGs are left alone).264
session.install_new_project(&project)?;148
let project = match std::env::args().nth(2) {265
session.save()?;149
Some(existing) => PathBuf::from(existing),266
install_body_export(&project)?;150
None => {151
let project = out.join("project");152
let mut session = Session::new();153
session.install_new_project(&project)?;154
session.save()?;155
install_body_export(&project)?;156
// Prove the preview reads the atlases: remove every loose PNG.157
fs::remove_dir_all(project.join("renders/run-preview2d-body/male"))?;158
project159
}160
};267
let catalog = discover_completed_runs(&project);161
let catalog = discover_completed_runs(&project);268
fs::write(162
fs::write(269
out.join("catalog.json"),163
out.join("catalog.json"),270
serde_json::to_vec_pretty(&serde_json::json!({164
serde_json::to_vec_pretty(&serde_json::json!({271
"count": catalog.len(),165
"count": catalog.len(),166
"fps": catalog.iter().map(|e| e.fps).collect::<Vec<_>>(),272
"sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),167
"sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),273
}))?,168
}))?,274
)?;169
)?;@@ -291,6 +186,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {291
started: Instant::now(),186
started: Instant::now(),292
frames: 0,187
frames: 0,293
done: false,188
done: false,189
shot_requested: false,190
shot_saved: false,294
}) as Box<dyn App>)191
}) as Box<dyn App>)295
}),192
}),296
)193
)@@ -124,5 +124,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
124
124
if report["success"] != true {
125
125
return Err("production smoke failed; see smoke-result.json".into());
126
126
}
127
// Newest run: loose maps and .ppda atlases must agree cell by cell.
128
let mut runs = std::fs::read_dir(root.join("renders"))?
129
.map(|e| e.map(|e| e.path()))
130
.collect::<Result<Vec<_>, _>>()?;
131
runs.retain(|p| p.is_dir());
132
runs.sort();
133
let run = runs.last().ok_or("geen run")?;
134
let atlas = paperdoll_renderer::atlas::cross_check_run(run)?;
135
println!("ATLAS {atlas}");
136
std::fs::write(
137
root.join("atlas-check.json"),
138
serde_json::to_vec_pretty(&atlas)?,
139
)?;
127
140
Ok(())
128
141
}
@@ -124,5 +124,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {124
if report["success"] != true {124
if report["success"] != true {125
return Err("production smoke failed; see smoke-result.json".into());125
return Err("production smoke failed; see smoke-result.json".into());126
}126
}127
// Newest run: loose maps and .ppda atlases must agree cell by cell.128
let mut runs = std::fs::read_dir(root.join("renders"))?129
.map(|e| e.map(|e| e.path()))130
.collect::<Result<Vec<_>, _>>()?;131
runs.retain(|p| p.is_dir());132
runs.sort();133
let run = runs.last().ok_or("geen run")?;134
let atlas = paperdoll_renderer::atlas::cross_check_run(run)?;135
println!("ATLAS {atlas}");136
std::fs::write(137
root.join("atlas-check.json"),138
serde_json::to_vec_pretty(&atlas)?,139
)?;127
Ok(())140
Ok(())128
}141
}@@ -160,18 +160,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
160
160
let mode = a.first().ok_or("mode")?.clone();
161
161
let root = PathBuf::from(a.get(1).ok_or("ROOT")?);
162
162
if mode == "verify" {
163
// Open every run directory through the strict schema-3 export_set reader.
163
// Open every run directory through export_set and the .ppda atlas reader.
164
164
let mut dirs = std::fs::read_dir(root.join("renders"))?
165
165
.map(|e| e.map(|e| e.path()))
166
166
.collect::<Result<Vec<_>, _>>()?;
167
167
dirs.sort();
168
168
for dir in dirs {
169
169
match paperdoll_renderer::export_set::ExportRun::open(&dir) {
170
Ok(run) => println!(
171
"OPEN-OK {} png_files={}",
172
dir.display(),
173
run.complete.png_files
174
),
170
Ok(run) => {
171
let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)
172
.map(|a| a["cells_equal_to_loose_pngs"].to_string())
173
.unwrap_or_else(|e| format!("FOUT {e}"));
174
println!(
175
"OPEN-OK {} png_files={} atlas_cells={atlas}",
176
dir.display(),
177
run.complete.png_files
178
)
179
}
175
180
Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),
176
181
}
177
182
}
@@ -160,18 +160,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {160
let mode = a.first().ok_or("mode")?.clone();160
let mode = a.first().ok_or("mode")?.clone();161
let root = PathBuf::from(a.get(1).ok_or("ROOT")?);161
let root = PathBuf::from(a.get(1).ok_or("ROOT")?);162
if mode == "verify" {162
if mode == "verify" {163
// Open every run directory through the strict schema-3 export_set reader.163
// Open every run directory through export_set and the .ppda atlas reader.164
let mut dirs = std::fs::read_dir(root.join("renders"))?164
let mut dirs = std::fs::read_dir(root.join("renders"))?165
.map(|e| e.map(|e| e.path()))165
.map(|e| e.map(|e| e.path()))166
.collect::<Result<Vec<_>, _>>()?;166
.collect::<Result<Vec<_>, _>>()?;167
dirs.sort();167
dirs.sort();168
for dir in dirs {168
for dir in dirs {169
match paperdoll_renderer::export_set::ExportRun::open(&dir) {169
match paperdoll_renderer::export_set::ExportRun::open(&dir) {170
Ok(run) => println!(170
Ok(run) => {171
"OPEN-OK {} png_files={}",171
let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)172
dir.display(),172
.map(|a| a["cells_equal_to_loose_pngs"].to_string())173
run.complete.png_files173
.unwrap_or_else(|e| format!("FOUT {e}"));174
),174
println!(175
"OPEN-OK {} png_files={} atlas_cells={atlas}",176
dir.display(),177
run.complete.png_files178
)179
}175
Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),180
Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),176
}181
}177
}182
}@@ -3,7 +3,7 @@
3
3
//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned
4
4
//! glTF body as female, screenshots the live viewport for both and the
5
5
//! equipment screen (bone binding unavailable), runs the production batch for
6
//! both bodies (one bind sample × 36 views × four maps), checks that an
6
//! both bodies (one bind sample × 60 views × four maps), checks that an
7
7
//! equipment render on the static body is refused, verifies the runs with
8
8
//! `export_set`, and compares OBJ vs glTF coverage/depth view by view.
9
9
use eframe::App;
@@ -158,7 +158,10 @@ fn walk_pngs(dir: &Path) -> Result<Vec<PathBuf>, String> {
158
158
let path = entry.map_err(|e| e.to_string())?.path();
159
159
if path.is_dir() {
160
160
stack.push(path);
161
} else if path.extension().is_some_and(|x| x == "png") {
161
} else if path.extension().is_some_and(|x| x == "png")
162
&& !path.to_string_lossy().ends_with("-atlas.png")
163
{
164
// Loose target maps only; per-map atlases are checked separately.
162
165
out.push(path);
163
166
}
164
167
}
@@ -194,7 +197,9 @@ fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> {
194
197
return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
195
198
}
196
199
}
200
let atlas = paperdoll_renderer::atlas::cross_check_run(dir)?;
197
201
let report = serde_json::json!({
202
"atlas": atlas,
198
203
"dir": dir.display().to_string(),
199
204
"png_files": run.complete.png_files,
200
205
"maps": counts,
@@ -397,7 +402,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
397
402
let runs_ok = checks.len() == 2
398
403
&& equipment_runs == 0
399
404
&& checks.iter().all(|c| {
400
c.run.complete.png_files == 144
405
c.run.complete.png_files == 240
406
&& c.report["atlas"]["cells_equal_to_loose_pngs"] == 240
401
407
&& c.run.render.clips.len() == 1
402
408
&& c.run.render.clips[0].token == "clip000-bind"
403
409
});
@@ -405,7 +411,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
405
411
&& coverage_mismatch * 200 <= covered
406
412
&& obj_red > 0
407
413
&& obj_blue > 0
408
&& obj_other * 20 <= obj_red + obj_blue
414
// Mixed red/blue seam pixels from the coverage resolve; the steep
415
// ±60° pitch rows (t955u) see the seam over more pixels than ±30°.
416
&& obj_other * 10 <= obj_red + obj_blue
409
417
&& gltf_colors.len() <= 2
410
418
&& normal_bad == 0;
411
419
if !(gui_ok && runs_ok && maps_ok) {
@@ -3,7 +3,7 @@3
//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned3
//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned4
//! glTF body as female, screenshots the live viewport for both and the4
//! glTF body as female, screenshots the live viewport for both and the5
//! equipment screen (bone binding unavailable), runs the production batch for5
//! equipment screen (bone binding unavailable), runs the production batch for6
//! both bodies (one bind sample × 36 views × four maps), checks that an6
//! both bodies (one bind sample × 60 views × four maps), checks that an7
//! equipment render on the static body is refused, verifies the runs with7
//! equipment render on the static body is refused, verifies the runs with8
//! `export_set`, and compares OBJ vs glTF coverage/depth view by view.8
//! `export_set`, and compares OBJ vs glTF coverage/depth view by view.9
use eframe::App;9
use eframe::App;@@ -158,7 +158,10 @@ fn walk_pngs(dir: &Path) -> Result<Vec<PathBuf>, String> {158
let path = entry.map_err(|e| e.to_string())?.path();158
let path = entry.map_err(|e| e.to_string())?.path();159
if path.is_dir() {159
if path.is_dir() {160
stack.push(path);160
stack.push(path);161
} else if path.extension().is_some_and(|x| x == "png") {161
} else if path.extension().is_some_and(|x| x == "png")162
&& !path.to_string_lossy().ends_with("-atlas.png")163
{164
// Loose target maps only; per-map atlases are checked separately.162
out.push(path);165
out.push(path);163
}166
}164
}167
}@@ -194,7 +197,9 @@ fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> {194
return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));197
return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));195
}198
}196
}199
}200
let atlas = paperdoll_renderer::atlas::cross_check_run(dir)?;197
let report = serde_json::json!({201
let report = serde_json::json!({202
"atlas": atlas,198
"dir": dir.display().to_string(),203
"dir": dir.display().to_string(),199
"png_files": run.complete.png_files,204
"png_files": run.complete.png_files,200
"maps": counts,205
"maps": counts,@@ -397,7 +402,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {397
let runs_ok = checks.len() == 2402
let runs_ok = checks.len() == 2398
&& equipment_runs == 0403
&& equipment_runs == 0399
&& checks.iter().all(|c| {404
&& checks.iter().all(|c| {400
c.run.complete.png_files == 144405
c.run.complete.png_files == 240406
&& c.report["atlas"]["cells_equal_to_loose_pngs"] == 240401
&& c.run.render.clips.len() == 1407
&& c.run.render.clips.len() == 1402
&& c.run.render.clips[0].token == "clip000-bind"408
&& c.run.render.clips[0].token == "clip000-bind"403
});409
});@@ -405,7 +411,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {405
&& coverage_mismatch * 200 <= covered411
&& coverage_mismatch * 200 <= covered406
&& obj_red > 0412
&& obj_red > 0407
&& obj_blue > 0413
&& obj_blue > 0408
&& obj_other * 20 <= obj_red + obj_blue414
// Mixed red/blue seam pixels from the coverage resolve; the steep415
// ±60° pitch rows (t955u) see the seam over more pixels than ±30°.416
&& obj_other * 10 <= obj_red + obj_blue409
&& gltf_colors.len() <= 2417
&& gltf_colors.len() <= 2410
&& normal_bad == 0;418
&& normal_bad == 0;411
if !(gui_ok && runs_ok && maps_ok) {419
if !(gui_ok && runs_ok && maps_ok) {@@ -0,0 +1,1203 @@
1
//! Per-map atlases and the `.ppda` (PaPerDollAtlas) consumer manifest
2
//! (Tim t955u/t957u).
3
//!
4
//! After every loose target PNG of a run is published and checksum-verified,
5
//! production writes one atlas per map type (`<sex>-<settype>-<map>-atlas.png`)
6
//! and a strict JSON `.ppda` (`<sex>-<settype>-atlas.ppda`) next to them in the
7
//! run directory. Cells are exactly the target resolution, without padding or
8
//! scaling. One row per (clip, frame) in source clip order and frame order;
9
//! columns are pitch (−60→+60) × yaw (0→330). All four atlases share identical
10
//! cell coordinates. Atlases are streamed band by band (one cell row at a time),
11
//! published atomically without clobbering, and the `.ppda` is written last.
12
//!
13
//! [`AtlasSet`] is the validating consumer reader used by the 2D preview.
14
use crate::{
15
digest::{self, is_sha256_hex, Sha256},
16
export_set::{self, ExportError, MapImage, MapKind, MAX_PNG_BYTES, MAX_TARGET},
17
preview::PRODUCTION_PITCHES,
18
production::{self, Plan, FAR, NEAR},
19
project::CameraFraming,
20
};
21
use serde::{Deserialize, Serialize};
22
use std::{
23
collections::BTreeMap,
24
fs,
25
io::Write,
26
path::{Path, PathBuf},
27
sync::atomic::{AtomicBool, Ordering},
28
};
29
30
/// `.ppda` schema version written and accepted.
31
pub const PPDA_FORMAT_VERSION: u32 = 1;
32
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
33
/// refused up front with a clear message; atlases are never split.
34
pub const MAX_ATLAS_DIM: u32 = 16384;
35
/// Bounded `.ppda` size on read.
36
pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024;
37
/// Bounded encoded atlas PNG size on read.
38
pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024;
39
/// Decoded-memory budget for one loaded atlas set (four maps) in the preview.
40
pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
41
/// Map order inside the `.ppda` and atlas production.
42
pub const ATLAS_MAPS: [MapKind; 4] = [
43
MapKind::Diffuse,
44
MapKind::Normal,
45
MapKind::Depth,
46
MapKind::Specular,
47
];
48
const MAX_CLIPS: usize = 4096;
49
const MAX_CLIP_NAME: usize = 256;
50
51
pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String {
52
format!("{sex}-{set}-{}-atlas.png", kind.token())
53
}
54
55
pub fn ppda_name(sex: &str, set: &str) -> String {
56
format!("{sex}-{set}-atlas.ppda")
57
}
58
59
/// Deterministic grid shared by all four atlases of one export.
60
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61
pub struct AtlasLayout {
62
pub cell_width: u32,
63
pub cell_height: u32,
64
pub columns: u32,
65
pub rows: u32,
66
pub width: u32,
67
pub height: u32,
68
}
69
70
impl AtlasLayout {
71
pub fn new(cell_width: u32, cell_height: u32, rows: u32) -> Result<Self, String> {
72
if cell_width == 0
73
|| cell_height == 0
74
|| cell_width > MAX_TARGET
75
|| cell_height > MAX_TARGET
76
{
77
return Err("atlascel moet 1–256 pixels per as zijn".into());
78
}
79
if rows == 0 {
80
return Err("atlas zonder rijen".into());
81
}
82
let columns = (PRODUCTION_PITCHES.len() * production::production_yaws().len()) as u32;
83
let width = u64::from(columns) * u64::from(cell_width);
84
let height = u64::from(rows) * u64::from(cell_height);
85
if width > u64::from(MAX_ATLAS_DIM) || height > u64::from(MAX_ATLAS_DIM) {
86
return Err(format!(
87
"atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); \
88
maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of \
89
animatieduur — de atlas wordt niet gesplitst"
90
));
91
}
92
Ok(Self {
93
cell_width,
94
cell_height,
95
columns,
96
rows,
97
width: width as u32,
98
height: height as u32,
99
})
100
}
101
102
pub fn for_plan(plan: &Plan, cell_width: u32, cell_height: u32) -> Result<Self, String> {
103
let rows = plan
104
.clips
105
.iter()
106
.try_fold(0u32, |n, c| n.checked_add(c.frames))
107
.ok_or("te veel frames voor de atlas")?;
108
Self::new(cell_width, cell_height, rows)
109
}
110
111
/// Column of (pitch, yaw): pitch-major, then yaw.
112
pub fn column(pitch: i32, yaw: u32) -> Option<u32> {
113
let p = PRODUCTION_PITCHES.iter().position(|x| *x == pitch)?;
114
let y = production::production_yaws()
115
.iter()
116
.position(|x| *x == yaw)?;
117
Some((p * production::production_yaws().len() + y) as u32)
118
}
119
120
pub fn cell(&self, row: u32, column: u32) -> PpdaView {
121
let yaws = production::production_yaws();
122
let pitch = PRODUCTION_PITCHES[column as usize / yaws.len()];
123
let yaw = yaws[column as usize % yaws.len()];
124
PpdaView {
125
pitch,
126
yaw,
127
x: column * self.cell_width,
128
y: row * self.cell_height,
129
w: self.cell_width,
130
h: self.cell_height,
131
}
132
}
133
}
134
135
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136
#[serde(deny_unknown_fields)]
137
pub struct Ppda {
138
pub format_version: u32,
139
pub sex: String,
140
pub settype: String,
141
pub project: PpdaProject,
142
/// Export/camera contract so body and equipment atlases can be checked for
143
/// compatibility without the loose run manifests.
144
pub identity: PpdaIdentity,
145
pub fps: u32,
146
pub cell_width: u32,
147
pub cell_height: u32,
148
pub pitches: Vec<i32>,
149
pub yaws: Vec<u32>,
150
pub maps: BTreeMap<String, PpdaMap>,
151
pub clips: Vec<PpdaClip>,
152
}
153
154
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155
#[serde(deny_unknown_fields)]
156
pub struct PpdaProject {
157
pub id: String,
158
pub name: String,
159
}
160
161
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162
#[serde(deny_unknown_fields)]
163
pub struct PpdaIdentity {
164
pub body_model_sha256: String,
165
pub equipment_preset_sha256: Option<String>,
166
pub framing: CameraFraming,
167
pub pivot_source: String,
168
pub near: f32,
169
pub far: f32,
170
pub camera_distance: f32,
171
pub normal_space: String,
172
pub depth: String,
173
}
174
175
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176
#[serde(deny_unknown_fields)]
177
pub struct PpdaMap {
178
pub file: String,
179
pub sha256: String,
180
pub width: u32,
181
pub height: u32,
182
}
183
184
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185
#[serde(deny_unknown_fields)]
186
pub struct PpdaClip {
187
pub source_index: u32,
188
pub name: String,
189
pub token: String,
190
pub frame_count: u32,
191
pub duration: f32,
192
pub frames: Vec<PpdaFrame>,
193
}
194
195
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196
#[serde(deny_unknown_fields)]
197
pub struct PpdaFrame {
198
pub index: u32,
199
pub views: Vec<PpdaView>,
200
}
201
202
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203
#[serde(deny_unknown_fields)]
204
pub struct PpdaView {
205
pub pitch: i32,
206
pub yaw: u32,
207
pub x: u32,
208
pub y: u32,
209
pub w: u32,
210
pub h: u32,
211
}
212
213
/// What production records in `complete.json` after writing the atlases.
214
#[derive(Debug, Clone, PartialEq, Eq)]
215
pub struct AtlasOutput {
216
pub ppda: String,
217
pub ppda_sha256: String,
218
/// map token → (file name, sha256)
219
pub maps: BTreeMap<String, (String, String)>,
220
}
221
222
impl AtlasOutput {
223
pub fn to_json(&self) -> serde_json::Value {
224
let maps: serde_json::Map<String, serde_json::Value> = self
225
.maps
226
.iter()
227
.map(|(k, (file, sha))| (k.clone(), serde_json::json!({"file": file, "sha256": sha})))
228
.collect();
229
serde_json::json!({
230
"ppda": self.ppda,
231
"ppda_sha256": self.ppda_sha256,
232
"maps": maps,
233
})
234
}
235
}
236
237
struct HashingWriter<W: Write> {
238
inner: W,
239
hasher: Sha256,
240
}
241
242
impl<W: Write> Write for HashingWriter<W> {
243
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
244
let n = self.inner.write(buf)?;
245
self.hasher.update(&buf[..n]);
246
Ok(n)
247
}
248
fn flush(&mut self) -> std::io::Result<()> {
249
self.inner.flush()
250
}
251
}
252
253
/// Inputs for writing the atlases of one completed run.
254
pub struct AtlasJob<'a> {
255
pub root: &'a Path,
256
pub render_doc: &'a serde_json::Value,
257
pub plan: &'a Plan,
258
pub sex: &'a str,
259
pub set: &'a str,
260
pub cell_width: u32,
261
pub cell_height: u32,
262
/// Relative loose PNG path → verified SHA-256 (every planned file).
263
pub png_sha256: &'a BTreeMap<String, String>,
264
/// Resume attempt number for quarantine names of stale atlas files.
265
pub attempt: u32,
266
pub cancel: &'a AtomicBool,
267
}
268
269
fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
270
plan.clips
271
.iter()
272
.enumerate()
273
.flat_map(|(c, clip)| (0..clip.frames).map(move |f| (c, f)))
274
.collect()
275
}
276
277
/// Load one verified loose cell as raw 4-byte-per-pixel rows (RGBA8 or
278
/// big-endian gray16+alpha16, matching the atlas PNG sample layout).
279
fn load_cell(
280
job: &AtlasJob<'_>,
281
clip: &str,
282
frame: u32,
283
pitch: i32,
284
yaw: u32,
285
kind: MapKind,
286
) -> Result<Vec<u8>, String> {
287
let rel = production::output_path(job.sex, job.set, clip, frame, pitch, yaw, kind.token())?;
288
let rel = rel.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");
289
let expected = job
290
.png_sha256
291
.get(&rel)
292
.ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?;
293
let path = job.root.join(&rel);
294
export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;
295
let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;
296
if digest::sha256_hex(&bytes) != *expected {
297
return Err(format!("atlas: checksum van {rel} klopt niet meer"));
298
}
299
match export_set::decode_map(&bytes, kind, job.cell_width, job.cell_height)
300
.map_err(|e| format!("atlas: {rel}: {e}"))?
301
{
302
MapImage::Rgba8 { rgba, .. } => Ok(rgba),
303
MapImage::Depth16 { samples, .. } => {
304
Ok(samples.iter().flat_map(|s| s.to_be_bytes()).collect())
305
}
306
}
307
}
308
309
/// Stream-encode one atlas PNG into `out`.
310
fn encode_atlas(
311
job: &AtlasJob<'_>,
312
layout: &AtlasLayout,
313
kind: MapKind,
314
out: &mut dyn Write,
315
progress: &mut dyn FnMut(String),
316
) -> Result<(), String> {
317
let mut encoder = png::Encoder::new(out, layout.width, layout.height);
318
if kind == MapKind::Depth {
319
encoder.set_color(png::ColorType::GrayscaleAlpha);
320
encoder.set_depth(png::BitDepth::Sixteen);
321
} else {
322
encoder.set_color(png::ColorType::Rgba);
323
encoder.set_depth(png::BitDepth::Eight);
324
if kind == MapKind::Diffuse {
325
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
326
}
327
}
328
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
329
let row_bytes = layout.width as usize * 4;
330
let cell_row_bytes = layout.cell_width as usize * 4;
331
let mut stream = writer
332
.stream_writer_with_size(row_bytes.max(4096))
333
.map_err(|e| e.to_string())?;
334
let rows = rows_of(job.plan);
335
let total = rows.len();
336
let mut band = vec![0u8; row_bytes * layout.cell_height as usize];
337
for (row, (clip, frame)) in rows.into_iter().enumerate() {
338
if job.cancel.load(Ordering::Relaxed) {
339
return Err("geannuleerd".into());
340
}
341
if row % 8 == 0 {
342
progress(format!(
343
"atlas {} · rij {}/{}",
344
kind.token(),
345
row + 1,
346
total
347
));
348
}
349
let token = &job.plan.clips[clip].token;
350
for column in 0..layout.columns {
351
let view = layout.cell(0, column);
352
let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
353
for y in 0..layout.cell_height as usize {
354
let dst = y * row_bytes + view.x as usize * 4;
355
band[dst..dst + cell_row_bytes]
356
.copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);
357
}
358
}
359
stream.write_all(&band).map_err(|e| e.to_string())?;
360
}
361
stream.finish().map_err(|e| e.to_string())?;
362
writer.finish().map_err(|e| e.to_string())?;
363
Ok(())
364
}
365
366
/// Publish `name` in the run root without clobbering. When a file already
367
/// exists (crash after publication during an earlier attempt) it is kept when
368
/// byte-identical to the regenerated output, otherwise moved aside.
369
fn publish_streamed(
370
root: &Path,
371
name: &str,
372
attempt: u32,
373
mut encode: impl FnMut(&mut dyn Write) -> Result<(), String>,
374
) -> Result<String, String> {
375
let path = root.join(name);
376
if fs::symlink_metadata(&path).is_ok() {
377
let mut sink = HashingWriter {
378
inner: std::io::sink(),
379
hasher: Sha256::new(),
380
};
381
encode(&mut sink)?;
382
let fresh = digest::hex(&sink.hasher.finalize());
383
let regular = fs::symlink_metadata(&path)
384
.map(|m| m.file_type().is_file())
385
.unwrap_or(false);
386
if regular
387
&& digest::sha256_file(&path, MAX_ATLAS_PNG_BYTES)
388
.ok()
389
.as_deref()
390
== Some(fresh.as_str())
391
{
392
return Ok(fresh);
393
}
394
crate::resume::quarantine(root, name, attempt)?;
395
}
396
let mut digest_out = None;
397
production::atomic_file(root, Path::new(name), |file| {
398
let mut writer = HashingWriter {
399
inner: std::io::BufWriter::new(file),
400
hasher: Sha256::new(),
401
};
402
encode(&mut writer)?;
403
writer.flush().map_err(|e| e.to_string())?;
404
digest_out = Some(digest::hex(&writer.hasher.finalize()));
405
Ok(())
406
})?;
407
digest_out.ok_or_else(|| "atlas-digest ontbreekt".into())
408
}
409
410
/// Build the `.ppda` document for a plan/contract (pure; no disk access).
411
pub fn build_ppda(
412
render_doc: &serde_json::Value,
413
plan: &Plan,
414
layout: &AtlasLayout,
415
sex: &str,
416
set: &str,
417
map_digests: &BTreeMap<String, String>,
418
) -> Result<Ppda, String> {
419
let get = |p: &str| render_doc.pointer(p).ok_or(format!("render.json mist {p}"));
420
let text = |p: &str| -> Result<String, String> {
421
get(p)?
422
.as_str()
423
.map(str::to_string)
424
.ok_or(format!("{p} is geen tekst"))
425
};
426
let framing: CameraFraming =
427
serde_json::from_value(get("/framing")?.clone()).map_err(|e| e.to_string())?;
428
let fps = get("/fps")?.as_u64().ok_or("fps")? as u32;
429
let mut maps = BTreeMap::new();
430
for kind in ATLAS_MAPS {
431
maps.insert(
432
kind.token().to_string(),
433
PpdaMap {
434
file: atlas_png_name(sex, set, kind),
435
sha256: map_digests
436
.get(kind.token())
437
.cloned()
438
.ok_or("atlas-digest ontbreekt")?,
439
width: layout.width,
440
height: layout.height,
441
},
442
);
443
}
444
let mut row = 0u32;
445
let mut clips = Vec::with_capacity(plan.clips.len());
446
for (i, clip) in plan.clips.iter().enumerate() {
447
let duration = render_doc
448
.pointer(&format!("/clips/{i}/duration"))
449
.and_then(|d| d.as_f64())
450
.unwrap_or(0.) as f32;
451
let mut frames = Vec::with_capacity(clip.frames as usize);
452
for index in 0..clip.frames {
453
frames.push(PpdaFrame {
454
index,
455
views: (0..layout.columns).map(|c| layout.cell(row, c)).collect(),
456
});
457
row += 1;
458
}
459
clips.push(PpdaClip {
460
source_index: i as u32,
461
name: clip.source_name.clone(),
462
token: clip.token.clone(),
463
frame_count: clip.frames,
464
duration,
465
frames,
466
});
467
}
468
Ok(Ppda {
469
format_version: PPDA_FORMAT_VERSION,
470
sex: sex.into(),
471
settype: set.into(),
472
project: PpdaProject {
473
id: text("/project/id")?,
474
name: text("/project/name")?,
475
},
476
identity: PpdaIdentity {
477
body_model_sha256: text("/body_model/sha256")?,
478
equipment_preset_sha256: render_doc
479
.pointer("/equipment_preset/sha256")
480
.and_then(|v| v.as_str())
481
.map(str::to_string),
482
framing,
483
pivot_source: text("/pivot_source/kind")?,
484
near: NEAR,
485
far: FAR,
486
camera_distance: 10.,
487
normal_space: text("/normal_space")?,
488
depth: text("/depth")?,
489
},
490
fps,
491
cell_width: layout.cell_width,
492
cell_height: layout.cell_height,
493
pitches: PRODUCTION_PITCHES.to_vec(),
494
yaws: production::production_yaws().to_vec(),
495
maps,
496
clips,
497
})
498
}
499
500
/// Write the four atlases and the `.ppda` after all loose maps are verified.
501
pub fn write_atlases(
502
job: &AtlasJob<'_>,
503
progress: &mut dyn FnMut(String),
504
) -> Result<AtlasOutput, String> {
505
let layout = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;
506
if job.png_sha256.len() as u64 != u64::from(job.plan.files) {
507
return Err("atlas: niet alle losse maps zijn geverifieerd".into());
508
}
509
let mut maps = BTreeMap::new();
510
let mut digests = BTreeMap::new();
511
for kind in ATLAS_MAPS {
512
let name = atlas_png_name(job.sex, job.set, kind);
513
let digest = publish_streamed(job.root, &name, job.attempt, |out| {
514
encode_atlas(job, &layout, kind, out, progress)
515
})?;
516
digests.insert(kind.token().to_string(), digest.clone());
517
maps.insert(kind.token().to_string(), (name, digest));
518
}
519
let ppda = build_ppda(
520
job.render_doc,
521
job.plan,
522
&layout,
523
job.sex,
524
job.set,
525
&digests,
526
)?;
527
let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?;
528
bytes.push(b'\n');
529
let name = ppda_name(job.sex, job.set);
530
progress(format!("atlas · {name}"));
531
let ppda_sha256 = publish_streamed(job.root, &name, job.attempt, |out| {
532
out.write_all(&bytes).map_err(|e| e.to_string())
533
})?;
534
Ok(AtlasOutput {
535
ppda: name,
536
ppda_sha256,
537
maps,
538
})
539
}
540
541
// ---------------------------------------------------------------- reader --
542
543
fn ident(msg: impl Into<String>) -> ExportError {
544
ExportError::Identity(msg.into())
545
}
546
547
fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {
548
if p.format_version != PPDA_FORMAT_VERSION {
549
return Err(ident(format!(
550
".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION})",
551
p.format_version
552
)));
553
}
554
if !["male", "female"].contains(&p.sex.as_str()) {
555
return Err(ident(".ppda: ongeldig geslacht"));
556
}
557
if p.settype != "body" && !crate::equipment::valid_name(&p.settype) {
558
return Err(ident(".ppda: ongeldig settype"));
559
}
560
if file_name != ppda_name(&p.sex, &p.settype) {
561
return Err(ident(format!(
562
".ppda-bestandsnaam '{file_name}' past niet bij {}/{}",
563
p.sex, p.settype
564
)));
565
}
566
if p.project.id.is_empty() || p.project.id.len() > 128 || p.project.name.len() > 256 {
567
return Err(ident(".ppda: project id/naam ongeldig"));
568
}
569
let id = &p.identity;
570
if !is_sha256_hex(&id.body_model_sha256) {
571
return Err(ExportError::Corrupt(
572
".ppda: body_model_sha256 ongeldig".into(),
573
));
574
}
575
match (&id.equipment_preset_sha256, p.settype == "body") {
576
(None, true) => {}
577
(Some(h), false) if is_sha256_hex(h) => {}
578
_ => {
579
return Err(ident(
580
".ppda: equipment_preset_sha256 past niet bij settype",
581
))
582
}
583
}
584
id.framing.validate().map_err(ExportError::Identity)?;
585
if !["manual", "neck", "fallback"].contains(&id.pivot_source.as_str()) {
586
return Err(ident(".ppda: onbekende pivot_source"));
587
}
588
if id.near != NEAR || id.far != FAR || id.camera_distance != 10. {
589
return Err(ident(".ppda: camera-contract wijkt af"));
590
}
591
if p.fps == 0 || p.fps > 240 {
592
return Err(ident(".ppda: ongeldige fps"));
593
}
594
if p.pitches != PRODUCTION_PITCHES || p.yaws != production::production_yaws() {
595
return Err(ident(".ppda: pitches/yaws wijken af van −60…+60 × 0…330"));
596
}
597
if p.clips.is_empty() || p.clips.len() > MAX_CLIPS {
598
return Err(ExportError::Resource(
599
".ppda: clip-inventaris ongeldig".into(),
600
));
601
}
602
let rows = p
603
.clips
604
.iter()
605
.try_fold(0u32, |n, c| n.checked_add(c.frame_count))
606
.ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?;
607
let layout =
608
AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;
609
let mut tokens = std::collections::BTreeSet::new();
610
let mut row = 0u32;
611
for (i, clip) in p.clips.iter().enumerate() {
612
if clip.source_index as usize != i {
613
return Err(ident(".ppda: clips niet in bronvolgorde"));
614
}
615
if clip.token.is_empty()
616
|| clip.token.len() > 64
617
|| !crate::equipment::valid_name(&clip.token)
618
|| !tokens.insert(clip.token.clone())
619
{
620
return Err(ident(format!(
621
".ppda: ongeldige clip-token '{}'",
622
clip.token
623
)));
624
}
625
if clip.name.len() > MAX_CLIP_NAME || !clip.duration.is_finite() || clip.duration < 0. {
626
return Err(ident(".ppda: clipnaam/duur ongeldig"));
627
}
628
if clip.frame_count == 0 || clip.frames.len() != clip.frame_count as usize {
629
return Err(ident(".ppda: frame_count past niet bij frames"));
630
}
631
for (k, frame) in clip.frames.iter().enumerate() {
632
if frame.index as usize != k || frame.views.len() != layout.columns as usize {
633
return Err(ident(".ppda: frame-index of aantal hoeken ongeldig"));
634
}
635
for (c, view) in frame.views.iter().enumerate() {
636
let expected = layout.cell(row, c as u32);
637
let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width)
638
&& u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height);
639
if *view != expected || !inside {
640
return Err(ExportError::Corrupt(format!(
641
".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet",
642
clip.token, view.pitch, view.yaw
643
)));
644
}
645
}
646
row += 1;
647
}
648
}
649
if p.maps.len() != ATLAS_MAPS.len() {
650
return Err(ident(
651
".ppda: maps moet precies diffuse/normal/depth/specular bevatten",
652
));
653
}
654
for kind in ATLAS_MAPS {
655
let m = p
656
.maps
657
.get(kind.token())
658
.ok_or_else(|| ident(format!(".ppda mist map {}", kind.token())))?;
659
if m.file != atlas_png_name(&p.sex, &p.settype, kind) {
660
return Err(ExportError::Traversal(format!(
661
".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap",
662
m.file
663
)));
664
}
665
if !is_sha256_hex(&m.sha256) {
666
return Err(ExportError::Corrupt(".ppda: atlas sha256 ongeldig".into()));
667
}
668
if m.width != layout.width || m.height != layout.height {
669
return Err(ExportError::Corrupt(format!(
670
".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}",
671
kind.token(),
672
m.width,
673
m.height,
674
layout.width,
675
layout.height
676
)));
677
}
678
}
679
Ok(layout)
680
}
681
682
/// A validated `.ppda` whose atlases exist, match their SHA-256 and headers.
683
#[derive(Debug, Clone)]
684
pub struct AtlasSet {
685
dir: PathBuf,
686
pub ppda: Ppda,
687
pub layout: AtlasLayout,
688
}
689
690
impl AtlasSet {
691
/// Export folder containing the `.ppda` and its atlases.
692
pub fn dir(&self) -> &Path {
693
&self.dir
694
}
695
696
fn read_ppda(path: &Path) -> Result<(PathBuf, Ppda, AtlasLayout), ExportError> {
697
let dir = path
698
.parent()
699
.ok_or_else(|| ExportError::Io(".ppda zonder map".into()))?;
700
let dir = dir
701
.canonicalize()
702
.map_err(|e| ExportError::Io(e.to_string()))?;
703
let file_name = path
704
.file_name()
705
.and_then(|n| n.to_str())
706
.ok_or_else(|| ExportError::Io("ongeldige .ppda-naam".into()))?
707
.to_string();
708
let path = dir.join(&file_name);
709
export_set::verify_regular_file(&path, &dir)?;
710
let bytes = export_set::read_bounded_file(&path, MAX_PPDA_BYTES)?;
711
let text = std::str::from_utf8(&bytes)
712
.map_err(|_| ExportError::Corrupt(".ppda is geen UTF-8".into()))?;
713
let ppda: Ppda =
714
serde_json::from_str(text).map_err(|e| ExportError::Corrupt(format!(".ppda: {e}")))?;
715
let layout = validate_ppda(&ppda, &file_name)?;
716
Ok((dir, ppda, layout))
717
}
718
719
fn read_map(&self, kind: MapKind) -> Result<Vec<u8>, ExportError> {
720
let m = &self.ppda.maps[kind.token()];
721
let path = self.dir.join(&m.file);
722
export_set::verify_regular_file(&path, &self.dir)?;
723
let bytes = export_set::read_bounded_file(&path, MAX_ATLAS_PNG_BYTES)?;
724
if digest::sha256_hex(&bytes) != m.sha256 {
725
return Err(ExportError::Corrupt(format!(
726
"checksum mismatch voor '{}'",
727
m.file
728
)));
729
}
730
Ok(bytes)
731
}
732
733
/// Validate the manifest, then each atlas: regular file inside the folder,
734
/// SHA-256, PNG header dimensions and sample format.
735
pub fn open(ppda_path: &Path) -> Result<Self, ExportError> {
736
let (dir, ppda, layout) = Self::read_ppda(ppda_path)?;
737
let set = Self { dir, ppda, layout };
738
for kind in ATLAS_MAPS {
739
let bytes = set.read_map(kind)?;
740
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes[..]));
741
let reader = decoder
742
.read_info()
743
.map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?;
744
let info = reader.info();
745
let (color, depth) = if kind == MapKind::Depth {
746
(png::ColorType::GrayscaleAlpha, png::BitDepth::Sixteen)
747
} else {
748
(png::ColorType::Rgba, png::BitDepth::Eight)
749
};
750
if info.width != layout.width
751
|| info.height != layout.height
752
|| info.color_type != color
753
|| info.bit_depth != depth
754
{
755
return Err(ExportError::Corrupt(format!(
756
"{} atlas: afmetingen of kleurtype wijken af van .ppda",
757
kind.token()
758
)));
759
}
760
}
761
Ok(set)
762
}
763
764
/// Open the single `.ppda` inside a run directory.
765
pub fn open_run(run_dir: &Path) -> Result<Self, ExportError> {
766
let mut found = Vec::new();
767
for entry in fs::read_dir(run_dir).map_err(|e| ExportError::Io(e.to_string()))? {
768
let entry = entry.map_err(|e| ExportError::Io(e.to_string()))?;
769
let name = entry.file_name();
770
if name.to_str().is_some_and(|n| n.ends_with("-atlas.ppda")) {
771
found.push(entry.path());
772
}
773
}
774
match found.as_slice() {
775
[one] => Self::open(one),
776
[] => Err(ExportError::Incomplete(
777
"geen .ppda in deze exportmap".into(),
778
)),
779
_ => Err(ExportError::Identity(
780
"meer dan één .ppda in deze exportmap".into(),
781
)),
782
}
783
}
784
785
/// Decode all four atlases (re-verifying SHA-256 on the bytes decoded) with
786
/// the full map pixel contract.
787
pub fn load(&self) -> Result<LoadedAtlas, ExportError> {
788
let decoded = u64::from(self.layout.width) * u64::from(self.layout.height) * 4 * 4;
789
if decoded > MAX_DECODED_SET_BYTES {
790
return Err(ExportError::Resource(format!(
791
"atlasset te groot om te laden ({decoded} bytes gedecodeerd)"
792
)));
793
}
794
let mut maps = BTreeMap::new();
795
for kind in ATLAS_MAPS {
796
let bytes = self.read_map(kind)?;
797
let image = export_set::decode_map_sized(
798
&bytes,
799
kind,
800
self.layout.width,
801
self.layout.height,
802
MAX_ATLAS_DIM,
803
)?;
804
maps.insert(kind.token(), image);
805
}
806
Ok(LoadedAtlas {
807
set: self.clone(),
808
maps,
809
})
810
}
811
}
812
813
/// Decoded atlases for one `.ppda`; crops cells for the compositor.
814
#[derive(Debug, Clone)]
815
pub struct LoadedAtlas {
816
pub set: AtlasSet,
817
maps: BTreeMap<&'static str, MapImage>,
818
}
819
820
impl LoadedAtlas {
821
pub fn ppda(&self) -> &Ppda {
822
&self.set.ppda
823
}
824
825
/// Coordinates of one view from the `.ppda`.
826
pub fn view(
827
&self,
828
clip_token: &str,
829
frame: u32,
830
pitch: i32,
831
yaw: u32,
832
) -> Result<PpdaView, ExportError> {
833
let clip = self
834
.set
835
.ppda
836
.clips
837
.iter()
838
.find(|c| c.token == clip_token)
839
.ok_or_else(|| ExportError::Incomplete(format!("clip '{clip_token}' niet in .ppda")))?;
840
let frame = clip.frames.get(frame as usize).ok_or_else(|| {
841
ExportError::Incomplete(format!("frame {frame} niet in clip '{clip_token}'"))
842
})?;
843
frame
844
.views
845
.iter()
846
.find(|v| v.pitch == pitch && v.yaw == yaw)
847
.copied()
848
.ok_or_else(|| ExportError::Incomplete(format!("hoek p{pitch} y{yaw} niet in .ppda")))
849
}
850
851
/// Crop one map cell exactly as stored.
852
pub fn cell(
853
&self,
854
clip_token: &str,
855
frame: u32,
856
pitch: i32,
857
yaw: u32,
858
kind: MapKind,
859
) -> Result<MapImage, ExportError> {
860
let v = self.view(clip_token, frame, pitch, yaw)?;
861
let atlas_w = self.set.layout.width as usize;
862
let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);
863
match &self.maps[kind.token()] {
864
MapImage::Rgba8 { rgba, .. } => {
865
let mut out = Vec::with_capacity(w * h * 4);
866
for row in y..y + h {
867
let start = (row * atlas_w + x) * 4;
868
out.extend_from_slice(&rgba[start..start + w * 4]);
869
}
870
Ok(MapImage::Rgba8 {
871
width: v.w,
872
height: v.h,
873
rgba: out,
874
})
875
}
876
MapImage::Depth16 { samples, .. } => {
877
let mut out = Vec::with_capacity(w * h * 2);
878
for row in y..y + h {
879
let start = (row * atlas_w + x) * 2;
880
out.extend_from_slice(&samples[start..start + w * 2]);
881
}
882
Ok(MapImage::Depth16 {
883
width: v.w,
884
height: v.h,
885
samples: out,
886
})
887
}
888
}
889
}
890
}
891
892
/// Open a completed run through both readers and compare every atlas cell of
893
/// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
894
pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
895
let run = export_set::ExportRun::open(run_dir).map_err(|e| e.to_string())?;
896
let set = AtlasSet::open_run(run_dir).map_err(|e| e.to_string())?;
897
let loaded = set.load().map_err(|e| e.to_string())?;
898
let mut cells = 0u64;
899
for clip in &set.ppda.clips {
900
for frame in &clip.frames {
901
for view in &frame.views {
902
for kind in ATLAS_MAPS {
903
let a = loaded
904
.cell(&clip.token, frame.index, view.pitch, view.yaw, kind)
905
.map_err(|e| e.to_string())?;
906
let b = run
907
.load_map(&clip.token, frame.index, view.pitch, view.yaw, kind)
908
.map_err(|e| e.to_string())?;
909
if a != b {
910
return Err(format!(
911
"atlascel {} f{} p{} y{} {} wijkt af van losse PNG",
912
clip.token,
913
frame.index,
914
view.pitch,
915
view.yaw,
916
kind.token()
917
));
918
}
919
cells += 1;
920
}
921
}
922
}
923
}
924
if cells != u64::from(run.complete.png_files) {
925
return Err(format!(
926
"{cells} atlascellen ≠ {} losse PNG's",
927
run.complete.png_files
928
));
929
}
930
Ok(serde_json::json!({
931
"ppda": ppda_name(&set.ppda.sex, &set.ppda.settype),
932
"atlas_size": [set.layout.width, set.layout.height],
933
"cell": [set.layout.cell_width, set.layout.cell_height],
934
"rows": set.layout.rows,
935
"columns": set.layout.columns,
936
"fps": set.ppda.fps,
937
"cells_equal_to_loose_pngs": cells,
938
"maps": set.ppda.maps.iter().map(|(k, m)| (k.clone(), serde_json::json!({"file": m.file, "sha256": m.sha256}))).collect::<serde_json::Map<_, _>>(),
939
}))
940
}
941
942
#[cfg(test)]
943
mod tests {
944
use super::*;
945
use crate::export_fixture::{Pixel, SyntheticRun};
946
use std::sync::atomic::AtomicU64;
947
948
struct Temp(PathBuf);
949
impl Temp {
950
fn new() -> Self {
951
static N: AtomicU64 = AtomicU64::new(0);
952
let path = std::env::temp_dir().join(format!(
953
"paperdoll-atlas-{}-{}",
954
std::process::id(),
955
N.fetch_add(1, Ordering::Relaxed)
956
));
957
let _ = fs::remove_dir_all(&path);
958
Self(path)
959
}
960
}
961
impl Drop for Temp {
962
fn drop(&mut self) {
963
let _ = fs::remove_dir_all(&self.0);
964
}
965
}
966
967
fn fixture() -> Temp {
968
let tmp = Temp::new();
969
let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
970
spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
971
spec.target = [3, 2];
972
spec.write(&tmp.0, |c, f, p, y| Pixel {
973
diffuse: [c as u8 * 100 + f as u8, (p + 60) as u8, (y / 30) as u8, 255],
974
normal: [128, 128, 255, 255],
975
specular: [9, 9, 9, 255],
976
depth: 1000 + f as u16,
977
})
978
.unwrap();
979
tmp
980
}
981
982
fn rewrite_ppda(dir: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
983
let path = dir.join("female-body-atlas.ppda");
984
let mut doc: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
985
edit(&mut doc);
986
fs::remove_file(&path).unwrap();
987
fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
988
}
989
990
#[test]
991
fn layout_is_pitch_major_columns_and_frame_rows() {
992
let l = AtlasLayout::new(32, 48, 5).unwrap();
993
assert_eq!((l.columns, l.width, l.height), (60, 60 * 32, 5 * 48));
994
assert_eq!(AtlasLayout::column(-60, 0), Some(0));
995
assert_eq!(AtlasLayout::column(-60, 330), Some(11));
996
assert_eq!(AtlasLayout::column(-30, 0), Some(12));
997
assert_eq!(AtlasLayout::column(60, 330), Some(59));
998
assert_eq!(AtlasLayout::column(45, 0), None);
999
let v = l.cell(2, 13);
1000
assert_eq!(
1001
(v.pitch, v.yaw, v.x, v.y, v.w, v.h),
1002
(-30, 30, 13 * 32, 96, 32, 48)
1003
);
1004
// 256-pixel cells allow 64 frame rows; 65 exceeds the GPU/PNG limit.
1005
assert!(AtlasLayout::new(256, 256, 64).is_ok());
1006
let err = AtlasLayout::new(256, 256, 65).unwrap_err();
1007
assert!(err.contains("niet gesplitst"), "{err}");
1008
assert!(AtlasLayout::new(0, 1, 1).is_err());
1009
assert!(AtlasLayout::new(257, 1, 1).is_err());
1010
}
1011
1012
#[test]
1013
fn written_ppda_has_exact_coordinates_names_fps_and_checksums() {
1014
let tmp = fixture();
1015
let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1016
let p = &set.ppda;
1017
assert_eq!(p.format_version, 1);
1018
assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1019
assert_eq!(p.fps, 4);
1020
assert_eq!((p.cell_width, p.cell_height), (3, 2));
1021
assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1022
assert_eq!(p.yaws.len(), 12);
1023
assert_eq!(p.maps["diffuse"].file, "female-body-diffuse-atlas.png");
1024
assert_eq!(p.maps["depth"].file, "female-body-depth-atlas.png");
1025
assert_eq!((p.maps["normal"].width, p.maps["normal"].height), (180, 8));
1026
for kind in ATLAS_MAPS {
1027
let m = &p.maps[kind.token()];
1028
let bytes = fs::read(tmp.0.join(&m.file)).unwrap();
1029
assert_eq!(digest::sha256_hex(&bytes), m.sha256);
1030
}
1031
assert_eq!(p.clips.len(), 2);
1032
assert_eq!(p.clips[0].frame_count, 3);
1033
assert_eq!(p.clips[1].token, "clip001-idle");
1034
let idle = &p.clips[1].frames[0].views[59];
1035
assert_eq!((idle.pitch, idle.yaw, idle.x, idle.y), (60, 330, 177, 6));
1036
// Cells hold exactly the loose map bytes.
1037
let loaded = set.load().unwrap();
1038
let run = crate::export_set::ExportRun::open(&tmp.0).unwrap();
1039
for (clip, frame) in [("clip000-walk", 2), ("clip001-idle", 0)] {
1040
for (pitch, yaw) in [(-60, 0), (30, 210), (60, 330)] {
1041
for kind in ATLAS_MAPS {
1042
assert_eq!(
1043
loaded.cell(clip, frame, pitch, yaw, kind).unwrap(),
1044
run.load_map(clip, frame, pitch, yaw, kind).unwrap()
1045
);
1046
}
1047
}
1048
}
1049
assert!(loaded
1050
.cell("clip000-walk", 3, 0, 0, MapKind::Diffuse)
1051
.is_err());
1052
assert!(loaded.cell("clip009-x", 0, 0, 0, MapKind::Diffuse).is_err());
1053
// The diffuse atlas carries the sRGB chunk like the loose diffuse maps.
1054
let bytes = fs::read(tmp.0.join("female-body-diffuse-atlas.png")).unwrap();
1055
let info = png::Decoder::new(std::io::Cursor::new(&bytes[..]))
1056
.read_info()
1057
.unwrap()
1058
.info()
1059
.srgb;
1060
assert!(info.is_some());
1061
}
1062
1063
#[test]
1064
fn reader_rejects_tampering_unknown_fields_versions_paths_and_coordinates() {
1065
let tmp = fixture();
1066
let ppda = tmp.0.join("female-body-atlas.ppda");
1067
let original = fs::read(&ppda).unwrap();
1068
let restore = || {
1069
fs::remove_file(&ppda).unwrap();
1070
fs::write(&ppda, &original).unwrap();
1071
};
1072
AtlasSet::open(&ppda).unwrap();
1073
1074
rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());
1075
assert!(matches!(
1076
AtlasSet::open(&ppda),
1077
Err(ExportError::Identity(_))
1078
));
1079
restore();
1080
rewrite_ppda(&tmp.0, |d| d["extra"] = true.into());
1081
assert!(matches!(
1082
AtlasSet::open(&ppda),
1083
Err(ExportError::Corrupt(_))
1084
));
1085
restore();
1086
rewrite_ppda(&tmp.0, |d| {
1087
d["clips"][0]["frames"][1]["views"][5]["x"] = 1.into()
1088
});
1089
assert!(matches!(
1090
AtlasSet::open(&ppda),
1091
Err(ExportError::Corrupt(_))
1092
));
1093
restore();
1094
rewrite_ppda(&tmp.0, |d| {
1095
d["maps"]["diffuse"]["file"] = "../female-body-diffuse-atlas.png".into()
1096
});
1097
assert!(matches!(
1098
AtlasSet::open(&ppda),
1099
Err(ExportError::Traversal(_))
1100
));
1101
restore();
1102
rewrite_ppda(&tmp.0, |d| d["pitches"] = serde_json::json!([-30, 0, 30]));
1103
assert!(AtlasSet::open(&ppda).is_err());
1104
restore();
1105
rewrite_ppda(&tmp.0, |d| d["maps"]["depth"]["height"] = 10.into());
1106
assert!(AtlasSet::open(&ppda).is_err());
1107
restore();
1108
rewrite_ppda(&tmp.0, |d| d["clips"][0]["frame_count"] = 2.into());
1109
assert!(AtlasSet::open(&ppda).is_err());
1110
restore();
1111
AtlasSet::open(&ppda).unwrap();
1112
1113
// Damaged atlas bytes fail the SHA-256 check.
1114
let normal = tmp.0.join("female-body-normal-atlas.png");
1115
let good = fs::read(&normal).unwrap();
1116
let mut bad = good.clone();
1117
let last = bad.len() - 20;
1118
bad[last] ^= 0xff;
1119
fs::remove_file(&normal).unwrap();
1120
fs::write(&normal, &bad).unwrap();
1121
assert!(matches!(
1122
AtlasSet::open(&ppda),
1123
Err(ExportError::Corrupt(_))
1124
));
1125
fs::remove_file(&normal).unwrap();
1126
#[cfg(unix)]
1127
{
1128
// A symlinked atlas is refused even with matching bytes.
1129
let outside = Temp::new();
1130
fs::create_dir_all(&outside.0).unwrap();
1131
fs::write(outside.0.join("n.png"), &good).unwrap();
1132
std::os::unix::fs::symlink(outside.0.join("n.png"), &normal).unwrap();
1133
assert!(matches!(
1134
AtlasSet::open(&ppda),
1135
Err(ExportError::Traversal(_))
1136
));
1137
fs::remove_file(&normal).unwrap();
1138
}
1139
fs::write(&normal, &good).unwrap();
1140
AtlasSet::open(&ppda).unwrap();
1141
// A renamed .ppda no longer matches its sex/settype.
1142
fs::copy(&ppda, tmp.0.join("male-body-atlas.ppda")).unwrap();
1143
assert!(AtlasSet::open(&tmp.0.join("male-body-atlas.ppda")).is_err());
1144
assert!(matches!(
1145
AtlasSet::open_run(&tmp.0),
1146
Err(ExportError::Identity(_))
1147
));
1148
}
1149
1150
#[test]
1151
fn atlases_never_clobber_and_are_refused_when_too_large() {
1152
let tmp = fixture();
1153
let render: serde_json::Value =
1154
serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap();
1155
let complete: serde_json::Value =
1156
serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap();
1157
let sums: BTreeMap<String, String> =
1158
serde_json::from_value(complete["png_sha256"].clone()).unwrap();
1159
let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1160
spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1161
let plan = spec.plan();
1162
let cancel = AtomicBool::new(false);
1163
let job = AtlasJob {
1164
root: &tmp.0,
1165
render_doc: &render,
1166
plan: &plan,
1167
sex: "female",
1168
set: "body",
1169
cell_width: 3,
1170
cell_height: 2,
1171
png_sha256: &sums,
1172
attempt: 1,
1173
cancel: &cancel,
1174
};
1175
// Re-running over identical files keeps them (no clobber, same result).
1176
let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
1177
let out = write_atlases(&job, &mut |_| {}).unwrap();
1178
assert_eq!(
1179
fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(),
1180
before
1181
);
1182
assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1183
// A loose map changed after verification is refused.
1184
let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png";
1185
let mut tampered = sums.clone();
1186
tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1187
let err = write_atlases(
1188
&AtlasJob {
1189
png_sha256: &tampered,
1190
..job
1191
},
1192
&mut |_| {},
1193
)
1194
.unwrap_err();
1195
assert!(err.contains("checksum"), "{err}");
1196
// Too many frames for 256-pixel cells: clear refusal, never split.
1197
let mut big = spec.clone();
1198
big.clips = vec![("long".into(), 65, 16.25)];
1199
assert!(AtlasLayout::for_plan(&big.plan(), 256, 256)
1200
.unwrap_err()
1201
.contains("16384"));
1202
}
1203
}
@@ -0,0 +1,1203 @@1
//! Per-map atlases and the `.ppda` (PaPerDollAtlas) consumer manifest2
//! (Tim t955u/t957u).3
//!4
//! After every loose target PNG of a run is published and checksum-verified,5
//! production writes one atlas per map type (`<sex>-<settype>-<map>-atlas.png`)6
//! and a strict JSON `.ppda` (`<sex>-<settype>-atlas.ppda`) next to them in the7
//! run directory. Cells are exactly the target resolution, without padding or8
//! scaling. One row per (clip, frame) in source clip order and frame order;9
//! columns are pitch (−60→+60) × yaw (0→330). All four atlases share identical10
//! cell coordinates. Atlases are streamed band by band (one cell row at a time),11
//! published atomically without clobbering, and the `.ppda` is written last.12
//!13
//! [`AtlasSet`] is the validating consumer reader used by the 2D preview.14
use crate::{15
digest::{self, is_sha256_hex, Sha256},16
export_set::{self, ExportError, MapImage, MapKind, MAX_PNG_BYTES, MAX_TARGET},17
preview::PRODUCTION_PITCHES,18
production::{self, Plan, FAR, NEAR},19
project::CameraFraming,20
};21
use serde::{Deserialize, Serialize};22
use std::{23
collections::BTreeMap,24
fs,25
io::Write,26
path::{Path, PathBuf},27
sync::atomic::{AtomicBool, Ordering},28
};29
30
/// `.ppda` schema version written and accepted.31
pub const PPDA_FORMAT_VERSION: u32 = 1;32
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are33
/// refused up front with a clear message; atlases are never split.34
pub const MAX_ATLAS_DIM: u32 = 16384;35
/// Bounded `.ppda` size on read.36
pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024;37
/// Bounded encoded atlas PNG size on read.38
pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024;39
/// Decoded-memory budget for one loaded atlas set (four maps) in the preview.40
pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024;41
/// Map order inside the `.ppda` and atlas production.42
pub const ATLAS_MAPS: [MapKind; 4] = [43
MapKind::Diffuse,44
MapKind::Normal,45
MapKind::Depth,46
MapKind::Specular,47
];48
const MAX_CLIPS: usize = 4096;49
const MAX_CLIP_NAME: usize = 256;50
51
pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String {52
format!("{sex}-{set}-{}-atlas.png", kind.token())53
}54
55
pub fn ppda_name(sex: &str, set: &str) -> String {56
format!("{sex}-{set}-atlas.ppda")57
}58
59
/// Deterministic grid shared by all four atlases of one export.60
#[derive(Debug, Clone, Copy, PartialEq, Eq)]61
pub struct AtlasLayout {62
pub cell_width: u32,63
pub cell_height: u32,64
pub columns: u32,65
pub rows: u32,66
pub width: u32,67
pub height: u32,68
}69
70
impl AtlasLayout {71
pub fn new(cell_width: u32, cell_height: u32, rows: u32) -> Result<Self, String> {72
if cell_width == 073
|| cell_height == 074
|| cell_width > MAX_TARGET75
|| cell_height > MAX_TARGET76
{77
return Err("atlascel moet 1–256 pixels per as zijn".into());78
}79
if rows == 0 {80
return Err("atlas zonder rijen".into());81
}82
let columns = (PRODUCTION_PITCHES.len() * production::production_yaws().len()) as u32;83
let width = u64::from(columns) * u64::from(cell_width);84
let height = u64::from(rows) * u64::from(cell_height);85
if width > u64::from(MAX_ATLAS_DIM) || height > u64::from(MAX_ATLAS_DIM) {86
return Err(format!(87
"atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); \88
maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of \89
animatieduur — de atlas wordt niet gesplitst"90
));91
}92
Ok(Self {93
cell_width,94
cell_height,95
columns,96
rows,97
width: width as u32,98
height: height as u32,99
})100
}101
102
pub fn for_plan(plan: &Plan, cell_width: u32, cell_height: u32) -> Result<Self, String> {103
let rows = plan104
.clips105
.iter()106
.try_fold(0u32, |n, c| n.checked_add(c.frames))107
.ok_or("te veel frames voor de atlas")?;108
Self::new(cell_width, cell_height, rows)109
}110
111
/// Column of (pitch, yaw): pitch-major, then yaw.112
pub fn column(pitch: i32, yaw: u32) -> Option<u32> {113
let p = PRODUCTION_PITCHES.iter().position(|x| *x == pitch)?;114
let y = production::production_yaws()115
.iter()116
.position(|x| *x == yaw)?;117
Some((p * production::production_yaws().len() + y) as u32)118
}119
120
pub fn cell(&self, row: u32, column: u32) -> PpdaView {121
let yaws = production::production_yaws();122
let pitch = PRODUCTION_PITCHES[column as usize / yaws.len()];123
let yaw = yaws[column as usize % yaws.len()];124
PpdaView {125
pitch,126
yaw,127
x: column * self.cell_width,128
y: row * self.cell_height,129
w: self.cell_width,130
h: self.cell_height,131
}132
}133
}134
135
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]136
#[serde(deny_unknown_fields)]137
pub struct Ppda {138
pub format_version: u32,139
pub sex: String,140
pub settype: String,141
pub project: PpdaProject,142
/// Export/camera contract so body and equipment atlases can be checked for143
/// compatibility without the loose run manifests.144
pub identity: PpdaIdentity,145
pub fps: u32,146
pub cell_width: u32,147
pub cell_height: u32,148
pub pitches: Vec<i32>,149
pub yaws: Vec<u32>,150
pub maps: BTreeMap<String, PpdaMap>,151
pub clips: Vec<PpdaClip>,152
}153
154
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]155
#[serde(deny_unknown_fields)]156
pub struct PpdaProject {157
pub id: String,158
pub name: String,159
}160
161
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]162
#[serde(deny_unknown_fields)]163
pub struct PpdaIdentity {164
pub body_model_sha256: String,165
pub equipment_preset_sha256: Option<String>,166
pub framing: CameraFraming,167
pub pivot_source: String,168
pub near: f32,169
pub far: f32,170
pub camera_distance: f32,171
pub normal_space: String,172
pub depth: String,173
}174
175
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]176
#[serde(deny_unknown_fields)]177
pub struct PpdaMap {178
pub file: String,179
pub sha256: String,180
pub width: u32,181
pub height: u32,182
}183
184
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]185
#[serde(deny_unknown_fields)]186
pub struct PpdaClip {187
pub source_index: u32,188
pub name: String,189
pub token: String,190
pub frame_count: u32,191
pub duration: f32,192
pub frames: Vec<PpdaFrame>,193
}194
195
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]196
#[serde(deny_unknown_fields)]197
pub struct PpdaFrame {198
pub index: u32,199
pub views: Vec<PpdaView>,200
}201
202
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]203
#[serde(deny_unknown_fields)]204
pub struct PpdaView {205
pub pitch: i32,206
pub yaw: u32,207
pub x: u32,208
pub y: u32,209
pub w: u32,210
pub h: u32,211
}212
213
/// What production records in `complete.json` after writing the atlases.214
#[derive(Debug, Clone, PartialEq, Eq)]215
pub struct AtlasOutput {216
pub ppda: String,217
pub ppda_sha256: String,218
/// map token → (file name, sha256)219
pub maps: BTreeMap<String, (String, String)>,220
}221
222
impl AtlasOutput {223
pub fn to_json(&self) -> serde_json::Value {224
let maps: serde_json::Map<String, serde_json::Value> = self225
.maps226
.iter()227
.map(|(k, (file, sha))| (k.clone(), serde_json::json!({"file": file, "sha256": sha})))228
.collect();229
serde_json::json!({230
"ppda": self.ppda,231
"ppda_sha256": self.ppda_sha256,232
"maps": maps,233
})234
}235
}236
237
struct HashingWriter<W: Write> {238
inner: W,239
hasher: Sha256,240
}241
242
impl<W: Write> Write for HashingWriter<W> {243
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {244
let n = self.inner.write(buf)?;245
self.hasher.update(&buf[..n]);246
Ok(n)247
}248
fn flush(&mut self) -> std::io::Result<()> {249
self.inner.flush()250
}251
}252
253
/// Inputs for writing the atlases of one completed run.254
pub struct AtlasJob<'a> {255
pub root: &'a Path,256
pub render_doc: &'a serde_json::Value,257
pub plan: &'a Plan,258
pub sex: &'a str,259
pub set: &'a str,260
pub cell_width: u32,261
pub cell_height: u32,262
/// Relative loose PNG path → verified SHA-256 (every planned file).263
pub png_sha256: &'a BTreeMap<String, String>,264
/// Resume attempt number for quarantine names of stale atlas files.265
pub attempt: u32,266
pub cancel: &'a AtomicBool,267
}268
269
fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {270
plan.clips271
.iter()272
.enumerate()273
.flat_map(|(c, clip)| (0..clip.frames).map(move |f| (c, f)))274
.collect()275
}276
277
/// Load one verified loose cell as raw 4-byte-per-pixel rows (RGBA8 or278
/// big-endian gray16+alpha16, matching the atlas PNG sample layout).279
fn load_cell(280
job: &AtlasJob<'_>,281
clip: &str,282
frame: u32,283
pitch: i32,284
yaw: u32,285
kind: MapKind,286
) -> Result<Vec<u8>, String> {287
let rel = production::output_path(job.sex, job.set, clip, frame, pitch, yaw, kind.token())?;288
let rel = rel.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");289
let expected = job290
.png_sha256291
.get(&rel)292
.ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?;293
let path = job.root.join(&rel);294
export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;295
let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;296
if digest::sha256_hex(&bytes) != *expected {297
return Err(format!("atlas: checksum van {rel} klopt niet meer"));298
}299
match export_set::decode_map(&bytes, kind, job.cell_width, job.cell_height)300
.map_err(|e| format!("atlas: {rel}: {e}"))?301
{302
MapImage::Rgba8 { rgba, .. } => Ok(rgba),303
MapImage::Depth16 { samples, .. } => {304
Ok(samples.iter().flat_map(|s| s.to_be_bytes()).collect())305
}306
}307
}308
309
/// Stream-encode one atlas PNG into `out`.310
fn encode_atlas(311
job: &AtlasJob<'_>,312
layout: &AtlasLayout,313
kind: MapKind,314
out: &mut dyn Write,315
progress: &mut dyn FnMut(String),316
) -> Result<(), String> {317
let mut encoder = png::Encoder::new(out, layout.width, layout.height);318
if kind == MapKind::Depth {319
encoder.set_color(png::ColorType::GrayscaleAlpha);320
encoder.set_depth(png::BitDepth::Sixteen);321
} else {322
encoder.set_color(png::ColorType::Rgba);323
encoder.set_depth(png::BitDepth::Eight);324
if kind == MapKind::Diffuse {325
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);326
}327
}328
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;329
let row_bytes = layout.width as usize * 4;330
let cell_row_bytes = layout.cell_width as usize * 4;331
let mut stream = writer332
.stream_writer_with_size(row_bytes.max(4096))333
.map_err(|e| e.to_string())?;334
let rows = rows_of(job.plan);335
let total = rows.len();336
let mut band = vec![0u8; row_bytes * layout.cell_height as usize];337
for (row, (clip, frame)) in rows.into_iter().enumerate() {338
if job.cancel.load(Ordering::Relaxed) {339
return Err("geannuleerd".into());340
}341
if row % 8 == 0 {342
progress(format!(343
"atlas {} · rij {}/{}",344
kind.token(),345
row + 1,346
total347
));348
}349
let token = &job.plan.clips[clip].token;350
for column in 0..layout.columns {351
let view = layout.cell(0, column);352
let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;353
for y in 0..layout.cell_height as usize {354
let dst = y * row_bytes + view.x as usize * 4;355
band[dst..dst + cell_row_bytes]356
.copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);357
}358
}359
stream.write_all(&band).map_err(|e| e.to_string())?;360
}361
stream.finish().map_err(|e| e.to_string())?;362
writer.finish().map_err(|e| e.to_string())?;363
Ok(())364
}365
366
/// Publish `name` in the run root without clobbering. When a file already367
/// exists (crash after publication during an earlier attempt) it is kept when368
/// byte-identical to the regenerated output, otherwise moved aside.369
fn publish_streamed(370
root: &Path,371
name: &str,372
attempt: u32,373
mut encode: impl FnMut(&mut dyn Write) -> Result<(), String>,374
) -> Result<String, String> {375
let path = root.join(name);376
if fs::symlink_metadata(&path).is_ok() {377
let mut sink = HashingWriter {378
inner: std::io::sink(),379
hasher: Sha256::new(),380
};381
encode(&mut sink)?;382
let fresh = digest::hex(&sink.hasher.finalize());383
let regular = fs::symlink_metadata(&path)384
.map(|m| m.file_type().is_file())385
.unwrap_or(false);386
if regular387
&& digest::sha256_file(&path, MAX_ATLAS_PNG_BYTES)388
.ok()389
.as_deref()390
== Some(fresh.as_str())391
{392
return Ok(fresh);393
}394
crate::resume::quarantine(root, name, attempt)?;395
}396
let mut digest_out = None;397
production::atomic_file(root, Path::new(name), |file| {398
let mut writer = HashingWriter {399
inner: std::io::BufWriter::new(file),400
hasher: Sha256::new(),401
};402
encode(&mut writer)?;403
writer.flush().map_err(|e| e.to_string())?;404
digest_out = Some(digest::hex(&writer.hasher.finalize()));405
Ok(())406
})?;407
digest_out.ok_or_else(|| "atlas-digest ontbreekt".into())408
}409
410
/// Build the `.ppda` document for a plan/contract (pure; no disk access).411
pub fn build_ppda(412
render_doc: &serde_json::Value,413
plan: &Plan,414
layout: &AtlasLayout,415
sex: &str,416
set: &str,417
map_digests: &BTreeMap<String, String>,418
) -> Result<Ppda, String> {419
let get = |p: &str| render_doc.pointer(p).ok_or(format!("render.json mist {p}"));420
let text = |p: &str| -> Result<String, String> {421
get(p)?422
.as_str()423
.map(str::to_string)424
.ok_or(format!("{p} is geen tekst"))425
};426
let framing: CameraFraming =427
serde_json::from_value(get("/framing")?.clone()).map_err(|e| e.to_string())?;428
let fps = get("/fps")?.as_u64().ok_or("fps")? as u32;429
let mut maps = BTreeMap::new();430
for kind in ATLAS_MAPS {431
maps.insert(432
kind.token().to_string(),433
PpdaMap {434
file: atlas_png_name(sex, set, kind),435
sha256: map_digests436
.get(kind.token())437
.cloned()438
.ok_or("atlas-digest ontbreekt")?,439
width: layout.width,440
height: layout.height,441
},442
);443
}444
let mut row = 0u32;445
let mut clips = Vec::with_capacity(plan.clips.len());446
for (i, clip) in plan.clips.iter().enumerate() {447
let duration = render_doc448
.pointer(&format!("/clips/{i}/duration"))449
.and_then(|d| d.as_f64())450
.unwrap_or(0.) as f32;451
let mut frames = Vec::with_capacity(clip.frames as usize);452
for index in 0..clip.frames {453
frames.push(PpdaFrame {454
index,455
views: (0..layout.columns).map(|c| layout.cell(row, c)).collect(),456
});457
row += 1;458
}459
clips.push(PpdaClip {460
source_index: i as u32,461
name: clip.source_name.clone(),462
token: clip.token.clone(),463
frame_count: clip.frames,464
duration,465
frames,466
});467
}468
Ok(Ppda {469
format_version: PPDA_FORMAT_VERSION,470
sex: sex.into(),471
settype: set.into(),472
project: PpdaProject {473
id: text("/project/id")?,474
name: text("/project/name")?,475
},476
identity: PpdaIdentity {477
body_model_sha256: text("/body_model/sha256")?,478
equipment_preset_sha256: render_doc479
.pointer("/equipment_preset/sha256")480
.and_then(|v| v.as_str())481
.map(str::to_string),482
framing,483
pivot_source: text("/pivot_source/kind")?,484
near: NEAR,485
far: FAR,486
camera_distance: 10.,487
normal_space: text("/normal_space")?,488
depth: text("/depth")?,489
},490
fps,491
cell_width: layout.cell_width,492
cell_height: layout.cell_height,493
pitches: PRODUCTION_PITCHES.to_vec(),494
yaws: production::production_yaws().to_vec(),495
maps,496
clips,497
})498
}499
500
/// Write the four atlases and the `.ppda` after all loose maps are verified.501
pub fn write_atlases(502
job: &AtlasJob<'_>,503
progress: &mut dyn FnMut(String),504
) -> Result<AtlasOutput, String> {505
let layout = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;506
if job.png_sha256.len() as u64 != u64::from(job.plan.files) {507
return Err("atlas: niet alle losse maps zijn geverifieerd".into());508
}509
let mut maps = BTreeMap::new();510
let mut digests = BTreeMap::new();511
for kind in ATLAS_MAPS {512
let name = atlas_png_name(job.sex, job.set, kind);513
let digest = publish_streamed(job.root, &name, job.attempt, |out| {514
encode_atlas(job, &layout, kind, out, progress)515
})?;516
digests.insert(kind.token().to_string(), digest.clone());517
maps.insert(kind.token().to_string(), (name, digest));518
}519
let ppda = build_ppda(520
job.render_doc,521
job.plan,522
&layout,523
job.sex,524
job.set,525
&digests,526
)?;527
let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?;528
bytes.push(b'\n');529
let name = ppda_name(job.sex, job.set);530
progress(format!("atlas · {name}"));531
let ppda_sha256 = publish_streamed(job.root, &name, job.attempt, |out| {532
out.write_all(&bytes).map_err(|e| e.to_string())533
})?;534
Ok(AtlasOutput {535
ppda: name,536
ppda_sha256,537
maps,538
})539
}540
541
// ---------------------------------------------------------------- reader --542
543
fn ident(msg: impl Into<String>) -> ExportError {544
ExportError::Identity(msg.into())545
}546
547
fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {548
if p.format_version != PPDA_FORMAT_VERSION {549
return Err(ident(format!(550
".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION})",551
p.format_version552
)));553
}554
if !["male", "female"].contains(&p.sex.as_str()) {555
return Err(ident(".ppda: ongeldig geslacht"));556
}557
if p.settype != "body" && !crate::equipment::valid_name(&p.settype) {558
return Err(ident(".ppda: ongeldig settype"));559
}560
if file_name != ppda_name(&p.sex, &p.settype) {561
return Err(ident(format!(562
".ppda-bestandsnaam '{file_name}' past niet bij {}/{}",563
p.sex, p.settype564
)));565
}566
if p.project.id.is_empty() || p.project.id.len() > 128 || p.project.name.len() > 256 {567
return Err(ident(".ppda: project id/naam ongeldig"));568
}569
let id = &p.identity;570
if !is_sha256_hex(&id.body_model_sha256) {571
return Err(ExportError::Corrupt(572
".ppda: body_model_sha256 ongeldig".into(),573
));574
}575
match (&id.equipment_preset_sha256, p.settype == "body") {576
(None, true) => {}577
(Some(h), false) if is_sha256_hex(h) => {}578
_ => {579
return Err(ident(580
".ppda: equipment_preset_sha256 past niet bij settype",581
))582
}583
}584
id.framing.validate().map_err(ExportError::Identity)?;585
if !["manual", "neck", "fallback"].contains(&id.pivot_source.as_str()) {586
return Err(ident(".ppda: onbekende pivot_source"));587
}588
if id.near != NEAR || id.far != FAR || id.camera_distance != 10. {589
return Err(ident(".ppda: camera-contract wijkt af"));590
}591
if p.fps == 0 || p.fps > 240 {592
return Err(ident(".ppda: ongeldige fps"));593
}594
if p.pitches != PRODUCTION_PITCHES || p.yaws != production::production_yaws() {595
return Err(ident(".ppda: pitches/yaws wijken af van −60…+60 × 0…330"));596
}597
if p.clips.is_empty() || p.clips.len() > MAX_CLIPS {598
return Err(ExportError::Resource(599
".ppda: clip-inventaris ongeldig".into(),600
));601
}602
let rows = p603
.clips604
.iter()605
.try_fold(0u32, |n, c| n.checked_add(c.frame_count))606
.ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?;607
let layout =608
AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;609
let mut tokens = std::collections::BTreeSet::new();610
let mut row = 0u32;611
for (i, clip) in p.clips.iter().enumerate() {612
if clip.source_index as usize != i {613
return Err(ident(".ppda: clips niet in bronvolgorde"));614
}615
if clip.token.is_empty()616
|| clip.token.len() > 64617
|| !crate::equipment::valid_name(&clip.token)618
|| !tokens.insert(clip.token.clone())619
{620
return Err(ident(format!(621
".ppda: ongeldige clip-token '{}'",622
clip.token623
)));624
}625
if clip.name.len() > MAX_CLIP_NAME || !clip.duration.is_finite() || clip.duration < 0. {626
return Err(ident(".ppda: clipnaam/duur ongeldig"));627
}628
if clip.frame_count == 0 || clip.frames.len() != clip.frame_count as usize {629
return Err(ident(".ppda: frame_count past niet bij frames"));630
}631
for (k, frame) in clip.frames.iter().enumerate() {632
if frame.index as usize != k || frame.views.len() != layout.columns as usize {633
return Err(ident(".ppda: frame-index of aantal hoeken ongeldig"));634
}635
for (c, view) in frame.views.iter().enumerate() {636
let expected = layout.cell(row, c as u32);637
let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width)638
&& u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height);639
if *view != expected || !inside {640
return Err(ExportError::Corrupt(format!(641
".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet",642
clip.token, view.pitch, view.yaw643
)));644
}645
}646
row += 1;647
}648
}649
if p.maps.len() != ATLAS_MAPS.len() {650
return Err(ident(651
".ppda: maps moet precies diffuse/normal/depth/specular bevatten",652
));653
}654
for kind in ATLAS_MAPS {655
let m = p656
.maps657
.get(kind.token())658
.ok_or_else(|| ident(format!(".ppda mist map {}", kind.token())))?;659
if m.file != atlas_png_name(&p.sex, &p.settype, kind) {660
return Err(ExportError::Traversal(format!(661
".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap",662
m.file663
)));664
}665
if !is_sha256_hex(&m.sha256) {666
return Err(ExportError::Corrupt(".ppda: atlas sha256 ongeldig".into()));667
}668
if m.width != layout.width || m.height != layout.height {669
return Err(ExportError::Corrupt(format!(670
".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}",671
kind.token(),672
m.width,673
m.height,674
layout.width,675
layout.height676
)));677
}678
}679
Ok(layout)680
}681
682
/// A validated `.ppda` whose atlases exist, match their SHA-256 and headers.683
#[derive(Debug, Clone)]684
pub struct AtlasSet {685
dir: PathBuf,686
pub ppda: Ppda,687
pub layout: AtlasLayout,688
}689
690
impl AtlasSet {691
/// Export folder containing the `.ppda` and its atlases.692
pub fn dir(&self) -> &Path {693
&self.dir694
}695
696
fn read_ppda(path: &Path) -> Result<(PathBuf, Ppda, AtlasLayout), ExportError> {697
let dir = path698
.parent()699
.ok_or_else(|| ExportError::Io(".ppda zonder map".into()))?;700
let dir = dir701
.canonicalize()702
.map_err(|e| ExportError::Io(e.to_string()))?;703
let file_name = path704
.file_name()705
.and_then(|n| n.to_str())706
.ok_or_else(|| ExportError::Io("ongeldige .ppda-naam".into()))?707
.to_string();708
let path = dir.join(&file_name);709
export_set::verify_regular_file(&path, &dir)?;710
let bytes = export_set::read_bounded_file(&path, MAX_PPDA_BYTES)?;711
let text = std::str::from_utf8(&bytes)712
.map_err(|_| ExportError::Corrupt(".ppda is geen UTF-8".into()))?;713
let ppda: Ppda =714
serde_json::from_str(text).map_err(|e| ExportError::Corrupt(format!(".ppda: {e}")))?;715
let layout = validate_ppda(&ppda, &file_name)?;716
Ok((dir, ppda, layout))717
}718
719
fn read_map(&self, kind: MapKind) -> Result<Vec<u8>, ExportError> {720
let m = &self.ppda.maps[kind.token()];721
let path = self.dir.join(&m.file);722
export_set::verify_regular_file(&path, &self.dir)?;723
let bytes = export_set::read_bounded_file(&path, MAX_ATLAS_PNG_BYTES)?;724
if digest::sha256_hex(&bytes) != m.sha256 {725
return Err(ExportError::Corrupt(format!(726
"checksum mismatch voor '{}'",727
m.file728
)));729
}730
Ok(bytes)731
}732
733
/// Validate the manifest, then each atlas: regular file inside the folder,734
/// SHA-256, PNG header dimensions and sample format.735
pub fn open(ppda_path: &Path) -> Result<Self, ExportError> {736
let (dir, ppda, layout) = Self::read_ppda(ppda_path)?;737
let set = Self { dir, ppda, layout };738
for kind in ATLAS_MAPS {739
let bytes = set.read_map(kind)?;740
let decoder = png::Decoder::new(std::io::Cursor::new(&bytes[..]));741
let reader = decoder742
.read_info()743
.map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?;744
let info = reader.info();745
let (color, depth) = if kind == MapKind::Depth {746
(png::ColorType::GrayscaleAlpha, png::BitDepth::Sixteen)747
} else {748
(png::ColorType::Rgba, png::BitDepth::Eight)749
};750
if info.width != layout.width751
|| info.height != layout.height752
|| info.color_type != color753
|| info.bit_depth != depth754
{755
return Err(ExportError::Corrupt(format!(756
"{} atlas: afmetingen of kleurtype wijken af van .ppda",757
kind.token()758
)));759
}760
}761
Ok(set)762
}763
764
/// Open the single `.ppda` inside a run directory.765
pub fn open_run(run_dir: &Path) -> Result<Self, ExportError> {766
let mut found = Vec::new();767
for entry in fs::read_dir(run_dir).map_err(|e| ExportError::Io(e.to_string()))? {768
let entry = entry.map_err(|e| ExportError::Io(e.to_string()))?;769
let name = entry.file_name();770
if name.to_str().is_some_and(|n| n.ends_with("-atlas.ppda")) {771
found.push(entry.path());772
}773
}774
match found.as_slice() {775
[one] => Self::open(one),776
[] => Err(ExportError::Incomplete(777
"geen .ppda in deze exportmap".into(),778
)),779
_ => Err(ExportError::Identity(780
"meer dan één .ppda in deze exportmap".into(),781
)),782
}783
}784
785
/// Decode all four atlases (re-verifying SHA-256 on the bytes decoded) with786
/// the full map pixel contract.787
pub fn load(&self) -> Result<LoadedAtlas, ExportError> {788
let decoded = u64::from(self.layout.width) * u64::from(self.layout.height) * 4 * 4;789
if decoded > MAX_DECODED_SET_BYTES {790
return Err(ExportError::Resource(format!(791
"atlasset te groot om te laden ({decoded} bytes gedecodeerd)"792
)));793
}794
let mut maps = BTreeMap::new();795
for kind in ATLAS_MAPS {796
let bytes = self.read_map(kind)?;797
let image = export_set::decode_map_sized(798
&bytes,799
kind,800
self.layout.width,801
self.layout.height,802
MAX_ATLAS_DIM,803
)?;804
maps.insert(kind.token(), image);805
}806
Ok(LoadedAtlas {807
set: self.clone(),808
maps,809
})810
}811
}812
813
/// Decoded atlases for one `.ppda`; crops cells for the compositor.814
#[derive(Debug, Clone)]815
pub struct LoadedAtlas {816
pub set: AtlasSet,817
maps: BTreeMap<&'static str, MapImage>,818
}819
820
impl LoadedAtlas {821
pub fn ppda(&self) -> &Ppda {822
&self.set.ppda823
}824
825
/// Coordinates of one view from the `.ppda`.826
pub fn view(827
&self,828
clip_token: &str,829
frame: u32,830
pitch: i32,831
yaw: u32,832
) -> Result<PpdaView, ExportError> {833
let clip = self834
.set835
.ppda836
.clips837
.iter()838
.find(|c| c.token == clip_token)839
.ok_or_else(|| ExportError::Incomplete(format!("clip '{clip_token}' niet in .ppda")))?;840
let frame = clip.frames.get(frame as usize).ok_or_else(|| {841
ExportError::Incomplete(format!("frame {frame} niet in clip '{clip_token}'"))842
})?;843
frame844
.views845
.iter()846
.find(|v| v.pitch == pitch && v.yaw == yaw)847
.copied()848
.ok_or_else(|| ExportError::Incomplete(format!("hoek p{pitch} y{yaw} niet in .ppda")))849
}850
851
/// Crop one map cell exactly as stored.852
pub fn cell(853
&self,854
clip_token: &str,855
frame: u32,856
pitch: i32,857
yaw: u32,858
kind: MapKind,859
) -> Result<MapImage, ExportError> {860
let v = self.view(clip_token, frame, pitch, yaw)?;861
let atlas_w = self.set.layout.width as usize;862
let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);863
match &self.maps[kind.token()] {864
MapImage::Rgba8 { rgba, .. } => {865
let mut out = Vec::with_capacity(w * h * 4);866
for row in y..y + h {867
let start = (row * atlas_w + x) * 4;868
out.extend_from_slice(&rgba[start..start + w * 4]);869
}870
Ok(MapImage::Rgba8 {871
width: v.w,872
height: v.h,873
rgba: out,874
})875
}876
MapImage::Depth16 { samples, .. } => {877
let mut out = Vec::with_capacity(w * h * 2);878
for row in y..y + h {879
let start = (row * atlas_w + x) * 2;880
out.extend_from_slice(&samples[start..start + w * 2]);881
}882
Ok(MapImage::Depth16 {883
width: v.w,884
height: v.h,885
samples: out,886
})887
}888
}889
}890
}891
892
/// Open a completed run through both readers and compare every atlas cell of893
/// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).894
pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {895
let run = export_set::ExportRun::open(run_dir).map_err(|e| e.to_string())?;896
let set = AtlasSet::open_run(run_dir).map_err(|e| e.to_string())?;897
let loaded = set.load().map_err(|e| e.to_string())?;898
let mut cells = 0u64;899
for clip in &set.ppda.clips {900
for frame in &clip.frames {901
for view in &frame.views {902
for kind in ATLAS_MAPS {903
let a = loaded904
.cell(&clip.token, frame.index, view.pitch, view.yaw, kind)905
.map_err(|e| e.to_string())?;906
let b = run907
.load_map(&clip.token, frame.index, view.pitch, view.yaw, kind)908
.map_err(|e| e.to_string())?;909
if a != b {910
return Err(format!(911
"atlascel {} f{} p{} y{} {} wijkt af van losse PNG",912
clip.token,913
frame.index,914
view.pitch,915
view.yaw,916
kind.token()917
));918
}919
cells += 1;920
}921
}922
}923
}924
if cells != u64::from(run.complete.png_files) {925
return Err(format!(926
"{cells} atlascellen ≠ {} losse PNG's",927
run.complete.png_files928
));929
}930
Ok(serde_json::json!({931
"ppda": ppda_name(&set.ppda.sex, &set.ppda.settype),932
"atlas_size": [set.layout.width, set.layout.height],933
"cell": [set.layout.cell_width, set.layout.cell_height],934
"rows": set.layout.rows,935
"columns": set.layout.columns,936
"fps": set.ppda.fps,937
"cells_equal_to_loose_pngs": cells,938
"maps": set.ppda.maps.iter().map(|(k, m)| (k.clone(), serde_json::json!({"file": m.file, "sha256": m.sha256}))).collect::<serde_json::Map<_, _>>(),939
}))940
}941
942
#[cfg(test)]943
mod tests {944
use super::*;945
use crate::export_fixture::{Pixel, SyntheticRun};946
use std::sync::atomic::AtomicU64;947
948
struct Temp(PathBuf);949
impl Temp {950
fn new() -> Self {951
static N: AtomicU64 = AtomicU64::new(0);952
let path = std::env::temp_dir().join(format!(953
"paperdoll-atlas-{}-{}",954
std::process::id(),955
N.fetch_add(1, Ordering::Relaxed)956
));957
let _ = fs::remove_dir_all(&path);958
Self(path)959
}960
}961
impl Drop for Temp {962
fn drop(&mut self) {963
let _ = fs::remove_dir_all(&self.0);964
}965
}966
967
fn fixture() -> Temp {968
let tmp = Temp::new();969
let mut spec = SyntheticRun::body("atlas-test", "female", b"body");970
spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];971
spec.target = [3, 2];972
spec.write(&tmp.0, |c, f, p, y| Pixel {973
diffuse: [c as u8 * 100 + f as u8, (p + 60) as u8, (y / 30) as u8, 255],974
normal: [128, 128, 255, 255],975
specular: [9, 9, 9, 255],976
depth: 1000 + f as u16,977
})978
.unwrap();979
tmp980
}981
982
fn rewrite_ppda(dir: &Path, edit: impl FnOnce(&mut serde_json::Value)) {983
let path = dir.join("female-body-atlas.ppda");984
let mut doc: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();985
edit(&mut doc);986
fs::remove_file(&path).unwrap();987
fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();988
}989
990
#[test]991
fn layout_is_pitch_major_columns_and_frame_rows() {992
let l = AtlasLayout::new(32, 48, 5).unwrap();993
assert_eq!((l.columns, l.width, l.height), (60, 60 * 32, 5 * 48));994
assert_eq!(AtlasLayout::column(-60, 0), Some(0));995
assert_eq!(AtlasLayout::column(-60, 330), Some(11));996
assert_eq!(AtlasLayout::column(-30, 0), Some(12));997
assert_eq!(AtlasLayout::column(60, 330), Some(59));998
assert_eq!(AtlasLayout::column(45, 0), None);999
let v = l.cell(2, 13);1000
assert_eq!(1001
(v.pitch, v.yaw, v.x, v.y, v.w, v.h),1002
(-30, 30, 13 * 32, 96, 32, 48)1003
);1004
// 256-pixel cells allow 64 frame rows; 65 exceeds the GPU/PNG limit.1005
assert!(AtlasLayout::new(256, 256, 64).is_ok());1006
let err = AtlasLayout::new(256, 256, 65).unwrap_err();1007
assert!(err.contains("niet gesplitst"), "{err}");1008
assert!(AtlasLayout::new(0, 1, 1).is_err());1009
assert!(AtlasLayout::new(257, 1, 1).is_err());1010
}1011
1012
#[test]1013
fn written_ppda_has_exact_coordinates_names_fps_and_checksums() {1014
let tmp = fixture();1015
let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();1016
let p = &set.ppda;1017
assert_eq!(p.format_version, 1);1018
assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));1019
assert_eq!(p.fps, 4);1020
assert_eq!((p.cell_width, p.cell_height), (3, 2));1021
assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);1022
assert_eq!(p.yaws.len(), 12);1023
assert_eq!(p.maps["diffuse"].file, "female-body-diffuse-atlas.png");1024
assert_eq!(p.maps["depth"].file, "female-body-depth-atlas.png");1025
assert_eq!((p.maps["normal"].width, p.maps["normal"].height), (180, 8));1026
for kind in ATLAS_MAPS {1027
let m = &p.maps[kind.token()];1028
let bytes = fs::read(tmp.0.join(&m.file)).unwrap();1029
assert_eq!(digest::sha256_hex(&bytes), m.sha256);1030
}1031
assert_eq!(p.clips.len(), 2);1032
assert_eq!(p.clips[0].frame_count, 3);1033
assert_eq!(p.clips[1].token, "clip001-idle");1034
let idle = &p.clips[1].frames[0].views[59];1035
assert_eq!((idle.pitch, idle.yaw, idle.x, idle.y), (60, 330, 177, 6));1036
// Cells hold exactly the loose map bytes.1037
let loaded = set.load().unwrap();1038
let run = crate::export_set::ExportRun::open(&tmp.0).unwrap();1039
for (clip, frame) in [("clip000-walk", 2), ("clip001-idle", 0)] {1040
for (pitch, yaw) in [(-60, 0), (30, 210), (60, 330)] {1041
for kind in ATLAS_MAPS {1042
assert_eq!(1043
loaded.cell(clip, frame, pitch, yaw, kind).unwrap(),1044
run.load_map(clip, frame, pitch, yaw, kind).unwrap()1045
);1046
}1047
}1048
}1049
assert!(loaded1050
.cell("clip000-walk", 3, 0, 0, MapKind::Diffuse)1051
.is_err());1052
assert!(loaded.cell("clip009-x", 0, 0, 0, MapKind::Diffuse).is_err());1053
// The diffuse atlas carries the sRGB chunk like the loose diffuse maps.1054
let bytes = fs::read(tmp.0.join("female-body-diffuse-atlas.png")).unwrap();1055
let info = png::Decoder::new(std::io::Cursor::new(&bytes[..]))1056
.read_info()1057
.unwrap()1058
.info()1059
.srgb;1060
assert!(info.is_some());1061
}1062
1063
#[test]1064
fn reader_rejects_tampering_unknown_fields_versions_paths_and_coordinates() {1065
let tmp = fixture();1066
let ppda = tmp.0.join("female-body-atlas.ppda");1067
let original = fs::read(&ppda).unwrap();1068
let restore = || {1069
fs::remove_file(&ppda).unwrap();1070
fs::write(&ppda, &original).unwrap();1071
};1072
AtlasSet::open(&ppda).unwrap();1073
1074
rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());1075
assert!(matches!(1076
AtlasSet::open(&ppda),1077
Err(ExportError::Identity(_))1078
));1079
restore();1080
rewrite_ppda(&tmp.0, |d| d["extra"] = true.into());1081
assert!(matches!(1082
AtlasSet::open(&ppda),1083
Err(ExportError::Corrupt(_))1084
));1085
restore();1086
rewrite_ppda(&tmp.0, |d| {1087
d["clips"][0]["frames"][1]["views"][5]["x"] = 1.into()1088
});1089
assert!(matches!(1090
AtlasSet::open(&ppda),1091
Err(ExportError::Corrupt(_))1092
));1093
restore();1094
rewrite_ppda(&tmp.0, |d| {1095
d["maps"]["diffuse"]["file"] = "../female-body-diffuse-atlas.png".into()1096
});1097
assert!(matches!(1098
AtlasSet::open(&ppda),1099
Err(ExportError::Traversal(_))1100
));1101
restore();1102
rewrite_ppda(&tmp.0, |d| d["pitches"] = serde_json::json!([-30, 0, 30]));1103
assert!(AtlasSet::open(&ppda).is_err());1104
restore();1105
rewrite_ppda(&tmp.0, |d| d["maps"]["depth"]["height"] = 10.into());1106
assert!(AtlasSet::open(&ppda).is_err());1107
restore();1108
rewrite_ppda(&tmp.0, |d| d["clips"][0]["frame_count"] = 2.into());1109
assert!(AtlasSet::open(&ppda).is_err());1110
restore();1111
AtlasSet::open(&ppda).unwrap();1112
1113
// Damaged atlas bytes fail the SHA-256 check.1114
let normal = tmp.0.join("female-body-normal-atlas.png");1115
let good = fs::read(&normal).unwrap();1116
let mut bad = good.clone();1117
let last = bad.len() - 20;1118
bad[last] ^= 0xff;1119
fs::remove_file(&normal).unwrap();1120
fs::write(&normal, &bad).unwrap();1121
assert!(matches!(1122
AtlasSet::open(&ppda),1123
Err(ExportError::Corrupt(_))1124
));1125
fs::remove_file(&normal).unwrap();1126
#[cfg(unix)]1127
{1128
// A symlinked atlas is refused even with matching bytes.1129
let outside = Temp::new();1130
fs::create_dir_all(&outside.0).unwrap();1131
fs::write(outside.0.join("n.png"), &good).unwrap();1132
std::os::unix::fs::symlink(outside.0.join("n.png"), &normal).unwrap();1133
assert!(matches!(1134
AtlasSet::open(&ppda),1135
Err(ExportError::Traversal(_))1136
));1137
fs::remove_file(&normal).unwrap();1138
}1139
fs::write(&normal, &good).unwrap();1140
AtlasSet::open(&ppda).unwrap();1141
// A renamed .ppda no longer matches its sex/settype.1142
fs::copy(&ppda, tmp.0.join("male-body-atlas.ppda")).unwrap();1143
assert!(AtlasSet::open(&tmp.0.join("male-body-atlas.ppda")).is_err());1144
assert!(matches!(1145
AtlasSet::open_run(&tmp.0),1146
Err(ExportError::Identity(_))1147
));1148
}1149
1150
#[test]1151
fn atlases_never_clobber_and_are_refused_when_too_large() {1152
let tmp = fixture();1153
let render: serde_json::Value =1154
serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap();1155
let complete: serde_json::Value =1156
serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap();1157
let sums: BTreeMap<String, String> =1158
serde_json::from_value(complete["png_sha256"].clone()).unwrap();1159
let mut spec = SyntheticRun::body("atlas-test", "female", b"body");1160
spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];1161
let plan = spec.plan();1162
let cancel = AtomicBool::new(false);1163
let job = AtlasJob {1164
root: &tmp.0,1165
render_doc: &render,1166
plan: &plan,1167
sex: "female",1168
set: "body",1169
cell_width: 3,1170
cell_height: 2,1171
png_sha256: &sums,1172
attempt: 1,1173
cancel: &cancel,1174
};1175
// Re-running over identical files keeps them (no clobber, same result).1176
let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();1177
let out = write_atlases(&job, &mut |_| {}).unwrap();1178
assert_eq!(1179
fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(),1180
before1181
);1182
assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);1183
// A loose map changed after verification is refused.1184
let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png";1185
let mut tampered = sums.clone();1186
tampered.insert(rel.into(), digest::sha256_hex(b"x"));1187
let err = write_atlases(1188
&AtlasJob {1189
png_sha256: &tampered,1190
..job1191
},1192
&mut |_| {},1193
)1194
.unwrap_err();1195
assert!(err.contains("checksum"), "{err}");1196
// Too many frames for 256-pixel cells: clear refusal, never split.1197
let mut big = spec.clone();1198
big.clips = vec![("long".into(), 65, 16.25)];1199
assert!(AtlasLayout::for_plan(&big.plan(), 256, 256)1200
.unwrap_err()1201
.contains("16384"));1202
}1203
}@@ -294,14 +294,14 @@ mod tests {
294
294
}
295
295
296
296
#[test]
297
fn all_36_angles_share_pivot_and_neck_projects_stably() {
297
fn all_60_angles_share_pivot_and_neck_projects_stably() {
298
298
let framing = CameraFraming {
299
299
height_milli: 3000,
300
300
center_y_milli: 1100,
301
301
pivot_y_milli: Some(1500),
302
302
};
303
303
let mut pivot_ndcs = Vec::new();
304
for pitch in [-30., 0., 30.] {
304
for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {
305
305
for yaw in (0..12).map(|i| i as f32 * 30.) {
306
306
let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
307
307
let pivot = project(view, projection, [0., 1.5, 0.]);
@@ -339,7 +339,7 @@ mod tests {
339
339
}
340
340
}
341
341
}
342
assert_eq!(pivot_ndcs.len(), 36);
342
assert_eq!(pivot_ndcs.len(), 60);
343
343
let first = pivot_ndcs[0];
344
344
assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
345
345
}
@@ -294,14 +294,14 @@ mod tests {294
}294
}295
295
296
#[test]296
#[test]297
fn all_36_angles_share_pivot_and_neck_projects_stably() {297
fn all_60_angles_share_pivot_and_neck_projects_stably() {298
let framing = CameraFraming {298
let framing = CameraFraming {299
height_milli: 3000,299
height_milli: 3000,300
center_y_milli: 1100,300
center_y_milli: 1100,301
pivot_y_milli: Some(1500),301
pivot_y_milli: Some(1500),302
};302
};303
let mut pivot_ndcs = Vec::new();303
let mut pivot_ndcs = Vec::new();304
for pitch in [-30., 0., 30.] {304
for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {305
for yaw in (0..12).map(|i| i as f32 * 30.) {305
for yaw in (0..12).map(|i| i as f32 * 30.) {306
let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);306
let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);307
let pivot = project(view, projection, [0., 1.5, 0.]);307
let pivot = project(view, projection, [0., 1.5, 0.]);@@ -339,7 +339,7 @@ mod tests {339
}339
}340
}340
}341
}341
}342
assert_eq!(pivot_ndcs.len(), 36);342
assert_eq!(pivot_ndcs.len(), 60);343
let first = pivot_ndcs[0];343
let first = pivot_ndcs[0];344
assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));344
assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));345
}345
}@@ -1,6 +1,7 @@
1
1
//! Deterministic 2D depth compositor and linear relighting.
2
2
//!
3
//! Consumes already-exported maps through [`crate::export_set`] (schema 3).
3
//! Consumes already-exported maps through [`crate::export_set`] (loose PNGs) or
4
//! [`crate::atlas`] (`.ppda` + atlases, the consumer format).
4
5
//! Saved diffuse remains unlit albedo elsewhere; this module only applies
5
6
//! consumer-side lighting to composed layers.
6
7
//!
@@ -150,6 +151,22 @@ impl LayerMaps {
150
151
let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;
151
152
Self::from_maps(label, &diffuse, &normal, &specular, &depth)
152
153
}
154
155
/// Crop the four map cells for one pose from decoded `.ppda` atlases.
156
pub fn from_atlas(
157
atlas: &crate::atlas::LoadedAtlas,
158
label: impl Into<String>,
159
clip_token: &str,
160
frame: u32,
161
pitch: i32,
162
yaw: u32,
163
) -> Result<Self, ComposeError> {
164
let diffuse = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;
165
let normal = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Normal)?;
166
let specular = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Specular)?;
167
let depth = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Depth)?;
168
Self::from_maps(label, &diffuse, &normal, &specular, &depth)
169
}
153
170
}
154
171
155
172
fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
@@ -213,14 +230,40 @@ impl CompatKey {
213
230
}
214
231
}
215
232
233
impl CompatKey {
234
/// Same contract read from a validated `.ppda` (consumer format).
235
pub fn from_ppda(p: &crate::atlas::Ppda) -> Self {
236
Self {
237
project_id: p.project.id.clone(),
238
sex: p.sex.clone(),
239
body_model_sha256: p.identity.body_model_sha256.clone(),
240
target: [p.cell_width, p.cell_height],
241
fps: p.fps,
242
framing: p.identity.framing,
243
near: p.identity.near,
244
far: p.identity.far,
245
clip_tokens: p
246
.clips
247
.iter()
248
.map(|c| (c.token.clone(), c.frame_count))
249
.collect(),
250
}
251
}
252
}
253
216
254
/// Reject runs that cannot be depth-composited together.
217
255
pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
218
if runs.is_empty() {
256
let keys: Vec<CompatKey> = runs.iter().map(|r| CompatKey::from_run(r)).collect();
257
assert_compatible_keys(&keys)
258
}
259
260
/// Reject layer contracts that cannot be depth-composited together.
261
pub fn assert_compatible_keys(keys: &[CompatKey]) -> Result<(), ComposeError> {
262
if keys.is_empty() {
219
263
return Err(ComposeError::Empty);
220
264
}
221
let first = CompatKey::from_run(runs[0]);
222
for run in runs.iter().skip(1) {
223
let key = CompatKey::from_run(run);
265
let first = &keys[0];
266
for key in keys.iter().skip(1) {
224
267
if key.project_id != first.project_id {
225
268
return Err(ComposeError::Incompatible(
226
269
"project.id verschilt tussen exportsets".into(),
@@ -505,10 +548,9 @@ fn encode_srgb(x: f32) -> u8 {
505
548
mod tests {
506
549
use super::*;
507
550
use crate::digest;
508
use crate::export_set::{complete_identity_json, ExportRun};
509
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
551
use crate::export_fixture::{Pixel, SyntheticRun};
552
use crate::export_set::ExportRun;
510
553
use crate::project::CameraFraming;
511
use std::collections::BTreeMap;
512
554
use std::fs;
513
555
use std::path::{Path, PathBuf};
514
556
use std::sync::atomic::{AtomicU64, Ordering};
@@ -845,138 +887,27 @@ mod tests {
845
887
assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));
846
888
}
847
889
848
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
849
let data = serde_json::to_vec_pretty(value).unwrap();
850
let path = root.join(name);
851
let temp = path.with_extension(format!("part-{}", std::process::id()));
852
fs::write(&temp, &data).unwrap();
853
fs::hard_link(&temp, &path).unwrap();
854
let _ = fs::remove_file(temp);
855
}
856
857
fn publish_pose(
858
root: &Path,
859
sex: &str,
860
set: &str,
861
diffuse: [u8; 4],
862
normal: [u8; 4],
863
specular: [u8; 4],
864
depth_gray: u16,
865
) -> BTreeMap<String, String> {
866
let mut map = BTreeMap::new();
867
let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000");
868
let rels = [
869
(format!("{base}_diffuse.png"), "diffuse"),
870
(format!("{base}_depth.png"), "depth"),
871
(format!("{base}_normal.png"), "normal"),
872
(format!("{base}_specular.png"), "specular"),
873
];
874
for (rel, kind) in rels {
875
let path = Path::new(&rel);
876
match kind {
877
"depth" => {
878
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
879
}
880
"normal" => {
881
write_rgba(
882
root,
883
path,
884
&ImageBuf {
885
width: 1,
886
height: 1,
887
rgba: normal.to_vec(),
888
},
889
)
890
.unwrap();
891
}
892
"specular" => {
893
write_rgba(
894
root,
895
path,
896
&ImageBuf {
897
width: 1,
898
height: 1,
899
rgba: specular.to_vec(),
900
},
901
)
902
.unwrap();
903
}
904
_ => {
905
write_rgba(
906
root,
907
path,
908
&ImageBuf {
909
width: 1,
910
height: 1,
911
rgba: diffuse.to_vec(),
912
},
913
)
914
.unwrap();
915
}
916
}
917
let bytes = fs::read(root.join(&rel)).unwrap();
918
map.insert(rel, digest::sha256_hex(&bytes));
919
}
920
map
921
}
922
923
890
fn write_completed_run(
924
891
root: &Path,
925
892
set: &str,
926
893
equipment_sha: Option<&str>,
927
checksums: BTreeMap<String, String>,
894
px: Pixel,
928
895
body_bytes: &[u8],
929
896
) {
930
let body_hash = digest::sha256_hex(body_bytes);
931
let mut render = serde_json::json!({
932
"format_version": 3,
933
"status": "incomplete",
934
"project": {"id": "demo", "name": "Demo"},
935
"sex": "male",
936
"set": set,
937
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
938
"equipment_preset": null,
939
"target": [1, 1],
940
"fps": 12,
941
"highres_scale": 8,
942
"framing": framing(),
943
"pivot_source": {"kind": "manual"},
944
"near": NEAR,
945
"far": FAR,
946
"camera_distance": 10.0,
947
"yaw_zero": "camera on -Z",
948
"normal_space": "view (+Y up), PNG rows top-down",
949
"pitches": [-30, 0, 30],
950
"yaw_step": 30,
951
"sampling": "test",
952
"alpha": "straight",
953
"diffuse": "unlit",
954
"depth": "16-bit",
955
"specular": "scalar",
956
"pass_order": PASSES,
957
"saved_maps": ["diffuse", "depth", "normal", "specular"],
958
"clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
959
"images": 1,
960
"passes": 6,
961
"png_files": 4
962
});
963
if let Some(sha) = equipment_sha {
964
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
897
let mut spec = SyntheticRun::body("demo", "male", body_bytes);
898
spec.framing = framing();
899
spec.set = set.into();
900
spec.equipment_sha256 = equipment_sha.map(str::to_string);
901
spec.write(root, |_, _, _, _| px).unwrap();
902
}
903
904
fn px(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
905
Pixel {
906
diffuse,
907
normal,
908
specular,
909
depth,
965
910
}
966
write_atomic_json(root, "render.json", &render);
967
let identity = complete_identity_json(&render).unwrap();
968
write_atomic_json(
969
root,
970
"complete.json",
971
&serde_json::json!({
972
"format_version": 3,
973
"status": "complete",
974
"identity": identity,
975
"passes": 6,
976
"png_files": 4,
977
"png_sha256": checksums
978
}),
979
);
980
911
}
981
912
982
913
#[test]
@@ -984,33 +915,21 @@ mod tests {
984
915
let body_tmp = Temp::new();
985
916
let gear_tmp = Temp::new();
986
917
let body_bytes = b"shared-body";
987
let body_maps = publish_pose(
988
&body_tmp.0,
989
"male",
990
"body",
918
let body_px = px(
991
919
[20, 40, 60, 255],
992
920
[128, 128, 255, 255],
993
921
[0, 0, 0, 255],
994
922
30000,
995
923
);
996
write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes);
924
write_completed_run(&body_tmp.0, "body", None, body_px, body_bytes);
997
925
let gear_hash = digest::sha256_hex(b"sword-ppe");
998
let gear_maps = publish_pose(
999
&gear_tmp.0,
1000
"male",
1001
"sword",
926
let gear_px = px(
1002
927
[220, 10, 10, 255],
1003
928
[255, 128, 128, 255],
1004
929
[200, 200, 200, 255],
1005
930
12000,
1006
931
);
1007
write_completed_run(
1008
&gear_tmp.0,
1009
"sword",
1010
Some(&gear_hash),
1011
gear_maps,
1012
body_bytes,
1013
);
932
write_completed_run(&gear_tmp.0, "sword", Some(&gear_hash), gear_px, body_bytes);
1014
933
1015
934
let body_run = ExportRun::open(&body_tmp.0).unwrap();
1016
935
let gear_run = ExportRun::open(&gear_tmp.0).unwrap();
@@ -1027,32 +946,33 @@ mod tests {
1027
946
assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);
1028
947
assert_eq!(lit.rgba[3], 255);
1029
948
assert!(lit.rgba[0] > 0);
949
950
// The .ppda/atlas path yields the identical layer maps.
951
let body_atlas = crate::atlas::AtlasSet::open_run(&body_tmp.0)
952
.unwrap()
953
.load()
954
.unwrap();
955
let from_atlas =
956
LayerMaps::from_atlas(&body_atlas, "body", "clip000-bind", 0, 60, 300).unwrap();
957
let loose =
958
LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 60, 300).unwrap();
959
assert_eq!(from_atlas.diffuse, loose.diffuse);
960
assert_eq!(from_atlas.normal, loose.normal);
961
assert_eq!(from_atlas.specular, loose.specular);
962
assert_eq!(from_atlas.depth, loose.depth);
963
assert_eq!(
964
CompatKey::from_ppda(body_atlas.ppda()),
965
CompatKey::from_run(&body_run)
966
);
1030
967
}
1031
968
1032
969
#[test]
1033
970
fn incompatible_export_sets_are_rejected() {
1034
971
let a = Temp::new();
1035
972
let b = Temp::new();
1036
let maps_a = publish_pose(
1037
&a.0,
1038
"male",
1039
"body",
1040
[1, 2, 3, 255],
1041
[128, 128, 255, 255],
1042
[0, 0, 0, 255],
1043
1000,
1044
);
1045
write_completed_run(&a.0, "body", None, maps_a, b"body-a");
1046
let maps_b = publish_pose(
1047
&b.0,
1048
"male",
1049
"body",
1050
[1, 2, 3, 255],
1051
[128, 128, 255, 255],
1052
[0, 0, 0, 255],
1053
1000,
1054
);
1055
write_completed_run(&b.0, "body", None, maps_b, b"body-b");
973
let same = px([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000);
974
write_completed_run(&a.0, "body", None, same, b"body-a");
975
write_completed_run(&b.0, "body", None, same, b"body-b");
1056
976
let run_a = ExportRun::open(&a.0).unwrap();
1057
977
let run_b = ExportRun::open(&b.0).unwrap();
1058
978
let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
@@ -1,6 +1,7 @@1
//! Deterministic 2D depth compositor and linear relighting.1
//! Deterministic 2D depth compositor and linear relighting.2
//!2
//!3
//! Consumes already-exported maps through [`crate::export_set`] (schema 3).3
//! Consumes already-exported maps through [`crate::export_set`] (loose PNGs) or4
//! [`crate::atlas`] (`.ppda` + atlases, the consumer format).4
//! Saved diffuse remains unlit albedo elsewhere; this module only applies5
//! Saved diffuse remains unlit albedo elsewhere; this module only applies5
//! consumer-side lighting to composed layers.6
//! consumer-side lighting to composed layers.6
//!7
//!@@ -150,6 +151,22 @@ impl LayerMaps {150
let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;151
let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;151
Self::from_maps(label, &diffuse, &normal, &specular, &depth)152
Self::from_maps(label, &diffuse, &normal, &specular, &depth)152
}153
}154
155
/// Crop the four map cells for one pose from decoded `.ppda` atlases.156
pub fn from_atlas(157
atlas: &crate::atlas::LoadedAtlas,158
label: impl Into<String>,159
clip_token: &str,160
frame: u32,161
pitch: i32,162
yaw: u32,163
) -> Result<Self, ComposeError> {164
let diffuse = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;165
let normal = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Normal)?;166
let specular = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Specular)?;167
let depth = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Depth)?;168
Self::from_maps(label, &diffuse, &normal, &specular, &depth)169
}153
}170
}154
171
155
fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {172
fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {@@ -213,14 +230,40 @@ impl CompatKey {213
}230
}214
}231
}215
232
233
impl CompatKey {234
/// Same contract read from a validated `.ppda` (consumer format).235
pub fn from_ppda(p: &crate::atlas::Ppda) -> Self {236
Self {237
project_id: p.project.id.clone(),238
sex: p.sex.clone(),239
body_model_sha256: p.identity.body_model_sha256.clone(),240
target: [p.cell_width, p.cell_height],241
fps: p.fps,242
framing: p.identity.framing,243
near: p.identity.near,244
far: p.identity.far,245
clip_tokens: p246
.clips247
.iter()248
.map(|c| (c.token.clone(), c.frame_count))249
.collect(),250
}251
}252
}253
216
/// Reject runs that cannot be depth-composited together.254
/// Reject runs that cannot be depth-composited together.217
pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {255
pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {218
if runs.is_empty() {256
let keys: Vec<CompatKey> = runs.iter().map(|r| CompatKey::from_run(r)).collect();257
assert_compatible_keys(&keys)258
}259
260
/// Reject layer contracts that cannot be depth-composited together.261
pub fn assert_compatible_keys(keys: &[CompatKey]) -> Result<(), ComposeError> {262
if keys.is_empty() {219
return Err(ComposeError::Empty);263
return Err(ComposeError::Empty);220
}264
}221
let first = CompatKey::from_run(runs[0]);265
let first = &keys[0];222
for run in runs.iter().skip(1) {266
for key in keys.iter().skip(1) {223
let key = CompatKey::from_run(run);224
if key.project_id != first.project_id {267
if key.project_id != first.project_id {225
return Err(ComposeError::Incompatible(268
return Err(ComposeError::Incompatible(226
"project.id verschilt tussen exportsets".into(),269
"project.id verschilt tussen exportsets".into(),@@ -505,10 +548,9 @@ fn encode_srgb(x: f32) -> u8 {505
mod tests {548
mod tests {506
use super::*;549
use super::*;507
use crate::digest;550
use crate::digest;508
use crate::export_set::{complete_identity_json, ExportRun};551
use crate::export_fixture::{Pixel, SyntheticRun};509
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};552
use crate::export_set::ExportRun;510
use crate::project::CameraFraming;553
use crate::project::CameraFraming;511
use std::collections::BTreeMap;512
use std::fs;554
use std::fs;513
use std::path::{Path, PathBuf};555
use std::path::{Path, PathBuf};514
use std::sync::atomic::{AtomicU64, Ordering};556
use std::sync::atomic::{AtomicU64, Ordering};@@ -845,138 +887,27 @@ mod tests {845
assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));887
assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));846
}888
}847
889
848
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {849
let data = serde_json::to_vec_pretty(value).unwrap();850
let path = root.join(name);851
let temp = path.with_extension(format!("part-{}", std::process::id()));852
fs::write(&temp, &data).unwrap();853
fs::hard_link(&temp, &path).unwrap();854
let _ = fs::remove_file(temp);855
}856
857
fn publish_pose(858
root: &Path,859
sex: &str,860
set: &str,861
diffuse: [u8; 4],862
normal: [u8; 4],863
specular: [u8; 4],864
depth_gray: u16,865
) -> BTreeMap<String, String> {866
let mut map = BTreeMap::new();867
let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000");868
let rels = [869
(format!("{base}_diffuse.png"), "diffuse"),870
(format!("{base}_depth.png"), "depth"),871
(format!("{base}_normal.png"), "normal"),872
(format!("{base}_specular.png"), "specular"),873
];874
for (rel, kind) in rels {875
let path = Path::new(&rel);876
match kind {877
"depth" => {878
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();879
}880
"normal" => {881
write_rgba(882
root,883
path,884
&ImageBuf {885
width: 1,886
height: 1,887
rgba: normal.to_vec(),888
},889
)890
.unwrap();891
}892
"specular" => {893
write_rgba(894
root,895
path,896
&ImageBuf {897
width: 1,898
height: 1,899
rgba: specular.to_vec(),900
},901
)902
.unwrap();903
}904
_ => {905
write_rgba(906
root,907
path,908
&ImageBuf {909
width: 1,910
height: 1,911
rgba: diffuse.to_vec(),912
},913
)914
.unwrap();915
}916
}917
let bytes = fs::read(root.join(&rel)).unwrap();918
map.insert(rel, digest::sha256_hex(&bytes));919
}920
map921
}922
923
fn write_completed_run(890
fn write_completed_run(924
root: &Path,891
root: &Path,925
set: &str,892
set: &str,926
equipment_sha: Option<&str>,893
equipment_sha: Option<&str>,927
checksums: BTreeMap<String, String>,894
px: Pixel,928
body_bytes: &[u8],895
body_bytes: &[u8],929
) {896
) {930
let body_hash = digest::sha256_hex(body_bytes);897
let mut spec = SyntheticRun::body("demo", "male", body_bytes);931
let mut render = serde_json::json!({898
spec.framing = framing();932
"format_version": 3,899
spec.set = set.into();933
"status": "incomplete",900
spec.equipment_sha256 = equipment_sha.map(str::to_string);934
"project": {"id": "demo", "name": "Demo"},901
spec.write(root, |_, _, _, _| px).unwrap();935
"sex": "male",902
}936
"set": set,903
937
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},904
fn px(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {938
"equipment_preset": null,905
Pixel {939
"target": [1, 1],906
diffuse,940
"fps": 12,907
normal,941
"highres_scale": 8,908
specular,942
"framing": framing(),909
depth,943
"pivot_source": {"kind": "manual"},944
"near": NEAR,945
"far": FAR,946
"camera_distance": 10.0,947
"yaw_zero": "camera on -Z",948
"normal_space": "view (+Y up), PNG rows top-down",949
"pitches": [-30, 0, 30],950
"yaw_step": 30,951
"sampling": "test",952
"alpha": "straight",953
"diffuse": "unlit",954
"depth": "16-bit",955
"specular": "scalar",956
"pass_order": PASSES,957
"saved_maps": ["diffuse", "depth", "normal", "specular"],958
"clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],959
"images": 1,960
"passes": 6,961
"png_files": 4962
});963
if let Some(sha) = equipment_sha {964
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});965
}910
}966
write_atomic_json(root, "render.json", &render);967
let identity = complete_identity_json(&render).unwrap();968
write_atomic_json(969
root,970
"complete.json",971
&serde_json::json!({972
"format_version": 3,973
"status": "complete",974
"identity": identity,975
"passes": 6,976
"png_files": 4,977
"png_sha256": checksums978
}),979
);980
}911
}981
912
982
#[test]913
#[test]@@ -984,33 +915,21 @@ mod tests {984
let body_tmp = Temp::new();915
let body_tmp = Temp::new();985
let gear_tmp = Temp::new();916
let gear_tmp = Temp::new();986
let body_bytes = b"shared-body";917
let body_bytes = b"shared-body";987
let body_maps = publish_pose(918
let body_px = px(988
&body_tmp.0,989
"male",990
"body",991
[20, 40, 60, 255],919
[20, 40, 60, 255],992
[128, 128, 255, 255],920
[128, 128, 255, 255],993
[0, 0, 0, 255],921
[0, 0, 0, 255],994
30000,922
30000,995
);923
);996
write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes);924
write_completed_run(&body_tmp.0, "body", None, body_px, body_bytes);997
let gear_hash = digest::sha256_hex(b"sword-ppe");925
let gear_hash = digest::sha256_hex(b"sword-ppe");998
let gear_maps = publish_pose(926
let gear_px = px(999
&gear_tmp.0,1000
"male",1001
"sword",1002
[220, 10, 10, 255],927
[220, 10, 10, 255],1003
[255, 128, 128, 255],928
[255, 128, 128, 255],1004
[200, 200, 200, 255],929
[200, 200, 200, 255],1005
12000,930
12000,1006
);931
);1007
write_completed_run(932
write_completed_run(&gear_tmp.0, "sword", Some(&gear_hash), gear_px, body_bytes);1008
&gear_tmp.0,1009
"sword",1010
Some(&gear_hash),1011
gear_maps,1012
body_bytes,1013
);1014
933
1015
let body_run = ExportRun::open(&body_tmp.0).unwrap();934
let body_run = ExportRun::open(&body_tmp.0).unwrap();1016
let gear_run = ExportRun::open(&gear_tmp.0).unwrap();935
let gear_run = ExportRun::open(&gear_tmp.0).unwrap();@@ -1027,32 +946,33 @@ mod tests {1027
assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);946
assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);1028
assert_eq!(lit.rgba[3], 255);947
assert_eq!(lit.rgba[3], 255);1029
assert!(lit.rgba[0] > 0);948
assert!(lit.rgba[0] > 0);949
950
// The .ppda/atlas path yields the identical layer maps.951
let body_atlas = crate::atlas::AtlasSet::open_run(&body_tmp.0)952
.unwrap()953
.load()954
.unwrap();955
let from_atlas =956
LayerMaps::from_atlas(&body_atlas, "body", "clip000-bind", 0, 60, 300).unwrap();957
let loose =958
LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 60, 300).unwrap();959
assert_eq!(from_atlas.diffuse, loose.diffuse);960
assert_eq!(from_atlas.normal, loose.normal);961
assert_eq!(from_atlas.specular, loose.specular);962
assert_eq!(from_atlas.depth, loose.depth);963
assert_eq!(964
CompatKey::from_ppda(body_atlas.ppda()),965
CompatKey::from_run(&body_run)966
);1030
}967
}1031
968
1032
#[test]969
#[test]1033
fn incompatible_export_sets_are_rejected() {970
fn incompatible_export_sets_are_rejected() {1034
let a = Temp::new();971
let a = Temp::new();1035
let b = Temp::new();972
let b = Temp::new();1036
let maps_a = publish_pose(973
let same = px([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000);1037
&a.0,974
write_completed_run(&a.0, "body", None, same, b"body-a");1038
"male",975
write_completed_run(&b.0, "body", None, same, b"body-b");1039
"body",1040
[1, 2, 3, 255],1041
[128, 128, 255, 255],1042
[0, 0, 0, 255],1043
1000,1044
);1045
write_completed_run(&a.0, "body", None, maps_a, b"body-a");1046
let maps_b = publish_pose(1047
&b.0,1048
"male",1049
"body",1050
[1, 2, 3, 255],1051
[128, 128, 255, 255],1052
[0, 0, 0, 255],1053
1000,1054
);1055
write_completed_run(&b.0, "body", None, maps_b, b"body-b");1056
let run_a = ExportRun::open(&a.0).unwrap();976
let run_a = ExportRun::open(&a.0).unwrap();1057
let run_b = ExportRun::open(&b.0).unwrap();977
let run_b = ExportRun::open(&b.0).unwrap();1058
let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();978
let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();@@ -10,9 +10,9 @@ use crate::preview::{
10
10
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,
11
11
};
12
12
use crate::preview2d::{
13
compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
14
sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
15
ViewMode,
13
compatible_equipment_sets, compose_preview_frame, discover_exports, open_selection,
14
selection_paths, sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis,
15
Preview2dState, ViewMode,
16
16
};
17
17
use crate::preview_gl::Viewport;
18
18
use crate::project::open_project;
@@ -42,6 +42,10 @@ pub struct EditorApp {
42
42
equipment_part: usize,
43
43
preview2d: Preview2dState,
44
44
preview2d_catalog: Vec<CatalogEntry>,
45
/// Runs under `renders/` without a valid `.ppda` (reason shown on hover).
46
preview2d_skipped: Vec<(std::path::PathBuf, String)>,
47
/// Decoded `.ppda` atlases of the current selection, keyed by run paths.
48
preview2d_loaded: Option<PreviewSelection>,
45
49
preview2d_texture: Option<egui::TextureHandle>,
46
50
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
47
51
preview2d_image: Option<ImageBuf>,
@@ -51,6 +55,13 @@ pub struct EditorApp {
51
55
resume_scan_error: Option<String>,
52
56
}
53
57
58
/// Decoded atlases for the 2D preview selection (body first).
59
struct PreviewSelection {
60
paths: Vec<std::path::PathBuf>,
61
body: crate::atlas::LoadedAtlas,
62
gear: Vec<crate::atlas::LoadedAtlas>,
63
}
64
54
65
impl EditorApp {
55
66
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
56
67
Self {
@@ -70,12 +81,14 @@ impl EditorApp {
70
81
applied_shell: None,
71
82
width_text: "128".to_string(),
72
83
height_text: "128".to_string(),
73
fps_text: "12".to_string(),
84
fps_text: crate::project::DEFAULT_FPS.to_string(),
74
85
allow_close: false,
75
86
dialog_pending: false,
76
87
equipment_part: 0,
77
88
preview2d: Preview2dState::default(),
78
89
preview2d_catalog: Vec::new(),
90
preview2d_skipped: Vec::new(),
91
preview2d_loaded: None,
79
92
preview2d_texture: None,
80
93
preview2d_texture_key: None,
81
94
preview2d_image: None,
@@ -1005,7 +1018,7 @@ impl EditorApp {
1005
1018
ui.colored_label(
1006
1019
egui::Color32::from_rgb(230, 190, 90),
1007
1020
format!(
1008
"{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 36 hoeken."
1021
"{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."
1009
1022
),
1010
1023
);
1011
1024
}
@@ -1386,15 +1399,21 @@ impl EditorApp {
1386
1399
&self.preview2d
1387
1400
}
1388
1401
1402
/// Mutable 2D-preview state (view/playback), e.g. for GUI smokes.
1403
pub fn preview2d_state_mut(&mut self) -> &mut Preview2dState {
1404
&mut self.preview2d
1405
}
1406
1389
1407
pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1390
1408
self.preview2d_image.as_ref()
1391
1409
}
1392
1410
1393
1411
fn refresh_preview2d_catalog(&mut self) {
1394
self.preview2d_catalog = match self.session.project_root() {
1395
Some(root) => discover_completed_runs(&root),
1396
None => Vec::new(),
1412
(self.preview2d_catalog, self.preview2d_skipped) = match self.session.project_root() {
1413
Some(root) => discover_exports(&root),
1414
None => (Vec::new(), Vec::new()),
1397
1415
};
1416
self.preview2d_loaded = None;
1398
1417
}
1399
1418
1400
1419
fn rebuild_preview2d_frame(&mut self) {
@@ -1411,10 +1430,31 @@ impl EditorApp {
1411
1430
self.preview2d_image = None;
1412
1431
return;
1413
1432
}
1414
match open_selection(&self.preview2d_catalog, sex, &equipment) {
1415
Ok((body, gear)) => match compose_preview_frame(ComposeRequest {
1416
body: &body,
1417
equipment: &gear,
1433
let paths = match selection_paths(&self.preview2d_catalog, sex, &equipment) {
1434
Ok(paths) => paths,
1435
Err(err) => {
1436
self.preview2d.diagnostic = Some(err.to_string());
1437
self.preview2d_image = None;
1438
return;
1439
}
1440
};
1441
if self.preview2d_loaded.as_ref().map(|l| &l.paths) != Some(&paths) {
1442
self.preview2d_loaded = None;
1443
match open_selection(&self.preview2d_catalog, sex, &equipment) {
1444
Ok((body, gear)) => {
1445
self.preview2d_loaded = Some(PreviewSelection { paths, body, gear })
1446
}
1447
Err(err) => {
1448
self.preview2d.diagnostic = Some(err.to_string());
1449
self.preview2d_image = None;
1450
return;
1451
}
1452
}
1453
}
1454
match self.preview2d_loaded.as_ref().ok_or("geen selectie") {
1455
Ok(PreviewSelection { body, gear, .. }) => match compose_preview_frame(ComposeRequest {
1456
body,
1457
equipment: gear,
1418
1458
clip_token: &clip,
1419
1459
frame,
1420
1460
pitch,
@@ -1503,9 +1543,25 @@ impl EditorApp {
1503
1543
refresh_catalog = true;
1504
1544
}
1505
1545
ui.label(format!(
1506
"{} voltooide exportset(s)",
1546
"{} voltooide exportset(s) (.ppda + atlassen)",
1507
1547
self.preview2d_catalog.len()
1508
1548
));
1549
if !self.preview2d_skipped.is_empty() {
1550
let reasons = self
1551
.preview2d_skipped
1552
.iter()
1553
.map(|(path, why)| {
1554
let name = path.file_name().unwrap_or_default().to_string_lossy();
1555
format!("{name}: {why}")
1556
})
1557
.collect::<Vec<_>>()
1558
.join("\n");
1559
ui.weak(format!(
1560
"{} run(s) zonder geldige .ppda overgeslagen",
1561
self.preview2d_skipped.len()
1562
))
1563
.on_hover_text(reasons);
1564
}
1509
1565
ui.separator();
1510
1566
ui.label("Equipment (zelfde project/rig/contract)");
1511
1567
let allowed =
@@ -2024,7 +2080,7 @@ mod tests {
2024
2080
assert_eq!(job.body_model.path, relative);
2025
2081
assert!(job.character.is_static());
2026
2082
let contract = crate::production::contract(job).unwrap();
2027
assert_eq!(contract.plan.images, 36);
2083
assert_eq!(contract.plan.images, 60);
2028
2084
assert_eq!(contract.plan.clips[0].token, "clip000-bind");
2029
2085
app.open_equipment_configuration(index, Sex::Male);
2030
2086
let job = app.build_job().expect("equipment job object");
@@ -10,9 +10,9 @@ use crate::preview::{10
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,10
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,11
};11
};12
use crate::preview2d::{12
use crate::preview2d::{13
compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,13
compatible_equipment_sets, compose_preview_frame, discover_exports, open_selection,14
sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,14
selection_paths, sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis,15
ViewMode,15
Preview2dState, ViewMode,16
};16
};17
use crate::preview_gl::Viewport;17
use crate::preview_gl::Viewport;18
use crate::project::open_project;18
use crate::project::open_project;@@ -42,6 +42,10 @@ pub struct EditorApp {42
equipment_part: usize,42
equipment_part: usize,43
preview2d: Preview2dState,43
preview2d: Preview2dState,44
preview2d_catalog: Vec<CatalogEntry>,44
preview2d_catalog: Vec<CatalogEntry>,45
/// Runs under `renders/` without a valid `.ppda` (reason shown on hover).46
preview2d_skipped: Vec<(std::path::PathBuf, String)>,47
/// Decoded `.ppda` atlases of the current selection, keyed by run paths.48
preview2d_loaded: Option<PreviewSelection>,45
preview2d_texture: Option<egui::TextureHandle>,49
preview2d_texture: Option<egui::TextureHandle>,46
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,50
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,47
preview2d_image: Option<ImageBuf>,51
preview2d_image: Option<ImageBuf>,@@ -51,6 +55,13 @@ pub struct EditorApp {51
resume_scan_error: Option<String>,55
resume_scan_error: Option<String>,52
}56
}53
57
58
/// Decoded atlases for the 2D preview selection (body first).59
struct PreviewSelection {60
paths: Vec<std::path::PathBuf>,61
body: crate::atlas::LoadedAtlas,62
gear: Vec<crate::atlas::LoadedAtlas>,63
}64
54
impl EditorApp {65
impl EditorApp {55
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {66
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {56
Self {67
Self {@@ -70,12 +81,14 @@ impl EditorApp {70
applied_shell: None,81
applied_shell: None,71
width_text: "128".to_string(),82
width_text: "128".to_string(),72
height_text: "128".to_string(),83
height_text: "128".to_string(),73
fps_text: "12".to_string(),84
fps_text: crate::project::DEFAULT_FPS.to_string(),74
allow_close: false,85
allow_close: false,75
dialog_pending: false,86
dialog_pending: false,76
equipment_part: 0,87
equipment_part: 0,77
preview2d: Preview2dState::default(),88
preview2d: Preview2dState::default(),78
preview2d_catalog: Vec::new(),89
preview2d_catalog: Vec::new(),90
preview2d_skipped: Vec::new(),91
preview2d_loaded: None,79
preview2d_texture: None,92
preview2d_texture: None,80
preview2d_texture_key: None,93
preview2d_texture_key: None,81
preview2d_image: None,94
preview2d_image: None,@@ -1005,7 +1018,7 @@ impl EditorApp {1005
ui.colored_label(1018
ui.colored_label(1006
egui::Color32::from_rgb(230, 190, 90),1019
egui::Color32::from_rgb(230, 190, 90),1007
format!(1020
format!(1008
"{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 36 hoeken."1021
"{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."1009
),1022
),1010
);1023
);1011
}1024
}@@ -1386,15 +1399,21 @@ impl EditorApp {1386
&self.preview2d1399
&self.preview2d1387
}1400
}1388
1401
1402
/// Mutable 2D-preview state (view/playback), e.g. for GUI smokes.1403
pub fn preview2d_state_mut(&mut self) -> &mut Preview2dState {1404
&mut self.preview2d1405
}1406
1389
pub fn preview2d_image(&self) -> Option<&ImageBuf> {1407
pub fn preview2d_image(&self) -> Option<&ImageBuf> {1390
self.preview2d_image.as_ref()1408
self.preview2d_image.as_ref()1391
}1409
}1392
1410
1393
fn refresh_preview2d_catalog(&mut self) {1411
fn refresh_preview2d_catalog(&mut self) {1394
self.preview2d_catalog = match self.session.project_root() {1412
(self.preview2d_catalog, self.preview2d_skipped) = match self.session.project_root() {1395
Some(root) => discover_completed_runs(&root),1413
Some(root) => discover_exports(&root),1396
None => Vec::new(),1414
None => (Vec::new(), Vec::new()),1397
};1415
};1416
self.preview2d_loaded = None;1398
}1417
}1399
1418
1400
fn rebuild_preview2d_frame(&mut self) {1419
fn rebuild_preview2d_frame(&mut self) {@@ -1411,10 +1430,31 @@ impl EditorApp {1411
self.preview2d_image = None;1430
self.preview2d_image = None;1412
return;1431
return;1413
}1432
}1414
match open_selection(&self.preview2d_catalog, sex, &equipment) {1433
let paths = match selection_paths(&self.preview2d_catalog, sex, &equipment) {1415
Ok((body, gear)) => match compose_preview_frame(ComposeRequest {1434
Ok(paths) => paths,1416
body: &body,1435
Err(err) => {1417
equipment: &gear,1436
self.preview2d.diagnostic = Some(err.to_string());1437
self.preview2d_image = None;1438
return;1439
}1440
};1441
if self.preview2d_loaded.as_ref().map(|l| &l.paths) != Some(&paths) {1442
self.preview2d_loaded = None;1443
match open_selection(&self.preview2d_catalog, sex, &equipment) {1444
Ok((body, gear)) => {1445
self.preview2d_loaded = Some(PreviewSelection { paths, body, gear })1446
}1447
Err(err) => {1448
self.preview2d.diagnostic = Some(err.to_string());1449
self.preview2d_image = None;1450
return;1451
}1452
}1453
}1454
match self.preview2d_loaded.as_ref().ok_or("geen selectie") {1455
Ok(PreviewSelection { body, gear, .. }) => match compose_preview_frame(ComposeRequest {1456
body,1457
equipment: gear,1418
clip_token: &clip,1458
clip_token: &clip,1419
frame,1459
frame,1420
pitch,1460
pitch,@@ -1503,9 +1543,25 @@ impl EditorApp {1503
refresh_catalog = true;1543
refresh_catalog = true;1504
}1544
}1505
ui.label(format!(1545
ui.label(format!(1506
"{} voltooide exportset(s)",1546
"{} voltooide exportset(s) (.ppda + atlassen)",1507
self.preview2d_catalog.len()1547
self.preview2d_catalog.len()1508
));1548
));1549
if !self.preview2d_skipped.is_empty() {1550
let reasons = self1551
.preview2d_skipped1552
.iter()1553
.map(|(path, why)| {1554
let name = path.file_name().unwrap_or_default().to_string_lossy();1555
format!("{name}: {why}")1556
})1557
.collect::<Vec<_>>()1558
.join("\n");1559
ui.weak(format!(1560
"{} run(s) zonder geldige .ppda overgeslagen",1561
self.preview2d_skipped.len()1562
))1563
.on_hover_text(reasons);1564
}1509
ui.separator();1565
ui.separator();1510
ui.label("Equipment (zelfde project/rig/contract)");1566
ui.label("Equipment (zelfde project/rig/contract)");1511
let allowed =1567
let allowed =@@ -2024,7 +2080,7 @@ mod tests {2024
assert_eq!(job.body_model.path, relative);2080
assert_eq!(job.body_model.path, relative);2025
assert!(job.character.is_static());2081
assert!(job.character.is_static());2026
let contract = crate::production::contract(job).unwrap();2082
let contract = crate::production::contract(job).unwrap();2027
assert_eq!(contract.plan.images, 36);2083
assert_eq!(contract.plan.images, 60);2028
assert_eq!(contract.plan.clips[0].token, "clip000-bind");2084
assert_eq!(contract.plan.clips[0].token, "clip000-bind");2029
app.open_equipment_configuration(index, Sex::Male);2085
app.open_equipment_configuration(index, Sex::Male);2030
let job = app.build_job().expect("equipment job object");2086
let job = app.build_job().expect("equipment job object");@@ -0,0 +1,223 @@
1
//! Synthetic completed export runs for tests, examples and GUI smokes.
2
//!
3
//! Writes real loose target PNGs (uniform colour per cell), schema-4
4
//! `render.json`, the per-map atlases + `.ppda` through the production
5
//! [`crate::atlas::write_atlases`] path, and `complete.json`. This replaces
6
//! hand-written partial manifests; it is not used by the renderer itself.
7
use crate::{
8
atlas, digest,
9
export_set::{complete_identity_json, OUTPUT_META_VERSION},
10
preview::PRODUCTION_PITCHES,
11
production::{self, ClipPlan, Plan, FAR, NEAR, PASSES},
12
project::CameraFraming,
13
render_job::ImageBuf,
14
};
15
use std::{collections::BTreeMap, path::Path, sync::atomic::AtomicBool};
16
17
/// Uniform per-cell map values.
18
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19
pub struct Pixel {
20
pub diffuse: [u8; 4],
21
pub normal: [u8; 4],
22
pub specular: [u8; 4],
23
pub depth: u16,
24
}
25
26
#[derive(Debug, Clone)]
27
pub struct SyntheticRun {
28
pub project_id: String,
29
pub sex: String,
30
pub set: String,
31
pub equipment_sha256: Option<String>,
32
pub body_bytes: Vec<u8>,
33
pub target: [u32; 2],
34
pub fps: u32,
35
pub framing: CameraFraming,
36
/// (source name, frames, duration seconds)
37
pub clips: Vec<(String, u32, f32)>,
38
}
39
40
impl SyntheticRun {
41
pub fn body(project_id: &str, sex: &str, body_bytes: &[u8]) -> Self {
42
Self {
43
project_id: project_id.into(),
44
sex: sex.into(),
45
set: "body".into(),
46
equipment_sha256: None,
47
body_bytes: body_bytes.to_vec(),
48
target: [1, 1],
49
fps: crate::project::DEFAULT_FPS,
50
framing: CameraFraming {
51
height_milli: 1800,
52
center_y_milli: 900,
53
pivot_y_milli: Some(1500),
54
},
55
clips: vec![("bind".into(), 1, 0.0)],
56
}
57
}
58
59
pub fn plan(&self) -> Plan {
60
let clips: Vec<ClipPlan> = self
61
.clips
62
.iter()
63
.enumerate()
64
.map(|(i, (name, frames, _))| ClipPlan {
65
source_name: name.clone(),
66
token: production::clip_token(i, name),
67
frames: *frames,
68
})
69
.collect();
70
let frames: u32 = clips.iter().map(|c| c.frames).sum();
71
let images = frames * crate::preview::PRODUCTION_VIEWS;
72
Plan {
73
clips,
74
images,
75
passes: images * PASSES.len() as u32,
76
files: images * 4,
77
}
78
}
79
80
/// `render.json` with the same keys production writes.
81
pub fn render_doc(&self) -> serde_json::Value {
82
let plan = self.plan();
83
let clips: Vec<_> = plan
84
.clips
85
.iter()
86
.zip(&self.clips)
87
.map(|(c, (_, _, duration))| {
88
serde_json::json!({
89
"name": c.source_name,
90
"token": c.token,
91
"frames": c.frames,
92
"duration": duration,
93
})
94
})
95
.collect();
96
let equipment = self
97
.equipment_sha256
98
.as_ref()
99
.map(|sha| serde_json::json!({"name": self.set, "sha256": sha}));
100
serde_json::json!({
101
"format_version": OUTPUT_META_VERSION,
102
"status": "incomplete",
103
"project": {"id": self.project_id, "name": "Synthetic"},
104
"sex": self.sex,
105
"set": self.set,
106
"body_model": {"path": "assets/body/synthetic.glb", "sha256": digest::sha256_hex(&self.body_bytes)},
107
"equipment_preset": equipment,
108
"target": self.target,
109
"fps": self.fps,
110
"highres_scale": production::SCALE,
111
"framing": self.framing,
112
"pivot_source": {"kind": "manual"},
113
"near": NEAR,
114
"far": FAR,
115
"normal_space": "view (+Y up), PNG rows top-down",
116
"camera_distance": 10,
117
"yaw_zero": "camera on -Z",
118
"pitches": PRODUCTION_PITCHES,
119
"yaws": production::production_yaws(),
120
"yaw_step": 30,
121
"sampling": "synthetic fixture",
122
"alpha": "straight",
123
"diffuse": "unlit",
124
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",
125
"specular": "scalar",
126
"pass_order": PASSES,
127
"saved_maps": ["diffuse", "depth", "normal", "specular"],
128
"clips": clips,
129
"images": plan.images,
130
"passes": plan.passes,
131
"png_files": plan.files,
132
})
133
}
134
135
/// Write the complete run into `root` (created if needed).
136
pub fn write(
137
&self,
138
root: &Path,
139
pixel: impl Fn(usize, u32, i32, u32) -> Pixel,
140
) -> Result<(), String> {
141
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;
142
let plan = self.plan();
143
let render = self.render_doc();
144
let [w, h] = self.target;
145
let n = w as usize * h as usize;
146
let mut sums = BTreeMap::new();
147
for s in production::samples(&plan) {
148
let px = pixel(s.clip, s.frame, s.pitch, s.yaw);
149
for map in production::SAVED_ORDER {
150
let rel = production::output_path(
151
&self.sex,
152
&self.set,
153
&plan.clips[s.clip].token,
154
s.frame,
155
s.pitch,
156
s.yaw,
157
map,
158
)?;
159
let digest = if map == "depth" {
160
let pair = if px.diffuse[3] == 0 {
161
[0, 0]
162
} else {
163
[px.depth, 65535]
164
};
165
let samples: Vec<u16> = pair.repeat(n);
166
production::write_depth(root, &rel, w, h, &samples)?
167
} else {
168
let rgba = match map {
169
"diffuse" => px.diffuse,
170
"normal" => px.normal,
171
_ => px.specular,
172
};
173
production::write_rgba(
174
root,
175
&rel,
176
&ImageBuf {
177
width: w,
178
height: h,
179
rgba: rgba.repeat(n),
180
},
181
)?
182
};
183
sums.insert(rel.to_string_lossy().replace('\\', "/"), digest);
184
}
185
}
186
write_json(root, "render.json", &render)?;
187
let cancel = AtomicBool::new(false);
188
let atlas = atlas::write_atlases(
189
&atlas::AtlasJob {
190
root,
191
render_doc: &render,
192
plan: &plan,
193
sex: &self.sex,
194
set: &self.set,
195
cell_width: w,
196
cell_height: h,
197
png_sha256: &sums,
198
attempt: 0,
199
cancel: &cancel,
200
},
201
&mut |_| {},
202
)?;
203
let identity = complete_identity_json(&render)?;
204
write_json(
205
root,
206
"complete.json",
207
&serde_json::json!({
208
"format_version": OUTPUT_META_VERSION,
209
"status": "complete",
210
"identity": identity,
211
"passes": plan.passes,
212
"png_files": plan.files,
213
"png_sha256": sums,
214
"atlas": atlas.to_json(),
215
}),
216
)
217
}
218
}
219
220
fn write_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {
221
let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
222
production::publish_bytes(root, Path::new(name), &bytes)
223
}
@@ -0,0 +1,223 @@1
//! Synthetic completed export runs for tests, examples and GUI smokes.2
//!3
//! Writes real loose target PNGs (uniform colour per cell), schema-44
//! `render.json`, the per-map atlases + `.ppda` through the production5
//! [`crate::atlas::write_atlases`] path, and `complete.json`. This replaces6
//! hand-written partial manifests; it is not used by the renderer itself.7
use crate::{8
atlas, digest,9
export_set::{complete_identity_json, OUTPUT_META_VERSION},10
preview::PRODUCTION_PITCHES,11
production::{self, ClipPlan, Plan, FAR, NEAR, PASSES},12
project::CameraFraming,13
render_job::ImageBuf,14
};15
use std::{collections::BTreeMap, path::Path, sync::atomic::AtomicBool};16
17
/// Uniform per-cell map values.18
#[derive(Debug, Clone, Copy, PartialEq, Eq)]19
pub struct Pixel {20
pub diffuse: [u8; 4],21
pub normal: [u8; 4],22
pub specular: [u8; 4],23
pub depth: u16,24
}25
26
#[derive(Debug, Clone)]27
pub struct SyntheticRun {28
pub project_id: String,29
pub sex: String,30
pub set: String,31
pub equipment_sha256: Option<String>,32
pub body_bytes: Vec<u8>,33
pub target: [u32; 2],34
pub fps: u32,35
pub framing: CameraFraming,36
/// (source name, frames, duration seconds)37
pub clips: Vec<(String, u32, f32)>,38
}39
40
impl SyntheticRun {41
pub fn body(project_id: &str, sex: &str, body_bytes: &[u8]) -> Self {42
Self {43
project_id: project_id.into(),44
sex: sex.into(),45
set: "body".into(),46
equipment_sha256: None,47
body_bytes: body_bytes.to_vec(),48
target: [1, 1],49
fps: crate::project::DEFAULT_FPS,50
framing: CameraFraming {51
height_milli: 1800,52
center_y_milli: 900,53
pivot_y_milli: Some(1500),54
},55
clips: vec![("bind".into(), 1, 0.0)],56
}57
}58
59
pub fn plan(&self) -> Plan {60
let clips: Vec<ClipPlan> = self61
.clips62
.iter()63
.enumerate()64
.map(|(i, (name, frames, _))| ClipPlan {65
source_name: name.clone(),66
token: production::clip_token(i, name),67
frames: *frames,68
})69
.collect();70
let frames: u32 = clips.iter().map(|c| c.frames).sum();71
let images = frames * crate::preview::PRODUCTION_VIEWS;72
Plan {73
clips,74
images,75
passes: images * PASSES.len() as u32,76
files: images * 4,77
}78
}79
80
/// `render.json` with the same keys production writes.81
pub fn render_doc(&self) -> serde_json::Value {82
let plan = self.plan();83
let clips: Vec<_> = plan84
.clips85
.iter()86
.zip(&self.clips)87
.map(|(c, (_, _, duration))| {88
serde_json::json!({89
"name": c.source_name,90
"token": c.token,91
"frames": c.frames,92
"duration": duration,93
})94
})95
.collect();96
let equipment = self97
.equipment_sha25698
.as_ref()99
.map(|sha| serde_json::json!({"name": self.set, "sha256": sha}));100
serde_json::json!({101
"format_version": OUTPUT_META_VERSION,102
"status": "incomplete",103
"project": {"id": self.project_id, "name": "Synthetic"},104
"sex": self.sex,105
"set": self.set,106
"body_model": {"path": "assets/body/synthetic.glb", "sha256": digest::sha256_hex(&self.body_bytes)},107
"equipment_preset": equipment,108
"target": self.target,109
"fps": self.fps,110
"highres_scale": production::SCALE,111
"framing": self.framing,112
"pivot_source": {"kind": "manual"},113
"near": NEAR,114
"far": FAR,115
"normal_space": "view (+Y up), PNG rows top-down",116
"camera_distance": 10,117
"yaw_zero": "camera on -Z",118
"pitches": PRODUCTION_PITCHES,119
"yaws": production::production_yaws(),120
"yaw_step": 30,121
"sampling": "synthetic fixture",122
"alpha": "straight",123
"diffuse": "unlit",124
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",125
"specular": "scalar",126
"pass_order": PASSES,127
"saved_maps": ["diffuse", "depth", "normal", "specular"],128
"clips": clips,129
"images": plan.images,130
"passes": plan.passes,131
"png_files": plan.files,132
})133
}134
135
/// Write the complete run into `root` (created if needed).136
pub fn write(137
&self,138
root: &Path,139
pixel: impl Fn(usize, u32, i32, u32) -> Pixel,140
) -> Result<(), String> {141
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;142
let plan = self.plan();143
let render = self.render_doc();144
let [w, h] = self.target;145
let n = w as usize * h as usize;146
let mut sums = BTreeMap::new();147
for s in production::samples(&plan) {148
let px = pixel(s.clip, s.frame, s.pitch, s.yaw);149
for map in production::SAVED_ORDER {150
let rel = production::output_path(151
&self.sex,152
&self.set,153
&plan.clips[s.clip].token,154
s.frame,155
s.pitch,156
s.yaw,157
map,158
)?;159
let digest = if map == "depth" {160
let pair = if px.diffuse[3] == 0 {161
[0, 0]162
} else {163
[px.depth, 65535]164
};165
let samples: Vec<u16> = pair.repeat(n);166
production::write_depth(root, &rel, w, h, &samples)?167
} else {168
let rgba = match map {169
"diffuse" => px.diffuse,170
"normal" => px.normal,171
_ => px.specular,172
};173
production::write_rgba(174
root,175
&rel,176
&ImageBuf {177
width: w,178
height: h,179
rgba: rgba.repeat(n),180
},181
)?182
};183
sums.insert(rel.to_string_lossy().replace('\\', "/"), digest);184
}185
}186
write_json(root, "render.json", &render)?;187
let cancel = AtomicBool::new(false);188
let atlas = atlas::write_atlases(189
&atlas::AtlasJob {190
root,191
render_doc: &render,192
plan: &plan,193
sex: &self.sex,194
set: &self.set,195
cell_width: w,196
cell_height: h,197
png_sha256: &sums,198
attempt: 0,199
cancel: &cancel,200
},201
&mut |_| {},202
)?;203
let identity = complete_identity_json(&render)?;204
write_json(205
root,206
"complete.json",207
&serde_json::json!({208
"format_version": OUTPUT_META_VERSION,209
"status": "complete",210
"identity": identity,211
"passes": plan.passes,212
"png_files": plan.files,213
"png_sha256": sums,214
"atlas": atlas.to_json(),215
}),216
)217
}218
}219
220
fn write_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {221
let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;222
production::publish_bytes(root, Path::new(name), &bytes)223
}@@ -18,7 +18,7 @@ use std::{
18
18
};
19
19
20
20
/// Output metadata schema written by production and required by this reader.
21
pub const OUTPUT_META_VERSION: u32 = 3;
21
pub const OUTPUT_META_VERSION: u32 = 4;
22
22
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
23
23
/// Hard ceiling for a single published PNG on disk (encoded).
24
24
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
@@ -159,6 +159,8 @@ pub struct RenderManifest {
159
159
pub near: f32,
160
160
pub far: f32,
161
161
pub camera_distance: f32,
162
pub pitches: Vec<i32>,
163
pub yaws: Vec<u32>,
162
164
pub pass_order: Vec<String>,
163
165
pub saved_maps: Vec<String>,
164
166
pub clips: Vec<ClipRecord>,
@@ -200,6 +202,23 @@ pub struct CompleteManifest {
200
202
pub passes: u32,
201
203
pub png_files: u32,
202
204
pub png_sha256: BTreeMap<String, String>,
205
/// Per-map atlases + `.ppda` written before this marker (t955u).
206
pub atlas: AtlasRef,
207
}
208
209
#[derive(Debug, Clone, Deserialize)]
210
#[serde(deny_unknown_fields)]
211
pub struct AtlasRef {
212
pub ppda: String,
213
pub ppda_sha256: String,
214
pub maps: BTreeMap<String, AtlasMapRef>,
215
}
216
217
#[derive(Debug, Clone, Deserialize)]
218
#[serde(deny_unknown_fields)]
219
pub struct AtlasMapRef {
220
pub file: String,
221
pub sha256: String,
203
222
}
204
223
205
224
#[derive(Debug, Clone)]
@@ -402,6 +421,16 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
402
421
"camera_distance wijkt af van contract".into(),
403
422
));
404
423
}
424
if m.pitches != crate::preview::PRODUCTION_PITCHES {
425
return Err(ExportError::Identity(
426
"pitches wijken af van −60,−30,0,+30,+60".into(),
427
));
428
}
429
if m.yaws != crate::production::production_yaws() {
430
return Err(ExportError::Identity(
431
"yaws wijken af van 0…330 stap 30".into(),
432
));
433
}
405
434
if m.pass_order != PASSES {
406
435
return Err(ExportError::Identity(
407
436
"pass_order wijkt af van contract".into(),
@@ -550,6 +579,24 @@ fn identities_match(
550
579
"near/far mismatch",
551
580
)?;
552
581
check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;
582
let atlas = &complete.atlas;
583
check(
584
atlas.ppda == crate::atlas::ppda_name(&render.sex, &render.set)
585
&& is_sha256_hex(&atlas.ppda_sha256),
586
"atlas.ppda naam/sha256 ongeldig",
587
)?;
588
check(atlas.maps.len() == 4, "atlas.maps moet vier maps bevatten")?;
589
for kind in crate::atlas::ATLAS_MAPS {
590
let entry = atlas
591
.maps
592
.get(kind.token())
593
.ok_or_else(|| ExportError::Identity(format!("atlas mist {}", kind.token())))?;
594
check(
595
entry.file == crate::atlas::atlas_png_name(&render.sex, &render.set, kind)
596
&& is_sha256_hex(&entry.sha256),
597
"atlas-bestandsnaam/sha256 ongeldig",
598
)?;
599
}
553
600
check(
554
601
id.clips.len() == render.clips.len(),
555
602
"clips length mismatch",
@@ -571,13 +618,34 @@ fn identities_match(
571
618
Ok(())
572
619
}
573
620
574
fn decode_map(
621
pub(crate) fn decode_map(
622
bytes: &[u8],
623
kind: MapKind,
624
expect_w: u32,
625
expect_h: u32,
626
) -> Result<MapImage, ExportError> {
627
decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET)
628
}
629
630
/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under
631
/// the published pixel contract. Atlases use the same rules with a larger cap.
632
pub(crate) fn decode_map_sized(
575
633
bytes: &[u8],
576
634
kind: MapKind,
577
635
expect_w: u32,
578
636
expect_h: u32,
637
max_dim: u32,
579
638
) -> Result<MapImage, ExportError> {
580
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
639
if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
640
return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
641
}
642
let decoded = expect_w as usize * expect_h as usize * 4;
643
let mut decoder = png::Decoder::new_with_limits(
644
std::io::Cursor::new(bytes),
645
png::Limits {
646
bytes: decoded.saturating_mul(2).max(64 * 1024 * 1024),
647
},
648
);
581
649
decoder.set_transformations(png::Transformations::IDENTITY);
582
650
let mut reader = decoder
583
651
.read_info()
@@ -589,8 +657,8 @@ fn decode_map(
589
657
info.width, info.height
590
658
)));
591
659
}
592
if info.width > MAX_TARGET || info.height > MAX_TARGET {
593
return Err(ExportError::Resource("PNG groter dan targetlimiet".into()));
660
if info.width > max_dim || info.height > max_dim {
661
return Err(ExportError::Resource("PNG groter dan limiet".into()));
594
662
}
595
663
match kind {
596
664
MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {
@@ -724,7 +792,7 @@ fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {
724
792
read_bounded_file(&path, MAX_MANIFEST_BYTES)
725
793
}
726
794
727
fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
795
pub(crate) fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
728
796
let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
729
797
if meta.len() > max {
730
798
return Err(ExportError::Resource(format!(
@@ -753,7 +821,7 @@ fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {
753
821
Ok(path)
754
822
}
755
823
756
fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
824
pub(crate) fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
757
825
let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;
758
826
if meta.file_type().is_symlink() {
759
827
return Err(ExportError::Traversal(format!(
@@ -848,9 +916,19 @@ mod tests {
848
916
let _ = fs::remove_file(temp);
849
917
}
850
918
919
fn atlas_ref() -> serde_json::Value {
920
let sha = digest::sha256_hex(b"atlas");
921
let map = |m: &str| serde_json::json!({"file": format!("male-body-{m}-atlas.png"), "sha256": sha});
922
serde_json::json!({
923
"ppda": "male-body-atlas.ppda",
924
"ppda_sha256": sha,
925
"maps": {"diffuse": map("diffuse"), "normal": map("normal"), "depth": map("depth"), "specular": map("specular")}
926
})
927
}
928
851
929
fn base_render(png_files: u32) -> serde_json::Value {
852
930
serde_json::json!({
853
"format_version": 3,
931
"format_version": OUTPUT_META_VERSION,
854
932
"status": "incomplete",
855
933
"project": {"id": "demo", "name": "Demo"},
856
934
"sex": "male",
@@ -867,7 +945,8 @@ mod tests {
867
945
"camera_distance": 10.0,
868
946
"yaw_zero": "camera on -Z",
869
947
"normal_space": "view (+Y up), PNG rows top-down",
870
"pitches": [-30, 0, 30],
948
"pitches": crate::preview::PRODUCTION_PITCHES,
949
"yaws": crate::production::production_yaws(),
871
950
"yaw_step": 30,
872
951
"sampling": "test",
873
952
"alpha": "straight",
@@ -887,19 +966,19 @@ mod tests {
887
966
let mut map = BTreeMap::new();
888
967
let rels = [
889
968
(
890
"male/body/clip000-bind/f000000/p-30_y000_diffuse.png",
969
"male/body/clip000-bind/f000000/p-60_y000_diffuse.png",
891
970
"diffuse",
892
971
),
893
972
(
894
"male/body/clip000-bind/f000000/p-30_y000_depth.png",
973
"male/body/clip000-bind/f000000/p-60_y000_depth.png",
895
974
"depth",
896
975
),
897
976
(
898
"male/body/clip000-bind/f000000/p-30_y000_normal.png",
977
"male/body/clip000-bind/f000000/p-60_y000_normal.png",
899
978
"normal",
900
979
),
901
980
(
902
"male/body/clip000-bind/f000000/p-30_y000_specular.png",
981
"male/body/clip000-bind/f000000/p-60_y000_specular.png",
903
982
"specular",
904
983
),
905
984
];
@@ -955,17 +1034,18 @@ mod tests {
955
1034
&tmp.0,
956
1035
"complete.json",
957
1036
&serde_json::json!({
958
"format_version": 3,
1037
"format_version": OUTPUT_META_VERSION,
959
1038
"status": "complete",
960
1039
"identity": identity,
961
1040
"passes": 6,
962
1041
"png_files": 4,
963
"png_sha256": checksums
1042
"png_sha256": checksums,
1043
"atlas": atlas_ref()
964
1044
}),
965
1045
);
966
1046
let run = ExportRun::open(&tmp.0).unwrap();
967
1047
let diffuse = run
968
.load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse)
1048
.load_map("clip000-bind", 0, -60, 0, MapKind::Diffuse)
969
1049
.unwrap();
970
1050
match diffuse {
971
1051
MapImage::Rgba8 {
@@ -979,7 +1059,7 @@ mod tests {
979
1059
_ => panic!("expected rgba"),
980
1060
}
981
1061
let depth = run
982
.load_map("clip000-bind", 0, -30, 0, MapKind::Depth)
1062
.load_map("clip000-bind", 0, -60, 0, MapKind::Depth)
983
1063
.unwrap();
984
1064
match depth {
985
1065
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),
@@ -1003,12 +1083,13 @@ mod tests {
1003
1083
&tmp.0,
1004
1084
"complete.json",
1005
1085
&serde_json::json!({
1006
"format_version": 3,
1086
"format_version": OUTPUT_META_VERSION,
1007
1087
"status": "complete",
1008
1088
"identity": identity,
1009
1089
"passes": 6,
1010
1090
"png_files": 4,
1011
"png_sha256": checksums
1091
"png_sha256": checksums,
1092
"atlas": atlas_ref()
1012
1093
}),
1013
1094
);
1014
1095
assert!(matches!(
@@ -1029,12 +1110,13 @@ mod tests {
1029
1110
&tmp.0,
1030
1111
"complete.json",
1031
1112
&serde_json::json!({
1032
"format_version": 3,
1113
"format_version": OUTPUT_META_VERSION,
1033
1114
"status": "complete",
1034
1115
"identity": identity,
1035
1116
"passes": 6,
1036
1117
"png_files": 5,
1037
"png_sha256": checksums
1118
"png_sha256": checksums,
1119
"atlas": atlas_ref()
1038
1120
}),
1039
1121
);
1040
1122
assert!(matches!(
@@ -1047,7 +1129,7 @@ mod tests {
1047
1129
let tmp = Temp::new();
1048
1130
let outside = Temp::new();
1049
1131
let checksums = publish_four(&tmp.0);
1050
let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png";
1132
let evil = "male/body/clip000-bind/f000000/p-60_y000_diffuse.png";
1051
1133
let _ = fs::remove_file(tmp.0.join(evil));
1052
1134
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();
1053
1135
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();
@@ -1060,12 +1142,13 @@ mod tests {
1060
1142
&tmp.0,
1061
1143
"complete.json",
1062
1144
&serde_json::json!({
1063
"format_version": 3,
1145
"format_version": OUTPUT_META_VERSION,
1064
1146
"status": "complete",
1065
1147
"identity": identity,
1066
1148
"passes": 6,
1067
1149
"png_files": 4,
1068
"png_sha256": checksums
1150
"png_sha256": checksums,
1151
"atlas": atlas_ref()
1069
1152
}),
1070
1153
);
1071
1154
assert!(matches!(
@@ -1086,7 +1169,7 @@ mod tests {
1086
1169
&tmp.0,
1087
1170
"complete.json",
1088
1171
&serde_json::json!({
1089
"format_version": 3,
1172
"format_version": OUTPUT_META_VERSION,
1090
1173
"status": "complete",
1091
1174
"identity": {
1092
1175
"project_id": "demo",
@@ -1106,7 +1189,8 @@ mod tests {
1106
1189
},
1107
1190
"passes": 6,
1108
1191
"png_files": 4,
1109
"png_sha256": checksums
1192
"png_sha256": checksums,
1193
"atlas": atlas_ref()
1110
1194
}),
1111
1195
);
1112
1196
assert!(matches!(
@@ -18,7 +18,7 @@ use std::{18
};18
};19
19
20
/// Output metadata schema written by production and required by this reader.20
/// Output metadata schema written by production and required by this reader.21
pub const OUTPUT_META_VERSION: u32 = 3;21
pub const OUTPUT_META_VERSION: u32 = 4;22
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;22
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;23
/// Hard ceiling for a single published PNG on disk (encoded).23
/// Hard ceiling for a single published PNG on disk (encoded).24
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;24
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;@@ -159,6 +159,8 @@ pub struct RenderManifest {159
pub near: f32,159
pub near: f32,160
pub far: f32,160
pub far: f32,161
pub camera_distance: f32,161
pub camera_distance: f32,162
pub pitches: Vec<i32>,163
pub yaws: Vec<u32>,162
pub pass_order: Vec<String>,164
pub pass_order: Vec<String>,163
pub saved_maps: Vec<String>,165
pub saved_maps: Vec<String>,164
pub clips: Vec<ClipRecord>,166
pub clips: Vec<ClipRecord>,@@ -200,6 +202,23 @@ pub struct CompleteManifest {200
pub passes: u32,202
pub passes: u32,201
pub png_files: u32,203
pub png_files: u32,202
pub png_sha256: BTreeMap<String, String>,204
pub png_sha256: BTreeMap<String, String>,205
/// Per-map atlases + `.ppda` written before this marker (t955u).206
pub atlas: AtlasRef,207
}208
209
#[derive(Debug, Clone, Deserialize)]210
#[serde(deny_unknown_fields)]211
pub struct AtlasRef {212
pub ppda: String,213
pub ppda_sha256: String,214
pub maps: BTreeMap<String, AtlasMapRef>,215
}216
217
#[derive(Debug, Clone, Deserialize)]218
#[serde(deny_unknown_fields)]219
pub struct AtlasMapRef {220
pub file: String,221
pub sha256: String,203
}222
}204
223
205
#[derive(Debug, Clone)]224
#[derive(Debug, Clone)]@@ -402,6 +421,16 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {402
"camera_distance wijkt af van contract".into(),421
"camera_distance wijkt af van contract".into(),403
));422
));404
}423
}424
if m.pitches != crate::preview::PRODUCTION_PITCHES {425
return Err(ExportError::Identity(426
"pitches wijken af van −60,−30,0,+30,+60".into(),427
));428
}429
if m.yaws != crate::production::production_yaws() {430
return Err(ExportError::Identity(431
"yaws wijken af van 0…330 stap 30".into(),432
));433
}405
if m.pass_order != PASSES {434
if m.pass_order != PASSES {406
return Err(ExportError::Identity(435
return Err(ExportError::Identity(407
"pass_order wijkt af van contract".into(),436
"pass_order wijkt af van contract".into(),@@ -550,6 +579,24 @@ fn identities_match(550
"near/far mismatch",579
"near/far mismatch",551
)?;580
)?;552
check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;581
check(id.saved_maps == render.saved_maps, "saved_maps mismatch")?;582
let atlas = &complete.atlas;583
check(584
atlas.ppda == crate::atlas::ppda_name(&render.sex, &render.set)585
&& is_sha256_hex(&atlas.ppda_sha256),586
"atlas.ppda naam/sha256 ongeldig",587
)?;588
check(atlas.maps.len() == 4, "atlas.maps moet vier maps bevatten")?;589
for kind in crate::atlas::ATLAS_MAPS {590
let entry = atlas591
.maps592
.get(kind.token())593
.ok_or_else(|| ExportError::Identity(format!("atlas mist {}", kind.token())))?;594
check(595
entry.file == crate::atlas::atlas_png_name(&render.sex, &render.set, kind)596
&& is_sha256_hex(&entry.sha256),597
"atlas-bestandsnaam/sha256 ongeldig",598
)?;599
}553
check(600
check(554
id.clips.len() == render.clips.len(),601
id.clips.len() == render.clips.len(),555
"clips length mismatch",602
"clips length mismatch",@@ -571,13 +618,34 @@ fn identities_match(571
Ok(())618
Ok(())572
}619
}573
620
574
fn decode_map(621
pub(crate) fn decode_map(622
bytes: &[u8],623
kind: MapKind,624
expect_w: u32,625
expect_h: u32,626
) -> Result<MapImage, ExportError> {627
decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET)628
}629
630
/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under631
/// the published pixel contract. Atlases use the same rules with a larger cap.632
pub(crate) fn decode_map_sized(575
bytes: &[u8],633
bytes: &[u8],576
kind: MapKind,634
kind: MapKind,577
expect_w: u32,635
expect_w: u32,578
expect_h: u32,636
expect_h: u32,637
max_dim: u32,579
) -> Result<MapImage, ExportError> {638
) -> Result<MapImage, ExportError> {580
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));639
if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {640
return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));641
}642
let decoded = expect_w as usize * expect_h as usize * 4;643
let mut decoder = png::Decoder::new_with_limits(644
std::io::Cursor::new(bytes),645
png::Limits {646
bytes: decoded.saturating_mul(2).max(64 * 1024 * 1024),647
},648
);581
decoder.set_transformations(png::Transformations::IDENTITY);649
decoder.set_transformations(png::Transformations::IDENTITY);582
let mut reader = decoder650
let mut reader = decoder583
.read_info()651
.read_info()@@ -589,8 +657,8 @@ fn decode_map(589
info.width, info.height657
info.width, info.height590
)));658
)));591
}659
}592
if info.width > MAX_TARGET || info.height > MAX_TARGET {660
if info.width > max_dim || info.height > max_dim {593
return Err(ExportError::Resource("PNG groter dan targetlimiet".into()));661
return Err(ExportError::Resource("PNG groter dan limiet".into()));594
}662
}595
match kind {663
match kind {596
MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {664
MapKind::Diffuse | MapKind::Normal | MapKind::Specular => {@@ -724,7 +792,7 @@ fn read_manifest_file(root: &Path, name: &str) -> Result<Vec<u8>, ExportError> {724
read_bounded_file(&path, MAX_MANIFEST_BYTES)792
read_bounded_file(&path, MAX_MANIFEST_BYTES)725
}793
}726
794
727
fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {795
pub(crate) fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {728
let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;796
let meta = fs::metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;729
if meta.len() > max {797
if meta.len() > max {730
return Err(ExportError::Resource(format!(798
return Err(ExportError::Resource(format!(@@ -753,7 +821,7 @@ fn resolve_inside(root: &Path, relative: &str) -> Result<PathBuf, ExportError> {753
Ok(path)821
Ok(path)754
}822
}755
823
756
fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {824
pub(crate) fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {757
let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;825
let meta = fs::symlink_metadata(path).map_err(|e| ExportError::Io(e.to_string()))?;758
if meta.file_type().is_symlink() {826
if meta.file_type().is_symlink() {759
return Err(ExportError::Traversal(format!(827
return Err(ExportError::Traversal(format!(@@ -848,9 +916,19 @@ mod tests {848
let _ = fs::remove_file(temp);916
let _ = fs::remove_file(temp);849
}917
}850
918
919
fn atlas_ref() -> serde_json::Value {920
let sha = digest::sha256_hex(b"atlas");921
let map = |m: &str| serde_json::json!({"file": format!("male-body-{m}-atlas.png"), "sha256": sha});922
serde_json::json!({923
"ppda": "male-body-atlas.ppda",924
"ppda_sha256": sha,925
"maps": {"diffuse": map("diffuse"), "normal": map("normal"), "depth": map("depth"), "specular": map("specular")}926
})927
}928
851
fn base_render(png_files: u32) -> serde_json::Value {929
fn base_render(png_files: u32) -> serde_json::Value {852
serde_json::json!({930
serde_json::json!({853
"format_version": 3,931
"format_version": OUTPUT_META_VERSION,854
"status": "incomplete",932
"status": "incomplete",855
"project": {"id": "demo", "name": "Demo"},933
"project": {"id": "demo", "name": "Demo"},856
"sex": "male",934
"sex": "male",@@ -867,7 +945,8 @@ mod tests {867
"camera_distance": 10.0,945
"camera_distance": 10.0,868
"yaw_zero": "camera on -Z",946
"yaw_zero": "camera on -Z",869
"normal_space": "view (+Y up), PNG rows top-down",947
"normal_space": "view (+Y up), PNG rows top-down",870
"pitches": [-30, 0, 30],948
"pitches": crate::preview::PRODUCTION_PITCHES,949
"yaws": crate::production::production_yaws(),871
"yaw_step": 30,950
"yaw_step": 30,872
"sampling": "test",951
"sampling": "test",873
"alpha": "straight",952
"alpha": "straight",@@ -887,19 +966,19 @@ mod tests {887
let mut map = BTreeMap::new();966
let mut map = BTreeMap::new();888
let rels = [967
let rels = [889
(968
(890
"male/body/clip000-bind/f000000/p-30_y000_diffuse.png",969
"male/body/clip000-bind/f000000/p-60_y000_diffuse.png",891
"diffuse",970
"diffuse",892
),971
),893
(972
(894
"male/body/clip000-bind/f000000/p-30_y000_depth.png",973
"male/body/clip000-bind/f000000/p-60_y000_depth.png",895
"depth",974
"depth",896
),975
),897
(976
(898
"male/body/clip000-bind/f000000/p-30_y000_normal.png",977
"male/body/clip000-bind/f000000/p-60_y000_normal.png",899
"normal",978
"normal",900
),979
),901
(980
(902
"male/body/clip000-bind/f000000/p-30_y000_specular.png",981
"male/body/clip000-bind/f000000/p-60_y000_specular.png",903
"specular",982
"specular",904
),983
),905
];984
];@@ -955,17 +1034,18 @@ mod tests {955
&tmp.0,1034
&tmp.0,956
"complete.json",1035
"complete.json",957
&serde_json::json!({1036
&serde_json::json!({958
"format_version": 3,1037
"format_version": OUTPUT_META_VERSION,959
"status": "complete",1038
"status": "complete",960
"identity": identity,1039
"identity": identity,961
"passes": 6,1040
"passes": 6,962
"png_files": 4,1041
"png_files": 4,963
"png_sha256": checksums1042
"png_sha256": checksums,1043
"atlas": atlas_ref()964
}),1044
}),965
);1045
);966
let run = ExportRun::open(&tmp.0).unwrap();1046
let run = ExportRun::open(&tmp.0).unwrap();967
let diffuse = run1047
let diffuse = run968
.load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse)1048
.load_map("clip000-bind", 0, -60, 0, MapKind::Diffuse)969
.unwrap();1049
.unwrap();970
match diffuse {1050
match diffuse {971
MapImage::Rgba8 {1051
MapImage::Rgba8 {@@ -979,7 +1059,7 @@ mod tests {979
_ => panic!("expected rgba"),1059
_ => panic!("expected rgba"),980
}1060
}981
let depth = run1061
let depth = run982
.load_map("clip000-bind", 0, -30, 0, MapKind::Depth)1062
.load_map("clip000-bind", 0, -60, 0, MapKind::Depth)983
.unwrap();1063
.unwrap();984
match depth {1064
match depth {985
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),1065
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),@@ -1003,12 +1083,13 @@ mod tests {1003
&tmp.0,1083
&tmp.0,1004
"complete.json",1084
"complete.json",1005
&serde_json::json!({1085
&serde_json::json!({1006
"format_version": 3,1086
"format_version": OUTPUT_META_VERSION,1007
"status": "complete",1087
"status": "complete",1008
"identity": identity,1088
"identity": identity,1009
"passes": 6,1089
"passes": 6,1010
"png_files": 4,1090
"png_files": 4,1011
"png_sha256": checksums1091
"png_sha256": checksums,1092
"atlas": atlas_ref()1012
}),1093
}),1013
);1094
);1014
assert!(matches!(1095
assert!(matches!(@@ -1029,12 +1110,13 @@ mod tests {1029
&tmp.0,1110
&tmp.0,1030
"complete.json",1111
"complete.json",1031
&serde_json::json!({1112
&serde_json::json!({1032
"format_version": 3,1113
"format_version": OUTPUT_META_VERSION,1033
"status": "complete",1114
"status": "complete",1034
"identity": identity,1115
"identity": identity,1035
"passes": 6,1116
"passes": 6,1036
"png_files": 5,1117
"png_files": 5,1037
"png_sha256": checksums1118
"png_sha256": checksums,1119
"atlas": atlas_ref()1038
}),1120
}),1039
);1121
);1040
assert!(matches!(1122
assert!(matches!(@@ -1047,7 +1129,7 @@ mod tests {1047
let tmp = Temp::new();1129
let tmp = Temp::new();1048
let outside = Temp::new();1130
let outside = Temp::new();1049
let checksums = publish_four(&tmp.0);1131
let checksums = publish_four(&tmp.0);1050
let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png";1132
let evil = "male/body/clip000-bind/f000000/p-60_y000_diffuse.png";1051
let _ = fs::remove_file(tmp.0.join(evil));1133
let _ = fs::remove_file(tmp.0.join(evil));1052
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();1134
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();1053
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();1135
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();@@ -1060,12 +1142,13 @@ mod tests {1060
&tmp.0,1142
&tmp.0,1061
"complete.json",1143
"complete.json",1062
&serde_json::json!({1144
&serde_json::json!({1063
"format_version": 3,1145
"format_version": OUTPUT_META_VERSION,1064
"status": "complete",1146
"status": "complete",1065
"identity": identity,1147
"identity": identity,1066
"passes": 6,1148
"passes": 6,1067
"png_files": 4,1149
"png_files": 4,1068
"png_sha256": checksums1150
"png_sha256": checksums,1151
"atlas": atlas_ref()1069
}),1152
}),1070
);1153
);1071
assert!(matches!(1154
assert!(matches!(@@ -1086,7 +1169,7 @@ mod tests {1086
&tmp.0,1169
&tmp.0,1087
"complete.json",1170
"complete.json",1088
&serde_json::json!({1171
&serde_json::json!({1089
"format_version": 3,1172
"format_version": OUTPUT_META_VERSION,1090
"status": "complete",1173
"status": "complete",1091
"identity": {1174
"identity": {1092
"project_id": "demo",1175
"project_id": "demo",@@ -1106,7 +1189,8 @@ mod tests {1106
},1189
},1107
"passes": 6,1190
"passes": 6,1108
"png_files": 4,1191
"png_files": 4,1109
"png_sha256": checksums1192
"png_sha256": checksums,1193
"atlas": atlas_ref()1110
}),1194
}),1111
);1195
);1112
assert!(matches!(1196
assert!(matches!(@@ -118,7 +118,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
118
118
}
119
119
120
120
pub fn dutch_summary() -> &'static str {
121
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."
121
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 60 hoeken (5 pitches × 12 yaws) met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie, plus per maptype een atlas en een .ppda. De 2D-preview leest exports via .ppda en atlassen en composeert ze met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."
122
122
}
123
123
124
124
/// Stable status text shared by `--status` and the window.
@@ -118,7 +118,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {118
}118
}119
119
120
pub fn dutch_summary() -> &'static str {120
pub fn dutch_summary() -> &'static str {121
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."121
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 60 hoeken (5 pitches × 12 yaws) met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie, plus per maptype een atlas en een .ppda. De 2D-preview leest exports via .ppda en atlassen en composeert ze met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."122
}122
}123
123
124
/// Stable status text shared by `--status` and the window.124
/// Stable status text shared by `--status` and the window.@@ -5,14 +5,17 @@
5
5
//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and
6
6
//! equipment previews, and streams production target maps.
7
7
//! Completed export runs are opened through `export_set` with checksum/identity
8
//! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
8
//! validation; `atlas` writes and validates the per-map atlases + `.ppda` consumer format; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
9
9
10
pub mod atlas;
10
11
pub mod camera;
11
12
pub mod compositor;
12
13
pub mod dialogs;
13
14
pub mod digest;
14
15
pub mod editor;
15
16
pub mod equipment;
17
#[doc(hidden)]
18
pub mod export_fixture;
16
19
pub mod export_set;
17
20
pub mod fbx_scene;
18
21
pub mod file_assoc;
@@ -5,14 +5,17 @@5
//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and5
//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and6
//! equipment previews, and streams production target maps.6
//! equipment previews, and streams production target maps.7
//! Completed export runs are opened through `export_set` with checksum/identity7
//! Completed export runs are opened through `export_set` with checksum/identity8
//! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.8
//! validation; `atlas` writes and validates the per-map atlases + `.ppda` consumer format; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.9
9
10
pub mod atlas;10
pub mod camera;11
pub mod camera;11
pub mod compositor;12
pub mod compositor;12
pub mod dialogs;13
pub mod dialogs;13
pub mod digest;14
pub mod digest;14
pub mod editor;15
pub mod editor;15
pub mod equipment;16
pub mod equipment;17
#[doc(hidden)]18
pub mod export_fixture;16
pub mod export_set;19
pub mod export_set;17
pub mod fbx_scene;20
pub mod fbx_scene;18
pub mod file_assoc;21
pub mod file_assoc;@@ -1,13 +1,17 @@
1
1
//! CPU skeleton sampling and orbit camera for the live glTF preview.
2
2
//!
3
3
//! This is not the paperdoll export. The export camera is orthographic and
4
//! still out of scope. The interactive camera can snap to the same three
4
//! still out of scope. The interactive camera can snap to the same five
5
5
//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,
6
6
//! so yaw 0 places the camera on -Z, looking at the body.
7
7
8
8
use std::path::Path;
9
9
10
pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
10
pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
11
/// Production pitches in integer degrees, atlas column order (t955u).
12
pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
13
/// Production views per sample: five pitches × twelve yaws.
14
pub const PRODUCTION_VIEWS: u32 = 60;
11
15
pub const PAPERDOLL_YAW_COUNT: usize = 12;
12
16
pub const MAX_JOINTS: usize = 128;
13
17
@@ -61,7 +65,7 @@ impl PreviewControls {
61
65
}
62
66
63
67
pub fn snap_pitch_row(&mut self, row: i32) {
64
let row = row.clamp(0, 2) as usize;
68
let row = row.clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1) as usize;
65
69
self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];
66
70
}
67
71
@@ -1345,7 +1349,8 @@ mod tests {
1345
1349
let low = eye_offset(0.0, -30.0, 4.0);
1346
1350
let high = eye_offset(0.0, 30.0, 4.0);
1347
1351
assert!(low[1] < 0.0 && high[1] > 0.0);
1348
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);
1352
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);
1353
assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);
1349
1354
}
1350
1355
1351
1356
#[test]
@@ -1359,9 +1364,11 @@ mod tests {
1359
1364
controls.snap_yaw_index(-1);
1360
1365
assert!((controls.yaw_deg - 330.0).abs() < 0.01);
1361
1366
controls.snap_pitch_row(99);
1362
assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1367
assert!((controls.pitch_deg - 60.0).abs() < 0.01);
1363
1368
controls.snap_pitch_row(-4);
1364
assert!((controls.pitch_deg + 30.0).abs() < 0.01);
1369
assert!((controls.pitch_deg + 60.0).abs() < 0.01);
1370
controls.snap_pitch_row(3);
1371
assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1365
1372
controls.time = 0.4;
1366
1373
controls.select_clip("attack");
1367
1374
assert_eq!(controls.clip_name, "attack");
@@ -1,13 +1,17 @@1
//! CPU skeleton sampling and orbit camera for the live glTF preview.1
//! CPU skeleton sampling and orbit camera for the live glTF preview.2
//!2
//!3
//! This is not the paperdoll export. The export camera is orthographic and3
//! This is not the paperdoll export. The export camera is orthographic and4
//! still out of scope. The interactive camera can snap to the same three4
//! still out of scope. The interactive camera can snap to the same five5
//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,5
//! pitches and twelve yaws. The character forward axis of the test bodies is -Z,6
//! so yaw 0 places the camera on -Z, looking at the body.6
//! so yaw 0 places the camera on -Z, looking at the body.7
7
8
use std::path::Path;8
use std::path::Path;9
9
10
pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];10
pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];11
/// Production pitches in integer degrees, atlas column order (t955u).12
pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];13
/// Production views per sample: five pitches × twelve yaws.14
pub const PRODUCTION_VIEWS: u32 = 60;11
pub const PAPERDOLL_YAW_COUNT: usize = 12;15
pub const PAPERDOLL_YAW_COUNT: usize = 12;12
pub const MAX_JOINTS: usize = 128;16
pub const MAX_JOINTS: usize = 128;13
17
@@ -61,7 +65,7 @@ impl PreviewControls {61
}65
}62
66
63
pub fn snap_pitch_row(&mut self, row: i32) {67
pub fn snap_pitch_row(&mut self, row: i32) {64
let row = row.clamp(0, 2) as usize;68
let row = row.clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1) as usize;65
self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];69
self.pitch_deg = PAPERDOLL_PITCHES_DEG[row];66
}70
}67
71
@@ -1345,7 +1349,8 @@ mod tests {1345
let low = eye_offset(0.0, -30.0, 4.0);1349
let low = eye_offset(0.0, -30.0, 4.0);1346
let high = eye_offset(0.0, 30.0, 4.0);1350
let high = eye_offset(0.0, 30.0, 4.0);1347
assert!(low[1] < 0.0 && high[1] > 0.0);1351
assert!(low[1] < 0.0 && high[1] > 0.0);1348
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);1352
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);1353
assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);1349
}1354
}1350
1355
1351
#[test]1356
#[test]@@ -1359,9 +1364,11 @@ mod tests {1359
controls.snap_yaw_index(-1);1364
controls.snap_yaw_index(-1);1360
assert!((controls.yaw_deg - 330.0).abs() < 0.01);1365
assert!((controls.yaw_deg - 330.0).abs() < 0.01);1361
controls.snap_pitch_row(99);1366
controls.snap_pitch_row(99);1362
assert!((controls.pitch_deg - 30.0).abs() < 0.01);1367
assert!((controls.pitch_deg - 60.0).abs() < 0.01);1363
controls.snap_pitch_row(-4);1368
controls.snap_pitch_row(-4);1364
assert!((controls.pitch_deg + 30.0).abs() < 0.01);1369
assert!((controls.pitch_deg + 60.0).abs() < 0.01);1370
controls.snap_pitch_row(3);1371
assert!((controls.pitch_deg - 30.0).abs() < 0.01);1365
controls.time = 0.4;1372
controls.time = 0.4;1366
controls.select_clip("attack");1373
controls.select_clip("attack");1367
assert_eq!(controls.clip_name, "attack");1374
assert_eq!(controls.clip_name, "attack");@@ -1,17 +1,20 @@
1
1
//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2
2
//!
3
//! Consumes completed schema-3 export runs through `export_set` + `compositor`.
3
//! Consumes completed exports through their `.ppda` + per-map atlases
4
//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and
5
//! `compositor`. Loose PNGs are not read here.
4
6
//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).
5
7
6
8
use std::collections::BTreeMap;
7
9
use std::fs;
8
10
use std::path::{Path, PathBuf};
9
11
12
use crate::atlas::{AtlasSet, LoadedAtlas};
10
13
use crate::compositor::{
11
assert_compatible, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
14
assert_compatible_keys, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
12
15
LayerMaps, Light,
13
16
};
14
use crate::export_set::{ExportError, ExportRun};
17
use crate::export_set::ExportError;
15
18
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
16
19
use crate::render_job::ImageBuf;
17
20
use crate::session::Sex;
@@ -28,6 +31,9 @@ pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
28
31
pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
29
32
pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
30
33
34
/// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60).
35
pub const ZERO_PITCH_ROW: i32 = 2;
36
31
37
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32
38
pub enum LightAxis {
33
39
Azimuth,
@@ -133,9 +139,10 @@ pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {
133
139
}
134
140
}
135
141
136
/// One completed export run discovered under a project `renders/` tree.
142
/// One export (`.ppda` + atlases) discovered under a project `renders/` tree.
137
143
#[derive(Debug, Clone)]
138
144
pub struct CatalogEntry {
145
/// Run directory containing the `.ppda`.
139
146
pub path: PathBuf,
140
147
pub sex: String,
141
148
pub set: String,
@@ -149,27 +156,23 @@ pub struct CatalogEntry {
149
156
}
150
157
151
158
impl CatalogEntry {
152
pub fn from_run(path: PathBuf, run: &ExportRun) -> Self {
159
pub fn from_atlas(path: PathBuf, set: &AtlasSet) -> Self {
160
let p = &set.ppda;
153
161
Self {
154
162
path,
155
sex: run.render.sex.clone(),
156
set: run.render.set.clone(),
157
project_id: run.render.project.id.clone(),
158
fps: run.render.fps,
159
target: run.render.target,
160
clips: run
161
.render
163
sex: p.sex.clone(),
164
set: p.settype.clone(),
165
project_id: p.project.id.clone(),
166
fps: p.fps,
167
target: [p.cell_width, p.cell_height],
168
clips: p
162
169
.clips
163
170
.iter()
164
.map(|c| (c.token.clone(), c.name.clone(), c.frames, c.duration))
171
.map(|c| (c.token.clone(), c.name.clone(), c.frame_count, c.duration))
165
172
.collect(),
166
body_model_sha256: run.render.body_model.sha256.clone(),
167
equipment_preset_sha256: run
168
.render
169
.equipment_preset
170
.as_ref()
171
.map(|p| p.sha256.clone()),
172
key: CompatKey::from_run(run),
173
body_model_sha256: p.identity.body_model_sha256.clone(),
174
equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),
175
key: CompatKey::from_ppda(p),
173
176
}
174
177
}
175
178
@@ -178,28 +181,37 @@ impl CatalogEntry {
178
181
}
179
182
}
180
183
181
/// Scan `project_root/renders/*/complete.json` and open only valid completed runs.
184
/// Run directories under `renders/` that were skipped, with the reason.
185
pub type Skipped = Vec<(PathBuf, String)>;
186
187
/// Scan `project_root/renders/*/` and open every export whose `.ppda` and
188
/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,
189
/// damaged) are skipped.
182
190
pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
191
discover_exports(project_root).0
192
}
193
194
/// Like [`discover_completed_runs`], also returning skipped runs + reasons.
195
pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {
183
196
let renders = project_root.join("renders");
184
197
let Ok(entries) = fs::read_dir(&renders) else {
185
return Vec::new();
198
return (Vec::new(), Vec::new());
186
199
};
187
200
let mut out = Vec::new();
201
let mut skipped = Vec::new();
188
202
for entry in entries.flatten() {
189
203
let path = entry.path();
190
204
if !path.is_dir() {
191
205
continue;
192
206
}
193
if !path.join("complete.json").is_file() {
194
continue;
195
}
196
match ExportRun::open(&path) {
197
Ok(run) => out.push(CatalogEntry::from_run(path, &run)),
198
Err(_) => continue,
207
match AtlasSet::open_run(&path) {
208
Ok(set) => out.push(CatalogEntry::from_atlas(path, &set)),
209
Err(err) => skipped.push((path, err.to_string())),
199
210
}
200
211
}
201
212
out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));
202
out
213
skipped.sort();
214
(out, skipped)
203
215
}
204
216
205
217
/// Discrete yaw index 0..11 → degrees 0,30,…,330.
@@ -217,7 +229,7 @@ pub fn pitch_row_from_deg(pitch: i32) -> i32 {
217
229
PAPERDOLL_PITCHES_DEG
218
230
.iter()
219
231
.position(|p| *p as i32 == pitch)
220
.unwrap_or(1) as i32
232
.unwrap_or(ZERO_PITCH_ROW as usize) as i32
221
233
}
222
234
223
235
/// View-space light direction toward the light.
@@ -290,12 +302,12 @@ impl Default for Preview2dState {
290
302
clip_token: String::new(),
291
303
clip_frames: 1,
292
304
clip_duration: 0.0,
293
fps: 12,
305
fps: crate::project::DEFAULT_FPS,
294
306
playback_speed: 1.0,
295
307
playing: true,
296
308
elapsed_secs: 0.0,
297
309
yaw_index: 0,
298
pitch_row: 1,
310
pitch_row: ZERO_PITCH_ROW,
299
311
light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
300
312
light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
301
313
hold_azimuth: None,
@@ -355,7 +367,7 @@ impl Preview2dState {
355
367
}
356
368
357
369
pub fn step_pitch(&mut self, delta: i32) {
358
self.pitch_row = (self.pitch_row + delta).clamp(0, 2);
370
self.pitch_row = (self.pitch_row + delta).clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1);
359
371
// Animation time intentionally preserved; pitch is clamped, not cyclic.
360
372
}
361
373
@@ -452,8 +464,8 @@ impl Preview2dState {
452
464
453
465
#[derive(Debug, Clone)]
454
466
pub struct ComposeRequest<'a> {
455
pub body: &'a ExportRun,
456
pub equipment: &'a [ExportRun],
467
pub body: &'a LoadedAtlas,
468
pub equipment: &'a [LoadedAtlas],
457
469
pub clip_token: &'a str,
458
470
pub frame: u32,
459
471
pub pitch: i32,
@@ -514,38 +526,18 @@ impl From<ExportError> for Preview2dError {
514
526
515
527
/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.
516
528
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
517
let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
518
runs.push(req.body);
519
for run in req.equipment {
520
runs.push(run);
521
}
522
assert_compatible(&runs)?;
523
524
let mut layers = Vec::with_capacity(runs.len());
525
let body_set = req.body.render.set.as_str();
526
match LayerMaps::from_export_run(
527
req.body,
528
body_set,
529
req.clip_token,
530
req.frame,
531
req.pitch,
532
req.yaw,
533
) {
534
Ok(layer) => layers.push(layer),
535
Err(err) => {
536
return Err(Preview2dError::Missing {
537
set: body_set.to_string(),
538
clip: req.clip_token.to_string(),
539
frame: req.frame,
540
pitch: req.pitch,
541
yaw: req.yaw,
542
detail: err.to_string(),
543
});
544
}
545
}
546
for run in req.equipment {
547
let set = run.render.set.as_str();
548
match LayerMaps::from_export_run(run, set, req.clip_token, req.frame, req.pitch, req.yaw) {
529
let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len());
530
atlases.push(req.body);
531
atlases.extend(req.equipment.iter());
532
let keys: Vec<CompatKey> = atlases
533
.iter()
534
.map(|a| CompatKey::from_ppda(a.ppda()))
535
.collect();
536
assert_compatible_keys(&keys)?;
537
let mut layers = Vec::with_capacity(atlases.len());
538
for atlas in atlases {
539
let set = atlas.ppda().settype.as_str();
540
match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) {
549
541
Ok(layer) => layers.push(layer),
550
542
Err(err) => {
551
543
return Err(Preview2dError::Missing {
@@ -568,23 +560,23 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe
568
560
}
569
561
}
570
562
571
/// Open body + selected equipment runs for one sex, validating compatibility.
572
pub fn open_selection(
563
/// Run directories (body first, then equipment in the given order) that
564
/// [`open_selection`] would load; used as a cache key by the editor.
565
pub fn selection_paths(
573
566
catalog: &[CatalogEntry],
574
567
sex: Sex,
575
568
equipment_sets: &[String],
576
) -> Result<(ExportRun, Vec<ExportRun>), Preview2dError> {
569
) -> Result<Vec<PathBuf>, Preview2dError> {
577
570
let sex_token = match sex {
578
571
Sex::Male => "male",
579
572
Sex::Female => "female",
580
573
};
581
let body_entry = catalog
574
let body = catalog
582
575
.iter()
583
576
.filter(|e| e.sex == sex_token && e.is_body())
584
577
.max_by_key(|e| e.path.clone())
585
578
.ok_or(Preview2dError::NoBody)?;
586
let body = ExportRun::open(&body_entry.path)?;
587
let mut equipment = Vec::new();
579
let mut paths = vec![body.path.clone()];
588
580
for set in equipment_sets {
589
581
let entry = catalog
590
582
.iter()
@@ -598,12 +590,30 @@ pub fn open_selection(
598
590
yaw: 0,
599
591
detail: "geen voltooide exportset voor dit equipment".into(),
600
592
})?;
601
equipment.push(ExportRun::open(&entry.path)?);
593
paths.push(entry.path.clone());
602
594
}
603
let mut refs: Vec<&ExportRun> = vec![&body];
604
refs.extend(equipment.iter());
605
assert_compatible(&refs)?;
606
Ok((body, equipment))
595
Ok(paths)
596
}
597
598
/// Open and decode the `.ppda` atlases of body + selected equipment for one
599
/// sex, re-validating checksums and compatibility.
600
pub fn open_selection(
601
catalog: &[CatalogEntry],
602
sex: Sex,
603
equipment_sets: &[String],
604
) -> Result<(LoadedAtlas, Vec<LoadedAtlas>), Preview2dError> {
605
let paths = selection_paths(catalog, sex, equipment_sets)?;
606
let mut loaded = Vec::with_capacity(paths.len());
607
for path in &paths {
608
loaded.push(AtlasSet::open_run(path)?.load()?);
609
}
610
let keys: Vec<CompatKey> = loaded
611
.iter()
612
.map(|a| CompatKey::from_ppda(a.ppda()))
613
.collect();
614
assert_compatible_keys(&keys)?;
615
let body = loaded.remove(0);
616
Ok((body, loaded))
607
617
}
608
618
609
619
/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).
@@ -676,10 +686,8 @@ pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEnt
676
686
mod tests {
677
687
use super::*;
678
688
use crate::digest;
679
use crate::export_set::complete_identity_json;
680
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
689
use crate::export_fixture::{Pixel, SyntheticRun};
681
690
use crate::project::CameraFraming;
682
use std::collections::BTreeMap as Map;
683
691
use std::sync::atomic::{AtomicU64, Ordering};
684
692
685
693
static COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -715,164 +723,39 @@ mod tests {
715
723
}
716
724
}
717
725
718
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
719
let data = serde_json::to_vec_pretty(value).unwrap();
720
let path = root.join(name);
721
let temp = path.with_extension(format!("part-{}", std::process::id()));
722
fs::write(&temp, &data).unwrap();
723
fs::hard_link(&temp, &path).unwrap();
724
let _ = fs::remove_file(temp);
725
}
726
727
#[allow(clippy::too_many_arguments)]
728
fn publish_pose(
729
root: &Path,
730
sex: &str,
731
set: &str,
732
clip: &str,
733
frame: u32,
734
pitch: i32,
735
yaw: u32,
736
diffuse: [u8; 4],
737
normal: [u8; 4],
738
specular: [u8; 4],
739
depth_gray: u16,
740
) -> Map<String, String> {
741
let mut map = Map::new();
742
let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
743
let rels = [
744
(format!("{base}_diffuse.png"), "diffuse"),
745
(format!("{base}_depth.png"), "depth"),
746
(format!("{base}_normal.png"), "normal"),
747
(format!("{base}_specular.png"), "specular"),
748
];
749
for (rel, kind) in rels {
750
let path = Path::new(&rel);
751
match kind {
752
"depth" => {
753
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
754
}
755
"normal" => {
756
write_rgba(
757
root,
758
path,
759
&ImageBuf {
760
width: 1,
761
height: 1,
762
rgba: normal.to_vec(),
763
},
764
)
765
.unwrap();
766
}
767
"specular" => {
768
write_rgba(
769
root,
770
path,
771
&ImageBuf {
772
width: 1,
773
height: 1,
774
rgba: specular.to_vec(),
775
},
776
)
777
.unwrap();
778
}
779
_ => {
780
write_rgba(
781
root,
782
path,
783
&ImageBuf {
784
width: 1,
785
height: 1,
786
rgba: diffuse.to_vec(),
787
},
788
)
789
.unwrap();
790
}
791
}
792
let bytes = fs::read(root.join(&rel)).unwrap();
793
map.insert(rel, digest::sha256_hex(&bytes));
726
fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
727
Pixel {
728
diffuse,
729
normal,
730
specular,
731
depth,
794
732
}
795
map
796
733
}
797
734
735
/// Complete synthetic run (all 60 views) with uniform maps per run.
798
736
#[allow(clippy::too_many_arguments)]
799
737
fn write_run(
800
738
root: &Path,
801
739
project_id: &str,
802
sex: &str,
803
740
set: &str,
804
741
equipment_sha: Option<&str>,
805
742
body_bytes: &[u8],
806
clips: &[(&str, &str, u32, f32)],
807
checksums: Map<String, String>,
743
clips: &[(&str, u32, f32)],
744
px: Pixel,
808
745
) {
809
let body_hash = digest::sha256_hex(body_bytes);
810
let clips_json: Vec<_> = clips
746
let mut spec = SyntheticRun::body(project_id, "male", body_bytes);
747
spec.framing = framing();
748
spec.set = set.into();
749
spec.equipment_sha256 = equipment_sha.map(str::to_string);
750
spec.clips = clips
811
751
.iter()
812
.map(|(name, token, frames, duration)| {
813
serde_json::json!({
814
"name": name,
815
"token": token,
816
"frames": frames,
817
"duration": duration
818
})
819
})
752
.map(|(n, f, d)| (n.to_string(), *f, *d))
820
753
.collect();
821
let images: u32 = clips.iter().map(|(_, _, f, _)| *f).sum::<u32>() * 36;
822
let png_files = checksums.len() as u32;
823
let mut render = serde_json::json!({
824
"format_version": 3,
825
"status": "incomplete",
826
"project": {"id": project_id, "name": "Demo"},
827
"sex": sex,
828
"set": set,
829
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
830
"equipment_preset": null,
831
"target": [1, 1],
832
"fps": 12,
833
"highres_scale": 8,
834
"framing": framing(),
835
"pivot_source": {"kind": "manual"},
836
"near": NEAR,
837
"far": FAR,
838
"camera_distance": 10.0,
839
"yaw_zero": "camera on -Z",
840
"normal_space": "view (+Y up), PNG rows top-down",
841
"pitches": [-30, 0, 30],
842
"yaw_step": 30,
843
"sampling": "test",
844
"alpha": "straight",
845
"diffuse": "unlit",
846
"depth": "16-bit",
847
"specular": "scalar",
848
"pass_order": PASSES,
849
"saved_maps": ["diffuse", "depth", "normal", "specular"],
850
"clips": clips_json,
851
"images": images,
852
"passes": images * 6,
853
"png_files": png_files
854
});
855
if let Some(sha) = equipment_sha {
856
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
857
}
858
write_atomic_json(root, "render.json", &render);
859
let identity = complete_identity_json(&render).unwrap();
860
write_atomic_json(
861
root,
862
"complete.json",
863
&serde_json::json!({
864
"format_version": 3,
865
"status": "complete",
866
"identity": identity,
867
"passes": images * 6,
868
"png_files": png_files,
869
"png_sha256": checksums
870
}),
871
);
754
spec.write(root, |_, _, _, _| px).unwrap();
872
755
}
873
756
874
757
#[test]
875
fn yaw_wraps_twelve_steps_pitch_clamps_three_rows() {
758
fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() {
876
759
let mut state = Preview2dState {
877
760
elapsed_secs: 1.25,
878
761
..Preview2dState::default()
@@ -889,17 +772,19 @@ mod tests {
889
772
assert_eq!(state.yaw_deg(), 0);
890
773
assert_eq!(PAPERDOLL_YAW_COUNT, 12);
891
774
892
state.pitch_row = 1;
775
state.pitch_row = ZERO_PITCH_ROW;
893
776
state.step_pitch(1);
894
777
assert_eq!(state.pitch_deg(), 30);
895
778
state.step_pitch(1);
896
assert_eq!(state.pitch_deg(), 30);
897
state.step_pitch(-1);
898
assert_eq!(state.pitch_deg(), 0);
899
state.step_pitch(-1);
779
assert_eq!(state.pitch_deg(), 60);
780
state.step_pitch(1);
781
assert_eq!(state.pitch_deg(), 60);
782
state.step_pitch(-3);
900
783
assert_eq!(state.pitch_deg(), -30);
901
784
state.step_pitch(-1);
902
assert_eq!(state.pitch_deg(), -30);
785
assert_eq!(state.pitch_deg(), -60);
786
state.step_pitch(-1);
787
assert_eq!(state.pitch_deg(), -60);
903
788
assert_eq!(state.elapsed_secs, 1.25);
904
789
}
905
790
@@ -975,64 +860,43 @@ mod tests {
975
860
976
861
#[test]
977
862
fn missing_frame_is_diagnostic_not_silent_substitute() {
978
let body_tmp = Temp::new();
979
let clips = [("walk", "clip000-walk", 2u32, 0.1667f32)];
980
let mut checksums = Map::new();
981
// Only frame 0 / pitch 0 / yaw 0 published — frame 1 missing.
982
checksums.extend(publish_pose(
983
&body_tmp.0,
984
"male",
985
"body",
986
"clip000-walk",
987
0,
988
0,
989
0,
990
[10, 20, 30, 255],
991
[128, 128, 255, 255],
992
[0, 0, 0, 255],
993
20000,
994
));
863
let project = Temp::new();
864
let dir = project.0.join("renders/run-body");
995
865
write_run(
996
&body_tmp.0,
866
&dir,
997
867
"demo",
998
"male",
999
868
"body",
1000
869
None,
1001
870
b"shared-body",
1002
&clips,
1003
checksums,
871
&[("walk", 2, 0.5)],
872
pixel(
873
[10, 20, 30, 255],
874
[128, 128, 255, 255],
875
[0, 0, 0, 255],
876
20000,
877
),
1004
878
);
1005
let run = ExportRun::open(&body_tmp.0).unwrap();
1006
let ok = compose_preview_frame(ComposeRequest {
1007
body: &run,
1008
equipment: &[],
1009
clip_token: "clip000-walk",
1010
frame: 0,
1011
pitch: 0,
1012
yaw: 0,
1013
light: Light::default(),
1014
view_mode: ViewMode::Resultaat,
1015
})
1016
.unwrap();
1017
assert_eq!(ok.width, 1);
1018
let err = compose_preview_frame(ComposeRequest {
1019
body: &run,
879
let catalog = discover_completed_runs(&project.0);
880
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
881
let req = ComposeRequest {
882
body: &body,
1020
883
equipment: &[],
1021
884
clip_token: "clip000-walk",
1022
885
frame: 1,
1023
pitch: 0,
1024
yaw: 0,
886
pitch: 60,
887
yaw: 330,
1025
888
light: Light::default(),
1026
889
view_mode: ViewMode::Resultaat,
1027
})
1028
.unwrap_err();
890
};
891
assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1);
892
let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err();
1029
893
match err {
1030
894
Preview2dError::Missing {
1031
895
set, clip, frame, ..
1032
896
} => {
1033
897
assert_eq!(set, "body");
1034
898
assert_eq!(clip, "clip000-walk");
1035
assert_eq!(frame, 1);
899
assert_eq!(frame, 2);
1036
900
}
1037
901
other => panic!("expected Missing, got {other}"),
1038
902
}
@@ -1042,98 +906,58 @@ mod tests {
1042
906
fn catalog_compatibility_filters_equipment_by_export_contract() {
1043
907
let project = Temp::new();
1044
908
let renders = project.0.join("renders");
1045
fs::create_dir_all(&renders).unwrap();
1046
let body_dir = renders.join("run-body");
1047
let good_dir = renders.join("run-sword");
1048
let bad_dir = renders.join("run-bad");
1049
fs::create_dir_all(&body_dir).unwrap();
1050
fs::create_dir_all(&good_dir).unwrap();
1051
fs::create_dir_all(&bad_dir).unwrap();
1052
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
909
let clips = [("bind", 1u32, 0.0f32)];
1053
910
let body_bytes = b"shared-body";
1054
let mut body_maps = Map::new();
1055
body_maps.extend(publish_pose(
1056
&body_dir,
1057
"male",
1058
"body",
1059
"clip000-bind",
1060
0,
1061
0,
1062
0,
1063
[1, 2, 3, 255],
1064
[128, 128, 255, 255],
1065
[0, 0, 0, 255],
1066
1000,
1067
));
1068
911
write_run(
1069
&body_dir, "proj-a", "male", "body", None, body_bytes, &clips, body_maps,
912
&renders.join("run-body"),
913
"proj-a",
914
"body",
915
None,
916
body_bytes,
917
&clips,
918
pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
1070
919
);
1071
let mut good_maps = Map::new();
1072
good_maps.extend(publish_pose(
1073
&good_dir,
1074
"male",
1075
"sword",
1076
"clip000-bind",
1077
0,
1078
0,
1079
0,
1080
[200, 10, 10, 255],
1081
[255, 128, 128, 255],
1082
[180, 180, 180, 255],
1083
500,
1084
));
1085
920
write_run(
1086
&good_dir,
921
&renders.join("run-sword"),
1087
922
"proj-a",
1088
"male",
1089
923
"sword",
1090
924
Some(&digest::sha256_hex(b"sword")),
1091
925
body_bytes,
1092
926
&clips,
1093
good_maps,
927
pixel(
928
[200, 10, 10, 255],
929
[255, 128, 128, 255],
930
[180, 180, 180, 255],
931
500,
932
),
1094
933
);
1095
let mut bad_maps = Map::new();
1096
bad_maps.extend(publish_pose(
1097
&bad_dir,
1098
"male",
1099
"helm",
1100
"clip000-bind",
1101
0,
1102
0,
1103
0,
1104
[9, 9, 9, 255],
1105
[128, 128, 255, 255],
1106
[0, 0, 0, 255],
1107
800,
1108
));
1109
934
// Different body fingerprint → incompatible.
1110
935
write_run(
1111
&bad_dir,
936
&renders.join("run-bad"),
1112
937
"proj-a",
1113
"male",
1114
938
"helm",
1115
939
Some(&digest::sha256_hex(b"helm")),
1116
940
b"other-body",
1117
941
&clips,
1118
bad_maps,
942
pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800),
1119
943
);
1120
944
1121
945
let catalog = discover_completed_runs(&project.0);
1122
946
assert_eq!(catalog.len(), 3);
1123
947
let allowed = compatible_equipment_sets(&catalog, Sex::Male);
1124
948
assert_eq!(allowed, vec!["sword".to_string()]);
1125
assert!(!allowed.iter().any(|s| s == "helm"));
949
assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err());
1126
950
1127
951
let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();
1128
assert_eq!(body.render.set, "body");
952
assert_eq!(body.ppda().settype, "body");
1129
953
assert_eq!(gear.len(), 1);
1130
954
let lit = compose_preview_frame(ComposeRequest {
1131
955
body: &body,
1132
956
equipment: &gear,
1133
957
clip_token: "clip000-bind",
1134
958
frame: 0,
1135
pitch: 0,
1136
yaw: 0,
959
pitch: -60,
960
yaw: 90,
1137
961
light: Light::default(),
1138
962
view_mode: ViewMode::Resultaat,
1139
963
})
@@ -1212,34 +1036,24 @@ mod tests {
1212
1036
1213
1037
#[test]
1214
1038
fn view_modes_show_composited_maps_unlit_except_resultaat() {
1215
let body_tmp = Temp::new();
1216
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
1217
let mut checksums = Map::new();
1218
// Distinct channels so modes are distinguishable.
1219
checksums.extend(publish_pose(
1220
&body_tmp.0,
1221
"male",
1222
"body",
1223
"clip000-bind",
1224
0,
1225
0,
1226
0,
1227
[10, 20, 30, 255],
1228
[40, 50, 60, 255],
1229
[70, 70, 70, 255], // scalar specular (equal RGB)
1230
32768, // mid gray depth
1231
));
1039
let project = Temp::new();
1040
// Distinct channels so modes are distinguishable; scalar specular.
1232
1041
write_run(
1233
&body_tmp.0,
1042
&project.0.join("renders/run-body"),
1234
1043
"demo",
1235
"male",
1236
1044
"body",
1237
1045
None,
1238
1046
b"shared-body",
1239
&clips,
1240
checksums,
1047
&[("bind", 1, 0.0)],
1048
pixel(
1049
[10, 20, 30, 255],
1050
[40, 50, 60, 255],
1051
[70, 70, 70, 255],
1052
32768,
1053
),
1241
1054
);
1242
let run = ExportRun::open(&body_tmp.0).unwrap();
1055
let catalog = discover_completed_runs(&project.0);
1056
let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1243
1057
let base = ComposeRequest {
1244
1058
body: &run,
1245
1059
equipment: &[],
@@ -1284,7 +1098,7 @@ mod tests {
1284
1098
1285
1099
let result = compose_preview_frame(ComposeRequest {
1286
1100
view_mode: ViewMode::Resultaat,
1287
..base
1101
..base.clone()
1288
1102
})
1289
1103
.unwrap();
1290
1104
// Relit differs from raw diffuse under default light.
@@ -1299,14 +1113,9 @@ mod tests {
1299
1113
..Light::default()
1300
1114
};
1301
1115
let diffuse_dark = compose_preview_frame(ComposeRequest {
1302
body: &run,
1303
equipment: &[],
1304
clip_token: "clip000-bind",
1305
frame: 0,
1306
pitch: 0,
1307
yaw: 0,
1308
1116
light: dark,
1309
1117
view_mode: ViewMode::DiffuseOnly,
1118
..base
1310
1119
})
1311
1120
.unwrap();
1312
1121
assert_eq!(diffuse_dark.rgba, diffuse.rgba);
@@ -1319,8 +1128,76 @@ mod tests {
1319
1128
let incomplete = renders.join("run-incomplete");
1320
1129
fs::create_dir_all(&incomplete).unwrap();
1321
1130
fs::write(incomplete.join("render.json"), b"{}").unwrap();
1322
// no complete.json
1323
let catalog = discover_completed_runs(&project.0);
1131
// no .ppda at all
1132
let (catalog, skipped) = discover_exports(&project.0);
1324
1133
assert!(catalog.is_empty());
1134
assert_eq!(skipped.len(), 1);
1135
assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1136
}
1137
1138
#[test]
1139
fn preview_reads_atlas_not_loose_pngs() {
1140
// Each view gets a distinct colour; the preview must return exactly the
1141
// atlas cell even after every loose PNG is deleted.
1142
let project = Temp::new();
1143
let dir = project.0.join("renders/run-body");
1144
let mut spec = SyntheticRun::body("demo", "male", b"b");
1145
spec.clips = vec![("walk".into(), 2, 0.5)];
1146
spec.target = [2, 3];
1147
spec.write(&dir, |_, f, p, y| {
1148
pixel(
1149
[(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255],
1150
[128, 128, 255, 255],
1151
[0, 0, 0, 255],
1152
1000,
1153
)
1154
})
1155
.unwrap();
1156
fs::remove_dir_all(dir.join("male")).unwrap();
1157
let catalog = discover_completed_runs(&project.0);
1158
assert_eq!(catalog.len(), 1);
1159
assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
1160
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1161
for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] {
1162
let img = compose_preview_frame(ComposeRequest {
1163
body: &body,
1164
equipment: &[],
1165
clip_token: "clip000-walk",
1166
frame,
1167
pitch,
1168
yaw,
1169
light: Light::default(),
1170
view_mode: ViewMode::DiffuseOnly,
1171
})
1172
.unwrap();
1173
assert_eq!((img.width, img.height), (2, 3));
1174
let want = [
1175
(yaw / 30) as u8 * 20,
1176
(pitch + 60) as u8,
1177
frame as u8 * 100 + 1,
1178
255,
1179
];
1180
for px in img.rgba.chunks_exact(4) {
1181
assert_eq!(px, want);
1182
}
1183
}
1184
}
1185
1186
#[test]
1187
fn five_pitch_rows_clamp_and_default_is_zero() {
1188
let mut state = Preview2dState::default();
1189
assert_eq!(state.pitch_deg(), 0);
1190
state.step_pitch(1);
1191
state.step_pitch(1);
1192
assert_eq!(state.pitch_deg(), 60);
1193
state.step_pitch(1);
1194
assert_eq!(state.pitch_deg(), 60);
1195
for _ in 0..10 {
1196
state.step_pitch(-1);
1197
}
1198
assert_eq!(state.pitch_deg(), -60);
1199
assert_eq!(pitch_row_from_deg(30), 3);
1200
assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1201
assert_eq!(state.fps, 4);
1325
1202
}
1326
1203
}
@@ -1,17 +1,20 @@1
//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.1
//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.2
//!2
//!3
//! Consumes completed schema-3 export runs through `export_set` + `compositor`.3
//! Consumes completed exports through their `.ppda` + per-map atlases4
//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and5
//! `compositor`. Loose PNGs are not read here.4
//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).6
//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).5
7
6
use std::collections::BTreeMap;8
use std::collections::BTreeMap;7
use std::fs;9
use std::fs;8
use std::path::{Path, PathBuf};10
use std::path::{Path, PathBuf};9
11
12
use crate::atlas::{AtlasSet, LoadedAtlas};10
use crate::compositor::{13
use crate::compositor::{11
assert_compatible, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,14
assert_compatible_keys, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,12
LayerMaps, Light,15
LayerMaps, Light,13
};16
};14
use crate::export_set::{ExportError, ExportRun};17
use crate::export_set::ExportError;15
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};18
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};16
use crate::render_job::ImageBuf;19
use crate::render_job::ImageBuf;17
use crate::session::Sex;20
use crate::session::Sex;@@ -28,6 +31,9 @@ pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;28
pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;31
pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;29
pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;32
pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;30
33
34
/// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60).35
pub const ZERO_PITCH_ROW: i32 = 2;36
31
#[derive(Debug, Clone, Copy, PartialEq, Eq)]37
#[derive(Debug, Clone, Copy, PartialEq, Eq)]32
pub enum LightAxis {38
pub enum LightAxis {33
Azimuth,39
Azimuth,@@ -133,9 +139,10 @@ pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {133
}139
}134
}140
}135
141
136
/// One completed export run discovered under a project `renders/` tree.142
/// One export (`.ppda` + atlases) discovered under a project `renders/` tree.137
#[derive(Debug, Clone)]143
#[derive(Debug, Clone)]138
pub struct CatalogEntry {144
pub struct CatalogEntry {145
/// Run directory containing the `.ppda`.139
pub path: PathBuf,146
pub path: PathBuf,140
pub sex: String,147
pub sex: String,141
pub set: String,148
pub set: String,@@ -149,27 +156,23 @@ pub struct CatalogEntry {149
}156
}150
157
151
impl CatalogEntry {158
impl CatalogEntry {152
pub fn from_run(path: PathBuf, run: &ExportRun) -> Self {159
pub fn from_atlas(path: PathBuf, set: &AtlasSet) -> Self {160
let p = &set.ppda;153
Self {161
Self {154
path,162
path,155
sex: run.render.sex.clone(),163
sex: p.sex.clone(),156
set: run.render.set.clone(),164
set: p.settype.clone(),157
project_id: run.render.project.id.clone(),165
project_id: p.project.id.clone(),158
fps: run.render.fps,166
fps: p.fps,159
target: run.render.target,167
target: [p.cell_width, p.cell_height],160
clips: run168
clips: p161
.render162
.clips169
.clips163
.iter()170
.iter()164
.map(|c| (c.token.clone(), c.name.clone(), c.frames, c.duration))171
.map(|c| (c.token.clone(), c.name.clone(), c.frame_count, c.duration))165
.collect(),172
.collect(),166
body_model_sha256: run.render.body_model.sha256.clone(),173
body_model_sha256: p.identity.body_model_sha256.clone(),167
equipment_preset_sha256: run174
equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),168
.render175
key: CompatKey::from_ppda(p),169
.equipment_preset170
.as_ref()171
.map(|p| p.sha256.clone()),172
key: CompatKey::from_run(run),173
}176
}174
}177
}175
178
@@ -178,28 +181,37 @@ impl CatalogEntry {178
}181
}179
}182
}180
183
181
/// Scan `project_root/renders/*/complete.json` and open only valid completed runs.184
/// Run directories under `renders/` that were skipped, with the reason.185
pub type Skipped = Vec<(PathBuf, String)>;186
187
/// Scan `project_root/renders/*/` and open every export whose `.ppda` and188
/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,189
/// damaged) are skipped.182
pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {190
pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {191
discover_exports(project_root).0192
}193
194
/// Like [`discover_completed_runs`], also returning skipped runs + reasons.195
pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {183
let renders = project_root.join("renders");196
let renders = project_root.join("renders");184
let Ok(entries) = fs::read_dir(&renders) else {197
let Ok(entries) = fs::read_dir(&renders) else {185
return Vec::new();198
return (Vec::new(), Vec::new());186
};199
};187
let mut out = Vec::new();200
let mut out = Vec::new();201
let mut skipped = Vec::new();188
for entry in entries.flatten() {202
for entry in entries.flatten() {189
let path = entry.path();203
let path = entry.path();190
if !path.is_dir() {204
if !path.is_dir() {191
continue;205
continue;192
}206
}193
if !path.join("complete.json").is_file() {207
match AtlasSet::open_run(&path) {194
continue;208
Ok(set) => out.push(CatalogEntry::from_atlas(path, &set)),195
}209
Err(err) => skipped.push((path, err.to_string())),196
match ExportRun::open(&path) {197
Ok(run) => out.push(CatalogEntry::from_run(path, &run)),198
Err(_) => continue,199
}210
}200
}211
}201
out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));212
out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));202
out213
skipped.sort();214
(out, skipped)203
}215
}204
216
205
/// Discrete yaw index 0..11 → degrees 0,30,…,330.217
/// Discrete yaw index 0..11 → degrees 0,30,…,330.@@ -217,7 +229,7 @@ pub fn pitch_row_from_deg(pitch: i32) -> i32 {217
PAPERDOLL_PITCHES_DEG229
PAPERDOLL_PITCHES_DEG218
.iter()230
.iter()219
.position(|p| *p as i32 == pitch)231
.position(|p| *p as i32 == pitch)220
.unwrap_or(1) as i32232
.unwrap_or(ZERO_PITCH_ROW as usize) as i32221
}233
}222
234
223
/// View-space light direction toward the light.235
/// View-space light direction toward the light.@@ -290,12 +302,12 @@ impl Default for Preview2dState {290
clip_token: String::new(),302
clip_token: String::new(),291
clip_frames: 1,303
clip_frames: 1,292
clip_duration: 0.0,304
clip_duration: 0.0,293
fps: 12,305
fps: crate::project::DEFAULT_FPS,294
playback_speed: 1.0,306
playback_speed: 1.0,295
playing: true,307
playing: true,296
elapsed_secs: 0.0,308
elapsed_secs: 0.0,297
yaw_index: 0,309
yaw_index: 0,298
pitch_row: 1,310
pitch_row: ZERO_PITCH_ROW,299
light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,311
light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,300
light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,312
light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,301
hold_azimuth: None,313
hold_azimuth: None,@@ -355,7 +367,7 @@ impl Preview2dState {355
}367
}356
368
357
pub fn step_pitch(&mut self, delta: i32) {369
pub fn step_pitch(&mut self, delta: i32) {358
self.pitch_row = (self.pitch_row + delta).clamp(0, 2);370
self.pitch_row = (self.pitch_row + delta).clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1);359
// Animation time intentionally preserved; pitch is clamped, not cyclic.371
// Animation time intentionally preserved; pitch is clamped, not cyclic.360
}372
}361
373
@@ -452,8 +464,8 @@ impl Preview2dState {452
464
453
#[derive(Debug, Clone)]465
#[derive(Debug, Clone)]454
pub struct ComposeRequest<'a> {466
pub struct ComposeRequest<'a> {455
pub body: &'a ExportRun,467
pub body: &'a LoadedAtlas,456
pub equipment: &'a [ExportRun],468
pub equipment: &'a [LoadedAtlas],457
pub clip_token: &'a str,469
pub clip_token: &'a str,458
pub frame: u32,470
pub frame: u32,459
pub pitch: i32,471
pub pitch: i32,@@ -514,38 +526,18 @@ impl From<ExportError> for Preview2dError {514
526
515
/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.527
/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.516
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {528
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {517
let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());529
let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len());518
runs.push(req.body);530
atlases.push(req.body);519
for run in req.equipment {531
atlases.extend(req.equipment.iter());520
runs.push(run);532
let keys: Vec<CompatKey> = atlases521
}533
.iter()522
assert_compatible(&runs)?;534
.map(|a| CompatKey::from_ppda(a.ppda()))523
535
.collect();524
let mut layers = Vec::with_capacity(runs.len());536
assert_compatible_keys(&keys)?;525
let body_set = req.body.render.set.as_str();537
let mut layers = Vec::with_capacity(atlases.len());526
match LayerMaps::from_export_run(538
for atlas in atlases {527
req.body,539
let set = atlas.ppda().settype.as_str();528
body_set,540
match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) {529
req.clip_token,530
req.frame,531
req.pitch,532
req.yaw,533
) {534
Ok(layer) => layers.push(layer),535
Err(err) => {536
return Err(Preview2dError::Missing {537
set: body_set.to_string(),538
clip: req.clip_token.to_string(),539
frame: req.frame,540
pitch: req.pitch,541
yaw: req.yaw,542
detail: err.to_string(),543
});544
}545
}546
for run in req.equipment {547
let set = run.render.set.as_str();548
match LayerMaps::from_export_run(run, set, req.clip_token, req.frame, req.pitch, req.yaw) {549
Ok(layer) => layers.push(layer),541
Ok(layer) => layers.push(layer),550
Err(err) => {542
Err(err) => {551
return Err(Preview2dError::Missing {543
return Err(Preview2dError::Missing {@@ -568,23 +560,23 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe568
}560
}569
}561
}570
562
571
/// Open body + selected equipment runs for one sex, validating compatibility.563
/// Run directories (body first, then equipment in the given order) that572
pub fn open_selection(564
/// [`open_selection`] would load; used as a cache key by the editor.565
pub fn selection_paths(573
catalog: &[CatalogEntry],566
catalog: &[CatalogEntry],574
sex: Sex,567
sex: Sex,575
equipment_sets: &[String],568
equipment_sets: &[String],576
) -> Result<(ExportRun, Vec<ExportRun>), Preview2dError> {569
) -> Result<Vec<PathBuf>, Preview2dError> {577
let sex_token = match sex {570
let sex_token = match sex {578
Sex::Male => "male",571
Sex::Male => "male",579
Sex::Female => "female",572
Sex::Female => "female",580
};573
};581
let body_entry = catalog574
let body = catalog582
.iter()575
.iter()583
.filter(|e| e.sex == sex_token && e.is_body())576
.filter(|e| e.sex == sex_token && e.is_body())584
.max_by_key(|e| e.path.clone())577
.max_by_key(|e| e.path.clone())585
.ok_or(Preview2dError::NoBody)?;578
.ok_or(Preview2dError::NoBody)?;586
let body = ExportRun::open(&body_entry.path)?;579
let mut paths = vec![body.path.clone()];587
let mut equipment = Vec::new();588
for set in equipment_sets {580
for set in equipment_sets {589
let entry = catalog581
let entry = catalog590
.iter()582
.iter()@@ -598,12 +590,30 @@ pub fn open_selection(598
yaw: 0,590
yaw: 0,599
detail: "geen voltooide exportset voor dit equipment".into(),591
detail: "geen voltooide exportset voor dit equipment".into(),600
})?;592
})?;601
equipment.push(ExportRun::open(&entry.path)?);593
paths.push(entry.path.clone());602
}594
}603
let mut refs: Vec<&ExportRun> = vec![&body];595
Ok(paths)604
refs.extend(equipment.iter());596
}605
assert_compatible(&refs)?;597
606
Ok((body, equipment))598
/// Open and decode the `.ppda` atlases of body + selected equipment for one599
/// sex, re-validating checksums and compatibility.600
pub fn open_selection(601
catalog: &[CatalogEntry],602
sex: Sex,603
equipment_sets: &[String],604
) -> Result<(LoadedAtlas, Vec<LoadedAtlas>), Preview2dError> {605
let paths = selection_paths(catalog, sex, equipment_sets)?;606
let mut loaded = Vec::with_capacity(paths.len());607
for path in &paths {608
loaded.push(AtlasSet::open_run(path)?.load()?);609
}610
let keys: Vec<CompatKey> = loaded611
.iter()612
.map(|a| CompatKey::from_ppda(a.ppda()))613
.collect();614
assert_compatible_keys(&keys)?;615
let body = loaded.remove(0);616
Ok((body, loaded))607
}617
}608
618
609
/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).619
/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).@@ -676,10 +686,8 @@ pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEnt676
mod tests {686
mod tests {677
use super::*;687
use super::*;678
use crate::digest;688
use crate::digest;679
use crate::export_set::complete_identity_json;689
use crate::export_fixture::{Pixel, SyntheticRun};680
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};681
use crate::project::CameraFraming;690
use crate::project::CameraFraming;682
use std::collections::BTreeMap as Map;683
use std::sync::atomic::{AtomicU64, Ordering};691
use std::sync::atomic::{AtomicU64, Ordering};684
692
685
static COUNTER: AtomicU64 = AtomicU64::new(0);693
static COUNTER: AtomicU64 = AtomicU64::new(0);@@ -715,164 +723,39 @@ mod tests {715
}723
}716
}724
}717
725
718
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {726
fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {719
let data = serde_json::to_vec_pretty(value).unwrap();727
Pixel {720
let path = root.join(name);728
diffuse,721
let temp = path.with_extension(format!("part-{}", std::process::id()));729
normal,722
fs::write(&temp, &data).unwrap();730
specular,723
fs::hard_link(&temp, &path).unwrap();731
depth,724
let _ = fs::remove_file(temp);725
}726
727
#[allow(clippy::too_many_arguments)]728
fn publish_pose(729
root: &Path,730
sex: &str,731
set: &str,732
clip: &str,733
frame: u32,734
pitch: i32,735
yaw: u32,736
diffuse: [u8; 4],737
normal: [u8; 4],738
specular: [u8; 4],739
depth_gray: u16,740
) -> Map<String, String> {741
let mut map = Map::new();742
let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");743
let rels = [744
(format!("{base}_diffuse.png"), "diffuse"),745
(format!("{base}_depth.png"), "depth"),746
(format!("{base}_normal.png"), "normal"),747
(format!("{base}_specular.png"), "specular"),748
];749
for (rel, kind) in rels {750
let path = Path::new(&rel);751
match kind {752
"depth" => {753
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();754
}755
"normal" => {756
write_rgba(757
root,758
path,759
&ImageBuf {760
width: 1,761
height: 1,762
rgba: normal.to_vec(),763
},764
)765
.unwrap();766
}767
"specular" => {768
write_rgba(769
root,770
path,771
&ImageBuf {772
width: 1,773
height: 1,774
rgba: specular.to_vec(),775
},776
)777
.unwrap();778
}779
_ => {780
write_rgba(781
root,782
path,783
&ImageBuf {784
width: 1,785
height: 1,786
rgba: diffuse.to_vec(),787
},788
)789
.unwrap();790
}791
}792
let bytes = fs::read(root.join(&rel)).unwrap();793
map.insert(rel, digest::sha256_hex(&bytes));794
}732
}795
map796
}733
}797
734
735
/// Complete synthetic run (all 60 views) with uniform maps per run.798
#[allow(clippy::too_many_arguments)]736
#[allow(clippy::too_many_arguments)]799
fn write_run(737
fn write_run(800
root: &Path,738
root: &Path,801
project_id: &str,739
project_id: &str,802
sex: &str,803
set: &str,740
set: &str,804
equipment_sha: Option<&str>,741
equipment_sha: Option<&str>,805
body_bytes: &[u8],742
body_bytes: &[u8],806
clips: &[(&str, &str, u32, f32)],743
clips: &[(&str, u32, f32)],807
checksums: Map<String, String>,744
px: Pixel,808
) {745
) {809
let body_hash = digest::sha256_hex(body_bytes);746
let mut spec = SyntheticRun::body(project_id, "male", body_bytes);810
let clips_json: Vec<_> = clips747
spec.framing = framing();748
spec.set = set.into();749
spec.equipment_sha256 = equipment_sha.map(str::to_string);750
spec.clips = clips811
.iter()751
.iter()812
.map(|(name, token, frames, duration)| {752
.map(|(n, f, d)| (n.to_string(), *f, *d))813
serde_json::json!({814
"name": name,815
"token": token,816
"frames": frames,817
"duration": duration818
})819
})820
.collect();753
.collect();821
let images: u32 = clips.iter().map(|(_, _, f, _)| *f).sum::<u32>() * 36;754
spec.write(root, |_, _, _, _| px).unwrap();822
let png_files = checksums.len() as u32;823
let mut render = serde_json::json!({824
"format_version": 3,825
"status": "incomplete",826
"project": {"id": project_id, "name": "Demo"},827
"sex": sex,828
"set": set,829
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},830
"equipment_preset": null,831
"target": [1, 1],832
"fps": 12,833
"highres_scale": 8,834
"framing": framing(),835
"pivot_source": {"kind": "manual"},836
"near": NEAR,837
"far": FAR,838
"camera_distance": 10.0,839
"yaw_zero": "camera on -Z",840
"normal_space": "view (+Y up), PNG rows top-down",841
"pitches": [-30, 0, 30],842
"yaw_step": 30,843
"sampling": "test",844
"alpha": "straight",845
"diffuse": "unlit",846
"depth": "16-bit",847
"specular": "scalar",848
"pass_order": PASSES,849
"saved_maps": ["diffuse", "depth", "normal", "specular"],850
"clips": clips_json,851
"images": images,852
"passes": images * 6,853
"png_files": png_files854
});855
if let Some(sha) = equipment_sha {856
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});857
}858
write_atomic_json(root, "render.json", &render);859
let identity = complete_identity_json(&render).unwrap();860
write_atomic_json(861
root,862
"complete.json",863
&serde_json::json!({864
"format_version": 3,865
"status": "complete",866
"identity": identity,867
"passes": images * 6,868
"png_files": png_files,869
"png_sha256": checksums870
}),871
);872
}755
}873
756
874
#[test]757
#[test]875
fn yaw_wraps_twelve_steps_pitch_clamps_three_rows() {758
fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() {876
let mut state = Preview2dState {759
let mut state = Preview2dState {877
elapsed_secs: 1.25,760
elapsed_secs: 1.25,878
..Preview2dState::default()761
..Preview2dState::default()@@ -889,17 +772,19 @@ mod tests {889
assert_eq!(state.yaw_deg(), 0);772
assert_eq!(state.yaw_deg(), 0);890
assert_eq!(PAPERDOLL_YAW_COUNT, 12);773
assert_eq!(PAPERDOLL_YAW_COUNT, 12);891
774
892
state.pitch_row = 1;775
state.pitch_row = ZERO_PITCH_ROW;893
state.step_pitch(1);776
state.step_pitch(1);894
assert_eq!(state.pitch_deg(), 30);777
assert_eq!(state.pitch_deg(), 30);895
state.step_pitch(1);778
state.step_pitch(1);896
assert_eq!(state.pitch_deg(), 30);779
assert_eq!(state.pitch_deg(), 60);897
state.step_pitch(-1);780
state.step_pitch(1);898
assert_eq!(state.pitch_deg(), 0);781
assert_eq!(state.pitch_deg(), 60);899
state.step_pitch(-1);782
state.step_pitch(-3);900
assert_eq!(state.pitch_deg(), -30);783
assert_eq!(state.pitch_deg(), -30);901
state.step_pitch(-1);784
state.step_pitch(-1);902
assert_eq!(state.pitch_deg(), -30);785
assert_eq!(state.pitch_deg(), -60);786
state.step_pitch(-1);787
assert_eq!(state.pitch_deg(), -60);903
assert_eq!(state.elapsed_secs, 1.25);788
assert_eq!(state.elapsed_secs, 1.25);904
}789
}905
790
@@ -975,64 +860,43 @@ mod tests {975
860
976
#[test]861
#[test]977
fn missing_frame_is_diagnostic_not_silent_substitute() {862
fn missing_frame_is_diagnostic_not_silent_substitute() {978
let body_tmp = Temp::new();863
let project = Temp::new();979
let clips = [("walk", "clip000-walk", 2u32, 0.1667f32)];864
let dir = project.0.join("renders/run-body");980
let mut checksums = Map::new();981
// Only frame 0 / pitch 0 / yaw 0 published — frame 1 missing.982
checksums.extend(publish_pose(983
&body_tmp.0,984
"male",985
"body",986
"clip000-walk",987
0,988
0,989
0,990
[10, 20, 30, 255],991
[128, 128, 255, 255],992
[0, 0, 0, 255],993
20000,994
));995
write_run(865
write_run(996
&body_tmp.0,866
&dir,997
"demo",867
"demo",998
"male",999
"body",868
"body",1000
None,869
None,1001
b"shared-body",870
b"shared-body",1002
&clips,871
&[("walk", 2, 0.5)],1003
checksums,872
pixel(873
[10, 20, 30, 255],874
[128, 128, 255, 255],875
[0, 0, 0, 255],876
20000,877
),1004
);878
);1005
let run = ExportRun::open(&body_tmp.0).unwrap();879
let catalog = discover_completed_runs(&project.0);1006
let ok = compose_preview_frame(ComposeRequest {880
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();1007
body: &run,881
let req = ComposeRequest {1008
equipment: &[],882
body: &body,1009
clip_token: "clip000-walk",1010
frame: 0,1011
pitch: 0,1012
yaw: 0,1013
light: Light::default(),1014
view_mode: ViewMode::Resultaat,1015
})1016
.unwrap();1017
assert_eq!(ok.width, 1);1018
let err = compose_preview_frame(ComposeRequest {1019
body: &run,1020
equipment: &[],883
equipment: &[],1021
clip_token: "clip000-walk",884
clip_token: "clip000-walk",1022
frame: 1,885
frame: 1,1023
pitch: 0,886
pitch: 60,1024
yaw: 0,887
yaw: 330,1025
light: Light::default(),888
light: Light::default(),1026
view_mode: ViewMode::Resultaat,889
view_mode: ViewMode::Resultaat,1027
})890
};1028
.unwrap_err();891
assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1);892
let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err();1029
match err {893
match err {1030
Preview2dError::Missing {894
Preview2dError::Missing {1031
set, clip, frame, ..895
set, clip, frame, ..1032
} => {896
} => {1033
assert_eq!(set, "body");897
assert_eq!(set, "body");1034
assert_eq!(clip, "clip000-walk");898
assert_eq!(clip, "clip000-walk");1035
assert_eq!(frame, 1);899
assert_eq!(frame, 2);1036
}900
}1037
other => panic!("expected Missing, got {other}"),901
other => panic!("expected Missing, got {other}"),1038
}902
}@@ -1042,98 +906,58 @@ mod tests {1042
fn catalog_compatibility_filters_equipment_by_export_contract() {906
fn catalog_compatibility_filters_equipment_by_export_contract() {1043
let project = Temp::new();907
let project = Temp::new();1044
let renders = project.0.join("renders");908
let renders = project.0.join("renders");1045
fs::create_dir_all(&renders).unwrap();909
let clips = [("bind", 1u32, 0.0f32)];1046
let body_dir = renders.join("run-body");1047
let good_dir = renders.join("run-sword");1048
let bad_dir = renders.join("run-bad");1049
fs::create_dir_all(&body_dir).unwrap();1050
fs::create_dir_all(&good_dir).unwrap();1051
fs::create_dir_all(&bad_dir).unwrap();1052
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];1053
let body_bytes = b"shared-body";910
let body_bytes = b"shared-body";1054
let mut body_maps = Map::new();1055
body_maps.extend(publish_pose(1056
&body_dir,1057
"male",1058
"body",1059
"clip000-bind",1060
0,1061
0,1062
0,1063
[1, 2, 3, 255],1064
[128, 128, 255, 255],1065
[0, 0, 0, 255],1066
1000,1067
));1068
write_run(911
write_run(1069
&body_dir, "proj-a", "male", "body", None, body_bytes, &clips, body_maps,912
&renders.join("run-body"),913
"proj-a",914
"body",915
None,916
body_bytes,917
&clips,918
pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),1070
);919
);1071
let mut good_maps = Map::new();1072
good_maps.extend(publish_pose(1073
&good_dir,1074
"male",1075
"sword",1076
"clip000-bind",1077
0,1078
0,1079
0,1080
[200, 10, 10, 255],1081
[255, 128, 128, 255],1082
[180, 180, 180, 255],1083
500,1084
));1085
write_run(920
write_run(1086
&good_dir,921
&renders.join("run-sword"),1087
"proj-a",922
"proj-a",1088
"male",1089
"sword",923
"sword",1090
Some(&digest::sha256_hex(b"sword")),924
Some(&digest::sha256_hex(b"sword")),1091
body_bytes,925
body_bytes,1092
&clips,926
&clips,1093
good_maps,927
pixel(928
[200, 10, 10, 255],929
[255, 128, 128, 255],930
[180, 180, 180, 255],931
500,932
),1094
);933
);1095
let mut bad_maps = Map::new();1096
bad_maps.extend(publish_pose(1097
&bad_dir,1098
"male",1099
"helm",1100
"clip000-bind",1101
0,1102
0,1103
0,1104
[9, 9, 9, 255],1105
[128, 128, 255, 255],1106
[0, 0, 0, 255],1107
800,1108
));1109
// Different body fingerprint → incompatible.934
// Different body fingerprint → incompatible.1110
write_run(935
write_run(1111
&bad_dir,936
&renders.join("run-bad"),1112
"proj-a",937
"proj-a",1113
"male",1114
"helm",938
"helm",1115
Some(&digest::sha256_hex(b"helm")),939
Some(&digest::sha256_hex(b"helm")),1116
b"other-body",940
b"other-body",1117
&clips,941
&clips,1118
bad_maps,942
pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800),1119
);943
);1120
944
1121
let catalog = discover_completed_runs(&project.0);945
let catalog = discover_completed_runs(&project.0);1122
assert_eq!(catalog.len(), 3);946
assert_eq!(catalog.len(), 3);1123
let allowed = compatible_equipment_sets(&catalog, Sex::Male);947
let allowed = compatible_equipment_sets(&catalog, Sex::Male);1124
assert_eq!(allowed, vec!["sword".to_string()]);948
assert_eq!(allowed, vec!["sword".to_string()]);1125
assert!(!allowed.iter().any(|s| s == "helm"));949
assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err());1126
950
1127
let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();951
let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();1128
assert_eq!(body.render.set, "body");952
assert_eq!(body.ppda().settype, "body");1129
assert_eq!(gear.len(), 1);953
assert_eq!(gear.len(), 1);1130
let lit = compose_preview_frame(ComposeRequest {954
let lit = compose_preview_frame(ComposeRequest {1131
body: &body,955
body: &body,1132
equipment: &gear,956
equipment: &gear,1133
clip_token: "clip000-bind",957
clip_token: "clip000-bind",1134
frame: 0,958
frame: 0,1135
pitch: 0,959
pitch: -60,1136
yaw: 0,960
yaw: 90,1137
light: Light::default(),961
light: Light::default(),1138
view_mode: ViewMode::Resultaat,962
view_mode: ViewMode::Resultaat,1139
})963
})@@ -1212,34 +1036,24 @@ mod tests {1212
1036
1213
#[test]1037
#[test]1214
fn view_modes_show_composited_maps_unlit_except_resultaat() {1038
fn view_modes_show_composited_maps_unlit_except_resultaat() {1215
let body_tmp = Temp::new();1039
let project = Temp::new();1216
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];1040
// Distinct channels so modes are distinguishable; scalar specular.1217
let mut checksums = Map::new();1218
// Distinct channels so modes are distinguishable.1219
checksums.extend(publish_pose(1220
&body_tmp.0,1221
"male",1222
"body",1223
"clip000-bind",1224
0,1225
0,1226
0,1227
[10, 20, 30, 255],1228
[40, 50, 60, 255],1229
[70, 70, 70, 255], // scalar specular (equal RGB)1230
32768, // mid gray depth1231
));1232
write_run(1041
write_run(1233
&body_tmp.0,1042
&project.0.join("renders/run-body"),1234
"demo",1043
"demo",1235
"male",1236
"body",1044
"body",1237
None,1045
None,1238
b"shared-body",1046
b"shared-body",1239
&clips,1047
&[("bind", 1, 0.0)],1240
checksums,1048
pixel(1049
[10, 20, 30, 255],1050
[40, 50, 60, 255],1051
[70, 70, 70, 255],1052
32768,1053
),1241
);1054
);1242
let run = ExportRun::open(&body_tmp.0).unwrap();1055
let catalog = discover_completed_runs(&project.0);1056
let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();1243
let base = ComposeRequest {1057
let base = ComposeRequest {1244
body: &run,1058
body: &run,1245
equipment: &[],1059
equipment: &[],@@ -1284,7 +1098,7 @@ mod tests {1284
1098
1285
let result = compose_preview_frame(ComposeRequest {1099
let result = compose_preview_frame(ComposeRequest {1286
view_mode: ViewMode::Resultaat,1100
view_mode: ViewMode::Resultaat,1287
..base1101
..base.clone()1288
})1102
})1289
.unwrap();1103
.unwrap();1290
// Relit differs from raw diffuse under default light.1104
// Relit differs from raw diffuse under default light.@@ -1299,14 +1113,9 @@ mod tests {1299
..Light::default()1113
..Light::default()1300
};1114
};1301
let diffuse_dark = compose_preview_frame(ComposeRequest {1115
let diffuse_dark = compose_preview_frame(ComposeRequest {1302
body: &run,1303
equipment: &[],1304
clip_token: "clip000-bind",1305
frame: 0,1306
pitch: 0,1307
yaw: 0,1308
light: dark,1116
light: dark,1309
view_mode: ViewMode::DiffuseOnly,1117
view_mode: ViewMode::DiffuseOnly,1118
..base1310
})1119
})1311
.unwrap();1120
.unwrap();1312
assert_eq!(diffuse_dark.rgba, diffuse.rgba);1121
assert_eq!(diffuse_dark.rgba, diffuse.rgba);@@ -1319,8 +1128,76 @@ mod tests {1319
let incomplete = renders.join("run-incomplete");1128
let incomplete = renders.join("run-incomplete");1320
fs::create_dir_all(&incomplete).unwrap();1129
fs::create_dir_all(&incomplete).unwrap();1321
fs::write(incomplete.join("render.json"), b"{}").unwrap();1130
fs::write(incomplete.join("render.json"), b"{}").unwrap();1322
// no complete.json1131
// no .ppda at all1323
let catalog = discover_completed_runs(&project.0);1132
let (catalog, skipped) = discover_exports(&project.0);1324
assert!(catalog.is_empty());1133
assert!(catalog.is_empty());1134
assert_eq!(skipped.len(), 1);1135
assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);1136
}1137
1138
#[test]1139
fn preview_reads_atlas_not_loose_pngs() {1140
// Each view gets a distinct colour; the preview must return exactly the1141
// atlas cell even after every loose PNG is deleted.1142
let project = Temp::new();1143
let dir = project.0.join("renders/run-body");1144
let mut spec = SyntheticRun::body("demo", "male", b"b");1145
spec.clips = vec![("walk".into(), 2, 0.5)];1146
spec.target = [2, 3];1147
spec.write(&dir, |_, f, p, y| {1148
pixel(1149
[(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255],1150
[128, 128, 255, 255],1151
[0, 0, 0, 255],1152
1000,1153
)1154
})1155
.unwrap();1156
fs::remove_dir_all(dir.join("male")).unwrap();1157
let catalog = discover_completed_runs(&project.0);1158
assert_eq!(catalog.len(), 1);1159
assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);1160
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();1161
for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] {1162
let img = compose_preview_frame(ComposeRequest {1163
body: &body,1164
equipment: &[],1165
clip_token: "clip000-walk",1166
frame,1167
pitch,1168
yaw,1169
light: Light::default(),1170
view_mode: ViewMode::DiffuseOnly,1171
})1172
.unwrap();1173
assert_eq!((img.width, img.height), (2, 3));1174
let want = [1175
(yaw / 30) as u8 * 20,1176
(pitch + 60) as u8,1177
frame as u8 * 100 + 1,1178
255,1179
];1180
for px in img.rgba.chunks_exact(4) {1181
assert_eq!(px, want);1182
}1183
}1184
}1185
1186
#[test]1187
fn five_pitch_rows_clamp_and_default_is_zero() {1188
let mut state = Preview2dState::default();1189
assert_eq!(state.pitch_deg(), 0);1190
state.step_pitch(1);1191
state.step_pitch(1);1192
assert_eq!(state.pitch_deg(), 60);1193
state.step_pitch(1);1194
assert_eq!(state.pitch_deg(), 60);1195
for _ in 0..10 {1196
state.step_pitch(-1);1197
}1198
assert_eq!(state.pitch_deg(), -60);1199
assert_eq!(pitch_row_from_deg(30), 3);1200
assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);1201
assert_eq!(state.fps, 4);1325
}1202
}1326
}1203
}@@ -137,7 +137,7 @@ impl Viewport {
137
137
));
138
138
});
139
139
ui.label(
140
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",
140
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de vijf pitches en de twaalf yaws.",
141
141
);
142
142
let available = ui.available_rect_before_wrap();
143
143
let rect = egui::Rect::from_min_size(
@@ -137,7 +137,7 @@ impl Viewport {137
));137
));138
});138
});139
ui.label(139
ui.label(140
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de drie pitches en de twaalf yaws.",140
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (−Z). De knoppen zetten de vijf pitches en de twaalf yaws.",141
);141
);142
let available = ui.available_rect_before_wrap();142
let available = ui.available_rect_before_wrap();143
let rect = egui::Rect::from_min_size(143
let rect = egui::Rect::from_min_size(@@ -146,7 +146,9 @@ pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
146
146
.iter()
147
147
.try_fold(0u32, |n, c| n.checked_add(c.frames))
148
148
.ok_or("te veel frames")?;
149
let images = frames.checked_mul(36).ok_or("te veel beelden")?;
149
let images = frames
150
.checked_mul(preview::PRODUCTION_VIEWS)
151
.ok_or("te veel beelden")?;
150
152
let passes = images.checked_mul(6).ok_or("te veel passes")?;
151
153
let files = images.checked_mul(4).ok_or("te veel bestanden")?;
152
154
if files > 500000 {
@@ -169,18 +171,25 @@ pub struct Sample {
169
171
pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
170
172
plan.clips.iter().enumerate().flat_map(|(clip, c)| {
171
173
(0..c.frames).flat_map(move |frame| {
172
[-30, 0, 30].into_iter().flat_map(move |pitch| {
173
(0..12).map(move |y| Sample {
174
clip,
175
frame,
176
pitch,
177
yaw: y * 30,
174
preview::PRODUCTION_PITCHES
175
.into_iter()
176
.flat_map(move |pitch| {
177
(0..12).map(move |y| Sample {
178
clip,
179
frame,
180
pitch,
181
yaw: y * 30,
182
})
178
183
})
179
})
180
184
})
181
185
})
182
186
}
183
187
188
/// Production yaw values in degrees, atlas column order within one pitch.
189
pub fn production_yaws() -> [u32; 12] {
190
std::array::from_fn(|i| i as u32 * 30)
191
}
192
184
193
pub fn clip_token(index: usize, name: &str) -> String {
185
194
let slug: String = name
186
195
.chars()
@@ -210,7 +219,7 @@ pub fn output_path(
210
219
if !["male", "female"].contains(&sex)
211
220
|| !(set == "body" || equipment::valid_name(set))
212
221
|| !equipment::valid_name(clip)
213
|| ![-30, 0, 30].contains(&pitch)
222
|| !preview::PRODUCTION_PITCHES.contains(&pitch)
214
223
|| yaw >= 360
215
224
|| !yaw.is_multiple_of(30)
216
225
|| !["diffuse", "normal", "depth", "specular"].contains(&map)
@@ -1046,7 +1055,7 @@ fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1046
1055
}
1047
1056
Ok(path)
1048
1057
}
1049
fn atomic_file(
1058
pub(crate) fn atomic_file(
1050
1059
root: &Path,
1051
1060
relative: &Path,
1052
1061
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
@@ -1160,7 +1169,7 @@ pub fn write_depth(
1160
1169
}
1161
1170
1162
1171
/// Atomic no-clobber publication of already encoded bytes under the run root.
1163
pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1172
pub fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1164
1173
atomic_file(root, relative, |file| {
1165
1174
use std::io::Write;
1166
1175
file.write_all(bytes).map_err(|e| e.to_string())
@@ -1337,6 +1346,9 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1337
1346
.map(|p| p.name.as_str())
1338
1347
.unwrap_or("body")
1339
1348
.to_string();
1349
// Refuse up front when the per-map atlas would exceed the PNG/GPU limit,
1350
// instead of rendering for hours and failing (or splitting) at the end.
1351
crate::atlas::AtlasLayout::for_plan(&plan, job.target_width, job.target_height)?;
1340
1352
if job.project_id.is_empty() || job.project_id.len() > 128 {
1341
1353
return Err("project.id ongeldig voor export-identiteit".into());
1342
1354
}
@@ -1389,7 +1401,8 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1389
1401
"normal_space": "view (+Y up), PNG rows top-down",
1390
1402
"camera_distance": 10,
1391
1403
"yaw_zero": "camera on -Z",
1392
"pitches": [-30, 0, 30],
1404
"pitches": preview::PRODUCTION_PITCHES,
1405
"yaws": production_yaws(),
1393
1406
"yaw_step": 30,
1394
1407
"sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained",
1395
1408
"alpha": "straight, opaque/cutout, coverage resolve",
@@ -1641,17 +1654,39 @@ fn execute_target(
1641
1654
});
1642
1655
}
1643
1656
}
1644
Ok(())
1657
if png_sha256.len() as u32 != plan.files {
1658
return Err(format!(
1659
"checksumtelling {} wijkt af van geplande {} PNGs",
1660
png_sha256.len(),
1661
plan.files
1662
));
1663
}
1664
// Atlases + .ppda only after every loose map is published and verified
1665
// (also after a resume); complete.json stays the final marker.
1666
crate::atlas::write_atlases(
1667
&crate::atlas::AtlasJob {
1668
root: &root,
1669
render_doc: &render_doc,
1670
plan: &plan,
1671
sex: &job.sex,
1672
set: &set,
1673
cell_width: job.target_width,
1674
cell_height: job.target_height,
1675
png_sha256: &png_sha256,
1676
attempt: session.attempt,
1677
cancel,
1678
},
1679
&mut |label| {
1680
emit(RenderEvent::Step {
1681
index: completed,
1682
total: plan.passes,
1683
label,
1684
})
1685
},
1686
)
1645
1687
})();
1646
1688
match result {
1647
Ok(()) => {
1648
if png_sha256.len() as u32 != plan.files {
1649
return Err(format!(
1650
"checksumtelling {} wijkt af van geplande {} PNGs",
1651
png_sha256.len(),
1652
plan.files
1653
));
1654
}
1689
Ok(atlas) => {
1655
1690
let identity = crate::export_set::complete_identity_json(&render_doc)?;
1656
1691
write_json(
1657
1692
&root,
@@ -1663,6 +1698,7 @@ fn execute_target(
1663
1698
"passes": completed,
1664
1699
"png_files": plan.files,
1665
1700
"png_sha256": png_sha256,
1701
"atlas": atlas.to_json(),
1666
1702
}),
1667
1703
)?;
1668
1704
emit(RenderEvent::Finished {
@@ -1868,23 +1904,26 @@ mod tests {
1868
1904
c.duration = 0.5;
1869
1905
b.clips = vec![c.clone(), c];
1870
1906
let p = plan(&b, 3).unwrap();
1871
assert_eq!((p.images, p.passes, p.files), (144, 864, 576));
1907
// 2 clips × 2 frames × 60 views.
1908
assert_eq!((p.images, p.passes, p.files), (240, 1440, 960));
1872
1909
let s = samples(&p).collect::<Vec<_>>();
1873
assert_eq!(s.len(), 144);
1910
assert_eq!(s.len(), 240);
1874
1911
assert_eq!(
1875
1912
s[0],
1876
1913
Sample {
1877
1914
clip: 0,
1878
1915
frame: 0,
1879
pitch: -30,
1916
pitch: -60,
1880
1917
yaw: 0
1881
1918
}
1882
1919
);
1883
1920
assert_eq!(s[11].yaw, 330);
1884
assert_eq!(s[12].pitch, 0);
1885
assert_eq!(s[35].pitch, 30);
1886
assert_eq!(s[36].frame, 1);
1887
assert_eq!(s[72].clip, 1);
1921
assert_eq!(s[12].pitch, -30);
1922
assert_eq!(s[24].pitch, 0);
1923
assert_eq!(s[36].pitch, 30);
1924
assert_eq!(s[59].pitch, 60);
1925
assert_eq!(s[60].frame, 1);
1926
assert_eq!(s[120].clip, 1);
1888
1927
assert_ne!(p.clips[0].token, p.clips[1].token);
1889
1928
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
1890
1929
assert_eq!(
@@ -1900,7 +1939,7 @@ mod tests {
1900
1939
);
1901
1940
b.clips.clear();
1902
1941
let p = plan(&b, 12).unwrap();
1903
assert_eq!((p.images, p.files), (36, 144));
1942
assert_eq!((p.images, p.files), (60, 240));
1904
1943
}
1905
1944
#[test]
1906
1945
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
@@ -1923,11 +1962,14 @@ mod tests {
1923
1962
}
1924
1963
#[test]
1925
1964
fn output_tokens_only_four_target_maps() {
1926
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");
1965
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
1927
1966
assert_eq!(
1928
output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),
1967
output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
1929
1968
expected
1930
1969
);
1970
assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok());
1971
assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err());
1972
assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err());
1931
1973
for map in ["diffuse", "normal", "depth", "specular"] {
1932
1974
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
1933
1975
}
@@ -2280,7 +2322,10 @@ mod tests {
2280
2322
let meta: serde_json::Value =
2281
2323
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2282
2324
.unwrap();
2283
assert_eq!(meta["format_version"], 3);
2325
assert_eq!(meta["format_version"], 4);
2326
assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2327
assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2328
assert_eq!(meta["fps"], 12);
2284
2329
assert_eq!(meta["project"]["id"], "test-project");
2285
2330
assert_eq!(
2286
2331
meta["body_model"]["sha256"],
@@ -2303,11 +2348,11 @@ mod tests {
2303
2348
&std::fs::read_to_string(run.join("complete.json")).unwrap(),
2304
2349
)
2305
2350
.unwrap();
2306
assert_eq!(complete["format_version"], 3);
2351
assert_eq!(complete["format_version"], 4);
2307
2352
assert_eq!(complete["status"], "complete");
2308
assert_eq!(complete["png_files"], 144);
2353
assert_eq!(complete["png_files"], 240);
2309
2354
let checksums = complete["png_sha256"].as_object().unwrap();
2310
assert_eq!(checksums.len(), 144);
2355
assert_eq!(checksums.len(), 240);
2311
2356
for (rel, digest) in checksums {
2312
2357
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2313
2358
let bytes = std::fs::read(run.join(rel)).unwrap();
@@ -2318,20 +2363,44 @@ mod tests {
2318
2363
.load_map(
2319
2364
"clip000-bind",
2320
2365
0,
2321
-30,
2366
-60,
2322
2367
0,
2323
2368
crate::export_set::MapKind::Diffuse,
2324
2369
)
2325
2370
.unwrap();
2326
2371
assert_eq!(sample.width(), 1);
2372
// Atlases + .ppda: one per map, written before complete.json.
2373
for map in ["diffuse", "normal", "depth", "specular"] {
2374
let name = format!("male-body-{map}-atlas.png");
2375
assert!(run.join(&name).is_file(), "{name}");
2376
assert_eq!(complete["atlas"]["maps"][map]["file"], name);
2377
}
2378
assert!(!stop);
2379
let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
2380
assert_eq!(atlas.ppda.fps, 12);
2381
assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1));
2382
let loaded = atlas.load().unwrap();
2383
for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] {
2384
for kind in crate::atlas::ATLAS_MAPS {
2385
assert_eq!(
2386
loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(),
2387
opened
2388
.load_map("clip000-bind", 0, pitch, yaw, kind)
2389
.unwrap()
2390
);
2391
}
2392
}
2393
} else {
2394
assert!(!run.join("male-body-atlas.ppda").exists());
2395
assert!(!run.join("male-body-diffuse-atlas.png").exists());
2327
2396
}
2328
2397
let frame = run.join("male/body/clip000-bind/f000000");
2329
assert!(frame.join("p-30_y000_diffuse.png").exists());
2330
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
2331
assert!(!frame.join("p-30_y000_highres.png").exists());
2332
assert!(!frame.join("p-30_y000_detail.png").exists());
2398
assert!(frame.join("p-60_y000_diffuse.png").exists());
2399
assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2400
assert!(!frame.join("p-60_y000_highres.png").exists());
2401
assert!(!frame.join("p-60_y000_detail.png").exists());
2333
2402
if !stop {
2334
assert_eq!(labels.len(), 216);
2403
assert_eq!(labels.len(), 360);
2335
2404
for labels in labels.chunks_exact(6) {
2336
2405
for (label, pass) in labels.iter().zip([
2337
2406
"highres",
@@ -2455,7 +2524,7 @@ mod tests {
2455
2524
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);
2456
2525
ok.unwrap();
2457
2526
let reference = checksums(&runs(&reference_dir.0)[0]);
2458
assert_eq!(reference.as_object().unwrap().len(), 144);
2527
assert_eq!(reference.as_object().unwrap().len(), 240);
2459
2528
2460
2529
let tmp = Temp::new();
2461
2530
// An earlier completed run in the same output must stay byte-identical.
@@ -2469,9 +2538,9 @@ mod tests {
2469
2538
let cancelled: serde_json::Value =
2470
2539
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2471
2540
let done_first = cancelled["png_files_done"].as_u64().unwrap();
2472
assert!(done_first > 0 && done_first < 144);
2541
assert!(done_first > 0 && done_first < 240);
2473
2542
assert_eq!(cancelled["attempt"], 0);
2474
let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png");
2543
let first_file = run.join("male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2475
2544
let first_inode = inode(&first_file);
2476
2545
assert!(crate::export_set::ExportRun::open(&run).is_err());
2477
2546
@@ -2552,11 +2621,11 @@ mod tests {
2552
2621
let torn_inode = inode(&torn_path);
2553
2622
// Corrupt one journaled PNG, delete another, leave a foreign temp file.
2554
2623
let frame = run.join("male/body/clip000-bind/f000000");
2555
let corrupt = frame.join("p-30_y030_normal.png");
2624
let corrupt = frame.join("p-60_y030_normal.png");
2556
2625
std::fs::write(&corrupt, b"not a png").unwrap();
2557
let missing = frame.join("p-30_y060_depth.png");
2626
let missing = frame.join("p-60_y060_depth.png");
2558
2627
std::fs::remove_file(&missing).unwrap();
2559
let stale = frame.join("p-30_y090_diffuse.part-999999-0");
2628
let stale = frame.join("p-60_y090_diffuse.part-999999-0");
2560
2629
std::fs::write(&stale, b"partial").unwrap();
2561
2630
2562
2631
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
@@ -2570,7 +2639,7 @@ mod tests {
2570
2639
crate::export_set::ExportRun::open(&run).unwrap();
2571
2640
assert!(missing.exists());
2572
2641
assert_eq!(
2573
std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(),
2642
std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(),
2574
2643
b"not a png"
2575
2644
);
2576
2645
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");
@@ -2590,10 +2659,71 @@ mod tests {
2590
2659
assert_eq!(log["previous_marker"], serde_json::Value::Null);
2591
2660
assert_eq!(log["damaged_journal_lines"], 1);
2592
2661
assert_eq!(log["unjournaled_existing"], 1);
2593
assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1);
2662
assert_eq!(log["missing"].as_u64().unwrap(), 240 - 60 / 6 * 4 + 1);
2594
2663
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2595
2664
}
2596
2665
2666
#[test]
2667
fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() {
2668
let tmp = Temp::new();
2669
let mut job = resume_job(&tmp.0);
2670
job.character = body();
2671
let total: f32 = job.character.clips.iter().map(|c| c.duration).sum();
2672
assert!(total > 0.);
2673
// ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap.
2674
job.fps = (100. / total).ceil() as u32;
2675
job.target_width = 256;
2676
job.target_height = 256;
2677
let err = contract(job).err().unwrap();
2678
assert!(err.contains("atlas") && err.contains("16384"), "{err}");
2679
assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
2680
}
2681
2682
#[test]
2683
#[cfg(unix)]
2684
fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
2685
let reference_dir = Temp::new();
2686
drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
2687
.0
2688
.unwrap();
2689
let reference_run = runs(&reference_dir.0)[0].clone();
2690
let tmp = Temp::new();
2691
drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2692
let run = runs(&tmp.0)[0].clone();
2693
// Simulate a crash after the atlases/.ppda were published but before
2694
// complete.json, with one atlas damaged afterwards.
2695
std::fs::remove_file(run.join("complete.json")).unwrap();
2696
let diffuse = run.join("male-body-diffuse-atlas.png");
2697
let normal = run.join("male-body-normal-atlas.png");
2698
let normal_inode = inode(&normal);
2699
let good = std::fs::read(&diffuse).unwrap();
2700
std::fs::remove_file(&diffuse).unwrap();
2701
std::fs::write(&diffuse, b"damaged").unwrap();
2702
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2703
.0
2704
.unwrap();
2705
assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
2706
assert_eq!(std::fs::read(&diffuse).unwrap(), good);
2707
assert_eq!(
2708
std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(),
2709
b"damaged"
2710
);
2711
crate::atlas::AtlasSet::open_run(&run).unwrap();
2712
crate::export_set::ExportRun::open(&run).unwrap();
2713
// Byte-identical to an uninterrupted run (deterministic encoding).
2714
for name in [
2715
"male-body-atlas.ppda",
2716
"male-body-diffuse-atlas.png",
2717
"male-body-depth-atlas.png",
2718
] {
2719
assert_eq!(
2720
std::fs::read(run.join(name)).unwrap(),
2721
std::fs::read(reference_run.join(name)).unwrap(),
2722
"{name}"
2723
);
2724
}
2725
}
2726
2597
2727
#[test]
2598
2728
fn unjournaled_different_file_is_quarantined_not_overwritten() {
2599
2729
let tmp = Temp::new();
@@ -2602,7 +2732,7 @@ mod tests {
2602
2732
.is_err());
2603
2733
let run = runs(&tmp.0)[0].clone();
2604
2734
// A planned PNG appears that the journal never recorded and that differs.
2605
let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png");
2735
let foreign = run.join("male/body/clip000-bind/f000000/p-60_y030_specular.png");
2606
2736
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
2607
2737
std::fs::write(&foreign, b"foreign").unwrap();
2608
2738
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
@@ -2610,7 +2740,7 @@ mod tests {
2610
2740
.unwrap();
2611
2741
crate::export_set::ExportRun::open(&run).unwrap();
2612
2742
assert_eq!(
2613
std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(),
2743
std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
2614
2744
b"foreign"
2615
2745
);
2616
2746
}
@@ -146,7 +146,9 @@ pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {146
.iter()146
.iter()147
.try_fold(0u32, |n, c| n.checked_add(c.frames))147
.try_fold(0u32, |n, c| n.checked_add(c.frames))148
.ok_or("te veel frames")?;148
.ok_or("te veel frames")?;149
let images = frames.checked_mul(36).ok_or("te veel beelden")?;149
let images = frames150
.checked_mul(preview::PRODUCTION_VIEWS)151
.ok_or("te veel beelden")?;150
let passes = images.checked_mul(6).ok_or("te veel passes")?;152
let passes = images.checked_mul(6).ok_or("te veel passes")?;151
let files = images.checked_mul(4).ok_or("te veel bestanden")?;153
let files = images.checked_mul(4).ok_or("te veel bestanden")?;152
if files > 500000 {154
if files > 500000 {@@ -169,18 +171,25 @@ pub struct Sample {169
pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {171
pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {170
plan.clips.iter().enumerate().flat_map(|(clip, c)| {172
plan.clips.iter().enumerate().flat_map(|(clip, c)| {171
(0..c.frames).flat_map(move |frame| {173
(0..c.frames).flat_map(move |frame| {172
[-30, 0, 30].into_iter().flat_map(move |pitch| {174
preview::PRODUCTION_PITCHES173
(0..12).map(move |y| Sample {175
.into_iter()174
clip,176
.flat_map(move |pitch| {175
frame,177
(0..12).map(move |y| Sample {176
pitch,178
clip,177
yaw: y * 30,179
frame,180
pitch,181
yaw: y * 30,182
})178
})183
})179
})180
})184
})181
})185
})182
}186
}183
187
188
/// Production yaw values in degrees, atlas column order within one pitch.189
pub fn production_yaws() -> [u32; 12] {190
std::array::from_fn(|i| i as u32 * 30)191
}192
184
pub fn clip_token(index: usize, name: &str) -> String {193
pub fn clip_token(index: usize, name: &str) -> String {185
let slug: String = name194
let slug: String = name186
.chars()195
.chars()@@ -210,7 +219,7 @@ pub fn output_path(210
if !["male", "female"].contains(&sex)219
if !["male", "female"].contains(&sex)211
|| !(set == "body" || equipment::valid_name(set))220
|| !(set == "body" || equipment::valid_name(set))212
|| !equipment::valid_name(clip)221
|| !equipment::valid_name(clip)213
|| ![-30, 0, 30].contains(&pitch)222
|| !preview::PRODUCTION_PITCHES.contains(&pitch)214
|| yaw >= 360223
|| yaw >= 360215
|| !yaw.is_multiple_of(30)224
|| !yaw.is_multiple_of(30)216
|| !["diffuse", "normal", "depth", "specular"].contains(&map)225
|| !["diffuse", "normal", "depth", "specular"].contains(&map)@@ -1046,7 +1055,7 @@ fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {1046
}1055
}1047
Ok(path)1056
Ok(path)1048
}1057
}1049
fn atomic_file(1058
pub(crate) fn atomic_file(1050
root: &Path,1059
root: &Path,1051
relative: &Path,1060
relative: &Path,1052
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,1061
write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,@@ -1160,7 +1169,7 @@ pub fn write_depth(1160
}1169
}1161
1170
1162
/// Atomic no-clobber publication of already encoded bytes under the run root.1171
/// Atomic no-clobber publication of already encoded bytes under the run root.1163
pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {1172
pub fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {1164
atomic_file(root, relative, |file| {1173
atomic_file(root, relative, |file| {1165
use std::io::Write;1174
use std::io::Write;1166
file.write_all(bytes).map_err(|e| e.to_string())1175
file.write_all(bytes).map_err(|e| e.to_string())@@ -1337,6 +1346,9 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1337
.map(|p| p.name.as_str())1346
.map(|p| p.name.as_str())1338
.unwrap_or("body")1347
.unwrap_or("body")1339
.to_string();1348
.to_string();1349
// Refuse up front when the per-map atlas would exceed the PNG/GPU limit,1350
// instead of rendering for hours and failing (or splitting) at the end.1351
crate::atlas::AtlasLayout::for_plan(&plan, job.target_width, job.target_height)?;1340
if job.project_id.is_empty() || job.project_id.len() > 128 {1352
if job.project_id.is_empty() || job.project_id.len() > 128 {1341
return Err("project.id ongeldig voor export-identiteit".into());1353
return Err("project.id ongeldig voor export-identiteit".into());1342
}1354
}@@ -1389,7 +1401,8 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1389
"normal_space": "view (+Y up), PNG rows top-down",1401
"normal_space": "view (+Y up), PNG rows top-down",1390
"camera_distance": 10,1402
"camera_distance": 10,1391
"yaw_zero": "camera on -Z",1403
"yaw_zero": "camera on -Z",1392
"pitches": [-30, 0, 30],1404
"pitches": preview::PRODUCTION_PITCHES,1405
"yaws": production_yaws(),1393
"yaw_step": 30,1406
"yaw_step": 30,1394
"sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained",1407
"sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained",1395
"alpha": "straight, opaque/cutout, coverage resolve",1408
"alpha": "straight, opaque/cutout, coverage resolve",@@ -1641,17 +1654,39 @@ fn execute_target(1641
});1654
});1642
}1655
}1643
}1656
}1644
Ok(())1657
if png_sha256.len() as u32 != plan.files {1658
return Err(format!(1659
"checksumtelling {} wijkt af van geplande {} PNGs",1660
png_sha256.len(),1661
plan.files1662
));1663
}1664
// Atlases + .ppda only after every loose map is published and verified1665
// (also after a resume); complete.json stays the final marker.1666
crate::atlas::write_atlases(1667
&crate::atlas::AtlasJob {1668
root: &root,1669
render_doc: &render_doc,1670
plan: &plan,1671
sex: &job.sex,1672
set: &set,1673
cell_width: job.target_width,1674
cell_height: job.target_height,1675
png_sha256: &png_sha256,1676
attempt: session.attempt,1677
cancel,1678
},1679
&mut |label| {1680
emit(RenderEvent::Step {1681
index: completed,1682
total: plan.passes,1683
label,1684
})1685
},1686
)1645
})();1687
})();1646
match result {1688
match result {1647
Ok(()) => {1689
Ok(atlas) => {1648
if png_sha256.len() as u32 != plan.files {1649
return Err(format!(1650
"checksumtelling {} wijkt af van geplande {} PNGs",1651
png_sha256.len(),1652
plan.files1653
));1654
}1655
let identity = crate::export_set::complete_identity_json(&render_doc)?;1690
let identity = crate::export_set::complete_identity_json(&render_doc)?;1656
write_json(1691
write_json(1657
&root,1692
&root,@@ -1663,6 +1698,7 @@ fn execute_target(1663
"passes": completed,1698
"passes": completed,1664
"png_files": plan.files,1699
"png_files": plan.files,1665
"png_sha256": png_sha256,1700
"png_sha256": png_sha256,1701
"atlas": atlas.to_json(),1666
}),1702
}),1667
)?;1703
)?;1668
emit(RenderEvent::Finished {1704
emit(RenderEvent::Finished {@@ -1868,23 +1904,26 @@ mod tests {1868
c.duration = 0.5;1904
c.duration = 0.5;1869
b.clips = vec![c.clone(), c];1905
b.clips = vec![c.clone(), c];1870
let p = plan(&b, 3).unwrap();1906
let p = plan(&b, 3).unwrap();1871
assert_eq!((p.images, p.passes, p.files), (144, 864, 576));1907
// 2 clips × 2 frames × 60 views.1908
assert_eq!((p.images, p.passes, p.files), (240, 1440, 960));1872
let s = samples(&p).collect::<Vec<_>>();1909
let s = samples(&p).collect::<Vec<_>>();1873
assert_eq!(s.len(), 144);1910
assert_eq!(s.len(), 240);1874
assert_eq!(1911
assert_eq!(1875
s[0],1912
s[0],1876
Sample {1913
Sample {1877
clip: 0,1914
clip: 0,1878
frame: 0,1915
frame: 0,1879
pitch: -30,1916
pitch: -60,1880
yaw: 01917
yaw: 01881
}1918
}1882
);1919
);1883
assert_eq!(s[11].yaw, 330);1920
assert_eq!(s[11].yaw, 330);1884
assert_eq!(s[12].pitch, 0);1921
assert_eq!(s[12].pitch, -30);1885
assert_eq!(s[35].pitch, 30);1922
assert_eq!(s[24].pitch, 0);1886
assert_eq!(s[36].frame, 1);1923
assert_eq!(s[36].pitch, 30);1887
assert_eq!(s[72].clip, 1);1924
assert_eq!(s[59].pitch, 60);1925
assert_eq!(s[60].frame, 1);1926
assert_eq!(s[120].clip, 1);1888
assert_ne!(p.clips[0].token, p.clips[1].token);1927
assert_ne!(p.clips[0].token, p.clips[1].token);1889
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));1928
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));1890
assert_eq!(1929
assert_eq!(@@ -1900,7 +1939,7 @@ mod tests {1900
);1939
);1901
b.clips.clear();1940
b.clips.clear();1902
let p = plan(&b, 12).unwrap();1941
let p = plan(&b, 12).unwrap();1903
assert_eq!((p.images, p.files), (36, 144));1942
assert_eq!((p.images, p.files), (60, 240));1904
}1943
}1905
#[test]1944
#[test]1906
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {1945
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {@@ -1923,11 +1962,14 @@ mod tests {1923
}1962
}1924
#[test]1963
#[test]1925
fn output_tokens_only_four_target_maps() {1964
fn output_tokens_only_four_target_maps() {1926
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");1965
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-60_y330_diffuse.png");1927
assert_eq!(1966
assert_eq!(1928
output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),1967
output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),1929
expected1968
expected1930
);1969
);1970
assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok());1971
assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err());1972
assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err());1931
for map in ["diffuse", "normal", "depth", "specular"] {1973
for map in ["diffuse", "normal", "depth", "specular"] {1932
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());1974
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());1933
}1975
}@@ -2280,7 +2322,10 @@ mod tests {2280
let meta: serde_json::Value =2322
let meta: serde_json::Value =2281
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())2323
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())2282
.unwrap();2324
.unwrap();2283
assert_eq!(meta["format_version"], 3);2325
assert_eq!(meta["format_version"], 4);2326
assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));2327
assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);2328
assert_eq!(meta["fps"], 12);2284
assert_eq!(meta["project"]["id"], "test-project");2329
assert_eq!(meta["project"]["id"], "test-project");2285
assert_eq!(2330
assert_eq!(2286
meta["body_model"]["sha256"],2331
meta["body_model"]["sha256"],@@ -2303,11 +2348,11 @@ mod tests {2303
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2348
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2304
)2349
)2305
.unwrap();2350
.unwrap();2306
assert_eq!(complete["format_version"], 3);2351
assert_eq!(complete["format_version"], 4);2307
assert_eq!(complete["status"], "complete");2352
assert_eq!(complete["status"], "complete");2308
assert_eq!(complete["png_files"], 144);2353
assert_eq!(complete["png_files"], 240);2309
let checksums = complete["png_sha256"].as_object().unwrap();2354
let checksums = complete["png_sha256"].as_object().unwrap();2310
assert_eq!(checksums.len(), 144);2355
assert_eq!(checksums.len(), 240);2311
for (rel, digest) in checksums {2356
for (rel, digest) in checksums {2312
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));2357
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));2313
let bytes = std::fs::read(run.join(rel)).unwrap();2358
let bytes = std::fs::read(run.join(rel)).unwrap();@@ -2318,20 +2363,44 @@ mod tests {2318
.load_map(2363
.load_map(2319
"clip000-bind",2364
"clip000-bind",2320
0,2365
0,2321
-30,2366
-60,2322
0,2367
0,2323
crate::export_set::MapKind::Diffuse,2368
crate::export_set::MapKind::Diffuse,2324
)2369
)2325
.unwrap();2370
.unwrap();2326
assert_eq!(sample.width(), 1);2371
assert_eq!(sample.width(), 1);2372
// Atlases + .ppda: one per map, written before complete.json.2373
for map in ["diffuse", "normal", "depth", "specular"] {2374
let name = format!("male-body-{map}-atlas.png");2375
assert!(run.join(&name).is_file(), "{name}");2376
assert_eq!(complete["atlas"]["maps"][map]["file"], name);2377
}2378
assert!(!stop);2379
let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();2380
assert_eq!(atlas.ppda.fps, 12);2381
assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1));2382
let loaded = atlas.load().unwrap();2383
for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] {2384
for kind in crate::atlas::ATLAS_MAPS {2385
assert_eq!(2386
loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(),2387
opened2388
.load_map("clip000-bind", 0, pitch, yaw, kind)2389
.unwrap()2390
);2391
}2392
}2393
} else {2394
assert!(!run.join("male-body-atlas.ppda").exists());2395
assert!(!run.join("male-body-diffuse-atlas.png").exists());2327
}2396
}2328
let frame = run.join("male/body/clip000-bind/f000000");2397
let frame = run.join("male/body/clip000-bind/f000000");2329
assert!(frame.join("p-30_y000_diffuse.png").exists());2398
assert!(frame.join("p-60_y000_diffuse.png").exists());2330
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);2399
assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);2331
assert!(!frame.join("p-30_y000_highres.png").exists());2400
assert!(!frame.join("p-60_y000_highres.png").exists());2332
assert!(!frame.join("p-30_y000_detail.png").exists());2401
assert!(!frame.join("p-60_y000_detail.png").exists());2333
if !stop {2402
if !stop {2334
assert_eq!(labels.len(), 216);2403
assert_eq!(labels.len(), 360);2335
for labels in labels.chunks_exact(6) {2404
for labels in labels.chunks_exact(6) {2336
for (label, pass) in labels.iter().zip([2405
for (label, pass) in labels.iter().zip([2337
"highres",2406
"highres",@@ -2455,7 +2524,7 @@ mod tests {2455
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);2524
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);2456
ok.unwrap();2525
ok.unwrap();2457
let reference = checksums(&runs(&reference_dir.0)[0]);2526
let reference = checksums(&runs(&reference_dir.0)[0]);2458
assert_eq!(reference.as_object().unwrap().len(), 144);2527
assert_eq!(reference.as_object().unwrap().len(), 240);2459
2528
2460
let tmp = Temp::new();2529
let tmp = Temp::new();2461
// An earlier completed run in the same output must stay byte-identical.2530
// An earlier completed run in the same output must stay byte-identical.@@ -2469,9 +2538,9 @@ mod tests {2469
let cancelled: serde_json::Value =2538
let cancelled: serde_json::Value =2470
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();2539
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();2471
let done_first = cancelled["png_files_done"].as_u64().unwrap();2540
let done_first = cancelled["png_files_done"].as_u64().unwrap();2472
assert!(done_first > 0 && done_first < 144);2541
assert!(done_first > 0 && done_first < 240);2473
assert_eq!(cancelled["attempt"], 0);2542
assert_eq!(cancelled["attempt"], 0);2474
let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png");2543
let first_file = run.join("male/body/clip000-bind/f000000/p-60_y000_diffuse.png");2475
let first_inode = inode(&first_file);2544
let first_inode = inode(&first_file);2476
assert!(crate::export_set::ExportRun::open(&run).is_err());2545
assert!(crate::export_set::ExportRun::open(&run).is_err());2477
2546
@@ -2552,11 +2621,11 @@ mod tests {2552
let torn_inode = inode(&torn_path);2621
let torn_inode = inode(&torn_path);2553
// Corrupt one journaled PNG, delete another, leave a foreign temp file.2622
// Corrupt one journaled PNG, delete another, leave a foreign temp file.2554
let frame = run.join("male/body/clip000-bind/f000000");2623
let frame = run.join("male/body/clip000-bind/f000000");2555
let corrupt = frame.join("p-30_y030_normal.png");2624
let corrupt = frame.join("p-60_y030_normal.png");2556
std::fs::write(&corrupt, b"not a png").unwrap();2625
std::fs::write(&corrupt, b"not a png").unwrap();2557
let missing = frame.join("p-30_y060_depth.png");2626
let missing = frame.join("p-60_y060_depth.png");2558
std::fs::remove_file(&missing).unwrap();2627
std::fs::remove_file(&missing).unwrap();2559
let stale = frame.join("p-30_y090_diffuse.part-999999-0");2628
let stale = frame.join("p-60_y090_diffuse.part-999999-0");2560
std::fs::write(&stale, b"partial").unwrap();2629
std::fs::write(&stale, b"partial").unwrap();2561
2630
2562
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;2631
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;@@ -2570,7 +2639,7 @@ mod tests {2570
crate::export_set::ExportRun::open(&run).unwrap();2639
crate::export_set::ExportRun::open(&run).unwrap();2571
assert!(missing.exists());2640
assert!(missing.exists());2572
assert_eq!(2641
assert_eq!(2573
std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(),2642
std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(),2574
b"not a png"2643
b"not a png"2575
);2644
);2576
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");2645
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");@@ -2590,10 +2659,71 @@ mod tests {2590
assert_eq!(log["previous_marker"], serde_json::Value::Null);2659
assert_eq!(log["previous_marker"], serde_json::Value::Null);2591
assert_eq!(log["damaged_journal_lines"], 1);2660
assert_eq!(log["damaged_journal_lines"], 1);2592
assert_eq!(log["unjournaled_existing"], 1);2661
assert_eq!(log["unjournaled_existing"], 1);2593
assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1);2662
assert_eq!(log["missing"].as_u64().unwrap(), 240 - 60 / 6 * 4 + 1);2594
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);2663
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);2595
}2664
}2596
2665
2666
#[test]2667
fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() {2668
let tmp = Temp::new();2669
let mut job = resume_job(&tmp.0);2670
job.character = body();2671
let total: f32 = job.character.clips.iter().map(|c| c.duration).sum();2672
assert!(total > 0.);2673
// ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap.2674
job.fps = (100. / total).ceil() as u32;2675
job.target_width = 256;2676
job.target_height = 256;2677
let err = contract(job).err().unwrap();2678
assert!(err.contains("atlas") && err.contains("16384"), "{err}");2679
assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());2680
}2681
2682
#[test]2683
#[cfg(unix)]2684
fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {2685
let reference_dir = Temp::new();2686
drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)2687
.02688
.unwrap();2689
let reference_run = runs(&reference_dir.0)[0].clone();2690
let tmp = Temp::new();2691
drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();2692
let run = runs(&tmp.0)[0].clone();2693
// Simulate a crash after the atlases/.ppda were published but before2694
// complete.json, with one atlas damaged afterwards.2695
std::fs::remove_file(run.join("complete.json")).unwrap();2696
let diffuse = run.join("male-body-diffuse-atlas.png");2697
let normal = run.join("male-body-normal-atlas.png");2698
let normal_inode = inode(&normal);2699
let good = std::fs::read(&diffuse).unwrap();2700
std::fs::remove_file(&diffuse).unwrap();2701
std::fs::write(&diffuse, b"damaged").unwrap();2702
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2703
.02704
.unwrap();2705
assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");2706
assert_eq!(std::fs::read(&diffuse).unwrap(), good);2707
assert_eq!(2708
std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(),2709
b"damaged"2710
);2711
crate::atlas::AtlasSet::open_run(&run).unwrap();2712
crate::export_set::ExportRun::open(&run).unwrap();2713
// Byte-identical to an uninterrupted run (deterministic encoding).2714
for name in [2715
"male-body-atlas.ppda",2716
"male-body-diffuse-atlas.png",2717
"male-body-depth-atlas.png",2718
] {2719
assert_eq!(2720
std::fs::read(run.join(name)).unwrap(),2721
std::fs::read(reference_run.join(name)).unwrap(),2722
"{name}"2723
);2724
}2725
}2726
2597
#[test]2727
#[test]2598
fn unjournaled_different_file_is_quarantined_not_overwritten() {2728
fn unjournaled_different_file_is_quarantined_not_overwritten() {2599
let tmp = Temp::new();2729
let tmp = Temp::new();@@ -2602,7 +2732,7 @@ mod tests {2602
.is_err());2732
.is_err());2603
let run = runs(&tmp.0)[0].clone();2733
let run = runs(&tmp.0)[0].clone();2604
// A planned PNG appears that the journal never recorded and that differs.2734
// A planned PNG appears that the journal never recorded and that differs.2605
let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png");2735
let foreign = run.join("male/body/clip000-bind/f000000/p-60_y030_specular.png");2606
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();2736
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();2607
std::fs::write(&foreign, b"foreign").unwrap();2737
std::fs::write(&foreign, b"foreign").unwrap();2608
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2738
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)@@ -2610,7 +2740,7 @@ mod tests {2610
.unwrap();2740
.unwrap();2611
crate::export_set::ExportRun::open(&run).unwrap();2741
crate::export_set::ExportRun::open(&run).unwrap();2612
assert_eq!(2742
assert_eq!(2613
std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(),2743
std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),2614
b"foreign"2744
b"foreign"2615
);2745
);2616
}2746
}@@ -75,12 +75,15 @@ impl Default for RenderSettings {
75
75
Self {
76
76
target_width: 128,
77
77
target_height: 128,
78
fps: 12,
78
fps: DEFAULT_FPS,
79
79
framing: [CameraFraming::default(); 2],
80
80
}
81
81
}
82
82
}
83
83
84
/// Framerate for new projects (t955u). Existing `.ppd` files keep their stored fps.
85
pub const DEFAULT_FPS: u32 = 4;
86
84
87
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85
88
#[serde(deny_unknown_fields)]
86
89
pub struct ProjectFile {
@@ -489,7 +492,10 @@ pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectErr
489
492
}
490
493
491
494
pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
492
// 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
495
// Shape heuristic from the original brief: 8× width and 8× height,
496
// 36 views, 3 maps, 4 bytes per pixel. Production streams one view at a
497
// time, so the 60-view plan (t955u) does not change memory use; the
498
// factor stays 36 so that 256×256 targets remain valid.
493
499
u64::from(width)
494
500
.checked_mul(u64::from(height))?
495
501
.checked_mul(64)?
@@ -939,6 +945,16 @@ mod tests {
939
945
mod framing_tests {
940
946
use super::*;
941
947
#[test]
948
fn new_projects_default_to_4_fps_and_stored_fps_is_kept() {
949
assert_eq!(DEFAULT_FPS, 4);
950
assert_eq!(RenderSettings::default().fps, 4);
951
assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 4);
952
let mut stored = ProjectFile::new_empty("Oud");
953
stored.render.fps = 12;
954
let text = serde_json::to_string(&stored).unwrap();
955
assert_eq!(parse_manifest(&text).unwrap().render.fps, 12);
956
}
957
#[test]
942
958
fn per_sex_fixed_framing_roundtrip_and_v1_migration() {
943
959
let mut p = assemble(ProjectDraft {
944
960
id: "fixed-framing".into(),
@@ -75,12 +75,15 @@ impl Default for RenderSettings {75
Self {75
Self {76
target_width: 128,76
target_width: 128,77
target_height: 128,77
target_height: 128,78
fps: 12,78
fps: DEFAULT_FPS,79
framing: [CameraFraming::default(); 2],79
framing: [CameraFraming::default(); 2],80
}80
}81
}81
}82
}82
}83
83
84
/// Framerate for new projects (t955u). Existing `.ppd` files keep their stored fps.85
pub const DEFAULT_FPS: u32 = 4;86
84
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]87
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]85
#[serde(deny_unknown_fields)]88
#[serde(deny_unknown_fields)]86
pub struct ProjectFile {89
pub struct ProjectFile {@@ -489,7 +492,10 @@ pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectErr489
}492
}490
493
491
pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {494
pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {492
// 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.495
// Shape heuristic from the original brief: 8× width and 8× height,496
// 36 views, 3 maps, 4 bytes per pixel. Production streams one view at a497
// time, so the 60-view plan (t955u) does not change memory use; the498
// factor stays 36 so that 256×256 targets remain valid.493
u64::from(width)499
u64::from(width)494
.checked_mul(u64::from(height))?500
.checked_mul(u64::from(height))?495
.checked_mul(64)?501
.checked_mul(64)?@@ -939,6 +945,16 @@ mod tests {939
mod framing_tests {945
mod framing_tests {940
use super::*;946
use super::*;941
#[test]947
#[test]948
fn new_projects_default_to_4_fps_and_stored_fps_is_kept() {949
assert_eq!(DEFAULT_FPS, 4);950
assert_eq!(RenderSettings::default().fps, 4);951
assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 4);952
let mut stored = ProjectFile::new_empty("Oud");953
stored.render.fps = 12;954
let text = serde_json::to_string(&stored).unwrap();955
assert_eq!(parse_manifest(&text).unwrap().render.fps, 12);956
}957
#[test]942
fn per_sex_fixed_framing_roundtrip_and_v1_migration() {958
fn per_sex_fixed_framing_roundtrip_and_v1_migration() {943
let mut p = assemble(ProjectDraft {959
let mut p = assemble(ProjectDraft {944
id: "fixed-framing".into(),960
id: "fixed-framing".into(),@@ -242,13 +242,15 @@ mod tests {
242
242
#[test]
243
243
fn full_view_order_is_pitch_then_yaw_then_passes() {
244
244
let views = export_views();
245
assert_eq!(views.len(), 36);
246
assert_eq!(views[0].pitch_deg, -30.0);
245
assert_eq!(views.len(), 60);
246
assert_eq!(views[0].pitch_deg, -60.0);
247
247
assert_eq!(views[0].yaw_deg, 0.0);
248
248
assert_eq!(views[11].yaw_deg, 330.0);
249
assert_eq!(views[12].pitch_deg, 0.0);
249
assert_eq!(views[12].pitch_deg, -30.0);
250
assert_eq!(views[24].pitch_deg, 0.0);
251
assert_eq!(views[59].pitch_deg, 60.0);
250
252
let steps = pass_sequence(&views);
251
assert_eq!(steps.len(), 36 * 6);
253
assert_eq!(steps.len(), 60 * 6);
252
254
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();
253
255
assert_eq!(first, PASS_ORDER.to_vec());
254
256
assert_eq!(steps[6].yaw_deg, 30.0);
@@ -242,13 +242,15 @@ mod tests {242
#[test]242
#[test]243
fn full_view_order_is_pitch_then_yaw_then_passes() {243
fn full_view_order_is_pitch_then_yaw_then_passes() {244
let views = export_views();244
let views = export_views();245
assert_eq!(views.len(), 36);245
assert_eq!(views.len(), 60);246
assert_eq!(views[0].pitch_deg, -30.0);246
assert_eq!(views[0].pitch_deg, -60.0);247
assert_eq!(views[0].yaw_deg, 0.0);247
assert_eq!(views[0].yaw_deg, 0.0);248
assert_eq!(views[11].yaw_deg, 330.0);248
assert_eq!(views[11].yaw_deg, 330.0);249
assert_eq!(views[12].pitch_deg, 0.0);249
assert_eq!(views[12].pitch_deg, -30.0);250
assert_eq!(views[24].pitch_deg, 0.0);251
assert_eq!(views[59].pitch_deg, 60.0);250
let steps = pass_sequence(&views);252
let steps = pass_sequence(&views);251
assert_eq!(steps.len(), 36 * 6);253
assert_eq!(steps.len(), 60 * 6);252
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();254
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();253
assert_eq!(first, PASS_ORDER.to_vec());255
assert_eq!(first, PASS_ORDER.to_vec());254
assert_eq!(steps[6].yaw_deg, 30.0);256
assert_eq!(steps[6].yaw_deg, 30.0);@@ -153,7 +153,7 @@ pub fn read_journal(root: &Path) -> Result<(BTreeMap<String, String>, usize, boo
153
153
}
154
154
155
155
/// Move a bad file/link aside inside the run without deleting or clobbering anything.
156
fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
156
pub(crate) fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
157
157
let path = root.join(rel);
158
158
let name = path
159
159
.file_name()
@@ -153,7 +153,7 @@ pub fn read_journal(root: &Path) -> Result<(BTreeMap<String, String>, usize, boo153
}153
}154
154
155
/// Move a bad file/link aside inside the run without deleting or clobbering anything.155
/// Move a bad file/link aside inside the run without deleting or clobbering anything.156
fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {156
pub(crate) fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {157
let path = root.join(rel);157
let path = root.join(rel);158
let name = path158
let name = path159
.file_name()159
.file_name()@@ -1,6 +1,6 @@
1
1
//! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.
2
2
//! They load in their stored pose with no skeleton and no clips, use the
3
//! documented center-Y pivot fallback, render one bind sample × 36 views and
3
//! documented center-Y pivot fallback, render one bind sample × 60 views and
4
4
//! refuse bone-dependent equipment instead of inventing a rig.
5
5
use paperdoll_renderer::{
6
6
camera, equipment, preview,
@@ -200,22 +200,22 @@ fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() {
200
200
}
201
201
202
202
#[test]
203
fn static_body_plans_one_bind_sample_times_36_views_and_four_maps() {
203
fn static_body_plans_one_bind_sample_times_60_views_and_four_maps() {
204
204
let plan = production::plan(&obj(), 12).unwrap();
205
205
assert_eq!(plan.clips.len(), 1);
206
206
assert_eq!(plan.clips[0].token, "clip000-bind");
207
207
assert_eq!(plan.clips[0].frames, 1);
208
assert_eq!(plan.images, 36);
209
assert_eq!(plan.files, 144);
208
assert_eq!(plan.images, 60);
209
assert_eq!(plan.files, 240);
210
210
let paths = production::planned_paths(&plan, "male", "body").unwrap();
211
assert_eq!(paths.len(), 144);
211
assert_eq!(paths.len(), 240);
212
212
for map in ["diffuse", "normal", "depth", "specular"] {
213
213
assert_eq!(
214
214
paths
215
215
.iter()
216
216
.filter(|p| p.ends_with(&format!("_{map}.png")))
217
217
.count(),
218
36
218
60
219
219
);
220
220
}
221
221
let contract = production::contract(job(obj(), None)).unwrap();
@@ -1,6 +1,6 @@1
//! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.1
//! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.2
//! They load in their stored pose with no skeleton and no clips, use the2
//! They load in their stored pose with no skeleton and no clips, use the3
//! documented center-Y pivot fallback, render one bind sample × 36 views and3
//! documented center-Y pivot fallback, render one bind sample × 60 views and4
//! refuse bone-dependent equipment instead of inventing a rig.4
//! refuse bone-dependent equipment instead of inventing a rig.5
use paperdoll_renderer::{5
use paperdoll_renderer::{6
camera, equipment, preview,6
camera, equipment, preview,@@ -200,22 +200,22 @@ fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() {200
}200
}201
201
202
#[test]202
#[test]203
fn static_body_plans_one_bind_sample_times_36_views_and_four_maps() {203
fn static_body_plans_one_bind_sample_times_60_views_and_four_maps() {204
let plan = production::plan(&obj(), 12).unwrap();204
let plan = production::plan(&obj(), 12).unwrap();205
assert_eq!(plan.clips.len(), 1);205
assert_eq!(plan.clips.len(), 1);206
assert_eq!(plan.clips[0].token, "clip000-bind");206
assert_eq!(plan.clips[0].token, "clip000-bind");207
assert_eq!(plan.clips[0].frames, 1);207
assert_eq!(plan.clips[0].frames, 1);208
assert_eq!(plan.images, 36);208
assert_eq!(plan.images, 60);209
assert_eq!(plan.files, 144);209
assert_eq!(plan.files, 240);210
let paths = production::planned_paths(&plan, "male", "body").unwrap();210
let paths = production::planned_paths(&plan, "male", "body").unwrap();211
assert_eq!(paths.len(), 144);211
assert_eq!(paths.len(), 240);212
for map in ["diffuse", "normal", "depth", "specular"] {212
for map in ["diffuse", "normal", "depth", "specular"] {213
assert_eq!(213
assert_eq!(214
paths214
paths215
.iter()215
.iter()216
.filter(|p| p.ends_with(&format!("_{map}.png")))216
.filter(|p| p.ends_with(&format!("_{map}.png")))217
.count(),217
.count(),218
36218
60219
);219
);220
}220
}221
let contract = production::contract(job(obj(), None)).unwrap();221
let contract = production::contract(job(obj(), None)).unwrap();