Merge feature/depth-range-encodings: configurable depth range + depth encodings (9j), Render alles live preview
@@ -89,11 +89,17 @@ Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opsla
89
89
90
90
### Render alles
91
91
92
**Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.
92
**Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Tijdens Render alles blijft de live passpreview in het Body-scherm zichtbaar en volgt die het item dat op dat moment rendert, net als bij een losse render. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.
93
93
94
94
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.
95
95
96
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 binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); 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.
96
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 lineair binnen een vast dieptebereik per geslacht, gecentreerd op het orbitpunt: `near/far = 10 m ∓ totaal/2`. Standaard is het totaal de T-pose-spanwijdte plus één armlengte marge aan elke kant (arm = afstand bovenarm→handbot × 1,2, spanwijdte = schouderbreedte + 2 armen, onafhankelijk van de bindpose; zonder herkenbare arm-/handbotten: spanwijdte = max(breedte, hoogte) en arm = 0,4·spanwijdte). In het body-scherm staat onder het renderkader **Dieptebereik**: *automatisch* of een eigen totaal in meters (`depth_range_milli` per geslacht in de `.ppd`), met de resulterende near/far. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik.
97
98
Het depthformaat kies je in het Body-scherm (**Depth-map**), opgeslagen in de `.ppd` als `render.depth_bits` (8, standaard, of 16) en `render.depth_encoding`. Met `t = (afstand − near)/(far − near)` en M = 2^bits − 1:
99
- `gray` (standaard): grijs+alpha, `grijs = round(t·M)`.
100
- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 niveaus bij 8-bit).
101
- `rgb_packed`: RGBA, positioneel in basis 2^bits (24-bit of 48-bit waarde), R hoogste deel; vereist nearest sampling, geen mipmaps en lossless opslag.
102
Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); 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.
97
103
98
104
```text
99
105
renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
@@ -101,16 +107,16 @@ renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -spec
101
107
renders/male-body-atlas/male-body-atlas.ppda
102
108
```
103
109
104
Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding. `render.json` (schema 5; met `depth_near`, `depth_far`, `depth_encoding`) 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 (o.a. schema 4 met het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `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`.
110
Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding. `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) 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. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `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- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
105
111
106
112
### Atlassen en .ppda
107
113
108
114
Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.
109
115
110
- 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.
116
- 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; de depth-atlas heeft byte-exact hetzelfde formaat als de losse depth-PNG's.
111
117
- 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.
112
118
- 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.
113
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 2, `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij grijs 0 / 65535) en `depth_encoding`, `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).
119
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 3 (versie 2 wordt nog gelezen als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `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).
114
120
- 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.
115
121
116
122
### Hervatten
@@ -89,11 +89,17 @@ Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opsla89
89
90
### Render alles90
### Render alles91
91
92
**Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.92
**Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Tijdens Render alles blijft de live passpreview in het Body-scherm zichtbaar en volgt die het item dat op dat moment rendert, net als bij een losse render. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.93
93
94
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.94
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.95
95
96
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 binnen een vast, smal dieptebereik per geslacht rond het orbitpunt: `near/far = 10 m ∓ R`, met R = halve renderhoogte + |pivot Y − centrum Y| (minimaal 0,5 m); bij 2,6 m hoogte en nekpivot 1,46 m dus ca. 8,24–11,76 m. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik; afstand = near + (grijs/65535)·(far − near). Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); 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.96
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 lineair binnen een vast dieptebereik per geslacht, gecentreerd op het orbitpunt: `near/far = 10 m ∓ totaal/2`. Standaard is het totaal de T-pose-spanwijdte plus één armlengte marge aan elke kant (arm = afstand bovenarm→handbot × 1,2, spanwijdte = schouderbreedte + 2 armen, onafhankelijk van de bindpose; zonder herkenbare arm-/handbotten: spanwijdte = max(breedte, hoogte) en arm = 0,4·spanwijdte). In het body-scherm staat onder het renderkader **Dieptebereik**: *automatisch* of een eigen totaal in meters (`depth_range_milli` per geslacht in de `.ppd`), met de resulterende near/far. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik.97
98
Het depthformaat kies je in het Body-scherm (**Depth-map**), opgeslagen in de `.ppd` als `render.depth_bits` (8, standaard, of 16) en `render.depth_encoding`. Met `t = (afstand − near)/(far − near)` en M = 2^bits − 1:99
- `gray` (standaard): grijs+alpha, `grijs = round(t·M)`.100
- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 niveaus bij 8-bit).101
- `rgb_packed`: RGBA, positioneel in basis 2^bits (24-bit of 48-bit waarde), R hoogste deel; vereist nearest sampling, geen mipmaps en lossless opslag.102
Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang en in `complete.json` (`depth_clamped`); 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.97
103
98
```text104
```text99
renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png105
renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png@@ -101,16 +107,16 @@ renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -spec101
renders/male-body-atlas/male-body-atlas.ppda107
renders/male-body-atlas/male-body-atlas.ppda102
```108
```103
109
104
Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding. `render.json` (schema 5; met `depth_near`, `depth_far`, `depth_encoding`) 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 (o.a. schema 4 met het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `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`.110
Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding. `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) 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. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `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- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.105
111
106
### Atlassen en .ppda112
### Atlassen en .ppda107
113
108
Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.114
Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json`.109
115
110
- 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.116
- 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; de depth-atlas heeft byte-exact hetzelfde formaat als de losse depth-PNG's.111
- 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.117
- 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.112
- 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.118
- 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.113
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 2, `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij grijs 0 / 65535) en `depth_encoding`, `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).119
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 3 (versie 2 wordt nog gelezen als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `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).114
- 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.120
- 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.115
121
116
### Hervatten122
### Hervatten@@ -409,7 +409,7 @@ Implementation (2026-11-07): the build folder is `renders/inprogress-<stamp>-<pi
409
409
410
410
## Decision 2026-11-07 (Tim t984u, amended t985u): default and configurable depth range
411
411
412
Supersedes the `R = half render height + |pivot − centre|` rule. (1) The default per-sex depth range is the **T-pose arm span** (fingertip to fingertip, measured in the bind pose) **plus one arm length of margin on each side**, centred on the pivot depth: `total = span + 2·arm`, `near = D − total/2`, `far = D + total/2` (camera distance D = 10 m). This covers the model in any rotation plus long weapons. Span = horizontal extent of the bind-pose body vertices; arm = (span − shoulder width)/2 with the shoulder width taken from the left/right upper-arm/shoulder joints when they can be identified by name, otherwise arm = 0.4·span (documented fallback, also for static bodies). Geometry still outside the range is clamped and reported (progress + `complete.json` `depth_clamped`). (2) The range is configurable per sex in the `.ppd` (`depth_range_milli`: optional total range in millimetres, centred on the pivot) and editable in the GUI next to framing ("Dieptebereik", with an "automatisch" choice). It stays fixed for all frames, views and equipment of that sex and is stored in `render.json` and `.ppda` `depth_near`/`depth_far`; readers use the recorded values (validated: finite, 0 < near < far, equal to identity near/far) instead of recomputing them.
412
Supersedes the `R = half render height + |pivot − centre|` rule. (1) The default per-sex depth range is the **T-pose arm span** (fingertip to fingertip, measured in the bind pose) **plus one arm length of margin on each side**, centred on the pivot depth: `total = span + 2·arm`, `near = D − total/2`, `far = D + total/2` (camera distance D = 10 m). This covers the model in any rotation plus long weapons. Implementation (pose-independent, so A-/I-pose binds give the T-pose value): with left/right upper-arm and hand joints identified by name, arm = |upper arm → hand| (rest pose, mean of both sides) × 1.2 for hand and fingers, and span = shoulder width (distance between the upper-arm joints) + 2·arm. Without those joints (incl. static bodies): span = max(bind-pose width, height), arm = 0.4·span. Test bodies (arms down): male ≈ 3.15 m total (near 8.42 / far 11.58 m), no clamped pixels. Geometry still outside the range is clamped and reported (progress + `complete.json` `depth_clamped`). (2) The range is configurable per sex in the `.ppd` (`depth_range_milli`: optional total range in millimetres, centred on the pivot) and editable in the GUI next to framing ("Dieptebereik", with an "automatisch" choice). It stays fixed for all frames, views and equipment of that sex and is stored in `render.json` and `.ppda` `depth_near`/`depth_far`; readers use the recorded values (validated: finite, 0 < near < far, equal to identity near/far) instead of recomputing them.
413
413
414
414
## Decision 2026-11-07 (Tim t980u, amended t981u/t983u/t987u): depth bit depth + depth encoding
415
415
@@ -409,7 +409,7 @@ Implementation (2026-11-07): the build folder is `renders/inprogress-<stamp>-<pi409
409
410
## Decision 2026-11-07 (Tim t984u, amended t985u): default and configurable depth range410
## Decision 2026-11-07 (Tim t984u, amended t985u): default and configurable depth range411
411
412
Supersedes the `R = half render height + |pivot − centre|` rule. (1) The default per-sex depth range is the **T-pose arm span** (fingertip to fingertip, measured in the bind pose) **plus one arm length of margin on each side**, centred on the pivot depth: `total = span + 2·arm`, `near = D − total/2`, `far = D + total/2` (camera distance D = 10 m). This covers the model in any rotation plus long weapons. Span = horizontal extent of the bind-pose body vertices; arm = (span − shoulder width)/2 with the shoulder width taken from the left/right upper-arm/shoulder joints when they can be identified by name, otherwise arm = 0.4·span (documented fallback, also for static bodies). Geometry still outside the range is clamped and reported (progress + `complete.json` `depth_clamped`). (2) The range is configurable per sex in the `.ppd` (`depth_range_milli`: optional total range in millimetres, centred on the pivot) and editable in the GUI next to framing ("Dieptebereik", with an "automatisch" choice). It stays fixed for all frames, views and equipment of that sex and is stored in `render.json` and `.ppda` `depth_near`/`depth_far`; readers use the recorded values (validated: finite, 0 < near < far, equal to identity near/far) instead of recomputing them.412
Supersedes the `R = half render height + |pivot − centre|` rule. (1) The default per-sex depth range is the **T-pose arm span** (fingertip to fingertip, measured in the bind pose) **plus one arm length of margin on each side**, centred on the pivot depth: `total = span + 2·arm`, `near = D − total/2`, `far = D + total/2` (camera distance D = 10 m). This covers the model in any rotation plus long weapons. Implementation (pose-independent, so A-/I-pose binds give the T-pose value): with left/right upper-arm and hand joints identified by name, arm = |upper arm → hand| (rest pose, mean of both sides) × 1.2 for hand and fingers, and span = shoulder width (distance between the upper-arm joints) + 2·arm. Without those joints (incl. static bodies): span = max(bind-pose width, height), arm = 0.4·span. Test bodies (arms down): male ≈ 3.15 m total (near 8.42 / far 11.58 m), no clamped pixels. Geometry still outside the range is clamped and reported (progress + `complete.json` `depth_clamped`). (2) The range is configurable per sex in the `.ppd` (`depth_range_milli`: optional total range in millimetres, centred on the pivot) and editable in the GUI next to framing ("Dieptebereik", with an "automatisch" choice). It stays fixed for all frames, views and equipment of that sex and is stored in `render.json` and `.ppda` `depth_near`/`depth_far`; readers use the recorded values (validated: finite, 0 < near < far, equal to identity near/far) instead of recomputing them.413
413
414
## Decision 2026-11-07 (Tim t980u, amended t981u/t983u/t987u): depth bit depth + depth encoding414
## Decision 2026-11-07 (Tim t980u, amended t981u/t983u/t987u): depth bit depth + depth encoding415
415
@@ -73,6 +73,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
73
73
let pitch = 30.;
74
74
let yaw = 90.;
75
75
let mut job = production::RenderJob {
76
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
76
77
character: body,
77
78
sex: "male".into(),
78
79
preset: None,
@@ -83,6 +84,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
83
84
height_milli: 2600,
84
85
center_y_milli: 1000,
85
86
pivot_y_milli: Some(1000),
87
depth_range_milli: None,
86
88
},
87
89
output: out.clone(),
88
90
project_id: "neck-probe".into(),
@@ -73,6 +73,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {73
let pitch = 30.;73
let pitch = 30.;74
let yaw = 90.;74
let yaw = 90.;75
let mut job = production::RenderJob {75
let mut job = production::RenderJob {76
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,76
character: body,77
character: body,77
sex: "male".into(),78
sex: "male".into(),78
preset: None,79
preset: None,@@ -83,6 +84,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {83
height_milli: 2600,84
height_milli: 2600,84
center_y_milli: 1000,85
center_y_milli: 1000,85
pivot_y_milli: Some(1000),86
pivot_y_milli: Some(1000),87
depth_range_milli: None,86
},88
},87
output: out.clone(),89
output: out.clone(),88
project_id: "neck-probe".into(),90
project_id: "neck-probe".into(),@@ -29,7 +29,9 @@ use std::{
29
29
30
30
/// `.ppda` schema version written and accepted.
31
31
/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
32
pub const PPDA_FORMAT_VERSION: u32 = 2;
32
/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
33
/// still read (16-bit gray).
34
pub const PPDA_FORMAT_VERSION: u32 = 3;
33
35
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
34
36
/// refused up front with a clear message; atlases are never split.
35
37
pub const MAX_ATLAS_DIM: u32 = 16384;
@@ -148,6 +150,9 @@ pub struct Ppda {
148
150
pub depth_near: f32,
149
151
pub depth_far: f32,
150
152
pub depth_encoding: String,
153
/// 8 or 16 (format 3); absent in format 2 (= 16-bit gray).
154
#[serde(default, skip_serializing_if = "Option::is_none")]
155
pub depth_bits: Option<u64>,
151
156
pub cell_width: u32,
152
157
pub cell_height: u32,
153
158
pub pitches: Vec<i32>,
@@ -301,13 +306,29 @@ fn load_cell(
301
306
if digest::sha256_hex(&bytes) != *expected {
302
307
return Err(format!("atlas: checksum van {rel} klopt niet meer"));
303
308
}
304
match export_set::decode_map(&bytes, kind, job.cell_width, job.cell_height)
305
.map_err(|e| format!("atlas: {rel}: {e}"))?
309
if kind == MapKind::Depth {
310
// Copied in its own encoding, byte-exact (lossless for every format).
311
let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
312
return export_set::decode_depth_native(
313
&bytes,
314
job.cell_width,
315
job.cell_height,
316
export_set::MAX_TARGET,
317
fmt,
318
)
319
.map_err(|e| format!("atlas: {rel}: {e}"));
320
}
321
match export_set::decode_map(
322
&bytes,
323
kind,
324
job.cell_width,
325
job.cell_height,
326
crate::camera::DepthFormat::GRAY16,
327
)
328
.map_err(|e| format!("atlas: {rel}: {e}"))?
306
329
{
307
330
MapImage::Rgba8 { rgba, .. } => Ok(rgba),
308
MapImage::Depth16 { samples, .. } => {
309
Ok(samples.iter().flat_map(|s| s.to_be_bytes()).collect())
310
}
331
MapImage::Depth16 { .. } => Err("atlas: onverwachte depth".into()),
311
332
}
312
333
}
313
334
@@ -320,9 +341,12 @@ fn encode_atlas(
320
341
progress: &mut dyn FnMut(String),
321
342
) -> Result<(), String> {
322
343
let mut encoder = png::Encoder::new(out, layout.width, layout.height);
344
let mut bpp = 4;
323
345
if kind == MapKind::Depth {
324
encoder.set_color(png::ColorType::GrayscaleAlpha);
325
encoder.set_depth(png::BitDepth::Sixteen);
346
let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
347
encoder.set_color(fmt.color_type());
348
encoder.set_depth(fmt.bit_depth());
349
bpp = fmt.bytes_per_pixel();
326
350
} else {
327
351
encoder.set_color(png::ColorType::Rgba);
328
352
encoder.set_depth(png::BitDepth::Eight);
@@ -331,8 +355,8 @@ fn encode_atlas(
331
355
}
332
356
}
333
357
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
334
let row_bytes = layout.width as usize * 4;
335
let cell_row_bytes = layout.cell_width as usize * 4;
358
let row_bytes = layout.width as usize * bpp;
359
let cell_row_bytes = layout.cell_width as usize * bpp;
336
360
let mut stream = writer
337
361
.stream_writer_with_size(row_bytes.max(4096))
338
362
.map_err(|e| e.to_string())?;
@@ -356,7 +380,7 @@ fn encode_atlas(
356
380
let view = layout.cell(0, column);
357
381
let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
358
382
for y in 0..layout.cell_height as usize {
359
let dst = y * row_bytes + view.x as usize * 4;
383
let dst = y * row_bytes + view.x as usize * bpp;
360
384
band[dst..dst + cell_row_bytes]
361
385
.copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);
362
386
}
@@ -496,7 +520,13 @@ pub fn build_ppda(
496
520
fps,
497
521
depth_near,
498
522
depth_far,
499
depth_encoding: crate::camera::DEPTH_ENCODING.into(),
523
depth_encoding: crate::camera::DepthFormat::from_doc(render_doc)?
524
.encoding
525
.token()
526
.into(),
527
depth_bits: Some(u64::from(
528
crate::camera::DepthFormat::from_doc(render_doc)?.bits,
529
)),
500
530
cell_width: layout.cell_width,
501
531
cell_height: layout.cell_height,
502
532
pitches: PRODUCTION_PITCHES.to_vec(),
@@ -553,8 +583,19 @@ fn ident(msg: impl Into<String>) -> ExportError {
553
583
ExportError::Identity(msg.into())
554
584
}
555
585
586
impl Ppda {
587
/// Depth PNG format of the depth atlas.
588
pub fn depth_format(&self) -> Result<crate::camera::DepthFormat, ExportError> {
589
if (self.format_version >= 3) != self.depth_bits.is_some() {
590
return Err(ident(".ppda: depth_bits past niet bij format_version"));
591
}
592
crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding)
593
.map_err(|e| ident(format!(".ppda: {e}")))
594
}
595
}
596
556
597
fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {
557
if p.format_version != PPDA_FORMAT_VERSION {
598
if !(2..=PPDA_FORMAT_VERSION).contains(&p.format_version) {
558
599
return Err(ident(format!(
559
600
".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)",
560
601
p.format_version
@@ -603,9 +644,7 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
603
644
{
604
645
return Err(ident(".ppda: camera-/dieptecontract wijkt af"));
605
646
}
606
if p.depth_encoding != crate::camera::DEPTH_ENCODING {
607
return Err(ident(".ppda: onbekende depth_encoding"));
608
}
647
p.depth_format()?;
609
648
if p.fps == 0 || p.fps > 240 {
610
649
return Err(ident(".ppda: ongeldige fps"));
611
650
}
@@ -761,7 +800,8 @@ impl AtlasSet {
761
800
.map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?;
762
801
let info = reader.info();
763
802
let (color, depth) = if kind == MapKind::Depth {
764
(png::ColorType::GrayscaleAlpha, png::BitDepth::Sixteen)
803
let fmt = set.ppda.depth_format()?;
804
(fmt.color_type(), fmt.bit_depth())
765
805
} else {
766
806
(png::ColorType::Rgba, png::BitDepth::Eight)
767
807
};
@@ -818,6 +858,7 @@ impl AtlasSet {
818
858
self.layout.width,
819
859
self.layout.height,
820
860
MAX_ATLAS_DIM,
861
self.ppda.depth_format()?,
821
862
)?;
822
863
maps.insert(kind.token(), image);
823
864
}
@@ -1005,6 +1046,83 @@ mod tests {
1005
1046
fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
1006
1047
}
1007
1048
1049
#[test]
1050
fn all_depth_formats_round_trip_through_loose_pngs_atlas_and_reader() {
1051
use crate::camera::{DepthEncoding, DepthFormat};
1052
for bits in [8u8, 16] {
1053
for encoding in DepthEncoding::ALL {
1054
let fmt = DepthFormat::new(bits, encoding).unwrap();
1055
let tmp = Temp::new();
1056
let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1057
spec.clips = vec![("walk".into(), 2, 0.5)];
1058
spec.target = [2, 2];
1059
spec.depth_format = fmt;
1060
spec.write(&tmp.0, |_, f, p, y| Pixel {
1061
diffuse: [1, 2, 3, if y == 30 { 0 } else { 255 }],
1062
normal: if y == 30 {
1063
[0, 0, 0, 0]
1064
} else {
1065
[128, 128, 255, 255]
1066
},
1067
specular: if y == 30 {
1068
[0, 0, 0, 0]
1069
} else {
1070
[0, 0, 0, 255]
1071
},
1072
depth: 9000 + f as u16 * 20000 + (p + 60) as u16 * 50 + y as u16,
1073
})
1074
.unwrap();
1075
let set = AtlasSet::open_run(&tmp.0).unwrap();
1076
assert_eq!(set.ppda.depth_encoding, encoding.token());
1077
assert_eq!(set.ppda.depth_bits, Some(u64::from(bits)));
1078
let loaded = set.load().unwrap();
1079
let MapImage::Depth16 { samples, width, .. } = &loaded.maps["depth"] else {
1080
panic!("depth")
1081
};
1082
// Precision: one level of the format, in 16-bit canonical units.
1083
let tol = (65535. / fmt.levels() as f64).ceil() as i64;
1084
for f in 0..2u32 {
1085
for col in 0..set.layout.columns {
1086
let cell = set.layout.cell(0, col);
1087
let view = (cell.pitch, cell.yaw);
1088
let x = col * 2;
1089
let y = f * 2;
1090
let i = (y * width + x) as usize * 2;
1091
if view.1 == 30 {
1092
assert_eq!(&samples[i..i + 2], &[0, 0]);
1093
continue;
1094
}
1095
let want =
1096
9000 + f as i64 * 20000 + (view.0 + 60) as i64 * 50 + view.1 as i64;
1097
let got = samples[i] as i64;
1098
assert!((got - want).abs() <= tol, "{fmt:?}: {got} vs {want}");
1099
assert_eq!(samples[i + 1], 65535);
1100
}
1101
}
1102
crate::export_set::ExportRun::open(&tmp.0).unwrap();
1103
}
1104
}
1105
}
1106
1107
#[test]
1108
fn legacy_ppda_v2_gray16_is_still_read() {
1109
let tmp = fixture();
1110
rewrite_ppda(&tmp.0, |d| {
1111
d["format_version"] = 2.into();
1112
d.as_object_mut().unwrap().remove("depth_bits");
1113
d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1114
});
1115
let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1116
assert_eq!(
1117
set.ppda.depth_format().unwrap(),
1118
crate::camera::DepthFormat::GRAY16
1119
);
1120
set.load().unwrap();
1121
// A v3 file must say which format it uses.
1122
rewrite_ppda(&tmp.0, |d| d["format_version"] = 3.into());
1123
assert!(AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).is_err());
1124
}
1125
1008
1126
#[test]
1009
1127
fn layout_is_pitch_major_columns_and_frame_rows() {
1010
1128
let l = AtlasLayout::new(32, 48, 5).unwrap();
@@ -1032,12 +1150,13 @@ mod tests {
1032
1150
let tmp = fixture();
1033
1151
let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1034
1152
let p = &set.ppda;
1035
assert_eq!(p.format_version, 2);
1153
assert_eq!(p.format_version, 3);
1036
1154
assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1037
1155
let (near, far) = crate::camera::depth_range(p.identity.framing);
1038
1156
assert_eq!((p.depth_near, p.depth_far), (near, far));
1039
1157
assert_eq!((p.identity.near, p.identity.far), (near, far));
1040
assert_eq!(p.depth_encoding, crate::camera::DEPTH_ENCODING);
1158
assert_eq!(p.depth_encoding, "gray");
1159
assert_eq!(p.depth_bits, Some(16));
1041
1160
assert_eq!(p.fps, 4);
1042
1161
assert_eq!((p.cell_width, p.cell_height), (3, 2));
1043
1162
assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
@@ -1105,7 +1224,20 @@ mod tests {
1105
1224
Err(ExportError::Identity(_))
1106
1225
));
1107
1226
restore();
1108
rewrite_ppda(&tmp.0, |d| d["format_version"] = 3.into());
1227
rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());
1228
assert!(matches!(
1229
AtlasSet::open(&ppda),
1230
Err(ExportError::Identity(_))
1231
));
1232
restore();
1233
// Format 2 never carries depth_bits; format 3 always does.
1234
rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());
1235
assert!(matches!(
1236
AtlasSet::open(&ppda),
1237
Err(ExportError::Identity(_))
1238
));
1239
restore();
1240
rewrite_ppda(&tmp.0, |d| d["depth_encoding"] = "rgb_weird".into());
1109
1241
assert!(matches!(
1110
1242
AtlasSet::open(&ppda),
1111
1243
Err(ExportError::Identity(_))
@@ -29,7 +29,9 @@ use std::{29
29
30
/// `.ppda` schema version written and accepted.30
/// `.ppda` schema version written and accepted.31
/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).31
/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).32
pub const PPDA_FORMAT_VERSION: u32 = 2;32
/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is33
/// still read (16-bit gray).34
pub const PPDA_FORMAT_VERSION: u32 = 3;33
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are35
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are34
/// refused up front with a clear message; atlases are never split.36
/// refused up front with a clear message; atlases are never split.35
pub const MAX_ATLAS_DIM: u32 = 16384;37
pub const MAX_ATLAS_DIM: u32 = 16384;@@ -148,6 +150,9 @@ pub struct Ppda {148
pub depth_near: f32,150
pub depth_near: f32,149
pub depth_far: f32,151
pub depth_far: f32,150
pub depth_encoding: String,152
pub depth_encoding: String,153
/// 8 or 16 (format 3); absent in format 2 (= 16-bit gray).154
#[serde(default, skip_serializing_if = "Option::is_none")]155
pub depth_bits: Option<u64>,151
pub cell_width: u32,156
pub cell_width: u32,152
pub cell_height: u32,157
pub cell_height: u32,153
pub pitches: Vec<i32>,158
pub pitches: Vec<i32>,@@ -301,13 +306,29 @@ fn load_cell(301
if digest::sha256_hex(&bytes) != *expected {306
if digest::sha256_hex(&bytes) != *expected {302
return Err(format!("atlas: checksum van {rel} klopt niet meer"));307
return Err(format!("atlas: checksum van {rel} klopt niet meer"));303
}308
}304
match export_set::decode_map(&bytes, kind, job.cell_width, job.cell_height)309
if kind == MapKind::Depth {305
.map_err(|e| format!("atlas: {rel}: {e}"))?310
// Copied in its own encoding, byte-exact (lossless for every format).311
let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;312
return export_set::decode_depth_native(313
&bytes,314
job.cell_width,315
job.cell_height,316
export_set::MAX_TARGET,317
fmt,318
)319
.map_err(|e| format!("atlas: {rel}: {e}"));320
}321
match export_set::decode_map(322
&bytes,323
kind,324
job.cell_width,325
job.cell_height,326
crate::camera::DepthFormat::GRAY16,327
)328
.map_err(|e| format!("atlas: {rel}: {e}"))?306
{329
{307
MapImage::Rgba8 { rgba, .. } => Ok(rgba),330
MapImage::Rgba8 { rgba, .. } => Ok(rgba),308
MapImage::Depth16 { samples, .. } => {331
MapImage::Depth16 { .. } => Err("atlas: onverwachte depth".into()),309
Ok(samples.iter().flat_map(|s| s.to_be_bytes()).collect())310
}311
}332
}312
}333
}313
334
@@ -320,9 +341,12 @@ fn encode_atlas(320
progress: &mut dyn FnMut(String),341
progress: &mut dyn FnMut(String),321
) -> Result<(), String> {342
) -> Result<(), String> {322
let mut encoder = png::Encoder::new(out, layout.width, layout.height);343
let mut encoder = png::Encoder::new(out, layout.width, layout.height);344
let mut bpp = 4;323
if kind == MapKind::Depth {345
if kind == MapKind::Depth {324
encoder.set_color(png::ColorType::GrayscaleAlpha);346
let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;325
encoder.set_depth(png::BitDepth::Sixteen);347
encoder.set_color(fmt.color_type());348
encoder.set_depth(fmt.bit_depth());349
bpp = fmt.bytes_per_pixel();326
} else {350
} else {327
encoder.set_color(png::ColorType::Rgba);351
encoder.set_color(png::ColorType::Rgba);328
encoder.set_depth(png::BitDepth::Eight);352
encoder.set_depth(png::BitDepth::Eight);@@ -331,8 +355,8 @@ fn encode_atlas(331
}355
}332
}356
}333
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;357
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;334
let row_bytes = layout.width as usize * 4;358
let row_bytes = layout.width as usize * bpp;335
let cell_row_bytes = layout.cell_width as usize * 4;359
let cell_row_bytes = layout.cell_width as usize * bpp;336
let mut stream = writer360
let mut stream = writer337
.stream_writer_with_size(row_bytes.max(4096))361
.stream_writer_with_size(row_bytes.max(4096))338
.map_err(|e| e.to_string())?;362
.map_err(|e| e.to_string())?;@@ -356,7 +380,7 @@ fn encode_atlas(356
let view = layout.cell(0, column);380
let view = layout.cell(0, column);357
let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;381
let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;358
for y in 0..layout.cell_height as usize {382
for y in 0..layout.cell_height as usize {359
let dst = y * row_bytes + view.x as usize * 4;383
let dst = y * row_bytes + view.x as usize * bpp;360
band[dst..dst + cell_row_bytes]384
band[dst..dst + cell_row_bytes]361
.copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);385
.copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);362
}386
}@@ -496,7 +520,13 @@ pub fn build_ppda(496
fps,520
fps,497
depth_near,521
depth_near,498
depth_far,522
depth_far,499
depth_encoding: crate::camera::DEPTH_ENCODING.into(),523
depth_encoding: crate::camera::DepthFormat::from_doc(render_doc)?524
.encoding525
.token()526
.into(),527
depth_bits: Some(u64::from(528
crate::camera::DepthFormat::from_doc(render_doc)?.bits,529
)),500
cell_width: layout.cell_width,530
cell_width: layout.cell_width,501
cell_height: layout.cell_height,531
cell_height: layout.cell_height,502
pitches: PRODUCTION_PITCHES.to_vec(),532
pitches: PRODUCTION_PITCHES.to_vec(),@@ -553,8 +583,19 @@ fn ident(msg: impl Into<String>) -> ExportError {553
ExportError::Identity(msg.into())583
ExportError::Identity(msg.into())554
}584
}555
585
586
impl Ppda {587
/// Depth PNG format of the depth atlas.588
pub fn depth_format(&self) -> Result<crate::camera::DepthFormat, ExportError> {589
if (self.format_version >= 3) != self.depth_bits.is_some() {590
return Err(ident(".ppda: depth_bits past niet bij format_version"));591
}592
crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding)593
.map_err(|e| ident(format!(".ppda: {e}")))594
}595
}596
556
fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {597
fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {557
if p.format_version != PPDA_FORMAT_VERSION {598
if !(2..=PPDA_FORMAT_VERSION).contains(&p.format_version) {558
return Err(ident(format!(599
return Err(ident(format!(559
".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)",600
".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)",560
p.format_version601
p.format_version@@ -603,9 +644,7 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>603
{644
{604
return Err(ident(".ppda: camera-/dieptecontract wijkt af"));645
return Err(ident(".ppda: camera-/dieptecontract wijkt af"));605
}646
}606
if p.depth_encoding != crate::camera::DEPTH_ENCODING {647
p.depth_format()?;607
return Err(ident(".ppda: onbekende depth_encoding"));608
}609
if p.fps == 0 || p.fps > 240 {648
if p.fps == 0 || p.fps > 240 {610
return Err(ident(".ppda: ongeldige fps"));649
return Err(ident(".ppda: ongeldige fps"));611
}650
}@@ -761,7 +800,8 @@ impl AtlasSet {761
.map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?;800
.map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?;762
let info = reader.info();801
let info = reader.info();763
let (color, depth) = if kind == MapKind::Depth {802
let (color, depth) = if kind == MapKind::Depth {764
(png::ColorType::GrayscaleAlpha, png::BitDepth::Sixteen)803
let fmt = set.ppda.depth_format()?;804
(fmt.color_type(), fmt.bit_depth())765
} else {805
} else {766
(png::ColorType::Rgba, png::BitDepth::Eight)806
(png::ColorType::Rgba, png::BitDepth::Eight)767
};807
};@@ -818,6 +858,7 @@ impl AtlasSet {818
self.layout.width,858
self.layout.width,819
self.layout.height,859
self.layout.height,820
MAX_ATLAS_DIM,860
MAX_ATLAS_DIM,861
self.ppda.depth_format()?,821
)?;862
)?;822
maps.insert(kind.token(), image);863
maps.insert(kind.token(), image);823
}864
}@@ -1005,6 +1046,83 @@ mod tests {1005
fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();1046
fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();1006
}1047
}1007
1048
1049
#[test]1050
fn all_depth_formats_round_trip_through_loose_pngs_atlas_and_reader() {1051
use crate::camera::{DepthEncoding, DepthFormat};1052
for bits in [8u8, 16] {1053
for encoding in DepthEncoding::ALL {1054
let fmt = DepthFormat::new(bits, encoding).unwrap();1055
let tmp = Temp::new();1056
let mut spec = SyntheticRun::body("atlas-test", "female", b"body");1057
spec.clips = vec![("walk".into(), 2, 0.5)];1058
spec.target = [2, 2];1059
spec.depth_format = fmt;1060
spec.write(&tmp.0, |_, f, p, y| Pixel {1061
diffuse: [1, 2, 3, if y == 30 { 0 } else { 255 }],1062
normal: if y == 30 {1063
[0, 0, 0, 0]1064
} else {1065
[128, 128, 255, 255]1066
},1067
specular: if y == 30 {1068
[0, 0, 0, 0]1069
} else {1070
[0, 0, 0, 255]1071
},1072
depth: 9000 + f as u16 * 20000 + (p + 60) as u16 * 50 + y as u16,1073
})1074
.unwrap();1075
let set = AtlasSet::open_run(&tmp.0).unwrap();1076
assert_eq!(set.ppda.depth_encoding, encoding.token());1077
assert_eq!(set.ppda.depth_bits, Some(u64::from(bits)));1078
let loaded = set.load().unwrap();1079
let MapImage::Depth16 { samples, width, .. } = &loaded.maps["depth"] else {1080
panic!("depth")1081
};1082
// Precision: one level of the format, in 16-bit canonical units.1083
let tol = (65535. / fmt.levels() as f64).ceil() as i64;1084
for f in 0..2u32 {1085
for col in 0..set.layout.columns {1086
let cell = set.layout.cell(0, col);1087
let view = (cell.pitch, cell.yaw);1088
let x = col * 2;1089
let y = f * 2;1090
let i = (y * width + x) as usize * 2;1091
if view.1 == 30 {1092
assert_eq!(&samples[i..i + 2], &[0, 0]);1093
continue;1094
}1095
let want =1096
9000 + f as i64 * 20000 + (view.0 + 60) as i64 * 50 + view.1 as i64;1097
let got = samples[i] as i64;1098
assert!((got - want).abs() <= tol, "{fmt:?}: {got} vs {want}");1099
assert_eq!(samples[i + 1], 65535);1100
}1101
}1102
crate::export_set::ExportRun::open(&tmp.0).unwrap();1103
}1104
}1105
}1106
1107
#[test]1108
fn legacy_ppda_v2_gray16_is_still_read() {1109
let tmp = fixture();1110
rewrite_ppda(&tmp.0, |d| {1111
d["format_version"] = 2.into();1112
d.as_object_mut().unwrap().remove("depth_bits");1113
d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();1114
});1115
let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();1116
assert_eq!(1117
set.ppda.depth_format().unwrap(),1118
crate::camera::DepthFormat::GRAY161119
);1120
set.load().unwrap();1121
// A v3 file must say which format it uses.1122
rewrite_ppda(&tmp.0, |d| d["format_version"] = 3.into());1123
assert!(AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).is_err());1124
}1125
1008
#[test]1126
#[test]1009
fn layout_is_pitch_major_columns_and_frame_rows() {1127
fn layout_is_pitch_major_columns_and_frame_rows() {1010
let l = AtlasLayout::new(32, 48, 5).unwrap();1128
let l = AtlasLayout::new(32, 48, 5).unwrap();@@ -1032,12 +1150,13 @@ mod tests {1032
let tmp = fixture();1150
let tmp = fixture();1033
let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();1151
let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();1034
let p = &set.ppda;1152
let p = &set.ppda;1035
assert_eq!(p.format_version, 2);1153
assert_eq!(p.format_version, 3);1036
assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));1154
assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));1037
let (near, far) = crate::camera::depth_range(p.identity.framing);1155
let (near, far) = crate::camera::depth_range(p.identity.framing);1038
assert_eq!((p.depth_near, p.depth_far), (near, far));1156
assert_eq!((p.depth_near, p.depth_far), (near, far));1039
assert_eq!((p.identity.near, p.identity.far), (near, far));1157
assert_eq!((p.identity.near, p.identity.far), (near, far));1040
assert_eq!(p.depth_encoding, crate::camera::DEPTH_ENCODING);1158
assert_eq!(p.depth_encoding, "gray");1159
assert_eq!(p.depth_bits, Some(16));1041
assert_eq!(p.fps, 4);1160
assert_eq!(p.fps, 4);1042
assert_eq!((p.cell_width, p.cell_height), (3, 2));1161
assert_eq!((p.cell_width, p.cell_height), (3, 2));1043
assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);1162
assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);@@ -1105,7 +1224,20 @@ mod tests {1105
Err(ExportError::Identity(_))1224
Err(ExportError::Identity(_))1106
));1225
));1107
restore();1226
restore();1108
rewrite_ppda(&tmp.0, |d| d["format_version"] = 3.into());1227
rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());1228
assert!(matches!(1229
AtlasSet::open(&ppda),1230
Err(ExportError::Identity(_))1231
));1232
restore();1233
// Format 2 never carries depth_bits; format 3 always does.1234
rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());1235
assert!(matches!(1236
AtlasSet::open(&ppda),1237
Err(ExportError::Identity(_))1238
));1239
restore();1240
rewrite_ppda(&tmp.0, |d| d["depth_encoding"] = "rgb_weird".into());1109
assert!(matches!(1241
assert!(matches!(1110
AtlasSet::open(&ppda),1242
AtlasSet::open(&ppda),1111
Err(ExportError::Identity(_))1243
Err(ExportError::Identity(_))@@ -105,6 +105,9 @@ pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
105
105
Ok((joint, height as i32))
106
106
}
107
107
pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
108
if framing.depth_range_milli.is_none() {
109
framing.depth_range_milli = Some(auto_depth_range_milli(body));
110
}
108
111
if framing.pivot_y_milli.is_some() {
109
112
return Resolved {
110
113
framing,
@@ -141,6 +144,10 @@ pub const DEPTH_ENCODING: &str = "linear: distance = depth_near + (sample/65535)
141
144
/// farthest edge, so geometry inside the pitch-0 frame stays in range at
142
145
/// pitch ±60 too. Shared by every clip, frame, view and equipment of that sex.
143
146
pub fn depth_range(framing: CameraFraming) -> (f32, f32) {
147
if let Some(total) = framing.depth_range_milli {
148
let r = total as f32 / 2000.;
149
return (CAMERA_DISTANCE - r, CAMERA_DISTANCE + r);
150
}
144
151
let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli);
145
152
let offset = (i64::from(pivot) - i64::from(framing.center_y_milli)).unsigned_abs();
146
153
let r = ((u64::from(framing.height_milli) / 2 + offset) as f32 / 1000.).max(MIN_DEPTH_HALF);
@@ -163,6 +170,298 @@ pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (M
163
170
(preview::view_matrix(eye, target), p)
164
171
}
165
172
173
/// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u).
174
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)]
175
#[serde(rename_all = "snake_case")]
176
pub enum DepthEncoding {
177
#[default]
178
Gray,
179
RgbBands,
180
RgbPacked,
181
}
182
impl DepthEncoding {
183
pub const ALL: [DepthEncoding; 3] = [Self::Gray, Self::RgbBands, Self::RgbPacked];
184
pub fn token(self) -> &'static str {
185
match self {
186
Self::Gray => "gray",
187
Self::RgbBands => "rgb_bands",
188
Self::RgbPacked => "rgb_packed",
189
}
190
}
191
pub fn parse(token: &str) -> Option<Self> {
192
Self::ALL.into_iter().find(|e| e.token() == token)
193
}
194
pub fn label(self) -> &'static str {
195
match self {
196
Self::Gray => "Grijs",
197
Self::RgbBands => "RGB-banden",
198
Self::RgbPacked => "RGB-gepakt (nearest, lossless)",
199
}
200
}
201
}
202
203
/// Depth PNG format: encoding × bit depth (8 or 16).
204
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
205
pub struct DepthFormat {
206
pub bits: u8,
207
pub encoding: DepthEncoding,
208
}
209
impl Default for DepthFormat {
210
fn default() -> Self {
211
Self {
212
bits: 8,
213
encoding: DepthEncoding::Gray,
214
}
215
}
216
}
217
impl DepthFormat {
218
/// Format of every export before t983u (and of manifests without depth_bits).
219
pub const GRAY16: DepthFormat = DepthFormat {
220
bits: 16,
221
encoding: DepthEncoding::Gray,
222
};
223
/// Format recorded in a manifest: `depth_bits` + `depth_encoding` token, or
224
/// the legacy schema (no `depth_bits`, long legacy text) = 16-bit gray.
225
pub fn from_manifest(bits: Option<u64>, encoding: &str) -> Result<Self, String> {
226
match bits {
227
None if encoding == DEPTH_ENCODING => Ok(Self::GRAY16),
228
None => Err("depth_encoding onbekend".into()),
229
Some(b) => Self::new(
230
u8::try_from(b).map_err(|_| "depth_bits moet 8 of 16 zijn")?,
231
DepthEncoding::parse(encoding).ok_or("depth_encoding onbekend")?,
232
),
233
}
234
}
235
/// Format recorded in a render.json document.
236
pub fn from_doc(doc: &serde_json::Value) -> Result<Self, String> {
237
Self::from_manifest(
238
doc.get("depth_bits").and_then(serde_json::Value::as_u64),
239
doc.get("depth_encoding")
240
.and_then(serde_json::Value::as_str)
241
.unwrap_or(""),
242
)
243
}
244
pub fn new(bits: u8, encoding: DepthEncoding) -> Result<Self, String> {
245
if bits != 8 && bits != 16 {
246
return Err("depth_bits moet 8 of 16 zijn".into());
247
}
248
Ok(Self { bits, encoding })
249
}
250
pub fn max(self) -> u64 {
251
(1u64 << self.bits) - 1
252
}
253
/// Channels per pixel (value channels + alpha).
254
pub fn channels(self) -> usize {
255
match self.encoding {
256
DepthEncoding::Gray => 2,
257
_ => 4,
258
}
259
}
260
pub fn bytes_per_pixel(self) -> usize {
261
self.channels() * usize::from(self.bits / 8)
262
}
263
pub fn color_type(self) -> png::ColorType {
264
match self.encoding {
265
DepthEncoding::Gray => png::ColorType::GrayscaleAlpha,
266
_ => png::ColorType::Rgba,
267
}
268
}
269
pub fn bit_depth(self) -> png::BitDepth {
270
if self.bits == 8 {
271
png::BitDepth::Eight
272
} else {
273
png::BitDepth::Sixteen
274
}
275
}
276
/// Number of distinct depth levels minus one.
277
pub fn levels(self) -> u64 {
278
let m = self.max();
279
match self.encoding {
280
DepthEncoding::Gray => m,
281
DepthEncoding::RgbBands => 3 * m,
282
DepthEncoding::RgbPacked => (1u64 << (3 * self.bits as u32)) - 1,
283
}
284
}
285
/// Human-readable formula written to render.json.
286
pub fn description(self) -> String {
287
let fmt = format!("{}-bit", self.bits);
288
let head = match self.encoding {
289
DepthEncoding::Gray => format!("{fmt} gray+alpha; t=gray/{}", self.max()),
290
DepthEncoding::RgbBands => format!(
291
"{fmt} RGBA bands; v=round(t*{}), R=min(v,M), G=clamp(v-M,0,M), B=clamp(v-2M,0,M), M={}; t=(R+G+B)/{}",
292
self.levels(), self.max(), self.levels()
293
),
294
DepthEncoding::RgbPacked => format!(
295
"{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only",
296
self.max() + 1, self.levels()
297
),
298
};
299
head + "; distance=depth_near+t*(depth_far-depth_near), clamped"
300
}
301
/// Channel values (without alpha) for normalized depth `t` in 0..1.
302
pub fn encode_value(self, t: f64) -> [u64; 3] {
303
let t = if t.is_finite() { t.clamp(0., 1.) } else { 0. };
304
let m = self.max();
305
let v = (t * self.levels() as f64).round() as u64;
306
match self.encoding {
307
DepthEncoding::Gray => [v, 0, 0],
308
DepthEncoding::RgbBands => [
309
v.min(m),
310
v.saturating_sub(m).min(m),
311
v.saturating_sub(2 * m).min(m),
312
],
313
DepthEncoding::RgbPacked => {
314
let b = m + 1;
315
[v / (b * b), (v / b) % b, v % b]
316
}
317
}
318
}
319
/// Normalized depth from channel values.
320
pub fn decode_value(self, c: [u64; 3]) -> f64 {
321
let b = self.max() + 1;
322
let v = match self.encoding {
323
DepthEncoding::Gray => c[0],
324
DepthEncoding::RgbBands => c[0] + c[1] + c[2],
325
DepthEncoding::RgbPacked => c[0] * b * b + c[1] * b + c[2],
326
};
327
v as f64 / self.levels() as f64
328
}
329
/// Encode one pixel (t, alpha 0..1) as big-endian PNG sample bytes.
330
pub fn encode_pixel(self, t: f64, alpha: f64, out: &mut Vec<u8>) {
331
let c = self.encode_value(t);
332
let a = (alpha.clamp(0., 1.) * self.max() as f64).round() as u64;
333
let mut push = |x: u64| {
334
if self.bits == 8 {
335
out.push(x as u8);
336
} else {
337
out.extend_from_slice(&(x as u16).to_be_bytes());
338
}
339
};
340
match self.encoding {
341
DepthEncoding::Gray => {
342
push(c[0]);
343
push(a);
344
}
345
_ => {
346
push(c[0]);
347
push(c[1]);
348
push(c[2]);
349
push(a);
350
}
351
}
352
}
353
/// Decode one pixel of `bytes_per_pixel` bytes into canonical 16-bit
354
/// (depth, alpha) samples as used by the compositor (gray16 scale).
355
pub fn decode_pixel(self, px: &[u8]) -> Result<(u16, u16), String> {
356
let read = |i: usize| -> u64 {
357
if self.bits == 8 {
358
u64::from(px[i])
359
} else {
360
u64::from(u16::from_be_bytes([px[2 * i], px[2 * i + 1]]))
361
}
362
};
363
let (c, a) = match self.encoding {
364
DepthEncoding::Gray => ([read(0), 0, 0], read(1)),
365
_ => ([read(0), read(1), read(2)], read(3)),
366
};
367
if self.encoding == DepthEncoding::RgbBands {
368
let m = self.max();
369
// Bands fill in order: G only when R is full, B only when G is full.
370
if (c[1] > 0 && c[0] != m) || (c[2] > 0 && c[1] != m) {
371
return Err("depth rgb_bands: ongeldige bandvolgorde".into());
372
}
373
}
374
let t = self.decode_value(c);
375
let scale = 65535. / self.max() as f64;
376
Ok((
377
(t * 65535.).round() as u16,
378
(a as f64 * scale).round() as u16,
379
))
380
}
381
}
382
383
/// Default per-sex depth range (t985u): T-pose arm span plus one arm length
384
/// on each side, in millimetres (total). Pose-independent: with identifiable
385
/// left/right upper-arm and hand joints, arm = |upper arm → hand| × 1.2 (hand
386
/// and fingers) and span = shoulder width + 2·arm, so A-/I-pose binds give the
387
/// same result as a T-pose. Otherwise span = max(bind width, bind height) and
388
/// arm = 0.4·span.
389
pub fn auto_depth_range_milli(body: &PreviewCharacter) -> u32 {
390
let (span, arm) = match arm_measure(body) {
391
Some((shoulders, arm)) => (shoulders + 2. * arm, arm),
392
None => {
393
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
394
for p in &body.positions {
395
for i in 0..2 {
396
if p[i].is_finite() {
397
lo[i] = lo[i].min(p[i]);
398
hi[i] = hi[i].max(p[i]);
399
}
400
}
401
}
402
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]);
403
let span = if span.is_finite() && span > 0. {
404
span
405
} else {
406
1.0
407
};
408
(span, 0.4 * span)
409
}
410
};
411
(((span + 2. * arm) * 1000.).round() as u32).clamp(MIN_DEPTH_RANGE_MILLI, MAX_DEPTH_RANGE_MILLI)
412
}
413
pub const MIN_DEPTH_RANGE_MILLI: u32 = 100;
414
pub const MAX_DEPTH_RANGE_MILLI: u32 = 19000;
415
416
/// (shoulder width, arm length) from rest-pose joints: left/right upper arm
417
/// and hand, identified by name. None when either side is missing.
418
pub fn arm_measure(body: &PreviewCharacter) -> Option<(f32, f32)> {
419
if body.is_static() {
420
return None;
421
}
422
// (left?, kind) with kind 0 = upper arm, 1 = hand.
423
let classify = |name: &str| -> Option<(bool, u8)> {
424
let l = name.to_ascii_lowercase();
425
let kind = if l.contains("upperarm") || l.contains("upper_arm") || l.contains("arm_upper") {
426
0
427
} else if l.contains("hand") && !l.contains("handle") {
428
1
429
} else {
430
return None;
431
};
432
let tokens: Vec<&str> = l.split(|c: char| !c.is_ascii_alphanumeric()).collect();
433
let left = l.contains("left") || tokens.contains(&"l") || l.starts_with("l_");
434
let right = l.contains("right") || tokens.contains(&"r") || l.starts_with("r_");
435
match (left, right) {
436
(true, false) => Some((true, kind)),
437
(false, true) => Some((false, kind)),
438
_ => None,
439
}
440
};
441
let mut found: [[Option<[f32; 3]>; 2]; 2] = [[None; 2]; 2];
442
for (j, name) in body.joint_names.iter().enumerate() {
443
let Some((left, kind)) = classify(name) else {
444
continue;
445
};
446
let slot = &mut found[usize::from(left)][kind as usize];
447
if slot.is_some() {
448
continue; // first match (closest to the root in joint order)
449
}
450
if let Some(w) = preview::rest_joint_world(body, j) {
451
*slot = Some([w[12], w[13], w[14]]);
452
}
453
}
454
let dist = |a: [f32; 3], b: [f32; 3]| {
455
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
456
};
457
let [[Some(ru), Some(rh)], [Some(lu), Some(lh)]] = found else {
458
return None;
459
};
460
let arm = (dist(lu, lh) + dist(ru, rh)) / 2. * 1.2;
461
let shoulders = dist(lu, ru);
462
(arm.is_finite() && arm > 0. && shoulders.is_finite()).then_some((shoulders, arm))
463
}
464
166
465
#[cfg(test)]
167
466
mod tests {
168
467
use super::*;
@@ -293,6 +592,7 @@ mod tests {
293
592
height_milli: 2600,
294
593
center_y_milli: 1000,
295
594
pivot_y_milli: None,
595
depth_range_milli: None,
296
596
},
297
597
);
298
598
assert!(matches!(auto.source, PivotSource::Neck { .. }));
@@ -303,6 +603,7 @@ mod tests {
303
603
height_milli: 2600,
304
604
center_y_milli: 1000,
305
605
pivot_y_milli: Some(1800),
606
depth_range_milli: None,
306
607
},
307
608
);
308
609
assert_eq!(manual.source, PivotSource::Manual);
@@ -313,6 +614,7 @@ mod tests {
313
614
height_milli: 2600,
314
615
center_y_milli: 900,
315
616
pivot_y_milli: None,
617
depth_range_milli: None,
316
618
},
317
619
);
318
620
assert!(matches!(missing.source, PivotSource::Fallback { .. }));
@@ -325,6 +627,7 @@ mod tests {
325
627
height_milli: 2600,
326
628
center_y_milli: 1000,
327
629
pivot_y_milli: Some(1600),
630
depth_range_milli: None,
328
631
};
329
632
for yaw in [0., 90., 180., 270.] {
330
633
let (view, projection) = matrices(framing, yaw, 0., 1.);
@@ -348,6 +651,7 @@ mod tests {
348
651
height_milli: 3000,
349
652
center_y_milli: 1100,
350
653
pivot_y_milli: Some(1500),
654
depth_range_milli: None,
351
655
};
352
656
let mut pivot_ndcs = Vec::new();
353
657
for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {
@@ -414,4 +718,98 @@ mod tests {
414
718
resolved.framing.center_y_milli
415
719
);
416
720
}
721
722
#[test]
723
fn depth_codecs_follow_the_documented_formulas() {
724
use super::{DepthEncoding as E, DepthFormat as F};
725
let f = |bits, e| F::new(bits, e).unwrap();
726
// gray
727
assert_eq!(f(8, E::Gray).encode_value(1.), [255, 0, 0]);
728
assert_eq!(f(16, E::Gray).encode_value(0.5), [32768, 0, 0]);
729
// rgb_bands: 766 levels at 8 bit
730
let b8 = f(8, E::RgbBands);
731
assert_eq!(b8.levels() + 1, 766);
732
assert_eq!(b8.encode_value(0.), [0, 0, 0]);
733
assert_eq!(b8.encode_value(100. / 765.), [100, 0, 0]);
734
assert_eq!(b8.encode_value(300. / 765.), [255, 45, 0]);
735
assert_eq!(b8.encode_value(1.), [255, 255, 255]);
736
assert_eq!(f(16, E::RgbBands).encode_value(1.), [65535, 65535, 65535]);
737
// rgb_packed: positional base 256 / 65536
738
let p8 = f(8, E::RgbPacked);
739
let v: u64 = 0x12_34_56;
740
assert_eq!(p8.encode_value(v as f64 / 16_777_215.), [0x12, 0x34, 0x56]);
741
let p16 = f(16, E::RgbPacked);
742
assert_eq!(p16.levels(), (1u64 << 48) - 1);
743
let v: u64 = 0x1234_5678_9abc;
744
assert_eq!(
745
p16.encode_value(v as f64 / p16.levels() as f64),
746
[0x1234, 0x5678, 0x9abc]
747
);
748
// Round trip and monotonic for every combination.
749
for bits in [8, 16] {
750
for e in E::ALL {
751
let fmt = f(bits, e);
752
let mut last = -1.;
753
for k in 0..=1000 {
754
let t = k as f64 / 1000.;
755
let back = fmt.decode_value(fmt.encode_value(t));
756
assert!(
757
(back - t).abs() <= 0.5 / fmt.levels() as f64 + 1e-12,
758
"{fmt:?} {t}"
759
);
760
assert!(back >= last);
761
last = back;
762
let mut px = Vec::new();
763
fmt.encode_pixel(t, 1., &mut px);
764
assert_eq!(px.len(), fmt.bytes_per_pixel());
765
let (d, a) = fmt.decode_pixel(&px).unwrap();
766
assert!(
767
(f64::from(d) / 65535. - t).abs() <= 1. / fmt.levels().min(65535) as f64
768
);
769
assert_eq!(a, 65535);
770
}
771
}
772
}
773
// Bands must fill in order.
774
assert!(b8.decode_pixel(&[10, 5, 0, 255]).is_err());
775
assert_eq!(F::default(), f(8, E::Gray));
776
assert!(F::new(12, E::Gray).is_err());
777
}
778
779
#[test]
780
fn auto_depth_range_is_arm_span_plus_arm_margin_and_override_wins() {
781
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
782
.join("tests/fixtures/bodies/test-body-male.glb");
783
let body = crate::preview::load_body_file(&path).unwrap();
784
let total = auto_depth_range_milli(&body) as f32 / 1000.;
785
// Test body is bound with arms down; the measure is pose-independent.
786
let (shoulders, arm) = arm_measure(&body).expect("arm joints");
787
let span = shoulders + 2. * arm;
788
assert!(
789
(total - (span + 2. * arm)).abs() < 0.002,
790
"{total} {span} {arm}"
791
);
792
assert!(span > 1.2 && span < 2.4, "T-pose span {span}");
793
let (lo, hi) = body
794
.positions
795
.iter()
796
.fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[0]), b.max(p[0])));
797
assert!(
798
span > hi - lo,
799
"arms-down width is smaller than the T-pose span"
800
);
801
let resolved = resolve(&body, CameraFraming::default());
802
assert!(matches!(
803
resolved.source,
804
PivotSource::Neck {
805
joint_name: ref name,
806
..
807
} if name == "Neck"
808
));
809
assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
810
assert_ne!(
811
resolved.framing.pivot_y_milli.unwrap(),
812
resolved.framing.center_y_milli
813
);
814
}
417
815
}
@@ -105,6 +105,9 @@ pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {105
Ok((joint, height as i32))105
Ok((joint, height as i32))106
}106
}107
pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {107
pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {108
if framing.depth_range_milli.is_none() {109
framing.depth_range_milli = Some(auto_depth_range_milli(body));110
}108
if framing.pivot_y_milli.is_some() {111
if framing.pivot_y_milli.is_some() {109
return Resolved {112
return Resolved {110
framing,113
framing,@@ -141,6 +144,10 @@ pub const DEPTH_ENCODING: &str = "linear: distance = depth_near + (sample/65535)141
/// farthest edge, so geometry inside the pitch-0 frame stays in range at144
/// farthest edge, so geometry inside the pitch-0 frame stays in range at142
/// pitch ±60 too. Shared by every clip, frame, view and equipment of that sex.145
/// pitch ±60 too. Shared by every clip, frame, view and equipment of that sex.143
pub fn depth_range(framing: CameraFraming) -> (f32, f32) {146
pub fn depth_range(framing: CameraFraming) -> (f32, f32) {147
if let Some(total) = framing.depth_range_milli {148
let r = total as f32 / 2000.;149
return (CAMERA_DISTANCE - r, CAMERA_DISTANCE + r);150
}144
let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli);151
let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli);145
let offset = (i64::from(pivot) - i64::from(framing.center_y_milli)).unsigned_abs();152
let offset = (i64::from(pivot) - i64::from(framing.center_y_milli)).unsigned_abs();146
let r = ((u64::from(framing.height_milli) / 2 + offset) as f32 / 1000.).max(MIN_DEPTH_HALF);153
let r = ((u64::from(framing.height_milli) / 2 + offset) as f32 / 1000.).max(MIN_DEPTH_HALF);@@ -163,6 +170,298 @@ pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (M163
(preview::view_matrix(eye, target), p)170
(preview::view_matrix(eye, target), p)164
}171
}165
172
173
/// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u).174
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)]175
#[serde(rename_all = "snake_case")]176
pub enum DepthEncoding {177
#[default]178
Gray,179
RgbBands,180
RgbPacked,181
}182
impl DepthEncoding {183
pub const ALL: [DepthEncoding; 3] = [Self::Gray, Self::RgbBands, Self::RgbPacked];184
pub fn token(self) -> &'static str {185
match self {186
Self::Gray => "gray",187
Self::RgbBands => "rgb_bands",188
Self::RgbPacked => "rgb_packed",189
}190
}191
pub fn parse(token: &str) -> Option<Self> {192
Self::ALL.into_iter().find(|e| e.token() == token)193
}194
pub fn label(self) -> &'static str {195
match self {196
Self::Gray => "Grijs",197
Self::RgbBands => "RGB-banden",198
Self::RgbPacked => "RGB-gepakt (nearest, lossless)",199
}200
}201
}202
203
/// Depth PNG format: encoding × bit depth (8 or 16).204
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]205
pub struct DepthFormat {206
pub bits: u8,207
pub encoding: DepthEncoding,208
}209
impl Default for DepthFormat {210
fn default() -> Self {211
Self {212
bits: 8,213
encoding: DepthEncoding::Gray,214
}215
}216
}217
impl DepthFormat {218
/// Format of every export before t983u (and of manifests without depth_bits).219
pub const GRAY16: DepthFormat = DepthFormat {220
bits: 16,221
encoding: DepthEncoding::Gray,222
};223
/// Format recorded in a manifest: `depth_bits` + `depth_encoding` token, or224
/// the legacy schema (no `depth_bits`, long legacy text) = 16-bit gray.225
pub fn from_manifest(bits: Option<u64>, encoding: &str) -> Result<Self, String> {226
match bits {227
None if encoding == DEPTH_ENCODING => Ok(Self::GRAY16),228
None => Err("depth_encoding onbekend".into()),229
Some(b) => Self::new(230
u8::try_from(b).map_err(|_| "depth_bits moet 8 of 16 zijn")?,231
DepthEncoding::parse(encoding).ok_or("depth_encoding onbekend")?,232
),233
}234
}235
/// Format recorded in a render.json document.236
pub fn from_doc(doc: &serde_json::Value) -> Result<Self, String> {237
Self::from_manifest(238
doc.get("depth_bits").and_then(serde_json::Value::as_u64),239
doc.get("depth_encoding")240
.and_then(serde_json::Value::as_str)241
.unwrap_or(""),242
)243
}244
pub fn new(bits: u8, encoding: DepthEncoding) -> Result<Self, String> {245
if bits != 8 && bits != 16 {246
return Err("depth_bits moet 8 of 16 zijn".into());247
}248
Ok(Self { bits, encoding })249
}250
pub fn max(self) -> u64 {251
(1u64 << self.bits) - 1252
}253
/// Channels per pixel (value channels + alpha).254
pub fn channels(self) -> usize {255
match self.encoding {256
DepthEncoding::Gray => 2,257
_ => 4,258
}259
}260
pub fn bytes_per_pixel(self) -> usize {261
self.channels() * usize::from(self.bits / 8)262
}263
pub fn color_type(self) -> png::ColorType {264
match self.encoding {265
DepthEncoding::Gray => png::ColorType::GrayscaleAlpha,266
_ => png::ColorType::Rgba,267
}268
}269
pub fn bit_depth(self) -> png::BitDepth {270
if self.bits == 8 {271
png::BitDepth::Eight272
} else {273
png::BitDepth::Sixteen274
}275
}276
/// Number of distinct depth levels minus one.277
pub fn levels(self) -> u64 {278
let m = self.max();279
match self.encoding {280
DepthEncoding::Gray => m,281
DepthEncoding::RgbBands => 3 * m,282
DepthEncoding::RgbPacked => (1u64 << (3 * self.bits as u32)) - 1,283
}284
}285
/// Human-readable formula written to render.json.286
pub fn description(self) -> String {287
let fmt = format!("{}-bit", self.bits);288
let head = match self.encoding {289
DepthEncoding::Gray => format!("{fmt} gray+alpha; t=gray/{}", self.max()),290
DepthEncoding::RgbBands => format!(291
"{fmt} RGBA bands; v=round(t*{}), R=min(v,M), G=clamp(v-M,0,M), B=clamp(v-2M,0,M), M={}; t=(R+G+B)/{}",292
self.levels(), self.max(), self.levels()293
),294
DepthEncoding::RgbPacked => format!(295
"{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only",296
self.max() + 1, self.levels()297
),298
};299
head + "; distance=depth_near+t*(depth_far-depth_near), clamped"300
}301
/// Channel values (without alpha) for normalized depth `t` in 0..1.302
pub fn encode_value(self, t: f64) -> [u64; 3] {303
let t = if t.is_finite() { t.clamp(0., 1.) } else { 0. };304
let m = self.max();305
let v = (t * self.levels() as f64).round() as u64;306
match self.encoding {307
DepthEncoding::Gray => [v, 0, 0],308
DepthEncoding::RgbBands => [309
v.min(m),310
v.saturating_sub(m).min(m),311
v.saturating_sub(2 * m).min(m),312
],313
DepthEncoding::RgbPacked => {314
let b = m + 1;315
[v / (b * b), (v / b) % b, v % b]316
}317
}318
}319
/// Normalized depth from channel values.320
pub fn decode_value(self, c: [u64; 3]) -> f64 {321
let b = self.max() + 1;322
let v = match self.encoding {323
DepthEncoding::Gray => c[0],324
DepthEncoding::RgbBands => c[0] + c[1] + c[2],325
DepthEncoding::RgbPacked => c[0] * b * b + c[1] * b + c[2],326
};327
v as f64 / self.levels() as f64328
}329
/// Encode one pixel (t, alpha 0..1) as big-endian PNG sample bytes.330
pub fn encode_pixel(self, t: f64, alpha: f64, out: &mut Vec<u8>) {331
let c = self.encode_value(t);332
let a = (alpha.clamp(0., 1.) * self.max() as f64).round() as u64;333
let mut push = |x: u64| {334
if self.bits == 8 {335
out.push(x as u8);336
} else {337
out.extend_from_slice(&(x as u16).to_be_bytes());338
}339
};340
match self.encoding {341
DepthEncoding::Gray => {342
push(c[0]);343
push(a);344
}345
_ => {346
push(c[0]);347
push(c[1]);348
push(c[2]);349
push(a);350
}351
}352
}353
/// Decode one pixel of `bytes_per_pixel` bytes into canonical 16-bit354
/// (depth, alpha) samples as used by the compositor (gray16 scale).355
pub fn decode_pixel(self, px: &[u8]) -> Result<(u16, u16), String> {356
let read = |i: usize| -> u64 {357
if self.bits == 8 {358
u64::from(px[i])359
} else {360
u64::from(u16::from_be_bytes([px[2 * i], px[2 * i + 1]]))361
}362
};363
let (c, a) = match self.encoding {364
DepthEncoding::Gray => ([read(0), 0, 0], read(1)),365
_ => ([read(0), read(1), read(2)], read(3)),366
};367
if self.encoding == DepthEncoding::RgbBands {368
let m = self.max();369
// Bands fill in order: G only when R is full, B only when G is full.370
if (c[1] > 0 && c[0] != m) || (c[2] > 0 && c[1] != m) {371
return Err("depth rgb_bands: ongeldige bandvolgorde".into());372
}373
}374
let t = self.decode_value(c);375
let scale = 65535. / self.max() as f64;376
Ok((377
(t * 65535.).round() as u16,378
(a as f64 * scale).round() as u16,379
))380
}381
}382
383
/// Default per-sex depth range (t985u): T-pose arm span plus one arm length384
/// on each side, in millimetres (total). Pose-independent: with identifiable385
/// left/right upper-arm and hand joints, arm = |upper arm → hand| × 1.2 (hand386
/// and fingers) and span = shoulder width + 2·arm, so A-/I-pose binds give the387
/// same result as a T-pose. Otherwise span = max(bind width, bind height) and388
/// arm = 0.4·span.389
pub fn auto_depth_range_milli(body: &PreviewCharacter) -> u32 {390
let (span, arm) = match arm_measure(body) {391
Some((shoulders, arm)) => (shoulders + 2. * arm, arm),392
None => {393
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);394
for p in &body.positions {395
for i in 0..2 {396
if p[i].is_finite() {397
lo[i] = lo[i].min(p[i]);398
hi[i] = hi[i].max(p[i]);399
}400
}401
}402
let span = (hi[0] - lo[0]).max(hi[1] - lo[1]);403
let span = if span.is_finite() && span > 0. {404
span405
} else {406
1.0407
};408
(span, 0.4 * span)409
}410
};411
(((span + 2. * arm) * 1000.).round() as u32).clamp(MIN_DEPTH_RANGE_MILLI, MAX_DEPTH_RANGE_MILLI)412
}413
pub const MIN_DEPTH_RANGE_MILLI: u32 = 100;414
pub const MAX_DEPTH_RANGE_MILLI: u32 = 19000;415
416
/// (shoulder width, arm length) from rest-pose joints: left/right upper arm417
/// and hand, identified by name. None when either side is missing.418
pub fn arm_measure(body: &PreviewCharacter) -> Option<(f32, f32)> {419
if body.is_static() {420
return None;421
}422
// (left?, kind) with kind 0 = upper arm, 1 = hand.423
let classify = |name: &str| -> Option<(bool, u8)> {424
let l = name.to_ascii_lowercase();425
let kind = if l.contains("upperarm") || l.contains("upper_arm") || l.contains("arm_upper") {426
0427
} else if l.contains("hand") && !l.contains("handle") {428
1429
} else {430
return None;431
};432
let tokens: Vec<&str> = l.split(|c: char| !c.is_ascii_alphanumeric()).collect();433
let left = l.contains("left") || tokens.contains(&"l") || l.starts_with("l_");434
let right = l.contains("right") || tokens.contains(&"r") || l.starts_with("r_");435
match (left, right) {436
(true, false) => Some((true, kind)),437
(false, true) => Some((false, kind)),438
_ => None,439
}440
};441
let mut found: [[Option<[f32; 3]>; 2]; 2] = [[None; 2]; 2];442
for (j, name) in body.joint_names.iter().enumerate() {443
let Some((left, kind)) = classify(name) else {444
continue;445
};446
let slot = &mut found[usize::from(left)][kind as usize];447
if slot.is_some() {448
continue; // first match (closest to the root in joint order)449
}450
if let Some(w) = preview::rest_joint_world(body, j) {451
*slot = Some([w[12], w[13], w[14]]);452
}453
}454
let dist = |a: [f32; 3], b: [f32; 3]| {455
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()456
};457
let [[Some(ru), Some(rh)], [Some(lu), Some(lh)]] = found else {458
return None;459
};460
let arm = (dist(lu, lh) + dist(ru, rh)) / 2. * 1.2;461
let shoulders = dist(lu, ru);462
(arm.is_finite() && arm > 0. && shoulders.is_finite()).then_some((shoulders, arm))463
}464
166
#[cfg(test)]465
#[cfg(test)]167
mod tests {466
mod tests {168
use super::*;467
use super::*;@@ -293,6 +592,7 @@ mod tests {293
height_milli: 2600,592
height_milli: 2600,294
center_y_milli: 1000,593
center_y_milli: 1000,295
pivot_y_milli: None,594
pivot_y_milli: None,595
depth_range_milli: None,296
},596
},297
);597
);298
assert!(matches!(auto.source, PivotSource::Neck { .. }));598
assert!(matches!(auto.source, PivotSource::Neck { .. }));@@ -303,6 +603,7 @@ mod tests {303
height_milli: 2600,603
height_milli: 2600,304
center_y_milli: 1000,604
center_y_milli: 1000,305
pivot_y_milli: Some(1800),605
pivot_y_milli: Some(1800),606
depth_range_milli: None,306
},607
},307
);608
);308
assert_eq!(manual.source, PivotSource::Manual);609
assert_eq!(manual.source, PivotSource::Manual);@@ -313,6 +614,7 @@ mod tests {313
height_milli: 2600,614
height_milli: 2600,314
center_y_milli: 900,615
center_y_milli: 900,315
pivot_y_milli: None,616
pivot_y_milli: None,617
depth_range_milli: None,316
},618
},317
);619
);318
assert!(matches!(missing.source, PivotSource::Fallback { .. }));620
assert!(matches!(missing.source, PivotSource::Fallback { .. }));@@ -325,6 +627,7 @@ mod tests {325
height_milli: 2600,627
height_milli: 2600,326
center_y_milli: 1000,628
center_y_milli: 1000,327
pivot_y_milli: Some(1600),629
pivot_y_milli: Some(1600),630
depth_range_milli: None,328
};631
};329
for yaw in [0., 90., 180., 270.] {632
for yaw in [0., 90., 180., 270.] {330
let (view, projection) = matrices(framing, yaw, 0., 1.);633
let (view, projection) = matrices(framing, yaw, 0., 1.);@@ -348,6 +651,7 @@ mod tests {348
height_milli: 3000,651
height_milli: 3000,349
center_y_milli: 1100,652
center_y_milli: 1100,350
pivot_y_milli: Some(1500),653
pivot_y_milli: Some(1500),654
depth_range_milli: None,351
};655
};352
let mut pivot_ndcs = Vec::new();656
let mut pivot_ndcs = Vec::new();353
for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {657
for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {@@ -414,4 +718,98 @@ mod tests {414
resolved.framing.center_y_milli718
resolved.framing.center_y_milli415
);719
);416
}720
}721
722
#[test]723
fn depth_codecs_follow_the_documented_formulas() {724
use super::{DepthEncoding as E, DepthFormat as F};725
let f = |bits, e| F::new(bits, e).unwrap();726
// gray727
assert_eq!(f(8, E::Gray).encode_value(1.), [255, 0, 0]);728
assert_eq!(f(16, E::Gray).encode_value(0.5), [32768, 0, 0]);729
// rgb_bands: 766 levels at 8 bit730
let b8 = f(8, E::RgbBands);731
assert_eq!(b8.levels() + 1, 766);732
assert_eq!(b8.encode_value(0.), [0, 0, 0]);733
assert_eq!(b8.encode_value(100. / 765.), [100, 0, 0]);734
assert_eq!(b8.encode_value(300. / 765.), [255, 45, 0]);735
assert_eq!(b8.encode_value(1.), [255, 255, 255]);736
assert_eq!(f(16, E::RgbBands).encode_value(1.), [65535, 65535, 65535]);737
// rgb_packed: positional base 256 / 65536738
let p8 = f(8, E::RgbPacked);739
let v: u64 = 0x12_34_56;740
assert_eq!(p8.encode_value(v as f64 / 16_777_215.), [0x12, 0x34, 0x56]);741
let p16 = f(16, E::RgbPacked);742
assert_eq!(p16.levels(), (1u64 << 48) - 1);743
let v: u64 = 0x1234_5678_9abc;744
assert_eq!(745
p16.encode_value(v as f64 / p16.levels() as f64),746
[0x1234, 0x5678, 0x9abc]747
);748
// Round trip and monotonic for every combination.749
for bits in [8, 16] {750
for e in E::ALL {751
let fmt = f(bits, e);752
let mut last = -1.;753
for k in 0..=1000 {754
let t = k as f64 / 1000.;755
let back = fmt.decode_value(fmt.encode_value(t));756
assert!(757
(back - t).abs() <= 0.5 / fmt.levels() as f64 + 1e-12,758
"{fmt:?} {t}"759
);760
assert!(back >= last);761
last = back;762
let mut px = Vec::new();763
fmt.encode_pixel(t, 1., &mut px);764
assert_eq!(px.len(), fmt.bytes_per_pixel());765
let (d, a) = fmt.decode_pixel(&px).unwrap();766
assert!(767
(f64::from(d) / 65535. - t).abs() <= 1. / fmt.levels().min(65535) as f64768
);769
assert_eq!(a, 65535);770
}771
}772
}773
// Bands must fill in order.774
assert!(b8.decode_pixel(&[10, 5, 0, 255]).is_err());775
assert_eq!(F::default(), f(8, E::Gray));776
assert!(F::new(12, E::Gray).is_err());777
}778
779
#[test]780
fn auto_depth_range_is_arm_span_plus_arm_margin_and_override_wins() {781
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))782
.join("tests/fixtures/bodies/test-body-male.glb");783
let body = crate::preview::load_body_file(&path).unwrap();784
let total = auto_depth_range_milli(&body) as f32 / 1000.;785
// Test body is bound with arms down; the measure is pose-independent.786
let (shoulders, arm) = arm_measure(&body).expect("arm joints");787
let span = shoulders + 2. * arm;788
assert!(789
(total - (span + 2. * arm)).abs() < 0.002,790
"{total} {span} {arm}"791
);792
assert!(span > 1.2 && span < 2.4, "T-pose span {span}");793
let (lo, hi) = body794
.positions795
.iter()796
.fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[0]), b.max(p[0])));797
assert!(798
span > hi - lo,799
"arms-down width is smaller than the T-pose span"800
);801
let resolved = resolve(&body, CameraFraming::default());802
assert!(matches!(803
resolved.source,804
PivotSource::Neck {805
joint_name: ref name,806
..807
} if name == "Neck"808
));809
assert_eq!(resolved.framing.pivot_y_milli, Some(1460));810
assert_ne!(811
resolved.framing.pivot_y_milli.unwrap(),812
resolved.framing.center_y_milli813
);814
}417
}815
}@@ -581,6 +581,7 @@ mod tests {
581
581
height_milli: 2600,
582
582
center_y_milli: 1000,
583
583
pivot_y_milli: Some(1460),
584
depth_range_milli: None,
584
585
}
585
586
}
586
587
@@ -581,6 +581,7 @@ mod tests {581
height_milli: 2600,581
height_milli: 2600,582
center_y_milli: 1000,582
center_y_milli: 1000,583
pivot_y_milli: Some(1460),583
pivot_y_milli: Some(1460),584
depth_range_milli: None,584
}585
}585
}586
}586
587
@@ -539,6 +539,10 @@ impl EditorApp {
539
539
sha256: body_sha,
540
540
},
541
541
preset_sha256,
542
depth_format: crate::camera::DepthFormat::new(
543
opened.document.render.depth_bits,
544
opened.document.render.depth_encoding,
545
)?,
542
546
})
543
547
}
544
548
/// Every body (male, female) and then every equipment item per applicable
@@ -978,6 +982,41 @@ impl EditorApp {
978
982
}
979
983
ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
980
984
});
985
let auto_range = diagnostic
986
.as_ref()
987
.and_then(|r| r.framing.depth_range_milli)
988
.unwrap_or(crate::camera::MIN_DEPTH_RANGE_MILLI);
989
ui.horizontal_wrapped(|ui| {
990
ui.label("Dieptebereik (m)");
991
let mut auto = camera.depth_range_milli.is_none();
992
if ui
993
.checkbox(&mut auto, "automatisch")
994
.on_hover_text(
995
"T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot",
996
)
997
.changed()
998
{
999
camera.depth_range_milli = if auto { None } else { Some(auto_range) };
1000
}
1001
let mut meters =
1002
camera.depth_range_milli.unwrap_or(auto_range) as f32 / 1000.;
1003
if ui
1004
.add_enabled(
1005
camera.depth_range_milli.is_some(),
1006
egui::DragValue::new(&mut meters)
1007
.speed(0.01)
1008
.range(0.1..=19.0),
1009
)
1010
.changed()
1011
{
1012
camera.depth_range_milli = Some((meters * 1000.).round() as u32);
1013
}
1014
let (near, far) = crate::camera::depth_range(crate::project::CameraFraming {
1015
depth_range_milli: Some(camera.depth_range_milli.unwrap_or(auto_range)),
1016
..*camera
1017
});
1018
ui.weak(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)"));
1019
});
981
1020
if let Some(resolved) = &diagnostic {
982
1021
ui.label(resolved.description());
983
1022
} else {
@@ -985,6 +1024,45 @@ impl EditorApp {
985
1024
}
986
1025
}
987
1026
}
1027
/// Depth PNG settings (bit depth 8/16, encoding), as set by the GUI.
1028
pub fn set_depth_format(&mut self, bits: u8, encoding: crate::camera::DepthEncoding) {
1029
if let Some(opened) = &mut self.session.opened {
1030
opened.document.render.depth_bits = bits;
1031
opened.document.render.depth_encoding = encoding;
1032
}
1033
}
1034
pub fn depth_format(&self) -> Option<(u8, crate::camera::DepthEncoding)> {
1035
let r = &self.session.opened.as_ref()?.document.render;
1036
Some((r.depth_bits, r.depth_encoding))
1037
}
1038
fn depth_format_ui(&mut self, ui: &mut egui::Ui) {
1039
let Some((mut bits, mut encoding)) = self.depth_format() else {
1040
return;
1041
};
1042
ui.horizontal_wrapped(|ui| {
1043
ui.label("Depth-map");
1044
egui::ComboBox::from_id_salt("depth_bits")
1045
.selected_text(format!("{bits}-bit"))
1046
.show_ui(ui, |ui| {
1047
ui.selectable_value(&mut bits, 8, "8-bit");
1048
ui.selectable_value(&mut bits, 16, "16-bit");
1049
});
1050
egui::ComboBox::from_id_salt("depth_encoding")
1051
.selected_text(encoding.label())
1052
.show_ui(ui, |ui| {
1053
for e in crate::camera::DepthEncoding::ALL {
1054
ui.selectable_value(&mut encoding, e, e.label());
1055
}
1056
});
1057
if let Ok(fmt) = crate::camera::DepthFormat::new(bits, encoding) {
1058
ui.weak(format!("{} niveaus", fmt.levels() + 1));
1059
}
1060
});
1061
if encoding == crate::camera::DepthEncoding::RgbPacked {
1062
ui.weak("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).");
1063
}
1064
self.set_depth_format(bits, encoding);
1065
}
988
1066
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
989
1067
if !self.show_render_frame {
990
1068
return None;
@@ -1142,6 +1220,7 @@ impl EditorApp {
1142
1220
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
1143
1221
});
1144
1222
self.apply_render_fields();
1223
self.depth_format_ui(ui);
1145
1224
ui.add_space(8.0);
1146
1225
ui.horizontal_wrapped(|ui| {
1147
1226
if ui
@@ -1159,6 +1238,10 @@ impl EditorApp {
1159
1238
});
1160
1239
self.render_all_ui(ui);
1161
1240
self.render_progress_ui(ui);
1241
// Same live pass preview as a single body/equipment render (t990u).
1242
if self.render_ui.preview.is_some() {
1243
self.paint_pass(ui);
1244
}
1162
1245
ui.add_space(8.0);
1163
1246
if ui.button("2D-preview").clicked() {
1164
1247
self.open_preview2d();
@@ -2416,6 +2499,45 @@ mod tests {
2416
2499
let _ = std::fs::remove_dir_all(root);
2417
2500
}
2418
2501
2502
#[test]
2503
fn depth_format_and_range_settings_reach_job_and_persist() {
2504
let root = root();
2505
let opened = crate::session::create_seeded_project(&root).unwrap();
2506
let mut app = EditorApp::default();
2507
app.open_path(opened.manifest_path.clone());
2508
use crate::camera::{DepthEncoding, DepthFormat};
2509
assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));
2510
// Body screen with the depth controls renders headless.
2511
let ctx = egui::Context::default();
2512
let _ = ctx.run(Default::default(), |ctx| app.show_bodies(ctx));
2513
assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));
2514
let job = app.job_for(0, None).unwrap();
2515
assert_eq!(job.depth_format, DepthFormat::default());
2516
app.set_depth_format(16, DepthEncoding::RgbPacked);
2517
app.session.opened.as_mut().unwrap().document.render.framing[1].depth_range_milli =
2518
Some(1500);
2519
let job = app.job_for(1, None).unwrap();
2520
assert_eq!(
2521
job.depth_format,
2522
DepthFormat::new(16, DepthEncoding::RgbPacked).unwrap()
2523
);
2524
let doc = crate::production::contract(job).unwrap().render_doc;
2525
assert_eq!(doc["depth_bits"], 16);
2526
assert_eq!(doc["depth_encoding"], "rgb_packed");
2527
assert_eq!(doc["depth_near"].as_f64().unwrap() as f32, 9.25);
2528
app.session.save().unwrap();
2529
let mut again = EditorApp::default();
2530
again.open_path(opened.manifest_path.clone());
2531
assert_eq!(again.depth_format(), Some((16, DepthEncoding::RgbPacked)));
2532
let text = std::fs::read_to_string(&opened.manifest_path).unwrap();
2533
assert!(
2534
text.contains("\"depth_encoding\": \"rgb_packed\""),
2535
"{text}"
2536
);
2537
assert!(text.contains("\"depth_range_milli\": 1500"), "{text}");
2538
let _ = std::fs::remove_dir_all(root);
2539
}
2540
2419
2541
#[test]
2420
2542
fn renderer_disconnect_is_reported() {
2421
2543
let mut app = EditorApp::default();
@@ -539,6 +539,10 @@ impl EditorApp {539
sha256: body_sha,539
sha256: body_sha,540
},540
},541
preset_sha256,541
preset_sha256,542
depth_format: crate::camera::DepthFormat::new(543
opened.document.render.depth_bits,544
opened.document.render.depth_encoding,545
)?,542
})546
})543
}547
}544
/// Every body (male, female) and then every equipment item per applicable548
/// Every body (male, female) and then every equipment item per applicable@@ -978,6 +982,41 @@ impl EditorApp {978
}982
}979
ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");983
ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");980
});984
});985
let auto_range = diagnostic986
.as_ref()987
.and_then(|r| r.framing.depth_range_milli)988
.unwrap_or(crate::camera::MIN_DEPTH_RANGE_MILLI);989
ui.horizontal_wrapped(|ui| {990
ui.label("Dieptebereik (m)");991
let mut auto = camera.depth_range_milli.is_none();992
if ui993
.checkbox(&mut auto, "automatisch")994
.on_hover_text(995
"T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot",996
)997
.changed()998
{999
camera.depth_range_milli = if auto { None } else { Some(auto_range) };1000
}1001
let mut meters =1002
camera.depth_range_milli.unwrap_or(auto_range) as f32 / 1000.;1003
if ui1004
.add_enabled(1005
camera.depth_range_milli.is_some(),1006
egui::DragValue::new(&mut meters)1007
.speed(0.01)1008
.range(0.1..=19.0),1009
)1010
.changed()1011
{1012
camera.depth_range_milli = Some((meters * 1000.).round() as u32);1013
}1014
let (near, far) = crate::camera::depth_range(crate::project::CameraFraming {1015
depth_range_milli: Some(camera.depth_range_milli.unwrap_or(auto_range)),1016
..*camera1017
});1018
ui.weak(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)"));1019
});981
if let Some(resolved) = &diagnostic {1020
if let Some(resolved) = &diagnostic {982
ui.label(resolved.description());1021
ui.label(resolved.description());983
} else {1022
} else {@@ -985,6 +1024,45 @@ impl EditorApp {985
}1024
}986
}1025
}987
}1026
}1027
/// Depth PNG settings (bit depth 8/16, encoding), as set by the GUI.1028
pub fn set_depth_format(&mut self, bits: u8, encoding: crate::camera::DepthEncoding) {1029
if let Some(opened) = &mut self.session.opened {1030
opened.document.render.depth_bits = bits;1031
opened.document.render.depth_encoding = encoding;1032
}1033
}1034
pub fn depth_format(&self) -> Option<(u8, crate::camera::DepthEncoding)> {1035
let r = &self.session.opened.as_ref()?.document.render;1036
Some((r.depth_bits, r.depth_encoding))1037
}1038
fn depth_format_ui(&mut self, ui: &mut egui::Ui) {1039
let Some((mut bits, mut encoding)) = self.depth_format() else {1040
return;1041
};1042
ui.horizontal_wrapped(|ui| {1043
ui.label("Depth-map");1044
egui::ComboBox::from_id_salt("depth_bits")1045
.selected_text(format!("{bits}-bit"))1046
.show_ui(ui, |ui| {1047
ui.selectable_value(&mut bits, 8, "8-bit");1048
ui.selectable_value(&mut bits, 16, "16-bit");1049
});1050
egui::ComboBox::from_id_salt("depth_encoding")1051
.selected_text(encoding.label())1052
.show_ui(ui, |ui| {1053
for e in crate::camera::DepthEncoding::ALL {1054
ui.selectable_value(&mut encoding, e, e.label());1055
}1056
});1057
if let Ok(fmt) = crate::camera::DepthFormat::new(bits, encoding) {1058
ui.weak(format!("{} niveaus", fmt.levels() + 1));1059
}1060
});1061
if encoding == crate::camera::DepthEncoding::RgbPacked {1062
ui.weak("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).");1063
}1064
self.set_depth_format(bits, encoding);1065
}988
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {1066
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {989
if !self.show_render_frame {1067
if !self.show_render_frame {990
return None;1068
return None;@@ -1142,6 +1220,7 @@ impl EditorApp {1142
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));1220
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));1143
});1221
});1144
self.apply_render_fields();1222
self.apply_render_fields();1223
self.depth_format_ui(ui);1145
ui.add_space(8.0);1224
ui.add_space(8.0);1146
ui.horizontal_wrapped(|ui| {1225
ui.horizontal_wrapped(|ui| {1147
if ui1226
if ui@@ -1159,6 +1238,10 @@ impl EditorApp {1159
});1238
});1160
self.render_all_ui(ui);1239
self.render_all_ui(ui);1161
self.render_progress_ui(ui);1240
self.render_progress_ui(ui);1241
// Same live pass preview as a single body/equipment render (t990u).1242
if self.render_ui.preview.is_some() {1243
self.paint_pass(ui);1244
}1162
ui.add_space(8.0);1245
ui.add_space(8.0);1163
if ui.button("2D-preview").clicked() {1246
if ui.button("2D-preview").clicked() {1164
self.open_preview2d();1247
self.open_preview2d();@@ -2416,6 +2499,45 @@ mod tests {2416
let _ = std::fs::remove_dir_all(root);2499
let _ = std::fs::remove_dir_all(root);2417
}2500
}2418
2501
2502
#[test]2503
fn depth_format_and_range_settings_reach_job_and_persist() {2504
let root = root();2505
let opened = crate::session::create_seeded_project(&root).unwrap();2506
let mut app = EditorApp::default();2507
app.open_path(opened.manifest_path.clone());2508
use crate::camera::{DepthEncoding, DepthFormat};2509
assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));2510
// Body screen with the depth controls renders headless.2511
let ctx = egui::Context::default();2512
let _ = ctx.run(Default::default(), |ctx| app.show_bodies(ctx));2513
assert_eq!(app.depth_format(), Some((8, DepthEncoding::Gray)));2514
let job = app.job_for(0, None).unwrap();2515
assert_eq!(job.depth_format, DepthFormat::default());2516
app.set_depth_format(16, DepthEncoding::RgbPacked);2517
app.session.opened.as_mut().unwrap().document.render.framing[1].depth_range_milli =2518
Some(1500);2519
let job = app.job_for(1, None).unwrap();2520
assert_eq!(2521
job.depth_format,2522
DepthFormat::new(16, DepthEncoding::RgbPacked).unwrap()2523
);2524
let doc = crate::production::contract(job).unwrap().render_doc;2525
assert_eq!(doc["depth_bits"], 16);2526
assert_eq!(doc["depth_encoding"], "rgb_packed");2527
assert_eq!(doc["depth_near"].as_f64().unwrap() as f32, 9.25);2528
app.session.save().unwrap();2529
let mut again = EditorApp::default();2530
again.open_path(opened.manifest_path.clone());2531
assert_eq!(again.depth_format(), Some((16, DepthEncoding::RgbPacked)));2532
let text = std::fs::read_to_string(&opened.manifest_path).unwrap();2533
assert!(2534
text.contains("\"depth_encoding\": \"rgb_packed\""),2535
"{text}"2536
);2537
assert!(text.contains("\"depth_range_milli\": 1500"), "{text}");2538
let _ = std::fs::remove_dir_all(root);2539
}2540
2419
#[test]2541
#[test]2420
fn renderer_disconnect_is_reported() {2542
fn renderer_disconnect_is_reported() {2421
let mut app = EditorApp::default();2543
let mut app = EditorApp::default();@@ -33,6 +33,8 @@ pub struct SyntheticRun {
33
33
pub target: [u32; 2],
34
34
pub fps: u32,
35
35
pub framing: CameraFraming,
36
/// Depth PNG format (default 16-bit gray, like pre-t983u exports).
37
pub depth_format: crate::camera::DepthFormat,
36
38
/// (source name, frames, duration seconds)
37
39
pub clips: Vec<(String, u32, f32)>,
38
40
}
@@ -51,8 +53,10 @@ impl SyntheticRun {
51
53
height_milli: 1800,
52
54
center_y_milli: 900,
53
55
pivot_y_milli: Some(1500),
56
depth_range_milli: None,
54
57
},
55
58
clips: vec![("bind".into(), 1, 0.0)],
59
depth_format: crate::camera::DepthFormat::GRAY16,
56
60
}
57
61
}
58
62
@@ -114,7 +118,8 @@ impl SyntheticRun {
114
118
"far": crate::camera::depth_range(self.framing).1,
115
119
"depth_near": crate::camera::depth_range(self.framing).0,
116
120
"depth_far": crate::camera::depth_range(self.framing).1,
117
"depth_encoding": crate::camera::DEPTH_ENCODING,
121
"depth_encoding": self.depth_format.encoding.token(),
122
"depth_bits": self.depth_format.bits,
118
123
"normal_space": "view (+Y up), PNG rows top-down",
119
124
"camera_distance": 10,
120
125
"yaw_zero": "camera on -Z",
@@ -124,7 +129,7 @@ impl SyntheticRun {
124
129
"sampling": "synthetic fixture",
125
130
"alpha": "straight",
126
131
"diffuse": "unlit",
127
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",
132
"depth": self.depth_format.description(),
128
133
"specular": "scalar",
129
134
"pass_order": PASSES,
130
135
"saved_maps": ["diffuse", "depth", "normal", "specular"],
@@ -161,12 +166,12 @@ impl SyntheticRun {
161
166
)?;
162
167
let digest = if map == "depth" {
163
168
let pair = if px.diffuse[3] == 0 {
164
[0, 0]
169
(0., 0.)
165
170
} else {
166
[px.depth, 65535]
171
(f64::from(px.depth) / 65535., 1.)
167
172
};
168
let samples: Vec<u16> = pair.repeat(n);
169
production::write_depth(root, &rel, w, h, &samples)?
173
let pixels = vec![pair; n];
174
production::write_depth_fmt(root, &rel, w, h, &pixels, self.depth_format)?
170
175
} else {
171
176
let rgba = match map {
172
177
"diffuse" => px.diffuse,
@@ -33,6 +33,8 @@ pub struct SyntheticRun {33
pub target: [u32; 2],33
pub target: [u32; 2],34
pub fps: u32,34
pub fps: u32,35
pub framing: CameraFraming,35
pub framing: CameraFraming,36
/// Depth PNG format (default 16-bit gray, like pre-t983u exports).37
pub depth_format: crate::camera::DepthFormat,36
/// (source name, frames, duration seconds)38
/// (source name, frames, duration seconds)37
pub clips: Vec<(String, u32, f32)>,39
pub clips: Vec<(String, u32, f32)>,38
}40
}@@ -51,8 +53,10 @@ impl SyntheticRun {51
height_milli: 1800,53
height_milli: 1800,52
center_y_milli: 900,54
center_y_milli: 900,53
pivot_y_milli: Some(1500),55
pivot_y_milli: Some(1500),56
depth_range_milli: None,54
},57
},55
clips: vec![("bind".into(), 1, 0.0)],58
clips: vec![("bind".into(), 1, 0.0)],59
depth_format: crate::camera::DepthFormat::GRAY16,56
}60
}57
}61
}58
62
@@ -114,7 +118,8 @@ impl SyntheticRun {114
"far": crate::camera::depth_range(self.framing).1,118
"far": crate::camera::depth_range(self.framing).1,115
"depth_near": crate::camera::depth_range(self.framing).0,119
"depth_near": crate::camera::depth_range(self.framing).0,116
"depth_far": crate::camera::depth_range(self.framing).1,120
"depth_far": crate::camera::depth_range(self.framing).1,117
"depth_encoding": crate::camera::DEPTH_ENCODING,121
"depth_encoding": self.depth_format.encoding.token(),122
"depth_bits": self.depth_format.bits,118
"normal_space": "view (+Y up), PNG rows top-down",123
"normal_space": "view (+Y up), PNG rows top-down",119
"camera_distance": 10,124
"camera_distance": 10,120
"yaw_zero": "camera on -Z",125
"yaw_zero": "camera on -Z",@@ -124,7 +129,7 @@ impl SyntheticRun {124
"sampling": "synthetic fixture",129
"sampling": "synthetic fixture",125
"alpha": "straight",130
"alpha": "straight",126
"diffuse": "unlit",131
"diffuse": "unlit",127
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",132
"depth": self.depth_format.description(),128
"specular": "scalar",133
"specular": "scalar",129
"pass_order": PASSES,134
"pass_order": PASSES,130
"saved_maps": ["diffuse", "depth", "normal", "specular"],135
"saved_maps": ["diffuse", "depth", "normal", "specular"],@@ -161,12 +166,12 @@ impl SyntheticRun {161
)?;166
)?;162
let digest = if map == "depth" {167
let digest = if map == "depth" {163
let pair = if px.diffuse[3] == 0 {168
let pair = if px.diffuse[3] == 0 {164
[0, 0]169
(0., 0.)165
} else {170
} else {166
[px.depth, 65535]171
(f64::from(px.depth) / 65535., 1.)167
};172
};168
let samples: Vec<u16> = pair.repeat(n);173
let pixels = vec![pair; n];169
production::write_depth(root, &rel, w, h, &samples)?174
production::write_depth_fmt(root, &rel, w, h, &pixels, self.depth_format)?170
} else {175
} else {171
let rgba = match map {176
let rgba = match map {172
"diffuse" => px.diffuse,177
"diffuse" => px.diffuse,@@ -20,7 +20,11 @@ use std::{
20
20
/// Output metadata schema written by production and required by this reader.
21
21
/// 5: per-sex narrow depth range (depth_near/depth_far/depth_encoding, t964u);
22
22
/// schema-4 runs used the global 0.05–40 m range and are not mixed in.
23
pub const OUTPUT_META_VERSION: u32 = 5;
23
/// 6: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed, t987u);
24
/// schema-5 runs are still read (16-bit gray).
25
pub const OUTPUT_META_VERSION: u32 = 6;
26
/// Oldest output schema still read.
27
pub const MIN_OUTPUT_META_VERSION: u32 = 5;
24
28
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
25
29
/// Hard ceiling for a single published PNG on disk (encoded).
26
30
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
@@ -163,6 +167,8 @@ pub struct RenderManifest {
163
167
pub depth_near: f32,
164
168
pub depth_far: f32,
165
169
pub depth_encoding: String,
170
#[serde(default)]
171
pub depth_bits: Option<u64>,
166
172
pub camera_distance: f32,
167
173
pub pitches: Vec<i32>,
168
174
pub yaws: Vec<u32>,
@@ -374,7 +380,7 @@ impl ExportRun {
374
380
)));
375
381
}
376
382
let [width, height] = self.render.target;
377
decode_map(&bytes, kind, width, height)
383
decode_map(&bytes, kind, width, height, self.render.depth_format()?)
378
384
}
379
385
380
386
pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {
@@ -385,8 +391,21 @@ impl ExportRun {
385
391
}
386
392
}
387
393
394
impl RenderManifest {
395
pub fn depth_format(&self) -> Result<crate::camera::DepthFormat, ExportError> {
396
let fmt = crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding)
397
.map_err(ExportError::Identity)?;
398
if (self.format_version >= 6) != self.depth_bits.is_some() {
399
return Err(ExportError::Identity(
400
"depth_bits past niet bij render.json-schema".into(),
401
));
402
}
403
Ok(fmt)
404
}
405
}
406
388
407
fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
389
if m.format_version != OUTPUT_META_VERSION {
408
if !(MIN_OUTPUT_META_VERSION..=OUTPUT_META_VERSION).contains(&m.format_version) {
390
409
return Err(ExportError::Identity(if m.format_version == 4 {
391
410
"render.json schema 4: oude export met globaal dieptebereik 0,05–40 m; opnieuw renderen"
392
411
.into()
@@ -427,9 +446,7 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
427
446
"near/far wijkt af van het vaste dieptebereik van deze framing".into(),
428
447
));
429
448
}
430
if m.depth_encoding != crate::camera::DEPTH_ENCODING {
431
return Err(ExportError::Identity("depth_encoding onbekend".into()));
432
}
449
m.depth_format()?;
433
450
if m.camera_distance != crate::camera::CAMERA_DISTANCE {
434
451
return Err(ExportError::Identity(
435
452
"camera_distance wijkt af van contract".into(),
@@ -517,7 +534,7 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
517
534
}
518
535
519
536
fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {
520
if m.format_version != OUTPUT_META_VERSION {
537
if !(MIN_OUTPUT_META_VERSION..=OUTPUT_META_VERSION).contains(&m.format_version) {
521
538
return Err(ExportError::Identity(format!(
522
539
"complete.json format_version {} wordt niet ondersteund",
523
540
m.format_version
@@ -637,8 +654,9 @@ pub(crate) fn decode_map(
637
654
kind: MapKind,
638
655
expect_w: u32,
639
656
expect_h: u32,
657
depth: crate::camera::DepthFormat,
640
658
) -> Result<MapImage, ExportError> {
641
decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET)
659
decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET, depth)
642
660
}
643
661
644
662
/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under
@@ -649,6 +667,7 @@ pub(crate) fn decode_map_sized(
649
667
expect_w: u32,
650
668
expect_h: u32,
651
669
max_dim: u32,
670
depth: crate::camera::DepthFormat,
652
671
) -> Result<MapImage, ExportError> {
653
672
if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
654
673
return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
@@ -705,32 +724,9 @@ pub(crate) fn decode_map_sized(
705
724
})
706
725
}
707
726
MapKind::Depth => {
708
if info.color_type != png::ColorType::GrayscaleAlpha
709
|| info.bit_depth != png::BitDepth::Sixteen
710
{
711
return Err(ExportError::Corrupt(
712
"depth-map moet gray-alpha 16-bit zijn".into(),
713
));
714
}
715
let size = reader.output_buffer_size().ok_or_else(|| {
716
ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())
717
})?;
718
let mut buf = vec![0u8; size];
719
let frame = reader
720
.next_frame(&mut buf)
721
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
722
let expected = expect_w as usize * expect_h as usize * 4; // 2 channels * 2 bytes
723
if frame.buffer_size() != expected {
724
return Err(ExportError::Corrupt(
725
"depth buffergrootte klopt niet".into(),
726
));
727
}
728
buf.truncate(frame.buffer_size());
729
let mut samples = Vec::with_capacity(expect_w as usize * expect_h as usize * 2);
730
for chunk in buf.chunks_exact(2) {
731
samples.push(u16::from_be_bytes([chunk[0], chunk[1]]));
732
}
733
validate_depth_alpha(&samples)?;
727
drop(reader);
728
let native = decode_depth_native(bytes, expect_w, expect_h, max_dim, depth)?;
729
let samples = canonical_depth(&native, depth)?;
734
730
Ok(MapImage::Depth16 {
735
731
width: expect_w,
736
732
height: expect_h,
@@ -740,6 +736,81 @@ pub(crate) fn decode_map_sized(
740
736
}
741
737
}
742
738
739
/// Decode a depth PNG in `fmt` and return its raw big-endian samples
740
/// (validated: format, size, alpha rule, band order).
741
pub(crate) fn decode_depth_native(
742
bytes: &[u8],
743
expect_w: u32,
744
expect_h: u32,
745
max_dim: u32,
746
fmt: crate::camera::DepthFormat,
747
) -> Result<Vec<u8>, ExportError> {
748
if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {
749
return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));
750
}
751
let expected = expect_w as usize * expect_h as usize * fmt.bytes_per_pixel();
752
let mut decoder = png::Decoder::new_with_limits(
753
std::io::Cursor::new(bytes),
754
png::Limits {
755
bytes: expected.saturating_mul(2).max(64 * 1024 * 1024),
756
},
757
);
758
decoder.set_transformations(png::Transformations::IDENTITY);
759
let mut reader = decoder
760
.read_info()
761
.map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;
762
let info = reader.info();
763
if info.width != expect_w || info.height != expect_h {
764
return Err(ExportError::Corrupt(format!(
765
"PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",
766
info.width, info.height
767
)));
768
}
769
if info.color_type != fmt.color_type() || info.bit_depth != fmt.bit_depth() {
770
return Err(ExportError::Corrupt(format!(
771
"depth-map moet {} {}-bit zijn",
772
fmt.encoding.token(),
773
fmt.bits
774
)));
775
}
776
let size = reader
777
.output_buffer_size()
778
.ok_or_else(|| ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into()))?;
779
let mut buf = vec![0u8; size];
780
let frame = reader
781
.next_frame(&mut buf)
782
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;
783
if frame.buffer_size() != expected {
784
return Err(ExportError::Corrupt(
785
"depth buffergrootte klopt niet".into(),
786
));
787
}
788
buf.truncate(expected);
789
canonical_depth(&buf, fmt)?;
790
Ok(buf)
791
}
792
793
/// Raw depth samples in `fmt` → canonical interleaved 16-bit (depth, alpha).
794
pub(crate) fn canonical_depth(
795
native: &[u8],
796
fmt: crate::camera::DepthFormat,
797
) -> Result<Vec<u16>, ExportError> {
798
let bpp = fmt.bytes_per_pixel();
799
let mut samples = Vec::with_capacity(native.len() / bpp * 2);
800
for px in native.chunks_exact(bpp) {
801
let (d, a) = fmt.decode_pixel(px).map_err(ExportError::Corrupt)?;
802
if a == 0 && px[..bpp - bpp / fmt.channels()].iter().any(|b| *b != 0) {
803
return Err(ExportError::Corrupt(
804
"transparante depth heeft niet-nul gray".into(),
805
));
806
}
807
samples.push(d);
808
samples.push(a);
809
}
810
validate_depth_alpha(&samples)?;
811
Ok(samples)
812
}
813
743
814
fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {
744
815
for px in rgba.chunks_exact(4) {
745
816
let a = px[3];
@@ -918,6 +989,7 @@ mod tests {
918
989
height_milli: 2600,
919
990
center_y_milli: 1000,
920
991
pivot_y_milli: Some(1460),
992
depth_range_milli: None,
921
993
}
922
994
}
923
995
@@ -958,7 +1030,8 @@ mod tests {
958
1030
"far": crate::camera::depth_range(framing()).1,
959
1031
"depth_near": crate::camera::depth_range(framing()).0,
960
1032
"depth_far": crate::camera::depth_range(framing()).1,
961
"depth_encoding": crate::camera::DEPTH_ENCODING,
1033
"depth_encoding": "gray",
1034
"depth_bits": 16,
962
1035
"camera_distance": 10.0,
963
1036
"yaw_zero": "camera on -Z",
964
1037
"normal_space": "view (+Y up), PNG rows top-down",
@@ -1188,6 +1261,8 @@ mod tests {
1188
1261
("far", serde_json::json!(40.0)),
1189
1262
("depth_far", serde_json::json!(12.0)),
1190
1263
("depth_encoding", serde_json::json!("other")),
1264
("depth_bits", serde_json::json!(12)),
1265
("depth_bits", serde_json::Value::Null),
1191
1266
] {
1192
1267
let mut doc = base_render(4);
1193
1268
doc[key] = value;
@@ -1196,6 +1271,30 @@ mod tests {
1196
1271
Err(ExportError::Identity(_))
1197
1272
));
1198
1273
}
1274
// Schema 5 (before depth_bits) is still read as 16-bit gray.
1275
let mut legacy = base_render(4);
1276
legacy["format_version"] = 5.into();
1277
legacy.as_object_mut().unwrap().remove("depth_bits");
1278
legacy["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1279
let m = parse(legacy.clone());
1280
validate_render(&m).unwrap();
1281
assert_eq!(
1282
m.depth_format().unwrap(),
1283
crate::camera::DepthFormat::GRAY16
1284
);
1285
// ...but schema 5 cannot claim a new encoding.
1286
legacy["depth_bits"] = 8.into();
1287
legacy["depth_encoding"] = "rgb_packed".into();
1288
assert!(validate_render(&parse(legacy)).is_err());
1289
// Every new encoding/bit combination is accepted in schema 6.
1290
for bits in [8, 16] {
1291
for e in ["gray", "rgb_bands", "rgb_packed"] {
1292
let mut doc = base_render(4);
1293
doc["depth_bits"] = bits.into();
1294
doc["depth_encoding"] = e.into();
1295
validate_render(&parse(doc)).unwrap();
1296
}
1297
}
1199
1298
}
1200
1299
1201
1300
#[test]
@@ -1255,7 +1354,13 @@ mod tests {
1255
1354
.unwrap();
1256
1355
let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();
1257
1356
assert!(matches!(
1258
decode_map(&bytes, MapKind::Normal, 1, 1),
1357
decode_map(
1358
&bytes,
1359
MapKind::Normal,
1360
1,
1361
1,
1362
crate::camera::DepthFormat::GRAY16
1363
),
1259
1364
Err(ExportError::Corrupt(_))
1260
1365
));
1261
1366
// RGBA written as depth should fail color type check
@@ -1271,7 +1376,13 @@ mod tests {
1271
1376
.unwrap();
1272
1377
let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();
1273
1378
assert!(matches!(
1274
decode_map(&bytes, MapKind::Depth, 1, 1),
1379
decode_map(
1380
&bytes,
1381
MapKind::Depth,
1382
1,
1383
1,
1384
crate::camera::DepthFormat::GRAY16
1385
),
1275
1386
Err(ExportError::Corrupt(_))
1276
1387
));
1277
1388
}
@@ -20,7 +20,11 @@ use std::{20
/// Output metadata schema written by production and required by this reader.20
/// Output metadata schema written by production and required by this reader.21
/// 5: per-sex narrow depth range (depth_near/depth_far/depth_encoding, t964u);21
/// 5: per-sex narrow depth range (depth_near/depth_far/depth_encoding, t964u);22
/// schema-4 runs used the global 0.05–40 m range and are not mixed in.22
/// schema-4 runs used the global 0.05–40 m range and are not mixed in.23
pub const OUTPUT_META_VERSION: u32 = 5;23
/// 6: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed, t987u);24
/// schema-5 runs are still read (16-bit gray).25
pub const OUTPUT_META_VERSION: u32 = 6;26
/// Oldest output schema still read.27
pub const MIN_OUTPUT_META_VERSION: u32 = 5;24
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;28
pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;25
/// Hard ceiling for a single published PNG on disk (encoded).29
/// Hard ceiling for a single published PNG on disk (encoded).26
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;30
pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;@@ -163,6 +167,8 @@ pub struct RenderManifest {163
pub depth_near: f32,167
pub depth_near: f32,164
pub depth_far: f32,168
pub depth_far: f32,165
pub depth_encoding: String,169
pub depth_encoding: String,170
#[serde(default)]171
pub depth_bits: Option<u64>,166
pub camera_distance: f32,172
pub camera_distance: f32,167
pub pitches: Vec<i32>,173
pub pitches: Vec<i32>,168
pub yaws: Vec<u32>,174
pub yaws: Vec<u32>,@@ -374,7 +380,7 @@ impl ExportRun {374
)));380
)));375
}381
}376
let [width, height] = self.render.target;382
let [width, height] = self.render.target;377
decode_map(&bytes, kind, width, height)383
decode_map(&bytes, kind, width, height, self.render.depth_format()?)378
}384
}379
385
380
pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {386
pub fn listed_maps(&self) -> impl Iterator<Item = (&str, &str)> {@@ -385,8 +391,21 @@ impl ExportRun {385
}391
}386
}392
}387
393
394
impl RenderManifest {395
pub fn depth_format(&self) -> Result<crate::camera::DepthFormat, ExportError> {396
let fmt = crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding)397
.map_err(ExportError::Identity)?;398
if (self.format_version >= 6) != self.depth_bits.is_some() {399
return Err(ExportError::Identity(400
"depth_bits past niet bij render.json-schema".into(),401
));402
}403
Ok(fmt)404
}405
}406
388
fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {407
fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {389
if m.format_version != OUTPUT_META_VERSION {408
if !(MIN_OUTPUT_META_VERSION..=OUTPUT_META_VERSION).contains(&m.format_version) {390
return Err(ExportError::Identity(if m.format_version == 4 {409
return Err(ExportError::Identity(if m.format_version == 4 {391
"render.json schema 4: oude export met globaal dieptebereik 0,05–40 m; opnieuw renderen"410
"render.json schema 4: oude export met globaal dieptebereik 0,05–40 m; opnieuw renderen"392
.into()411
.into()@@ -427,9 +446,7 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {427
"near/far wijkt af van het vaste dieptebereik van deze framing".into(),446
"near/far wijkt af van het vaste dieptebereik van deze framing".into(),428
));447
));429
}448
}430
if m.depth_encoding != crate::camera::DEPTH_ENCODING {449
m.depth_format()?;431
return Err(ExportError::Identity("depth_encoding onbekend".into()));432
}433
if m.camera_distance != crate::camera::CAMERA_DISTANCE {450
if m.camera_distance != crate::camera::CAMERA_DISTANCE {434
return Err(ExportError::Identity(451
return Err(ExportError::Identity(435
"camera_distance wijkt af van contract".into(),452
"camera_distance wijkt af van contract".into(),@@ -517,7 +534,7 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {517
}534
}518
535
519
fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {536
fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {520
if m.format_version != OUTPUT_META_VERSION {537
if !(MIN_OUTPUT_META_VERSION..=OUTPUT_META_VERSION).contains(&m.format_version) {521
return Err(ExportError::Identity(format!(538
return Err(ExportError::Identity(format!(522
"complete.json format_version {} wordt niet ondersteund",539
"complete.json format_version {} wordt niet ondersteund",523
m.format_version540
m.format_version@@ -637,8 +654,9 @@ pub(crate) fn decode_map(637
kind: MapKind,654
kind: MapKind,638
expect_w: u32,655
expect_w: u32,639
expect_h: u32,656
expect_h: u32,657
depth: crate::camera::DepthFormat,640
) -> Result<MapImage, ExportError> {658
) -> Result<MapImage, ExportError> {641
decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET)659
decode_map_sized(bytes, kind, expect_w, expect_h, MAX_TARGET, depth)642
}660
}643
661
644
/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under662
/// Decode a map PNG of exactly `expect_w`×`expect_h` (each ≤ `max_dim`) under@@ -649,6 +667,7 @@ pub(crate) fn decode_map_sized(649
expect_w: u32,667
expect_w: u32,650
expect_h: u32,668
expect_h: u32,651
max_dim: u32,669
max_dim: u32,670
depth: crate::camera::DepthFormat,652
) -> Result<MapImage, ExportError> {671
) -> Result<MapImage, ExportError> {653
if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {672
if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {654
return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));673
return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));@@ -705,32 +724,9 @@ pub(crate) fn decode_map_sized(705
})724
})706
}725
}707
MapKind::Depth => {726
MapKind::Depth => {708
if info.color_type != png::ColorType::GrayscaleAlpha727
drop(reader);709
|| info.bit_depth != png::BitDepth::Sixteen728
let native = decode_depth_native(bytes, expect_w, expect_h, max_dim, depth)?;710
{729
let samples = canonical_depth(&native, depth)?;711
return Err(ExportError::Corrupt(712
"depth-map moet gray-alpha 16-bit zijn".into(),713
));714
}715
let size = reader.output_buffer_size().ok_or_else(|| {716
ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into())717
})?;718
let mut buf = vec![0u8; size];719
let frame = reader720
.next_frame(&mut buf)721
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;722
let expected = expect_w as usize * expect_h as usize * 4; // 2 channels * 2 bytes723
if frame.buffer_size() != expected {724
return Err(ExportError::Corrupt(725
"depth buffergrootte klopt niet".into(),726
));727
}728
buf.truncate(frame.buffer_size());729
let mut samples = Vec::with_capacity(expect_w as usize * expect_h as usize * 2);730
for chunk in buf.chunks_exact(2) {731
samples.push(u16::from_be_bytes([chunk[0], chunk[1]]));732
}733
validate_depth_alpha(&samples)?;734
Ok(MapImage::Depth16 {730
Ok(MapImage::Depth16 {735
width: expect_w,731
width: expect_w,736
height: expect_h,732
height: expect_h,@@ -740,6 +736,81 @@ pub(crate) fn decode_map_sized(740
}736
}741
}737
}742
738
739
/// Decode a depth PNG in `fmt` and return its raw big-endian samples740
/// (validated: format, size, alpha rule, band order).741
pub(crate) fn decode_depth_native(742
bytes: &[u8],743
expect_w: u32,744
expect_h: u32,745
max_dim: u32,746
fmt: crate::camera::DepthFormat,747
) -> Result<Vec<u8>, ExportError> {748
if expect_w == 0 || expect_h == 0 || expect_w > max_dim || expect_h > max_dim {749
return Err(ExportError::Resource("PNG-afmetingen buiten limiet".into()));750
}751
let expected = expect_w as usize * expect_h as usize * fmt.bytes_per_pixel();752
let mut decoder = png::Decoder::new_with_limits(753
std::io::Cursor::new(bytes),754
png::Limits {755
bytes: expected.saturating_mul(2).max(64 * 1024 * 1024),756
},757
);758
decoder.set_transformations(png::Transformations::IDENTITY);759
let mut reader = decoder760
.read_info()761
.map_err(|e| ExportError::Corrupt(format!("PNG header: {e}")))?;762
let info = reader.info();763
if info.width != expect_w || info.height != expect_h {764
return Err(ExportError::Corrupt(format!(765
"PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}",766
info.width, info.height767
)));768
}769
if info.color_type != fmt.color_type() || info.bit_depth != fmt.bit_depth() {770
return Err(ExportError::Corrupt(format!(771
"depth-map moet {} {}-bit zijn",772
fmt.encoding.token(),773
fmt.bits774
)));775
}776
let size = reader777
.output_buffer_size()778
.ok_or_else(|| ExportError::Corrupt("PNG buffergrootte onbekend of te groot".into()))?;779
let mut buf = vec![0u8; size];780
let frame = reader781
.next_frame(&mut buf)782
.map_err(|e| ExportError::Corrupt(format!("PNG data: {e}")))?;783
if frame.buffer_size() != expected {784
return Err(ExportError::Corrupt(785
"depth buffergrootte klopt niet".into(),786
));787
}788
buf.truncate(expected);789
canonical_depth(&buf, fmt)?;790
Ok(buf)791
}792
793
/// Raw depth samples in `fmt` → canonical interleaved 16-bit (depth, alpha).794
pub(crate) fn canonical_depth(795
native: &[u8],796
fmt: crate::camera::DepthFormat,797
) -> Result<Vec<u16>, ExportError> {798
let bpp = fmt.bytes_per_pixel();799
let mut samples = Vec::with_capacity(native.len() / bpp * 2);800
for px in native.chunks_exact(bpp) {801
let (d, a) = fmt.decode_pixel(px).map_err(ExportError::Corrupt)?;802
if a == 0 && px[..bpp - bpp / fmt.channels()].iter().any(|b| *b != 0) {803
return Err(ExportError::Corrupt(804
"transparante depth heeft niet-nul gray".into(),805
));806
}807
samples.push(d);808
samples.push(a);809
}810
validate_depth_alpha(&samples)?;811
Ok(samples)812
}813
743
fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {814
fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {744
for px in rgba.chunks_exact(4) {815
for px in rgba.chunks_exact(4) {745
let a = px[3];816
let a = px[3];@@ -918,6 +989,7 @@ mod tests {918
height_milli: 2600,989
height_milli: 2600,919
center_y_milli: 1000,990
center_y_milli: 1000,920
pivot_y_milli: Some(1460),991
pivot_y_milli: Some(1460),992
depth_range_milli: None,921
}993
}922
}994
}923
995
@@ -958,7 +1030,8 @@ mod tests {958
"far": crate::camera::depth_range(framing()).1,1030
"far": crate::camera::depth_range(framing()).1,959
"depth_near": crate::camera::depth_range(framing()).0,1031
"depth_near": crate::camera::depth_range(framing()).0,960
"depth_far": crate::camera::depth_range(framing()).1,1032
"depth_far": crate::camera::depth_range(framing()).1,961
"depth_encoding": crate::camera::DEPTH_ENCODING,1033
"depth_encoding": "gray",1034
"depth_bits": 16,962
"camera_distance": 10.0,1035
"camera_distance": 10.0,963
"yaw_zero": "camera on -Z",1036
"yaw_zero": "camera on -Z",964
"normal_space": "view (+Y up), PNG rows top-down",1037
"normal_space": "view (+Y up), PNG rows top-down",@@ -1188,6 +1261,8 @@ mod tests {1188
("far", serde_json::json!(40.0)),1261
("far", serde_json::json!(40.0)),1189
("depth_far", serde_json::json!(12.0)),1262
("depth_far", serde_json::json!(12.0)),1190
("depth_encoding", serde_json::json!("other")),1263
("depth_encoding", serde_json::json!("other")),1264
("depth_bits", serde_json::json!(12)),1265
("depth_bits", serde_json::Value::Null),1191
] {1266
] {1192
let mut doc = base_render(4);1267
let mut doc = base_render(4);1193
doc[key] = value;1268
doc[key] = value;@@ -1196,6 +1271,30 @@ mod tests {1196
Err(ExportError::Identity(_))1271
Err(ExportError::Identity(_))1197
));1272
));1198
}1273
}1274
// Schema 5 (before depth_bits) is still read as 16-bit gray.1275
let mut legacy = base_render(4);1276
legacy["format_version"] = 5.into();1277
legacy.as_object_mut().unwrap().remove("depth_bits");1278
legacy["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();1279
let m = parse(legacy.clone());1280
validate_render(&m).unwrap();1281
assert_eq!(1282
m.depth_format().unwrap(),1283
crate::camera::DepthFormat::GRAY161284
);1285
// ...but schema 5 cannot claim a new encoding.1286
legacy["depth_bits"] = 8.into();1287
legacy["depth_encoding"] = "rgb_packed".into();1288
assert!(validate_render(&parse(legacy)).is_err());1289
// Every new encoding/bit combination is accepted in schema 6.1290
for bits in [8, 16] {1291
for e in ["gray", "rgb_bands", "rgb_packed"] {1292
let mut doc = base_render(4);1293
doc["depth_bits"] = bits.into();1294
doc["depth_encoding"] = e.into();1295
validate_render(&parse(doc)).unwrap();1296
}1297
}1199
}1298
}1200
1299
1201
#[test]1300
#[test]@@ -1255,7 +1354,13 @@ mod tests {1255
.unwrap();1354
.unwrap();1256
let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();1355
let bytes = fs::read(tmp.0.join("bad_normal.png")).unwrap();1257
assert!(matches!(1356
assert!(matches!(1258
decode_map(&bytes, MapKind::Normal, 1, 1),1357
decode_map(1358
&bytes,1359
MapKind::Normal,1360
1,1361
1,1362
crate::camera::DepthFormat::GRAY161363
),1259
Err(ExportError::Corrupt(_))1364
Err(ExportError::Corrupt(_))1260
));1365
));1261
// RGBA written as depth should fail color type check1366
// RGBA written as depth should fail color type check@@ -1271,7 +1376,13 @@ mod tests {1271
.unwrap();1376
.unwrap();1272
let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();1377
let bytes = fs::read(tmp.0.join("fake_depth.png")).unwrap();1273
assert!(matches!(1378
assert!(matches!(1274
decode_map(&bytes, MapKind::Depth, 1, 1),1379
decode_map(1380
&bytes,1381
MapKind::Depth,1382
1,1383
1,1384
crate::camera::DepthFormat::GRAY161385
),1275
Err(ExportError::Corrupt(_))1386
Err(ExportError::Corrupt(_))1276
));1387
));1277
}1388
}@@ -762,6 +762,7 @@ mod tests {
762
762
height_milli: 1800,
763
763
center_y_milli: 900,
764
764
pivot_y_milli: Some(1500),
765
depth_range_milli: None,
765
766
}
766
767
}
767
768
@@ -762,6 +762,7 @@ mod tests {762
height_milli: 1800,762
height_milli: 1800,763
center_y_milli: 900,763
center_y_milli: 900,764
pivot_y_milli: Some(1500),764
pivot_y_milli: Some(1500),765
depth_range_milli: None,765
}766
}766
}767
}767
768
@@ -64,6 +64,8 @@ pub struct Target {
64
64
pub normal: ImageBuf,
65
65
pub specular: ImageBuf,
66
66
pub depth: Vec<u16>,
67
/// Unquantized normalized depth t (0..1) per pixel, for the export encoding.
68
pub depth_t: Vec<f32>,
67
69
pub depth_preview: ImageBuf,
68
70
}
69
71
/// Content fingerprint of a source file used for a production job.
@@ -88,6 +90,8 @@ pub struct RenderJob {
88
90
pub body_model: SourceFingerprint,
89
91
/// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.
90
92
pub preset_sha256: Option<String>,
93
/// Depth PNG bit depth + encoding (project setting).
94
pub depth_format: crate::camera::DepthFormat,
91
95
}
92
96
#[derive(Debug, Clone, PartialEq)]
93
97
pub struct ClipPlan {
@@ -958,6 +962,7 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S
958
962
let mut normals = diffuse.clone();
959
963
let mut specular = diffuse.clone();
960
964
let mut depth = vec![0u16; count * 2];
965
let mut depth_t = vec![0f32; count];
961
966
let mut depth_preview = diffuse.clone();
962
967
for y in 0..h {
963
968
for x in 0..w {
@@ -1022,6 +1027,7 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S
1022
1027
map[i * 4 + 3] = alpha;
1023
1028
}
1024
1029
depth[i * 2] = d;
1030
depth_t[i] = closest.clamp(0., 1.);
1025
1031
depth[i * 2 + 1] = u16::from(alpha) * 257;
1026
1032
}
1027
1033
}
@@ -1030,6 +1036,7 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S
1030
1036
normal: image(w, h, normals),
1031
1037
specular: image(w, h, specular),
1032
1038
depth,
1039
depth_t,
1033
1040
depth_preview: image(w, h, depth_preview),
1034
1041
})
1035
1042
}
@@ -1165,6 +1172,54 @@ pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String
1165
1172
}
1166
1173
1167
1174
/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.
1175
/// Encode depth pixels `(t, alpha)` (both 0..1) as a PNG in `fmt`.
1176
pub fn encode_depth_png_fmt(
1177
w: u32,
1178
h: u32,
1179
pixels: &[(f64, f64)],
1180
fmt: crate::camera::DepthFormat,
1181
) -> Result<Vec<u8>, String> {
1182
if w == 0 || h == 0 || pixels.len() != w as usize * h as usize {
1183
return Err("ongeldige depthafmetingen".into());
1184
}
1185
let mut samples = Vec::with_capacity(pixels.len() * fmt.bytes_per_pixel());
1186
for &(t, a) in pixels {
1187
// Transparent pixels carry no depth (alpha rule of the map contract).
1188
let t = if a > 0. { t } else { 0. };
1189
fmt.encode_pixel(t, a, &mut samples);
1190
}
1191
let mut cursor = std::io::Cursor::new(Vec::new());
1192
{
1193
let mut encoder = png::Encoder::new(&mut cursor, w, h);
1194
encoder.set_color(fmt.color_type());
1195
encoder.set_depth(fmt.bit_depth());
1196
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1197
writer
1198
.write_image_data(&samples)
1199
.map_err(|e| e.to_string())?;
1200
writer.finish().map_err(|e| e.to_string())?;
1201
}
1202
Ok(cursor.into_inner())
1203
}
1204
1205
/// Atomically publish a depth PNG in `fmt` from `(t, alpha)` pixels; returns SHA-256 hex.
1206
pub fn write_depth_fmt(
1207
root: &Path,
1208
relative: &Path,
1209
w: u32,
1210
h: u32,
1211
pixels: &[(f64, f64)],
1212
fmt: crate::camera::DepthFormat,
1213
) -> Result<String, String> {
1214
let bytes = encode_depth_png_fmt(w, h, pixels, fmt)?;
1215
let digest = crate::digest::sha256_hex(&bytes);
1216
atomic_file(root, relative, |file| {
1217
use std::io::Write;
1218
file.write_all(&bytes).map_err(|e| e.to_string())
1219
})?;
1220
Ok(digest)
1221
}
1222
1168
1223
pub fn write_depth(
1169
1224
root: &Path,
1170
1225
relative: &Path,
@@ -1321,6 +1376,7 @@ pub struct Contract {
1321
1376
/// Validate a job and build its `render.json` contract without touching disk.
1322
1377
pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1323
1378
job.framing.validate()?;
1379
crate::camera::DepthFormat::new(job.depth_format.bits, job.depth_format.encoding)?;
1324
1380
let pivot = crate::camera::resolve(&job.character, job.framing);
1325
1381
job.framing = pivot.framing;
1326
1382
if job.target_width == 0
@@ -1414,7 +1470,8 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1414
1470
"far": depth_far,
1415
1471
"depth_near": depth_near,
1416
1472
"depth_far": depth_far,
1417
"depth_encoding": crate::camera::DEPTH_ENCODING,
1473
"depth_encoding": job.depth_format.encoding.token(),
1474
"depth_bits": job.depth_format.bits,
1418
1475
"normal_space": "view (+Y up), PNG rows top-down",
1419
1476
"camera_distance": crate::camera::CAMERA_DISTANCE,
1420
1477
"yaw_zero": "camera on -Z",
@@ -1424,7 +1481,7 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1424
1481
"sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained",
1425
1482
"alpha": "straight, opaque/cutout, coverage resolve",
1426
1483
"diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",
1427
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near); per-sex fixed range, out-of-range clamped",
1484
"depth": job.depth_format.description(),
1428
1485
"specular": "linear scalar; body defaults zero",
1429
1486
"pass_order": PASSES,
1430
1487
"saved_maps": ["diffuse", "depth", "normal", "specular"],
@@ -1672,7 +1729,20 @@ fn execute_target(
1672
1729
session.verified[rel].clone()
1673
1730
} else {
1674
1731
let bytes = if map == "depth" {
1675
encode_depth_png(job.target_width, job.target_height, &target.depth)?
1732
let pixels: Vec<(f64, f64)> = target
1733
.depth_t
1734
.iter()
1735
.enumerate()
1736
.map(|(i, t)| {
1737
(f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)
1738
})
1739
.collect();
1740
encode_depth_png_fmt(
1741
job.target_width,
1742
job.target_height,
1743
&pixels,
1744
job.depth_format,
1745
)?
1676
1746
} else {
1677
1747
encode_rgba_png(&preview, map == "diffuse")?
1678
1748
};
@@ -2194,6 +2264,7 @@ mod tests {
2194
2264
fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {
2195
2265
let temp = Temp::new();
2196
2266
let mut job = RenderJob {
2267
depth_format: crate::camera::DepthFormat::GRAY16,
2197
2268
character: body(),
2198
2269
sex: "male".into(),
2199
2270
preset: None,
@@ -2204,6 +2275,7 @@ mod tests {
2204
2275
height_milli: 3100,
2205
2276
center_y_milli: 1200,
2206
2277
pivot_y_milli: Some(1200),
2278
depth_range_milli: None,
2207
2279
},
2208
2280
output: temp.0.clone(),
2209
2281
project_id: "test-project".into(),
@@ -2320,6 +2392,7 @@ mod tests {
2320
2392
let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2321
2393
assert!(!missing.source_normals_complete);
2322
2394
let job = RenderJob {
2395
depth_format: crate::camera::DepthFormat::GRAY16,
2323
2396
character: missing,
2324
2397
sex: "male".into(),
2325
2398
preset: None,
@@ -2357,6 +2430,7 @@ mod tests {
2357
2430
let mut b = body();
2358
2431
b.clips.clear();
2359
2432
let job = RenderJob {
2433
depth_format: crate::camera::DepthFormat::GRAY16,
2360
2434
character: b,
2361
2435
sex: "male".into(),
2362
2436
preset: None,
@@ -2406,12 +2480,13 @@ mod tests {
2406
2480
let meta: serde_json::Value =
2407
2481
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2408
2482
.unwrap();
2409
assert_eq!(meta["format_version"], 5);
2483
assert_eq!(meta["format_version"], 6);
2410
2484
let (near, far) = crate::camera::depth_range(meta_framing(&meta));
2411
2485
assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);
2412
2486
assert_eq!(meta["depth_far"].as_f64().unwrap() as f32, far);
2413
2487
assert_eq!(meta["near"], meta["depth_near"]);
2414
assert_eq!(meta["depth_encoding"], crate::camera::DEPTH_ENCODING);
2488
assert_eq!(meta["depth_encoding"], "gray");
2489
assert_eq!(meta["depth_bits"], 16);
2415
2490
if !stop {
2416
2491
let done: serde_json::Value = serde_json::from_str(
2417
2492
&std::fs::read_to_string(run.join("complete.json")).unwrap(),
@@ -2444,7 +2519,7 @@ mod tests {
2444
2519
&std::fs::read_to_string(run.join("complete.json")).unwrap(),
2445
2520
)
2446
2521
.unwrap();
2447
assert_eq!(complete["format_version"], 5);
2522
assert_eq!(complete["format_version"], 6);
2448
2523
assert_eq!(complete["status"], "complete");
2449
2524
assert_eq!(complete["png_files"], 240);
2450
2525
let checksums = complete["png_sha256"].as_object().unwrap();
@@ -2518,6 +2593,7 @@ mod tests {
2518
2593
let mut b = body();
2519
2594
b.clips.clear();
2520
2595
RenderJob {
2596
depth_format: crate::camera::DepthFormat::GRAY16,
2521
2597
character: b,
2522
2598
sex: "male".into(),
2523
2599
preset: None,
@@ -64,6 +64,8 @@ pub struct Target {64
pub normal: ImageBuf,64
pub normal: ImageBuf,65
pub specular: ImageBuf,65
pub specular: ImageBuf,66
pub depth: Vec<u16>,66
pub depth: Vec<u16>,67
/// Unquantized normalized depth t (0..1) per pixel, for the export encoding.68
pub depth_t: Vec<f32>,67
pub depth_preview: ImageBuf,69
pub depth_preview: ImageBuf,68
}70
}69
/// Content fingerprint of a source file used for a production job.71
/// Content fingerprint of a source file used for a production job.@@ -88,6 +90,8 @@ pub struct RenderJob {88
pub body_model: SourceFingerprint,90
pub body_model: SourceFingerprint,89
/// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.91
/// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.90
pub preset_sha256: Option<String>,92
pub preset_sha256: Option<String>,93
/// Depth PNG bit depth + encoding (project setting).94
pub depth_format: crate::camera::DepthFormat,91
}95
}92
#[derive(Debug, Clone, PartialEq)]96
#[derive(Debug, Clone, PartialEq)]93
pub struct ClipPlan {97
pub struct ClipPlan {@@ -958,6 +962,7 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S958
let mut normals = diffuse.clone();962
let mut normals = diffuse.clone();959
let mut specular = diffuse.clone();963
let mut specular = diffuse.clone();960
let mut depth = vec![0u16; count * 2];964
let mut depth = vec![0u16; count * 2];965
let mut depth_t = vec![0f32; count];961
let mut depth_preview = diffuse.clone();966
let mut depth_preview = diffuse.clone();962
for y in 0..h {967
for y in 0..h {963
for x in 0..w {968
for x in 0..w {@@ -1022,6 +1027,7 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S1022
map[i * 4 + 3] = alpha;1027
map[i * 4 + 3] = alpha;1023
}1028
}1024
depth[i * 2] = d;1029
depth[i * 2] = d;1030
depth_t[i] = closest.clamp(0., 1.);1025
depth[i * 2 + 1] = u16::from(alpha) * 257;1031
depth[i * 2 + 1] = u16::from(alpha) * 257;1026
}1032
}1027
}1033
}@@ -1030,6 +1036,7 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S1030
normal: image(w, h, normals),1036
normal: image(w, h, normals),1031
specular: image(w, h, specular),1037
specular: image(w, h, specular),1032
depth,1038
depth,1039
depth_t,1033
depth_preview: image(w, h, depth_preview),1040
depth_preview: image(w, h, depth_preview),1034
})1041
})1035
}1042
}@@ -1165,6 +1172,54 @@ pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String1165
}1172
}1166
1173
1167
/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.1174
/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.1175
/// Encode depth pixels `(t, alpha)` (both 0..1) as a PNG in `fmt`.1176
pub fn encode_depth_png_fmt(1177
w: u32,1178
h: u32,1179
pixels: &[(f64, f64)],1180
fmt: crate::camera::DepthFormat,1181
) -> Result<Vec<u8>, String> {1182
if w == 0 || h == 0 || pixels.len() != w as usize * h as usize {1183
return Err("ongeldige depthafmetingen".into());1184
}1185
let mut samples = Vec::with_capacity(pixels.len() * fmt.bytes_per_pixel());1186
for &(t, a) in pixels {1187
// Transparent pixels carry no depth (alpha rule of the map contract).1188
let t = if a > 0. { t } else { 0. };1189
fmt.encode_pixel(t, a, &mut samples);1190
}1191
let mut cursor = std::io::Cursor::new(Vec::new());1192
{1193
let mut encoder = png::Encoder::new(&mut cursor, w, h);1194
encoder.set_color(fmt.color_type());1195
encoder.set_depth(fmt.bit_depth());1196
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;1197
writer1198
.write_image_data(&samples)1199
.map_err(|e| e.to_string())?;1200
writer.finish().map_err(|e| e.to_string())?;1201
}1202
Ok(cursor.into_inner())1203
}1204
1205
/// Atomically publish a depth PNG in `fmt` from `(t, alpha)` pixels; returns SHA-256 hex.1206
pub fn write_depth_fmt(1207
root: &Path,1208
relative: &Path,1209
w: u32,1210
h: u32,1211
pixels: &[(f64, f64)],1212
fmt: crate::camera::DepthFormat,1213
) -> Result<String, String> {1214
let bytes = encode_depth_png_fmt(w, h, pixels, fmt)?;1215
let digest = crate::digest::sha256_hex(&bytes);1216
atomic_file(root, relative, |file| {1217
use std::io::Write;1218
file.write_all(&bytes).map_err(|e| e.to_string())1219
})?;1220
Ok(digest)1221
}1222
1168
pub fn write_depth(1223
pub fn write_depth(1169
root: &Path,1224
root: &Path,1170
relative: &Path,1225
relative: &Path,@@ -1321,6 +1376,7 @@ pub struct Contract {1321
/// Validate a job and build its `render.json` contract without touching disk.1376
/// Validate a job and build its `render.json` contract without touching disk.1322
pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1377
pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1323
job.framing.validate()?;1378
job.framing.validate()?;1379
crate::camera::DepthFormat::new(job.depth_format.bits, job.depth_format.encoding)?;1324
let pivot = crate::camera::resolve(&job.character, job.framing);1380
let pivot = crate::camera::resolve(&job.character, job.framing);1325
job.framing = pivot.framing;1381
job.framing = pivot.framing;1326
if job.target_width == 01382
if job.target_width == 0@@ -1414,7 +1470,8 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1414
"far": depth_far,1470
"far": depth_far,1415
"depth_near": depth_near,1471
"depth_near": depth_near,1416
"depth_far": depth_far,1472
"depth_far": depth_far,1417
"depth_encoding": crate::camera::DEPTH_ENCODING,1473
"depth_encoding": job.depth_format.encoding.token(),1474
"depth_bits": job.depth_format.bits,1418
"normal_space": "view (+Y up), PNG rows top-down",1475
"normal_space": "view (+Y up), PNG rows top-down",1419
"camera_distance": crate::camera::CAMERA_DISTANCE,1476
"camera_distance": crate::camera::CAMERA_DISTANCE,1420
"yaw_zero": "camera on -Z",1477
"yaw_zero": "camera on -Z",@@ -1424,7 +1481,7 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1424
"sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained",1481
"sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained",1425
"alpha": "straight, opaque/cutout, coverage resolve",1482
"alpha": "straight, opaque/cutout, coverage resolve",1426
"diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",1483
"diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",1427
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near); per-sex fixed range, out-of-range clamped",1484
"depth": job.depth_format.description(),1428
"specular": "linear scalar; body defaults zero",1485
"specular": "linear scalar; body defaults zero",1429
"pass_order": PASSES,1486
"pass_order": PASSES,1430
"saved_maps": ["diffuse", "depth", "normal", "specular"],1487
"saved_maps": ["diffuse", "depth", "normal", "specular"],@@ -1672,7 +1729,20 @@ fn execute_target(1672
session.verified[rel].clone()1729
session.verified[rel].clone()1673
} else {1730
} else {1674
let bytes = if map == "depth" {1731
let bytes = if map == "depth" {1675
encode_depth_png(job.target_width, job.target_height, &target.depth)?1732
let pixels: Vec<(f64, f64)> = target1733
.depth_t1734
.iter()1735
.enumerate()1736
.map(|(i, t)| {1737
(f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)1738
})1739
.collect();1740
encode_depth_png_fmt(1741
job.target_width,1742
job.target_height,1743
&pixels,1744
job.depth_format,1745
)?1676
} else {1746
} else {1677
encode_rgba_png(&preview, map == "diffuse")?1747
encode_rgba_png(&preview, map == "diffuse")?1678
};1748
};@@ -2194,6 +2264,7 @@ mod tests {2194
fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {2264
fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {2195
let temp = Temp::new();2265
let temp = Temp::new();2196
let mut job = RenderJob {2266
let mut job = RenderJob {2267
depth_format: crate::camera::DepthFormat::GRAY16,2197
character: body(),2268
character: body(),2198
sex: "male".into(),2269
sex: "male".into(),2199
preset: None,2270
preset: None,@@ -2204,6 +2275,7 @@ mod tests {2204
height_milli: 3100,2275
height_milli: 3100,2205
center_y_milli: 1200,2276
center_y_milli: 1200,2206
pivot_y_milli: Some(1200),2277
pivot_y_milli: Some(1200),2278
depth_range_milli: None,2207
},2279
},2208
output: temp.0.clone(),2280
output: temp.0.clone(),2209
project_id: "test-project".into(),2281
project_id: "test-project".into(),@@ -2320,6 +2392,7 @@ mod tests {2320
let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();2392
let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();2321
assert!(!missing.source_normals_complete);2393
assert!(!missing.source_normals_complete);2322
let job = RenderJob {2394
let job = RenderJob {2395
depth_format: crate::camera::DepthFormat::GRAY16,2323
character: missing,2396
character: missing,2324
sex: "male".into(),2397
sex: "male".into(),2325
preset: None,2398
preset: None,@@ -2357,6 +2430,7 @@ mod tests {2357
let mut b = body();2430
let mut b = body();2358
b.clips.clear();2431
b.clips.clear();2359
let job = RenderJob {2432
let job = RenderJob {2433
depth_format: crate::camera::DepthFormat::GRAY16,2360
character: b,2434
character: b,2361
sex: "male".into(),2435
sex: "male".into(),2362
preset: None,2436
preset: None,@@ -2406,12 +2480,13 @@ mod tests {2406
let meta: serde_json::Value =2480
let meta: serde_json::Value =2407
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())2481
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())2408
.unwrap();2482
.unwrap();2409
assert_eq!(meta["format_version"], 5);2483
assert_eq!(meta["format_version"], 6);2410
let (near, far) = crate::camera::depth_range(meta_framing(&meta));2484
let (near, far) = crate::camera::depth_range(meta_framing(&meta));2411
assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);2485
assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);2412
assert_eq!(meta["depth_far"].as_f64().unwrap() as f32, far);2486
assert_eq!(meta["depth_far"].as_f64().unwrap() as f32, far);2413
assert_eq!(meta["near"], meta["depth_near"]);2487
assert_eq!(meta["near"], meta["depth_near"]);2414
assert_eq!(meta["depth_encoding"], crate::camera::DEPTH_ENCODING);2488
assert_eq!(meta["depth_encoding"], "gray");2489
assert_eq!(meta["depth_bits"], 16);2415
if !stop {2490
if !stop {2416
let done: serde_json::Value = serde_json::from_str(2491
let done: serde_json::Value = serde_json::from_str(2417
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2492
&std::fs::read_to_string(run.join("complete.json")).unwrap(),@@ -2444,7 +2519,7 @@ mod tests {2444
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2519
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2445
)2520
)2446
.unwrap();2521
.unwrap();2447
assert_eq!(complete["format_version"], 5);2522
assert_eq!(complete["format_version"], 6);2448
assert_eq!(complete["status"], "complete");2523
assert_eq!(complete["status"], "complete");2449
assert_eq!(complete["png_files"], 240);2524
assert_eq!(complete["png_files"], 240);2450
let checksums = complete["png_sha256"].as_object().unwrap();2525
let checksums = complete["png_sha256"].as_object().unwrap();@@ -2518,6 +2593,7 @@ mod tests {2518
let mut b = body();2593
let mut b = body();2519
b.clips.clear();2594
b.clips.clear();2520
RenderJob {2595
RenderJob {2596
depth_format: crate::camera::DepthFormat::GRAY16,2521
character: b,2597
character: b,2522
sex: "male".into(),2598
sex: "male".into(),2523
preset: None,2599
preset: None,@@ -37,6 +37,10 @@ pub struct CameraFraming {
37
37
/// None resolves the rest-pose neck height, independently of image framing.
38
38
#[serde(default)]
39
39
pub pivot_y_milli: Option<i32>,
40
/// Total depth range (mm) centred on the pivot; None = automatic
41
/// (T-pose arm span + one arm length margin per side, t985u).
42
#[serde(default, skip_serializing_if = "Option::is_none")]
43
pub depth_range_milli: Option<u32>,
40
44
}
41
45
impl Default for CameraFraming {
42
46
fn default() -> Self {
@@ -44,6 +48,7 @@ impl Default for CameraFraming {
44
48
height_milli: 2600,
45
49
center_y_milli: 1000,
46
50
pivot_y_milli: None,
51
depth_range_milli: None,
47
52
}
48
53
}
49
54
}
@@ -52,8 +57,15 @@ impl CameraFraming {
52
57
if !(100..=10000).contains(&self.height_milli)
53
58
|| self.center_y_milli.unsigned_abs() > 10000
54
59
|| self.pivot_y_milli.is_some_and(|p| p.unsigned_abs() > 10000)
60
|| self.depth_range_milli.is_some_and(|d| {
61
!(crate::camera::MIN_DEPTH_RANGE_MILLI..=crate::camera::MAX_DEPTH_RANGE_MILLI)
62
.contains(&d)
63
})
55
64
{
56
Err("renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m".into())
65
Err(
66
"renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m, dieptebereik 0,1–19 m"
67
.into(),
68
)
57
69
} else {
58
70
Ok(())
59
71
}
@@ -68,6 +80,16 @@ pub struct RenderSettings {
68
80
pub fps: u32,
69
81
#[serde(default)]
70
82
pub framing: [CameraFraming; 2],
83
/// Depth PNG bit depth: 8 (default) or 16 (t983u).
84
#[serde(default = "default_depth_bits")]
85
pub depth_bits: u8,
86
/// Depth PNG encoding: gray (default), rgb_bands or rgb_packed (t987u).
87
#[serde(default)]
88
pub depth_encoding: crate::camera::DepthEncoding,
89
}
90
91
fn default_depth_bits() -> u8 {
92
8
71
93
}
72
94
73
95
impl Default for RenderSettings {
@@ -77,6 +99,8 @@ impl Default for RenderSettings {
77
99
target_height: 128,
78
100
fps: DEFAULT_FPS,
79
101
framing: [CameraFraming::default(); 2],
102
depth_bits: 8,
103
depth_encoding: crate::camera::DepthEncoding::Gray,
80
104
}
81
105
}
82
106
}
@@ -171,6 +195,9 @@ impl ProjectFile {
171
195
172
196
pub fn validate_shape(&self) -> Result<(), ProjectError> {
173
197
let mut issues = Vec::new();
198
if self.render.depth_bits != 8 && self.render.depth_bits != 16 {
199
issues.push("depth_bits moet 8 of 16 zijn".to_string());
200
}
174
201
for camera in self.render.framing {
175
202
if let Err(error) = camera.validate() {
176
203
issues.push(error);
@@ -315,7 +342,7 @@ pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {
315
342
target_width: draft.target_width,
316
343
target_height: draft.target_height,
317
344
fps: draft.fps,
318
framing: [CameraFraming::default(); 2],
345
..RenderSettings::default()
319
346
},
320
347
};
321
348
document.validate_shape()?;
@@ -890,7 +917,7 @@ mod tests {
890
917
target_width: 4096,
891
918
target_height: 4096,
892
919
fps: 12,
893
framing: [CameraFraming::default(); 2],
920
..RenderSettings::default()
894
921
};
895
922
assert!(document
896
923
.validate_shape()
@@ -973,11 +1000,13 @@ mod framing_tests {
973
1000
height_milli: 3210,
974
1001
center_y_milli: 1100,
975
1002
pivot_y_milli: Some(1700),
1003
depth_range_milli: None,
976
1004
},
977
1005
CameraFraming {
978
1006
height_milli: 2890,
979
1007
center_y_milli: 987,
980
1008
pivot_y_milli: Some(1650),
1009
depth_range_milli: None,
981
1010
},
982
1011
];
983
1012
let root = std::env::temp_dir().join(format!(
@@ -37,6 +37,10 @@ pub struct CameraFraming {37
/// None resolves the rest-pose neck height, independently of image framing.37
/// None resolves the rest-pose neck height, independently of image framing.38
#[serde(default)]38
#[serde(default)]39
pub pivot_y_milli: Option<i32>,39
pub pivot_y_milli: Option<i32>,40
/// Total depth range (mm) centred on the pivot; None = automatic41
/// (T-pose arm span + one arm length margin per side, t985u).42
#[serde(default, skip_serializing_if = "Option::is_none")]43
pub depth_range_milli: Option<u32>,40
}44
}41
impl Default for CameraFraming {45
impl Default for CameraFraming {42
fn default() -> Self {46
fn default() -> Self {@@ -44,6 +48,7 @@ impl Default for CameraFraming {44
height_milli: 2600,48
height_milli: 2600,45
center_y_milli: 1000,49
center_y_milli: 1000,46
pivot_y_milli: None,50
pivot_y_milli: None,51
depth_range_milli: None,47
}52
}48
}53
}49
}54
}@@ -52,8 +57,15 @@ impl CameraFraming {52
if !(100..=10000).contains(&self.height_milli)57
if !(100..=10000).contains(&self.height_milli)53
|| self.center_y_milli.unsigned_abs() > 1000058
|| self.center_y_milli.unsigned_abs() > 1000054
|| self.pivot_y_milli.is_some_and(|p| p.unsigned_abs() > 10000)59
|| self.pivot_y_milli.is_some_and(|p| p.unsigned_abs() > 10000)60
|| self.depth_range_milli.is_some_and(|d| {61
!(crate::camera::MIN_DEPTH_RANGE_MILLI..=crate::camera::MAX_DEPTH_RANGE_MILLI)62
.contains(&d)63
})55
{64
{56
Err("renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m".into())65
Err(66
"renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m, dieptebereik 0,1–19 m"67
.into(),68
)57
} else {69
} else {58
Ok(())70
Ok(())59
}71
}@@ -68,6 +80,16 @@ pub struct RenderSettings {68
pub fps: u32,80
pub fps: u32,69
#[serde(default)]81
#[serde(default)]70
pub framing: [CameraFraming; 2],82
pub framing: [CameraFraming; 2],83
/// Depth PNG bit depth: 8 (default) or 16 (t983u).84
#[serde(default = "default_depth_bits")]85
pub depth_bits: u8,86
/// Depth PNG encoding: gray (default), rgb_bands or rgb_packed (t987u).87
#[serde(default)]88
pub depth_encoding: crate::camera::DepthEncoding,89
}90
91
fn default_depth_bits() -> u8 {92
871
}93
}72
94
73
impl Default for RenderSettings {95
impl Default for RenderSettings {@@ -77,6 +99,8 @@ impl Default for RenderSettings {77
target_height: 128,99
target_height: 128,78
fps: DEFAULT_FPS,100
fps: DEFAULT_FPS,79
framing: [CameraFraming::default(); 2],101
framing: [CameraFraming::default(); 2],102
depth_bits: 8,103
depth_encoding: crate::camera::DepthEncoding::Gray,80
}104
}81
}105
}82
}106
}@@ -171,6 +195,9 @@ impl ProjectFile {171
195
172
pub fn validate_shape(&self) -> Result<(), ProjectError> {196
pub fn validate_shape(&self) -> Result<(), ProjectError> {173
let mut issues = Vec::new();197
let mut issues = Vec::new();198
if self.render.depth_bits != 8 && self.render.depth_bits != 16 {199
issues.push("depth_bits moet 8 of 16 zijn".to_string());200
}174
for camera in self.render.framing {201
for camera in self.render.framing {175
if let Err(error) = camera.validate() {202
if let Err(error) = camera.validate() {176
issues.push(error);203
issues.push(error);@@ -315,7 +342,7 @@ pub fn assemble(draft: ProjectDraft) -> Result<ProjectFile, ProjectError> {315
target_width: draft.target_width,342
target_width: draft.target_width,316
target_height: draft.target_height,343
target_height: draft.target_height,317
fps: draft.fps,344
fps: draft.fps,318
framing: [CameraFraming::default(); 2],345
..RenderSettings::default()319
},346
},320
};347
};321
document.validate_shape()?;348
document.validate_shape()?;@@ -890,7 +917,7 @@ mod tests {890
target_width: 4096,917
target_width: 4096,891
target_height: 4096,918
target_height: 4096,892
fps: 12,919
fps: 12,893
framing: [CameraFraming::default(); 2],920
..RenderSettings::default()894
};921
};895
assert!(document922
assert!(document896
.validate_shape()923
.validate_shape()@@ -973,11 +1000,13 @@ mod framing_tests {973
height_milli: 3210,1000
height_milli: 3210,974
center_y_milli: 1100,1001
center_y_milli: 1100,975
pivot_y_milli: Some(1700),1002
pivot_y_milli: Some(1700),1003
depth_range_milli: None,976
},1004
},977
CameraFraming {1005
CameraFraming {978
height_milli: 2890,1006
height_milli: 2890,979
center_y_milli: 987,1007
center_y_milli: 987,980
pivot_y_milli: Some(1650),1008
pivot_y_milli: Some(1650),1009
depth_range_milli: None,981
},1010
},982
];1011
];983
let root = std::env::temp_dir().join(format!(1012
let root = std::env::temp_dir().join(format!(@@ -27,6 +27,7 @@ fn scratch(tag: &str) -> PathBuf {
27
27
}
28
28
fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
29
29
RenderJob {
30
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
30
31
character,
31
32
sex: "male".into(),
32
33
preset,
@@ -37,6 +38,7 @@ fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
37
38
height_milli: 2400,
38
39
center_y_milli: 1000,
39
40
pivot_y_milli: None,
41
depth_range_milli: None,
40
42
},
41
43
output: std::env::temp_dir(),
42
44
project_id: "fbx-test".into(),
@@ -27,6 +27,7 @@ fn scratch(tag: &str) -> PathBuf {27
}27
}28
fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {28
fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {29
RenderJob {29
RenderJob {30
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,30
character,31
character,31
sex: "male".into(),32
sex: "male".into(),32
preset,33
preset,@@ -37,6 +38,7 @@ fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {37
height_milli: 2400,38
height_milli: 2400,38
center_y_milli: 1000,39
center_y_milli: 1000,39
pivot_y_milli: None,40
pivot_y_milli: None,41
depth_range_milli: None,40
},42
},41
output: std::env::temp_dir(),43
output: std::env::temp_dir(),42
project_id: "fbx-test".into(),44
project_id: "fbx-test".into(),@@ -53,6 +53,7 @@ fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) {
53
53
}
54
54
fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {
55
55
RenderJob {
56
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
56
57
character,
57
58
sex: "male".into(),
58
59
preset,
@@ -53,6 +53,7 @@ fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) {53
}53
}54
fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {54
fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {55
RenderJob {55
RenderJob {56
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,56
character,57
character,57
sex: "male".into(),58
sex: "male".into(),58
preset,59
preset,