BlueGit

golden-age paperdoll-renderer

Merge feature/ppda-atlases: 60 views, atlases + .ppda, Preview2d via .ppda, fps 4 (9b)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
54f043f2b911fdd9d74f9c4a815a74de1b4c9a03
Pipeline
Passed
24 files changed +2342 −861
README.md +36 −12
@@ -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 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
@@ -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 ![2D-preview op pitch +60 met een echte FBX-export via .ppda en atlassen](docs/screenshots/preview2d-ppda.png)
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 ![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
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** selecteert
23
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![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
@@ -37,24 +37,28 @@ Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL
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.
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-preview
43## 2D-preview
44
44
45Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:
45Vanuit **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![2D-preview op pitch +60 met een echte FBX-export via .ppda en atlassen](docs/screenshots/preview2d-ppda.png)
56
55```bash
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
57cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
59cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
60# Zelfde smoke op een bestaand project met echte exports
61cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live
58```
62```
59
63
60## Equipment en opslaan
64## Equipment en opslaan
@@ -88,10 +92,22 @@ De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de e
88Per 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.
92Per 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```text
94```text
91renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
95renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
96renders/run-<tijd>-<pid>/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
97renders/run-<tijd>-<pid>/male-body-atlas.ppda
92```
98```
93
99
94Ieder 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`.
100Ieder 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
104Als 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### Hervatten
112### Hervatten
97
113
@@ -100,13 +116,14 @@ Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaange
100- 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![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
122![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
106
123
107## 2D depth-compositor (bibliotheek)
124## 2D depth-compositor (bibliotheek)
108
125
109De 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).
126De 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];
126let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
143let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
144
145// Zelfde lagen via de consumentenvorm (.ppda + atlassen):
146use paperdoll_renderer::atlas::AtlasSet;
147let body = AtlasSet::open_run(&body_run_dir)?.load()?;
148let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
127```
149```
128
150
129
151
130Highres 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.
152Highres 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 met
154## Openen met
133
155
@@ -146,7 +168,9 @@ cargo clippy --workspace --all-targets --locked -- -D warnings
146cargo test --workspace --locked
168cargo test --workspace --locked
147# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
169# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
148xvfb-run -a cargo run --example preview_depth_probe --locked
170xvfb-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
172xvfb-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
150cargo 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
174cargo 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
151xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
175xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
152xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
176xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
design.md +21 −11
@@ -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 tempor
104
104
105## 6. Camera, axes, alignment and animation sampling
105## 6. Camera, axes, alignment and animation sampling
106
106
107Required 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.
107Required 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
115Highres 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.
115Highres 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` s
184
184
185## 10. Rendered 2D preview
185## 10. Rendered 2D preview
186
186
187Select 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.
187Select 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
189Left/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.
189Left/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
191Depth 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).
191Depth 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
199The 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`.
199The 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
201Live 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.
201Live 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
203CI 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.
203CI 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- 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
224Current 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.
224Current 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 gates
226## 12. Incremental feature plan and acceptance gates
227
227
@@ -230,7 +230,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
2302. 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.
2302. 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.
2313. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2313. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2324. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2324. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2335. 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.
2335. 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.
2346. 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.)
2346. 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.)
2357. 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.
2357. 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.
2368. 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.
2368. 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- **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 removed
373## Decision 2026-11-07 (Tim t958u): full-circle light azimuth, rim lighting removed
364
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.
docs/screenshots/preview2d-ppda.png added Binary file +0 −0

The contents of this binary file are not shown.

examples/fbx_live_fixture.rs +4 −1
@@ -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, Str
154 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 = name
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 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,
examples/preview2d_smoke.rs +77 −180
@@ -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 synthetic
2//! Usage: preview2d_smoke OUTPUT_DIR
2//! export through its `.ppda` + atlases (loose PNGs are deleted first).
3//! Usage: preview2d_smoke OUTPUT_DIR [EXISTING_PROJECT_DIR]
3
4
4use eframe::App;
5use eframe::App;
5use paperdoll_renderer::digest;
6use paperdoll_renderer::editor::EditorApp;
6use paperdoll_renderer::editor::EditorApp;
7use paperdoll_renderer::export_set::complete_identity_json;
7use paperdoll_renderer::export_fixture::{Pixel, SyntheticRun};
8use paperdoll_renderer::preview2d::discover_completed_runs;
8use paperdoll_renderer::preview2d::discover_completed_runs;
9use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
10use paperdoll_renderer::project::CameraFraming;
11use paperdoll_renderer::render_job::ImageBuf;
9use paperdoll_renderer::render_job::ImageBuf;
12use paperdoll_renderer::session::Session;
10use paperdoll_renderer::session::Session;
13use std::collections::BTreeMap;
14use std::fs;
11use std::fs;
15use std::path::{Path, PathBuf};
12use std::path::{Path, PathBuf};
16use std::time::{Duration, Instant};
13use 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
26impl App for Smoke {
25impl 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 false
76 false
47 };
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
86fn 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 its
87 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)]
97fn 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
171fn install_body_export(project: &Path) -> Result<(), String> {
119fn 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.1667
128 (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_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(())
253}
137}
254
138
255fn main() -> Result<(), Box<dyn std::error::Error>> {
139fn 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 production
263 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 project
159 }
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 )
examples/production_fixture.rs +13 −0
@@ -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}
examples/resume_fixture.rs +11 −6
@@ -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_files
173 .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_files
178 )
179 }
175 Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),
180 Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),
176 }
181 }
177 }
182 }
examples/static_body_fixture.rs +12 −4
@@ -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 unskinned
3//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned
4//! glTF body as female, screenshots the live viewport for both and the
4//! glTF body as female, screenshots the live viewport for both and the
5//! equipment screen (bone binding unavailable), runs the production batch for
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//! equipment render on the static body is refused, verifies the runs with
7//! equipment render on the static body is refused, verifies the runs with
8//! `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.
9use eframe::App;
9use 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() == 2
402 let runs_ok = checks.len() == 2
398 && equipment_runs == 0
403 && equipment_runs == 0
399 && checks.iter().all(|c| {
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 && c.run.render.clips.len() == 1
407 && c.run.render.clips.len() == 1
402 && 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 <= covered
411 && coverage_mismatch * 200 <= covered
406 && obj_red > 0
412 && obj_red > 0
407 && obj_blue > 0
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 && gltf_colors.len() <= 2
417 && gltf_colors.len() <= 2
410 && normal_bad == 0;
418 && normal_bad == 0;
411 if !(gui_ok && runs_ok && maps_ok) {
419 if !(gui_ok && runs_ok && maps_ok) {
src/atlas.rs added +1203 −0
@@ -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 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.
14use 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};
21use serde::{Deserialize, Serialize};
22use 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.
31pub 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.
34pub const MAX_ATLAS_DIM: u32 = 16384;
35/// Bounded `.ppda` size on read.
36pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024;
37/// Bounded encoded atlas PNG size on read.
38pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024;
39/// Decoded-memory budget for one loaded atlas set (four maps) in the preview.
40pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
41/// Map order inside the `.ppda` and atlas production.
42pub const ATLAS_MAPS: [MapKind; 4] = [
43 MapKind::Diffuse,
44 MapKind::Normal,
45 MapKind::Depth,
46 MapKind::Specular,
47];
48const MAX_CLIPS: usize = 4096;
49const MAX_CLIP_NAME: usize = 256;
50
51pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String {
52 format!("{sex}-{set}-{}-atlas.png", kind.token())
53}
54
55pub 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)]
61pub 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
70impl 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)]
137pub 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)]
156pub struct PpdaProject {
157 pub id: String,
158 pub name: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub 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)]
177pub 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)]
186pub 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)]
197pub 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)]
204pub 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)]
215pub 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
222impl 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
237struct HashingWriter<W: Write> {
238 inner: W,
239 hasher: Sha256,
240}
241
242impl<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.
254pub 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
269fn 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).
279fn 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`.
310fn 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.
369fn 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).
411pub 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.
501pub 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
543fn ident(msg: impl Into<String>) -> ExportError {
544 ExportError::Identity(msg.into())
545}
546
547fn 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)]
684pub struct AtlasSet {
685 dir: PathBuf,
686 pub ppda: Ppda,
687 pub layout: AtlasLayout,
688}
689
690impl 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)]
815pub struct LoadedAtlas {
816 pub set: AtlasSet,
817 maps: BTreeMap<&'static str, MapImage>,
818}
819
820impl 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).
894pub 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)]
943mod 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}
src/camera.rs +3 −3
@@ -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 }
src/compositor.rs +89 −169
@@ -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) or
4//! [`crate::atlas`] (`.ppda` + atlases, the consumer format).
4//! Saved diffuse remains unlit albedo elsewhere; this module only applies
5//! Saved diffuse remains unlit albedo elsewhere; this module only applies
5//! 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
155fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
172fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
@@ -213,14 +230,40 @@ impl CompatKey {
213 }
230 }
214}
231}
215
232
233impl 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/// Reject runs that cannot be depth-composited together.
254/// Reject runs that cannot be depth-composited together.
217pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
255pub 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.
261pub 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 {
505mod tests {
548mod 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 map
921 }
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": 4
962 });
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": checksums
978 }),
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();
src/editor.rs +70 −14
@@ -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};
12use crate::preview2d::{
12use 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};
17use crate::preview_gl::Viewport;
17use crate::preview_gl::Viewport;
18use crate::project::open_project;
18use 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).
59struct PreviewSelection {
60 paths: Vec<std::path::PathBuf>,
61 body: crate::atlas::LoadedAtlas,
62 gear: Vec<crate::atlas::LoadedAtlas>,
63}
64
54impl EditorApp {
65impl 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.preview2d
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 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 = 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 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");
src/export_fixture.rs added +223 −0
@@ -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-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.
7use 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};
15use std::{collections::BTreeMap, path::Path, sync::atomic::AtomicBool};
16
17/// Uniform per-cell map values.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub 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)]
27pub 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
40impl 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
220fn 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}
src/export_set.rs +110 −26
@@ -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.
21pub const OUTPUT_META_VERSION: u32 = 3;
21pub const OUTPUT_META_VERSION: u32 = 4;
22pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
22pub 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).
24pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
24pub 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)]
211pub 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)]
219pub 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 = 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 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
574fn decode_map(
621pub(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.
632pub(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 = decoder
650 let mut reader = decoder
583 .read_info()
651 .read_info()
@@ -589,8 +657,8 @@ fn decode_map(
589 info.width, info.height
657 info.width, info.height
590 )));
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
727fn read_bounded_file(path: &Path, max: u64) -> Result<Vec<u8>, ExportError> {
795pub(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
756fn verify_regular_file(path: &Path, root: &Path) -> Result<(), ExportError> {
824pub(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": checksums
1042 "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 = run
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 .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 = run
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 .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": checksums
1091 "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": checksums
1118 "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": checksums
1150 "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": checksums
1192 "png_sha256": checksums,
1193 "atlas": atlas_ref()
1110 }),
1194 }),
1111 );
1195 );
1112 assert!(matches!(
1196 assert!(matches!(
src/foundation.rs +1 −1
@@ -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
120pub fn dutch_summary() -> &'static str {
120pub 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.
src/lib.rs +4 −1
@@ -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 and
5//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and
6//! 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/identity
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
10pub mod atlas;
10pub mod camera;
11pub mod camera;
11pub mod compositor;
12pub mod compositor;
12pub mod dialogs;
13pub mod dialogs;
13pub mod digest;
14pub mod digest;
14pub mod editor;
15pub mod editor;
15pub mod equipment;
16pub mod equipment;
17#[doc(hidden)]
18pub mod export_fixture;
16pub mod export_set;
19pub mod export_set;
17pub mod fbx_scene;
20pub mod fbx_scene;
18pub mod file_assoc;
21pub mod file_assoc;
src/preview.rs +13 −6
@@ -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 and
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//! 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
8use std::path::Path;
8use std::path::Path;
9
9
10pub const PAPERDOLL_PITCHES_DEG: [f32; 3] = [-30.0, 0.0, 30.0];
10pub 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).
12pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
13/// Production views per sample: five pitches × twelve yaws.
14pub const PRODUCTION_VIEWS: u32 = 60;
11pub const PAPERDOLL_YAW_COUNT: usize = 12;
15pub const PAPERDOLL_YAW_COUNT: usize = 12;
12pub const MAX_JOINTS: usize = 128;
16pub 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");
src/preview2d.rs +239 −362
@@ -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 atlases
4//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and
5//! `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
6use std::collections::BTreeMap;
8use std::collections::BTreeMap;
7use std::fs;
9use std::fs;
8use std::path::{Path, PathBuf};
10use std::path::{Path, PathBuf};
9
11
12use crate::atlas::{AtlasSet, LoadedAtlas};
10use crate::compositor::{
13use 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};
14use crate::export_set::{ExportError, ExportRun};
17use crate::export_set::ExportError;
15use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
18use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
16use crate::render_job::ImageBuf;
19use crate::render_job::ImageBuf;
17use crate::session::Sex;
20use crate::session::Sex;
@@ -28,6 +31,9 @@ pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
28pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
31pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
29pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
32pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
30
33
34/// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60).
35pub const ZERO_PITCH_ROW: i32 = 2;
36
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum LightAxis {
38pub 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)]
138pub struct CatalogEntry {
144pub 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
151impl CatalogEntry {
158impl 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: run
168 clips: p
161 .render
162 .clips
169 .clips
163 .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: run
174 equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),
168 .render
175 key: CompatKey::from_ppda(p),
169 .equipment_preset
170 .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.
185pub 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.
182pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
190pub 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.
195pub 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 out
213 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_DEG
229 PAPERDOLL_PITCHES_DEG
218 .iter()
230 .iter()
219 .position(|p| *p as i32 == pitch)
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/// 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)]
454pub struct ComposeRequest<'a> {
466pub 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.
516pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
528pub 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> = atlases
521 }
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, Previe
568 }
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) that
572pub fn open_selection(
564/// [`open_selection`] would load; used as a cache key by the editor.
565pub 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 = catalog
574 let body = catalog
582 .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 = catalog
581 let entry = catalog
590 .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 one
599/// sex, re-validating checksums and compatibility.
600pub 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/// 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: &[CatalogEnt
676mod tests {
686mod 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 map
796 }
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<_> = clips
747 spec.framing = framing();
748 spec.set = set.into();
749 spec.equipment_sha256 = equipment_sha.map(str::to_string);
750 spec.clips = clips
811 .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": duration
818 })
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_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 );
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 depth
1231 ));
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 ..base
1101 ..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 ..base
1310 })
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.json
1131 // no .ppda at all
1323 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 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}
src/preview_gl.rs +1 −1
@@ -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(
src/production.rs +181 −51
@@ -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 = frames
150 .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 {
169pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
171pub 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_PITCHES
173 (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.
189pub fn production_yaws() -> [u32; 12] {
190 std::array::from_fn(|i| i as u32 * 30)
191}
192
184pub fn clip_token(index: usize, name: &str) -> String {
193pub fn clip_token(index: usize, name: &str) -> String {
185 let slug: String = name
194 let slug: String = name
186 .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 >= 360
223 || yaw >= 360
215 || !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}
1049fn atomic_file(
1058pub(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.
1163pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1172pub 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.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 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.files
1653 ));
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: 0
1917 yaw: 0
1881 }
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 expected
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 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 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 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 .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 #[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 }
src/project.rs +18 −2
@@ -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.
85pub 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)]
86pub struct ProjectFile {
89pub struct ProjectFile {
@@ -489,7 +492,10 @@ pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectErr
489}
492}
490
493
491pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
494pub 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 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 {
939mod framing_tests {
945mod 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(),
src/render_job.rs +6 −4
@@ -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);
src/resume.rs +1 −1
@@ -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, boo
153}
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.
156fn quarantine(root: &Path, rel: &str, attempt: u32) -> Result<String, String> {
156pub(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 = path
158 let name = path
159 .file_name()
159 .file_name()
tests/static_body.rs +6 −6
@@ -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 the
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//! refuse bone-dependent equipment instead of inventing a rig.
4//! refuse bone-dependent equipment instead of inventing a rig.
5use paperdoll_renderer::{
5use 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]
203fn static_body_plans_one_bind_sample_times_36_views_and_four_maps() {
203fn 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 paths
214 paths
215 .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 36
218 60
219 );
219 );
220 }
220 }
221 let contract = production::contract(job(obj(), None)).unwrap();
221 let contract = production::contract(job(obj(), None)).unwrap();