BlueGit

golden-age paperdoll-renderer

Preview2d: view modes, full-circle light, remove rim, fix loop

Implement design t958u/t960u/t961u together:
- Remove consumer rim/edge lighting (RimParams/apply_rim/Randlicht).
- Relight is ambient + Lambert + Blinn-Phong only.
- Azimuth wraps continuously modulo 360; elevation stays −80…+80.
- View modes Resultaat / Alleen diffuse/normal/depth/specular.
- Playback loops on frames/fps so every frame lasts 1/fps.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
2397e3c5c9bb26ca4b953a4a4ab048213f996be5
Parent
f5d2567b
7 files changed +317 −687
README.md +6 −5
@@ -47,8 +47,9 @@ Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor
47 47 - Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
48 48 - 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.
49 49 - **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
50 - 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.
51 - **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.
50 - Kies weergavestand **Resultaat** (standaard; belichte compositie), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** of **Alleen specular map**. Mapstanden tonen de diepte-gecompositeerde winnende laag onbelicht (depth als grijswaarde, normal als opgeslagen RGB). Playback, hoek en equipment werken in elke stand; licht alleen in Resultaat.
51 - Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); azimut wrapt over 360° zonder klem, elevatie blijft −80…+80. Loslaten, focusverlies of pointer-annulering stopt de beweging. Belichting is alleen voor Resultaat; opgeslagen diffuse blijft unlit albedo.
52 - Playback loopt over exact `frames / fps` seconden, zodat ieder geëxporteerd frame (inclusief het laatste) even lang duurt (`1/fps`).
52 53 - Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
53 54
54 55 ```bash
@@ -107,11 +108,11 @@ Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaange
107 108
108 109 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).
109 110
110 `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.
111 `relight` / `compose_and_relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. Er is geen randlicht: de preview toont alleen deze relight in stand Resultaat. Opgeslagen diffuse blijft elders onbelichte albedo.
111 112
112 113 ```rust
113 114 use paperdoll_renderer::compositor::{
114 assert_compatible, compose_relight_and_rim, LayerMaps, Light, RimParams,
115 assert_compatible, compose_and_relight, LayerMaps, Light,
115 116 };
116 117 use paperdoll_renderer::export_set::ExportRun;
117 118
@@ -122,7 +123,7 @@ let layers = [
122 123 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
123 124 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
124 125 ];
125 let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimParams::default())?;
126 let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
126 127 ```
127 128
128 129
@@ -47,8 +47,9 @@ Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor
47- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
47- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
48- 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.
48- 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.
49- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
49- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
50- 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.
50- Kies weergavestand **Resultaat** (standaard; belichte compositie), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** of **Alleen specular map**. Mapstanden tonen de diepte-gecompositeerde winnende laag onbelicht (depth als grijswaarde, normal als opgeslagen RGB). Playback, hoek en equipment werken in elke stand; licht alleen in Resultaat.
51- **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.
51- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); azimut wrapt over 360° zonder klem, elevatie blijft −80…+80. Loslaten, focusverlies of pointer-annulering stopt de beweging. Belichting is alleen voor Resultaat; opgeslagen diffuse blijft unlit albedo.
52- Playback loopt over exact `frames / fps` seconden, zodat ieder geëxporteerd frame (inclusief het laatste) even lang duurt (`1/fps`).
52- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
53- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
53
54
54```bash
55```bash
@@ -107,11 +108,11 @@ Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaange
107
108
108De 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).
109De 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).
109
110
110`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.
111`relight` / `compose_and_relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. Er is geen randlicht: de preview toont alleen deze relight in stand Resultaat. Opgeslagen diffuse blijft elders onbelichte albedo.
111
112
112```rust
113```rust
113use paperdoll_renderer::compositor::{
114use paperdoll_renderer::compositor::{
114 assert_compatible, compose_relight_and_rim, LayerMaps, Light, RimParams,
115 assert_compatible, compose_and_relight, LayerMaps, Light,
115};
116};
116use paperdoll_renderer::export_set::ExportRun;
117use paperdoll_renderer::export_set::ExportRun;
117
118
@@ -122,7 +123,7 @@ let layers = [
122 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
123 LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
123 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
124 LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
124];
125];
125let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimParams::default())?;
126let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
126```
127```
127
128
128
129
design.md +6 −6
@@ -150,7 +150,7 @@ Per pose/view, the actual completed-preview order is:
150 150 5. **Normal:** write/publish target normal.
151 151 6. **Specular:** write/publish target scalar map, including body zero.
152 152
153 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.
153 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. Preview lighting is consumer-side relight only (no rim); never baked into pure diffuse albedo.
154 154
155 155 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 bodies use the normalized drawable/animated scene (`fbx_scene`) in both the live and production GPU paths, with the same draw/material rules (first skinned mesh, per material part surfaces, PNG albedo).
156 156
@@ -184,11 +184,11 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
184 184
185 185 ## 10. Rendered 2D preview
186 186
187 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.
187 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 over exactly `frames/fps` seconds so every frame lasts `1/fps`; 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 frame with aspect-preserving nearest-neighbor letterboxing. View modes: **Resultaat** (relit composite), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map**, **Alleen specular map** (map modes show the depth-composited winning-layer map unlit). Light azimuth wraps full 360°; elevation stays −80…+80; light affects Resultaat only. Rim/edge lighting is removed (t958u). The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
188 188
189 189 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.
190 190
191 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.
191 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` / `compose_and_relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). Rim/edge lighting was removed (Tim t958u). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u).
192 192
193 193 ## 11. Development workflow, quality and packaging
194 194
@@ -221,7 +221,7 @@ These requirements are durable even where the implementation is staged. They sup
221 221 - 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.
222 222 - 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.
223 223
224 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, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
224 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 (view modes Resultaat/map-only, full-circle light azimuth, loop on frames/fps; rim removed per t958u) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
225 225
226 226 ## 12. Incremental feature plan and acceptance gates
227 227
@@ -231,8 +231,8 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
231 231 3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
232 232 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.
233 233 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.
234 6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)
235 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.
234 6. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (No rim accents; preview uses relight only — t958u.)
235 7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping on frames/fps, sex/equipment/clip selection, view modes (Resultaat + map-only), discrete view controls, continuous held light controls with full-circle azimuth; deterministic state tests and GUI smoke.
236 236 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.
237 237
238 238 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.
@@ -150,7 +150,7 @@ Per pose/view, the actual completed-preview order is:
1505. **Normal:** write/publish target normal.
1505. **Normal:** write/publish target normal.
1516. **Specular:** write/publish target scalar map, including body zero.
1516. **Specular:** write/publish target scalar map, including body zero.
152
152
153Every 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.
153Every 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. Preview lighting is consumer-side relight only (no rim); never baked into pure diffuse albedo.
154
154
155Current 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 bodies use the normalized drawable/animated scene (`fbx_scene`) in both the live and production GPU paths, with the same draw/material rules (first skinned mesh, per material part surfaces, PNG albedo).
155Current 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 bodies use the normalized drawable/animated scene (`fbx_scene`) in both the live and production GPU paths, with the same draw/material rules (first skinned mesh, per material part surfaces, PNG albedo).
156
156
@@ -184,11 +184,11 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
184
184
185## 10. Rendered 2D preview
185## 10. Rendered 2D preview
186
186
187Select 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.
187Select 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 over exactly `frames/fps` seconds so every frame lasts `1/fps`; 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 frame with aspect-preserving nearest-neighbor letterboxing. View modes: **Resultaat** (relit composite), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map**, **Alleen specular map** (map modes show the depth-composited winning-layer map unlit). Light azimuth wraps full 360°; elevation stays −80…+80; light affects Resultaat only. Rim/edge lighting is removed (t958u). The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
188
188
189Left/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.
189Left/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.
190
190
191Depth 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.
191Depth 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` / `compose_and_relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). Rim/edge lighting was removed (Tim t958u). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u).
192
192
193## 11. Development workflow, quality and packaging
193## 11. Development workflow, quality and packaging
194
194
@@ -221,7 +221,7 @@ These requirements are durable even where the implementation is staged. They sup
221- 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.
221- 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.
222- 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.
222- 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.
223
223
224Current 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, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
224Current 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 (view modes Resultaat/map-only, full-circle light azimuth, loop on frames/fps; rim removed per t958u) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
225
225
226## 12. Incremental feature plan and acceptance gates
226## 12. Incremental feature plan and acceptance gates
227
227
@@ -231,8 +231,8 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
2313. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2313. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2324. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2324. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2335. 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.
2335. 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.
2346. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.)
2346. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (No rim accents; preview uses relight only — t958u.)
2357. 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.
2357. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping on frames/fps, sex/equipment/clip selection, view modes (Resultaat + map-only), discrete view controls, continuous held light controls with full-circle azimuth; deterministic state tests and GUI smoke.
2368. 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.
2368. 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.
237
237
238Every 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.
238Every 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 deleted +0 −138
@@ -1,138 +0,0 @@
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 }
@@ -1,138 +0,0 @@
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 +2 −435
@@ -1,4 +1,4 @@
1 //! Deterministic 2D depth compositor, linear relighting and subtle rim/edge lighting.
1 //! Deterministic 2D depth compositor and linear relighting.
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,14 +41,7 @@
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 45 use crate::{
53 46 export_set::{ExportError, ExportRun, MapImage, MapKind},
54 47 render_job::ImageBuf,
@@ -464,207 +457,6 @@ pub fn compose_and_relight(
464 457 Ok((composed, lit))
465 458 }
466 459
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
668 460 fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
669 461 normalize([
670 462 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
@@ -1266,229 +1058,4 @@ mod tests {
1266 1058 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1267 1059 assert!(matches!(err, ComposeError::Incompatible(_)));
1268 1060 }
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 }
1494 1061 }
@@ -1,4 +1,4 @@
1//! Deterministic 2D depth compositor, linear relighting and subtle rim/edge lighting.
1//! Deterministic 2D depth compositor and linear relighting.
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,14 +41,7 @@
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//!
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.
52use crate::{
45use crate::{
53 export_set::{ExportError, ExportRun, MapImage, MapKind},
46 export_set::{ExportError, ExportRun, MapImage, MapKind},
54 render_job::ImageBuf,
47 render_job::ImageBuf,
@@ -464,207 +457,6 @@ pub fn compose_and_relight(
464 Ok((composed, lit))
457 Ok((composed, lit))
465}
458}
466
459
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
668fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
460fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {
669 normalize([
461 normalize([
670 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
462 rgb[0] as f32 / 255.0 * 2.0 - 1.0,
@@ -1266,229 +1058,4 @@ mod tests {
1266 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1058 let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
1267 assert!(matches!(err, ComposeError::Incompatible(_)));
1059 assert!(matches!(err, ComposeError::Incompatible(_)));
1268 }
1060 }
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 }
1494}
1061}
src/editor.rs +69 −51
@@ -4,7 +4,6 @@ use std::path::PathBuf;
4 4
5 5 use eframe::egui;
6 6
7 use crate::compositor::MAX_RIM_INTENSITY;
8 7 use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9 8 use crate::import::{import_project_model, ImportedModel};
10 9 use crate::preview::{
@@ -13,6 +12,7 @@ use crate::preview::{
13 12 use crate::preview2d::{
14 13 compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
15 14 sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
15 ViewMode,
16 16 };
17 17 use crate::preview_gl::Viewport;
18 18 use crate::project::open_project;
@@ -1405,7 +1405,7 @@ impl EditorApp {
1405 1405 let pitch = self.preview2d.pitch_deg();
1406 1406 let yaw = self.preview2d.yaw_deg();
1407 1407 let light = self.preview2d.light();
1408 let rim = self.preview2d.rim();
1408 let view_mode = self.preview2d.view_mode;
1409 1409 if clip.is_empty() {
1410 1410 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1411 1411 self.preview2d_image = None;
@@ -1420,7 +1420,7 @@ impl EditorApp {
1420 1420 pitch,
1421 1421 yaw,
1422 1422 light,
1423 rim,
1423 view_mode,
1424 1424 }) {
1425 1425 Ok(image) => {
1426 1426 self.preview2d.diagnostic = None;
@@ -1471,6 +1471,7 @@ impl EditorApp {
1471 1471 let mut hold_el = None;
1472 1472 let mut release_az = false;
1473 1473 let mut release_el = false;
1474 let mut set_view_mode = None;
1474 1475
1475 1476 egui::SidePanel::left("preview2d_controls")
1476 1477 .default_width(330.0)
@@ -1576,6 +1577,17 @@ impl EditorApp {
1576 1577 ui.label(format!("export-fps {}", self.preview2d.fps));
1577 1578 });
1578 1579 ui.separator();
1580 ui.label("Weergave");
1581 for mode in ViewMode::all() {
1582 if ui
1583 .selectable_label(self.preview2d.view_mode == mode, mode.label_nl())
1584 .clicked()
1585 {
1586 set_view_mode = Some(mode);
1587 }
1588 }
1589 ui.weak("Resultaat = belichte compositie; andere standen tonen de ongebelichte map.");
1590 ui.separator();
1579 1591 ui.label("Hoek (export)");
1580 1592 ui.horizontal(|ui| {
1581 1593 if ui.button("Yaw −").clicked() {
@@ -1596,50 +1608,46 @@ impl EditorApp {
1596 1608 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1597 1609 });
1598 1610 ui.separator();
1599 ui.label("Licht (vasthouden; °/s)");
1600 ui.horizontal(|ui| {
1601 let west = ui.button("Azimut −");
1602 let east = ui.button("Azimut +");
1603 if west.is_pointer_button_down_on() {
1604 hold_az = Some(HoldSign::Negative);
1605 } else if east.is_pointer_button_down_on() {
1606 hold_az = Some(HoldSign::Positive);
1607 } else if self.preview2d.hold_azimuth.is_some()
1608 && !west.is_pointer_button_down_on()
1609 && !east.is_pointer_button_down_on()
1610 {
1611 release_az = true;
1612 }
1613 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1614 });
1615 ui.horizontal(|ui| {
1616 let down = ui.button("Elevatie −");
1617 let up = ui.button("Elevatie +");
1618 if down.is_pointer_button_down_on() {
1619 hold_el = Some(HoldSign::Negative);
1620 } else if up.is_pointer_button_down_on() {
1621 hold_el = Some(HoldSign::Positive);
1622 } else if self.preview2d.hold_elevation.is_some()
1623 && !down.is_pointer_button_down_on()
1624 && !up.is_pointer_button_down_on()
1625 {
1626 release_el = true;
1627 }
1628 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1611 ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| {
1612 ui.label("Licht (vasthouden; °/s — alleen Resultaat)");
1613 ui.horizontal(|ui| {
1614 let west = ui.button("Azimut −");
1615 let east = ui.button("Azimut +");
1616 if self.preview2d.view_mode.uses_light() {
1617 if west.is_pointer_button_down_on() {
1618 hold_az = Some(HoldSign::Negative);
1619 } else if east.is_pointer_button_down_on() {
1620 hold_az = Some(HoldSign::Positive);
1621 } else if self.preview2d.hold_azimuth.is_some()
1622 && !west.is_pointer_button_down_on()
1623 && !east.is_pointer_button_down_on()
1624 {
1625 release_az = true;
1626 }
1627 }
1628 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1629 });
1630 ui.horizontal(|ui| {
1631 let down = ui.button("Elevatie −");
1632 let up = ui.button("Elevatie +");
1633 if self.preview2d.view_mode.uses_light() {
1634 if down.is_pointer_button_down_on() {
1635 hold_el = Some(HoldSign::Negative);
1636 } else if up.is_pointer_button_down_on() {
1637 hold_el = Some(HoldSign::Positive);
1638 } else if self.preview2d.hold_elevation.is_some()
1639 && !down.is_pointer_button_down_on()
1640 && !up.is_pointer_button_down_on()
1641 {
1642 release_el = true;
1643 }
1644 }
1645 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1646 });
1647 ui.weak(
1648 "Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.",
1649 );
1629 1650 });
1630 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1631 ui.separator();
1632 ui.label("Randlicht (preview)");
1633 let mut rim = self.preview2d.rim_intensity;
1634 let rim_slider =
1635 ui.add(egui::Slider::new(&mut rim, 0.0..=MAX_RIM_INTENSITY).text("sterkte"));
1636 if rim_slider.changed() {
1637 self.preview2d.set_rim_intensity(rim);
1638 need_rebuild = true;
1639 }
1640 ui.weak(
1641 "Fresnel + diepterand; 0 = alleen relight. Wijzigt geen opgeslagen diffuse.",
1642 );
1643 1651 });
1644 1652
1645 1653 if go_back {
@@ -1674,6 +1682,15 @@ impl EditorApp {
1674 1682 }
1675 1683 }
1676 1684 }
1685 if let Some(mode) = set_view_mode {
1686 if self.preview2d.view_mode != mode {
1687 self.preview2d.view_mode = mode;
1688 if !mode.uses_light() {
1689 self.preview2d.release_all_holds();
1690 }
1691 need_rebuild = true;
1692 }
1693 }
1677 1694 if step_yaw != 0 {
1678 1695 self.preview2d.step_yaw(step_yaw);
1679 1696 need_rebuild = true;
@@ -1693,10 +1710,11 @@ impl EditorApp {
1693 1710 self.preview2d.release_light(LightAxis::Elevation);
1694 1711 }
1695 1712
1696 // Continuous light or playing changes the image every tick.
1713 // Playing always refreshes; light holds only matter in Resultaat.
1697 1714 if self.preview2d.playing
1698 || self.preview2d.hold_azimuth.is_some()
1699 || self.preview2d.hold_elevation.is_some()
1715 || (self.preview2d.view_mode.uses_light()
1716 && (self.preview2d.hold_azimuth.is_some()
1717 || self.preview2d.hold_elevation.is_some()))
1700 1718 {
1701 1719 need_rebuild = true;
1702 1720 }
@@ -1708,10 +1726,10 @@ impl EditorApp {
1708 1726 self.preview2d.yaw_deg(),
1709 1727 self.preview2d.selected_equipment.len() as u32,
1710 1728 format!(
1711 "{:.1}/{:.1}/{:.3}/{}",
1729 "{:.1}/{:.1}/{:?}/{}",
1712 1730 self.preview2d.light_azimuth_deg,
1713 1731 self.preview2d.light_elevation_deg,
1714 self.preview2d.rim_intensity,
1732 self.preview2d.view_mode,
1715 1733 self.preview2d.sex_token()
1716 1734 ),
1717 1735 );
@@ -4,7 +4,6 @@ use std::path::PathBuf;
4
4
5use eframe::egui;
5use eframe::egui;
6
6
7use crate::compositor::MAX_RIM_INTENSITY;
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
7use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9use crate::import::{import_project_model, ImportedModel};
8use crate::import::{import_project_model, ImportedModel};
10use crate::preview::{
9use crate::preview::{
@@ -13,6 +12,7 @@ use crate::preview::{
13use crate::preview2d::{
12use crate::preview2d::{
14 compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
13 compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
15 sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
14 sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
15 ViewMode,
16};
16};
17use crate::preview_gl::Viewport;
17use crate::preview_gl::Viewport;
18use crate::project::open_project;
18use crate::project::open_project;
@@ -1405,7 +1405,7 @@ impl EditorApp {
1405 let pitch = self.preview2d.pitch_deg();
1405 let pitch = self.preview2d.pitch_deg();
1406 let yaw = self.preview2d.yaw_deg();
1406 let yaw = self.preview2d.yaw_deg();
1407 let light = self.preview2d.light();
1407 let light = self.preview2d.light();
1408 let rim = self.preview2d.rim();
1408 let view_mode = self.preview2d.view_mode;
1409 if clip.is_empty() {
1409 if clip.is_empty() {
1410 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1410 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1411 self.preview2d_image = None;
1411 self.preview2d_image = None;
@@ -1420,7 +1420,7 @@ impl EditorApp {
1420 pitch,
1420 pitch,
1421 yaw,
1421 yaw,
1422 light,
1422 light,
1423 rim,
1423 view_mode,
1424 }) {
1424 }) {
1425 Ok(image) => {
1425 Ok(image) => {
1426 self.preview2d.diagnostic = None;
1426 self.preview2d.diagnostic = None;
@@ -1471,6 +1471,7 @@ impl EditorApp {
1471 let mut hold_el = None;
1471 let mut hold_el = None;
1472 let mut release_az = false;
1472 let mut release_az = false;
1473 let mut release_el = false;
1473 let mut release_el = false;
1474 let mut set_view_mode = None;
1474
1475
1475 egui::SidePanel::left("preview2d_controls")
1476 egui::SidePanel::left("preview2d_controls")
1476 .default_width(330.0)
1477 .default_width(330.0)
@@ -1576,6 +1577,17 @@ impl EditorApp {
1576 ui.label(format!("export-fps {}", self.preview2d.fps));
1577 ui.label(format!("export-fps {}", self.preview2d.fps));
1577 });
1578 });
1578 ui.separator();
1579 ui.separator();
1580 ui.label("Weergave");
1581 for mode in ViewMode::all() {
1582 if ui
1583 .selectable_label(self.preview2d.view_mode == mode, mode.label_nl())
1584 .clicked()
1585 {
1586 set_view_mode = Some(mode);
1587 }
1588 }
1589 ui.weak("Resultaat = belichte compositie; andere standen tonen de ongebelichte map.");
1590 ui.separator();
1579 ui.label("Hoek (export)");
1591 ui.label("Hoek (export)");
1580 ui.horizontal(|ui| {
1592 ui.horizontal(|ui| {
1581 if ui.button("Yaw −").clicked() {
1593 if ui.button("Yaw −").clicked() {
@@ -1596,50 +1608,46 @@ impl EditorApp {
1596 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1608 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1597 });
1609 });
1598 ui.separator();
1610 ui.separator();
1599 ui.label("Licht (vasthouden; °/s)");
1611 ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| {
1600 ui.horizontal(|ui| {
1612 ui.label("Licht (vasthouden; °/s — alleen Resultaat)");
1601 let west = ui.button("Azimut −");
1613 ui.horizontal(|ui| {
1602 let east = ui.button("Azimut +");
1614 let west = ui.button("Azimut −");
1603 if west.is_pointer_button_down_on() {
1615 let east = ui.button("Azimut +");
1604 hold_az = Some(HoldSign::Negative);
1616 if self.preview2d.view_mode.uses_light() {
1605 } else if east.is_pointer_button_down_on() {
1617 if west.is_pointer_button_down_on() {
1606 hold_az = Some(HoldSign::Positive);
1618 hold_az = Some(HoldSign::Negative);
1607 } else if self.preview2d.hold_azimuth.is_some()
1619 } else if east.is_pointer_button_down_on() {
1608 && !west.is_pointer_button_down_on()
1620 hold_az = Some(HoldSign::Positive);
1609 && !east.is_pointer_button_down_on()
1621 } else if self.preview2d.hold_azimuth.is_some()
1610 {
1622 && !west.is_pointer_button_down_on()
1611 release_az = true;
1623 && !east.is_pointer_button_down_on()
1612 }
1624 {
1613 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1625 release_az = true;
1614 });
1626 }
1615 ui.horizontal(|ui| {
1627 }
1616 let down = ui.button("Elevatie −");
1628 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1617 let up = ui.button("Elevatie +");
1629 });
1618 if down.is_pointer_button_down_on() {
1630 ui.horizontal(|ui| {
1619 hold_el = Some(HoldSign::Negative);
1631 let down = ui.button("Elevatie −");
1620 } else if up.is_pointer_button_down_on() {
1632 let up = ui.button("Elevatie +");
1621 hold_el = Some(HoldSign::Positive);
1633 if self.preview2d.view_mode.uses_light() {
1622 } else if self.preview2d.hold_elevation.is_some()
1634 if down.is_pointer_button_down_on() {
1623 && !down.is_pointer_button_down_on()
1635 hold_el = Some(HoldSign::Negative);
1624 && !up.is_pointer_button_down_on()
1636 } else if up.is_pointer_button_down_on() {
1625 {
1637 hold_el = Some(HoldSign::Positive);
1626 release_el = true;
1638 } else if self.preview2d.hold_elevation.is_some()
1627 }
1639 && !down.is_pointer_button_down_on()
1628 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1640 && !up.is_pointer_button_down_on()
1641 {
1642 release_el = true;
1643 }
1644 }
1645 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1646 });
1647 ui.weak(
1648 "Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.",
1649 );
1629 });
1650 });
1630 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1631 ui.separator();
1632 ui.label("Randlicht (preview)");
1633 let mut rim = self.preview2d.rim_intensity;
1634 let rim_slider =
1635 ui.add(egui::Slider::new(&mut rim, 0.0..=MAX_RIM_INTENSITY).text("sterkte"));
1636 if rim_slider.changed() {
1637 self.preview2d.set_rim_intensity(rim);
1638 need_rebuild = true;
1639 }
1640 ui.weak(
1641 "Fresnel + diepterand; 0 = alleen relight. Wijzigt geen opgeslagen diffuse.",
1642 );
1643 });
1651 });
1644
1652
1645 if go_back {
1653 if go_back {
@@ -1674,6 +1682,15 @@ impl EditorApp {
1674 }
1682 }
1675 }
1683 }
1676 }
1684 }
1685 if let Some(mode) = set_view_mode {
1686 if self.preview2d.view_mode != mode {
1687 self.preview2d.view_mode = mode;
1688 if !mode.uses_light() {
1689 self.preview2d.release_all_holds();
1690 }
1691 need_rebuild = true;
1692 }
1693 }
1677 if step_yaw != 0 {
1694 if step_yaw != 0 {
1678 self.preview2d.step_yaw(step_yaw);
1695 self.preview2d.step_yaw(step_yaw);
1679 need_rebuild = true;
1696 need_rebuild = true;
@@ -1693,10 +1710,11 @@ impl EditorApp {
1693 self.preview2d.release_light(LightAxis::Elevation);
1710 self.preview2d.release_light(LightAxis::Elevation);
1694 }
1711 }
1695
1712
1696 // Continuous light or playing changes the image every tick.
1713 // Playing always refreshes; light holds only matter in Resultaat.
1697 if self.preview2d.playing
1714 if self.preview2d.playing
1698 || self.preview2d.hold_azimuth.is_some()
1715 || (self.preview2d.view_mode.uses_light()
1699 || self.preview2d.hold_elevation.is_some()
1716 && (self.preview2d.hold_azimuth.is_some()
1717 || self.preview2d.hold_elevation.is_some()))
1700 {
1718 {
1701 need_rebuild = true;
1719 need_rebuild = true;
1702 }
1720 }
@@ -1708,10 +1726,10 @@ impl EditorApp {
1708 self.preview2d.yaw_deg(),
1726 self.preview2d.yaw_deg(),
1709 self.preview2d.selected_equipment.len() as u32,
1727 self.preview2d.selected_equipment.len() as u32,
1710 format!(
1728 format!(
1711 "{:.1}/{:.1}/{:.3}/{}",
1729 "{:.1}/{:.1}/{:?}/{}",
1712 self.preview2d.light_azimuth_deg,
1730 self.preview2d.light_azimuth_deg,
1713 self.preview2d.light_elevation_deg,
1731 self.preview2d.light_elevation_deg,
1714 self.preview2d.rim_intensity,
1732 self.preview2d.view_mode,
1715 self.preview2d.sex_token()
1733 self.preview2d.sex_token()
1716 ),
1734 ),
1717 );
1735 );
src/lib.rs +1 −1
@@ -5,7 +5,7 @@
5 5 //! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and
6 6 //! equipment previews, and streams production target maps.
7 7 //! Completed export runs are opened through `export_set` with checksum/identity
8 //! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
8 //! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
9 9
10 10 pub mod camera;
11 11 pub mod compositor;
@@ -5,7 +5,7 @@
5//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and
5//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and
6//! equipment previews, and streams production target maps.
6//! equipment previews, and streams production target maps.
7//! Completed export runs are opened through `export_set` with checksum/identity
7//! Completed export runs are opened through `export_set` with checksum/identity
8//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
8//! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
9
9
10pub mod camera;
10pub mod camera;
11pub mod compositor;
11pub mod compositor;
src/preview2d.rs +233 −51
@@ -1,15 +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 and rim are preview-only.
4 //! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).
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_relight_and_rim, CompatKey, ComposeError, LayerMaps, Light,
12 RimParams, DEFAULT_RIM_INTENSITY, MAX_RIM_INTENSITY,
11 assert_compatible, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
12 LayerMaps, Light,
13 13 };
14 14 use crate::export_set::{ExportError, ExportRun};
15 15 use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
@@ -49,6 +49,90 @@ impl HoldSign {
49 49 }
50 50 }
51 51
52 /// What the 2D preview shows for the current composed pose.
53 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54 pub enum ViewMode {
55 /// Relit composite (ambient + Lambert + Blinn-Phong). Default.
56 #[default]
57 Resultaat,
58 /// Depth-composited diffuse albedo, unlit.
59 DiffuseOnly,
60 /// Depth-composited normal map as stored RGB, unlit.
61 NormalOnly,
62 /// Depth-composited depth as grayscale, unlit.
63 DepthOnly,
64 /// Depth-composited specular map as stored RGB, unlit.
65 SpecularOnly,
66 }
67
68 impl ViewMode {
69 pub fn label_nl(self) -> &'static str {
70 match self {
71 Self::Resultaat => "Resultaat",
72 Self::DiffuseOnly => "Alleen diffuse",
73 Self::NormalOnly => "Alleen normal map",
74 Self::DepthOnly => "Alleen depth map",
75 Self::SpecularOnly => "Alleen specular map",
76 }
77 }
78
79 pub fn all() -> [Self; 5] {
80 [
81 Self::Resultaat,
82 Self::DiffuseOnly,
83 Self::NormalOnly,
84 Self::DepthOnly,
85 Self::SpecularOnly,
86 ]
87 }
88
89 pub fn uses_light(self) -> bool {
90 matches!(self, Self::Resultaat)
91 }
92 }
93
94 /// Render a composed map mode to RGBA8 (unlit inspection).
95 pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {
96 let pixels = (composed.width as usize) * (composed.height as usize);
97 let mut rgba = vec![0u8; pixels * 4];
98 match mode {
99 ViewMode::Resultaat => {
100 // Caller should use relight; fall back to diffuse if misused.
101 rgba.copy_from_slice(&composed.diffuse);
102 }
103 ViewMode::DiffuseOnly => {
104 rgba.copy_from_slice(&composed.diffuse);
105 }
106 ViewMode::NormalOnly => {
107 rgba.copy_from_slice(&composed.normal);
108 }
109 ViewMode::SpecularOnly => {
110 rgba.copy_from_slice(&composed.specular);
111 }
112 ViewMode::DepthOnly => {
113 for i in 0..pixels {
114 let o = i * 4;
115 if composed.winner[i] == u8::MAX {
116 continue;
117 }
118 let gray = composed.depth[i * 2];
119 let alpha16 = composed.depth[i * 2 + 1];
120 let g = ((u32::from(gray) * 255 + 32767) / 65535) as u8;
121 let a = ((u32::from(alpha16) * 255 + 32767) / 65535) as u8;
122 rgba[o] = g;
123 rgba[o + 1] = g;
124 rgba[o + 2] = g;
125 rgba[o + 3] = a;
126 }
127 }
128 }
129 ImageBuf {
130 width: composed.width,
131 height: composed.height,
132 rgba,
133 }
134 }
135
52 136 /// One completed export run discovered under a project `renders/` tree.
53 137 #[derive(Debug, Clone)]
54 138 pub struct CatalogEntry {
@@ -193,8 +277,8 @@ pub struct Preview2dState {
193 277 pub light_elevation_deg: f32,
194 278 pub hold_azimuth: Option<HoldSign>,
195 279 pub hold_elevation: Option<HoldSign>,
196 /// Consumer-side rim intensity in `[0, MAX_RIM_INTENSITY]` (preview only).
197 pub rim_intensity: f32,
280 /// Preview display mode (Resultaat vs raw composed maps).
281 pub view_mode: ViewMode,
198 282 pub diagnostic: Option<String>,
199 283 }
200 284
@@ -216,7 +300,7 @@ impl Default for Preview2dState {
216 300 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
217 301 hold_azimuth: None,
218 302 hold_elevation: None,
219 rim_intensity: DEFAULT_RIM_INTENSITY,
303 view_mode: ViewMode::Resultaat,
220 304 diagnostic: None,
221 305 }
222 306 }
@@ -306,25 +390,6 @@ impl Preview2dState {
306 390 }
307 391 }
308 392
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
328 393 /// Advance playback and held light controls by `dt` seconds.
329 394 pub fn tick(&mut self, dt: f32) {
330 395 let dt = if dt.is_finite() && dt > 0.0 {
@@ -333,10 +398,12 @@ impl Preview2dState {
333 398 0.0
334 399 };
335 400 if self.playing && dt > 0.0 {
336 let duration = if self.clip_duration > 0.0 {
337 self.clip_duration
338 } else if self.fps > 0 {
401 // Loop over exactly frames/fps so every exported frame, including the
402 // last, lasts a full 1/fps (not shortened by source clip_duration).
403 let duration = if self.fps > 0 {
339 404 self.clip_frames.max(1) as f32 / self.fps as f32
405 } else if self.clip_duration > 0.0 {
406 self.clip_duration
340 407 } else {
341 408 1.0
342 409 };
@@ -392,7 +459,7 @@ pub struct ComposeRequest<'a> {
392 459 pub pitch: i32,
393 460 pub yaw: u32,
394 461 pub light: Light,
395 pub rim: RimParams,
462 pub view_mode: ViewMode,
396 463 }
397 464
398 465 #[derive(Debug)]
@@ -445,7 +512,7 @@ impl From<ExportError> for Preview2dError {
445 512 }
446 513 }
447 514
448 /// Compose + relight + rim one view. Missing maps become an explicit error (no silent substitute).
515 /// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.
449 516 pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
450 517 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
451 518 runs.push(req.body);
@@ -492,8 +559,13 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe
492 559 }
493 560 }
494 561 }
495 let (_composed, lit) = compose_relight_and_rim(&layers, &req.light, &req.rim)?;
496 Ok(lit)
562 if req.view_mode.uses_light() {
563 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
564 Ok(lit)
565 } else {
566 let composed = compose(&layers)?;
567 Ok(composed_map_image(&composed, req.view_mode))
568 }
497 569 }
498 570
499 571 /// Open body + selected equipment runs for one sex, validating compatibility.
@@ -939,7 +1011,7 @@ mod tests {
939 1011 pitch: 0,
940 1012 yaw: 0,
941 1013 light: Light::default(),
942 rim: RimParams::off(),
1014 view_mode: ViewMode::Resultaat,
943 1015 })
944 1016 .unwrap();
945 1017 assert_eq!(ok.width, 1);
@@ -951,7 +1023,7 @@ mod tests {
951 1023 pitch: 0,
952 1024 yaw: 0,
953 1025 light: Light::default(),
954 rim: RimParams::off(),
1026 view_mode: ViewMode::Resultaat,
955 1027 })
956 1028 .unwrap_err();
957 1029 match err {
@@ -1063,7 +1135,7 @@ mod tests {
1063 1135 pitch: 0,
1064 1136 yaw: 0,
1065 1137 light: Light::default(),
1066 rim: RimParams::default(),
1138 view_mode: ViewMode::Resultaat,
1067 1139 })
1068 1140 .unwrap();
1069 1141 assert_eq!(lit.rgba[3], 255);
@@ -1072,10 +1144,78 @@ mod tests {
1072 1144 }
1073 1145
1074 1146 #[test]
1075 fn zero_rim_matches_relight_only_within_preview_path() {
1147 fn playback_loops_on_frames_over_fps_not_source_duration() {
1148 // Non-integer duration: 1.1s at 4 fps → ceil = 5 frames.
1149 // Each frame must last a full 1/fps = 0.25s, so the loop wraps at 1.25s
1150 // (not at 1.1s, which would shorten the last frame to 0.1s).
1151 let mut state = Preview2dState {
1152 fps: 4,
1153 clip_frames: 5,
1154 clip_duration: 1.1,
1155 playback_speed: 1.0,
1156 playing: true,
1157 elapsed_secs: 0.0,
1158 ..Preview2dState::default()
1159 };
1160 // Frames 0..3 occupy [0.00,1.00); frame 4 occupies [1.00,1.25).
1161 state.elapsed_secs = 1.0;
1162 assert_eq!(state.frame_index(), 4, "last frame starts at 1.0s");
1163 state.tick(0.2); // 1.20 — still last frame
1164 assert!((state.elapsed_secs - 1.2).abs() < 1e-5);
1165 assert_eq!(state.frame_index(), 4, "last frame must last full 0.25s");
1166 state.tick(0.05); // 1.25 → wrap to 0
1167 assert!(
1168 state.elapsed_secs < 1e-5,
1169 "wrap at frames/fps=1.25, got {}",
1170 state.elapsed_secs
1171 );
1172 assert_eq!(state.frame_index(), 0);
1173 // Old bug: wrapping on clip_duration=1.1 would have wrapped at 1.1 → never a full last frame.
1174 state.elapsed_secs = 1.05;
1175 assert_eq!(state.frame_index(), 4);
1176 state.tick(0.1); // 1.15 — still within 1.25 loop (would have wrapped under the bug)
1177 assert!(
1178 (state.elapsed_secs - 1.15).abs() < 1e-5,
1179 "must NOT wrap on source duration 1.1; elapsed={}",
1180 state.elapsed_secs
1181 );
1182 assert_eq!(state.frame_index(), 4);
1183 }
1184
1185 #[test]
1186 fn azimuth_hold_wraps_full_circle_continuously() {
1187 let mut state = Preview2dState {
1188 light_azimuth_deg: 10.0,
1189 ..Preview2dState::default()
1190 };
1191 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
1192 // 90°/s * many ticks past 360.
1193 for _ in 0..20 {
1194 state.tick(0.25); // +22.5 each, capped dt
1195 }
1196 // 20 * 22.5 = 450 → 10+450 = 460 → 100
1197 assert!(
1198 (state.light_azimuth_deg - 100.0).abs() < 1e-2,
1199 "got {}",
1200 state.light_azimuth_deg
1201 );
1202 assert!(state.light_azimuth_deg >= 0.0 && state.light_azimuth_deg < 360.0);
1203 // Negative wrap
1204 state.light_azimuth_deg = 5.0;
1205 state.hold_light(LightAxis::Azimuth, HoldSign::Negative);
1206 state.tick(0.25); // -22.5 → -17.5 → 342.5
1207 assert!((state.light_azimuth_deg - 342.5).abs() < 1e-2);
1208 assert_eq!(wrap_azimuth_deg(-0.1), 359.9);
1209 assert_eq!(wrap_azimuth_deg(360.0), 0.0);
1210 assert_eq!(wrap_azimuth_deg(720.5), 0.5);
1211 }
1212
1213 #[test]
1214 fn view_modes_show_composited_maps_unlit_except_resultaat() {
1076 1215 let body_tmp = Temp::new();
1077 1216 let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
1078 1217 let mut checksums = Map::new();
1218 // Distinct channels so modes are distinguishable.
1079 1219 checksums.extend(publish_pose(
1080 1220 &body_tmp.0,
1081 1221 "male",
@@ -1084,10 +1224,10 @@ mod tests {
1084 1224 0,
1085 1225 0,
1086 1226 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,
1227 [10, 20, 30, 255],
1228 [40, 50, 60, 255],
1229 [70, 70, 70, 255], // scalar specular (equal RGB)
1230 32768, // mid gray depth
1091 1231 ));
1092 1232 write_run(
1093 1233 &body_tmp.0,
@@ -1100,7 +1240,7 @@ mod tests {
1100 1240 checksums,
1101 1241 );
1102 1242 let run = ExportRun::open(&body_tmp.0).unwrap();
1103 let off = compose_preview_frame(ComposeRequest {
1243 let base = ComposeRequest {
1104 1244 body: &run,
1105 1245 equipment: &[],
1106 1246 clip_token: "clip000-bind",
@@ -1108,26 +1248,68 @@ mod tests {
1108 1248 pitch: 0,
1109 1249 yaw: 0,
1110 1250 light: Light::default(),
1111 rim: RimParams::off(),
1251 view_mode: ViewMode::Resultaat,
1252 };
1253 let diffuse = compose_preview_frame(ComposeRequest {
1254 view_mode: ViewMode::DiffuseOnly,
1255 ..base.clone()
1256 })
1257 .unwrap();
1258 assert_eq!(&diffuse.rgba[0..4], &[10, 20, 30, 255]);
1259
1260 let normal = compose_preview_frame(ComposeRequest {
1261 view_mode: ViewMode::NormalOnly,
1262 ..base.clone()
1263 })
1264 .unwrap();
1265 assert_eq!(&normal.rgba[0..4], &[40, 50, 60, 255]);
1266
1267 let specular = compose_preview_frame(ComposeRequest {
1268 view_mode: ViewMode::SpecularOnly,
1269 ..base.clone()
1112 1270 })
1113 1271 .unwrap();
1114 let on = compose_preview_frame(ComposeRequest {
1272 assert_eq!(&specular.rgba[0..4], &[70, 70, 70, 255]);
1273
1274 let depth = compose_preview_frame(ComposeRequest {
1275 view_mode: ViewMode::DepthOnly,
1276 ..base.clone()
1277 })
1278 .unwrap();
1279 // 32768/65535 ≈ 0.5 → ~128
1280 assert_eq!(depth.rgba[0], depth.rgba[1]);
1281 assert_eq!(depth.rgba[1], depth.rgba[2]);
1282 assert!((depth.rgba[0] as i32 - 128).abs() <= 1);
1283 assert_eq!(depth.rgba[3], 255);
1284
1285 let result = compose_preview_frame(ComposeRequest {
1286 view_mode: ViewMode::Resultaat,
1287 ..base
1288 })
1289 .unwrap();
1290 // Relit differs from raw diffuse under default light.
1291 assert_ne!(&result.rgba[0..3], &diffuse.rgba[0..3]);
1292 assert_eq!(result.rgba[3], 255);
1293
1294 // Light must not change map modes.
1295 let dark = Light {
1296 ambient: 0.0,
1297 diffuse: 0.0,
1298 specular: 0.0,
1299 ..Light::default()
1300 };
1301 let diffuse_dark = compose_preview_frame(ComposeRequest {
1115 1302 body: &run,
1116 1303 equipment: &[],
1117 1304 clip_token: "clip000-bind",
1118 1305 frame: 0,
1119 1306 pitch: 0,
1120 1307 yaw: 0,
1121 light: Light::default(),
1122 rim: RimParams::default(),
1308 light: dark,
1309 view_mode: ViewMode::DiffuseOnly,
1123 1310 })
1124 1311 .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);
1312 assert_eq!(diffuse_dark.rgba, diffuse.rgba);
1131 1313 }
1132 1314
1133 1315 #[test]
@@ -1,15 +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 and rim are preview-only.
4//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).
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_relight_and_rim, CompatKey, ComposeError, LayerMaps, Light,
11 assert_compatible, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
12 RimParams, DEFAULT_RIM_INTENSITY, MAX_RIM_INTENSITY,
12 LayerMaps, Light,
13};
13};
14use crate::export_set::{ExportError, ExportRun};
14use crate::export_set::{ExportError, ExportRun};
15use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
15use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
@@ -49,6 +49,90 @@ impl HoldSign {
49 }
49 }
50}
50}
51
51
52/// What the 2D preview shows for the current composed pose.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum ViewMode {
55 /// Relit composite (ambient + Lambert + Blinn-Phong). Default.
56 #[default]
57 Resultaat,
58 /// Depth-composited diffuse albedo, unlit.
59 DiffuseOnly,
60 /// Depth-composited normal map as stored RGB, unlit.
61 NormalOnly,
62 /// Depth-composited depth as grayscale, unlit.
63 DepthOnly,
64 /// Depth-composited specular map as stored RGB, unlit.
65 SpecularOnly,
66}
67
68impl ViewMode {
69 pub fn label_nl(self) -> &'static str {
70 match self {
71 Self::Resultaat => "Resultaat",
72 Self::DiffuseOnly => "Alleen diffuse",
73 Self::NormalOnly => "Alleen normal map",
74 Self::DepthOnly => "Alleen depth map",
75 Self::SpecularOnly => "Alleen specular map",
76 }
77 }
78
79 pub fn all() -> [Self; 5] {
80 [
81 Self::Resultaat,
82 Self::DiffuseOnly,
83 Self::NormalOnly,
84 Self::DepthOnly,
85 Self::SpecularOnly,
86 ]
87 }
88
89 pub fn uses_light(self) -> bool {
90 matches!(self, Self::Resultaat)
91 }
92}
93
94/// Render a composed map mode to RGBA8 (unlit inspection).
95pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {
96 let pixels = (composed.width as usize) * (composed.height as usize);
97 let mut rgba = vec![0u8; pixels * 4];
98 match mode {
99 ViewMode::Resultaat => {
100 // Caller should use relight; fall back to diffuse if misused.
101 rgba.copy_from_slice(&composed.diffuse);
102 }
103 ViewMode::DiffuseOnly => {
104 rgba.copy_from_slice(&composed.diffuse);
105 }
106 ViewMode::NormalOnly => {
107 rgba.copy_from_slice(&composed.normal);
108 }
109 ViewMode::SpecularOnly => {
110 rgba.copy_from_slice(&composed.specular);
111 }
112 ViewMode::DepthOnly => {
113 for i in 0..pixels {
114 let o = i * 4;
115 if composed.winner[i] == u8::MAX {
116 continue;
117 }
118 let gray = composed.depth[i * 2];
119 let alpha16 = composed.depth[i * 2 + 1];
120 let g = ((u32::from(gray) * 255 + 32767) / 65535) as u8;
121 let a = ((u32::from(alpha16) * 255 + 32767) / 65535) as u8;
122 rgba[o] = g;
123 rgba[o + 1] = g;
124 rgba[o + 2] = g;
125 rgba[o + 3] = a;
126 }
127 }
128 }
129 ImageBuf {
130 width: composed.width,
131 height: composed.height,
132 rgba,
133 }
134}
135
52/// One completed export run discovered under a project `renders/` tree.
136/// One completed export run discovered under a project `renders/` tree.
53#[derive(Debug, Clone)]
137#[derive(Debug, Clone)]
54pub struct CatalogEntry {
138pub struct CatalogEntry {
@@ -193,8 +277,8 @@ pub struct Preview2dState {
193 pub light_elevation_deg: f32,
277 pub light_elevation_deg: f32,
194 pub hold_azimuth: Option<HoldSign>,
278 pub hold_azimuth: Option<HoldSign>,
195 pub hold_elevation: Option<HoldSign>,
279 pub hold_elevation: Option<HoldSign>,
196 /// Consumer-side rim intensity in `[0, MAX_RIM_INTENSITY]` (preview only).
280 /// Preview display mode (Resultaat vs raw composed maps).
197 pub rim_intensity: f32,
281 pub view_mode: ViewMode,
198 pub diagnostic: Option<String>,
282 pub diagnostic: Option<String>,
199}
283}
200
284
@@ -216,7 +300,7 @@ impl Default for Preview2dState {
216 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
300 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
217 hold_azimuth: None,
301 hold_azimuth: None,
218 hold_elevation: None,
302 hold_elevation: None,
219 rim_intensity: DEFAULT_RIM_INTENSITY,
303 view_mode: ViewMode::Resultaat,
220 diagnostic: None,
304 diagnostic: None,
221 }
305 }
222 }
306 }
@@ -306,25 +390,6 @@ impl Preview2dState {
306 }
390 }
307 }
391 }
308
392
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
328 /// Advance playback and held light controls by `dt` seconds.
393 /// Advance playback and held light controls by `dt` seconds.
329 pub fn tick(&mut self, dt: f32) {
394 pub fn tick(&mut self, dt: f32) {
330 let dt = if dt.is_finite() && dt > 0.0 {
395 let dt = if dt.is_finite() && dt > 0.0 {
@@ -333,10 +398,12 @@ impl Preview2dState {
333 0.0
398 0.0
334 };
399 };
335 if self.playing && dt > 0.0 {
400 if self.playing && dt > 0.0 {
336 let duration = if self.clip_duration > 0.0 {
401 // Loop over exactly frames/fps so every exported frame, including the
337 self.clip_duration
402 // last, lasts a full 1/fps (not shortened by source clip_duration).
338 } else if self.fps > 0 {
403 let duration = if self.fps > 0 {
339 self.clip_frames.max(1) as f32 / self.fps as f32
404 self.clip_frames.max(1) as f32 / self.fps as f32
405 } else if self.clip_duration > 0.0 {
406 self.clip_duration
340 } else {
407 } else {
341 1.0
408 1.0
342 };
409 };
@@ -392,7 +459,7 @@ pub struct ComposeRequest<'a> {
392 pub pitch: i32,
459 pub pitch: i32,
393 pub yaw: u32,
460 pub yaw: u32,
394 pub light: Light,
461 pub light: Light,
395 pub rim: RimParams,
462 pub view_mode: ViewMode,
396}
463}
397
464
398#[derive(Debug)]
465#[derive(Debug)]
@@ -445,7 +512,7 @@ impl From<ExportError> for Preview2dError {
445 }
512 }
446}
513}
447
514
448/// Compose + relight + rim one view. Missing maps become an explicit error (no silent substitute).
515/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.
449pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
516pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
450 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
517 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
451 runs.push(req.body);
518 runs.push(req.body);
@@ -492,8 +559,13 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe
492 }
559 }
493 }
560 }
494 }
561 }
495 let (_composed, lit) = compose_relight_and_rim(&layers, &req.light, &req.rim)?;
562 if req.view_mode.uses_light() {
496 Ok(lit)
563 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
564 Ok(lit)
565 } else {
566 let composed = compose(&layers)?;
567 Ok(composed_map_image(&composed, req.view_mode))
568 }
497}
569}
498
570
499/// Open body + selected equipment runs for one sex, validating compatibility.
571/// Open body + selected equipment runs for one sex, validating compatibility.
@@ -939,7 +1011,7 @@ mod tests {
939 pitch: 0,
1011 pitch: 0,
940 yaw: 0,
1012 yaw: 0,
941 light: Light::default(),
1013 light: Light::default(),
942 rim: RimParams::off(),
1014 view_mode: ViewMode::Resultaat,
943 })
1015 })
944 .unwrap();
1016 .unwrap();
945 assert_eq!(ok.width, 1);
1017 assert_eq!(ok.width, 1);
@@ -951,7 +1023,7 @@ mod tests {
951 pitch: 0,
1023 pitch: 0,
952 yaw: 0,
1024 yaw: 0,
953 light: Light::default(),
1025 light: Light::default(),
954 rim: RimParams::off(),
1026 view_mode: ViewMode::Resultaat,
955 })
1027 })
956 .unwrap_err();
1028 .unwrap_err();
957 match err {
1029 match err {
@@ -1063,7 +1135,7 @@ mod tests {
1063 pitch: 0,
1135 pitch: 0,
1064 yaw: 0,
1136 yaw: 0,
1065 light: Light::default(),
1137 light: Light::default(),
1066 rim: RimParams::default(),
1138 view_mode: ViewMode::Resultaat,
1067 })
1139 })
1068 .unwrap();
1140 .unwrap();
1069 assert_eq!(lit.rgba[3], 255);
1141 assert_eq!(lit.rgba[3], 255);
@@ -1072,10 +1144,78 @@ mod tests {
1072 }
1144 }
1073
1145
1074 #[test]
1146 #[test]
1075 fn zero_rim_matches_relight_only_within_preview_path() {
1147 fn playback_loops_on_frames_over_fps_not_source_duration() {
1148 // Non-integer duration: 1.1s at 4 fps → ceil = 5 frames.
1149 // Each frame must last a full 1/fps = 0.25s, so the loop wraps at 1.25s
1150 // (not at 1.1s, which would shorten the last frame to 0.1s).
1151 let mut state = Preview2dState {
1152 fps: 4,
1153 clip_frames: 5,
1154 clip_duration: 1.1,
1155 playback_speed: 1.0,
1156 playing: true,
1157 elapsed_secs: 0.0,
1158 ..Preview2dState::default()
1159 };
1160 // Frames 0..3 occupy [0.00,1.00); frame 4 occupies [1.00,1.25).
1161 state.elapsed_secs = 1.0;
1162 assert_eq!(state.frame_index(), 4, "last frame starts at 1.0s");
1163 state.tick(0.2); // 1.20 — still last frame
1164 assert!((state.elapsed_secs - 1.2).abs() < 1e-5);
1165 assert_eq!(state.frame_index(), 4, "last frame must last full 0.25s");
1166 state.tick(0.05); // 1.25 → wrap to 0
1167 assert!(
1168 state.elapsed_secs < 1e-5,
1169 "wrap at frames/fps=1.25, got {}",
1170 state.elapsed_secs
1171 );
1172 assert_eq!(state.frame_index(), 0);
1173 // Old bug: wrapping on clip_duration=1.1 would have wrapped at 1.1 → never a full last frame.
1174 state.elapsed_secs = 1.05;
1175 assert_eq!(state.frame_index(), 4);
1176 state.tick(0.1); // 1.15 — still within 1.25 loop (would have wrapped under the bug)
1177 assert!(
1178 (state.elapsed_secs - 1.15).abs() < 1e-5,
1179 "must NOT wrap on source duration 1.1; elapsed={}",
1180 state.elapsed_secs
1181 );
1182 assert_eq!(state.frame_index(), 4);
1183 }
1184
1185 #[test]
1186 fn azimuth_hold_wraps_full_circle_continuously() {
1187 let mut state = Preview2dState {
1188 light_azimuth_deg: 10.0,
1189 ..Preview2dState::default()
1190 };
1191 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
1192 // 90°/s * many ticks past 360.
1193 for _ in 0..20 {
1194 state.tick(0.25); // +22.5 each, capped dt
1195 }
1196 // 20 * 22.5 = 450 → 10+450 = 460 → 100
1197 assert!(
1198 (state.light_azimuth_deg - 100.0).abs() < 1e-2,
1199 "got {}",
1200 state.light_azimuth_deg
1201 );
1202 assert!(state.light_azimuth_deg >= 0.0 && state.light_azimuth_deg < 360.0);
1203 // Negative wrap
1204 state.light_azimuth_deg = 5.0;
1205 state.hold_light(LightAxis::Azimuth, HoldSign::Negative);
1206 state.tick(0.25); // -22.5 → -17.5 → 342.5
1207 assert!((state.light_azimuth_deg - 342.5).abs() < 1e-2);
1208 assert_eq!(wrap_azimuth_deg(-0.1), 359.9);
1209 assert_eq!(wrap_azimuth_deg(360.0), 0.0);
1210 assert_eq!(wrap_azimuth_deg(720.5), 0.5);
1211 }
1212
1213 #[test]
1214 fn view_modes_show_composited_maps_unlit_except_resultaat() {
1076 let body_tmp = Temp::new();
1215 let body_tmp = Temp::new();
1077 let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
1216 let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
1078 let mut checksums = Map::new();
1217 let mut checksums = Map::new();
1218 // Distinct channels so modes are distinguishable.
1079 checksums.extend(publish_pose(
1219 checksums.extend(publish_pose(
1080 &body_tmp.0,
1220 &body_tmp.0,
1081 "male",
1221 "male",
@@ -1084,10 +1224,10 @@ mod tests {
1084 0,
1224 0,
1085 0,
1225 0,
1086 0,
1226 0,
1087 [90, 40, 40, 255],
1227 [10, 20, 30, 255],
1088 [254, 128, 142, 255], // near-unit grazing (~0.9,0,0.1)
1228 [40, 50, 60, 255],
1089 [0, 0, 0, 255],
1229 [70, 70, 70, 255], // scalar specular (equal RGB)
1090 18000,
1230 32768, // mid gray depth
1091 ));
1231 ));
1092 write_run(
1232 write_run(
1093 &body_tmp.0,
1233 &body_tmp.0,
@@ -1100,7 +1240,7 @@ mod tests {
1100 checksums,
1240 checksums,
1101 );
1241 );
1102 let run = ExportRun::open(&body_tmp.0).unwrap();
1242 let run = ExportRun::open(&body_tmp.0).unwrap();
1103 let off = compose_preview_frame(ComposeRequest {
1243 let base = ComposeRequest {
1104 body: &run,
1244 body: &run,
1105 equipment: &[],
1245 equipment: &[],
1106 clip_token: "clip000-bind",
1246 clip_token: "clip000-bind",
@@ -1108,26 +1248,68 @@ mod tests {
1108 pitch: 0,
1248 pitch: 0,
1109 yaw: 0,
1249 yaw: 0,
1110 light: Light::default(),
1250 light: Light::default(),
1111 rim: RimParams::off(),
1251 view_mode: ViewMode::Resultaat,
1252 };
1253 let diffuse = compose_preview_frame(ComposeRequest {
1254 view_mode: ViewMode::DiffuseOnly,
1255 ..base.clone()
1256 })
1257 .unwrap();
1258 assert_eq!(&diffuse.rgba[0..4], &[10, 20, 30, 255]);
1259
1260 let normal = compose_preview_frame(ComposeRequest {
1261 view_mode: ViewMode::NormalOnly,
1262 ..base.clone()
1263 })
1264 .unwrap();
1265 assert_eq!(&normal.rgba[0..4], &[40, 50, 60, 255]);
1266
1267 let specular = compose_preview_frame(ComposeRequest {
1268 view_mode: ViewMode::SpecularOnly,
1269 ..base.clone()
1112 })
1270 })
1113 .unwrap();
1271 .unwrap();
1114 let on = compose_preview_frame(ComposeRequest {
1272 assert_eq!(&specular.rgba[0..4], &[70, 70, 70, 255]);
1273
1274 let depth = compose_preview_frame(ComposeRequest {
1275 view_mode: ViewMode::DepthOnly,
1276 ..base.clone()
1277 })
1278 .unwrap();
1279 // 32768/65535 ≈ 0.5 → ~128
1280 assert_eq!(depth.rgba[0], depth.rgba[1]);
1281 assert_eq!(depth.rgba[1], depth.rgba[2]);
1282 assert!((depth.rgba[0] as i32 - 128).abs() <= 1);
1283 assert_eq!(depth.rgba[3], 255);
1284
1285 let result = compose_preview_frame(ComposeRequest {
1286 view_mode: ViewMode::Resultaat,
1287 ..base
1288 })
1289 .unwrap();
1290 // Relit differs from raw diffuse under default light.
1291 assert_ne!(&result.rgba[0..3], &diffuse.rgba[0..3]);
1292 assert_eq!(result.rgba[3], 255);
1293
1294 // Light must not change map modes.
1295 let dark = Light {
1296 ambient: 0.0,
1297 diffuse: 0.0,
1298 specular: 0.0,
1299 ..Light::default()
1300 };
1301 let diffuse_dark = compose_preview_frame(ComposeRequest {
1115 body: &run,
1302 body: &run,
1116 equipment: &[],
1303 equipment: &[],
1117 clip_token: "clip000-bind",
1304 clip_token: "clip000-bind",
1118 frame: 0,
1305 frame: 0,
1119 pitch: 0,
1306 pitch: 0,
1120 yaw: 0,
1307 yaw: 0,
1121 light: Light::default(),
1308 light: dark,
1122 rim: RimParams::default(),
1309 view_mode: ViewMode::DiffuseOnly,
1123 })
1310 })
1124 .unwrap();
1311 .unwrap();
1125 assert_eq!(off.rgba[3], 255);
1312 assert_eq!(diffuse_dark.rgba, diffuse.rgba);
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 }
1313 }
1132
1314
1133 #[test]
1315 #[test]