BlueGit

golden-age paperdoll-renderer

feat: deterministic 2D depth compositor and view-space relighting

Consume schema-3 export_set maps for body + compatible equipment. Nearest
valid depth wins with DEPTH_EPSILON_U16=1 and lower-index tie-break; diffuse,
normal and specular stay bound to that same layer. Documented single-depth
alpha-edge policy. Linear ambient/diffuse/Blinn-Phong relighting uses a
view-space light matching exported normals. Pixel fixtures cover occlusion,
ties, transparency and lighting. Preview2d GUI remains not built.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
b770a89001eb3e0b2fdb53d47429553df0ccb3f2
Parent
75356b81
5 files changed +1093 −11
README.md +21 −0
@@ -65,6 +65,27 @@ renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
65 65
66 66 Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
67 67
68 ## 2D depth-compositor (bibliotheek)
69
70 De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris via `export_set`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).
71
72 `relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps; opgeslagen diffuse blijft elders onbelichte albedo.
73
74 ```rust
75 use paperdoll_renderer::compositor::{assert_compatible, compose_and_relight, LayerMaps, Light};
76 use paperdoll_renderer::export_set::ExportRun;
77
78 let body = ExportRun::open(&body_run_dir)?;
79 let sword = ExportRun::open(&sword_run_dir)?;
80 assert_compatible(&[&body, &sword])?;
81 let layers = [
82 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
83 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
84 ];
85 let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
86 ```
87
88
68 89 Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
69 90
70 91 ## Openen met
@@ -65,6 +65,27 @@ renders/run-<tijd>-<pid>/male/body/clip001-walk/f000012/p-30_y330_diffuse.png
65
65
66Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
66Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet overschreven. `render.json` (schema 3) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract, clipinventaris en telling. Alleen `complete.json` (zelfde schema, met per-PNG SHA-256) betekent een volledige set. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8-/depth16-maps met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
67
67
68## 2D depth-compositor (bibliotheek)
69
70De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris via `export_set`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).
71
72`relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps; opgeslagen diffuse blijft elders onbelichte albedo.
73
74```rust
75use paperdoll_renderer::compositor::{assert_compatible, compose_and_relight, LayerMaps, Light};
76use paperdoll_renderer::export_set::ExportRun;
77
78let body = ExportRun::open(&body_run_dir)?;
79let sword = ExportRun::open(&sword_run_dir)?;
80assert_compatible(&[&body, &sword])?;
81let layers = [
82 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
83 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
84];
85let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
86```
87
88
68Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
89Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX is alleen importoverzicht; morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
69
90
70## Openen met
91## Openen met
design.md +5 −5
@@ -126,7 +126,7 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
126 126
127 127 `render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
128 128
129 **Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. Resume and the 2D compositor remain later work; they will consume this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.
129 **Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Resume remains later work. Automatic cleanup of old runs is still future work.
130 130
131 131 ## 8. Map contracts and rendering algorithm
132 132
@@ -154,7 +154,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write
154 154
155 155 ## 9. Equipment editor and binary presets
156 156
157 The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import, deformation/weight painting and a rendered 2D compositor are not implemented by this feature.
157 The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
158 158
159 159 Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
160 160
@@ -180,11 +180,11 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
180 180
181 181 ## 10. Rendered 2D preview
182 182
183 Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI itself is not built yet; only the safe map reader/index API is.
183 Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI/tab/playback itself is not built yet. The library depth compositor and relighting API (`compositor`) consumes the safe map reader.
184 184
185 185 Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.
186 186
187 Depth compositor selects the nearest valid surface per pixel from body and chosen equipment, with stable epsilon/tie-break ordering. Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Blend alpha edges using a documented approximation; single-depth compositing cannot reconstruct arbitrary order-dependent translucent surfaces. A simple ambient + diffuse + specular preview proves maps and lighting; it is not the future game controller. Transform the continuous light into the same view space used by each normal map.
187 Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo.
188 188
189 189 ## 11. Development workflow, quality and packaging
190 190
@@ -217,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup
217 217 - Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
218 218 - During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
219 219
220 Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
220 Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting is implemented; the 2D GUI/tab/playback, rim effects, resume and FBX drawing remain unimplemented.
221 221
222 222 ## 12. Incremental feature plan and acceptance gates
223 223
@@ -126,7 +126,7 @@ renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
126
126
127`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
127`render.json` (output metadata schema **3**, separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
128
128
129**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. Resume and the 2D compositor remain later work; they will consume this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.
129**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Resume remains later work. Automatic cleanup of old runs is still future work.
130
130
131## 8. Map contracts and rendering algorithm
131## 8. Map contracts and rendering algorithm
132
132
@@ -154,7 +154,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write
154
154
155## 9. Equipment editor and binary presets
155## 9. Equipment editor and binary presets
156
156
157The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import, deformation/weight painting and a rendered 2D compositor are not implemented by this feature.
157The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
158
158
159Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
159Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
160
160
@@ -180,11 +180,11 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
180
180
181## 10. Rendered 2D preview
181## 10. Rendered 2D preview
182
182
183Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI itself is not built yet; only the safe map reader/index API is.
183Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI/tab/playback itself is not built yet. The library depth compositor and relighting API (`compositor`) consumes the safe map reader.
184
184
185Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.
185Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.
186
186
187Depth compositor selects the nearest valid surface per pixel from body and chosen equipment, with stable epsilon/tie-break ordering. Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Blend alpha edges using a documented approximation; single-depth compositing cannot reconstruct arbitrary order-dependent translucent surfaces. A simple ambient + diffuse + specular preview proves maps and lighting; it is not the future game controller. Transform the continuous light into the same view space used by each normal map.
187Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo.
188
188
189## 11. Development workflow, quality and packaging
189## 11. Development workflow, quality and packaging
190
190
@@ -217,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup
217- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
217- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
218- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
218- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
219
219
220Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The 2D relighting/compositor, rim effects, resume and FBX drawing remain unimplemented.
220Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting is implemented; the 2D GUI/tab/playback, rim effects, resume and FBX drawing remain unimplemented.
221
221
222## 12. Incremental feature plan and acceptance gates
222## 12. Incremental feature plan and acceptance gates
223
223
src/compositor.rs added +1060 −0
@@ -0,0 +1,1060 @@
1 //! Deterministic 2D depth compositor and linear relighting.
2 //!
3 //! Consumes already-exported maps through [`crate::export_set`] (schema 3).
4 //! Saved diffuse remains unlit albedo elsewhere; this module only applies
5 //! consumer-side lighting to composed layers.
6 //!
7 //! # Depth selection
8 //!
9 //! For each pixel, every layer with a **valid** sample (depth alpha ≠ 0 and
10 //! diffuse alpha ≠ 0) competes. The nearest surface wins: smaller depth gray
11 //! is closer (OpenGL-style 0 = near, 1 = far, stored as 16-bit gray).
12 //!
13 //! ## Epsilon and tie-break
14 //!
15 //! Depths that differ by at most [`DEPTH_EPSILON_U16`] codes are treated as a
16 //! tie. On a tie, the **lower layer index** wins. Callers must pass layers in a
17 //! stable order (body first, then equipment sorted by preset name) so ties are
18 //! reproducible across machines.
19 //!
20 //! # Same-layer attribute binding
21 //!
22 //! The winning layer supplies diffuse, normal and specular/shiny from that
23 //! **same** layer index. Maps are never mixed across layers for one pixel.
24 //!
25 //! # Alpha-edge policy
26 //!
27 //! Single-depth compositing cannot reconstruct arbitrary order-dependent
28 //! translucency. Supported approximation:
29 //!
30 //! - Eligible samples require non-zero depth coverage **and** non-zero diffuse
31 //! alpha (cutout / coverage). Purely transparent texels never win.
32 //! - Exactly one winning layer is chosen by depth+tie-break; losing layers
33 //! contribute nothing, even if they have soft alpha.
34 //! - The winner’s straight diffuse alpha is copied to the composed output.
35 //! Soft edges therefore keep the winner’s own coverage; they are not blended
36 //! with occluded layers behind them.
37 //!
38 //! # Lighting
39 //!
40 //! Linear ambient + Lambertian diffuse + Blinn-Phong specular. The light
41 //! direction is already in **view space**, matching the exported normal maps
42 //! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the
43 //! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage.
44 use crate::{
45 export_set::{ExportError, ExportRun, MapImage, MapKind},
46 render_job::ImageBuf,
47 };
48 use std::fmt;
49
50 /// Depth codes within this inclusive distance count as a tie.
51 pub const DEPTH_EPSILON_U16: u16 = 1;
52
53 /// Default Blinn-Phong exponent for the simple preview light.
54 pub const DEFAULT_SHININESS: f32 = 32.0;
55
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 pub enum ComposeError {
58 Empty,
59 Dimension(String),
60 Map(String),
61 Incompatible(String),
62 Export(String),
63 }
64
65 impl fmt::Display for ComposeError {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 Self::Empty => write!(f, "geen lagen om te composeren"),
69 Self::Dimension(m) | Self::Map(m) | Self::Incompatible(m) | Self::Export(m) => {
70 write!(f, "{m}")
71 }
72 }
73 }
74 }
75
76 impl std::error::Error for ComposeError {}
77
78 impl From<ExportError> for ComposeError {
79 fn from(value: ExportError) -> Self {
80 Self::Export(value.to_string())
81 }
82 }
83
84 /// One composable layer already decoded to the export pixel contract.
85 #[derive(Debug, Clone)]
86 pub struct LayerMaps {
87 pub label: String,
88 pub width: u32,
89 pub height: u32,
90 pub diffuse: Vec<u8>,
91 pub normal: Vec<u8>,
92 pub specular: Vec<u8>,
93 /// Interleaved gray, alpha (`width*height*2`), same layout as `MapImage::Depth16`.
94 pub depth: Vec<u16>,
95 }
96
97 impl LayerMaps {
98 pub fn from_maps(
99 label: impl Into<String>,
100 diffuse: &MapImage,
101 normal: &MapImage,
102 specular: &MapImage,
103 depth: &MapImage,
104 ) -> Result<Self, ComposeError> {
105 let (dw, dh, drgba) = expect_rgba(diffuse, "diffuse")?;
106 let (nw, nh, nrgba) = expect_rgba(normal, "normal")?;
107 let (sw, sh, srgba) = expect_rgba(specular, "specular")?;
108 let (zw, zh, samples) = expect_depth(depth)?;
109 if !(dw == nw && nw == sw && sw == zw && dh == nh && nh == sh && sh == zh) {
110 return Err(ComposeError::Dimension(
111 "laagmaps hebben niet dezelfde afmetingen".into(),
112 ));
113 }
114 let pixels = (dw as usize)
115 .checked_mul(dh as usize)
116 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
117 if drgba.len() != pixels * 4
118 || nrgba.len() != pixels * 4
119 || srgba.len() != pixels * 4
120 || samples.len() != pixels * 2
121 {
122 return Err(ComposeError::Map(
123 "buffergrootte past niet bij breedte×hoogte".into(),
124 ));
125 }
126 Ok(Self {
127 label: label.into(),
128 width: dw,
129 height: dh,
130 diffuse: drgba,
131 normal: nrgba,
132 specular: srgba,
133 depth: samples,
134 })
135 }
136
137 /// Load the four target maps for one pose from a completed export run.
138 pub fn from_export_run(
139 run: &ExportRun,
140 label: impl Into<String>,
141 clip_token: &str,
142 frame: u32,
143 pitch: i32,
144 yaw: u32,
145 ) -> Result<Self, ComposeError> {
146 let diffuse = run.load_map(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;
147 let normal = run.load_map(clip_token, frame, pitch, yaw, MapKind::Normal)?;
148 let specular = run.load_map(clip_token, frame, pitch, yaw, MapKind::Specular)?;
149 let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;
150 Self::from_maps(label, &diffuse, &normal, &specular, &depth)
151 }
152 }
153
154 fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
155 match image {
156 MapImage::Rgba8 {
157 width,
158 height,
159 rgba,
160 } => Ok((*width, *height, rgba.clone())),
161 MapImage::Depth16 { .. } => Err(ComposeError::Map(format!(
162 "{kind} verwacht RGBA8, kreeg depth16"
163 ))),
164 }
165 }
166
167 fn expect_depth(image: &MapImage) -> Result<(u32, u32, Vec<u16>), ComposeError> {
168 match image {
169 MapImage::Depth16 {
170 width,
171 height,
172 samples,
173 } => Ok((*width, *height, samples.clone())),
174 MapImage::Rgba8 { .. } => Err(ComposeError::Map(
175 "depth verwacht gray-alpha16, kreeg RGBA8".into(),
176 )),
177 }
178 }
179
180 /// Shared export contract that body + equipment layers must agree on.
181 #[derive(Debug, Clone, PartialEq)]
182 pub struct CompatKey {
183 pub project_id: String,
184 pub sex: String,
185 pub body_model_sha256: String,
186 pub target: [u32; 2],
187 pub fps: u32,
188 pub framing: crate::project::CameraFraming,
189 pub near: f32,
190 pub far: f32,
191 pub clip_tokens: Vec<(String, u32)>,
192 }
193
194 impl CompatKey {
195 pub fn from_run(run: &ExportRun) -> Self {
196 Self {
197 project_id: run.render.project.id.clone(),
198 sex: run.render.sex.clone(),
199 body_model_sha256: run.render.body_model.sha256.clone(),
200 target: run.render.target,
201 fps: run.render.fps,
202 framing: run.render.framing,
203 near: run.render.near,
204 far: run.render.far,
205 clip_tokens: run
206 .render
207 .clips
208 .iter()
209 .map(|c| (c.token.clone(), c.frames))
210 .collect(),
211 }
212 }
213 }
214
215 /// Reject runs that cannot be depth-composited together.
216 pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
217 if runs.is_empty() {
218 return Err(ComposeError::Empty);
219 }
220 let first = CompatKey::from_run(runs[0]);
221 for run in runs.iter().skip(1) {
222 let key = CompatKey::from_run(run);
223 if key.project_id != first.project_id {
224 return Err(ComposeError::Incompatible(
225 "project.id verschilt tussen exportsets".into(),
226 ));
227 }
228 if key.sex != first.sex {
229 return Err(ComposeError::Incompatible(
230 "geslacht verschilt tussen exportsets".into(),
231 ));
232 }
233 if key.body_model_sha256 != first.body_model_sha256 {
234 return Err(ComposeError::Incompatible(
235 "body_model fingerprint verschilt".into(),
236 ));
237 }
238 if key.target != first.target {
239 return Err(ComposeError::Incompatible(
240 "target-resolutie verschilt".into(),
241 ));
242 }
243 if key.fps != first.fps {
244 return Err(ComposeError::Incompatible("fps verschilt".into()));
245 }
246 if key.framing != first.framing {
247 return Err(ComposeError::Incompatible("framing verschilt".into()));
248 }
249 if (key.near - first.near).abs() > f32::EPSILON
250 || (key.far - first.far).abs() > f32::EPSILON
251 {
252 return Err(ComposeError::Incompatible("near/far verschilt".into()));
253 }
254 if key.clip_tokens != first.clip_tokens {
255 return Err(ComposeError::Incompatible(
256 "clip-inventaris verschilt".into(),
257 ));
258 }
259 }
260 Ok(())
261 }
262
263 /// Composed target maps plus the winning layer index per pixel (`u8::MAX` = empty).
264 #[derive(Debug, Clone)]
265 pub struct ComposedMaps {
266 pub width: u32,
267 pub height: u32,
268 pub diffuse: Vec<u8>,
269 pub normal: Vec<u8>,
270 pub specular: Vec<u8>,
271 pub depth: Vec<u16>,
272 pub winner: Vec<u8>,
273 }
274
275 /// Select nearest valid surface per pixel; bind all maps from that layer.
276 pub fn compose(layers: &[LayerMaps]) -> Result<ComposedMaps, ComposeError> {
277 if layers.is_empty() {
278 return Err(ComposeError::Empty);
279 }
280 let width = layers[0].width;
281 let height = layers[0].height;
282 if width == 0 || height == 0 {
283 return Err(ComposeError::Dimension("lege afmetingen".into()));
284 }
285 let pixels = (width as usize)
286 .checked_mul(height as usize)
287 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
288 for (index, layer) in layers.iter().enumerate() {
289 if layer.width != width || layer.height != height {
290 return Err(ComposeError::Dimension(format!(
291 "laag {index} ('{}') heeft andere afmetingen",
292 layer.label
293 )));
294 }
295 if layer.diffuse.len() != pixels * 4
296 || layer.normal.len() != pixels * 4
297 || layer.specular.len() != pixels * 4
298 || layer.depth.len() != pixels * 2
299 {
300 return Err(ComposeError::Map(format!(
301 "laag {index} ('{}') heeft inconsistente buffers",
302 layer.label
303 )));
304 }
305 if layers.len() > u8::MAX as usize {
306 return Err(ComposeError::Map(
307 "maximaal 255 lagen voor winner-index".into(),
308 ));
309 }
310 }
311
312 let mut diffuse = vec![0u8; pixels * 4];
313 let mut normal = vec![0u8; pixels * 4];
314 let mut specular = vec![0u8; pixels * 4];
315 let mut depth = vec![0u16; pixels * 2];
316 let mut winner = vec![u8::MAX; pixels];
317
318 for i in 0..pixels {
319 let mut best_depth: Option<u16> = None;
320 let mut best_layer: Option<usize> = None;
321 for (layer_idx, layer) in layers.iter().enumerate() {
322 let depth_alpha = layer.depth[i * 2 + 1];
323 let diffuse_alpha = layer.diffuse[i * 4 + 3];
324 if depth_alpha == 0 || diffuse_alpha == 0 {
325 continue;
326 }
327 let gray = layer.depth[i * 2];
328 match (best_depth, best_layer) {
329 (None, _) => {
330 best_depth = Some(gray);
331 best_layer = Some(layer_idx);
332 }
333 (Some(bd), Some(_)) => {
334 if gray.saturating_add(DEPTH_EPSILON_U16) < bd {
335 // Strictly nearer beyond epsilon.
336 best_depth = Some(gray);
337 best_layer = Some(layer_idx);
338 } else if gray <= bd.saturating_add(DEPTH_EPSILON_U16) {
339 // Tie (within epsilon): keep lower layer index already chosen.
340 }
341 // else: farther — ignore
342 }
343 _ => unreachable!(),
344 }
345 }
346 if let (Some(_), Some(layer_idx)) = (best_depth, best_layer) {
347 let layer = &layers[layer_idx];
348 let o = i * 4;
349 diffuse[o..o + 4].copy_from_slice(&layer.diffuse[o..o + 4]);
350 normal[o..o + 4].copy_from_slice(&layer.normal[o..o + 4]);
351 specular[o..o + 4].copy_from_slice(&layer.specular[o..o + 4]);
352 depth[i * 2] = layer.depth[i * 2];
353 depth[i * 2 + 1] = layer.depth[i * 2 + 1];
354 winner[i] = layer_idx as u8;
355 }
356 }
357
358 Ok(ComposedMaps {
359 width,
360 height,
361 diffuse,
362 normal,
363 specular,
364 depth,
365 winner,
366 })
367 }
368
369 /// View-space directional light for consumer-side relighting.
370 #[derive(Debug, Clone, Copy, PartialEq)]
371 pub struct Light {
372 /// Direction **toward** the light, view space (will be normalized).
373 pub direction_view: [f32; 3],
374 pub ambient: f32,
375 pub diffuse: f32,
376 pub specular: f32,
377 pub shininess: f32,
378 }
379
380 impl Default for Light {
381 fn default() -> Self {
382 Self {
383 // Slightly above and toward +Z so a front-facing normal responds.
384 direction_view: [0.35, 0.55, 0.75],
385 ambient: 0.15,
386 diffuse: 0.75,
387 specular: 0.4,
388 shininess: DEFAULT_SHININESS,
389 }
390 }
391 }
392
393 /// Relight composed albedo/normal/specular into an sRGB RGBA image.
394 pub fn relight(composed: &ComposedMaps, light: &Light) -> Result<ImageBuf, ComposeError> {
395 let pixels = (composed.width as usize)
396 .checked_mul(composed.height as usize)
397 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
398 if composed.diffuse.len() != pixels * 4
399 || composed.normal.len() != pixels * 4
400 || composed.specular.len() != pixels * 4
401 {
402 return Err(ComposeError::Map(
403 "composed buffers passen niet bij afmetingen".into(),
404 ));
405 }
406 let l = normalize(light.direction_view);
407 let view = [0.0_f32, 0.0, 1.0];
408 let half = normalize([l[0] + view[0], l[1] + view[1], l[2] + view[2]]);
409 let mut rgba = vec![0u8; pixels * 4];
410 for i in 0..pixels {
411 let o = i * 4;
412 let a = composed.diffuse[o + 3];
413 if a == 0 || composed.winner[i] == u8::MAX {
414 continue;
415 }
416 let albedo = [
417 linear_srgb(composed.diffuse[o]),
418 linear_srgb(composed.diffuse[o + 1]),
419 linear_srgb(composed.diffuse[o + 2]),
420 ];
421 let n = decode_normal([
422 composed.normal[o],
423 composed.normal[o + 1],
424 composed.normal[o + 2],
425 ]);
426 let shiny = composed.specular[o] as f32 / 255.0;
427 let ndotl = dot(n, l).max(0.0);
428 let ndoth = dot(n, half).max(0.0);
429 let spec = shiny * ndoth.powf(light.shininess.max(1.0));
430 let mut lit = [0.0_f32; 3];
431 for c in 0..3 {
432 lit[c] = light.ambient * albedo[c]
433 + light.diffuse * ndotl * albedo[c]
434 + light.specular * spec;
435 lit[c] = lit[c].clamp(0.0, 1.0);
436 }
437 rgba[o] = encode_srgb(lit[0]);
438 rgba[o + 1] = encode_srgb(lit[1]);
439 rgba[o + 2] = encode_srgb(lit[2]);
440 rgba[o + 3] = a;
441 }
442 Ok(ImageBuf {
443 width: composed.width,
444 height: composed.height,
445 rgba,
446 })
447 }
448
449 /// Compose then relight in one step.
450 pub fn compose_and_relight(
451 layers: &[LayerMaps],
452 light: &Light,
453 ) -> Result<(ComposedMaps, ImageBuf), ComposeError> {
454 let composed = compose(layers)?;
455 let lit = relight(&composed, light)?;
456 Ok((composed, lit))
457 }
458
459 fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
460 normalize([
461 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
462 rgb[1] as f32 / 255.0 * 2.0 - 1.0,
463 rgb[2] as f32 / 255.0 * 2.0 - 1.0,
464 ])
465 }
466
467 fn normalize(v: [f32; 3]) -> [f32; 3] {
468 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
469 if len > 1e-10 {
470 [v[0] / len, v[1] / len, v[2] / len]
471 } else {
472 [0.0, 0.0, 1.0]
473 }
474 }
475
476 fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
477 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
478 }
479
480 fn linear_srgb(x: u8) -> f32 {
481 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
482 LUT.get_or_init(|| {
483 std::array::from_fn(|i| {
484 let v = i as f32 / 255.;
485 if v <= 0.04045 {
486 v / 12.92
487 } else {
488 ((v + 0.055) / 1.055).powf(2.4)
489 }
490 })
491 })[x as usize]
492 }
493
494 fn encode_srgb(x: f32) -> u8 {
495 let v = if x <= 0.0031308 {
496 x * 12.92
497 } else {
498 1.055 * x.powf(1. / 2.4) - 0.055
499 };
500 (v * 255.).round().clamp(0., 255.) as u8
501 }
502
503 #[cfg(test)]
504 mod tests {
505 use super::*;
506 use crate::digest;
507 use crate::export_set::{complete_identity_json, ExportRun};
508 use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
509 use crate::project::CameraFraming;
510 use std::collections::BTreeMap;
511 use std::fs;
512 use std::path::{Path, PathBuf};
513 use std::sync::atomic::{AtomicU64, Ordering};
514
515 struct Temp(PathBuf);
516 impl Temp {
517 fn new() -> Self {
518 static N: AtomicU64 = AtomicU64::new(0);
519 let id = N.fetch_add(1, Ordering::Relaxed);
520 let path = std::env::temp_dir().join(format!(
521 "paperdoll-compositor-{}-{}",
522 std::process::id(),
523 id
524 ));
525 let _ = fs::remove_dir_all(&path);
526 fs::create_dir_all(&path).unwrap();
527 Self(path)
528 }
529 }
530 impl Drop for Temp {
531 fn drop(&mut self) {
532 let _ = fs::remove_dir_all(&self.0);
533 }
534 }
535
536 fn framing() -> CameraFraming {
537 CameraFraming {
538 height_milli: 2600,
539 center_y_milli: 1000,
540 pivot_y_milli: Some(1460),
541 }
542 }
543
544 #[allow(clippy::too_many_arguments)]
545 fn solid_layer(
546 label: &str,
547 w: u32,
548 h: u32,
549 diffuse: [u8; 4],
550 normal: [u8; 4],
551 specular: [u8; 4],
552 depth_gray: u16,
553 depth_alpha: u16,
554 ) -> LayerMaps {
555 let pixels = (w * h) as usize;
556 LayerMaps {
557 label: label.into(),
558 width: w,
559 height: h,
560 diffuse: diffuse.repeat(pixels),
561 normal: normal.repeat(pixels),
562 specular: specular.repeat(pixels),
563 depth: [depth_gray, depth_alpha]
564 .into_iter()
565 .cycle()
566 .take(pixels * 2)
567 .collect(),
568 }
569 }
570
571 fn pixel_layer(
572 label: &str,
573 w: u32,
574 h: u32,
575 fill: impl Fn(usize) -> ([u8; 4], [u8; 4], [u8; 4], u16, u16),
576 ) -> LayerMaps {
577 let pixels = (w * h) as usize;
578 let mut diffuse = vec![0; pixels * 4];
579 let mut normal = vec![0; pixels * 4];
580 let mut specular = vec![0; pixels * 4];
581 let mut depth = vec![0u16; pixels * 2];
582 for i in 0..pixels {
583 let (d, n, s, g, a) = fill(i);
584 diffuse[i * 4..i * 4 + 4].copy_from_slice(&d);
585 normal[i * 4..i * 4 + 4].copy_from_slice(&n);
586 specular[i * 4..i * 4 + 4].copy_from_slice(&s);
587 depth[i * 2] = g;
588 depth[i * 2 + 1] = a;
589 }
590 LayerMaps {
591 label: label.into(),
592 width: w,
593 height: h,
594 diffuse,
595 normal,
596 specular,
597 depth,
598 }
599 }
600
601 #[test]
602 fn front_occludes_back_and_binds_same_layer_maps() {
603 // 2×1: left pixel has front red equipment over blue body; right is body only.
604 let body = pixel_layer("body", 2, 1, |i| {
605 let depth = if i == 0 { 30000 } else { 20000 };
606 (
607 [0, 0, 200, 255],
608 [128, 128, 255, 255],
609 [0, 0, 0, 255],
610 depth,
611 65535,
612 )
613 });
614 let gear = pixel_layer("sword", 2, 1, |i| {
615 if i == 0 {
616 (
617 [200, 0, 0, 255],
618 [255, 128, 128, 255], // +X-ish normal from gear
619 [180, 180, 180, 255],
620 10000, // nearer
621 65535,
622 )
623 } else {
624 ([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
625 }
626 });
627 let composed = compose(&[body, gear]).unwrap();
628 assert_eq!(composed.winner[0], 1, "front equipment wins left");
629 assert_eq!(&composed.diffuse[0..4], &[200, 0, 0, 255]);
630 assert_eq!(&composed.normal[0..4], &[255, 128, 128, 255]);
631 assert_eq!(&composed.specular[0..4], &[180, 180, 180, 255]);
632 assert_eq!(composed.depth[0], 10000);
633 assert_eq!(composed.winner[1], 0, "body remains on right");
634 assert_eq!(&composed.diffuse[4..8], &[0, 0, 200, 255]);
635 assert_eq!(&composed.specular[4..8], &[0, 0, 0, 255]);
636 }
637
638 #[test]
639 fn epsilon_ties_prefer_lower_layer_index() {
640 let body = solid_layer(
641 "body",
642 1,
643 1,
644 [10, 20, 30, 255],
645 [128, 128, 255, 255],
646 [0, 0, 0, 255],
647 20000,
648 65535,
649 );
650 // Within DEPTH_EPSILON_U16 of body depth.
651 let gear = solid_layer(
652 "hat",
653 1,
654 1,
655 [90, 80, 70, 255],
656 [128, 200, 128, 255],
657 [50, 50, 50, 255],
658 20000 + DEPTH_EPSILON_U16,
659 65535,
660 );
661 let composed = compose(&[body.clone(), gear.clone()]).unwrap();
662 assert_eq!(composed.winner[0], 0);
663 assert_eq!(&composed.diffuse[..4], &[10, 20, 30, 255]);
664
665 // Reverse order: now gear is index 0 and wins the tie.
666 let reversed = compose(&[gear, body]).unwrap();
667 assert_eq!(reversed.winner[0], 0);
668 assert_eq!(&reversed.diffuse[..4], &[90, 80, 70, 255]);
669 }
670
671 #[test]
672 fn strictly_nearer_beyond_epsilon_beats_earlier_layer() {
673 let body = solid_layer(
674 "body",
675 1,
676 1,
677 [1, 2, 3, 255],
678 [128, 128, 255, 255],
679 [0, 0, 0, 255],
680 20000,
681 65535,
682 );
683 let gear = solid_layer(
684 "cloak",
685 1,
686 1,
687 [9, 8, 7, 255],
688 [128, 128, 255, 255],
689 [0, 0, 0, 255],
690 20000 - DEPTH_EPSILON_U16 - 1,
691 65535,
692 );
693 let composed = compose(&[body, gear]).unwrap();
694 assert_eq!(composed.winner[0], 1);
695 assert_eq!(&composed.diffuse[..4], &[9, 8, 7, 255]);
696 }
697
698 #[test]
699 fn transparency_and_alpha_edges_follow_documented_policy() {
700 // Center soft-edge body; equipment only on left with full coverage;
701 // right pixel has transparent equipment over body — body keeps soft alpha.
702 let body = pixel_layer("body", 3, 1, |i| {
703 let alpha = match i {
704 0 => 255,
705 1 => 128, // soft edge
706 _ => 255,
707 };
708 (
709 [40, 40, 40, alpha],
710 [128, 128, 255, alpha],
711 [0, 0, 0, alpha],
712 25000,
713 u16::from(alpha) * 257,
714 )
715 });
716 let gear = pixel_layer("pad", 3, 1, |i| match i {
717 0 => (
718 [255, 0, 0, 255],
719 [128, 128, 255, 255],
720 [0, 0, 0, 255],
721 10000,
722 65535,
723 ),
724 // Transparent equipment must not steal the soft body edge.
725 1 => ([255, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
726 // Semi-transparent losing layer behind would also be ignored if farther;
727 // here nearer but alpha 0 — skipped.
728 _ => ([0, 255, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
729 });
730 let composed = compose(&[body, gear]).unwrap();
731 assert_eq!(composed.winner[0], 1);
732 assert_eq!(composed.diffuse[3], 255);
733 assert_eq!(composed.winner[1], 0);
734 assert_eq!(
735 composed.diffuse[7], 128,
736 "winner soft alpha preserved; no cross-layer blend"
737 );
738 assert_eq!(composed.winner[2], 0);
739 assert_eq!(&composed.diffuse[8..12], &[40, 40, 40, 255]);
740 }
741
742 #[test]
743 fn lighting_responds_to_normal_and_light_direction() {
744 let facing = solid_layer(
745 "front",
746 1,
747 1,
748 [200, 200, 200, 255],
749 [128, 128, 255, 255], // ~+Z
750 [0, 0, 0, 255],
751 10000,
752 65535,
753 );
754 let away = solid_layer(
755 "back",
756 1,
757 1,
758 [200, 200, 200, 255],
759 [128, 128, 0, 255], // ~-Z
760 [0, 0, 0, 255],
761 10000,
762 65535,
763 );
764 let light = Light {
765 direction_view: [0.0, 0.0, 1.0],
766 ambient: 0.05,
767 diffuse: 0.9,
768 specular: 0.0,
769 shininess: 16.0,
770 };
771 let lit_front = relight(&compose(&[facing]).unwrap(), &light).unwrap();
772 let lit_back = relight(&compose(&[away]).unwrap(), &light).unwrap();
773 let front_luma =
774 lit_front.rgba[0] as u32 + lit_front.rgba[1] as u32 + lit_front.rgba[2] as u32;
775 let back_luma = lit_back.rgba[0] as u32 + lit_back.rgba[1] as u32 + lit_back.rgba[2] as u32;
776 assert!(
777 front_luma > back_luma + 40,
778 "front-facing should be brighter: {front_luma} vs {back_luma}"
779 );
780
781 let shiny = solid_layer(
782 "metal",
783 1,
784 1,
785 [180, 180, 180, 255],
786 [128, 128, 255, 255],
787 [255, 255, 255, 255],
788 10000,
789 65535,
790 );
791 let matte = solid_layer(
792 "matte",
793 1,
794 1,
795 [180, 180, 180, 255],
796 [128, 128, 255, 255],
797 [0, 0, 0, 255],
798 10000,
799 65535,
800 );
801 let spec_light = Light {
802 direction_view: [0.0, 0.0, 1.0],
803 ambient: 0.0,
804 diffuse: 0.2,
805 specular: 1.0,
806 shininess: 64.0,
807 };
808 let lit_shiny = relight(&compose(&[shiny]).unwrap(), &spec_light).unwrap();
809 let lit_matte = relight(&compose(&[matte]).unwrap(), &spec_light).unwrap();
810 let shiny_luma =
811 lit_shiny.rgba[0] as u32 + lit_shiny.rgba[1] as u32 + lit_shiny.rgba[2] as u32;
812 let matte_luma =
813 lit_matte.rgba[0] as u32 + lit_matte.rgba[1] as u32 + lit_matte.rgba[2] as u32;
814 assert!(
815 shiny_luma > matte_luma + 20,
816 "specular map must brighten highlight: {shiny_luma} vs {matte_luma}"
817 );
818 assert_eq!(lit_shiny.rgba[3], 255);
819 }
820
821 #[test]
822 fn empty_and_mismatched_layers_error() {
823 assert!(matches!(compose(&[]), Err(ComposeError::Empty)));
824 let a = solid_layer(
825 "a",
826 1,
827 1,
828 [1, 1, 1, 255],
829 [128, 128, 255, 255],
830 [0, 0, 0, 255],
831 1,
832 65535,
833 );
834 let b = solid_layer(
835 "b",
836 2,
837 1,
838 [1, 1, 1, 255],
839 [128, 128, 255, 255],
840 [0, 0, 0, 255],
841 1,
842 65535,
843 );
844 assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));
845 }
846
847 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
848 let data = serde_json::to_vec_pretty(value).unwrap();
849 let path = root.join(name);
850 let temp = path.with_extension(format!("part-{}", std::process::id()));
851 fs::write(&temp, &data).unwrap();
852 fs::hard_link(&temp, &path).unwrap();
853 let _ = fs::remove_file(temp);
854 }
855
856 fn publish_pose(
857 root: &Path,
858 sex: &str,
859 set: &str,
860 diffuse: [u8; 4],
861 normal: [u8; 4],
862 specular: [u8; 4],
863 depth_gray: u16,
864 ) -> BTreeMap<String, String> {
865 let mut map = BTreeMap::new();
866 let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000");
867 let rels = [
868 (format!("{base}_diffuse.png"), "diffuse"),
869 (format!("{base}_depth.png"), "depth"),
870 (format!("{base}_normal.png"), "normal"),
871 (format!("{base}_specular.png"), "specular"),
872 ];
873 for (rel, kind) in rels {
874 let path = Path::new(&rel);
875 match kind {
876 "depth" => {
877 write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
878 }
879 "normal" => {
880 write_rgba(
881 root,
882 path,
883 &ImageBuf {
884 width: 1,
885 height: 1,
886 rgba: normal.to_vec(),
887 },
888 )
889 .unwrap();
890 }
891 "specular" => {
892 write_rgba(
893 root,
894 path,
895 &ImageBuf {
896 width: 1,
897 height: 1,
898 rgba: specular.to_vec(),
899 },
900 )
901 .unwrap();
902 }
903 _ => {
904 write_rgba(
905 root,
906 path,
907 &ImageBuf {
908 width: 1,
909 height: 1,
910 rgba: diffuse.to_vec(),
911 },
912 )
913 .unwrap();
914 }
915 }
916 let bytes = fs::read(root.join(&rel)).unwrap();
917 map.insert(rel, digest::sha256_hex(&bytes));
918 }
919 map
920 }
921
922 fn write_completed_run(
923 root: &Path,
924 set: &str,
925 equipment_sha: Option<&str>,
926 checksums: BTreeMap<String, String>,
927 body_bytes: &[u8],
928 ) {
929 let body_hash = digest::sha256_hex(body_bytes);
930 let mut render = serde_json::json!({
931 "format_version": 3,
932 "status": "incomplete",
933 "project": {"id": "demo", "name": "Demo"},
934 "sex": "male",
935 "set": set,
936 "body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
937 "equipment_preset": null,
938 "target": [1, 1],
939 "fps": 12,
940 "highres_scale": 8,
941 "framing": framing(),
942 "pivot_source": {"kind": "manual"},
943 "near": NEAR,
944 "far": FAR,
945 "camera_distance": 10.0,
946 "yaw_zero": "camera on -Z",
947 "normal_space": "view (+Y up), PNG rows top-down",
948 "pitches": [-30, 0, 30],
949 "yaw_step": 30,
950 "sampling": "test",
951 "alpha": "straight",
952 "diffuse": "unlit",
953 "depth": "16-bit",
954 "specular": "scalar",
955 "pass_order": PASSES,
956 "saved_maps": ["diffuse", "depth", "normal", "specular"],
957 "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
958 "images": 1,
959 "passes": 6,
960 "png_files": 4
961 });
962 if let Some(sha) = equipment_sha {
963 render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
964 }
965 write_atomic_json(root, "render.json", &render);
966 let identity = complete_identity_json(&render).unwrap();
967 write_atomic_json(
968 root,
969 "complete.json",
970 &serde_json::json!({
971 "format_version": 3,
972 "status": "complete",
973 "identity": identity,
974 "passes": 6,
975 "png_files": 4,
976 "png_sha256": checksums
977 }),
978 );
979 }
980
981 #[test]
982 fn export_set_layers_compose_through_safe_reader() {
983 let body_tmp = Temp::new();
984 let gear_tmp = Temp::new();
985 let body_bytes = b"shared-body";
986 let body_maps = publish_pose(
987 &body_tmp.0,
988 "male",
989 "body",
990 [20, 40, 60, 255],
991 [128, 128, 255, 255],
992 [0, 0, 0, 255],
993 30000,
994 );
995 write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes);
996 let gear_hash = digest::sha256_hex(b"sword-ppe");
997 let gear_maps = publish_pose(
998 &gear_tmp.0,
999 "male",
1000 "sword",
1001 [220, 10, 10, 255],
1002 [255, 128, 128, 255],
1003 [200, 200, 200, 255],
1004 12000,
1005 );
1006 write_completed_run(
1007 &gear_tmp.0,
1008 "sword",
1009 Some(&gear_hash),
1010 gear_maps,
1011 body_bytes,
1012 );
1013
1014 let body_run = ExportRun::open(&body_tmp.0).unwrap();
1015 let gear_run = ExportRun::open(&gear_tmp.0).unwrap();
1016 assert_compatible(&[&body_run, &gear_run]).unwrap();
1017
1018 let body_layer =
1019 LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 0, 0).unwrap();
1020 let gear_layer =
1021 LayerMaps::from_export_run(&gear_run, "sword", "clip000-bind", 0, 0, 0).unwrap();
1022 let (composed, lit) =
1023 compose_and_relight(&[body_layer, gear_layer], &Light::default()).unwrap();
1024 assert_eq!(composed.winner[0], 1);
1025 assert_eq!(&composed.diffuse[..4], &[220, 10, 10, 255]);
1026 assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);
1027 assert_eq!(lit.rgba[3], 255);
1028 assert!(lit.rgba[0] > 0);
1029 }
1030
1031 #[test]
1032 fn incompatible_export_sets_are_rejected() {
1033 let a = Temp::new();
1034 let b = Temp::new();
1035 let maps_a = publish_pose(
1036 &a.0,
1037 "male",
1038 "body",
1039 [1, 2, 3, 255],
1040 [128, 128, 255, 255],
1041 [0, 0, 0, 255],
1042 1000,
1043 );
1044 write_completed_run(&a.0, "body", None, maps_a, b"body-a");
1045 let maps_b = publish_pose(
1046 &b.0,
1047 "male",
1048 "body",
1049 [1, 2, 3, 255],
1050 [128, 128, 255, 255],
1051 [0, 0, 0, 255],
1052 1000,
1053 );
1054 write_completed_run(&b.0, "body", None, maps_b, b"body-b");
1055 let run_a = ExportRun::open(&a.0).unwrap();
1056 let run_b = ExportRun::open(&b.0).unwrap();
1057 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1058 assert!(matches!(err, ComposeError::Incompatible(_)));
1059 }
1060 }
@@ -0,0 +1,1060 @@
1//! Deterministic 2D depth compositor and linear relighting.
2//!
3//! Consumes already-exported maps through [`crate::export_set`] (schema 3).
4//! Saved diffuse remains unlit albedo elsewhere; this module only applies
5//! consumer-side lighting to composed layers.
6//!
7//! # Depth selection
8//!
9//! For each pixel, every layer with a **valid** sample (depth alpha ≠ 0 and
10//! diffuse alpha ≠ 0) competes. The nearest surface wins: smaller depth gray
11//! is closer (OpenGL-style 0 = near, 1 = far, stored as 16-bit gray).
12//!
13//! ## Epsilon and tie-break
14//!
15//! Depths that differ by at most [`DEPTH_EPSILON_U16`] codes are treated as a
16//! tie. On a tie, the **lower layer index** wins. Callers must pass layers in a
17//! stable order (body first, then equipment sorted by preset name) so ties are
18//! reproducible across machines.
19//!
20//! # Same-layer attribute binding
21//!
22//! The winning layer supplies diffuse, normal and specular/shiny from that
23//! **same** layer index. Maps are never mixed across layers for one pixel.
24//!
25//! # Alpha-edge policy
26//!
27//! Single-depth compositing cannot reconstruct arbitrary order-dependent
28//! translucency. Supported approximation:
29//!
30//! - Eligible samples require non-zero depth coverage **and** non-zero diffuse
31//! alpha (cutout / coverage). Purely transparent texels never win.
32//! - Exactly one winning layer is chosen by depth+tie-break; losing layers
33//! contribute nothing, even if they have soft alpha.
34//! - The winner’s straight diffuse alpha is copied to the composed output.
35//! Soft edges therefore keep the winner’s own coverage; they are not blended
36//! with occluded layers behind them.
37//!
38//! # Lighting
39//!
40//! Linear ambient + Lambertian diffuse + Blinn-Phong specular. The light
41//! direction is already in **view space**, matching the exported normal maps
42//! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the
43//! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage.
44use crate::{
45 export_set::{ExportError, ExportRun, MapImage, MapKind},
46 render_job::ImageBuf,
47};
48use std::fmt;
49
50/// Depth codes within this inclusive distance count as a tie.
51pub const DEPTH_EPSILON_U16: u16 = 1;
52
53/// Default Blinn-Phong exponent for the simple preview light.
54pub const DEFAULT_SHININESS: f32 = 32.0;
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ComposeError {
58 Empty,
59 Dimension(String),
60 Map(String),
61 Incompatible(String),
62 Export(String),
63}
64
65impl fmt::Display for ComposeError {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 match self {
68 Self::Empty => write!(f, "geen lagen om te composeren"),
69 Self::Dimension(m) | Self::Map(m) | Self::Incompatible(m) | Self::Export(m) => {
70 write!(f, "{m}")
71 }
72 }
73 }
74}
75
76impl std::error::Error for ComposeError {}
77
78impl From<ExportError> for ComposeError {
79 fn from(value: ExportError) -> Self {
80 Self::Export(value.to_string())
81 }
82}
83
84/// One composable layer already decoded to the export pixel contract.
85#[derive(Debug, Clone)]
86pub struct LayerMaps {
87 pub label: String,
88 pub width: u32,
89 pub height: u32,
90 pub diffuse: Vec<u8>,
91 pub normal: Vec<u8>,
92 pub specular: Vec<u8>,
93 /// Interleaved gray, alpha (`width*height*2`), same layout as `MapImage::Depth16`.
94 pub depth: Vec<u16>,
95}
96
97impl LayerMaps {
98 pub fn from_maps(
99 label: impl Into<String>,
100 diffuse: &MapImage,
101 normal: &MapImage,
102 specular: &MapImage,
103 depth: &MapImage,
104 ) -> Result<Self, ComposeError> {
105 let (dw, dh, drgba) = expect_rgba(diffuse, "diffuse")?;
106 let (nw, nh, nrgba) = expect_rgba(normal, "normal")?;
107 let (sw, sh, srgba) = expect_rgba(specular, "specular")?;
108 let (zw, zh, samples) = expect_depth(depth)?;
109 if !(dw == nw && nw == sw && sw == zw && dh == nh && nh == sh && sh == zh) {
110 return Err(ComposeError::Dimension(
111 "laagmaps hebben niet dezelfde afmetingen".into(),
112 ));
113 }
114 let pixels = (dw as usize)
115 .checked_mul(dh as usize)
116 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
117 if drgba.len() != pixels * 4
118 || nrgba.len() != pixels * 4
119 || srgba.len() != pixels * 4
120 || samples.len() != pixels * 2
121 {
122 return Err(ComposeError::Map(
123 "buffergrootte past niet bij breedte×hoogte".into(),
124 ));
125 }
126 Ok(Self {
127 label: label.into(),
128 width: dw,
129 height: dh,
130 diffuse: drgba,
131 normal: nrgba,
132 specular: srgba,
133 depth: samples,
134 })
135 }
136
137 /// Load the four target maps for one pose from a completed export run.
138 pub fn from_export_run(
139 run: &ExportRun,
140 label: impl Into<String>,
141 clip_token: &str,
142 frame: u32,
143 pitch: i32,
144 yaw: u32,
145 ) -> Result<Self, ComposeError> {
146 let diffuse = run.load_map(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;
147 let normal = run.load_map(clip_token, frame, pitch, yaw, MapKind::Normal)?;
148 let specular = run.load_map(clip_token, frame, pitch, yaw, MapKind::Specular)?;
149 let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;
150 Self::from_maps(label, &diffuse, &normal, &specular, &depth)
151 }
152}
153
154fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
155 match image {
156 MapImage::Rgba8 {
157 width,
158 height,
159 rgba,
160 } => Ok((*width, *height, rgba.clone())),
161 MapImage::Depth16 { .. } => Err(ComposeError::Map(format!(
162 "{kind} verwacht RGBA8, kreeg depth16"
163 ))),
164 }
165}
166
167fn expect_depth(image: &MapImage) -> Result<(u32, u32, Vec<u16>), ComposeError> {
168 match image {
169 MapImage::Depth16 {
170 width,
171 height,
172 samples,
173 } => Ok((*width, *height, samples.clone())),
174 MapImage::Rgba8 { .. } => Err(ComposeError::Map(
175 "depth verwacht gray-alpha16, kreeg RGBA8".into(),
176 )),
177 }
178}
179
180/// Shared export contract that body + equipment layers must agree on.
181#[derive(Debug, Clone, PartialEq)]
182pub struct CompatKey {
183 pub project_id: String,
184 pub sex: String,
185 pub body_model_sha256: String,
186 pub target: [u32; 2],
187 pub fps: u32,
188 pub framing: crate::project::CameraFraming,
189 pub near: f32,
190 pub far: f32,
191 pub clip_tokens: Vec<(String, u32)>,
192}
193
194impl CompatKey {
195 pub fn from_run(run: &ExportRun) -> Self {
196 Self {
197 project_id: run.render.project.id.clone(),
198 sex: run.render.sex.clone(),
199 body_model_sha256: run.render.body_model.sha256.clone(),
200 target: run.render.target,
201 fps: run.render.fps,
202 framing: run.render.framing,
203 near: run.render.near,
204 far: run.render.far,
205 clip_tokens: run
206 .render
207 .clips
208 .iter()
209 .map(|c| (c.token.clone(), c.frames))
210 .collect(),
211 }
212 }
213}
214
215/// Reject runs that cannot be depth-composited together.
216pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
217 if runs.is_empty() {
218 return Err(ComposeError::Empty);
219 }
220 let first = CompatKey::from_run(runs[0]);
221 for run in runs.iter().skip(1) {
222 let key = CompatKey::from_run(run);
223 if key.project_id != first.project_id {
224 return Err(ComposeError::Incompatible(
225 "project.id verschilt tussen exportsets".into(),
226 ));
227 }
228 if key.sex != first.sex {
229 return Err(ComposeError::Incompatible(
230 "geslacht verschilt tussen exportsets".into(),
231 ));
232 }
233 if key.body_model_sha256 != first.body_model_sha256 {
234 return Err(ComposeError::Incompatible(
235 "body_model fingerprint verschilt".into(),
236 ));
237 }
238 if key.target != first.target {
239 return Err(ComposeError::Incompatible(
240 "target-resolutie verschilt".into(),
241 ));
242 }
243 if key.fps != first.fps {
244 return Err(ComposeError::Incompatible("fps verschilt".into()));
245 }
246 if key.framing != first.framing {
247 return Err(ComposeError::Incompatible("framing verschilt".into()));
248 }
249 if (key.near - first.near).abs() > f32::EPSILON
250 || (key.far - first.far).abs() > f32::EPSILON
251 {
252 return Err(ComposeError::Incompatible("near/far verschilt".into()));
253 }
254 if key.clip_tokens != first.clip_tokens {
255 return Err(ComposeError::Incompatible(
256 "clip-inventaris verschilt".into(),
257 ));
258 }
259 }
260 Ok(())
261}
262
263/// Composed target maps plus the winning layer index per pixel (`u8::MAX` = empty).
264#[derive(Debug, Clone)]
265pub struct ComposedMaps {
266 pub width: u32,
267 pub height: u32,
268 pub diffuse: Vec<u8>,
269 pub normal: Vec<u8>,
270 pub specular: Vec<u8>,
271 pub depth: Vec<u16>,
272 pub winner: Vec<u8>,
273}
274
275/// Select nearest valid surface per pixel; bind all maps from that layer.
276pub fn compose(layers: &[LayerMaps]) -> Result<ComposedMaps, ComposeError> {
277 if layers.is_empty() {
278 return Err(ComposeError::Empty);
279 }
280 let width = layers[0].width;
281 let height = layers[0].height;
282 if width == 0 || height == 0 {
283 return Err(ComposeError::Dimension("lege afmetingen".into()));
284 }
285 let pixels = (width as usize)
286 .checked_mul(height as usize)
287 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
288 for (index, layer) in layers.iter().enumerate() {
289 if layer.width != width || layer.height != height {
290 return Err(ComposeError::Dimension(format!(
291 "laag {index} ('{}') heeft andere afmetingen",
292 layer.label
293 )));
294 }
295 if layer.diffuse.len() != pixels * 4
296 || layer.normal.len() != pixels * 4
297 || layer.specular.len() != pixels * 4
298 || layer.depth.len() != pixels * 2
299 {
300 return Err(ComposeError::Map(format!(
301 "laag {index} ('{}') heeft inconsistente buffers",
302 layer.label
303 )));
304 }
305 if layers.len() > u8::MAX as usize {
306 return Err(ComposeError::Map(
307 "maximaal 255 lagen voor winner-index".into(),
308 ));
309 }
310 }
311
312 let mut diffuse = vec![0u8; pixels * 4];
313 let mut normal = vec![0u8; pixels * 4];
314 let mut specular = vec![0u8; pixels * 4];
315 let mut depth = vec![0u16; pixels * 2];
316 let mut winner = vec![u8::MAX; pixels];
317
318 for i in 0..pixels {
319 let mut best_depth: Option<u16> = None;
320 let mut best_layer: Option<usize> = None;
321 for (layer_idx, layer) in layers.iter().enumerate() {
322 let depth_alpha = layer.depth[i * 2 + 1];
323 let diffuse_alpha = layer.diffuse[i * 4 + 3];
324 if depth_alpha == 0 || diffuse_alpha == 0 {
325 continue;
326 }
327 let gray = layer.depth[i * 2];
328 match (best_depth, best_layer) {
329 (None, _) => {
330 best_depth = Some(gray);
331 best_layer = Some(layer_idx);
332 }
333 (Some(bd), Some(_)) => {
334 if gray.saturating_add(DEPTH_EPSILON_U16) < bd {
335 // Strictly nearer beyond epsilon.
336 best_depth = Some(gray);
337 best_layer = Some(layer_idx);
338 } else if gray <= bd.saturating_add(DEPTH_EPSILON_U16) {
339 // Tie (within epsilon): keep lower layer index already chosen.
340 }
341 // else: farther — ignore
342 }
343 _ => unreachable!(),
344 }
345 }
346 if let (Some(_), Some(layer_idx)) = (best_depth, best_layer) {
347 let layer = &layers[layer_idx];
348 let o = i * 4;
349 diffuse[o..o + 4].copy_from_slice(&layer.diffuse[o..o + 4]);
350 normal[o..o + 4].copy_from_slice(&layer.normal[o..o + 4]);
351 specular[o..o + 4].copy_from_slice(&layer.specular[o..o + 4]);
352 depth[i * 2] = layer.depth[i * 2];
353 depth[i * 2 + 1] = layer.depth[i * 2 + 1];
354 winner[i] = layer_idx as u8;
355 }
356 }
357
358 Ok(ComposedMaps {
359 width,
360 height,
361 diffuse,
362 normal,
363 specular,
364 depth,
365 winner,
366 })
367}
368
369/// View-space directional light for consumer-side relighting.
370#[derive(Debug, Clone, Copy, PartialEq)]
371pub struct Light {
372 /// Direction **toward** the light, view space (will be normalized).
373 pub direction_view: [f32; 3],
374 pub ambient: f32,
375 pub diffuse: f32,
376 pub specular: f32,
377 pub shininess: f32,
378}
379
380impl Default for Light {
381 fn default() -> Self {
382 Self {
383 // Slightly above and toward +Z so a front-facing normal responds.
384 direction_view: [0.35, 0.55, 0.75],
385 ambient: 0.15,
386 diffuse: 0.75,
387 specular: 0.4,
388 shininess: DEFAULT_SHININESS,
389 }
390 }
391}
392
393/// Relight composed albedo/normal/specular into an sRGB RGBA image.
394pub fn relight(composed: &ComposedMaps, light: &Light) -> Result<ImageBuf, ComposeError> {
395 let pixels = (composed.width as usize)
396 .checked_mul(composed.height as usize)
397 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
398 if composed.diffuse.len() != pixels * 4
399 || composed.normal.len() != pixels * 4
400 || composed.specular.len() != pixels * 4
401 {
402 return Err(ComposeError::Map(
403 "composed buffers passen niet bij afmetingen".into(),
404 ));
405 }
406 let l = normalize(light.direction_view);
407 let view = [0.0_f32, 0.0, 1.0];
408 let half = normalize([l[0] + view[0], l[1] + view[1], l[2] + view[2]]);
409 let mut rgba = vec![0u8; pixels * 4];
410 for i in 0..pixels {
411 let o = i * 4;
412 let a = composed.diffuse[o + 3];
413 if a == 0 || composed.winner[i] == u8::MAX {
414 continue;
415 }
416 let albedo = [
417 linear_srgb(composed.diffuse[o]),
418 linear_srgb(composed.diffuse[o + 1]),
419 linear_srgb(composed.diffuse[o + 2]),
420 ];
421 let n = decode_normal([
422 composed.normal[o],
423 composed.normal[o + 1],
424 composed.normal[o + 2],
425 ]);
426 let shiny = composed.specular[o] as f32 / 255.0;
427 let ndotl = dot(n, l).max(0.0);
428 let ndoth = dot(n, half).max(0.0);
429 let spec = shiny * ndoth.powf(light.shininess.max(1.0));
430 let mut lit = [0.0_f32; 3];
431 for c in 0..3 {
432 lit[c] = light.ambient * albedo[c]
433 + light.diffuse * ndotl * albedo[c]
434 + light.specular * spec;
435 lit[c] = lit[c].clamp(0.0, 1.0);
436 }
437 rgba[o] = encode_srgb(lit[0]);
438 rgba[o + 1] = encode_srgb(lit[1]);
439 rgba[o + 2] = encode_srgb(lit[2]);
440 rgba[o + 3] = a;
441 }
442 Ok(ImageBuf {
443 width: composed.width,
444 height: composed.height,
445 rgba,
446 })
447}
448
449/// Compose then relight in one step.
450pub fn compose_and_relight(
451 layers: &[LayerMaps],
452 light: &Light,
453) -> Result<(ComposedMaps, ImageBuf), ComposeError> {
454 let composed = compose(layers)?;
455 let lit = relight(&composed, light)?;
456 Ok((composed, lit))
457}
458
459fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
460 normalize([
461 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
462 rgb[1] as f32 / 255.0 * 2.0 - 1.0,
463 rgb[2] as f32 / 255.0 * 2.0 - 1.0,
464 ])
465}
466
467fn normalize(v: [f32; 3]) -> [f32; 3] {
468 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
469 if len > 1e-10 {
470 [v[0] / len, v[1] / len, v[2] / len]
471 } else {
472 [0.0, 0.0, 1.0]
473 }
474}
475
476fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
477 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
478}
479
480fn linear_srgb(x: u8) -> f32 {
481 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
482 LUT.get_or_init(|| {
483 std::array::from_fn(|i| {
484 let v = i as f32 / 255.;
485 if v <= 0.04045 {
486 v / 12.92
487 } else {
488 ((v + 0.055) / 1.055).powf(2.4)
489 }
490 })
491 })[x as usize]
492}
493
494fn encode_srgb(x: f32) -> u8 {
495 let v = if x <= 0.0031308 {
496 x * 12.92
497 } else {
498 1.055 * x.powf(1. / 2.4) - 0.055
499 };
500 (v * 255.).round().clamp(0., 255.) as u8
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::digest;
507 use crate::export_set::{complete_identity_json, ExportRun};
508 use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
509 use crate::project::CameraFraming;
510 use std::collections::BTreeMap;
511 use std::fs;
512 use std::path::{Path, PathBuf};
513 use std::sync::atomic::{AtomicU64, Ordering};
514
515 struct Temp(PathBuf);
516 impl Temp {
517 fn new() -> Self {
518 static N: AtomicU64 = AtomicU64::new(0);
519 let id = N.fetch_add(1, Ordering::Relaxed);
520 let path = std::env::temp_dir().join(format!(
521 "paperdoll-compositor-{}-{}",
522 std::process::id(),
523 id
524 ));
525 let _ = fs::remove_dir_all(&path);
526 fs::create_dir_all(&path).unwrap();
527 Self(path)
528 }
529 }
530 impl Drop for Temp {
531 fn drop(&mut self) {
532 let _ = fs::remove_dir_all(&self.0);
533 }
534 }
535
536 fn framing() -> CameraFraming {
537 CameraFraming {
538 height_milli: 2600,
539 center_y_milli: 1000,
540 pivot_y_milli: Some(1460),
541 }
542 }
543
544 #[allow(clippy::too_many_arguments)]
545 fn solid_layer(
546 label: &str,
547 w: u32,
548 h: u32,
549 diffuse: [u8; 4],
550 normal: [u8; 4],
551 specular: [u8; 4],
552 depth_gray: u16,
553 depth_alpha: u16,
554 ) -> LayerMaps {
555 let pixels = (w * h) as usize;
556 LayerMaps {
557 label: label.into(),
558 width: w,
559 height: h,
560 diffuse: diffuse.repeat(pixels),
561 normal: normal.repeat(pixels),
562 specular: specular.repeat(pixels),
563 depth: [depth_gray, depth_alpha]
564 .into_iter()
565 .cycle()
566 .take(pixels * 2)
567 .collect(),
568 }
569 }
570
571 fn pixel_layer(
572 label: &str,
573 w: u32,
574 h: u32,
575 fill: impl Fn(usize) -> ([u8; 4], [u8; 4], [u8; 4], u16, u16),
576 ) -> LayerMaps {
577 let pixels = (w * h) as usize;
578 let mut diffuse = vec![0; pixels * 4];
579 let mut normal = vec![0; pixels * 4];
580 let mut specular = vec![0; pixels * 4];
581 let mut depth = vec![0u16; pixels * 2];
582 for i in 0..pixels {
583 let (d, n, s, g, a) = fill(i);
584 diffuse[i * 4..i * 4 + 4].copy_from_slice(&d);
585 normal[i * 4..i * 4 + 4].copy_from_slice(&n);
586 specular[i * 4..i * 4 + 4].copy_from_slice(&s);
587 depth[i * 2] = g;
588 depth[i * 2 + 1] = a;
589 }
590 LayerMaps {
591 label: label.into(),
592 width: w,
593 height: h,
594 diffuse,
595 normal,
596 specular,
597 depth,
598 }
599 }
600
601 #[test]
602 fn front_occludes_back_and_binds_same_layer_maps() {
603 // 2×1: left pixel has front red equipment over blue body; right is body only.
604 let body = pixel_layer("body", 2, 1, |i| {
605 let depth = if i == 0 { 30000 } else { 20000 };
606 (
607 [0, 0, 200, 255],
608 [128, 128, 255, 255],
609 [0, 0, 0, 255],
610 depth,
611 65535,
612 )
613 });
614 let gear = pixel_layer("sword", 2, 1, |i| {
615 if i == 0 {
616 (
617 [200, 0, 0, 255],
618 [255, 128, 128, 255], // +X-ish normal from gear
619 [180, 180, 180, 255],
620 10000, // nearer
621 65535,
622 )
623 } else {
624 ([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
625 }
626 });
627 let composed = compose(&[body, gear]).unwrap();
628 assert_eq!(composed.winner[0], 1, "front equipment wins left");
629 assert_eq!(&composed.diffuse[0..4], &[200, 0, 0, 255]);
630 assert_eq!(&composed.normal[0..4], &[255, 128, 128, 255]);
631 assert_eq!(&composed.specular[0..4], &[180, 180, 180, 255]);
632 assert_eq!(composed.depth[0], 10000);
633 assert_eq!(composed.winner[1], 0, "body remains on right");
634 assert_eq!(&composed.diffuse[4..8], &[0, 0, 200, 255]);
635 assert_eq!(&composed.specular[4..8], &[0, 0, 0, 255]);
636 }
637
638 #[test]
639 fn epsilon_ties_prefer_lower_layer_index() {
640 let body = solid_layer(
641 "body",
642 1,
643 1,
644 [10, 20, 30, 255],
645 [128, 128, 255, 255],
646 [0, 0, 0, 255],
647 20000,
648 65535,
649 );
650 // Within DEPTH_EPSILON_U16 of body depth.
651 let gear = solid_layer(
652 "hat",
653 1,
654 1,
655 [90, 80, 70, 255],
656 [128, 200, 128, 255],
657 [50, 50, 50, 255],
658 20000 + DEPTH_EPSILON_U16,
659 65535,
660 );
661 let composed = compose(&[body.clone(), gear.clone()]).unwrap();
662 assert_eq!(composed.winner[0], 0);
663 assert_eq!(&composed.diffuse[..4], &[10, 20, 30, 255]);
664
665 // Reverse order: now gear is index 0 and wins the tie.
666 let reversed = compose(&[gear, body]).unwrap();
667 assert_eq!(reversed.winner[0], 0);
668 assert_eq!(&reversed.diffuse[..4], &[90, 80, 70, 255]);
669 }
670
671 #[test]
672 fn strictly_nearer_beyond_epsilon_beats_earlier_layer() {
673 let body = solid_layer(
674 "body",
675 1,
676 1,
677 [1, 2, 3, 255],
678 [128, 128, 255, 255],
679 [0, 0, 0, 255],
680 20000,
681 65535,
682 );
683 let gear = solid_layer(
684 "cloak",
685 1,
686 1,
687 [9, 8, 7, 255],
688 [128, 128, 255, 255],
689 [0, 0, 0, 255],
690 20000 - DEPTH_EPSILON_U16 - 1,
691 65535,
692 );
693 let composed = compose(&[body, gear]).unwrap();
694 assert_eq!(composed.winner[0], 1);
695 assert_eq!(&composed.diffuse[..4], &[9, 8, 7, 255]);
696 }
697
698 #[test]
699 fn transparency_and_alpha_edges_follow_documented_policy() {
700 // Center soft-edge body; equipment only on left with full coverage;
701 // right pixel has transparent equipment over body — body keeps soft alpha.
702 let body = pixel_layer("body", 3, 1, |i| {
703 let alpha = match i {
704 0 => 255,
705 1 => 128, // soft edge
706 _ => 255,
707 };
708 (
709 [40, 40, 40, alpha],
710 [128, 128, 255, alpha],
711 [0, 0, 0, alpha],
712 25000,
713 u16::from(alpha) * 257,
714 )
715 });
716 let gear = pixel_layer("pad", 3, 1, |i| match i {
717 0 => (
718 [255, 0, 0, 255],
719 [128, 128, 255, 255],
720 [0, 0, 0, 255],
721 10000,
722 65535,
723 ),
724 // Transparent equipment must not steal the soft body edge.
725 1 => ([255, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
726 // Semi-transparent losing layer behind would also be ignored if farther;
727 // here nearer but alpha 0 — skipped.
728 _ => ([0, 255, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0),
729 });
730 let composed = compose(&[body, gear]).unwrap();
731 assert_eq!(composed.winner[0], 1);
732 assert_eq!(composed.diffuse[3], 255);
733 assert_eq!(composed.winner[1], 0);
734 assert_eq!(
735 composed.diffuse[7], 128,
736 "winner soft alpha preserved; no cross-layer blend"
737 );
738 assert_eq!(composed.winner[2], 0);
739 assert_eq!(&composed.diffuse[8..12], &[40, 40, 40, 255]);
740 }
741
742 #[test]
743 fn lighting_responds_to_normal_and_light_direction() {
744 let facing = solid_layer(
745 "front",
746 1,
747 1,
748 [200, 200, 200, 255],
749 [128, 128, 255, 255], // ~+Z
750 [0, 0, 0, 255],
751 10000,
752 65535,
753 );
754 let away = solid_layer(
755 "back",
756 1,
757 1,
758 [200, 200, 200, 255],
759 [128, 128, 0, 255], // ~-Z
760 [0, 0, 0, 255],
761 10000,
762 65535,
763 );
764 let light = Light {
765 direction_view: [0.0, 0.0, 1.0],
766 ambient: 0.05,
767 diffuse: 0.9,
768 specular: 0.0,
769 shininess: 16.0,
770 };
771 let lit_front = relight(&compose(&[facing]).unwrap(), &light).unwrap();
772 let lit_back = relight(&compose(&[away]).unwrap(), &light).unwrap();
773 let front_luma =
774 lit_front.rgba[0] as u32 + lit_front.rgba[1] as u32 + lit_front.rgba[2] as u32;
775 let back_luma = lit_back.rgba[0] as u32 + lit_back.rgba[1] as u32 + lit_back.rgba[2] as u32;
776 assert!(
777 front_luma > back_luma + 40,
778 "front-facing should be brighter: {front_luma} vs {back_luma}"
779 );
780
781 let shiny = solid_layer(
782 "metal",
783 1,
784 1,
785 [180, 180, 180, 255],
786 [128, 128, 255, 255],
787 [255, 255, 255, 255],
788 10000,
789 65535,
790 );
791 let matte = solid_layer(
792 "matte",
793 1,
794 1,
795 [180, 180, 180, 255],
796 [128, 128, 255, 255],
797 [0, 0, 0, 255],
798 10000,
799 65535,
800 );
801 let spec_light = Light {
802 direction_view: [0.0, 0.0, 1.0],
803 ambient: 0.0,
804 diffuse: 0.2,
805 specular: 1.0,
806 shininess: 64.0,
807 };
808 let lit_shiny = relight(&compose(&[shiny]).unwrap(), &spec_light).unwrap();
809 let lit_matte = relight(&compose(&[matte]).unwrap(), &spec_light).unwrap();
810 let shiny_luma =
811 lit_shiny.rgba[0] as u32 + lit_shiny.rgba[1] as u32 + lit_shiny.rgba[2] as u32;
812 let matte_luma =
813 lit_matte.rgba[0] as u32 + lit_matte.rgba[1] as u32 + lit_matte.rgba[2] as u32;
814 assert!(
815 shiny_luma > matte_luma + 20,
816 "specular map must brighten highlight: {shiny_luma} vs {matte_luma}"
817 );
818 assert_eq!(lit_shiny.rgba[3], 255);
819 }
820
821 #[test]
822 fn empty_and_mismatched_layers_error() {
823 assert!(matches!(compose(&[]), Err(ComposeError::Empty)));
824 let a = solid_layer(
825 "a",
826 1,
827 1,
828 [1, 1, 1, 255],
829 [128, 128, 255, 255],
830 [0, 0, 0, 255],
831 1,
832 65535,
833 );
834 let b = solid_layer(
835 "b",
836 2,
837 1,
838 [1, 1, 1, 255],
839 [128, 128, 255, 255],
840 [0, 0, 0, 255],
841 1,
842 65535,
843 );
844 assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));
845 }
846
847 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
848 let data = serde_json::to_vec_pretty(value).unwrap();
849 let path = root.join(name);
850 let temp = path.with_extension(format!("part-{}", std::process::id()));
851 fs::write(&temp, &data).unwrap();
852 fs::hard_link(&temp, &path).unwrap();
853 let _ = fs::remove_file(temp);
854 }
855
856 fn publish_pose(
857 root: &Path,
858 sex: &str,
859 set: &str,
860 diffuse: [u8; 4],
861 normal: [u8; 4],
862 specular: [u8; 4],
863 depth_gray: u16,
864 ) -> BTreeMap<String, String> {
865 let mut map = BTreeMap::new();
866 let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000");
867 let rels = [
868 (format!("{base}_diffuse.png"), "diffuse"),
869 (format!("{base}_depth.png"), "depth"),
870 (format!("{base}_normal.png"), "normal"),
871 (format!("{base}_specular.png"), "specular"),
872 ];
873 for (rel, kind) in rels {
874 let path = Path::new(&rel);
875 match kind {
876 "depth" => {
877 write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
878 }
879 "normal" => {
880 write_rgba(
881 root,
882 path,
883 &ImageBuf {
884 width: 1,
885 height: 1,
886 rgba: normal.to_vec(),
887 },
888 )
889 .unwrap();
890 }
891 "specular" => {
892 write_rgba(
893 root,
894 path,
895 &ImageBuf {
896 width: 1,
897 height: 1,
898 rgba: specular.to_vec(),
899 },
900 )
901 .unwrap();
902 }
903 _ => {
904 write_rgba(
905 root,
906 path,
907 &ImageBuf {
908 width: 1,
909 height: 1,
910 rgba: diffuse.to_vec(),
911 },
912 )
913 .unwrap();
914 }
915 }
916 let bytes = fs::read(root.join(&rel)).unwrap();
917 map.insert(rel, digest::sha256_hex(&bytes));
918 }
919 map
920 }
921
922 fn write_completed_run(
923 root: &Path,
924 set: &str,
925 equipment_sha: Option<&str>,
926 checksums: BTreeMap<String, String>,
927 body_bytes: &[u8],
928 ) {
929 let body_hash = digest::sha256_hex(body_bytes);
930 let mut render = serde_json::json!({
931 "format_version": 3,
932 "status": "incomplete",
933 "project": {"id": "demo", "name": "Demo"},
934 "sex": "male",
935 "set": set,
936 "body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
937 "equipment_preset": null,
938 "target": [1, 1],
939 "fps": 12,
940 "highres_scale": 8,
941 "framing": framing(),
942 "pivot_source": {"kind": "manual"},
943 "near": NEAR,
944 "far": FAR,
945 "camera_distance": 10.0,
946 "yaw_zero": "camera on -Z",
947 "normal_space": "view (+Y up), PNG rows top-down",
948 "pitches": [-30, 0, 30],
949 "yaw_step": 30,
950 "sampling": "test",
951 "alpha": "straight",
952 "diffuse": "unlit",
953 "depth": "16-bit",
954 "specular": "scalar",
955 "pass_order": PASSES,
956 "saved_maps": ["diffuse", "depth", "normal", "specular"],
957 "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
958 "images": 1,
959 "passes": 6,
960 "png_files": 4
961 });
962 if let Some(sha) = equipment_sha {
963 render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
964 }
965 write_atomic_json(root, "render.json", &render);
966 let identity = complete_identity_json(&render).unwrap();
967 write_atomic_json(
968 root,
969 "complete.json",
970 &serde_json::json!({
971 "format_version": 3,
972 "status": "complete",
973 "identity": identity,
974 "passes": 6,
975 "png_files": 4,
976 "png_sha256": checksums
977 }),
978 );
979 }
980
981 #[test]
982 fn export_set_layers_compose_through_safe_reader() {
983 let body_tmp = Temp::new();
984 let gear_tmp = Temp::new();
985 let body_bytes = b"shared-body";
986 let body_maps = publish_pose(
987 &body_tmp.0,
988 "male",
989 "body",
990 [20, 40, 60, 255],
991 [128, 128, 255, 255],
992 [0, 0, 0, 255],
993 30000,
994 );
995 write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes);
996 let gear_hash = digest::sha256_hex(b"sword-ppe");
997 let gear_maps = publish_pose(
998 &gear_tmp.0,
999 "male",
1000 "sword",
1001 [220, 10, 10, 255],
1002 [255, 128, 128, 255],
1003 [200, 200, 200, 255],
1004 12000,
1005 );
1006 write_completed_run(
1007 &gear_tmp.0,
1008 "sword",
1009 Some(&gear_hash),
1010 gear_maps,
1011 body_bytes,
1012 );
1013
1014 let body_run = ExportRun::open(&body_tmp.0).unwrap();
1015 let gear_run = ExportRun::open(&gear_tmp.0).unwrap();
1016 assert_compatible(&[&body_run, &gear_run]).unwrap();
1017
1018 let body_layer =
1019 LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 0, 0).unwrap();
1020 let gear_layer =
1021 LayerMaps::from_export_run(&gear_run, "sword", "clip000-bind", 0, 0, 0).unwrap();
1022 let (composed, lit) =
1023 compose_and_relight(&[body_layer, gear_layer], &Light::default()).unwrap();
1024 assert_eq!(composed.winner[0], 1);
1025 assert_eq!(&composed.diffuse[..4], &[220, 10, 10, 255]);
1026 assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);
1027 assert_eq!(lit.rgba[3], 255);
1028 assert!(lit.rgba[0] > 0);
1029 }
1030
1031 #[test]
1032 fn incompatible_export_sets_are_rejected() {
1033 let a = Temp::new();
1034 let b = Temp::new();
1035 let maps_a = publish_pose(
1036 &a.0,
1037 "male",
1038 "body",
1039 [1, 2, 3, 255],
1040 [128, 128, 255, 255],
1041 [0, 0, 0, 255],
1042 1000,
1043 );
1044 write_completed_run(&a.0, "body", None, maps_a, b"body-a");
1045 let maps_b = publish_pose(
1046 &b.0,
1047 "male",
1048 "body",
1049 [1, 2, 3, 255],
1050 [128, 128, 255, 255],
1051 [0, 0, 0, 255],
1052 1000,
1053 );
1054 write_completed_run(&b.0, "body", None, maps_b, b"body-b");
1055 let run_a = ExportRun::open(&a.0).unwrap();
1056 let run_b = ExportRun::open(&b.0).unwrap();
1057 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1058 assert!(matches!(err, ComposeError::Incompatible(_)));
1059 }
1060}
src/foundation.rs +5 −5
@@ -1,7 +1,7 @@
1 1 //! Honest capability report for the current editor.
2 2 //!
3 //! Attachment authoring and target-map production batches are present.
4 //! The rendered 2D lighting/compositor remains not built.
3 //! Attachment authoring, target-map production and the library depth compositor
4 //! are present. The rendered 2D GUI/tab/playback remains not built.
5 5
6 6 /// Product capability named by the design.
7 7 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -111,7 +111,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
111 111 }
112 112
113 113 pub fn dutch_summary() -> &'static str {
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-lichtcompositor is niet gebouwd."
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De bibliotheekmodule compositor depth-composeert body+equipment en belicht lineair; de 2D-preview-GUI is niet gebouwd."
115 115 }
116 116
117 117 /// Stable status text shared by `--status` and the window.
@@ -233,7 +233,7 @@ mod tests {
233 233 use super::*;
234 234
235 235 #[test]
236 fn import_and_production_are_present_but_compositor_is_not() {
236 fn import_and_production_are_present_but_preview2d_gui_is_not() {
237 237 assert_eq!(
238 238 availability(Capability::ProjectShell),
239 239 Availability::Present
@@ -267,7 +267,7 @@ mod tests {
267 267 }
268 268
269 269 #[test]
270 fn status_report_matches_current_production_without_claiming_compositor() {
270 fn status_report_matches_current_production_without_claiming_preview2d_gui() {
271 271 let report = report();
272 272 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
273 273 for key in [
@@ -1,7 +1,7 @@
1//! Honest capability report for the current editor.
1//! Honest capability report for the current editor.
2//!
2//!
3//! Attachment authoring and target-map production batches are present.
3//! Attachment authoring, target-map production and the library depth compositor
4//! The rendered 2D lighting/compositor remains not built.
4//! are present. The rendered 2D GUI/tab/playback remains not built.
5
5
6/// Product capability named by the design.
6/// Product capability named by the design.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -111,7 +111,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
111}
111}
112
112
113pub fn dutch_summary() -> &'static str {
113pub fn dutch_summary() -> &'static str {
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-lichtcompositor is niet gebouwd."
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De bibliotheekmodule compositor depth-composeert body+equipment en belicht lineair; de 2D-preview-GUI is niet gebouwd."
115}
115}
116
116
117/// Stable status text shared by `--status` and the window.
117/// Stable status text shared by `--status` and the window.
@@ -233,7 +233,7 @@ mod tests {
233 use super::*;
233 use super::*;
234
234
235 #[test]
235 #[test]
236 fn import_and_production_are_present_but_compositor_is_not() {
236 fn import_and_production_are_present_but_preview2d_gui_is_not() {
237 assert_eq!(
237 assert_eq!(
238 availability(Capability::ProjectShell),
238 availability(Capability::ProjectShell),
239 Availability::Present
239 Availability::Present
@@ -267,7 +267,7 @@ mod tests {
267 }
267 }
268
268
269 #[test]
269 #[test]
270 fn status_report_matches_current_production_without_claiming_compositor() {
270 fn status_report_matches_current_production_without_claiming_preview2d_gui() {
271 let report = report();
271 let report = report();
272 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
272 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
273 for key in [
273 for key in [
src/lib.rs +2 −1
@@ -3,9 +3,10 @@
3 3 //! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4 4 //! draws skinned body/equipment previews, and streams production target maps.
5 5 //! Completed export runs are opened through `export_set` with checksum/identity
6 //! validation for later compositing.
6 //! validation; `compositor` depth-composites compatible layers and relights them.
7 7
8 8 pub mod camera;
9 pub mod compositor;
9 10 pub mod dialogs;
10 11 pub mod digest;
11 12 pub mod editor;
@@ -3,9 +3,10 @@
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4//! draws skinned body/equipment previews, and streams production target maps.
4//! draws skinned body/equipment previews, and streams production target maps.
5//! Completed export runs are opened through `export_set` with checksum/identity
5//! Completed export runs are opened through `export_set` with checksum/identity
6//! validation for later compositing.
6//! validation; `compositor` depth-composites compatible layers and relights them.
7
7
8pub mod camera;
8pub mod camera;
9pub mod compositor;
9pub mod dialogs;
10pub mod dialogs;
10pub mod digest;
11pub mod digest;
11pub mod editor;
12pub mod editor;