BlueGit

golden-age paperdoll-renderer

9b: 60 views, per-map atlases + .ppda, Preview2d via .ppda, fps default 4; docs

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
b6d4ecc9c9268144c92ddeacea3585cb08d1c797
Parent
c8ede4e7
9 files changed +124 −38
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/preview2d_smoke.rs +55 −11
@@ -1,6 +1,6 @@
1 1 //! Native GUI smoke for the rendered 2D preview tab: it loads a synthetic
2 2 //! export through its `.ppda` + atlases (loose PNGs are deleted first).
3 //! Usage: preview2d_smoke OUTPUT_DIR
3 //! Usage: preview2d_smoke OUTPUT_DIR [EXISTING_PROJECT_DIR]
4 4
5 5 use eframe::App;
6 6 use paperdoll_renderer::editor::EditorApp;
@@ -18,6 +18,8 @@ struct Smoke {
18 18 started: Instant,
19 19 frames: u64,
20 20 done: bool,
21 shot_requested: bool,
22 shot_saved: bool,
21 23 }
22 24
23 25 impl App for Smoke {
@@ -27,13 +29,44 @@ impl App for Smoke {
27 29 if self.frames == 2 {
28 30 self.editor.open_preview2d();
29 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 }
30 63 let ready = self.editor.preview2d_image().is_some()
31 64 || self.editor.preview2d_state().diagnostic.is_some();
32 let timeout = self.started.elapsed() > Duration::from_secs(8);
65 let timeout = self.started.elapsed() > Duration::from_secs(20);
33 66 if self.done {
34 67 return;
35 68 }
36 if (self.frames >= 12 && ready) || timeout {
69 if (self.shot_saved && ready) || timeout {
37 70 self.done = true;
38 71 let state = self.editor.preview2d_state();
39 72 let image_ok = if let Some(img) = self.editor.preview2d_image() {
@@ -43,7 +76,8 @@ impl App for Smoke {
43 76 false
44 77 };
45 78 let value = serde_json::json!({
46 "success": image_ok,
79 "success": image_ok && self.shot_saved,
80 "screenshot": self.shot_saved,
47 81 "gui_updates": self.frames,
48 82 "sex": state.sex_token(),
49 83 "clip": state.clip_token,
@@ -109,13 +143,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
109 143 .ok_or("usage: preview2d_smoke OUTPUT_DIR")?,
110 144 );
111 145 fs::create_dir_all(&out)?;
112 let project = out.join("project");
113 let mut session = Session::new();
114 session.install_new_project(&project)?;
115 session.save()?;
116 install_body_export(&project)?;
117 // Prove the preview reads the atlases: remove every loose PNG.
118 fs::remove_dir_all(project.join("renders/run-preview2d-body/male"))?;
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 };
119 161 let catalog = discover_completed_runs(&project);
120 162 fs::write(
121 163 out.join("catalog.json"),
@@ -144,6 +186,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
144 186 started: Instant::now(),
145 187 frames: 0,
146 188 done: false,
189 shot_requested: false,
190 shot_saved: false,
147 191 }) as Box<dyn App>)
148 192 }),
149 193 )
@@ -1,6 +1,6 @@
1//! Native GUI smoke for the rendered 2D preview tab: it loads a synthetic
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).
2//! export through its `.ppda` + atlases (loose PNGs are deleted first).
3//! Usage: preview2d_smoke OUTPUT_DIR
3//! Usage: preview2d_smoke OUTPUT_DIR [EXISTING_PROJECT_DIR]
4
4
5use eframe::App;
5use eframe::App;
6use paperdoll_renderer::editor::EditorApp;
6use paperdoll_renderer::editor::EditorApp;
@@ -18,6 +18,8 @@ struct Smoke {
18 started: Instant,
18 started: Instant,
19 frames: u64,
19 frames: u64,
20 done: bool,
20 done: bool,
21 shot_requested: bool,
22 shot_saved: bool,
21}
23}
22
24
23impl App for Smoke {
25impl App for Smoke {
@@ -27,13 +29,44 @@ impl App for Smoke {
27 if self.frames == 2 {
29 if self.frames == 2 {
28 self.editor.open_preview2d();
30 self.editor.open_preview2d();
29 }
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 }
30 let ready = self.editor.preview2d_image().is_some()
63 let ready = self.editor.preview2d_image().is_some()
31 || self.editor.preview2d_state().diagnostic.is_some();
64 || self.editor.preview2d_state().diagnostic.is_some();
32 let timeout = self.started.elapsed() > Duration::from_secs(8);
65 let timeout = self.started.elapsed() > Duration::from_secs(20);
33 if self.done {
66 if self.done {
34 return;
67 return;
35 }
68 }
36 if (self.frames >= 12 && ready) || timeout {
69 if (self.shot_saved && ready) || timeout {
37 self.done = true;
70 self.done = true;
38 let state = self.editor.preview2d_state();
71 let state = self.editor.preview2d_state();
39 let image_ok = if let Some(img) = self.editor.preview2d_image() {
72 let image_ok = if let Some(img) = self.editor.preview2d_image() {
@@ -43,7 +76,8 @@ impl App for Smoke {
43 false
76 false
44 };
77 };
45 let value = serde_json::json!({
78 let value = serde_json::json!({
46 "success": image_ok,
79 "success": image_ok && self.shot_saved,
80 "screenshot": self.shot_saved,
47 "gui_updates": self.frames,
81 "gui_updates": self.frames,
48 "sex": state.sex_token(),
82 "sex": state.sex_token(),
49 "clip": state.clip_token,
83 "clip": state.clip_token,
@@ -109,13 +143,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
109 .ok_or("usage: preview2d_smoke OUTPUT_DIR")?,
143 .ok_or("usage: preview2d_smoke OUTPUT_DIR")?,
110 );
144 );
111 fs::create_dir_all(&out)?;
145 fs::create_dir_all(&out)?;
112 let project = out.join("project");
146 // Optional second argument: an existing project with real production
113 let mut session = Session::new();
147 // exports (its loose PNGs are left alone).
114 session.install_new_project(&project)?;
148 let project = match std::env::args().nth(2) {
115 session.save()?;
149 Some(existing) => PathBuf::from(existing),
116 install_body_export(&project)?;
150 None => {
117 // Prove the preview reads the atlases: remove every loose PNG.
151 let project = out.join("project");
118 fs::remove_dir_all(project.join("renders/run-preview2d-body/male"))?;
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 };
119 let catalog = discover_completed_runs(&project);
161 let catalog = discover_completed_runs(&project);
120 fs::write(
162 fs::write(
121 out.join("catalog.json"),
163 out.join("catalog.json"),
@@ -144,6 +186,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
144 started: Instant::now(),
186 started: Instant::now(),
145 frames: 0,
187 frames: 0,
146 done: false,
188 done: false,
189 shot_requested: false,
190 shot_saved: false,
147 }) as Box<dyn App>)
191 }) as Box<dyn App>)
148 }),
192 }),
149 )
193 )
examples/static_body_fixture.rs +3 −1
@@ -411,7 +411,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
411 411 && coverage_mismatch * 200 <= covered
412 412 && obj_red > 0
413 413 && obj_blue > 0
414 && 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
415 417 && gltf_colors.len() <= 2
416 418 && normal_bad == 0;
417 419 if !(gui_ok && runs_ok && maps_ok) {
@@ -411,7 +411,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
411 && coverage_mismatch * 200 <= covered
411 && coverage_mismatch * 200 <= covered
412 && obj_red > 0
412 && obj_red > 0
413 && obj_blue > 0
413 && obj_blue > 0
414 && 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
415 && gltf_colors.len() <= 2
417 && gltf_colors.len() <= 2
416 && normal_bad == 0;
418 && normal_bad == 0;
417 if !(gui_ok && runs_ok && maps_ok) {
419 if !(gui_ok && runs_ok && maps_ok) {
src/compositor.rs +2 −1
@@ -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 //!
@@ -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//!
src/editor.rs +5 −0
@@ -1399,6 +1399,11 @@ impl EditorApp {
1399 1399 &self.preview2d
1400 1400 }
1401 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
1402 1407 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1403 1408 self.preview2d_image.as_ref()
1404 1409 }
@@ -1399,6 +1399,11 @@ impl EditorApp {
1399 &self.preview2d
1399 &self.preview2d
1400 }
1400 }
1401
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
1402 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1407 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1403 self.preview2d_image.as_ref()
1408 self.preview2d_image.as_ref()
1404 }
1409 }
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 +1 −1
@@ -5,7 +5,7 @@
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 10 pub mod atlas;
11 11 pub mod camera;
@@ -5,7 +5,7 @@
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 atlas;
11pub mod camera;
11pub mod camera;