BlueGit

golden-age paperdoll-renderer

Merge feature/rim-edge-lighting: consumer-side rim/edge lighting

R08 subtle Fresnel/depth rim in compositor + Preview2d Randlicht control.
Saved diffuse remains unlit; remaining.md #5.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
995bda0fbff145dc3be4a74e9ef448ac90a31941
Pipeline
Passed
7 files changed +693 −16
README.md +6 −3
@@ -37,6 +37,7 @@ Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor
37 37 - Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
38 38 - **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
39 39 - Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); loslaten, focusverlies of pointer-annulering stopt de beweging. Azimut 0° is licht van voren (+Z); elevatie is geklemd. Belichting is alleen voor de preview; opgeslagen diffuse blijft unlit albedo.
40 - **Randlicht (sterkte)** voegt een subtiel Fresnel-/diepterand-effect toe op het samengestelde frame (standaard aan, begrensd). Op 0 blijft alleen relight over; opgeslagen diffuse wijzigt nooit.
40 41 - Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
41 42
42 43 ```bash
@@ -84,10 +85,12 @@ Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet
84 85
85 86 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).
86 87
87 `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.
88 `relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. `apply_rim` / `compose_relight_and_rim` voegen daarna een begrensd Fresnel-randlicht (`(1−N·V)^power`) plus optionele diepte-rand toe; intensiteit is geklemd tot `MAX_RIM_INTENSITY` (0,45) en alpha blijft byte-exact. Opgeslagen diffuse blijft elders onbelichte albedo.
88 89
89 90 ```rust
90 use paperdoll_renderer::compositor::{assert_compatible, compose_and_relight, LayerMaps, Light};
91 use paperdoll_renderer::compositor::{
92 assert_compatible, compose_relight_and_rim, LayerMaps, Light, RimParams,
93 };
91 94 use paperdoll_renderer::export_set::ExportRun;
92 95
93 96 let body = ExportRun::open(&body_run_dir)?;
@@ -97,7 +100,7 @@ let layers = [
97 100 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
98 101 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
99 102 ];
100 let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
103 let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimParams::default())?;
101 104 ```
102 105
103 106
@@ -37,6 +37,7 @@ Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor
37- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
37- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
38- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
38- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
39- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); loslaten, focusverlies of pointer-annulering stopt de beweging. Azimut 0° is licht van voren (+Z); elevatie is geklemd. Belichting is alleen voor de preview; opgeslagen diffuse blijft unlit albedo.
39- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); loslaten, focusverlies of pointer-annulering stopt de beweging. Azimut 0° is licht van voren (+Z); elevatie is geklemd. Belichting is alleen voor de preview; opgeslagen diffuse blijft unlit albedo.
40- **Randlicht (sterkte)** voegt een subtiel Fresnel-/diepterand-effect toe op het samengestelde frame (standaard aan, begrensd). Op 0 blijft alleen relight over; opgeslagen diffuse wijzigt nooit.
40- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
41- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
41
42
42```bash
43```bash
@@ -84,10 +85,12 @@ Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet
84
85
85De 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).
86De 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).
86
87
87`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.
88`relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. `apply_rim` / `compose_relight_and_rim` voegen daarna een begrensd Fresnel-randlicht (`(1−N·V)^power`) plus optionele diepte-rand toe; intensiteit is geklemd tot `MAX_RIM_INTENSITY` (0,45) en alpha blijft byte-exact. Opgeslagen diffuse blijft elders onbelichte albedo.
88
89
89```rust
90```rust
90use paperdoll_renderer::compositor::{assert_compatible, compose_and_relight, LayerMaps, Light};
91use paperdoll_renderer::compositor::{
92 assert_compatible, compose_relight_and_rim, LayerMaps, Light, RimParams,
93};
91use paperdoll_renderer::export_set::ExportRun;
94use paperdoll_renderer::export_set::ExportRun;
92
95
93let body = ExportRun::open(&body_run_dir)?;
96let body = ExportRun::open(&body_run_dir)?;
@@ -97,7 +100,7 @@ let layers = [
97 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
100 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
98 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
101 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
99];
102];
100let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
103let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimParams::default())?;
101```
104```
102
105
103
106
design.md +6 −6
@@ -146,7 +146,7 @@ Per pose/view, the actual completed-preview order is:
146 146 5. **Normal:** write/publish target normal.
147 147 6. **Specular:** write/publish target scalar map, including body zero.
148 148
149 Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Rim lighting and the 2D lighting/compositor remain deferred; any future rim belongs in the consumer, not in pure diffuse albedo.
149 Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo.
150 150
151 151 Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX remains inventory-only.
152 152
@@ -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 **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the composed+relit frame with aspect-preserving nearest-neighbor letterboxing. The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
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 **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the composed+relit+rim frame with aspect-preserving nearest-neighbor letterboxing. A **Randlicht** strength slider (default subtle, zero disables) drives consumer-side rim. The library depth compositor, relighting and rim API (`compositor`) consumes the safe map reader; lighting/rim are preview-only and do not alter saved unlit diffuse.
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. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
186 186
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.
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). `apply_rim` then adds a bounded Fresnel-like `(1 − N·V)^power` highlight plus an optional depth-discontinuity term (`RimParams`; intensity clamped to `MAX_RIM_INTENSITY`); straight alpha is preserved byte-for-byte and zero intensity is identical to relight-only. It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u); rim is never baked into target PNGs.
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 library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback are implemented; 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, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented; resume and FBX drawing remain unimplemented.
221 221
222 222 ## 12. Incremental feature plan and acceptance gates
223 223
@@ -227,8 +227,8 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
227 227 3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
228 228 4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
229 229 5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
230 6. Production image pipeline: detail-aware downsampling, depth/normal resolve, rim accents, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets.
231 7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls and continuous held light controls; deterministic state tests and GUI smoke.
230 6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)
231 7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls, continuous held light controls and bounded rim/edge lighting; deterministic state tests and GUI smoke.
232 232 8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
233 233
234 234 Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.
@@ -146,7 +146,7 @@ Per pose/view, the actual completed-preview order is:
1465. **Normal:** write/publish target normal.
1465. **Normal:** write/publish target normal.
1476. **Specular:** write/publish target scalar map, including body zero.
1476. **Specular:** write/publish target scalar map, including body zero.
148
148
149Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Rim lighting and the 2D lighting/compositor remain deferred; any future rim belongs in the consumer, not in pure diffuse albedo.
149Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo.
150
150
151Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX remains inventory-only.
151Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX remains inventory-only.
152
152
@@ -180,11 +180,11 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
180
180
181## 10. Rendered 2D preview
181## 10. Rendered 2D preview
182
182
183Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the composed+relit frame with aspect-preserving nearest-neighbor letterboxing. The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
183Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the composed+relit+rim frame with aspect-preserving nearest-neighbor letterboxing. A **Randlicht** strength slider (default subtle, zero disables) drives consumer-side rim. The library depth compositor, relighting and rim API (`compositor`) consumes the safe map reader; lighting/rim are preview-only and do not alter saved unlit diffuse.
184
184
185Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
185Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
186
186
187Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo.
187Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). `apply_rim` then adds a bounded Fresnel-like `(1 − N·V)^power` highlight plus an optional depth-discontinuity term (`RimParams`; intensity clamped to `MAX_RIM_INTENSITY`); straight alpha is preserved byte-for-byte and zero intensity is identical to relight-only. It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u); rim is never baked into target PNGs.
188
188
189## 11. Development workflow, quality and packaging
189## 11. Development workflow, quality and packaging
190
190
@@ -217,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup
217- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
217- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
218- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
218- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
219
219
220Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback are implemented; rim effects, resume and FBX drawing remain unimplemented.
220Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented; resume and FBX drawing remain unimplemented.
221
221
222## 12. Incremental feature plan and acceptance gates
222## 12. Incremental feature plan and acceptance gates
223
223
@@ -227,8 +227,8 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
2273. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2273. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2284. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2284. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2295. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
2295. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
2306. Production image pipeline: detail-aware downsampling, depth/normal resolve, rim accents, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets.
2306. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)
2317. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls and continuous held light controls; deterministic state tests and GUI smoke.
2317. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls, continuous held light controls and bounded rim/edge lighting; deterministic state tests and GUI smoke.
2328. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
2328. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
233
233
234Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.
234Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.
examples/rim_compare.rs added +138 −0
@@ -0,0 +1,138 @@
1 //! Write with/without rim comparison PNGs for acceptance artifacts.
2 use paperdoll_renderer::compositor::{
3 apply_rim, compose, relight, LayerMaps, Light, RimParams, DEFAULT_DEPTH_EDGE_SCALE,
4 DEFAULT_RIM_POWER,
5 };
6 use paperdoll_renderer::render_job::ImageBuf;
7 use std::env;
8 use std::path::PathBuf;
9
10 fn encode_normal(n: [f32; 3]) -> [u8; 4] {
11 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-10);
12 let n = [n[0] / len, n[1] / len, n[2] / len];
13 [
14 ((n[0] * 0.5 + 0.5) * 255.0).round() as u8,
15 ((n[1] * 0.5 + 0.5) * 255.0).round() as u8,
16 ((n[2] * 0.5 + 0.5) * 255.0).round() as u8,
17 255,
18 ]
19 }
20
21 fn sphere_layer(size: u32) -> LayerMaps {
22 let w = size as usize;
23 let mut diffuse = vec![0u8; w * w * 4];
24 let mut normal = vec![0u8; w * w * 4];
25 let mut specular = vec![0u8; w * w * 4];
26 let mut depth = vec![0u16; w * w * 2];
27 let cx = (size as f32 - 1.0) * 0.5;
28 let cy = cx;
29 let radius = cx * 0.92;
30 for y in 0..w {
31 for x in 0..w {
32 let i = y * w + x;
33 let dx = x as f32 - cx;
34 let dy = y as f32 - cy;
35 let r2 = dx * dx + dy * dy;
36 if r2 > radius * radius {
37 continue;
38 }
39 let nz = (1.0 - (r2 / (radius * radius))).sqrt();
40 let nx = dx / radius;
41 let ny = -dy / radius;
42 let n = encode_normal([nx, ny, nz]);
43 let o = i * 4;
44 // Warm albedo
45 diffuse[o] = 160;
46 diffuse[o + 1] = 90;
47 diffuse[o + 2] = 70;
48 diffuse[o + 3] = 255;
49 normal[o..o + 4].copy_from_slice(&n);
50 specular[o] = 40;
51 specular[o + 1] = 40;
52 specular[o + 2] = 40;
53 specular[o + 3] = 255;
54 // Nearer at center (smaller gray)
55 let gray = (5000.0 + (1.0 - nz) * 25000.0) as u16;
56 depth[i * 2] = gray;
57 depth[i * 2 + 1] = 65535;
58 }
59 }
60 LayerMaps {
61 label: "sphere".into(),
62 width: size,
63 height: size,
64 diffuse,
65 normal,
66 specular,
67 depth,
68 }
69 }
70
71 fn side_by_side(a: &ImageBuf, b: &ImageBuf) -> ImageBuf {
72 assert_eq!(a.width, b.width);
73 assert_eq!(a.height, b.height);
74 let w = a.width as usize;
75 let h = a.height as usize;
76 let out_w = w * 2 + 4;
77 let mut rgba = vec![30u8; out_w * h * 4];
78 for y in 0..h {
79 for x in 0..w {
80 let si = (y * w + x) * 4;
81 let dl = (y * out_w + x) * 4;
82 let dr = (y * out_w + (x + w + 4)) * 4;
83 rgba[dl..dl + 4].copy_from_slice(&a.rgba[si..si + 4]);
84 rgba[dr..dr + 4].copy_from_slice(&b.rgba[si..si + 4]);
85 }
86 }
87 ImageBuf {
88 width: out_w as u32,
89 height: a.height,
90 rgba,
91 }
92 }
93
94 fn main() -> Result<(), String> {
95 let out = PathBuf::from(
96 env::args()
97 .nth(1)
98 .unwrap_or_else(|| "/workspace/paperdoll-rim-artifacts".into()),
99 );
100 std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
101 let layer = sphere_layer(96);
102 let composed = compose(&[layer]).map_err(|e| e.to_string())?;
103 let light = Light::default();
104 let lit = relight(&composed, &light).map_err(|e| e.to_string())?;
105 let rimmed = apply_rim(
106 &composed,
107 &lit,
108 &RimParams {
109 intensity: 0.35,
110 power: DEFAULT_RIM_POWER,
111 depth_edge_weight: 0.25,
112 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
113 },
114 )
115 .map_err(|e| e.to_string())?;
116 let off = apply_rim(&composed, &lit, &RimParams::off()).map_err(|e| e.to_string())?;
117 assert_eq!(off.rgba, lit.rgba);
118 let compare = side_by_side(&lit, &rimmed);
119 for (name, img) in [
120 ("rim_off.png", &lit),
121 ("rim_on.png", &rimmed),
122 ("rim_compare_off_vs_on.png", &compare),
123 ] {
124 let path = out.join(name);
125 let file = std::fs::File::create(&path).map_err(|e| e.to_string())?;
126 let mut encoder = png::Encoder::new(file, img.width, img.height);
127 encoder.set_color(png::ColorType::Rgba);
128 encoder.set_depth(png::BitDepth::Eight);
129 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
130 writer
131 .write_image_data(&img.rgba)
132 .map_err(|e| e.to_string())?;
133 writer.finish().map_err(|e| e.to_string())?;
134 }
135 // Bound check note
136 println!("wrote comparison PNGs under {}", out.display());
137 Ok(())
138 }
@@ -0,0 +1,138 @@
1//! Write with/without rim comparison PNGs for acceptance artifacts.
2use paperdoll_renderer::compositor::{
3 apply_rim, compose, relight, LayerMaps, Light, RimParams, DEFAULT_DEPTH_EDGE_SCALE,
4 DEFAULT_RIM_POWER,
5};
6use paperdoll_renderer::render_job::ImageBuf;
7use std::env;
8use std::path::PathBuf;
9
10fn encode_normal(n: [f32; 3]) -> [u8; 4] {
11 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-10);
12 let n = [n[0] / len, n[1] / len, n[2] / len];
13 [
14 ((n[0] * 0.5 + 0.5) * 255.0).round() as u8,
15 ((n[1] * 0.5 + 0.5) * 255.0).round() as u8,
16 ((n[2] * 0.5 + 0.5) * 255.0).round() as u8,
17 255,
18 ]
19}
20
21fn sphere_layer(size: u32) -> LayerMaps {
22 let w = size as usize;
23 let mut diffuse = vec![0u8; w * w * 4];
24 let mut normal = vec![0u8; w * w * 4];
25 let mut specular = vec![0u8; w * w * 4];
26 let mut depth = vec![0u16; w * w * 2];
27 let cx = (size as f32 - 1.0) * 0.5;
28 let cy = cx;
29 let radius = cx * 0.92;
30 for y in 0..w {
31 for x in 0..w {
32 let i = y * w + x;
33 let dx = x as f32 - cx;
34 let dy = y as f32 - cy;
35 let r2 = dx * dx + dy * dy;
36 if r2 > radius * radius {
37 continue;
38 }
39 let nz = (1.0 - (r2 / (radius * radius))).sqrt();
40 let nx = dx / radius;
41 let ny = -dy / radius;
42 let n = encode_normal([nx, ny, nz]);
43 let o = i * 4;
44 // Warm albedo
45 diffuse[o] = 160;
46 diffuse[o + 1] = 90;
47 diffuse[o + 2] = 70;
48 diffuse[o + 3] = 255;
49 normal[o..o + 4].copy_from_slice(&n);
50 specular[o] = 40;
51 specular[o + 1] = 40;
52 specular[o + 2] = 40;
53 specular[o + 3] = 255;
54 // Nearer at center (smaller gray)
55 let gray = (5000.0 + (1.0 - nz) * 25000.0) as u16;
56 depth[i * 2] = gray;
57 depth[i * 2 + 1] = 65535;
58 }
59 }
60 LayerMaps {
61 label: "sphere".into(),
62 width: size,
63 height: size,
64 diffuse,
65 normal,
66 specular,
67 depth,
68 }
69}
70
71fn side_by_side(a: &ImageBuf, b: &ImageBuf) -> ImageBuf {
72 assert_eq!(a.width, b.width);
73 assert_eq!(a.height, b.height);
74 let w = a.width as usize;
75 let h = a.height as usize;
76 let out_w = w * 2 + 4;
77 let mut rgba = vec![30u8; out_w * h * 4];
78 for y in 0..h {
79 for x in 0..w {
80 let si = (y * w + x) * 4;
81 let dl = (y * out_w + x) * 4;
82 let dr = (y * out_w + (x + w + 4)) * 4;
83 rgba[dl..dl + 4].copy_from_slice(&a.rgba[si..si + 4]);
84 rgba[dr..dr + 4].copy_from_slice(&b.rgba[si..si + 4]);
85 }
86 }
87 ImageBuf {
88 width: out_w as u32,
89 height: a.height,
90 rgba,
91 }
92}
93
94fn main() -> Result<(), String> {
95 let out = PathBuf::from(
96 env::args()
97 .nth(1)
98 .unwrap_or_else(|| "/workspace/paperdoll-rim-artifacts".into()),
99 );
100 std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
101 let layer = sphere_layer(96);
102 let composed = compose(&[layer]).map_err(|e| e.to_string())?;
103 let light = Light::default();
104 let lit = relight(&composed, &light).map_err(|e| e.to_string())?;
105 let rimmed = apply_rim(
106 &composed,
107 &lit,
108 &RimParams {
109 intensity: 0.35,
110 power: DEFAULT_RIM_POWER,
111 depth_edge_weight: 0.25,
112 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
113 },
114 )
115 .map_err(|e| e.to_string())?;
116 let off = apply_rim(&composed, &lit, &RimParams::off()).map_err(|e| e.to_string())?;
117 assert_eq!(off.rgba, lit.rgba);
118 let compare = side_by_side(&lit, &rimmed);
119 for (name, img) in [
120 ("rim_off.png", &lit),
121 ("rim_on.png", &rimmed),
122 ("rim_compare_off_vs_on.png", &compare),
123 ] {
124 let path = out.join(name);
125 let file = std::fs::File::create(&path).map_err(|e| e.to_string())?;
126 let mut encoder = png::Encoder::new(file, img.width, img.height);
127 encoder.set_color(png::ColorType::Rgba);
128 encoder.set_depth(png::BitDepth::Eight);
129 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
130 writer
131 .write_image_data(&img.rgba)
132 .map_err(|e| e.to_string())?;
133 writer.finish().map_err(|e| e.to_string())?;
134 }
135 // Bound check note
136 println!("wrote comparison PNGs under {}", out.display());
137 Ok(())
138}
src/compositor.rs +435 −1
@@ -1,4 +1,4 @@
1 //! Deterministic 2D depth compositor and linear relighting.
1 //! Deterministic 2D depth compositor, linear relighting and subtle rim/edge lighting.
2 2 //!
3 3 //! Consumes already-exported maps through [`crate::export_set`] (schema 3).
4 4 //! Saved diffuse remains unlit albedo elsewhere; this module only applies
@@ -41,6 +41,14 @@
41 41 //! direction is already in **view space**, matching the exported normal maps
42 42 //! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the
43 43 //! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage.
44 //!
45 //! # Rim / edge highlight (consumer only)
46 //!
47 //! [`apply_rim`] adds a bounded Fresnel-like `(1 − N·V)^power` highlight plus an
48 //! optional depth-discontinuity term. Intensity is clamped to
49 //! [`MAX_RIM_INTENSITY`]. Straight alpha/coverage is preserved byte-for-byte.
50 //! Zero intensity returns the relit image unchanged. Production diffuse PNGs
51 //! are never modified.
44 52 use crate::{
45 53 export_set::{ExportError, ExportRun, MapImage, MapKind},
46 54 render_job::ImageBuf,
@@ -456,6 +464,207 @@ pub fn compose_and_relight(
456 464 Ok((composed, lit))
457 465 }
458 466
467 /// Maximum additive linear rim intensity (after clamp).
468 pub const MAX_RIM_INTENSITY: f32 = 0.45;
469
470 /// Default subtle rim intensity used by the 2D preview.
471 pub const DEFAULT_RIM_INTENSITY: f32 = 0.20;
472
473 /// Default Fresnel exponent for `(1 − N·V)^power`.
474 pub const DEFAULT_RIM_POWER: f32 = 3.0;
475
476 /// Depth gray delta (u16 codes) that saturates the depth-edge term.
477 pub const DEFAULT_DEPTH_EDGE_SCALE: f32 = 2048.0;
478
479 /// Consumer-side rim / silhouette edge highlight parameters.
480 ///
481 /// Applied after [`relight`]. Does not touch saved production albedo.
482 #[derive(Debug, Clone, Copy, PartialEq)]
483 pub struct RimParams {
484 /// Additive linear intensity; clamped to `[0, MAX_RIM_INTENSITY]`.
485 pub intensity: f32,
486 /// Fresnel exponent; values below 1 clamp up to 1.
487 pub power: f32,
488 /// Blend weight of the depth-discontinuity term in `[0, 1]`.
489 /// `0` = Fresnel only; `1` = depth edges only.
490 pub depth_edge_weight: f32,
491 /// Depth gray delta that maps to a full depth-edge contribution.
492 pub depth_edge_scale: f32,
493 }
494
495 impl Default for RimParams {
496 fn default() -> Self {
497 Self {
498 intensity: DEFAULT_RIM_INTENSITY,
499 power: DEFAULT_RIM_POWER,
500 depth_edge_weight: 0.35,
501 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
502 }
503 }
504 }
505
506 impl RimParams {
507 /// Explicitly disabled rim (byte-identical to relight-only output).
508 pub fn off() -> Self {
509 Self {
510 intensity: 0.0,
511 ..Self::default()
512 }
513 }
514
515 /// Finite, clamped parameters used by [`apply_rim`].
516 pub fn clamped(self) -> Self {
517 Self {
518 intensity: if self.intensity.is_finite() {
519 self.intensity.clamp(0.0, MAX_RIM_INTENSITY)
520 } else {
521 0.0
522 },
523 power: if self.power.is_finite() {
524 self.power.max(1.0)
525 } else {
526 DEFAULT_RIM_POWER
527 },
528 depth_edge_weight: if self.depth_edge_weight.is_finite() {
529 self.depth_edge_weight.clamp(0.0, 1.0)
530 } else {
531 0.0
532 },
533 depth_edge_scale: if self.depth_edge_scale.is_finite() && self.depth_edge_scale > 1.0 {
534 self.depth_edge_scale
535 } else {
536 DEFAULT_DEPTH_EDGE_SCALE
537 },
538 }
539 }
540 }
541
542 /// Add a bounded rim/edge highlight on top of an already-relit sRGB image.
543 ///
544 /// - Fresnel term from view-space normals with view = `+Z`.
545 /// - Optional depth-edge term from max |Δdepth| to covered 4-neighbors.
546 /// - Alpha/coverage is copied unchanged from `lit`.
547 /// - Zero intensity returns a byte-identical clone of `lit`.
548 pub fn apply_rim(
549 composed: &ComposedMaps,
550 lit: &ImageBuf,
551 rim: &RimParams,
552 ) -> Result<ImageBuf, ComposeError> {
553 let width = composed.width;
554 let height = composed.height;
555 let pixels = (width as usize)
556 .checked_mul(height as usize)
557 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
558 if lit.width != width
559 || lit.height != height
560 || lit.rgba.len() != pixels * 4
561 || composed.diffuse.len() != pixels * 4
562 || composed.normal.len() != pixels * 4
563 || composed.depth.len() != pixels * 2
564 || composed.winner.len() != pixels
565 {
566 return Err(ComposeError::Map(
567 "rim buffers passen niet bij composed/lit afmetingen".into(),
568 ));
569 }
570
571 let rim = rim.clamped();
572 if rim.intensity == 0.0 {
573 return Ok(ImageBuf {
574 width,
575 height,
576 rgba: lit.rgba.clone(),
577 });
578 }
579
580 let view = [0.0_f32, 0.0, 1.0];
581 let mut rgba = lit.rgba.clone();
582 let w = width as usize;
583 let h = height as usize;
584
585 for y in 0..h {
586 for x in 0..w {
587 let i = y * w + x;
588 let o = i * 4;
589 let a = rgba[o + 3];
590 if a == 0 || composed.winner[i] == u8::MAX {
591 continue;
592 }
593 let n = decode_normal([
594 composed.normal[o],
595 composed.normal[o + 1],
596 composed.normal[o + 2],
597 ]);
598 let ndotv = dot(n, view).clamp(0.0, 1.0);
599 let fresnel = (1.0 - ndotv).powf(rim.power);
600
601 let mut depth_edge = 0.0_f32;
602 if rim.depth_edge_weight > 0.0 {
603 let center = composed.depth[i * 2];
604 let mut max_delta = 0u16;
605 let mut silhouette = false;
606 for (dx, dy) in [(-1isize, 0), (1, 0), (0, -1), (0, 1)] {
607 let nx = x as isize + dx;
608 let ny = y as isize + dy;
609 if nx < 0 || ny < 0 || nx as usize >= w || ny as usize >= h {
610 // Image border next to covered geometry counts as a silhouette edge.
611 silhouette = true;
612 break;
613 }
614 let ni = ny as usize * w + nx as usize;
615 if composed.winner[ni] == u8::MAX || composed.depth[ni * 2 + 1] == 0 {
616 silhouette = true;
617 break;
618 }
619 let d = center.abs_diff(composed.depth[ni * 2]);
620 max_delta = max_delta.max(d);
621 }
622 depth_edge = if silhouette {
623 1.0
624 } else {
625 (max_delta as f32 / rim.depth_edge_scale).clamp(0.0, 1.0)
626 };
627 }
628
629 let factor =
630 fresnel * (1.0 - rim.depth_edge_weight) + depth_edge * rim.depth_edge_weight;
631 if factor <= 0.0 {
632 continue;
633 }
634 let add = rim.intensity * factor;
635 let mut lin = [
636 linear_srgb(rgba[o]),
637 linear_srgb(rgba[o + 1]),
638 linear_srgb(rgba[o + 2]),
639 ];
640 for channel in &mut lin {
641 *channel = (*channel + add).clamp(0.0, 1.0);
642 }
643 rgba[o] = encode_srgb(lin[0]);
644 rgba[o + 1] = encode_srgb(lin[1]);
645 rgba[o + 2] = encode_srgb(lin[2]);
646 // Alpha intentionally untouched.
647 }
648 }
649
650 Ok(ImageBuf {
651 width,
652 height,
653 rgba,
654 })
655 }
656
657 /// Compose, relight, then apply rim in one step.
658 pub fn compose_relight_and_rim(
659 layers: &[LayerMaps],
660 light: &Light,
661 rim: &RimParams,
662 ) -> Result<(ComposedMaps, ImageBuf), ComposeError> {
663 let (composed, lit) = compose_and_relight(layers, light)?;
664 let rimmed = apply_rim(&composed, &lit, rim)?;
665 Ok((composed, rimmed))
666 }
667
459 668 fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
460 669 normalize([
461 670 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
@@ -1057,4 +1266,229 @@ mod tests {
1057 1266 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1058 1267 assert!(matches!(err, ComposeError::Incompatible(_)));
1059 1268 }
1269
1270 fn encode_normal_rgb(n: [f32; 3]) -> [u8; 4] {
1271 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1272 let n = if len > 1e-10 {
1273 [n[0] / len, n[1] / len, n[2] / len]
1274 } else {
1275 [0.0, 0.0, 1.0]
1276 };
1277 [
1278 ((n[0] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1279 ((n[1] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1280 ((n[2] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1281 255,
1282 ]
1283 }
1284
1285 fn luma(rgba: &[u8]) -> u32 {
1286 rgba[0] as u32 + rgba[1] as u32 + rgba[2] as u32
1287 }
1288
1289 #[test]
1290 fn rim_zero_intensity_matches_relight_exactly() {
1291 let layer = solid_layer(
1292 "body",
1293 1,
1294 1,
1295 [180, 40, 40, 200],
1296 encode_normal_rgb([0.7, 0.1, 0.2]),
1297 [0, 0, 0, 255],
1298 12000,
1299 65535,
1300 );
1301 let composed = compose(&[layer]).unwrap();
1302 let lit = relight(&composed, &Light::default()).unwrap();
1303 let rimmed = apply_rim(&composed, &lit, &RimParams::off()).unwrap();
1304 assert_eq!(rimmed.rgba, lit.rgba);
1305 let (_c, via) = compose_relight_and_rim(
1306 &[solid_layer(
1307 "body",
1308 1,
1309 1,
1310 [180, 40, 40, 200],
1311 encode_normal_rgb([0.7, 0.1, 0.2]),
1312 [0, 0, 0, 255],
1313 12000,
1314 65535,
1315 )],
1316 &Light::default(),
1317 &RimParams::off(),
1318 )
1319 .unwrap();
1320 assert_eq!(via.rgba, lit.rgba);
1321 }
1322
1323 #[test]
1324 fn rim_flat_front_facing_surface_stays_near_relight() {
1325 // Uniform +Z normal → Fresnel ≈ 0; no depth neighbors → depth edge from border only.
1326 // Use depth_edge_weight = 0 so only Fresnel matters.
1327 let layer = solid_layer(
1328 "flat",
1329 3,
1330 3,
1331 [120, 120, 120, 255],
1332 encode_normal_rgb([0.0, 0.0, 1.0]),
1333 [0, 0, 0, 255],
1334 20000,
1335 65535,
1336 );
1337 let composed = compose(&[layer]).unwrap();
1338 let lit = relight(&composed, &Light::default()).unwrap();
1339 let rim = RimParams {
1340 intensity: MAX_RIM_INTENSITY,
1341 power: DEFAULT_RIM_POWER,
1342 depth_edge_weight: 0.0,
1343 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1344 };
1345 let rimmed = apply_rim(&composed, &lit, &rim).unwrap();
1346 // Center pixel has no Fresnel contribution on a perfect front-facing normal.
1347 let c = 16; // (row 1, col 1) in 3×3
1348 assert_eq!(&rimmed.rgba[c..c + 4], &lit.rgba[c..c + 4]);
1349 // Alpha preserved everywhere.
1350 for i in 0..9 {
1351 assert_eq!(rimmed.rgba[i * 4 + 3], lit.rgba[i * 4 + 3]);
1352 }
1353 }
1354
1355 #[test]
1356 fn rim_grazing_angle_adds_bounded_highlight() {
1357 let facing = solid_layer(
1358 "face",
1359 1,
1360 1,
1361 [80, 80, 80, 255],
1362 encode_normal_rgb([0.0, 0.0, 1.0]),
1363 [0, 0, 0, 255],
1364 15000,
1365 65535,
1366 );
1367 let grazing = solid_layer(
1368 "edge",
1369 1,
1370 1,
1371 [80, 80, 80, 255],
1372 encode_normal_rgb([0.95, 0.0, 0.05]),
1373 [0, 0, 0, 255],
1374 15000,
1375 65535,
1376 );
1377 let light = Light {
1378 ambient: 0.2,
1379 diffuse: 0.0,
1380 specular: 0.0,
1381 ..Light::default()
1382 };
1383 let rim = RimParams {
1384 intensity: 0.30,
1385 power: 2.0,
1386 depth_edge_weight: 0.0,
1387 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1388 };
1389 let face_c = compose(&[facing]).unwrap();
1390 let edge_c = compose(&[grazing]).unwrap();
1391 let face_lit = relight(&face_c, &light).unwrap();
1392 let edge_lit = relight(&edge_c, &light).unwrap();
1393 let face_rim = apply_rim(&face_c, &face_lit, &rim).unwrap();
1394 let edge_rim = apply_rim(&edge_c, &edge_lit, &rim).unwrap();
1395 assert_eq!(
1396 face_rim.rgba, face_lit.rgba,
1397 "flat front must not gain Fresnel rim"
1398 );
1399 assert!(
1400 luma(&edge_rim.rgba) > luma(&edge_lit.rgba),
1401 "grazing must brighten: {} vs {}",
1402 luma(&edge_rim.rgba),
1403 luma(&edge_lit.rgba)
1404 );
1405 // Bound: additive intensity cannot exceed MAX after clamp; RGB stay ≤ 255 and alpha intact.
1406 assert_eq!(edge_rim.rgba[3], 255);
1407 assert_eq!(edge_rim.rgba[3], edge_lit.rgba[3]);
1408 for c in 0..3 {
1409 assert!(edge_rim.rgba[c] >= edge_lit.rgba[c]);
1410 }
1411 // Over-large intensity clamps; result still finite and ≤ full white from black+max.
1412 let over = RimParams {
1413 intensity: 99.0,
1414 power: 1.0,
1415 depth_edge_weight: 0.0,
1416 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1417 };
1418 assert_eq!(over.clamped().intensity, MAX_RIM_INTENSITY);
1419 let clamped_img = apply_rim(&edge_c, &edge_lit, &over).unwrap();
1420 // Over-max intensity still preserves alpha and stays within sRGB encoding.
1421 assert_eq!(clamped_img.rgba[3], 255);
1422 assert!(luma(&clamped_img.rgba) >= luma(&edge_lit.rgba));
1423 }
1424
1425 #[test]
1426 fn rim_depth_edge_highlights_silhouette_not_flat_interior() {
1427 // 3×3 with uniform normals facing camera; center depth differs strongly from ring.
1428 let layer = pixel_layer("sil", 3, 3, |i| {
1429 let x = i % 3;
1430 let y = i / 3;
1431 let border = x == 0 || y == 0 || x == 2 || y == 2;
1432 let gray = if border { 10000 } else { 40000 };
1433 (
1434 [60, 60, 60, 255],
1435 encode_normal_rgb([0.0, 0.0, 1.0]),
1436 [0, 0, 0, 255],
1437 gray,
1438 65535,
1439 )
1440 });
1441 let composed = compose(&[layer]).unwrap();
1442 let light = Light {
1443 ambient: 0.25,
1444 diffuse: 0.0,
1445 specular: 0.0,
1446 ..Light::default()
1447 };
1448 let lit = relight(&composed, &light).unwrap();
1449 let rim = RimParams {
1450 intensity: 0.35,
1451 power: 4.0,
1452 depth_edge_weight: 1.0, // depth only
1453 depth_edge_scale: 5000.0,
1454 };
1455 let rimmed = apply_rim(&composed, &lit, &rim).unwrap();
1456 let center = 16; // (row 1, col 1) in 3×3
1457 let edge = 4; // top-middle (row 0, col 1)
1458 // Interior center sees large depth deltas to neighbors → highlighted.
1459 assert!(
1460 luma(&rimmed.rgba[center..center + 4]) > luma(&lit.rgba[center..center + 4]),
1461 "depth jump at interior should rim"
1462 );
1463 assert!(
1464 luma(&rimmed.rgba[edge..edge + 4]) > luma(&lit.rgba[edge..edge + 4]),
1465 "border pixel next to depth jump should rim"
1466 );
1467 for i in 0..9 {
1468 assert_eq!(rimmed.rgba[i * 4 + 3], 255);
1469 }
1470 }
1471
1472 #[test]
1473 fn rim_preserves_transparent_and_soft_alpha() {
1474 let layer = pixel_layer("a", 2, 1, |i| {
1475 if i == 0 {
1476 ([50, 50, 50, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
1477 } else {
1478 (
1479 [50, 50, 50, 128],
1480 encode_normal_rgb([0.9, 0.0, 0.1]),
1481 [0, 0, 0, 255],
1482 12000,
1483 65535,
1484 )
1485 }
1486 });
1487 let composed = compose(&[layer]).unwrap();
1488 let lit = relight(&composed, &Light::default()).unwrap();
1489 let rimmed = apply_rim(&composed, &lit, &RimParams::default()).unwrap();
1490 assert_eq!(&rimmed.rgba[0..4], &[0, 0, 0, 0]);
1491 assert_eq!(rimmed.rgba[7], 128);
1492 assert_eq!(rimmed.rgba[7], lit.rgba[7]);
1493 }
1060 1494 }
@@ -1,4 +1,4 @@
1//! Deterministic 2D depth compositor and linear relighting.
1//! Deterministic 2D depth compositor, linear relighting and subtle rim/edge lighting.
2//!
2//!
3//! Consumes already-exported maps through [`crate::export_set`] (schema 3).
3//! Consumes already-exported maps through [`crate::export_set`] (schema 3).
4//! Saved diffuse remains unlit albedo elsewhere; this module only applies
4//! Saved diffuse remains unlit albedo elsewhere; this module only applies
@@ -41,6 +41,14 @@
41//! direction is already in **view space**, matching the exported normal maps
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
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.
43//! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage.
44//!
45//! # Rim / edge highlight (consumer only)
46//!
47//! [`apply_rim`] adds a bounded Fresnel-like `(1 − N·V)^power` highlight plus an
48//! optional depth-discontinuity term. Intensity is clamped to
49//! [`MAX_RIM_INTENSITY`]. Straight alpha/coverage is preserved byte-for-byte.
50//! Zero intensity returns the relit image unchanged. Production diffuse PNGs
51//! are never modified.
44use crate::{
52use crate::{
45 export_set::{ExportError, ExportRun, MapImage, MapKind},
53 export_set::{ExportError, ExportRun, MapImage, MapKind},
46 render_job::ImageBuf,
54 render_job::ImageBuf,
@@ -456,6 +464,207 @@ pub fn compose_and_relight(
456 Ok((composed, lit))
464 Ok((composed, lit))
457}
465}
458
466
467/// Maximum additive linear rim intensity (after clamp).
468pub const MAX_RIM_INTENSITY: f32 = 0.45;
469
470/// Default subtle rim intensity used by the 2D preview.
471pub const DEFAULT_RIM_INTENSITY: f32 = 0.20;
472
473/// Default Fresnel exponent for `(1 − N·V)^power`.
474pub const DEFAULT_RIM_POWER: f32 = 3.0;
475
476/// Depth gray delta (u16 codes) that saturates the depth-edge term.
477pub const DEFAULT_DEPTH_EDGE_SCALE: f32 = 2048.0;
478
479/// Consumer-side rim / silhouette edge highlight parameters.
480///
481/// Applied after [`relight`]. Does not touch saved production albedo.
482#[derive(Debug, Clone, Copy, PartialEq)]
483pub struct RimParams {
484 /// Additive linear intensity; clamped to `[0, MAX_RIM_INTENSITY]`.
485 pub intensity: f32,
486 /// Fresnel exponent; values below 1 clamp up to 1.
487 pub power: f32,
488 /// Blend weight of the depth-discontinuity term in `[0, 1]`.
489 /// `0` = Fresnel only; `1` = depth edges only.
490 pub depth_edge_weight: f32,
491 /// Depth gray delta that maps to a full depth-edge contribution.
492 pub depth_edge_scale: f32,
493}
494
495impl Default for RimParams {
496 fn default() -> Self {
497 Self {
498 intensity: DEFAULT_RIM_INTENSITY,
499 power: DEFAULT_RIM_POWER,
500 depth_edge_weight: 0.35,
501 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
502 }
503 }
504}
505
506impl RimParams {
507 /// Explicitly disabled rim (byte-identical to relight-only output).
508 pub fn off() -> Self {
509 Self {
510 intensity: 0.0,
511 ..Self::default()
512 }
513 }
514
515 /// Finite, clamped parameters used by [`apply_rim`].
516 pub fn clamped(self) -> Self {
517 Self {
518 intensity: if self.intensity.is_finite() {
519 self.intensity.clamp(0.0, MAX_RIM_INTENSITY)
520 } else {
521 0.0
522 },
523 power: if self.power.is_finite() {
524 self.power.max(1.0)
525 } else {
526 DEFAULT_RIM_POWER
527 },
528 depth_edge_weight: if self.depth_edge_weight.is_finite() {
529 self.depth_edge_weight.clamp(0.0, 1.0)
530 } else {
531 0.0
532 },
533 depth_edge_scale: if self.depth_edge_scale.is_finite() && self.depth_edge_scale > 1.0 {
534 self.depth_edge_scale
535 } else {
536 DEFAULT_DEPTH_EDGE_SCALE
537 },
538 }
539 }
540}
541
542/// Add a bounded rim/edge highlight on top of an already-relit sRGB image.
543///
544/// - Fresnel term from view-space normals with view = `+Z`.
545/// - Optional depth-edge term from max |Δdepth| to covered 4-neighbors.
546/// - Alpha/coverage is copied unchanged from `lit`.
547/// - Zero intensity returns a byte-identical clone of `lit`.
548pub fn apply_rim(
549 composed: &ComposedMaps,
550 lit: &ImageBuf,
551 rim: &RimParams,
552) -> Result<ImageBuf, ComposeError> {
553 let width = composed.width;
554 let height = composed.height;
555 let pixels = (width as usize)
556 .checked_mul(height as usize)
557 .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;
558 if lit.width != width
559 || lit.height != height
560 || lit.rgba.len() != pixels * 4
561 || composed.diffuse.len() != pixels * 4
562 || composed.normal.len() != pixels * 4
563 || composed.depth.len() != pixels * 2
564 || composed.winner.len() != pixels
565 {
566 return Err(ComposeError::Map(
567 "rim buffers passen niet bij composed/lit afmetingen".into(),
568 ));
569 }
570
571 let rim = rim.clamped();
572 if rim.intensity == 0.0 {
573 return Ok(ImageBuf {
574 width,
575 height,
576 rgba: lit.rgba.clone(),
577 });
578 }
579
580 let view = [0.0_f32, 0.0, 1.0];
581 let mut rgba = lit.rgba.clone();
582 let w = width as usize;
583 let h = height as usize;
584
585 for y in 0..h {
586 for x in 0..w {
587 let i = y * w + x;
588 let o = i * 4;
589 let a = rgba[o + 3];
590 if a == 0 || composed.winner[i] == u8::MAX {
591 continue;
592 }
593 let n = decode_normal([
594 composed.normal[o],
595 composed.normal[o + 1],
596 composed.normal[o + 2],
597 ]);
598 let ndotv = dot(n, view).clamp(0.0, 1.0);
599 let fresnel = (1.0 - ndotv).powf(rim.power);
600
601 let mut depth_edge = 0.0_f32;
602 if rim.depth_edge_weight > 0.0 {
603 let center = composed.depth[i * 2];
604 let mut max_delta = 0u16;
605 let mut silhouette = false;
606 for (dx, dy) in [(-1isize, 0), (1, 0), (0, -1), (0, 1)] {
607 let nx = x as isize + dx;
608 let ny = y as isize + dy;
609 if nx < 0 || ny < 0 || nx as usize >= w || ny as usize >= h {
610 // Image border next to covered geometry counts as a silhouette edge.
611 silhouette = true;
612 break;
613 }
614 let ni = ny as usize * w + nx as usize;
615 if composed.winner[ni] == u8::MAX || composed.depth[ni * 2 + 1] == 0 {
616 silhouette = true;
617 break;
618 }
619 let d = center.abs_diff(composed.depth[ni * 2]);
620 max_delta = max_delta.max(d);
621 }
622 depth_edge = if silhouette {
623 1.0
624 } else {
625 (max_delta as f32 / rim.depth_edge_scale).clamp(0.0, 1.0)
626 };
627 }
628
629 let factor =
630 fresnel * (1.0 - rim.depth_edge_weight) + depth_edge * rim.depth_edge_weight;
631 if factor <= 0.0 {
632 continue;
633 }
634 let add = rim.intensity * factor;
635 let mut lin = [
636 linear_srgb(rgba[o]),
637 linear_srgb(rgba[o + 1]),
638 linear_srgb(rgba[o + 2]),
639 ];
640 for channel in &mut lin {
641 *channel = (*channel + add).clamp(0.0, 1.0);
642 }
643 rgba[o] = encode_srgb(lin[0]);
644 rgba[o + 1] = encode_srgb(lin[1]);
645 rgba[o + 2] = encode_srgb(lin[2]);
646 // Alpha intentionally untouched.
647 }
648 }
649
650 Ok(ImageBuf {
651 width,
652 height,
653 rgba,
654 })
655}
656
657/// Compose, relight, then apply rim in one step.
658pub fn compose_relight_and_rim(
659 layers: &[LayerMaps],
660 light: &Light,
661 rim: &RimParams,
662) -> Result<(ComposedMaps, ImageBuf), ComposeError> {
663 let (composed, lit) = compose_and_relight(layers, light)?;
664 let rimmed = apply_rim(&composed, &lit, rim)?;
665 Ok((composed, rimmed))
666}
667
459fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
668fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
460 normalize([
669 normalize([
461 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
670 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
@@ -1057,4 +1266,229 @@ mod tests {
1057 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1266 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1058 assert!(matches!(err, ComposeError::Incompatible(_)));
1267 assert!(matches!(err, ComposeError::Incompatible(_)));
1059 }
1268 }
1269
1270 fn encode_normal_rgb(n: [f32; 3]) -> [u8; 4] {
1271 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1272 let n = if len > 1e-10 {
1273 [n[0] / len, n[1] / len, n[2] / len]
1274 } else {
1275 [0.0, 0.0, 1.0]
1276 };
1277 [
1278 ((n[0] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1279 ((n[1] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1280 ((n[2] * 0.5 + 0.5) * 255.0).round().clamp(0.0, 255.0) as u8,
1281 255,
1282 ]
1283 }
1284
1285 fn luma(rgba: &[u8]) -> u32 {
1286 rgba[0] as u32 + rgba[1] as u32 + rgba[2] as u32
1287 }
1288
1289 #[test]
1290 fn rim_zero_intensity_matches_relight_exactly() {
1291 let layer = solid_layer(
1292 "body",
1293 1,
1294 1,
1295 [180, 40, 40, 200],
1296 encode_normal_rgb([0.7, 0.1, 0.2]),
1297 [0, 0, 0, 255],
1298 12000,
1299 65535,
1300 );
1301 let composed = compose(&[layer]).unwrap();
1302 let lit = relight(&composed, &Light::default()).unwrap();
1303 let rimmed = apply_rim(&composed, &lit, &RimParams::off()).unwrap();
1304 assert_eq!(rimmed.rgba, lit.rgba);
1305 let (_c, via) = compose_relight_and_rim(
1306 &[solid_layer(
1307 "body",
1308 1,
1309 1,
1310 [180, 40, 40, 200],
1311 encode_normal_rgb([0.7, 0.1, 0.2]),
1312 [0, 0, 0, 255],
1313 12000,
1314 65535,
1315 )],
1316 &Light::default(),
1317 &RimParams::off(),
1318 )
1319 .unwrap();
1320 assert_eq!(via.rgba, lit.rgba);
1321 }
1322
1323 #[test]
1324 fn rim_flat_front_facing_surface_stays_near_relight() {
1325 // Uniform +Z normal → Fresnel ≈ 0; no depth neighbors → depth edge from border only.
1326 // Use depth_edge_weight = 0 so only Fresnel matters.
1327 let layer = solid_layer(
1328 "flat",
1329 3,
1330 3,
1331 [120, 120, 120, 255],
1332 encode_normal_rgb([0.0, 0.0, 1.0]),
1333 [0, 0, 0, 255],
1334 20000,
1335 65535,
1336 );
1337 let composed = compose(&[layer]).unwrap();
1338 let lit = relight(&composed, &Light::default()).unwrap();
1339 let rim = RimParams {
1340 intensity: MAX_RIM_INTENSITY,
1341 power: DEFAULT_RIM_POWER,
1342 depth_edge_weight: 0.0,
1343 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1344 };
1345 let rimmed = apply_rim(&composed, &lit, &rim).unwrap();
1346 // Center pixel has no Fresnel contribution on a perfect front-facing normal.
1347 let c = 16; // (row 1, col 1) in 3×3
1348 assert_eq!(&rimmed.rgba[c..c + 4], &lit.rgba[c..c + 4]);
1349 // Alpha preserved everywhere.
1350 for i in 0..9 {
1351 assert_eq!(rimmed.rgba[i * 4 + 3], lit.rgba[i * 4 + 3]);
1352 }
1353 }
1354
1355 #[test]
1356 fn rim_grazing_angle_adds_bounded_highlight() {
1357 let facing = solid_layer(
1358 "face",
1359 1,
1360 1,
1361 [80, 80, 80, 255],
1362 encode_normal_rgb([0.0, 0.0, 1.0]),
1363 [0, 0, 0, 255],
1364 15000,
1365 65535,
1366 );
1367 let grazing = solid_layer(
1368 "edge",
1369 1,
1370 1,
1371 [80, 80, 80, 255],
1372 encode_normal_rgb([0.95, 0.0, 0.05]),
1373 [0, 0, 0, 255],
1374 15000,
1375 65535,
1376 );
1377 let light = Light {
1378 ambient: 0.2,
1379 diffuse: 0.0,
1380 specular: 0.0,
1381 ..Light::default()
1382 };
1383 let rim = RimParams {
1384 intensity: 0.30,
1385 power: 2.0,
1386 depth_edge_weight: 0.0,
1387 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1388 };
1389 let face_c = compose(&[facing]).unwrap();
1390 let edge_c = compose(&[grazing]).unwrap();
1391 let face_lit = relight(&face_c, &light).unwrap();
1392 let edge_lit = relight(&edge_c, &light).unwrap();
1393 let face_rim = apply_rim(&face_c, &face_lit, &rim).unwrap();
1394 let edge_rim = apply_rim(&edge_c, &edge_lit, &rim).unwrap();
1395 assert_eq!(
1396 face_rim.rgba, face_lit.rgba,
1397 "flat front must not gain Fresnel rim"
1398 );
1399 assert!(
1400 luma(&edge_rim.rgba) > luma(&edge_lit.rgba),
1401 "grazing must brighten: {} vs {}",
1402 luma(&edge_rim.rgba),
1403 luma(&edge_lit.rgba)
1404 );
1405 // Bound: additive intensity cannot exceed MAX after clamp; RGB stay ≤ 255 and alpha intact.
1406 assert_eq!(edge_rim.rgba[3], 255);
1407 assert_eq!(edge_rim.rgba[3], edge_lit.rgba[3]);
1408 for c in 0..3 {
1409 assert!(edge_rim.rgba[c] >= edge_lit.rgba[c]);
1410 }
1411 // Over-large intensity clamps; result still finite and ≤ full white from black+max.
1412 let over = RimParams {
1413 intensity: 99.0,
1414 power: 1.0,
1415 depth_edge_weight: 0.0,
1416 depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,
1417 };
1418 assert_eq!(over.clamped().intensity, MAX_RIM_INTENSITY);
1419 let clamped_img = apply_rim(&edge_c, &edge_lit, &over).unwrap();
1420 // Over-max intensity still preserves alpha and stays within sRGB encoding.
1421 assert_eq!(clamped_img.rgba[3], 255);
1422 assert!(luma(&clamped_img.rgba) >= luma(&edge_lit.rgba));
1423 }
1424
1425 #[test]
1426 fn rim_depth_edge_highlights_silhouette_not_flat_interior() {
1427 // 3×3 with uniform normals facing camera; center depth differs strongly from ring.
1428 let layer = pixel_layer("sil", 3, 3, |i| {
1429 let x = i % 3;
1430 let y = i / 3;
1431 let border = x == 0 || y == 0 || x == 2 || y == 2;
1432 let gray = if border { 10000 } else { 40000 };
1433 (
1434 [60, 60, 60, 255],
1435 encode_normal_rgb([0.0, 0.0, 1.0]),
1436 [0, 0, 0, 255],
1437 gray,
1438 65535,
1439 )
1440 });
1441 let composed = compose(&[layer]).unwrap();
1442 let light = Light {
1443 ambient: 0.25,
1444 diffuse: 0.0,
1445 specular: 0.0,
1446 ..Light::default()
1447 };
1448 let lit = relight(&composed, &light).unwrap();
1449 let rim = RimParams {
1450 intensity: 0.35,
1451 power: 4.0,
1452 depth_edge_weight: 1.0, // depth only
1453 depth_edge_scale: 5000.0,
1454 };
1455 let rimmed = apply_rim(&composed, &lit, &rim).unwrap();
1456 let center = 16; // (row 1, col 1) in 3×3
1457 let edge = 4; // top-middle (row 0, col 1)
1458 // Interior center sees large depth deltas to neighbors → highlighted.
1459 assert!(
1460 luma(&rimmed.rgba[center..center + 4]) > luma(&lit.rgba[center..center + 4]),
1461 "depth jump at interior should rim"
1462 );
1463 assert!(
1464 luma(&rimmed.rgba[edge..edge + 4]) > luma(&lit.rgba[edge..edge + 4]),
1465 "border pixel next to depth jump should rim"
1466 );
1467 for i in 0..9 {
1468 assert_eq!(rimmed.rgba[i * 4 + 3], 255);
1469 }
1470 }
1471
1472 #[test]
1473 fn rim_preserves_transparent_and_soft_alpha() {
1474 let layer = pixel_layer("a", 2, 1, |i| {
1475 if i == 0 {
1476 ([50, 50, 50, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)
1477 } else {
1478 (
1479 [50, 50, 50, 128],
1480 encode_normal_rgb([0.9, 0.0, 0.1]),
1481 [0, 0, 0, 255],
1482 12000,
1483 65535,
1484 )
1485 }
1486 });
1487 let composed = compose(&[layer]).unwrap();
1488 let lit = relight(&composed, &Light::default()).unwrap();
1489 let rimmed = apply_rim(&composed, &lit, &RimParams::default()).unwrap();
1490 assert_eq!(&rimmed.rgba[0..4], &[0, 0, 0, 0]);
1491 assert_eq!(rimmed.rgba[7], 128);
1492 assert_eq!(rimmed.rgba[7], lit.rgba[7]);
1493 }
1060}
1494}
src/editor.rs +17 −1
@@ -4,6 +4,7 @@ use std::path::PathBuf;
4 4
5 5 use eframe::egui;
6 6
7 use crate::compositor::MAX_RIM_INTENSITY;
7 8 use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8 9 use crate::import::{import_project_model, ImportedModel};
9 10 use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
@@ -1265,6 +1266,7 @@ impl EditorApp {
1265 1266 let pitch = self.preview2d.pitch_deg();
1266 1267 let yaw = self.preview2d.yaw_deg();
1267 1268 let light = self.preview2d.light();
1269 let rim = self.preview2d.rim();
1268 1270 if clip.is_empty() {
1269 1271 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1270 1272 self.preview2d_image = None;
@@ -1279,6 +1281,7 @@ impl EditorApp {
1279 1281 pitch,
1280 1282 yaw,
1281 1283 light,
1284 rim,
1282 1285 }) {
1283 1286 Ok(image) => {
1284 1287 self.preview2d.diagnostic = None;
@@ -1486,6 +1489,18 @@ impl EditorApp {
1486 1489 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1487 1490 });
1488 1491 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1492 ui.separator();
1493 ui.label("Randlicht (preview)");
1494 let mut rim = self.preview2d.rim_intensity;
1495 let rim_slider =
1496 ui.add(egui::Slider::new(&mut rim, 0.0..=MAX_RIM_INTENSITY).text("sterkte"));
1497 if rim_slider.changed() {
1498 self.preview2d.set_rim_intensity(rim);
1499 need_rebuild = true;
1500 }
1501 ui.weak(
1502 "Fresnel + diepterand; 0 = alleen relight. Wijzigt geen opgeslagen diffuse.",
1503 );
1489 1504 });
1490 1505
1491 1506 if go_back {
@@ -1554,9 +1569,10 @@ impl EditorApp {
1554 1569 self.preview2d.yaw_deg(),
1555 1570 self.preview2d.selected_equipment.len() as u32,
1556 1571 format!(
1557 "{:.1}/{:.1}/{}",
1572 "{:.1}/{:.1}/{:.3}/{}",
1558 1573 self.preview2d.light_azimuth_deg,
1559 1574 self.preview2d.light_elevation_deg,
1575 self.preview2d.rim_intensity,
1560 1576 self.preview2d.sex_token()
1561 1577 ),
1562 1578 );
@@ -4,6 +4,7 @@ use std::path::PathBuf;
4
4
5use eframe::egui;
5use eframe::egui;
6
6
7use crate::compositor::MAX_RIM_INTENSITY;
7use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8use crate::import::{import_project_model, ImportedModel};
9use crate::import::{import_project_model, ImportedModel};
9use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
@@ -1265,6 +1266,7 @@ impl EditorApp {
1265 let pitch = self.preview2d.pitch_deg();
1266 let pitch = self.preview2d.pitch_deg();
1266 let yaw = self.preview2d.yaw_deg();
1267 let yaw = self.preview2d.yaw_deg();
1267 let light = self.preview2d.light();
1268 let light = self.preview2d.light();
1269 let rim = self.preview2d.rim();
1268 if clip.is_empty() {
1270 if clip.is_empty() {
1269 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1271 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1270 self.preview2d_image = None;
1272 self.preview2d_image = None;
@@ -1279,6 +1281,7 @@ impl EditorApp {
1279 pitch,
1281 pitch,
1280 yaw,
1282 yaw,
1281 light,
1283 light,
1284 rim,
1282 }) {
1285 }) {
1283 Ok(image) => {
1286 Ok(image) => {
1284 self.preview2d.diagnostic = None;
1287 self.preview2d.diagnostic = None;
@@ -1486,6 +1489,18 @@ impl EditorApp {
1486 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1489 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1487 });
1490 });
1488 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1491 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1492 ui.separator();
1493 ui.label("Randlicht (preview)");
1494 let mut rim = self.preview2d.rim_intensity;
1495 let rim_slider =
1496 ui.add(egui::Slider::new(&mut rim, 0.0..=MAX_RIM_INTENSITY).text("sterkte"));
1497 if rim_slider.changed() {
1498 self.preview2d.set_rim_intensity(rim);
1499 need_rebuild = true;
1500 }
1501 ui.weak(
1502 "Fresnel + diepterand; 0 = alleen relight. Wijzigt geen opgeslagen diffuse.",
1503 );
1489 });
1504 });
1490
1505
1491 if go_back {
1506 if go_back {
@@ -1554,9 +1569,10 @@ impl EditorApp {
1554 self.preview2d.yaw_deg(),
1569 self.preview2d.yaw_deg(),
1555 self.preview2d.selected_equipment.len() as u32,
1570 self.preview2d.selected_equipment.len() as u32,
1556 format!(
1571 format!(
1557 "{:.1}/{:.1}/{}",
1572 "{:.1}/{:.1}/{:.3}/{}",
1558 self.preview2d.light_azimuth_deg,
1573 self.preview2d.light_azimuth_deg,
1559 self.preview2d.light_elevation_deg,
1574 self.preview2d.light_elevation_deg,
1575 self.preview2d.rim_intensity,
1560 self.preview2d.sex_token()
1576 self.preview2d.sex_token()
1561 ),
1577 ),
1562 );
1578 );
src/lib.rs +1 −1
@@ -3,7 +3,7 @@
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; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback.
6 //! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback.
7 7
8 8 pub mod camera;
9 9 pub mod compositor;
@@ -3,7 +3,7 @@
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4//! draws skinned body/equipment previews, and streams production target maps.
4//! draws skinned body/equipment previews, and streams production target maps.
5//! Completed export runs are opened through `export_set` with checksum/identity
5//! Completed export runs are opened through `export_set` with checksum/identity
6//! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback.
6//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback.
7
7
8pub mod camera;
8pub mod camera;
9pub mod compositor;
9pub mod compositor;
src/preview2d.rs +90 −4
@@ -1,14 +1,15 @@
1 1 //! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2 2 //!
3 3 //! Consumes completed schema-3 export runs through `export_set` + `compositor`.
4 //! Saved production diffuse stays unlit albedo; lighting is preview-only.
4 //! Saved production diffuse stays unlit albedo; lighting and rim are preview-only.
5 5
6 6 use std::collections::BTreeMap;
7 7 use std::fs;
8 8 use std::path::{Path, PathBuf};
9 9
10 10 use crate::compositor::{
11 assert_compatible, compose_and_relight, CompatKey, ComposeError, LayerMaps, Light,
11 assert_compatible, compose_relight_and_rim, CompatKey, ComposeError, LayerMaps, Light,
12 RimParams, DEFAULT_RIM_INTENSITY, MAX_RIM_INTENSITY,
12 13 };
13 14 use crate::export_set::{ExportError, ExportRun};
14 15 use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
@@ -192,6 +193,8 @@ pub struct Preview2dState {
192 193 pub light_elevation_deg: f32,
193 194 pub hold_azimuth: Option<HoldSign>,
194 195 pub hold_elevation: Option<HoldSign>,
196 /// Consumer-side rim intensity in `[0, MAX_RIM_INTENSITY]` (preview only).
197 pub rim_intensity: f32,
195 198 pub diagnostic: Option<String>,
196 199 }
197 200
@@ -213,6 +216,7 @@ impl Default for Preview2dState {
213 216 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
214 217 hold_azimuth: None,
215 218 hold_elevation: None,
219 rim_intensity: DEFAULT_RIM_INTENSITY,
216 220 diagnostic: None,
217 221 }
218 222 }
@@ -302,6 +306,25 @@ impl Preview2dState {
302 306 }
303 307 }
304 308
309 pub fn rim(&self) -> RimParams {
310 RimParams {
311 intensity: if self.rim_intensity.is_finite() {
312 self.rim_intensity.clamp(0.0, MAX_RIM_INTENSITY)
313 } else {
314 0.0
315 },
316 ..RimParams::default()
317 }
318 }
319
320 pub fn set_rim_intensity(&mut self, value: f32) {
321 self.rim_intensity = if value.is_finite() {
322 value.clamp(0.0, MAX_RIM_INTENSITY)
323 } else {
324 0.0
325 };
326 }
327
305 328 /// Advance playback and held light controls by `dt` seconds.
306 329 pub fn tick(&mut self, dt: f32) {
307 330 let dt = if dt.is_finite() && dt > 0.0 {
@@ -369,6 +392,7 @@ pub struct ComposeRequest<'a> {
369 392 pub pitch: i32,
370 393 pub yaw: u32,
371 394 pub light: Light,
395 pub rim: RimParams,
372 396 }
373 397
374 398 #[derive(Debug)]
@@ -421,7 +445,7 @@ impl From<ExportError> for Preview2dError {
421 445 }
422 446 }
423 447
424 /// Compose + relight one view. Missing maps become an explicit error (no silent substitute).
448 /// Compose + relight + rim one view. Missing maps become an explicit error (no silent substitute).
425 449 pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
426 450 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
427 451 runs.push(req.body);
@@ -468,7 +492,7 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe
468 492 }
469 493 }
470 494 }
471 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
495 let (_composed, lit) = compose_relight_and_rim(&layers, &req.light, &req.rim)?;
472 496 Ok(lit)
473 497 }
474 498
@@ -915,6 +939,7 @@ mod tests {
915 939 pitch: 0,
916 940 yaw: 0,
917 941 light: Light::default(),
942 rim: RimParams::off(),
918 943 })
919 944 .unwrap();
920 945 assert_eq!(ok.width, 1);
@@ -926,6 +951,7 @@ mod tests {
926 951 pitch: 0,
927 952 yaw: 0,
928 953 light: Light::default(),
954 rim: RimParams::off(),
929 955 })
930 956 .unwrap_err();
931 957 match err {
@@ -1037,6 +1063,7 @@ mod tests {
1037 1063 pitch: 0,
1038 1064 yaw: 0,
1039 1065 light: Light::default(),
1066 rim: RimParams::default(),
1040 1067 })
1041 1068 .unwrap();
1042 1069 assert_eq!(lit.rgba[3], 255);
@@ -1044,6 +1071,65 @@ mod tests {
1044 1071 assert!(lit.rgba[0] > 100);
1045 1072 }
1046 1073
1074 #[test]
1075 fn zero_rim_matches_relight_only_within_preview_path() {
1076 let body_tmp = Temp::new();
1077 let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
1078 let mut checksums = Map::new();
1079 checksums.extend(publish_pose(
1080 &body_tmp.0,
1081 "male",
1082 "body",
1083 "clip000-bind",
1084 0,
1085 0,
1086 0,
1087 [90, 40, 40, 255],
1088 [254, 128, 142, 255], // near-unit grazing (~0.9,0,0.1)
1089 [0, 0, 0, 255],
1090 18000,
1091 ));
1092 write_run(
1093 &body_tmp.0,
1094 "demo",
1095 "male",
1096 "body",
1097 None,
1098 b"shared-body",
1099 &clips,
1100 checksums,
1101 );
1102 let run = ExportRun::open(&body_tmp.0).unwrap();
1103 let off = compose_preview_frame(ComposeRequest {
1104 body: &run,
1105 equipment: &[],
1106 clip_token: "clip000-bind",
1107 frame: 0,
1108 pitch: 0,
1109 yaw: 0,
1110 light: Light::default(),
1111 rim: RimParams::off(),
1112 })
1113 .unwrap();
1114 let on = compose_preview_frame(ComposeRequest {
1115 body: &run,
1116 equipment: &[],
1117 clip_token: "clip000-bind",
1118 frame: 0,
1119 pitch: 0,
1120 yaw: 0,
1121 light: Light::default(),
1122 rim: RimParams::default(),
1123 })
1124 .unwrap();
1125 assert_eq!(off.rgba[3], 255);
1126 assert_eq!(on.rgba[3], off.rgba[3]);
1127 // Default rim must not darken; grazing normal should gain some highlight.
1128 let off_l = off.rgba[0] as u32 + off.rgba[1] as u32 + off.rgba[2] as u32;
1129 let on_l = on.rgba[0] as u32 + on.rgba[1] as u32 + on.rgba[2] as u32;
1130 assert!(on_l >= off_l);
1131 }
1132
1047 1133 #[test]
1048 1134 fn discover_skips_incomplete_and_wrong_schema() {
1049 1135 let project = Temp::new();
@@ -1,14 +1,15 @@
1//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
1//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2//!
2//!
3//! Consumes completed schema-3 export runs through `export_set` + `compositor`.
3//! Consumes completed schema-3 export runs through `export_set` + `compositor`.
4//! Saved production diffuse stays unlit albedo; lighting is preview-only.
4//! Saved production diffuse stays unlit albedo; lighting and rim are preview-only.
5
5
6use std::collections::BTreeMap;
6use std::collections::BTreeMap;
7use std::fs;
7use std::fs;
8use std::path::{Path, PathBuf};
8use std::path::{Path, PathBuf};
9
9
10use crate::compositor::{
10use crate::compositor::{
11 assert_compatible, compose_and_relight, CompatKey, ComposeError, LayerMaps, Light,
11 assert_compatible, compose_relight_and_rim, CompatKey, ComposeError, LayerMaps, Light,
12 RimParams, DEFAULT_RIM_INTENSITY, MAX_RIM_INTENSITY,
12};
13};
13use crate::export_set::{ExportError, ExportRun};
14use crate::export_set::{ExportError, ExportRun};
14use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
15use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
@@ -192,6 +193,8 @@ pub struct Preview2dState {
192 pub light_elevation_deg: f32,
193 pub light_elevation_deg: f32,
193 pub hold_azimuth: Option<HoldSign>,
194 pub hold_azimuth: Option<HoldSign>,
194 pub hold_elevation: Option<HoldSign>,
195 pub hold_elevation: Option<HoldSign>,
196 /// Consumer-side rim intensity in `[0, MAX_RIM_INTENSITY]` (preview only).
197 pub rim_intensity: f32,
195 pub diagnostic: Option<String>,
198 pub diagnostic: Option<String>,
196}
199}
197
200
@@ -213,6 +216,7 @@ impl Default for Preview2dState {
213 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
216 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
214 hold_azimuth: None,
217 hold_azimuth: None,
215 hold_elevation: None,
218 hold_elevation: None,
219 rim_intensity: DEFAULT_RIM_INTENSITY,
216 diagnostic: None,
220 diagnostic: None,
217 }
221 }
218 }
222 }
@@ -302,6 +306,25 @@ impl Preview2dState {
302 }
306 }
303 }
307 }
304
308
309 pub fn rim(&self) -> RimParams {
310 RimParams {
311 intensity: if self.rim_intensity.is_finite() {
312 self.rim_intensity.clamp(0.0, MAX_RIM_INTENSITY)
313 } else {
314 0.0
315 },
316 ..RimParams::default()
317 }
318 }
319
320 pub fn set_rim_intensity(&mut self, value: f32) {
321 self.rim_intensity = if value.is_finite() {
322 value.clamp(0.0, MAX_RIM_INTENSITY)
323 } else {
324 0.0
325 };
326 }
327
305 /// Advance playback and held light controls by `dt` seconds.
328 /// Advance playback and held light controls by `dt` seconds.
306 pub fn tick(&mut self, dt: f32) {
329 pub fn tick(&mut self, dt: f32) {
307 let dt = if dt.is_finite() && dt > 0.0 {
330 let dt = if dt.is_finite() && dt > 0.0 {
@@ -369,6 +392,7 @@ pub struct ComposeRequest<'a> {
369 pub pitch: i32,
392 pub pitch: i32,
370 pub yaw: u32,
393 pub yaw: u32,
371 pub light: Light,
394 pub light: Light,
395 pub rim: RimParams,
372}
396}
373
397
374#[derive(Debug)]
398#[derive(Debug)]
@@ -421,7 +445,7 @@ impl From<ExportError> for Preview2dError {
421 }
445 }
422}
446}
423
447
424/// Compose + relight one view. Missing maps become an explicit error (no silent substitute).
448/// Compose + relight + rim one view. Missing maps become an explicit error (no silent substitute).
425pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
449pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
426 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
450 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
427 runs.push(req.body);
451 runs.push(req.body);
@@ -468,7 +492,7 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe
468 }
492 }
469 }
493 }
470 }
494 }
471 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
495 let (_composed, lit) = compose_relight_and_rim(&layers, &req.light, &req.rim)?;
472 Ok(lit)
496 Ok(lit)
473}
497}
474
498
@@ -915,6 +939,7 @@ mod tests {
915 pitch: 0,
939 pitch: 0,
916 yaw: 0,
940 yaw: 0,
917 light: Light::default(),
941 light: Light::default(),
942 rim: RimParams::off(),
918 })
943 })
919 .unwrap();
944 .unwrap();
920 assert_eq!(ok.width, 1);
945 assert_eq!(ok.width, 1);
@@ -926,6 +951,7 @@ mod tests {
926 pitch: 0,
951 pitch: 0,
927 yaw: 0,
952 yaw: 0,
928 light: Light::default(),
953 light: Light::default(),
954 rim: RimParams::off(),
929 })
955 })
930 .unwrap_err();
956 .unwrap_err();
931 match err {
957 match err {
@@ -1037,6 +1063,7 @@ mod tests {
1037 pitch: 0,
1063 pitch: 0,
1038 yaw: 0,
1064 yaw: 0,
1039 light: Light::default(),
1065 light: Light::default(),
1066 rim: RimParams::default(),
1040 })
1067 })
1041 .unwrap();
1068 .unwrap();
1042 assert_eq!(lit.rgba[3], 255);
1069 assert_eq!(lit.rgba[3], 255);
@@ -1044,6 +1071,65 @@ mod tests {
1044 assert!(lit.rgba[0] > 100);
1071 assert!(lit.rgba[0] > 100);
1045 }
1072 }
1046
1073
1074 #[test]
1075 fn zero_rim_matches_relight_only_within_preview_path() {
1076 let body_tmp = Temp::new();
1077 let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
1078 let mut checksums = Map::new();
1079 checksums.extend(publish_pose(
1080 &body_tmp.0,
1081 "male",
1082 "body",
1083 "clip000-bind",
1084 0,
1085 0,
1086 0,
1087 [90, 40, 40, 255],
1088 [254, 128, 142, 255], // near-unit grazing (~0.9,0,0.1)
1089 [0, 0, 0, 255],
1090 18000,
1091 ));
1092 write_run(
1093 &body_tmp.0,
1094 "demo",
1095 "male",
1096 "body",
1097 None,
1098 b"shared-body",
1099 &clips,
1100 checksums,
1101 );
1102 let run = ExportRun::open(&body_tmp.0).unwrap();
1103 let off = compose_preview_frame(ComposeRequest {
1104 body: &run,
1105 equipment: &[],
1106 clip_token: "clip000-bind",
1107 frame: 0,
1108 pitch: 0,
1109 yaw: 0,
1110 light: Light::default(),
1111 rim: RimParams::off(),
1112 })
1113 .unwrap();
1114 let on = compose_preview_frame(ComposeRequest {
1115 body: &run,
1116 equipment: &[],
1117 clip_token: "clip000-bind",
1118 frame: 0,
1119 pitch: 0,
1120 yaw: 0,
1121 light: Light::default(),
1122 rim: RimParams::default(),
1123 })
1124 .unwrap();
1125 assert_eq!(off.rgba[3], 255);
1126 assert_eq!(on.rgba[3], off.rgba[3]);
1127 // Default rim must not darken; grazing normal should gain some highlight.
1128 let off_l = off.rgba[0] as u32 + off.rgba[1] as u32 + off.rgba[2] as u32;
1129 let on_l = on.rgba[0] as u32 + on.rgba[1] as u32 + on.rgba[2] as u32;
1130 assert!(on_l >= off_l);
1131 }
1132
1047 #[test]
1133 #[test]
1048 fn discover_skips_incomplete_and_wrong_schema() {
1134 fn discover_skips_incomplete_and_wrong_schema() {
1049 let project = Temp::new();
1135 let project = Temp::new();