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.
@@ -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 compositor37
- 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
```bash43
```bash@@ -84,10 +85,12 @@ Ieder nieuw renderverzoek krijgt een eigen runmap; bestaande uitvoer wordt niet84
85
85
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
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
```rust90
```rust90
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
use paperdoll_renderer::export_set::ExportRun;94
use paperdoll_renderer::export_set::ExportRun;92
95
93
let body = ExportRun::open(&body_run_dir)?;96
let 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
];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
@@ -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:146
5. **Normal:** write/publish target normal.146
5. **Normal:** write/publish target normal.147
6. **Specular:** write/publish target scalar map, including body zero.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
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.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` 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 **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
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.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
## 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 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
## 12. Incremental feature plan and acceptance gates222
## 12. Incremental feature plan and acceptance gates223
223
@@ -227,8 +227,8 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p227
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.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
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.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
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.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.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 and continuous held light controls; deterministic state tests and GUI smoke.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
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.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
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.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.@@ -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.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 albedo45
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
writer131
.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 note136
println!("wrote comparison PNGs under {}", out.display());137
Ok(())138
}@@ -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 applies4
//! 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 maps41
//! direction is already in **view space**, matching the exported normal maps42
//! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the42
//! (`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.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 an48
//! optional depth-discontinuity term. Intensity is clamped to49
//! [`MAX_RIM_INTENSITY`]. Straight alpha/coverage is preserved byte-for-byte.50
//! Zero intensity returns the relit image unchanged. Production diffuse PNGs51
//! are never modified.44
use crate::{52
use 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).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.0522
},523
power: if self.power.is_finite() {524
self.power.max(1.0)525
} else {526
DEFAULT_RIM_POWER527
},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.0532
},533
depth_edge_scale: if self.depth_edge_scale.is_finite() && self.depth_edge_scale > 1.0 {534
self.depth_edge_scale535
} else {536
DEFAULT_DEPTH_EDGE_SCALE537
},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 != width559
|| lit.height != height560
|| lit.rgba.len() != pixels * 4561
|| composed.diffuse.len() != pixels * 4562
|| composed.normal.len() != pixels * 4563
|| composed.depth.len() != pixels * 2564
|| composed.winner.len() != pixels565
{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.0624
} 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
fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {668
fn 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 u321287
}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×31348
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 only1453
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×31457
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
}@@ -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
5
use eframe::egui;5
use eframe::egui;6
6
7
use crate::compositor::MAX_RIM_INTENSITY;7
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};8
use crate::import::{import_project_model, ImportedModel};9
use crate::import::{import_project_model, ImportedModel};9
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};10
use 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
);@@ -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/identity5
//! Completed export runs are opened through `export_set` with checksum/identity6
//! 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
pub mod camera;8
pub mod camera;9
pub mod compositor;9
pub mod compositor;@@ -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
6
use std::collections::BTreeMap;6
use std::collections::BTreeMap;7
use std::fs;7
use std::fs;8
use std::path::{Path, PathBuf};8
use std::path::{Path, PathBuf};9
9
10
use crate::compositor::{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
use crate::export_set::{ExportError, ExportRun};14
use crate::export_set::{ExportError, ExportRun};14
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};15
use 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.0315
},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.0325
};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).425
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {449
pub 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, Previe468
}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();