Merge feature/depth-compositor: deterministic 2D depth compose + relighting
Library compositor over schema-3 export_set maps; Preview2d GUI deferred.
@@ -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.png65
65
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`.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
```rust75
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
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.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
## Openen met91
## Openen met@@ -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 algorithm131
## 8. Map contracts and rendering algorithm132
132
@@ -154,7 +154,7 @@ Equipment pass hides the body from **all color, depth, normal and specular write154
154
155
## 9. Equipment editor and binary presets155
## 9. Equipment editor and binary presets156
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
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.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` s180
180
181
## 10. Rendered 2D preview181
## 10. Rendered 2D preview182
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
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.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
## 11. Development workflow, quality and packaging189
## 11. Development workflow, quality and packaging190
190
@@ -217,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup217
- 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
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
## 12. Incremental feature plan and acceptance gates222
## 12. Incremental feature plan and acceptance gates223
223
@@ -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 applies5
//! consumer-side lighting to composed layers.6
//!7
//! # Depth selection8
//!9
//! For each pixel, every layer with a **valid** sample (depth alpha ≠ 0 and10
//! diffuse alpha ≠ 0) competes. The nearest surface wins: smaller depth gray11
//! is closer (OpenGL-style 0 = near, 1 = far, stored as 16-bit gray).12
//!13
//! ## Epsilon and tie-break14
//!15
//! Depths that differ by at most [`DEPTH_EPSILON_U16`] codes are treated as a16
//! tie. On a tie, the **lower layer index** wins. Callers must pass layers in a17
//! stable order (body first, then equipment sorted by preset name) so ties are18
//! reproducible across machines.19
//!20
//! # Same-layer attribute binding21
//!22
//! The winning layer supplies diffuse, normal and specular/shiny from that23
//! **same** layer index. Maps are never mixed across layers for one pixel.24
//!25
//! # Alpha-edge policy26
//!27
//! Single-depth compositing cannot reconstruct arbitrary order-dependent28
//! translucency. Supported approximation:29
//!30
//! - Eligible samples require non-zero depth coverage **and** non-zero diffuse31
//! alpha (cutout / coverage). Purely transparent texels never win.32
//! - Exactly one winning layer is chosen by depth+tie-break; losing layers33
//! 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 blended36
//! with occluded layers behind them.37
//!38
//! # Lighting39
//!40
//! Linear ambient + Lambertian diffuse + Blinn-Phong specular. The light41
//! direction is already in **view space**, matching the exported normal maps42
//! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the43
//! 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 * 4118
|| nrgba.len() != pixels * 4119
|| srgba.len() != pixels * 4120
|| samples.len() != pixels * 2121
{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: run206
.render207
.clips208
.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::EPSILON250
|| (key.far - first.far).abs() > f32::EPSILON251
{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.label293
)));294
}295
if layer.diffuse.len() != pixels * 4296
|| layer.normal.len() != pixels * 4297
|| layer.specular.len() != pixels * 4298
|| layer.depth.len() != pixels * 2299
{300
return Err(ComposeError::Map(format!(301
"laag {index} ('{}') heeft inconsistente buffers",302
layer.label303
)));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 — ignore342
}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 * 4399
|| composed.normal.len() != pixels * 4400
|| composed.specular.len() != pixels * 4401
{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.92487
} 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.92497
} else {498
1.055 * x.powf(1. / 2.4) - 0.055499
};500
(v * 255.).round().clamp(0., 255.) as u8501
}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
id524
));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 gear619
[180, 180, 180, 255],620
10000, // nearer621
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 edge706
_ => 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], // ~+Z750
[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], // ~-Z760
[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
map920
}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": 4961
});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": checksums977
}),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
}@@ -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 compositor4
//! 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
113
pub fn dutch_summary() -> &'static str {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
/// 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::Present239
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 [@@ -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/identity5
//! Completed export runs are opened through `export_set` with checksum/identity6
//! validation for later compositing.6
//! validation; `compositor` depth-composites compatible layers and relights them.7
7
8
pub mod camera;8
pub mod camera;9
pub mod compositor;9
pub mod dialogs;10
pub mod dialogs;10
pub mod digest;11
pub mod digest;11
pub mod editor;12
pub mod editor;