BlueGit

golden-age paperdoll-renderer

9j: depth range = T-pose arm span + arm margin (per-sex .ppd override, GUI); depth_bits 8 (default)/16 x gray/rgb_bands/rgb_packed; schema 6/.ppda 3 (v5/v2 read as gray16); readers decode all; Render alles keeps live pass preview (t990u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
673fde687ac33a92cccf1bd2e7053543cbedda77
Parent
574598c6
14 files changed +964 −78
README.md +11 −5
@@ -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 **Opsla
89
89
90### Render alles
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
94De 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.
94De 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
96Per 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.
96Per 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
98Het 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.
102Alpha 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```text
104```text
99renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
105renders/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
101renders/male-body-atlas/male-body-atlas.ppda
107renders/male-body-atlas/male-body-atlas.ppda
102```
108```
103
109
104Per 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`.
110Per 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 .ppda
112### Atlassen en .ppda
107
113
108Als 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`.
114Als 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### Hervatten
122### Hervatten
design.md +1 −1
@@ -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>-<pi
409
409
410## Decision 2026-11-07 (Tim t984u, amended t985u): default and configurable depth range
410## Decision 2026-11-07 (Tim t984u, amended t985u): default and configurable depth range
411
411
412Supersedes 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.
412Supersedes 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 encoding
414## Decision 2026-11-07 (Tim t980u, amended t981u/t983u/t987u): depth bit depth + depth encoding
415
415
examples/neck_pivot_probe.rs +2 −0
@@ -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(),
src/atlas.rs +152 −20
@@ -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).
32pub 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).
34pub const PPDA_FORMAT_VERSION: u32 = 3;
33/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
35/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
34/// refused up front with a clear message; atlases are never split.
36/// refused up front with a clear message; atlases are never split.
35pub const MAX_ATLAS_DIM: u32 = 16384;
37pub 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 = writer
360 let mut stream = writer
337 .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 .encoding
525 .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
586impl 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
556fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {
597fn 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_version
601 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::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 #[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(_))
src/camera.rs +398 −0
@@ -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}
107pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
107pub 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 at
144/// farthest edge, so geometry inside the pitch-0 frame stays in range at
142/// 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.
143pub fn depth_range(framing: CameraFraming) -> (f32, f32) {
146pub 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) -> (M
163 (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")]
176pub enum DepthEncoding {
177 #[default]
178 Gray,
179 RgbBands,
180 RgbPacked,
181}
182impl 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)]
205pub struct DepthFormat {
206 pub bits: u8,
207 pub encoding: DepthEncoding,
208}
209impl Default for DepthFormat {
210 fn default() -> Self {
211 Self {
212 bits: 8,
213 encoding: DepthEncoding::Gray,
214 }
215 }
216}
217impl 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.
389pub 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}
413pub const MIN_DEPTH_RANGE_MILLI: u32 = 100;
414pub 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.
418pub 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#[cfg(test)]
465#[cfg(test)]
167mod tests {
466mod 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_milli
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}
src/compositor.rs +1 −0
@@ -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
src/editor.rs +122 −0
@@ -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 applicable
548 /// 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 = 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 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 ui
1226 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();
src/export_fixture.rs +11 −6
@@ -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,
src/export_set.rs +148 −37
@@ -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.
23pub 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).
25pub const OUTPUT_META_VERSION: u32 = 6;
26/// Oldest output schema still read.
27pub const MIN_OUTPUT_META_VERSION: u32 = 5;
24pub const MAX_TARGET: u32 = MAX_HIGHRES / SCALE;
28pub 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).
26pub const MAX_PNG_BYTES: u64 = 8 * 1024 * 1024;
30pub 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
394impl 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
388fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
407fn 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
519fn validate_complete(m: &CompleteManifest) -> Result<(), ExportError> {
536fn 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_version
540 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`) under
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 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::GrayscaleAlpha
727 drop(reader);
709 || info.bit_depth != png::BitDepth::Sixteen
728 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 = 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)?;
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 samples
740/// (validated: format, size, alpha rule, band order).
741pub(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).
794pub(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
743fn validate_normals(rgba: &[u8], _w: u32, _h: u32) -> Result<(), ExportError> {
814fn 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::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 #[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::GRAY16
1363 ),
1259 Err(ExportError::Corrupt(_))
1364 Err(ExportError::Corrupt(_))
1260 ));
1365 ));
1261 // RGBA written as depth should fail color type check
1366 // 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::GRAY16
1385 ),
1275 Err(ExportError::Corrupt(_))
1386 Err(ExportError::Corrupt(_))
1276 ));
1387 ));
1277 }
1388 }
src/preview2d.rs +1 −0
@@ -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
src/production.rs +82 −6
@@ -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)]
93pub struct ClipPlan {
97pub struct ClipPlan {
@@ -958,6 +962,7 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S
958 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, S
1022 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, S
1030 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<String
1165}
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`.
1176pub 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.
1206pub 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
1168pub fn write_depth(
1223pub 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.
1322pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1377pub 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 == 0
1382 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)> = 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 } 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,
src/project.rs +32 −3
@@ -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 = 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}
41impl Default for CameraFraming {
45impl 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() > 10000
58 || self.center_y_milli.unsigned_abs() > 10000
54 || 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
91fn default_depth_bits() -> u8 {
92 8
71}
93}
72
94
73impl Default for RenderSettings {
95impl 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!(document
922 assert!(document
896 .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!(
tests/fbx_integration.rs +2 −0
@@ -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}
28fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
28fn 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(),
tests/static_body.rs +1 −0
@@ -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}
54fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {
54fn 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,