Merge design: Preview2d modes, no rim, loop timing
Non-FF merge of feature/preview2d-modes-no-rim (t958u+t960u+t961u).
@@ -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 compositor47
- 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
```bash55
```bash@@ -107,11 +108,11 @@ Een hervatte run schrijft in dezelfde runmap verder; andere runs blijven onaange107
108
108
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
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
```rust113
```rust113
use paperdoll_renderer::compositor::{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
use paperdoll_renderer::export_set::ExportRun;117
use 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
];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
@@ -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:150
5. **Normal:** write/publish target normal.150
5. **Normal:** write/publish target normal.151
6. **Specular:** write/publish target scalar map, including body zero.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
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).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` s184
184
185
## 10. Rendered 2D preview185
## 10. Rendered 2D preview186
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
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.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
## 11. Development workflow, quality and packaging193
## 11. Development workflow, quality and packaging194
194
@@ -221,7 +221,7 @@ These requirements are durable even where the implementation is staged. They sup221
- 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
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
## 12. Incremental feature plan and acceptance gates226
## 12. Incremental feature plan and acceptance gates227
227
@@ -231,8 +231,8 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p231
3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.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
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.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
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.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.)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, sex/equipment/clip selection, discrete view controls, continuous held light controls and bounded rim/edge lighting; deterministic state tests and GUI smoke.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
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.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
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.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.@@ -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.2
use paperdoll_renderer::compositor::{3
apply_rim, compose, relight, LayerMaps, Light, RimParams, DEFAULT_DEPTH_EDGE_SCALE,4
DEFAULT_RIM_POWER,5
};6
use paperdoll_renderer::render_job::ImageBuf;7
use std::env;8
use std::path::PathBuf;9
10
fn encode_normal(n: [f32; 3]) -> [u8; 4] {11
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-10);12
let n = [n[0] / len, n[1] / len, n[2] / len];13
[14
((n[0] * 0.5 + 0.5) * 255.0).round() as u8,15
((n[1] * 0.5 + 0.5) * 255.0).round() as u8,16
((n[2] * 0.5 + 0.5) * 255.0).round() as u8,17
255,18
]19
}20
21
fn sphere_layer(size: u32) -> LayerMaps {22
let w = size as usize;23
let mut diffuse = vec![0u8; w * w * 4];24
let mut normal = vec![0u8; w * w * 4];25
let mut specular = vec![0u8; w * w * 4];26
let mut depth = vec![0u16; w * w * 2];27
let cx = (size as f32 - 1.0) * 0.5;28
let cy = cx;29
let radius = cx * 0.92;30
for y in 0..w {31
for x in 0..w {32
let i = y * w + x;33
let dx = x as f32 - cx;34
let dy = y as f32 - cy;35
let r2 = dx * dx + dy * dy;36
if r2 > radius * radius {37
continue;38
}39
let nz = (1.0 - (r2 / (radius * radius))).sqrt();40
let nx = dx / radius;41
let ny = -dy / radius;42
let n = encode_normal([nx, ny, nz]);43
let o = i * 4;44
// Warm albedo45
diffuse[o] = 160;46
diffuse[o + 1] = 90;47
diffuse[o + 2] = 70;48
diffuse[o + 3] = 255;49
normal[o..o + 4].copy_from_slice(&n);50
specular[o] = 40;51
specular[o + 1] = 40;52
specular[o + 2] = 40;53
specular[o + 3] = 255;54
// Nearer at center (smaller gray)55
let gray = (5000.0 + (1.0 - nz) * 25000.0) as u16;56
depth[i * 2] = gray;57
depth[i * 2 + 1] = 65535;58
}59
}60
LayerMaps {61
label: "sphere".into(),62
width: size,63
height: size,64
diffuse,65
normal,66
specular,67
depth,68
}69
}70
71
fn side_by_side(a: &ImageBuf, b: &ImageBuf) -> ImageBuf {72
assert_eq!(a.width, b.width);73
assert_eq!(a.height, b.height);74
let w = a.width as usize;75
let h = a.height as usize;76
let out_w = w * 2 + 4;77
let mut rgba = vec![30u8; out_w * h * 4];78
for y in 0..h {79
for x in 0..w {80
let si = (y * w + x) * 4;81
let dl = (y * out_w + x) * 4;82
let dr = (y * out_w + (x + w + 4)) * 4;83
rgba[dl..dl + 4].copy_from_slice(&a.rgba[si..si + 4]);84
rgba[dr..dr + 4].copy_from_slice(&b.rgba[si..si + 4]);85
}86
}87
ImageBuf {88
width: out_w as u32,89
height: a.height,90
rgba,91
}92
}93
94
fn main() -> Result<(), String> {95
let out = PathBuf::from(96
env::args()97
.nth(1)98
.unwrap_or_else(|| "/workspace/paperdoll-rim-artifacts".into()),99
);100
std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;101
let layer = sphere_layer(96);102
let composed = compose(&[layer]).map_err(|e| e.to_string())?;103
let light = Light::default();104
let lit = relight(&composed, &light).map_err(|e| e.to_string())?;105
let rimmed = apply_rim(106
&composed,107
&lit,108
&RimParams {109
intensity: 0.35,110
power: DEFAULT_RIM_POWER,111
depth_edge_weight: 0.25,112
depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,113
},114
)115
.map_err(|e| e.to_string())?;116
let off = apply_rim(&composed, &lit, &RimParams::off()).map_err(|e| e.to_string())?;117
assert_eq!(off.rgba, lit.rgba);118
let compare = side_by_side(&lit, &rimmed);119
for (name, img) in [120
("rim_off.png", &lit),121
("rim_on.png", &rimmed),122
("rim_compare_off_vs_on.png", &compare),123
] {124
let path = out.join(name);125
let file = std::fs::File::create(&path).map_err(|e| e.to_string())?;126
let mut encoder = png::Encoder::new(file, img.width, img.height);127
encoder.set_color(png::ColorType::Rgba);128
encoder.set_depth(png::BitDepth::Eight);129
let mut writer = encoder.write_header().map_err(|e| e.to_string())?;130
writer131
.write_image_data(&img.rgba)132
.map_err(|e| e.to_string())?;133
writer.finish().map_err(|e| e.to_string())?;134
}135
// Bound check note136
println!("wrote comparison PNGs under {}", out.display());137
Ok(())138
}@@ -1,4 +1,4 @@
1
//! Deterministic 2D depth compositor, 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 applies4
//! 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 maps41
//! direction is already in **view space**, matching the exported normal maps42
//! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the42
//! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the43
//! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage.43
//! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage.44
//!44
45
//! # Rim / edge highlight (consumer only)46
//!47
//! [`apply_rim`] adds a bounded Fresnel-like `(1 − N·V)^power` highlight plus an48
//! optional depth-discontinuity term. Intensity is clamped to49
//! [`MAX_RIM_INTENSITY`]. Straight alpha/coverage is preserved byte-for-byte.50
//! Zero intensity returns the relit image unchanged. Production diffuse PNGs51
//! are never modified.52
use crate::{45
use 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).468
pub const MAX_RIM_INTENSITY: f32 = 0.45;469
470
/// Default subtle rim intensity used by the 2D preview.471
pub const DEFAULT_RIM_INTENSITY: f32 = 0.20;472
473
/// Default Fresnel exponent for `(1 − N·V)^power`.474
pub const DEFAULT_RIM_POWER: f32 = 3.0;475
476
/// Depth gray delta (u16 codes) that saturates the depth-edge term.477
pub const DEFAULT_DEPTH_EDGE_SCALE: f32 = 2048.0;478
479
/// Consumer-side rim / silhouette edge highlight parameters.480
///481
/// Applied after [`relight`]. Does not touch saved production albedo.482
#[derive(Debug, Clone, Copy, PartialEq)]483
pub struct RimParams {484
/// Additive linear intensity; clamped to `[0, MAX_RIM_INTENSITY]`.485
pub intensity: f32,486
/// Fresnel exponent; values below 1 clamp up to 1.487
pub power: f32,488
/// Blend weight of the depth-discontinuity term in `[0, 1]`.489
/// `0` = Fresnel only; `1` = depth edges only.490
pub depth_edge_weight: f32,491
/// Depth gray delta that maps to a full depth-edge contribution.492
pub depth_edge_scale: f32,493
}494
495
impl Default for RimParams {496
fn default() -> Self {497
Self {498
intensity: DEFAULT_RIM_INTENSITY,499
power: DEFAULT_RIM_POWER,500
depth_edge_weight: 0.35,501
depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,502
}503
}504
}505
506
impl RimParams {507
/// Explicitly disabled rim (byte-identical to relight-only output).508
pub fn off() -> Self {509
Self {510
intensity: 0.0,511
..Self::default()512
}513
}514
515
/// Finite, clamped parameters used by [`apply_rim`].516
pub fn clamped(self) -> Self {517
Self {518
intensity: if self.intensity.is_finite() {519
self.intensity.clamp(0.0, MAX_RIM_INTENSITY)520
} else {521
0.0522
},523
power: if self.power.is_finite() {524
self.power.max(1.0)525
} else {526
DEFAULT_RIM_POWER527
},528
depth_edge_weight: if self.depth_edge_weight.is_finite() {529
self.depth_edge_weight.clamp(0.0, 1.0)530
} else {531
0.0532
},533
depth_edge_scale: if self.depth_edge_scale.is_finite() && self.depth_edge_scale > 1.0 {534
self.depth_edge_scale535
} else {536
DEFAULT_DEPTH_EDGE_SCALE537
},538
}539
}540
}541
542
/// Add a bounded rim/edge highlight on top of an already-relit sRGB image.543
///544
/// - Fresnel term from view-space normals with view = `+Z`.545
/// - Optional depth-edge term from max |Δdepth| to covered 4-neighbors.546
/// - Alpha/coverage is copied unchanged from `lit`.547
/// - Zero intensity returns a byte-identical clone of `lit`.548
pub fn apply_rim(549
composed: &ComposedMaps,550
lit: &ImageBuf,551
rim: &RimParams,552
) -> Result<ImageBuf, ComposeError> {553
let width = composed.width;554
let height = composed.height;555
let pixels = (width as usize)556
.checked_mul(height as usize)557
.ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?;558
if lit.width != width559
|| lit.height != height560
|| lit.rgba.len() != pixels * 4561
|| composed.diffuse.len() != pixels * 4562
|| composed.normal.len() != pixels * 4563
|| composed.depth.len() != pixels * 2564
|| composed.winner.len() != pixels565
{566
return Err(ComposeError::Map(567
"rim buffers passen niet bij composed/lit afmetingen".into(),568
));569
}570
571
let rim = rim.clamped();572
if rim.intensity == 0.0 {573
return Ok(ImageBuf {574
width,575
height,576
rgba: lit.rgba.clone(),577
});578
}579
580
let view = [0.0_f32, 0.0, 1.0];581
let mut rgba = lit.rgba.clone();582
let w = width as usize;583
let h = height as usize;584
585
for y in 0..h {586
for x in 0..w {587
let i = y * w + x;588
let o = i * 4;589
let a = rgba[o + 3];590
if a == 0 || composed.winner[i] == u8::MAX {591
continue;592
}593
let n = decode_normal([594
composed.normal[o],595
composed.normal[o + 1],596
composed.normal[o + 2],597
]);598
let ndotv = dot(n, view).clamp(0.0, 1.0);599
let fresnel = (1.0 - ndotv).powf(rim.power);600
601
let mut depth_edge = 0.0_f32;602
if rim.depth_edge_weight > 0.0 {603
let center = composed.depth[i * 2];604
let mut max_delta = 0u16;605
let mut silhouette = false;606
for (dx, dy) in [(-1isize, 0), (1, 0), (0, -1), (0, 1)] {607
let nx = x as isize + dx;608
let ny = y as isize + dy;609
if nx < 0 || ny < 0 || nx as usize >= w || ny as usize >= h {610
// Image border next to covered geometry counts as a silhouette edge.611
silhouette = true;612
break;613
}614
let ni = ny as usize * w + nx as usize;615
if composed.winner[ni] == u8::MAX || composed.depth[ni * 2 + 1] == 0 {616
silhouette = true;617
break;618
}619
let d = center.abs_diff(composed.depth[ni * 2]);620
max_delta = max_delta.max(d);621
}622
depth_edge = if silhouette {623
1.0624
} else {625
(max_delta as f32 / rim.depth_edge_scale).clamp(0.0, 1.0)626
};627
}628
629
let factor =630
fresnel * (1.0 - rim.depth_edge_weight) + depth_edge * rim.depth_edge_weight;631
if factor <= 0.0 {632
continue;633
}634
let add = rim.intensity * factor;635
let mut lin = [636
linear_srgb(rgba[o]),637
linear_srgb(rgba[o + 1]),638
linear_srgb(rgba[o + 2]),639
];640
for channel in &mut lin {641
*channel = (*channel + add).clamp(0.0, 1.0);642
}643
rgba[o] = encode_srgb(lin[0]);644
rgba[o + 1] = encode_srgb(lin[1]);645
rgba[o + 2] = encode_srgb(lin[2]);646
// Alpha intentionally untouched.647
}648
}649
650
Ok(ImageBuf {651
width,652
height,653
rgba,654
})655
}656
657
/// Compose, relight, then apply rim in one step.658
pub fn compose_relight_and_rim(659
layers: &[LayerMaps],660
light: &Light,661
rim: &RimParams,662
) -> Result<(ComposedMaps, ImageBuf), ComposeError> {663
let (composed, lit) = compose_and_relight(layers, light)?;664
let rimmed = apply_rim(&composed, &lit, rim)?;665
Ok((composed, rimmed))666
}667
668
fn decode_normal(rgb: [u8; 3]) -> [f32; 3] {460
fn 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 u321287
}1288
1289
#[test]1290
fn rim_zero_intensity_matches_relight_exactly() {1291
let layer = solid_layer(1292
"body",1293
1,1294
1,1295
[180, 40, 40, 200],1296
encode_normal_rgb([0.7, 0.1, 0.2]),1297
[0, 0, 0, 255],1298
12000,1299
65535,1300
);1301
let composed = compose(&[layer]).unwrap();1302
let lit = relight(&composed, &Light::default()).unwrap();1303
let rimmed = apply_rim(&composed, &lit, &RimParams::off()).unwrap();1304
assert_eq!(rimmed.rgba, lit.rgba);1305
let (_c, via) = compose_relight_and_rim(1306
&[solid_layer(1307
"body",1308
1,1309
1,1310
[180, 40, 40, 200],1311
encode_normal_rgb([0.7, 0.1, 0.2]),1312
[0, 0, 0, 255],1313
12000,1314
65535,1315
)],1316
&Light::default(),1317
&RimParams::off(),1318
)1319
.unwrap();1320
assert_eq!(via.rgba, lit.rgba);1321
}1322
1323
#[test]1324
fn rim_flat_front_facing_surface_stays_near_relight() {1325
// Uniform +Z normal → Fresnel ≈ 0; no depth neighbors → depth edge from border only.1326
// Use depth_edge_weight = 0 so only Fresnel matters.1327
let layer = solid_layer(1328
"flat",1329
3,1330
3,1331
[120, 120, 120, 255],1332
encode_normal_rgb([0.0, 0.0, 1.0]),1333
[0, 0, 0, 255],1334
20000,1335
65535,1336
);1337
let composed = compose(&[layer]).unwrap();1338
let lit = relight(&composed, &Light::default()).unwrap();1339
let rim = RimParams {1340
intensity: MAX_RIM_INTENSITY,1341
power: DEFAULT_RIM_POWER,1342
depth_edge_weight: 0.0,1343
depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,1344
};1345
let rimmed = apply_rim(&composed, &lit, &rim).unwrap();1346
// Center pixel has no Fresnel contribution on a perfect front-facing normal.1347
let c = 16; // (row 1, col 1) in 3×31348
assert_eq!(&rimmed.rgba[c..c + 4], &lit.rgba[c..c + 4]);1349
// Alpha preserved everywhere.1350
for i in 0..9 {1351
assert_eq!(rimmed.rgba[i * 4 + 3], lit.rgba[i * 4 + 3]);1352
}1353
}1354
1355
#[test]1356
fn rim_grazing_angle_adds_bounded_highlight() {1357
let facing = solid_layer(1358
"face",1359
1,1360
1,1361
[80, 80, 80, 255],1362
encode_normal_rgb([0.0, 0.0, 1.0]),1363
[0, 0, 0, 255],1364
15000,1365
65535,1366
);1367
let grazing = solid_layer(1368
"edge",1369
1,1370
1,1371
[80, 80, 80, 255],1372
encode_normal_rgb([0.95, 0.0, 0.05]),1373
[0, 0, 0, 255],1374
15000,1375
65535,1376
);1377
let light = Light {1378
ambient: 0.2,1379
diffuse: 0.0,1380
specular: 0.0,1381
..Light::default()1382
};1383
let rim = RimParams {1384
intensity: 0.30,1385
power: 2.0,1386
depth_edge_weight: 0.0,1387
depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,1388
};1389
let face_c = compose(&[facing]).unwrap();1390
let edge_c = compose(&[grazing]).unwrap();1391
let face_lit = relight(&face_c, &light).unwrap();1392
let edge_lit = relight(&edge_c, &light).unwrap();1393
let face_rim = apply_rim(&face_c, &face_lit, &rim).unwrap();1394
let edge_rim = apply_rim(&edge_c, &edge_lit, &rim).unwrap();1395
assert_eq!(1396
face_rim.rgba, face_lit.rgba,1397
"flat front must not gain Fresnel rim"1398
);1399
assert!(1400
luma(&edge_rim.rgba) > luma(&edge_lit.rgba),1401
"grazing must brighten: {} vs {}",1402
luma(&edge_rim.rgba),1403
luma(&edge_lit.rgba)1404
);1405
// Bound: additive intensity cannot exceed MAX after clamp; RGB stay ≤ 255 and alpha intact.1406
assert_eq!(edge_rim.rgba[3], 255);1407
assert_eq!(edge_rim.rgba[3], edge_lit.rgba[3]);1408
for c in 0..3 {1409
assert!(edge_rim.rgba[c] >= edge_lit.rgba[c]);1410
}1411
// Over-large intensity clamps; result still finite and ≤ full white from black+max.1412
let over = RimParams {1413
intensity: 99.0,1414
power: 1.0,1415
depth_edge_weight: 0.0,1416
depth_edge_scale: DEFAULT_DEPTH_EDGE_SCALE,1417
};1418
assert_eq!(over.clamped().intensity, MAX_RIM_INTENSITY);1419
let clamped_img = apply_rim(&edge_c, &edge_lit, &over).unwrap();1420
// Over-max intensity still preserves alpha and stays within sRGB encoding.1421
assert_eq!(clamped_img.rgba[3], 255);1422
assert!(luma(&clamped_img.rgba) >= luma(&edge_lit.rgba));1423
}1424
1425
#[test]1426
fn rim_depth_edge_highlights_silhouette_not_flat_interior() {1427
// 3×3 with uniform normals facing camera; center depth differs strongly from ring.1428
let layer = pixel_layer("sil", 3, 3, |i| {1429
let x = i % 3;1430
let y = i / 3;1431
let border = x == 0 || y == 0 || x == 2 || y == 2;1432
let gray = if border { 10000 } else { 40000 };1433
(1434
[60, 60, 60, 255],1435
encode_normal_rgb([0.0, 0.0, 1.0]),1436
[0, 0, 0, 255],1437
gray,1438
65535,1439
)1440
});1441
let composed = compose(&[layer]).unwrap();1442
let light = Light {1443
ambient: 0.25,1444
diffuse: 0.0,1445
specular: 0.0,1446
..Light::default()1447
};1448
let lit = relight(&composed, &light).unwrap();1449
let rim = RimParams {1450
intensity: 0.35,1451
power: 4.0,1452
depth_edge_weight: 1.0, // depth only1453
depth_edge_scale: 5000.0,1454
};1455
let rimmed = apply_rim(&composed, &lit, &rim).unwrap();1456
let center = 16; // (row 1, col 1) in 3×31457
let edge = 4; // top-middle (row 0, col 1)1458
// Interior center sees large depth deltas to neighbors → highlighted.1459
assert!(1460
luma(&rimmed.rgba[center..center + 4]) > luma(&lit.rgba[center..center + 4]),1461
"depth jump at interior should rim"1462
);1463
assert!(1464
luma(&rimmed.rgba[edge..edge + 4]) > luma(&lit.rgba[edge..edge + 4]),1465
"border pixel next to depth jump should rim"1466
);1467
for i in 0..9 {1468
assert_eq!(rimmed.rgba[i * 4 + 3], 255);1469
}1470
}1471
1472
#[test]1473
fn rim_preserves_transparent_and_soft_alpha() {1474
let layer = pixel_layer("a", 2, 1, |i| {1475
if i == 0 {1476
([50, 50, 50, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0)1477
} else {1478
(1479
[50, 50, 50, 128],1480
encode_normal_rgb([0.9, 0.0, 0.1]),1481
[0, 0, 0, 255],1482
12000,1483
65535,1484
)1485
}1486
});1487
let composed = compose(&[layer]).unwrap();1488
let lit = relight(&composed, &Light::default()).unwrap();1489
let rimmed = apply_rim(&composed, &lit, &RimParams::default()).unwrap();1490
assert_eq!(&rimmed.rgba[0..4], &[0, 0, 0, 0]);1491
assert_eq!(rimmed.rgba[7], 128);1492
assert_eq!(rimmed.rgba[7], lit.rgba[7]);1493
}1494
}1061
}@@ -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
5
use eframe::egui;5
use eframe::egui;6
6
7
use crate::compositor::MAX_RIM_INTENSITY;8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};7
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};9
use crate::import::{import_project_model, ImportedModel};8
use crate::import::{import_project_model, ImportedModel};10
use crate::preview::{9
use crate::preview::{@@ -13,6 +12,7 @@ use crate::preview::{13
use crate::preview2d::{12
use 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
};17
use crate::preview_gl::Viewport;17
use crate::preview_gl::Viewport;18
use crate::project::open_project;18
use 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 ui1583
.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.playing1714
if self.preview2d.playing1698
|| 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
);@@ -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 and5
//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and6
//! 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/identity7
//! Completed export runs are opened through `export_set` with checksum/identity8
//! 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
pub mod camera;10
pub mod camera;11
pub mod compositor;11
pub mod compositor;@@ -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
6
use std::collections::BTreeMap;6
use std::collections::BTreeMap;7
use std::fs;7
use std::fs;8
use std::path::{Path, PathBuf};8
use std::path::{Path, PathBuf};9
9
10
use crate::compositor::{10
use crate::compositor::{11
assert_compatible, compose_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
};14
use crate::export_set::{ExportError, ExportRun};14
use crate::export_set::{ExportError, ExportRun};15
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};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
/// 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)]54
pub struct CatalogEntry {138
pub 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.0315
},316
..RimParams::default()317
}318
}319
320
pub fn set_rim_intensity(&mut self, value: f32) {321
self.rim_intensity = if value.is_finite() {322
value.clamp(0.0, MAX_RIM_INTENSITY)323
} else {324
0.0325
};326
}327
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.0398
0.0334
};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 the337
self.clip_duration402
// 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 f32404
self.clip_frames.max(1) as f32 / self.fps as f32405
} else if self.clip_duration > 0.0 {406
self.clip_duration340
} else {407
} else {341
1.0408
1.0342
};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.449
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {516
pub 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, Previe492
}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.25s1150
// (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 frame1164
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 01167
assert!(1168
state.elapsed_secs < 1e-5,1169
"wrap at frames/fps=1.25, got {}",1170
state.elapsed_secs1171
);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_secs1181
);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 dt1195
}1196
// 20 * 22.5 = 450 → 10+450 = 460 → 1001197
assert!(1198
(state.light_azimuth_deg - 100.0).abs() < 1e-2,1199
"got {}",1200
state.light_azimuth_deg1201
);1202
assert!(state.light_azimuth_deg >= 0.0 && state.light_azimuth_deg < 360.0);1203
// Negative wrap1204
state.light_azimuth_deg = 5.0;1205
state.hold_light(LightAxis::Azimuth, HoldSign::Negative);1206
state.tick(0.25); // -22.5 → -17.5 → 342.51207
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 depth1091
));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 → ~1281280
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
..base1288
})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]