BlueGit

golden-age paperdoll-renderer

feat: rendered 2D preview tab with playback, views and light holds

Add preview2d state machine over schema-3 export_set + compositor:
sex/equipment/clip selection via CompatKey, looping FPS/speed playback,
yaw wrap and pitch clamp preserving time, continuous held light controls
with dt, missing-frame diagnostics, and an editor 2D-preview tab with
aspect-preserving nearest-neighbor letterboxing. Mark Preview2d present.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
135e091585419b7bf1e79e8fdfc15d60c5319db8
Parent
6c806931
8 files changed +1813 −31
README.md +15 −0
@@ -29,6 +29,21 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
29 29
30 30 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31 31
32 ## 2D-preview
33
34 Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:
35
36 - Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
37 - Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
38 - **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
39 - Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); loslaten, focusverlies of pointer-annulering stopt de beweging. Azimut 0° is licht van voren (+Z); elevatie is geklemd. Belichting is alleen voor de preview; opgeslagen diffuse blijft unlit albedo.
40 - Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
41
42 ```bash
43 # Native GUI-smoke met schema-3 fixture-export (Xvfb)
44 cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
45 ```
46
32 47 ## Equipment en opslaan
33 48
34 49 Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
@@ -29,6 +29,21 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
29
29
30![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
30![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31
31
32## 2D-preview
33
34Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:
35
36- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
37- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
38- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −30 / 0 / +30 met klemmen. Hoekwijzigingen behouden de animatietijd.
39- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); loslaten, focusverlies of pointer-annulering stopt de beweging. Azimut 0° is licht van voren (+Z); elevatie is geklemd. Belichting is alleen voor de preview; opgeslagen diffuse blijft unlit albedo.
40- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
41
42```bash
43# Native GUI-smoke met schema-3 fixture-export (Xvfb)
44cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
45```
46
32## Equipment en opslaan
47## Equipment en opslaan
33
48
34Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
49Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
design.md +4 −4
@@ -180,9 +180,9 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
180 180
181 181 ## 10. Rendered 2D preview
182 182
183 Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI/tab/playback itself is not built yet. The library depth compositor and relighting API (`compositor`) consumes the safe map reader.
183 Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the composed+relit frame with aspect-preserving nearest-neighbor letterboxing. The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
184 184
185 Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.
185 Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
186 186
187 187 Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo.
188 188
@@ -217,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup
217 217 - Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
218 218 - During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
219 219
220 Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting is implemented; the 2D GUI/tab/playback, rim effects, resume and FBX drawing remain unimplemented.
220 Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback are implemented; rim effects, resume and FBX drawing remain unimplemented.
221 221
222 222 ## 12. Incremental feature plan and acceptance gates
223 223
@@ -228,7 +228,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
228 228 4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
229 229 5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
230 230 6. Production image pipeline: detail-aware downsampling, depth/normal resolve, rim accents, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets.
231 7. Rendered 2D compositor preview: real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls and continuous held light controls; deterministic state tests and manual lighting test.
231 7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls and continuous held light controls; deterministic state tests and GUI smoke.
232 232 8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
233 233
234 234 Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.
@@ -180,9 +180,9 @@ Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` s
180
180
181## 10. Rendered 2D preview
181## 10. Rendered 2D preview
182
182
183Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The compositor GUI/tab/playback itself is not built yet. The library depth compositor and relighting API (`compositor`) consumes the safe map reader.
183Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the composed+relit frame with aspect-preserving nearest-neighbor letterboxing. The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
184
184
185Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.
185Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
186
186
187Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo.
187Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo.
188
188
@@ -217,7 +217,7 @@ These requirements are durable even where the implementation is staged. They sup
217- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
217- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
218- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
218- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
219
219
220Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting is implemented; the 2D GUI/tab/playback, rim effects, resume and FBX drawing remain unimplemented.
220Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback are implemented; rim effects, resume and FBX drawing remain unimplemented.
221
221
222## 12. Incremental feature plan and acceptance gates
222## 12. Incremental feature plan and acceptance gates
223
223
@@ -228,7 +228,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p
2284. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2284. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2295. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
2295. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
2306. Production image pipeline: detail-aware downsampling, depth/normal resolve, rim accents, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets.
2306. Production image pipeline: detail-aware downsampling, depth/normal resolve, rim accents, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets.
2317. Rendered 2D compositor preview: real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls and continuous held light controls; deterministic state tests and manual lighting test.
2317. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls and continuous held light controls; deterministic state tests and GUI smoke.
2328. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
2328. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
233
233
234Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.
234Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.
examples/preview2d_smoke.rs added +299 −0
@@ -0,0 +1,299 @@
1 //! Native GUI smoke for the rendered 2D preview tab (schema-3 export fixtures).
2 //! Usage: preview2d_smoke OUTPUT_DIR
3
4 use eframe::App;
5 use paperdoll_renderer::digest;
6 use paperdoll_renderer::editor::EditorApp;
7 use paperdoll_renderer::export_set::complete_identity_json;
8 use paperdoll_renderer::preview2d::discover_completed_runs;
9 use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
10 use paperdoll_renderer::project::CameraFraming;
11 use paperdoll_renderer::render_job::ImageBuf;
12 use paperdoll_renderer::session::Session;
13 use std::collections::BTreeMap;
14 use std::fs;
15 use std::path::{Path, PathBuf};
16 use std::time::{Duration, Instant};
17
18 struct Smoke {
19 editor: EditorApp,
20 out: PathBuf,
21 started: Instant,
22 frames: u64,
23 done: bool,
24 }
25
26 impl App for Smoke {
27 fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
28 self.frames += 1;
29 self.editor.update(ctx, frame);
30 if self.frames == 2 {
31 self.editor.open_preview2d();
32 }
33 let ready = self.editor.preview2d_image().is_some()
34 || self.editor.preview2d_state().diagnostic.is_some();
35 let timeout = self.started.elapsed() > Duration::from_secs(8);
36 if self.done {
37 return;
38 }
39 if (self.frames >= 12 && ready) || timeout {
40 self.done = true;
41 let state = self.editor.preview2d_state();
42 let image_ok = if let Some(img) = self.editor.preview2d_image() {
43 write_png(&self.out.join("preview2d-composed.png"), img).expect("png");
44 true
45 } else {
46 false
47 };
48 let value = serde_json::json!({
49 "success": image_ok,
50 "gui_updates": self.frames,
51 "sex": state.sex_token(),
52 "clip": state.clip_token,
53 "frame": state.frame_index(),
54 "yaw": state.yaw_deg(),
55 "pitch": state.pitch_deg(),
56 "playing": state.playing,
57 "diagnostic": state.diagnostic,
58 "elapsed_secs": state.elapsed_secs,
59 });
60 fs::write(
61 self.out.join("gui-smoke.json"),
62 serde_json::to_vec_pretty(&value).unwrap(),
63 )
64 .unwrap();
65 println!("RESULT {value}");
66 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
67 }
68 }
69
70 fn on_exit(&mut self, gl: Option<&glow::Context>) {
71 self.editor.on_exit(gl);
72 }
73 }
74
75 fn write_png(path: &Path, img: &ImageBuf) -> Result<(), String> {
76 let file = fs::File::create(path).map_err(|e| e.to_string())?;
77 let mut encoder = png::Encoder::new(file, img.width, img.height);
78 encoder.set_color(png::ColorType::Rgba);
79 encoder.set_depth(png::BitDepth::Eight);
80 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
81 writer
82 .write_image_data(&img.rgba)
83 .map_err(|e| e.to_string())
84 }
85
86 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {
87 let data = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
88 let path = root.join(name);
89 let temp = path.with_extension(format!("part-{}", std::process::id()));
90 fs::write(&temp, &data).map_err(|e| e.to_string())?;
91 fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
92 let _ = fs::remove_file(temp);
93 Ok(())
94 }
95
96 #[allow(clippy::too_many_arguments)]
97 fn publish_pose(
98 root: &Path,
99 sex: &str,
100 set: &str,
101 clip: &str,
102 frame: u32,
103 pitch: i32,
104 yaw: u32,
105 diffuse: [u8; 4],
106 depth_gray: u16,
107 ) -> Result<BTreeMap<String, String>, String> {
108 let mut map = BTreeMap::new();
109 let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
110 let normal = [128u8, 128, 255, 255];
111 let specular = [0u8, 0, 0, 255];
112 for (rel, kind) in [
113 (format!("{base}_diffuse.png"), "diffuse"),
114 (format!("{base}_depth.png"), "depth"),
115 (format!("{base}_normal.png"), "normal"),
116 (format!("{base}_specular.png"), "specular"),
117 ] {
118 let path = Path::new(&rel);
119 match kind {
120 "depth" => {
121 write_depth(
122 root,
123 path,
124 2,
125 2,
126 &[
127 depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, depth_gray, 65535,
128 ],
129 )?;
130 }
131 "normal" => {
132 write_rgba(
133 root,
134 path,
135 &ImageBuf {
136 width: 2,
137 height: 2,
138 rgba: normal.repeat(4),
139 },
140 )?;
141 }
142 "specular" => {
143 write_rgba(
144 root,
145 path,
146 &ImageBuf {
147 width: 2,
148 height: 2,
149 rgba: specular.repeat(4),
150 },
151 )?;
152 }
153 _ => {
154 write_rgba(
155 root,
156 path,
157 &ImageBuf {
158 width: 2,
159 height: 2,
160 rgba: diffuse.repeat(4),
161 },
162 )?;
163 }
164 }
165 let bytes = fs::read(root.join(&rel)).map_err(|e| e.to_string())?;
166 map.insert(rel, digest::sha256_hex(&bytes));
167 }
168 Ok(map)
169 }
170
171 fn install_body_export(project: &Path) -> Result<(), String> {
172 let run = project.join("renders/run-preview2d-body");
173 fs::create_dir_all(&run).map_err(|e| e.to_string())?;
174 let body_bytes = b"preview2d-smoke-body";
175 let body_hash = digest::sha256_hex(body_bytes);
176 let clips = vec![serde_json::json!({
177 "name": "walk",
178 "token": "clip000-walk",
179 "frames": 2,
180 "duration": 0.1667
181 })];
182 let mut checksums = BTreeMap::new();
183 for frame in [0u32, 1] {
184 for pitch in [-30i32, 0, 30] {
185 for yaw in [0u32, 30, 90] {
186 let tint = [40u8.wrapping_add(frame as u8 * 80), 90, 140, 255];
187 checksums.extend(publish_pose(
188 &run,
189 "male",
190 "body",
191 "clip000-walk",
192 frame,
193 pitch,
194 yaw,
195 tint,
196 25000 - frame as u16 * 1000,
197 )?);
198 }
199 }
200 }
201 let png_files = checksums.len() as u32;
202 let render = serde_json::json!({
203 "format_version": 3,
204 "status": "incomplete",
205 "project": {"id": "smoke-preview2d", "name": "Preview2d Smoke"},
206 "sex": "male",
207 "set": "body",
208 "body_model": {"path": "assets/body/test-body-male.glb", "sha256": body_hash},
209 "equipment_preset": null,
210 "target": [2, 2],
211 "fps": 12,
212 "highres_scale": 8,
213 "framing": CameraFraming {
214 height_milli: 1800,
215 center_y_milli: 900,
216 pivot_y_milli: Some(1500),
217 },
218 "pivot_source": {"kind": "manual"},
219 "near": NEAR,
220 "far": FAR,
221 "camera_distance": 10.0,
222 "yaw_zero": "camera on -Z",
223 "normal_space": "view (+Y up), PNG rows top-down",
224 "pitches": [-30, 0, 30],
225 "yaw_step": 30,
226 "sampling": "smoke",
227 "alpha": "straight",
228 "diffuse": "unlit",
229 "depth": "16-bit",
230 "specular": "scalar",
231 "pass_order": PASSES,
232 "saved_maps": ["diffuse", "depth", "normal", "specular"],
233 "clips": clips,
234 "images": 2 * 36,
235 "passes": 2 * 36 * 6,
236 "png_files": png_files
237 });
238 write_atomic_json(&run, "render.json", &render)?;
239 let identity = complete_identity_json(&render)?;
240 write_atomic_json(
241 &run,
242 "complete.json",
243 &serde_json::json!({
244 "format_version": 3,
245 "status": "complete",
246 "identity": identity,
247 "passes": 2 * 36 * 6,
248 "png_files": png_files,
249 "png_sha256": checksums
250 }),
251 )?;
252 Ok(())
253 }
254
255 fn main() -> Result<(), Box<dyn std::error::Error>> {
256 let out = PathBuf::from(
257 std::env::args()
258 .nth(1)
259 .ok_or("usage: preview2d_smoke OUTPUT_DIR")?,
260 );
261 fs::create_dir_all(&out)?;
262 let project = out.join("project");
263 let mut session = Session::new();
264 session.install_new_project(&project)?;
265 session.save()?;
266 install_body_export(&project)?;
267 let catalog = discover_completed_runs(&project);
268 fs::write(
269 out.join("catalog.json"),
270 serde_json::to_vec_pretty(&serde_json::json!({
271 "count": catalog.len(),
272 "sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),
273 }))?,
274 )?;
275
276 let mut editor = EditorApp::new(Box::new(paperdoll_renderer::dialogs::SystemDialogs::new()));
277 editor.open_path(project.join("character.ppd"));
278
279 eframe::run_native(
280 "Paperdoll Preview2d Smoke",
281 eframe::NativeOptions {
282 viewport: eframe::egui::ViewportBuilder::default()
283 .with_inner_size([1100.0, 720.0])
284 .with_visible(true),
285 ..Default::default()
286 },
287 Box::new(move |_cc| {
288 Ok(Box::new(Smoke {
289 editor,
290 out: out.clone(),
291 started: Instant::now(),
292 frames: 0,
293 done: false,
294 }) as Box<dyn App>)
295 }),
296 )
297 .map_err(|e| e.to_string())?;
298 Ok(())
299 }
@@ -0,0 +1,299 @@
1//! Native GUI smoke for the rendered 2D preview tab (schema-3 export fixtures).
2//! Usage: preview2d_smoke OUTPUT_DIR
3
4use eframe::App;
5use paperdoll_renderer::digest;
6use paperdoll_renderer::editor::EditorApp;
7use paperdoll_renderer::export_set::complete_identity_json;
8use paperdoll_renderer::preview2d::discover_completed_runs;
9use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
10use paperdoll_renderer::project::CameraFraming;
11use paperdoll_renderer::render_job::ImageBuf;
12use paperdoll_renderer::session::Session;
13use std::collections::BTreeMap;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::time::{Duration, Instant};
17
18struct Smoke {
19 editor: EditorApp,
20 out: PathBuf,
21 started: Instant,
22 frames: u64,
23 done: bool,
24}
25
26impl App for Smoke {
27 fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
28 self.frames += 1;
29 self.editor.update(ctx, frame);
30 if self.frames == 2 {
31 self.editor.open_preview2d();
32 }
33 let ready = self.editor.preview2d_image().is_some()
34 || self.editor.preview2d_state().diagnostic.is_some();
35 let timeout = self.started.elapsed() > Duration::from_secs(8);
36 if self.done {
37 return;
38 }
39 if (self.frames >= 12 && ready) || timeout {
40 self.done = true;
41 let state = self.editor.preview2d_state();
42 let image_ok = if let Some(img) = self.editor.preview2d_image() {
43 write_png(&self.out.join("preview2d-composed.png"), img).expect("png");
44 true
45 } else {
46 false
47 };
48 let value = serde_json::json!({
49 "success": image_ok,
50 "gui_updates": self.frames,
51 "sex": state.sex_token(),
52 "clip": state.clip_token,
53 "frame": state.frame_index(),
54 "yaw": state.yaw_deg(),
55 "pitch": state.pitch_deg(),
56 "playing": state.playing,
57 "diagnostic": state.diagnostic,
58 "elapsed_secs": state.elapsed_secs,
59 });
60 fs::write(
61 self.out.join("gui-smoke.json"),
62 serde_json::to_vec_pretty(&value).unwrap(),
63 )
64 .unwrap();
65 println!("RESULT {value}");
66 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
67 }
68 }
69
70 fn on_exit(&mut self, gl: Option<&glow::Context>) {
71 self.editor.on_exit(gl);
72 }
73}
74
75fn write_png(path: &Path, img: &ImageBuf) -> Result<(), String> {
76 let file = fs::File::create(path).map_err(|e| e.to_string())?;
77 let mut encoder = png::Encoder::new(file, img.width, img.height);
78 encoder.set_color(png::ColorType::Rgba);
79 encoder.set_depth(png::BitDepth::Eight);
80 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
81 writer
82 .write_image_data(&img.rgba)
83 .map_err(|e| e.to_string())
84}
85
86fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {
87 let data = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
88 let path = root.join(name);
89 let temp = path.with_extension(format!("part-{}", std::process::id()));
90 fs::write(&temp, &data).map_err(|e| e.to_string())?;
91 fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
92 let _ = fs::remove_file(temp);
93 Ok(())
94}
95
96#[allow(clippy::too_many_arguments)]
97fn publish_pose(
98 root: &Path,
99 sex: &str,
100 set: &str,
101 clip: &str,
102 frame: u32,
103 pitch: i32,
104 yaw: u32,
105 diffuse: [u8; 4],
106 depth_gray: u16,
107) -> Result<BTreeMap<String, String>, String> {
108 let mut map = BTreeMap::new();
109 let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
110 let normal = [128u8, 128, 255, 255];
111 let specular = [0u8, 0, 0, 255];
112 for (rel, kind) in [
113 (format!("{base}_diffuse.png"), "diffuse"),
114 (format!("{base}_depth.png"), "depth"),
115 (format!("{base}_normal.png"), "normal"),
116 (format!("{base}_specular.png"), "specular"),
117 ] {
118 let path = Path::new(&rel);
119 match kind {
120 "depth" => {
121 write_depth(
122 root,
123 path,
124 2,
125 2,
126 &[
127 depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, depth_gray, 65535,
128 ],
129 )?;
130 }
131 "normal" => {
132 write_rgba(
133 root,
134 path,
135 &ImageBuf {
136 width: 2,
137 height: 2,
138 rgba: normal.repeat(4),
139 },
140 )?;
141 }
142 "specular" => {
143 write_rgba(
144 root,
145 path,
146 &ImageBuf {
147 width: 2,
148 height: 2,
149 rgba: specular.repeat(4),
150 },
151 )?;
152 }
153 _ => {
154 write_rgba(
155 root,
156 path,
157 &ImageBuf {
158 width: 2,
159 height: 2,
160 rgba: diffuse.repeat(4),
161 },
162 )?;
163 }
164 }
165 let bytes = fs::read(root.join(&rel)).map_err(|e| e.to_string())?;
166 map.insert(rel, digest::sha256_hex(&bytes));
167 }
168 Ok(map)
169}
170
171fn install_body_export(project: &Path) -> Result<(), String> {
172 let run = project.join("renders/run-preview2d-body");
173 fs::create_dir_all(&run).map_err(|e| e.to_string())?;
174 let body_bytes = b"preview2d-smoke-body";
175 let body_hash = digest::sha256_hex(body_bytes);
176 let clips = vec![serde_json::json!({
177 "name": "walk",
178 "token": "clip000-walk",
179 "frames": 2,
180 "duration": 0.1667
181 })];
182 let mut checksums = BTreeMap::new();
183 for frame in [0u32, 1] {
184 for pitch in [-30i32, 0, 30] {
185 for yaw in [0u32, 30, 90] {
186 let tint = [40u8.wrapping_add(frame as u8 * 80), 90, 140, 255];
187 checksums.extend(publish_pose(
188 &run,
189 "male",
190 "body",
191 "clip000-walk",
192 frame,
193 pitch,
194 yaw,
195 tint,
196 25000 - frame as u16 * 1000,
197 )?);
198 }
199 }
200 }
201 let png_files = checksums.len() as u32;
202 let render = serde_json::json!({
203 "format_version": 3,
204 "status": "incomplete",
205 "project": {"id": "smoke-preview2d", "name": "Preview2d Smoke"},
206 "sex": "male",
207 "set": "body",
208 "body_model": {"path": "assets/body/test-body-male.glb", "sha256": body_hash},
209 "equipment_preset": null,
210 "target": [2, 2],
211 "fps": 12,
212 "highres_scale": 8,
213 "framing": CameraFraming {
214 height_milli: 1800,
215 center_y_milli: 900,
216 pivot_y_milli: Some(1500),
217 },
218 "pivot_source": {"kind": "manual"},
219 "near": NEAR,
220 "far": FAR,
221 "camera_distance": 10.0,
222 "yaw_zero": "camera on -Z",
223 "normal_space": "view (+Y up), PNG rows top-down",
224 "pitches": [-30, 0, 30],
225 "yaw_step": 30,
226 "sampling": "smoke",
227 "alpha": "straight",
228 "diffuse": "unlit",
229 "depth": "16-bit",
230 "specular": "scalar",
231 "pass_order": PASSES,
232 "saved_maps": ["diffuse", "depth", "normal", "specular"],
233 "clips": clips,
234 "images": 2 * 36,
235 "passes": 2 * 36 * 6,
236 "png_files": png_files
237 });
238 write_atomic_json(&run, "render.json", &render)?;
239 let identity = complete_identity_json(&render)?;
240 write_atomic_json(
241 &run,
242 "complete.json",
243 &serde_json::json!({
244 "format_version": 3,
245 "status": "complete",
246 "identity": identity,
247 "passes": 2 * 36 * 6,
248 "png_files": png_files,
249 "png_sha256": checksums
250 }),
251 )?;
252 Ok(())
253}
254
255fn main() -> Result<(), Box<dyn std::error::Error>> {
256 let out = PathBuf::from(
257 std::env::args()
258 .nth(1)
259 .ok_or("usage: preview2d_smoke OUTPUT_DIR")?,
260 );
261 fs::create_dir_all(&out)?;
262 let project = out.join("project");
263 let mut session = Session::new();
264 session.install_new_project(&project)?;
265 session.save()?;
266 install_body_export(&project)?;
267 let catalog = discover_completed_runs(&project);
268 fs::write(
269 out.join("catalog.json"),
270 serde_json::to_vec_pretty(&serde_json::json!({
271 "count": catalog.len(),
272 "sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),
273 }))?,
274 )?;
275
276 let mut editor = EditorApp::new(Box::new(paperdoll_renderer::dialogs::SystemDialogs::new()));
277 editor.open_path(project.join("character.ppd"));
278
279 eframe::run_native(
280 "Paperdoll Preview2d Smoke",
281 eframe::NativeOptions {
282 viewport: eframe::egui::ViewportBuilder::default()
283 .with_inner_size([1100.0, 720.0])
284 .with_visible(true),
285 ..Default::default()
286 },
287 Box::new(move |_cc| {
288 Ok(Box::new(Smoke {
289 editor,
290 out: out.clone(),
291 started: Instant::now(),
292 frames: 0,
293 done: false,
294 }) as Box<dyn App>)
295 }),
296 )
297 .map_err(|e| e.to_string())?;
298 Ok(())
299}
src/editor.rs +416 −3
@@ -1,4 +1,4 @@
1 //! Project window: start screen, body, equipment, and the render queue.
1 //! Project window: start screen, body, equipment, render queue, and 2D preview.
2 2
3 3 use std::path::PathBuf;
4 4
@@ -7,9 +7,13 @@ use eframe::egui;
7 7 use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8 8 use crate::import::{import_project_model, ImportedModel};
9 9 use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
10 use crate::preview2d::{
11 compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
12 sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
13 };
10 14 use crate::preview_gl::Viewport;
11 15 use crate::project::open_project;
12 use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
16 use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
13 17 use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
14 18
15 19 pub struct EditorApp {
@@ -33,6 +37,12 @@ pub struct EditorApp {
33 37 allow_close: bool,
34 38 dialog_pending: bool,
35 39 equipment_part: usize,
40 preview2d: Preview2dState,
41 preview2d_catalog: Vec<CatalogEntry>,
42 preview2d_texture: Option<egui::TextureHandle>,
43 preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
44 preview2d_image: Option<ImageBuf>,
45 preview2d_speed_text: String,
36 46 }
37 47
38 48 impl EditorApp {
@@ -58,6 +68,12 @@ impl EditorApp {
58 68 allow_close: false,
59 69 dialog_pending: false,
60 70 equipment_part: 0,
71 preview2d: Preview2dState::default(),
72 preview2d_catalog: Vec::new(),
73 preview2d_texture: None,
74 preview2d_texture_key: None,
75 preview2d_image: None,
76 preview2d_speed_text: "1.0".to_string(),
61 77 }
62 78 }
63 79
@@ -679,7 +695,12 @@ impl eframe::App for EditorApp {
679 695 }
680 696 self.poll_dialogs();
681 697 self.drain_render();
682 if self.render_ui.running {
698 if self.render_ui.running
699 || matches!(self.session.screen, Screen::Preview2d)
700 && (self.preview2d.playing
701 || self.preview2d.hold_azimuth.is_some()
702 || self.preview2d.hold_elevation.is_some())
703 {
683 704 ctx.request_repaint();
684 705 }
685 706 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
@@ -696,6 +717,7 @@ impl eframe::App for EditorApp {
696 717 Screen::Body(_) => self.show_body(ctx),
697 718 Screen::EquipmentList => self.show_equipment_list(ctx),
698 719 Screen::Equipment(_) => self.show_equipment(ctx),
720 Screen::Preview2d => self.show_preview2d(ctx),
699 721 }
700 722 self.show_confirm(ctx);
701 723 if self.dialog_pending {
@@ -775,6 +797,11 @@ impl EditorApp {
775 797 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
776 798 });
777 799 self.apply_render_fields();
800 ui.add_space(8.0);
801 if ui.button("2D-preview").clicked() {
802 self.open_preview2d();
803 }
804 ui.label("Opent de gerenderde 2D-compositor over voltooide exportsets in renders/.");
778 805 });
779 806 }
780 807
@@ -865,6 +892,9 @@ impl EditorApp {
865 892 if ui.button("Live 3D").clicked() {
866 893 self.render_ui.preview = None;
867 894 }
895 if ui.button("2D-preview").clicked() {
896 self.open_preview2d();
897 }
868 898 });
869 899 self.framing_ui(ui, usize::from(sex == Sex::Female));
870 900 self.render_progress_ui(ui);
@@ -1177,6 +1207,9 @@ impl EditorApp {
1177 1207 if ui.button("Live 3D").clicked() {
1178 1208 self.render_ui.preview = None;
1179 1209 }
1210 if ui.button("2D-preview").clicked() {
1211 self.open_preview2d();
1212 }
1180 1213 });
1181 1214 self.render_progress_ui(ui);
1182 1215 if self.render_ui.preview.is_some() {
@@ -1200,6 +1233,383 @@ impl EditorApp {
1200 1233 });
1201 1234 }
1202 1235
1236 pub fn open_preview2d(&mut self) {
1237 self.refresh_preview2d_catalog();
1238 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1239 self.preview2d_image = None;
1240 self.preview2d_texture = None;
1241 self.preview2d_texture_key = None;
1242 self.session.show_preview2d();
1243 }
1244
1245 pub fn preview2d_state(&self) -> &Preview2dState {
1246 &self.preview2d
1247 }
1248
1249 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1250 self.preview2d_image.as_ref()
1251 }
1252
1253 fn refresh_preview2d_catalog(&mut self) {
1254 self.preview2d_catalog = match self.session.project_root() {
1255 Some(root) => discover_completed_runs(&root),
1256 None => Vec::new(),
1257 };
1258 }
1259
1260 fn rebuild_preview2d_frame(&mut self) {
1261 let sex = self.preview2d.sex;
1262 let equipment = self.preview2d.selected_equipment.clone();
1263 let clip = self.preview2d.clip_token.clone();
1264 let frame = self.preview2d.frame_index();
1265 let pitch = self.preview2d.pitch_deg();
1266 let yaw = self.preview2d.yaw_deg();
1267 let light = self.preview2d.light();
1268 if clip.is_empty() {
1269 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1270 self.preview2d_image = None;
1271 return;
1272 }
1273 match open_selection(&self.preview2d_catalog, sex, &equipment) {
1274 Ok((body, gear)) => match compose_preview_frame(ComposeRequest {
1275 body: &body,
1276 equipment: &gear,
1277 clip_token: &clip,
1278 frame,
1279 pitch,
1280 yaw,
1281 light,
1282 }) {
1283 Ok(image) => {
1284 self.preview2d.diagnostic = None;
1285 self.preview2d_image = Some(image);
1286 }
1287 Err(err) => {
1288 self.preview2d.diagnostic = Some(err.to_string());
1289 self.preview2d_image = None;
1290 }
1291 },
1292 Err(err) => {
1293 self.preview2d.diagnostic = Some(err.to_string());
1294 self.preview2d_image = None;
1295 }
1296 }
1297 }
1298
1299 fn show_preview2d(&mut self, ctx: &egui::Context) {
1300 self.toolbar(ctx, true);
1301 let dt = ctx.input(|i| i.stable_dt);
1302 let focused = ctx.input(|i| i.focused);
1303 let cancel_holds = ctx.input(|i| {
1304 !i.focused
1305 || i.pointer.any_released()
1306 || i.events.iter().any(|e| {
1307 matches!(
1308 e,
1309 egui::Event::PointerGone | egui::Event::WindowFocused(false)
1310 )
1311 })
1312 });
1313 if cancel_holds || !focused {
1314 self.preview2d.on_interaction_cancel();
1315 }
1316 self.preview2d.tick(dt);
1317
1318 let mut need_rebuild = false;
1319 let mut refresh_catalog = false;
1320 let mut go_back = false;
1321 let mut set_sex = None;
1322 let mut toggle_set = None;
1323 let mut select_clip = None;
1324 let mut step_yaw = 0i32;
1325 let mut step_pitch = 0i32;
1326 let mut toggle_play = false;
1327 let mut apply_speed = false;
1328 let mut hold_az = None;
1329 let mut hold_el = None;
1330 let mut release_az = false;
1331 let mut release_el = false;
1332
1333 egui::SidePanel::left("preview2d_controls")
1334 .default_width(330.0)
1335 .show(ctx, |ui| {
1336 if self.session.confirm.is_some() || self.dialog_pending {
1337 ui.disable();
1338 }
1339 ui.heading("2D-preview");
1340 if ui.button("Terug").clicked() {
1341 go_back = true;
1342 }
1343 ui.separator();
1344 ui.label("Geslacht");
1345 ui.horizontal(|ui| {
1346 if ui
1347 .selectable_label(self.preview2d.sex == Sex::Male, "Man")
1348 .clicked()
1349 {
1350 set_sex = Some(Sex::Male);
1351 }
1352 if ui
1353 .selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")
1354 .clicked()
1355 {
1356 set_sex = Some(Sex::Female);
1357 }
1358 });
1359 if ui.button("Vernieuw exportsets").clicked() {
1360 refresh_catalog = true;
1361 }
1362 ui.label(format!(
1363 "{} voltooide exportset(s)",
1364 self.preview2d_catalog.len()
1365 ));
1366 ui.separator();
1367 ui.label("Equipment (zelfde project/rig/contract)");
1368 let allowed =
1369 compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);
1370 if allowed.is_empty() {
1371 ui.weak("Geen compatibele equipment-export.");
1372 }
1373 for set in allowed {
1374 let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);
1375 if ui.checkbox(&mut checked, &set).changed() {
1376 toggle_set = Some(set);
1377 }
1378 }
1379 ui.separator();
1380 ui.label("Clip");
1381 let sex_token = self.preview2d.sex_token();
1382 let clips: Vec<_> = self
1383 .preview2d_catalog
1384 .iter()
1385 .filter(|e| e.sex == sex_token && e.is_body())
1386 .max_by_key(|e| e.path.clone())
1387 .map(|e| e.clips.clone())
1388 .unwrap_or_default();
1389 if clips.is_empty() {
1390 ui.colored_label(
1391 egui::Color32::LIGHT_RED,
1392 "Geen body-export met clips voor dit geslacht.",
1393 );
1394 }
1395 for (token, name, frames, duration) in &clips {
1396 let label = if name.is_empty() {
1397 format!("{token} ({frames}f)")
1398 } else {
1399 format!("{name} ({token}, {frames}f)")
1400 };
1401 if ui
1402 .selectable_label(self.preview2d.clip_token == *token, label)
1403 .clicked()
1404 {
1405 select_clip = Some((token.clone(), *frames, *duration));
1406 }
1407 }
1408 ui.separator();
1409 ui.horizontal(|ui| {
1410 let play_label = if self.preview2d.playing {
1411 "Pauze"
1412 } else {
1413 "Speel"
1414 };
1415 if ui.button(play_label).clicked() {
1416 toggle_play = true;
1417 }
1418 ui.label(format!(
1419 "frame {}/{} t={:.2}s",
1420 self.preview2d.frame_index() + 1,
1421 self.preview2d.clip_frames.max(1),
1422 self.preview2d.elapsed_secs
1423 ));
1424 });
1425 ui.horizontal(|ui| {
1426 ui.label("Snelheid");
1427 ui.add(
1428 egui::TextEdit::singleline(&mut self.preview2d_speed_text)
1429 .desired_width(48.0),
1430 );
1431 if ui.button("Pas toe").clicked() {
1432 apply_speed = true;
1433 }
1434 ui.label(format!("export-fps {}", self.preview2d.fps));
1435 });
1436 ui.separator();
1437 ui.label("Hoek (export)");
1438 ui.horizontal(|ui| {
1439 if ui.button("Yaw −").clicked() {
1440 step_yaw = -1;
1441 }
1442 if ui.button("Yaw +").clicked() {
1443 step_yaw = 1;
1444 }
1445 ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));
1446 });
1447 ui.horizontal(|ui| {
1448 if ui.button("Pitch −").clicked() {
1449 step_pitch = -1;
1450 }
1451 if ui.button("Pitch +").clicked() {
1452 step_pitch = 1;
1453 }
1454 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1455 });
1456 ui.separator();
1457 ui.label("Licht (vasthouden; °/s)");
1458 ui.horizontal(|ui| {
1459 let west = ui.button("Azimut −");
1460 let east = ui.button("Azimut +");
1461 if west.is_pointer_button_down_on() {
1462 hold_az = Some(HoldSign::Negative);
1463 } else if east.is_pointer_button_down_on() {
1464 hold_az = Some(HoldSign::Positive);
1465 } else if self.preview2d.hold_azimuth.is_some()
1466 && !west.is_pointer_button_down_on()
1467 && !east.is_pointer_button_down_on()
1468 {
1469 release_az = true;
1470 }
1471 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1472 });
1473 ui.horizontal(|ui| {
1474 let down = ui.button("Elevatie −");
1475 let up = ui.button("Elevatie +");
1476 if down.is_pointer_button_down_on() {
1477 hold_el = Some(HoldSign::Negative);
1478 } else if up.is_pointer_button_down_on() {
1479 hold_el = Some(HoldSign::Positive);
1480 } else if self.preview2d.hold_elevation.is_some()
1481 && !down.is_pointer_button_down_on()
1482 && !up.is_pointer_button_down_on()
1483 {
1484 release_el = true;
1485 }
1486 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1487 });
1488 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1489 });
1490
1491 if go_back {
1492 self.session.back();
1493 return;
1494 }
1495 if refresh_catalog {
1496 self.refresh_preview2d_catalog();
1497 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1498 need_rebuild = true;
1499 }
1500 if let Some(sex) = set_sex {
1501 self.preview2d.set_sex(sex);
1502 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1503 need_rebuild = true;
1504 }
1505 if let Some(set) = toggle_set {
1506 self.preview2d.toggle_equipment(&set);
1507 need_rebuild = true;
1508 }
1509 if let Some((token, frames, duration)) = select_clip {
1510 self.preview2d.set_clip(token, frames, duration);
1511 need_rebuild = true;
1512 }
1513 if toggle_play {
1514 self.preview2d.playing = !self.preview2d.playing;
1515 }
1516 if apply_speed {
1517 if let Ok(speed) = self.preview2d_speed_text.parse::<f32>() {
1518 if speed.is_finite() && speed >= 0.0 {
1519 self.preview2d.playback_speed = speed;
1520 }
1521 }
1522 }
1523 if step_yaw != 0 {
1524 self.preview2d.step_yaw(step_yaw);
1525 need_rebuild = true;
1526 }
1527 if step_pitch != 0 {
1528 self.preview2d.step_pitch(step_pitch);
1529 need_rebuild = true;
1530 }
1531 if let Some(sign) = hold_az {
1532 self.preview2d.hold_light(LightAxis::Azimuth, sign);
1533 } else if release_az {
1534 self.preview2d.release_light(LightAxis::Azimuth);
1535 }
1536 if let Some(sign) = hold_el {
1537 self.preview2d.hold_light(LightAxis::Elevation, sign);
1538 } else if release_el {
1539 self.preview2d.release_light(LightAxis::Elevation);
1540 }
1541
1542 // Continuous light or playing changes the image every tick.
1543 if self.preview2d.playing
1544 || self.preview2d.hold_azimuth.is_some()
1545 || self.preview2d.hold_elevation.is_some()
1546 {
1547 need_rebuild = true;
1548 }
1549
1550 let key = (
1551 self.preview2d.clip_token.clone(),
1552 self.preview2d.frame_index(),
1553 self.preview2d.pitch_deg(),
1554 self.preview2d.yaw_deg(),
1555 self.preview2d.selected_equipment.len() as u32,
1556 format!(
1557 "{:.1}/{:.1}/{}",
1558 self.preview2d.light_azimuth_deg,
1559 self.preview2d.light_elevation_deg,
1560 self.preview2d.sex_token()
1561 ),
1562 );
1563 if need_rebuild || self.preview2d_texture_key.as_ref() != Some(&key) {
1564 self.rebuild_preview2d_frame();
1565 self.preview2d_texture_key = Some(key);
1566 self.preview2d_texture = None;
1567 }
1568
1569 egui::CentralPanel::default().show(ctx, |ui| {
1570 if self.session.confirm.is_some() || self.dialog_pending {
1571 ui.disable();
1572 }
1573 if let Some(diag) = &self.preview2d.diagnostic {
1574 ui.colored_label(egui::Color32::LIGHT_RED, diag);
1575 }
1576 let Some(image) = &self.preview2d_image else {
1577 ui.label(
1578 "Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",
1579 );
1580 return;
1581 };
1582 if self.preview2d_texture.is_none() {
1583 let color = egui::ColorImage::from_rgba_unmultiplied(
1584 [image.width as usize, image.height as usize],
1585 &image.rgba,
1586 );
1587 self.preview2d_texture = Some(ui.ctx().load_texture(
1588 "preview2d-frame",
1589 color,
1590 egui::TextureOptions::NEAREST,
1591 ));
1592 }
1593 let texture = self.preview2d_texture.as_ref().expect("texture");
1594 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
1595 let fit = crate::render_job::fit_preview(
1596 [image.width, image.height],
1597 [bounds.width(), bounds.height()],
1598 );
1599 let rect = egui::Rect::from_min_size(
1600 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
1601 egui::vec2(fit.size[0], fit.size[1]),
1602 );
1603 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
1604 ui.painter().image(
1605 texture.id(),
1606 rect,
1607 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1608 egui::Color32::WHITE,
1609 );
1610 });
1611 }
1612
1203 1613 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
1204 1614 if !project {
1205 1615 return;
@@ -1212,6 +1622,9 @@ impl EditorApp {
1212 1622 if ui.button("Body").clicked() {
1213 1623 self.session.show_bodies();
1214 1624 }
1625 if ui.button("2D-preview").clicked() {
1626 self.open_preview2d();
1627 }
1215 1628 if ui.button("Opslaan").clicked() {
1216 1629 self.save();
1217 1630 }
@@ -1,4 +1,4 @@
1//! Project window: start screen, body, equipment, and the render queue.
1//! Project window: start screen, body, equipment, render queue, and 2D preview.
2
2
3use std::path::PathBuf;
3use std::path::PathBuf;
4
4
@@ -7,9 +7,13 @@ use eframe::egui;
7use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
7use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8use crate::import::{import_project_model, ImportedModel};
8use crate::import::{import_project_model, ImportedModel};
9use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
9use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
10use crate::preview2d::{
11 compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
12 sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
13};
10use crate::preview_gl::Viewport;
14use crate::preview_gl::Viewport;
11use crate::project::open_project;
15use crate::project::open_project;
12use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
16use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
13use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
17use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
14
18
15pub struct EditorApp {
19pub struct EditorApp {
@@ -33,6 +37,12 @@ pub struct EditorApp {
33 allow_close: bool,
37 allow_close: bool,
34 dialog_pending: bool,
38 dialog_pending: bool,
35 equipment_part: usize,
39 equipment_part: usize,
40 preview2d: Preview2dState,
41 preview2d_catalog: Vec<CatalogEntry>,
42 preview2d_texture: Option<egui::TextureHandle>,
43 preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
44 preview2d_image: Option<ImageBuf>,
45 preview2d_speed_text: String,
36}
46}
37
47
38impl EditorApp {
48impl EditorApp {
@@ -58,6 +68,12 @@ impl EditorApp {
58 allow_close: false,
68 allow_close: false,
59 dialog_pending: false,
69 dialog_pending: false,
60 equipment_part: 0,
70 equipment_part: 0,
71 preview2d: Preview2dState::default(),
72 preview2d_catalog: Vec::new(),
73 preview2d_texture: None,
74 preview2d_texture_key: None,
75 preview2d_image: None,
76 preview2d_speed_text: "1.0".to_string(),
61 }
77 }
62 }
78 }
63
79
@@ -679,7 +695,12 @@ impl eframe::App for EditorApp {
679 }
695 }
680 self.poll_dialogs();
696 self.poll_dialogs();
681 self.drain_render();
697 self.drain_render();
682 if self.render_ui.running {
698 if self.render_ui.running
699 || matches!(self.session.screen, Screen::Preview2d)
700 && (self.preview2d.playing
701 || self.preview2d.hold_azimuth.is_some()
702 || self.preview2d.hold_elevation.is_some())
703 {
683 ctx.request_repaint();
704 ctx.request_repaint();
684 }
705 }
685 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
706 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
@@ -696,6 +717,7 @@ impl eframe::App for EditorApp {
696 Screen::Body(_) => self.show_body(ctx),
717 Screen::Body(_) => self.show_body(ctx),
697 Screen::EquipmentList => self.show_equipment_list(ctx),
718 Screen::EquipmentList => self.show_equipment_list(ctx),
698 Screen::Equipment(_) => self.show_equipment(ctx),
719 Screen::Equipment(_) => self.show_equipment(ctx),
720 Screen::Preview2d => self.show_preview2d(ctx),
699 }
721 }
700 self.show_confirm(ctx);
722 self.show_confirm(ctx);
701 if self.dialog_pending {
723 if self.dialog_pending {
@@ -775,6 +797,11 @@ impl EditorApp {
775 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
797 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
776 });
798 });
777 self.apply_render_fields();
799 self.apply_render_fields();
800 ui.add_space(8.0);
801 if ui.button("2D-preview").clicked() {
802 self.open_preview2d();
803 }
804 ui.label("Opent de gerenderde 2D-compositor over voltooide exportsets in renders/.");
778 });
805 });
779 }
806 }
780
807
@@ -865,6 +892,9 @@ impl EditorApp {
865 if ui.button("Live 3D").clicked() {
892 if ui.button("Live 3D").clicked() {
866 self.render_ui.preview = None;
893 self.render_ui.preview = None;
867 }
894 }
895 if ui.button("2D-preview").clicked() {
896 self.open_preview2d();
897 }
868 });
898 });
869 self.framing_ui(ui, usize::from(sex == Sex::Female));
899 self.framing_ui(ui, usize::from(sex == Sex::Female));
870 self.render_progress_ui(ui);
900 self.render_progress_ui(ui);
@@ -1177,6 +1207,9 @@ impl EditorApp {
1177 if ui.button("Live 3D").clicked() {
1207 if ui.button("Live 3D").clicked() {
1178 self.render_ui.preview = None;
1208 self.render_ui.preview = None;
1179 }
1209 }
1210 if ui.button("2D-preview").clicked() {
1211 self.open_preview2d();
1212 }
1180 });
1213 });
1181 self.render_progress_ui(ui);
1214 self.render_progress_ui(ui);
1182 if self.render_ui.preview.is_some() {
1215 if self.render_ui.preview.is_some() {
@@ -1200,6 +1233,383 @@ impl EditorApp {
1200 });
1233 });
1201 }
1234 }
1202
1235
1236 pub fn open_preview2d(&mut self) {
1237 self.refresh_preview2d_catalog();
1238 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1239 self.preview2d_image = None;
1240 self.preview2d_texture = None;
1241 self.preview2d_texture_key = None;
1242 self.session.show_preview2d();
1243 }
1244
1245 pub fn preview2d_state(&self) -> &Preview2dState {
1246 &self.preview2d
1247 }
1248
1249 pub fn preview2d_image(&self) -> Option<&ImageBuf> {
1250 self.preview2d_image.as_ref()
1251 }
1252
1253 fn refresh_preview2d_catalog(&mut self) {
1254 self.preview2d_catalog = match self.session.project_root() {
1255 Some(root) => discover_completed_runs(&root),
1256 None => Vec::new(),
1257 };
1258 }
1259
1260 fn rebuild_preview2d_frame(&mut self) {
1261 let sex = self.preview2d.sex;
1262 let equipment = self.preview2d.selected_equipment.clone();
1263 let clip = self.preview2d.clip_token.clone();
1264 let frame = self.preview2d.frame_index();
1265 let pitch = self.preview2d.pitch_deg();
1266 let yaw = self.preview2d.yaw_deg();
1267 let light = self.preview2d.light();
1268 if clip.is_empty() {
1269 self.preview2d.diagnostic = Some("geen clip in de voltooide body-export".into());
1270 self.preview2d_image = None;
1271 return;
1272 }
1273 match open_selection(&self.preview2d_catalog, sex, &equipment) {
1274 Ok((body, gear)) => match compose_preview_frame(ComposeRequest {
1275 body: &body,
1276 equipment: &gear,
1277 clip_token: &clip,
1278 frame,
1279 pitch,
1280 yaw,
1281 light,
1282 }) {
1283 Ok(image) => {
1284 self.preview2d.diagnostic = None;
1285 self.preview2d_image = Some(image);
1286 }
1287 Err(err) => {
1288 self.preview2d.diagnostic = Some(err.to_string());
1289 self.preview2d_image = None;
1290 }
1291 },
1292 Err(err) => {
1293 self.preview2d.diagnostic = Some(err.to_string());
1294 self.preview2d_image = None;
1295 }
1296 }
1297 }
1298
1299 fn show_preview2d(&mut self, ctx: &egui::Context) {
1300 self.toolbar(ctx, true);
1301 let dt = ctx.input(|i| i.stable_dt);
1302 let focused = ctx.input(|i| i.focused);
1303 let cancel_holds = ctx.input(|i| {
1304 !i.focused
1305 || i.pointer.any_released()
1306 || i.events.iter().any(|e| {
1307 matches!(
1308 e,
1309 egui::Event::PointerGone | egui::Event::WindowFocused(false)
1310 )
1311 })
1312 });
1313 if cancel_holds || !focused {
1314 self.preview2d.on_interaction_cancel();
1315 }
1316 self.preview2d.tick(dt);
1317
1318 let mut need_rebuild = false;
1319 let mut refresh_catalog = false;
1320 let mut go_back = false;
1321 let mut set_sex = None;
1322 let mut toggle_set = None;
1323 let mut select_clip = None;
1324 let mut step_yaw = 0i32;
1325 let mut step_pitch = 0i32;
1326 let mut toggle_play = false;
1327 let mut apply_speed = false;
1328 let mut hold_az = None;
1329 let mut hold_el = None;
1330 let mut release_az = false;
1331 let mut release_el = false;
1332
1333 egui::SidePanel::left("preview2d_controls")
1334 .default_width(330.0)
1335 .show(ctx, |ui| {
1336 if self.session.confirm.is_some() || self.dialog_pending {
1337 ui.disable();
1338 }
1339 ui.heading("2D-preview");
1340 if ui.button("Terug").clicked() {
1341 go_back = true;
1342 }
1343 ui.separator();
1344 ui.label("Geslacht");
1345 ui.horizontal(|ui| {
1346 if ui
1347 .selectable_label(self.preview2d.sex == Sex::Male, "Man")
1348 .clicked()
1349 {
1350 set_sex = Some(Sex::Male);
1351 }
1352 if ui
1353 .selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")
1354 .clicked()
1355 {
1356 set_sex = Some(Sex::Female);
1357 }
1358 });
1359 if ui.button("Vernieuw exportsets").clicked() {
1360 refresh_catalog = true;
1361 }
1362 ui.label(format!(
1363 "{} voltooide exportset(s)",
1364 self.preview2d_catalog.len()
1365 ));
1366 ui.separator();
1367 ui.label("Equipment (zelfde project/rig/contract)");
1368 let allowed =
1369 compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);
1370 if allowed.is_empty() {
1371 ui.weak("Geen compatibele equipment-export.");
1372 }
1373 for set in allowed {
1374 let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);
1375 if ui.checkbox(&mut checked, &set).changed() {
1376 toggle_set = Some(set);
1377 }
1378 }
1379 ui.separator();
1380 ui.label("Clip");
1381 let sex_token = self.preview2d.sex_token();
1382 let clips: Vec<_> = self
1383 .preview2d_catalog
1384 .iter()
1385 .filter(|e| e.sex == sex_token && e.is_body())
1386 .max_by_key(|e| e.path.clone())
1387 .map(|e| e.clips.clone())
1388 .unwrap_or_default();
1389 if clips.is_empty() {
1390 ui.colored_label(
1391 egui::Color32::LIGHT_RED,
1392 "Geen body-export met clips voor dit geslacht.",
1393 );
1394 }
1395 for (token, name, frames, duration) in &clips {
1396 let label = if name.is_empty() {
1397 format!("{token} ({frames}f)")
1398 } else {
1399 format!("{name} ({token}, {frames}f)")
1400 };
1401 if ui
1402 .selectable_label(self.preview2d.clip_token == *token, label)
1403 .clicked()
1404 {
1405 select_clip = Some((token.clone(), *frames, *duration));
1406 }
1407 }
1408 ui.separator();
1409 ui.horizontal(|ui| {
1410 let play_label = if self.preview2d.playing {
1411 "Pauze"
1412 } else {
1413 "Speel"
1414 };
1415 if ui.button(play_label).clicked() {
1416 toggle_play = true;
1417 }
1418 ui.label(format!(
1419 "frame {}/{} t={:.2}s",
1420 self.preview2d.frame_index() + 1,
1421 self.preview2d.clip_frames.max(1),
1422 self.preview2d.elapsed_secs
1423 ));
1424 });
1425 ui.horizontal(|ui| {
1426 ui.label("Snelheid");
1427 ui.add(
1428 egui::TextEdit::singleline(&mut self.preview2d_speed_text)
1429 .desired_width(48.0),
1430 );
1431 if ui.button("Pas toe").clicked() {
1432 apply_speed = true;
1433 }
1434 ui.label(format!("export-fps {}", self.preview2d.fps));
1435 });
1436 ui.separator();
1437 ui.label("Hoek (export)");
1438 ui.horizontal(|ui| {
1439 if ui.button("Yaw −").clicked() {
1440 step_yaw = -1;
1441 }
1442 if ui.button("Yaw +").clicked() {
1443 step_yaw = 1;
1444 }
1445 ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));
1446 });
1447 ui.horizontal(|ui| {
1448 if ui.button("Pitch −").clicked() {
1449 step_pitch = -1;
1450 }
1451 if ui.button("Pitch +").clicked() {
1452 step_pitch = 1;
1453 }
1454 ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
1455 });
1456 ui.separator();
1457 ui.label("Licht (vasthouden; °/s)");
1458 ui.horizontal(|ui| {
1459 let west = ui.button("Azimut −");
1460 let east = ui.button("Azimut +");
1461 if west.is_pointer_button_down_on() {
1462 hold_az = Some(HoldSign::Negative);
1463 } else if east.is_pointer_button_down_on() {
1464 hold_az = Some(HoldSign::Positive);
1465 } else if self.preview2d.hold_azimuth.is_some()
1466 && !west.is_pointer_button_down_on()
1467 && !east.is_pointer_button_down_on()
1468 {
1469 release_az = true;
1470 }
1471 ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
1472 });
1473 ui.horizontal(|ui| {
1474 let down = ui.button("Elevatie −");
1475 let up = ui.button("Elevatie +");
1476 if down.is_pointer_button_down_on() {
1477 hold_el = Some(HoldSign::Negative);
1478 } else if up.is_pointer_button_down_on() {
1479 hold_el = Some(HoldSign::Positive);
1480 } else if self.preview2d.hold_elevation.is_some()
1481 && !down.is_pointer_button_down_on()
1482 && !up.is_pointer_button_down_on()
1483 {
1484 release_el = true;
1485 }
1486 ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
1487 });
1488 ui.weak("Azimut 0° = licht van voren (+Z); elevatie geklemd −80…+80.");
1489 });
1490
1491 if go_back {
1492 self.session.back();
1493 return;
1494 }
1495 if refresh_catalog {
1496 self.refresh_preview2d_catalog();
1497 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1498 need_rebuild = true;
1499 }
1500 if let Some(sex) = set_sex {
1501 self.preview2d.set_sex(sex);
1502 sync_state_from_catalog(&mut self.preview2d, &self.preview2d_catalog);
1503 need_rebuild = true;
1504 }
1505 if let Some(set) = toggle_set {
1506 self.preview2d.toggle_equipment(&set);
1507 need_rebuild = true;
1508 }
1509 if let Some((token, frames, duration)) = select_clip {
1510 self.preview2d.set_clip(token, frames, duration);
1511 need_rebuild = true;
1512 }
1513 if toggle_play {
1514 self.preview2d.playing = !self.preview2d.playing;
1515 }
1516 if apply_speed {
1517 if let Ok(speed) = self.preview2d_speed_text.parse::<f32>() {
1518 if speed.is_finite() && speed >= 0.0 {
1519 self.preview2d.playback_speed = speed;
1520 }
1521 }
1522 }
1523 if step_yaw != 0 {
1524 self.preview2d.step_yaw(step_yaw);
1525 need_rebuild = true;
1526 }
1527 if step_pitch != 0 {
1528 self.preview2d.step_pitch(step_pitch);
1529 need_rebuild = true;
1530 }
1531 if let Some(sign) = hold_az {
1532 self.preview2d.hold_light(LightAxis::Azimuth, sign);
1533 } else if release_az {
1534 self.preview2d.release_light(LightAxis::Azimuth);
1535 }
1536 if let Some(sign) = hold_el {
1537 self.preview2d.hold_light(LightAxis::Elevation, sign);
1538 } else if release_el {
1539 self.preview2d.release_light(LightAxis::Elevation);
1540 }
1541
1542 // Continuous light or playing changes the image every tick.
1543 if self.preview2d.playing
1544 || self.preview2d.hold_azimuth.is_some()
1545 || self.preview2d.hold_elevation.is_some()
1546 {
1547 need_rebuild = true;
1548 }
1549
1550 let key = (
1551 self.preview2d.clip_token.clone(),
1552 self.preview2d.frame_index(),
1553 self.preview2d.pitch_deg(),
1554 self.preview2d.yaw_deg(),
1555 self.preview2d.selected_equipment.len() as u32,
1556 format!(
1557 "{:.1}/{:.1}/{}",
1558 self.preview2d.light_azimuth_deg,
1559 self.preview2d.light_elevation_deg,
1560 self.preview2d.sex_token()
1561 ),
1562 );
1563 if need_rebuild || self.preview2d_texture_key.as_ref() != Some(&key) {
1564 self.rebuild_preview2d_frame();
1565 self.preview2d_texture_key = Some(key);
1566 self.preview2d_texture = None;
1567 }
1568
1569 egui::CentralPanel::default().show(ctx, |ui| {
1570 if self.session.confirm.is_some() || self.dialog_pending {
1571 ui.disable();
1572 }
1573 if let Some(diag) = &self.preview2d.diagnostic {
1574 ui.colored_label(egui::Color32::LIGHT_RED, diag);
1575 }
1576 let Some(image) = &self.preview2d_image else {
1577 ui.label(
1578 "Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",
1579 );
1580 return;
1581 };
1582 if self.preview2d_texture.is_none() {
1583 let color = egui::ColorImage::from_rgba_unmultiplied(
1584 [image.width as usize, image.height as usize],
1585 &image.rgba,
1586 );
1587 self.preview2d_texture = Some(ui.ctx().load_texture(
1588 "preview2d-frame",
1589 color,
1590 egui::TextureOptions::NEAREST,
1591 ));
1592 }
1593 let texture = self.preview2d_texture.as_ref().expect("texture");
1594 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
1595 let fit = crate::render_job::fit_preview(
1596 [image.width, image.height],
1597 [bounds.width(), bounds.height()],
1598 );
1599 let rect = egui::Rect::from_min_size(
1600 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
1601 egui::vec2(fit.size[0], fit.size[1]),
1602 );
1603 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
1604 ui.painter().image(
1605 texture.id(),
1606 rect,
1607 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1608 egui::Color32::WHITE,
1609 );
1610 });
1611 }
1612
1203 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
1613 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
1204 if !project {
1614 if !project {
1205 return;
1615 return;
@@ -1212,6 +1622,9 @@ impl EditorApp {
1212 if ui.button("Body").clicked() {
1622 if ui.button("Body").clicked() {
1213 self.session.show_bodies();
1623 self.session.show_bodies();
1214 }
1624 }
1625 if ui.button("2D-preview").clicked() {
1626 self.open_preview2d();
1627 }
1215 if ui.button("Opslaan").clicked() {
1628 if ui.button("Opslaan").clicked() {
1216 self.save();
1629 self.save();
1217 }
1630 }
src/foundation.rs +11 −23
@@ -66,7 +66,7 @@ pub fn availability(capability: Capability) -> Availability {
66 66 | Capability::SkeletalAnimation
67 67 | Capability::AttachmentEditor
68 68 | Capability::PaperdollRender => Availability::Present,
69 Capability::Preview2d => Availability::NotBuilt,
69 Capability::Preview2d => Availability::Present,
70 70 }
71 71 }
72 72
@@ -111,7 +111,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
111 111 }
112 112
113 113 pub fn dutch_summary() -> &'static str {
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De bibliotheekmodule compositor depth-composeert body+equipment en belicht lineair; de 2D-preview-GUI is niet gebouwd."
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."
115 115 }
116 116
117 117 /// Stable status text shared by `--status` and the window.
@@ -233,7 +233,7 @@ mod tests {
233 233 use super::*;
234 234
235 235 #[test]
236 fn import_and_production_are_present_but_preview2d_gui_is_not() {
236 fn import_production_and_preview2d_are_present() {
237 237 assert_eq!(
238 238 availability(Capability::ProjectShell),
239 239 Availability::Present
@@ -256,18 +256,11 @@ mod tests {
256 256 availability(Capability::PaperdollRender),
257 257 Availability::Present
258 258 );
259 {
260 let capability = Capability::Preview2d;
261 assert_eq!(
262 availability(capability),
263 Availability::NotBuilt,
264 "{capability:?} must stay not built"
265 );
266 }
259 assert_eq!(availability(Capability::Preview2d), Availability::Present);
267 260 }
268 261
269 262 #[test]
270 fn status_report_matches_current_production_without_claiming_preview2d_gui() {
263 fn status_report_matches_current_production_including_preview2d() {
271 264 let report = report();
272 265 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
273 266 for key in [
@@ -278,27 +271,22 @@ mod tests {
278 271 "skeletal_animation",
279 272 "attachment_editor",
280 273 "paperdoll_render",
274 "preview_2d",
281 275 ] {
282 276 assert!(
283 277 report.contains(&format!("{key}: present")),
284 278 "missing present {key} in {report}"
285 279 );
286 280 }
287 {
288 let key = "preview_2d";
289 assert!(
290 report.contains(&format!("{key}: not_built")),
291 "missing {key} in {report}"
292 );
293 assert!(
294 !report.contains(&format!("{key}: present")),
295 "{key} must not be reported as present"
296 );
297 }
281 assert!(
282 !report.contains("preview_2d: not_built"),
283 "preview_2d must not stay not_built"
284 );
298 285 let summary = dutch_summary();
299 286 assert!(summary.contains("3D-preview"));
300 287 assert!(summary.contains("glTF/GLB"));
301 288 assert!(summary.contains("walk"));
289 assert!(summary.contains("2D-preview"));
302 290 }
303 291
304 292 #[test]
@@ -66,7 +66,7 @@ pub fn availability(capability: Capability) -> Availability {
66 | Capability::SkeletalAnimation
66 | Capability::SkeletalAnimation
67 | Capability::AttachmentEditor
67 | Capability::AttachmentEditor
68 | Capability::PaperdollRender => Availability::Present,
68 | Capability::PaperdollRender => Availability::Present,
69 Capability::Preview2d => Availability::NotBuilt,
69 Capability::Preview2d => Availability::Present,
70 }
70 }
71}
71}
72
72
@@ -111,7 +111,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
111}
111}
112
112
113pub fn dutch_summary() -> &'static str {
113pub fn dutch_summary() -> &'static str {
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De bibliotheekmodule compositor depth-composeert body+equipment en belicht lineair; de 2D-preview-GUI is niet gebouwd."
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."
115}
115}
116
116
117/// Stable status text shared by `--status` and the window.
117/// Stable status text shared by `--status` and the window.
@@ -233,7 +233,7 @@ mod tests {
233 use super::*;
233 use super::*;
234
234
235 #[test]
235 #[test]
236 fn import_and_production_are_present_but_preview2d_gui_is_not() {
236 fn import_production_and_preview2d_are_present() {
237 assert_eq!(
237 assert_eq!(
238 availability(Capability::ProjectShell),
238 availability(Capability::ProjectShell),
239 Availability::Present
239 Availability::Present
@@ -256,18 +256,11 @@ mod tests {
256 availability(Capability::PaperdollRender),
256 availability(Capability::PaperdollRender),
257 Availability::Present
257 Availability::Present
258 );
258 );
259 {
259 assert_eq!(availability(Capability::Preview2d), Availability::Present);
260 let capability = Capability::Preview2d;
261 assert_eq!(
262 availability(capability),
263 Availability::NotBuilt,
264 "{capability:?} must stay not built"
265 );
266 }
267 }
260 }
268
261
269 #[test]
262 #[test]
270 fn status_report_matches_current_production_without_claiming_preview2d_gui() {
263 fn status_report_matches_current_production_including_preview2d() {
271 let report = report();
264 let report = report();
272 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
265 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-editor\n"));
273 for key in [
266 for key in [
@@ -278,27 +271,22 @@ mod tests {
278 "skeletal_animation",
271 "skeletal_animation",
279 "attachment_editor",
272 "attachment_editor",
280 "paperdoll_render",
273 "paperdoll_render",
274 "preview_2d",
281 ] {
275 ] {
282 assert!(
276 assert!(
283 report.contains(&format!("{key}: present")),
277 report.contains(&format!("{key}: present")),
284 "missing present {key} in {report}"
278 "missing present {key} in {report}"
285 );
279 );
286 }
280 }
287 {
281 assert!(
288 let key = "preview_2d";
282 !report.contains("preview_2d: not_built"),
289 assert!(
283 "preview_2d must not stay not_built"
290 report.contains(&format!("{key}: not_built")),
284 );
291 "missing {key} in {report}"
292 );
293 assert!(
294 !report.contains(&format!("{key}: present")),
295 "{key} must not be reported as present"
296 );
297 }
298 let summary = dutch_summary();
285 let summary = dutch_summary();
299 assert!(summary.contains("3D-preview"));
286 assert!(summary.contains("3D-preview"));
300 assert!(summary.contains("glTF/GLB"));
287 assert!(summary.contains("glTF/GLB"));
301 assert!(summary.contains("walk"));
288 assert!(summary.contains("walk"));
289 assert!(summary.contains("2D-preview"));
302 }
290 }
303
291
304 #[test]
292 #[test]
src/lib.rs +2 −1
@@ -3,7 +3,7 @@
3 3 //! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4 4 //! draws skinned body/equipment previews, and streams production target maps.
5 5 //! Completed export runs are opened through `export_set` with checksum/identity
6 //! validation; `compositor` depth-composites compatible layers and relights them.
6 //! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback.
7 7
8 8 pub mod camera;
9 9 pub mod compositor;
@@ -17,6 +17,7 @@ pub mod foundation;
17 17 pub mod icon;
18 18 pub mod import;
19 19 pub mod preview;
20 pub mod preview2d;
20 21 pub mod preview_gl;
21 22 pub mod production;
22 23 pub mod project;
@@ -3,7 +3,7 @@
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4//! draws skinned body/equipment previews, and streams production target maps.
4//! draws skinned body/equipment previews, and streams production target maps.
5//! Completed export runs are opened through `export_set` with checksum/identity
5//! Completed export runs are opened through `export_set` with checksum/identity
6//! validation; `compositor` depth-composites compatible layers and relights them.
6//! validation; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback.
7
7
8pub mod camera;
8pub mod camera;
9pub mod compositor;
9pub mod compositor;
@@ -17,6 +17,7 @@ pub mod foundation;
17pub mod icon;
17pub mod icon;
18pub mod import;
18pub mod import;
19pub mod preview;
19pub mod preview;
20pub mod preview2d;
20pub mod preview_gl;
21pub mod preview_gl;
21pub mod production;
22pub mod production;
22pub mod project;
23pub mod project;
src/preview2d.rs added +1058 −0
@@ -0,0 +1,1058 @@
1 //! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2 //!
3 //! Consumes completed schema-3 export runs through `export_set` + `compositor`.
4 //! Saved production diffuse stays unlit albedo; lighting is preview-only.
5
6 use std::collections::BTreeMap;
7 use std::fs;
8 use std::path::{Path, PathBuf};
9
10 use crate::compositor::{
11 assert_compatible, compose_and_relight, CompatKey, ComposeError, LayerMaps, Light,
12 };
13 use crate::export_set::{ExportError, ExportRun};
14 use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
15 use crate::render_job::ImageBuf;
16 use crate::session::Sex;
17
18 /// Degrees per second while a light hold button stays pressed.
19 pub const LIGHT_AZIMUTH_RATE_DEG_PER_SEC: f32 = 90.0;
20 pub const LIGHT_ELEVATION_RATE_DEG_PER_SEC: f32 = 60.0;
21
22 /// Elevation clamp (degrees). Not cyclic; soft limit keeps the light off the horizon flip.
23 pub const LIGHT_ELEVATION_MIN_DEG: f32 = -80.0;
24 pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
25
26 /// Default light pose: slightly above and from the front (+Z in view space).
27 pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
28 pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
29
30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 pub enum LightAxis {
32 Azimuth,
33 Elevation,
34 }
35
36 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
37 pub enum HoldSign {
38 Positive,
39 Negative,
40 }
41
42 impl HoldSign {
43 pub fn as_f32(self) -> f32 {
44 match self {
45 Self::Positive => 1.0,
46 Self::Negative => -1.0,
47 }
48 }
49 }
50
51 /// One completed export run discovered under a project `renders/` tree.
52 #[derive(Debug, Clone)]
53 pub struct CatalogEntry {
54 pub path: PathBuf,
55 pub sex: String,
56 pub set: String,
57 pub project_id: String,
58 pub fps: u32,
59 pub target: [u32; 2],
60 pub clips: Vec<(String, String, u32, f32)>,
61 pub body_model_sha256: String,
62 pub equipment_preset_sha256: Option<String>,
63 pub key: CompatKey,
64 }
65
66 impl CatalogEntry {
67 pub fn from_run(path: PathBuf, run: &ExportRun) -> Self {
68 Self {
69 path,
70 sex: run.render.sex.clone(),
71 set: run.render.set.clone(),
72 project_id: run.render.project.id.clone(),
73 fps: run.render.fps,
74 target: run.render.target,
75 clips: run
76 .render
77 .clips
78 .iter()
79 .map(|c| (c.token.clone(), c.name.clone(), c.frames, c.duration))
80 .collect(),
81 body_model_sha256: run.render.body_model.sha256.clone(),
82 equipment_preset_sha256: run
83 .render
84 .equipment_preset
85 .as_ref()
86 .map(|p| p.sha256.clone()),
87 key: CompatKey::from_run(run),
88 }
89 }
90
91 pub fn is_body(&self) -> bool {
92 self.set == "body"
93 }
94 }
95
96 /// Scan `project_root/renders/*/complete.json` and open only valid completed runs.
97 pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
98 let renders = project_root.join("renders");
99 let Ok(entries) = fs::read_dir(&renders) else {
100 return Vec::new();
101 };
102 let mut out = Vec::new();
103 for entry in entries.flatten() {
104 let path = entry.path();
105 if !path.is_dir() {
106 continue;
107 }
108 if !path.join("complete.json").is_file() {
109 continue;
110 }
111 match ExportRun::open(&path) {
112 Ok(run) => out.push(CatalogEntry::from_run(path, &run)),
113 Err(_) => continue,
114 }
115 }
116 out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));
117 out
118 }
119
120 /// Discrete yaw index 0..11 → degrees 0,30,…,330.
121 pub fn yaw_deg_from_index(index: i32) -> u32 {
122 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as u32;
123 wrapped * 30
124 }
125
126 pub fn pitch_deg_from_row(row: i32) -> i32 {
127 let row = row.clamp(0, (PAPERDOLL_PITCHES_DEG.len() as i32) - 1) as usize;
128 PAPERDOLL_PITCHES_DEG[row] as i32
129 }
130
131 pub fn pitch_row_from_deg(pitch: i32) -> i32 {
132 PAPERDOLL_PITCHES_DEG
133 .iter()
134 .position(|p| *p as i32 == pitch)
135 .unwrap_or(1) as i32
136 }
137
138 /// View-space light direction toward the light.
139 ///
140 /// Convention (right-hand, view +Y up, camera looks −Z toward the character so
141 /// character front faces +Z in view):
142 /// - azimuth 0° = light from +Z (in front of the character)
143 /// - positive azimuth rotates toward +X (character's left when facing camera)
144 /// - elevation 0° = horizon; positive raises the light
145 pub fn light_direction_view(azimuth_deg: f32, elevation_deg: f32) -> [f32; 3] {
146 let az = azimuth_deg.to_radians();
147 let el = elevation_deg.to_radians();
148 let cos_el = el.cos();
149 [cos_el * az.sin(), el.sin(), cos_el * az.cos()]
150 }
151
152 pub fn wrap_azimuth_deg(deg: f32) -> f32 {
153 let mut d = deg % 360.0;
154 if d < 0.0 {
155 d += 360.0;
156 }
157 d
158 }
159
160 pub fn clamp_elevation_deg(deg: f32) -> f32 {
161 deg.clamp(LIGHT_ELEVATION_MIN_DEG, LIGHT_ELEVATION_MAX_DEG)
162 }
163
164 fn wrap_time(time: f32, duration: f32) -> f32 {
165 if !(duration.is_finite() && duration > 0.0 && time.is_finite()) {
166 return 0.0;
167 }
168 let mut t = time % duration;
169 if t < 0.0 {
170 t += duration;
171 }
172 t
173 }
174
175 /// Playback / view / light state for the rendered 2D tab.
176 #[derive(Debug, Clone)]
177 pub struct Preview2dState {
178 pub sex: Sex,
179 /// Equipment set names currently selected (body is always included when present).
180 pub selected_equipment: Vec<String>,
181 pub clip_token: String,
182 pub clip_frames: u32,
183 pub clip_duration: f32,
184 pub fps: u32,
185 pub playback_speed: f32,
186 pub playing: bool,
187 /// Elapsed animation seconds (preserved across yaw/pitch changes).
188 pub elapsed_secs: f32,
189 pub yaw_index: i32,
190 pub pitch_row: i32,
191 pub light_azimuth_deg: f32,
192 pub light_elevation_deg: f32,
193 pub hold_azimuth: Option<HoldSign>,
194 pub hold_elevation: Option<HoldSign>,
195 pub diagnostic: Option<String>,
196 }
197
198 impl Default for Preview2dState {
199 fn default() -> Self {
200 Self {
201 sex: Sex::Male,
202 selected_equipment: Vec::new(),
203 clip_token: String::new(),
204 clip_frames: 1,
205 clip_duration: 0.0,
206 fps: 12,
207 playback_speed: 1.0,
208 playing: true,
209 elapsed_secs: 0.0,
210 yaw_index: 0,
211 pitch_row: 1,
212 light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
213 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
214 hold_azimuth: None,
215 hold_elevation: None,
216 diagnostic: None,
217 }
218 }
219 }
220
221 impl Preview2dState {
222 pub fn sex_token(&self) -> &'static str {
223 match self.sex {
224 Sex::Male => "male",
225 Sex::Female => "female",
226 }
227 }
228
229 pub fn yaw_deg(&self) -> u32 {
230 yaw_deg_from_index(self.yaw_index)
231 }
232
233 pub fn pitch_deg(&self) -> i32 {
234 pitch_deg_from_row(self.pitch_row)
235 }
236
237 pub fn frame_index(&self) -> u32 {
238 let frames = self.clip_frames.max(1);
239 if self.fps == 0 {
240 return 0;
241 }
242 // `elapsed_secs` already advances by `dt * playback_speed` in `tick`.
243 let samples = self.elapsed_secs * self.fps as f32;
244 if !samples.is_finite() || samples < 0.0 {
245 return 0;
246 }
247 (samples.floor() as u64 % u64::from(frames)) as u32
248 }
249
250 pub fn set_clip(&mut self, token: String, frames: u32, duration: f32) {
251 if self.clip_token != token {
252 self.clip_token = token;
253 self.elapsed_secs = 0.0;
254 }
255 self.clip_frames = frames.max(1);
256 self.clip_duration = if duration.is_finite() && duration >= 0.0 {
257 duration
258 } else {
259 0.0
260 };
261 self.diagnostic = None;
262 }
263
264 pub fn step_yaw(&mut self, delta: i32) {
265 self.yaw_index = (self.yaw_index + delta).rem_euclid(PAPERDOLL_YAW_COUNT as i32);
266 // Animation time intentionally preserved.
267 }
268
269 pub fn step_pitch(&mut self, delta: i32) {
270 self.pitch_row = (self.pitch_row + delta).clamp(0, 2);
271 // Animation time intentionally preserved; pitch is clamped, not cyclic.
272 }
273
274 pub fn hold_light(&mut self, axis: LightAxis, sign: HoldSign) {
275 match axis {
276 LightAxis::Azimuth => self.hold_azimuth = Some(sign),
277 LightAxis::Elevation => self.hold_elevation = Some(sign),
278 }
279 }
280
281 pub fn release_light(&mut self, axis: LightAxis) {
282 match axis {
283 LightAxis::Azimuth => self.hold_azimuth = None,
284 LightAxis::Elevation => self.hold_elevation = None,
285 }
286 }
287
288 pub fn release_all_holds(&mut self) {
289 self.hold_azimuth = None;
290 self.hold_elevation = None;
291 }
292
293 /// Stop continuous light motion (button release, focus loss, pointer cancel).
294 pub fn on_interaction_cancel(&mut self) {
295 self.release_all_holds();
296 }
297
298 pub fn light(&self) -> Light {
299 Light {
300 direction_view: light_direction_view(self.light_azimuth_deg, self.light_elevation_deg),
301 ..Light::default()
302 }
303 }
304
305 /// Advance playback and held light controls by `dt` seconds.
306 pub fn tick(&mut self, dt: f32) {
307 let dt = if dt.is_finite() && dt > 0.0 {
308 dt.min(0.25)
309 } else {
310 0.0
311 };
312 if self.playing && dt > 0.0 {
313 let duration = if self.clip_duration > 0.0 {
314 self.clip_duration
315 } else if self.fps > 0 {
316 self.clip_frames.max(1) as f32 / self.fps as f32
317 } else {
318 1.0
319 };
320 let speed = if self.playback_speed.is_finite() {
321 self.playback_speed.max(0.0)
322 } else {
323 0.0
324 };
325 self.elapsed_secs = wrap_time(self.elapsed_secs + dt * speed, duration);
326 }
327 if let Some(sign) = self.hold_azimuth {
328 self.light_azimuth_deg = wrap_azimuth_deg(
329 self.light_azimuth_deg + sign.as_f32() * LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt,
330 );
331 }
332 if let Some(sign) = self.hold_elevation {
333 self.light_elevation_deg = clamp_elevation_deg(
334 self.light_elevation_deg + sign.as_f32() * LIGHT_ELEVATION_RATE_DEG_PER_SEC * dt,
335 );
336 }
337 }
338
339 pub fn toggle_equipment(&mut self, set: &str) {
340 if set == "body" {
341 return;
342 }
343 if let Some(pos) = self.selected_equipment.iter().position(|s| s == set) {
344 self.selected_equipment.remove(pos);
345 } else {
346 self.selected_equipment.push(set.to_string());
347 self.selected_equipment.sort();
348 }
349 self.diagnostic = None;
350 }
351
352 pub fn set_sex(&mut self, sex: Sex) {
353 if self.sex != sex {
354 self.sex = sex;
355 self.selected_equipment.clear();
356 self.clip_token.clear();
357 self.elapsed_secs = 0.0;
358 self.diagnostic = None;
359 }
360 }
361 }
362
363 #[derive(Debug, Clone)]
364 pub struct ComposeRequest<'a> {
365 pub body: &'a ExportRun,
366 pub equipment: &'a [ExportRun],
367 pub clip_token: &'a str,
368 pub frame: u32,
369 pub pitch: i32,
370 pub yaw: u32,
371 pub light: Light,
372 }
373
374 #[derive(Debug)]
375 pub enum Preview2dError {
376 Compatible(ComposeError),
377 Export(ExportError),
378 Missing {
379 set: String,
380 clip: String,
381 frame: u32,
382 pitch: i32,
383 yaw: u32,
384 detail: String,
385 },
386 NoBody,
387 }
388
389 impl std::fmt::Display for Preview2dError {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 match self {
392 Self::Compatible(e) => write!(f, "{e}"),
393 Self::Export(e) => write!(f, "{e}"),
394 Self::Missing {
395 set,
396 clip,
397 frame,
398 pitch,
399 yaw,
400 detail,
401 } => write!(
402 f,
403 "ontbrekende maps voor set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}"
404 ),
405 Self::NoBody => write!(f, "geen body-exportset voor dit geslacht"),
406 }
407 }
408 }
409
410 impl std::error::Error for Preview2dError {}
411
412 impl From<ComposeError> for Preview2dError {
413 fn from(value: ComposeError) -> Self {
414 Self::Compatible(value)
415 }
416 }
417
418 impl From<ExportError> for Preview2dError {
419 fn from(value: ExportError) -> Self {
420 Self::Export(value)
421 }
422 }
423
424 /// Compose + relight one view. Missing maps become an explicit error (no silent substitute).
425 pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
426 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
427 runs.push(req.body);
428 for run in req.equipment {
429 runs.push(run);
430 }
431 assert_compatible(&runs)?;
432
433 let mut layers = Vec::with_capacity(runs.len());
434 let body_set = req.body.render.set.as_str();
435 match LayerMaps::from_export_run(
436 req.body,
437 body_set,
438 req.clip_token,
439 req.frame,
440 req.pitch,
441 req.yaw,
442 ) {
443 Ok(layer) => layers.push(layer),
444 Err(err) => {
445 return Err(Preview2dError::Missing {
446 set: body_set.to_string(),
447 clip: req.clip_token.to_string(),
448 frame: req.frame,
449 pitch: req.pitch,
450 yaw: req.yaw,
451 detail: err.to_string(),
452 });
453 }
454 }
455 for run in req.equipment {
456 let set = run.render.set.as_str();
457 match LayerMaps::from_export_run(run, set, req.clip_token, req.frame, req.pitch, req.yaw) {
458 Ok(layer) => layers.push(layer),
459 Err(err) => {
460 return Err(Preview2dError::Missing {
461 set: set.to_string(),
462 clip: req.clip_token.to_string(),
463 frame: req.frame,
464 pitch: req.pitch,
465 yaw: req.yaw,
466 detail: err.to_string(),
467 });
468 }
469 }
470 }
471 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
472 Ok(lit)
473 }
474
475 /// Open body + selected equipment runs for one sex, validating compatibility.
476 pub fn open_selection(
477 catalog: &[CatalogEntry],
478 sex: Sex,
479 equipment_sets: &[String],
480 ) -> Result<(ExportRun, Vec<ExportRun>), Preview2dError> {
481 let sex_token = match sex {
482 Sex::Male => "male",
483 Sex::Female => "female",
484 };
485 let body_entry = catalog
486 .iter()
487 .filter(|e| e.sex == sex_token && e.is_body())
488 .max_by_key(|e| e.path.clone())
489 .ok_or(Preview2dError::NoBody)?;
490 let body = ExportRun::open(&body_entry.path)?;
491 let mut equipment = Vec::new();
492 for set in equipment_sets {
493 let entry = catalog
494 .iter()
495 .filter(|e| e.sex == sex_token && e.set == *set && !e.is_body())
496 .max_by_key(|e| e.path.clone())
497 .ok_or_else(|| Preview2dError::Missing {
498 set: set.clone(),
499 clip: String::new(),
500 frame: 0,
501 pitch: 0,
502 yaw: 0,
503 detail: "geen voltooide exportset voor dit equipment".into(),
504 })?;
505 equipment.push(ExportRun::open(&entry.path)?);
506 }
507 let mut refs: Vec<&ExportRun> = vec![&body];
508 refs.extend(equipment.iter());
509 assert_compatible(&refs)?;
510 Ok((body, equipment))
511 }
512
513 /// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).
514 pub fn compatible_equipment_sets(catalog: &[CatalogEntry], sex: Sex) -> Vec<String> {
515 let sex_token = match sex {
516 Sex::Male => "male",
517 Sex::Female => "female",
518 };
519 let Some(body) = catalog
520 .iter()
521 .filter(|e| e.sex == sex_token && e.is_body())
522 .max_by_key(|e| e.path.clone())
523 else {
524 return Vec::new();
525 };
526 let mut names = BTreeMap::new();
527 for entry in catalog {
528 if entry.sex != sex_token || entry.is_body() {
529 continue;
530 }
531 if entry.key.project_id != body.key.project_id
532 || entry.key.sex != body.key.sex
533 || entry.key.body_model_sha256 != body.key.body_model_sha256
534 || entry.key.target != body.key.target
535 || entry.key.fps != body.key.fps
536 || entry.key.framing != body.key.framing
537 || (entry.key.near - body.key.near).abs() > f32::EPSILON
538 || (entry.key.far - body.key.far).abs() > f32::EPSILON
539 || entry.key.clip_tokens != body.key.clip_tokens
540 {
541 continue;
542 }
543 names.insert(entry.set.clone(), ());
544 }
545 names.into_keys().collect()
546 }
547
548 /// Apply catalog defaults when sex/clip inventory changes.
549 pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEntry]) {
550 let sex_token = state.sex_token();
551 let body = catalog
552 .iter()
553 .filter(|e| e.sex == sex_token && e.is_body())
554 .max_by_key(|e| e.path.clone());
555 let Some(body) = body else {
556 state.diagnostic = Some("geen voltooide body-export voor dit geslacht".into());
557 return;
558 };
559 state.fps = body.fps;
560 let allowed = compatible_equipment_sets(catalog, state.sex);
561 state
562 .selected_equipment
563 .retain(|s| allowed.iter().any(|a| a == s));
564 if state.clip_token.is_empty() || !body.clips.iter().any(|(t, _, _, _)| t == &state.clip_token)
565 {
566 if let Some((token, _name, frames, duration)) = body.clips.first() {
567 state.set_clip(token.clone(), *frames, *duration);
568 }
569 } else if let Some((_, _, frames, duration)) = body
570 .clips
571 .iter()
572 .find(|(t, _, _, _)| t == &state.clip_token)
573 {
574 state.clip_frames = (*frames).max(1);
575 state.clip_duration = *duration;
576 }
577 }
578
579 #[cfg(test)]
580 mod tests {
581 use super::*;
582 use crate::digest;
583 use crate::export_set::complete_identity_json;
584 use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
585 use crate::project::CameraFraming;
586 use std::collections::BTreeMap as Map;
587 use std::sync::atomic::{AtomicU64, Ordering};
588
589 static COUNTER: AtomicU64 = AtomicU64::new(0);
590
591 struct Temp(PathBuf);
592 impl Temp {
593 fn new() -> Self {
594 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
595 let path = std::env::temp_dir().join(format!(
596 "paperdoll-preview2d-{}-{}-{}",
597 std::process::id(),
598 n,
599 std::time::SystemTime::now()
600 .duration_since(std::time::UNIX_EPOCH)
601 .unwrap()
602 .as_nanos()
603 ));
604 fs::create_dir_all(&path).unwrap();
605 Self(path)
606 }
607 }
608 impl Drop for Temp {
609 fn drop(&mut self) {
610 let _ = fs::remove_dir_all(&self.0);
611 }
612 }
613
614 fn framing() -> CameraFraming {
615 CameraFraming {
616 height_milli: 1800,
617 center_y_milli: 900,
618 pivot_y_milli: Some(1500),
619 }
620 }
621
622 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
623 let data = serde_json::to_vec_pretty(value).unwrap();
624 let path = root.join(name);
625 let temp = path.with_extension(format!("part-{}", std::process::id()));
626 fs::write(&temp, &data).unwrap();
627 fs::hard_link(&temp, &path).unwrap();
628 let _ = fs::remove_file(temp);
629 }
630
631 #[allow(clippy::too_many_arguments)]
632 fn publish_pose(
633 root: &Path,
634 sex: &str,
635 set: &str,
636 clip: &str,
637 frame: u32,
638 pitch: i32,
639 yaw: u32,
640 diffuse: [u8; 4],
641 normal: [u8; 4],
642 specular: [u8; 4],
643 depth_gray: u16,
644 ) -> Map<String, String> {
645 let mut map = Map::new();
646 let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
647 let rels = [
648 (format!("{base}_diffuse.png"), "diffuse"),
649 (format!("{base}_depth.png"), "depth"),
650 (format!("{base}_normal.png"), "normal"),
651 (format!("{base}_specular.png"), "specular"),
652 ];
653 for (rel, kind) in rels {
654 let path = Path::new(&rel);
655 match kind {
656 "depth" => {
657 write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
658 }
659 "normal" => {
660 write_rgba(
661 root,
662 path,
663 &ImageBuf {
664 width: 1,
665 height: 1,
666 rgba: normal.to_vec(),
667 },
668 )
669 .unwrap();
670 }
671 "specular" => {
672 write_rgba(
673 root,
674 path,
675 &ImageBuf {
676 width: 1,
677 height: 1,
678 rgba: specular.to_vec(),
679 },
680 )
681 .unwrap();
682 }
683 _ => {
684 write_rgba(
685 root,
686 path,
687 &ImageBuf {
688 width: 1,
689 height: 1,
690 rgba: diffuse.to_vec(),
691 },
692 )
693 .unwrap();
694 }
695 }
696 let bytes = fs::read(root.join(&rel)).unwrap();
697 map.insert(rel, digest::sha256_hex(&bytes));
698 }
699 map
700 }
701
702 #[allow(clippy::too_many_arguments)]
703 fn write_run(
704 root: &Path,
705 project_id: &str,
706 sex: &str,
707 set: &str,
708 equipment_sha: Option<&str>,
709 body_bytes: &[u8],
710 clips: &[(&str, &str, u32, f32)],
711 checksums: Map<String, String>,
712 ) {
713 let body_hash = digest::sha256_hex(body_bytes);
714 let clips_json: Vec<_> = clips
715 .iter()
716 .map(|(name, token, frames, duration)| {
717 serde_json::json!({
718 "name": name,
719 "token": token,
720 "frames": frames,
721 "duration": duration
722 })
723 })
724 .collect();
725 let images: u32 = clips.iter().map(|(_, _, f, _)| *f).sum::<u32>() * 36;
726 let png_files = checksums.len() as u32;
727 let mut render = serde_json::json!({
728 "format_version": 3,
729 "status": "incomplete",
730 "project": {"id": project_id, "name": "Demo"},
731 "sex": sex,
732 "set": set,
733 "body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
734 "equipment_preset": null,
735 "target": [1, 1],
736 "fps": 12,
737 "highres_scale": 8,
738 "framing": framing(),
739 "pivot_source": {"kind": "manual"},
740 "near": NEAR,
741 "far": FAR,
742 "camera_distance": 10.0,
743 "yaw_zero": "camera on -Z",
744 "normal_space": "view (+Y up), PNG rows top-down",
745 "pitches": [-30, 0, 30],
746 "yaw_step": 30,
747 "sampling": "test",
748 "alpha": "straight",
749 "diffuse": "unlit",
750 "depth": "16-bit",
751 "specular": "scalar",
752 "pass_order": PASSES,
753 "saved_maps": ["diffuse", "depth", "normal", "specular"],
754 "clips": clips_json,
755 "images": images,
756 "passes": images * 6,
757 "png_files": png_files
758 });
759 if let Some(sha) = equipment_sha {
760 render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
761 }
762 write_atomic_json(root, "render.json", &render);
763 let identity = complete_identity_json(&render).unwrap();
764 write_atomic_json(
765 root,
766 "complete.json",
767 &serde_json::json!({
768 "format_version": 3,
769 "status": "complete",
770 "identity": identity,
771 "passes": images * 6,
772 "png_files": png_files,
773 "png_sha256": checksums
774 }),
775 );
776 }
777
778 #[test]
779 fn yaw_wraps_twelve_steps_pitch_clamps_three_rows() {
780 let mut state = Preview2dState {
781 elapsed_secs: 1.25,
782 ..Preview2dState::default()
783 };
784 assert_eq!(state.yaw_deg(), 0);
785 state.step_yaw(-1);
786 assert_eq!(state.yaw_deg(), 330);
787 assert_eq!(state.elapsed_secs, 1.25);
788 state.step_yaw(1);
789 assert_eq!(state.yaw_deg(), 0);
790 for _ in 0..12 {
791 state.step_yaw(1);
792 }
793 assert_eq!(state.yaw_deg(), 0);
794 assert_eq!(PAPERDOLL_YAW_COUNT, 12);
795
796 state.pitch_row = 1;
797 state.step_pitch(1);
798 assert_eq!(state.pitch_deg(), 30);
799 state.step_pitch(1);
800 assert_eq!(state.pitch_deg(), 30);
801 state.step_pitch(-1);
802 assert_eq!(state.pitch_deg(), 0);
803 state.step_pitch(-1);
804 assert_eq!(state.pitch_deg(), -30);
805 state.step_pitch(-1);
806 assert_eq!(state.pitch_deg(), -30);
807 assert_eq!(state.elapsed_secs, 1.25);
808 }
809
810 #[test]
811 fn playback_accumulates_elapsed_and_loops_by_fps() {
812 let mut state = Preview2dState {
813 fps: 10,
814 clip_frames: 4,
815 clip_duration: 0.4,
816 playback_speed: 1.0,
817 playing: true,
818 ..Preview2dState::default()
819 };
820 assert_eq!(state.frame_index(), 0);
821 state.tick(0.15);
822 assert_eq!(state.frame_index(), 1);
823 state.tick(0.25);
824 assert_eq!(state.frame_index(), 0); // wrapped: 0.4s * 10fps = frame 0 again after loop
825 // 0.15+0.25=0.40 → wrap to 0 → frame 0
826 state.playing = false;
827 let frozen = state.elapsed_secs;
828 state.tick(1.0);
829 assert_eq!(state.elapsed_secs, frozen);
830 state.playing = true;
831 state.playback_speed = 2.0;
832 state.elapsed_secs = 0.0;
833 state.tick(0.05);
834 // elapsed += 0.05*2 = 0.10; samples = 0.10*10 = 1 → frame 1
835 assert_eq!(state.frame_index(), 1);
836 }
837
838 #[test]
839 fn light_hold_uses_dt_and_stops_on_release_or_cancel() {
840 let mut state = Preview2dState::default();
841 let start_az = state.light_azimuth_deg;
842 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
843 // Per-tick dt is capped at 0.25s (hitch guard).
844 state.tick(0.25);
845 state.tick(0.25);
846 let mid = state.light_azimuth_deg;
847 assert!((mid - start_az - 45.0).abs() < 1e-3);
848 state.release_light(LightAxis::Azimuth);
849 state.tick(0.25);
850 assert_eq!(state.light_azimuth_deg, mid);
851
852 state.hold_light(LightAxis::Elevation, HoldSign::Positive);
853 state.tick(0.25); // +15°
854 assert!(state.light_elevation_deg <= LIGHT_ELEVATION_MAX_DEG);
855 // Drive to clamp
856 state.light_elevation_deg = LIGHT_ELEVATION_MAX_DEG - 1.0;
857 state.tick(0.25);
858 assert_eq!(state.light_elevation_deg, LIGHT_ELEVATION_MAX_DEG);
859 state.on_interaction_cancel();
860 assert!(state.hold_azimuth.is_none() && state.hold_elevation.is_none());
861
862 state.light_azimuth_deg = 350.0;
863 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
864 state.tick(0.2); // +18 → 368 → 8
865 assert!((state.light_azimuth_deg - 8.0).abs() < 1e-3);
866 }
867
868 #[test]
869 fn light_direction_convention_front_and_up() {
870 let front = light_direction_view(0.0, 0.0);
871 assert!((front[0]).abs() < 1e-5);
872 assert!((front[1]).abs() < 1e-5);
873 assert!((front[2] - 1.0).abs() < 1e-5);
874 let up = light_direction_view(0.0, 90.0);
875 assert!((up[1] - 1.0).abs() < 1e-5);
876 let leftish = light_direction_view(90.0, 0.0);
877 assert!((leftish[0] - 1.0).abs() < 1e-5);
878 }
879
880 #[test]
881 fn missing_frame_is_diagnostic_not_silent_substitute() {
882 let body_tmp = Temp::new();
883 let clips = [("walk", "clip000-walk", 2u32, 0.1667f32)];
884 let mut checksums = Map::new();
885 // Only frame 0 / pitch 0 / yaw 0 published — frame 1 missing.
886 checksums.extend(publish_pose(
887 &body_tmp.0,
888 "male",
889 "body",
890 "clip000-walk",
891 0,
892 0,
893 0,
894 [10, 20, 30, 255],
895 [128, 128, 255, 255],
896 [0, 0, 0, 255],
897 20000,
898 ));
899 write_run(
900 &body_tmp.0,
901 "demo",
902 "male",
903 "body",
904 None,
905 b"shared-body",
906 &clips,
907 checksums,
908 );
909 let run = ExportRun::open(&body_tmp.0).unwrap();
910 let ok = compose_preview_frame(ComposeRequest {
911 body: &run,
912 equipment: &[],
913 clip_token: "clip000-walk",
914 frame: 0,
915 pitch: 0,
916 yaw: 0,
917 light: Light::default(),
918 })
919 .unwrap();
920 assert_eq!(ok.width, 1);
921 let err = compose_preview_frame(ComposeRequest {
922 body: &run,
923 equipment: &[],
924 clip_token: "clip000-walk",
925 frame: 1,
926 pitch: 0,
927 yaw: 0,
928 light: Light::default(),
929 })
930 .unwrap_err();
931 match err {
932 Preview2dError::Missing {
933 set, clip, frame, ..
934 } => {
935 assert_eq!(set, "body");
936 assert_eq!(clip, "clip000-walk");
937 assert_eq!(frame, 1);
938 }
939 other => panic!("expected Missing, got {other}"),
940 }
941 }
942
943 #[test]
944 fn catalog_compatibility_filters_equipment_by_export_contract() {
945 let project = Temp::new();
946 let renders = project.0.join("renders");
947 fs::create_dir_all(&renders).unwrap();
948 let body_dir = renders.join("run-body");
949 let good_dir = renders.join("run-sword");
950 let bad_dir = renders.join("run-bad");
951 fs::create_dir_all(&body_dir).unwrap();
952 fs::create_dir_all(&good_dir).unwrap();
953 fs::create_dir_all(&bad_dir).unwrap();
954 let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
955 let body_bytes = b"shared-body";
956 let mut body_maps = Map::new();
957 body_maps.extend(publish_pose(
958 &body_dir,
959 "male",
960 "body",
961 "clip000-bind",
962 0,
963 0,
964 0,
965 [1, 2, 3, 255],
966 [128, 128, 255, 255],
967 [0, 0, 0, 255],
968 1000,
969 ));
970 write_run(
971 &body_dir, "proj-a", "male", "body", None, body_bytes, &clips, body_maps,
972 );
973 let mut good_maps = Map::new();
974 good_maps.extend(publish_pose(
975 &good_dir,
976 "male",
977 "sword",
978 "clip000-bind",
979 0,
980 0,
981 0,
982 [200, 10, 10, 255],
983 [255, 128, 128, 255],
984 [180, 180, 180, 255],
985 500,
986 ));
987 write_run(
988 &good_dir,
989 "proj-a",
990 "male",
991 "sword",
992 Some(&digest::sha256_hex(b"sword")),
993 body_bytes,
994 &clips,
995 good_maps,
996 );
997 let mut bad_maps = Map::new();
998 bad_maps.extend(publish_pose(
999 &bad_dir,
1000 "male",
1001 "helm",
1002 "clip000-bind",
1003 0,
1004 0,
1005 0,
1006 [9, 9, 9, 255],
1007 [128, 128, 255, 255],
1008 [0, 0, 0, 255],
1009 800,
1010 ));
1011 // Different body fingerprint → incompatible.
1012 write_run(
1013 &bad_dir,
1014 "proj-a",
1015 "male",
1016 "helm",
1017 Some(&digest::sha256_hex(b"helm")),
1018 b"other-body",
1019 &clips,
1020 bad_maps,
1021 );
1022
1023 let catalog = discover_completed_runs(&project.0);
1024 assert_eq!(catalog.len(), 3);
1025 let allowed = compatible_equipment_sets(&catalog, Sex::Male);
1026 assert_eq!(allowed, vec!["sword".to_string()]);
1027 assert!(!allowed.iter().any(|s| s == "helm"));
1028
1029 let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();
1030 assert_eq!(body.render.set, "body");
1031 assert_eq!(gear.len(), 1);
1032 let lit = compose_preview_frame(ComposeRequest {
1033 body: &body,
1034 equipment: &gear,
1035 clip_token: "clip000-bind",
1036 frame: 0,
1037 pitch: 0,
1038 yaw: 0,
1039 light: Light::default(),
1040 })
1041 .unwrap();
1042 assert_eq!(lit.rgba[3], 255);
1043 // Foreground equipment (closer depth) wins.
1044 assert!(lit.rgba[0] > 100);
1045 }
1046
1047 #[test]
1048 fn discover_skips_incomplete_and_wrong_schema() {
1049 let project = Temp::new();
1050 let renders = project.0.join("renders");
1051 let incomplete = renders.join("run-incomplete");
1052 fs::create_dir_all(&incomplete).unwrap();
1053 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1054 // no complete.json
1055 let catalog = discover_completed_runs(&project.0);
1056 assert!(catalog.is_empty());
1057 }
1058 }
@@ -0,0 +1,1058 @@
1//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2//!
3//! Consumes completed schema-3 export runs through `export_set` + `compositor`.
4//! Saved production diffuse stays unlit albedo; lighting is preview-only.
5
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::compositor::{
11 assert_compatible, compose_and_relight, CompatKey, ComposeError, LayerMaps, Light,
12};
13use crate::export_set::{ExportError, ExportRun};
14use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
15use crate::render_job::ImageBuf;
16use crate::session::Sex;
17
18/// Degrees per second while a light hold button stays pressed.
19pub const LIGHT_AZIMUTH_RATE_DEG_PER_SEC: f32 = 90.0;
20pub const LIGHT_ELEVATION_RATE_DEG_PER_SEC: f32 = 60.0;
21
22/// Elevation clamp (degrees). Not cyclic; soft limit keeps the light off the horizon flip.
23pub const LIGHT_ELEVATION_MIN_DEG: f32 = -80.0;
24pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
25
26/// Default light pose: slightly above and from the front (+Z in view space).
27pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
28pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LightAxis {
32 Azimuth,
33 Elevation,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum HoldSign {
38 Positive,
39 Negative,
40}
41
42impl HoldSign {
43 pub fn as_f32(self) -> f32 {
44 match self {
45 Self::Positive => 1.0,
46 Self::Negative => -1.0,
47 }
48 }
49}
50
51/// One completed export run discovered under a project `renders/` tree.
52#[derive(Debug, Clone)]
53pub struct CatalogEntry {
54 pub path: PathBuf,
55 pub sex: String,
56 pub set: String,
57 pub project_id: String,
58 pub fps: u32,
59 pub target: [u32; 2],
60 pub clips: Vec<(String, String, u32, f32)>,
61 pub body_model_sha256: String,
62 pub equipment_preset_sha256: Option<String>,
63 pub key: CompatKey,
64}
65
66impl CatalogEntry {
67 pub fn from_run(path: PathBuf, run: &ExportRun) -> Self {
68 Self {
69 path,
70 sex: run.render.sex.clone(),
71 set: run.render.set.clone(),
72 project_id: run.render.project.id.clone(),
73 fps: run.render.fps,
74 target: run.render.target,
75 clips: run
76 .render
77 .clips
78 .iter()
79 .map(|c| (c.token.clone(), c.name.clone(), c.frames, c.duration))
80 .collect(),
81 body_model_sha256: run.render.body_model.sha256.clone(),
82 equipment_preset_sha256: run
83 .render
84 .equipment_preset
85 .as_ref()
86 .map(|p| p.sha256.clone()),
87 key: CompatKey::from_run(run),
88 }
89 }
90
91 pub fn is_body(&self) -> bool {
92 self.set == "body"
93 }
94}
95
96/// Scan `project_root/renders/*/complete.json` and open only valid completed runs.
97pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
98 let renders = project_root.join("renders");
99 let Ok(entries) = fs::read_dir(&renders) else {
100 return Vec::new();
101 };
102 let mut out = Vec::new();
103 for entry in entries.flatten() {
104 let path = entry.path();
105 if !path.is_dir() {
106 continue;
107 }
108 if !path.join("complete.json").is_file() {
109 continue;
110 }
111 match ExportRun::open(&path) {
112 Ok(run) => out.push(CatalogEntry::from_run(path, &run)),
113 Err(_) => continue,
114 }
115 }
116 out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));
117 out
118}
119
120/// Discrete yaw index 0..11 → degrees 0,30,…,330.
121pub fn yaw_deg_from_index(index: i32) -> u32 {
122 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as u32;
123 wrapped * 30
124}
125
126pub fn pitch_deg_from_row(row: i32) -> i32 {
127 let row = row.clamp(0, (PAPERDOLL_PITCHES_DEG.len() as i32) - 1) as usize;
128 PAPERDOLL_PITCHES_DEG[row] as i32
129}
130
131pub fn pitch_row_from_deg(pitch: i32) -> i32 {
132 PAPERDOLL_PITCHES_DEG
133 .iter()
134 .position(|p| *p as i32 == pitch)
135 .unwrap_or(1) as i32
136}
137
138/// View-space light direction toward the light.
139///
140/// Convention (right-hand, view +Y up, camera looks −Z toward the character so
141/// character front faces +Z in view):
142/// - azimuth 0° = light from +Z (in front of the character)
143/// - positive azimuth rotates toward +X (character's left when facing camera)
144/// - elevation 0° = horizon; positive raises the light
145pub fn light_direction_view(azimuth_deg: f32, elevation_deg: f32) -> [f32; 3] {
146 let az = azimuth_deg.to_radians();
147 let el = elevation_deg.to_radians();
148 let cos_el = el.cos();
149 [cos_el * az.sin(), el.sin(), cos_el * az.cos()]
150}
151
152pub fn wrap_azimuth_deg(deg: f32) -> f32 {
153 let mut d = deg % 360.0;
154 if d < 0.0 {
155 d += 360.0;
156 }
157 d
158}
159
160pub fn clamp_elevation_deg(deg: f32) -> f32 {
161 deg.clamp(LIGHT_ELEVATION_MIN_DEG, LIGHT_ELEVATION_MAX_DEG)
162}
163
164fn wrap_time(time: f32, duration: f32) -> f32 {
165 if !(duration.is_finite() && duration > 0.0 && time.is_finite()) {
166 return 0.0;
167 }
168 let mut t = time % duration;
169 if t < 0.0 {
170 t += duration;
171 }
172 t
173}
174
175/// Playback / view / light state for the rendered 2D tab.
176#[derive(Debug, Clone)]
177pub struct Preview2dState {
178 pub sex: Sex,
179 /// Equipment set names currently selected (body is always included when present).
180 pub selected_equipment: Vec<String>,
181 pub clip_token: String,
182 pub clip_frames: u32,
183 pub clip_duration: f32,
184 pub fps: u32,
185 pub playback_speed: f32,
186 pub playing: bool,
187 /// Elapsed animation seconds (preserved across yaw/pitch changes).
188 pub elapsed_secs: f32,
189 pub yaw_index: i32,
190 pub pitch_row: i32,
191 pub light_azimuth_deg: f32,
192 pub light_elevation_deg: f32,
193 pub hold_azimuth: Option<HoldSign>,
194 pub hold_elevation: Option<HoldSign>,
195 pub diagnostic: Option<String>,
196}
197
198impl Default for Preview2dState {
199 fn default() -> Self {
200 Self {
201 sex: Sex::Male,
202 selected_equipment: Vec::new(),
203 clip_token: String::new(),
204 clip_frames: 1,
205 clip_duration: 0.0,
206 fps: 12,
207 playback_speed: 1.0,
208 playing: true,
209 elapsed_secs: 0.0,
210 yaw_index: 0,
211 pitch_row: 1,
212 light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
213 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
214 hold_azimuth: None,
215 hold_elevation: None,
216 diagnostic: None,
217 }
218 }
219}
220
221impl Preview2dState {
222 pub fn sex_token(&self) -> &'static str {
223 match self.sex {
224 Sex::Male => "male",
225 Sex::Female => "female",
226 }
227 }
228
229 pub fn yaw_deg(&self) -> u32 {
230 yaw_deg_from_index(self.yaw_index)
231 }
232
233 pub fn pitch_deg(&self) -> i32 {
234 pitch_deg_from_row(self.pitch_row)
235 }
236
237 pub fn frame_index(&self) -> u32 {
238 let frames = self.clip_frames.max(1);
239 if self.fps == 0 {
240 return 0;
241 }
242 // `elapsed_secs` already advances by `dt * playback_speed` in `tick`.
243 let samples = self.elapsed_secs * self.fps as f32;
244 if !samples.is_finite() || samples < 0.0 {
245 return 0;
246 }
247 (samples.floor() as u64 % u64::from(frames)) as u32
248 }
249
250 pub fn set_clip(&mut self, token: String, frames: u32, duration: f32) {
251 if self.clip_token != token {
252 self.clip_token = token;
253 self.elapsed_secs = 0.0;
254 }
255 self.clip_frames = frames.max(1);
256 self.clip_duration = if duration.is_finite() && duration >= 0.0 {
257 duration
258 } else {
259 0.0
260 };
261 self.diagnostic = None;
262 }
263
264 pub fn step_yaw(&mut self, delta: i32) {
265 self.yaw_index = (self.yaw_index + delta).rem_euclid(PAPERDOLL_YAW_COUNT as i32);
266 // Animation time intentionally preserved.
267 }
268
269 pub fn step_pitch(&mut self, delta: i32) {
270 self.pitch_row = (self.pitch_row + delta).clamp(0, 2);
271 // Animation time intentionally preserved; pitch is clamped, not cyclic.
272 }
273
274 pub fn hold_light(&mut self, axis: LightAxis, sign: HoldSign) {
275 match axis {
276 LightAxis::Azimuth => self.hold_azimuth = Some(sign),
277 LightAxis::Elevation => self.hold_elevation = Some(sign),
278 }
279 }
280
281 pub fn release_light(&mut self, axis: LightAxis) {
282 match axis {
283 LightAxis::Azimuth => self.hold_azimuth = None,
284 LightAxis::Elevation => self.hold_elevation = None,
285 }
286 }
287
288 pub fn release_all_holds(&mut self) {
289 self.hold_azimuth = None;
290 self.hold_elevation = None;
291 }
292
293 /// Stop continuous light motion (button release, focus loss, pointer cancel).
294 pub fn on_interaction_cancel(&mut self) {
295 self.release_all_holds();
296 }
297
298 pub fn light(&self) -> Light {
299 Light {
300 direction_view: light_direction_view(self.light_azimuth_deg, self.light_elevation_deg),
301 ..Light::default()
302 }
303 }
304
305 /// Advance playback and held light controls by `dt` seconds.
306 pub fn tick(&mut self, dt: f32) {
307 let dt = if dt.is_finite() && dt > 0.0 {
308 dt.min(0.25)
309 } else {
310 0.0
311 };
312 if self.playing && dt > 0.0 {
313 let duration = if self.clip_duration > 0.0 {
314 self.clip_duration
315 } else if self.fps > 0 {
316 self.clip_frames.max(1) as f32 / self.fps as f32
317 } else {
318 1.0
319 };
320 let speed = if self.playback_speed.is_finite() {
321 self.playback_speed.max(0.0)
322 } else {
323 0.0
324 };
325 self.elapsed_secs = wrap_time(self.elapsed_secs + dt * speed, duration);
326 }
327 if let Some(sign) = self.hold_azimuth {
328 self.light_azimuth_deg = wrap_azimuth_deg(
329 self.light_azimuth_deg + sign.as_f32() * LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt,
330 );
331 }
332 if let Some(sign) = self.hold_elevation {
333 self.light_elevation_deg = clamp_elevation_deg(
334 self.light_elevation_deg + sign.as_f32() * LIGHT_ELEVATION_RATE_DEG_PER_SEC * dt,
335 );
336 }
337 }
338
339 pub fn toggle_equipment(&mut self, set: &str) {
340 if set == "body" {
341 return;
342 }
343 if let Some(pos) = self.selected_equipment.iter().position(|s| s == set) {
344 self.selected_equipment.remove(pos);
345 } else {
346 self.selected_equipment.push(set.to_string());
347 self.selected_equipment.sort();
348 }
349 self.diagnostic = None;
350 }
351
352 pub fn set_sex(&mut self, sex: Sex) {
353 if self.sex != sex {
354 self.sex = sex;
355 self.selected_equipment.clear();
356 self.clip_token.clear();
357 self.elapsed_secs = 0.0;
358 self.diagnostic = None;
359 }
360 }
361}
362
363#[derive(Debug, Clone)]
364pub struct ComposeRequest<'a> {
365 pub body: &'a ExportRun,
366 pub equipment: &'a [ExportRun],
367 pub clip_token: &'a str,
368 pub frame: u32,
369 pub pitch: i32,
370 pub yaw: u32,
371 pub light: Light,
372}
373
374#[derive(Debug)]
375pub enum Preview2dError {
376 Compatible(ComposeError),
377 Export(ExportError),
378 Missing {
379 set: String,
380 clip: String,
381 frame: u32,
382 pitch: i32,
383 yaw: u32,
384 detail: String,
385 },
386 NoBody,
387}
388
389impl std::fmt::Display for Preview2dError {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 match self {
392 Self::Compatible(e) => write!(f, "{e}"),
393 Self::Export(e) => write!(f, "{e}"),
394 Self::Missing {
395 set,
396 clip,
397 frame,
398 pitch,
399 yaw,
400 detail,
401 } => write!(
402 f,
403 "ontbrekende maps voor set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}"
404 ),
405 Self::NoBody => write!(f, "geen body-exportset voor dit geslacht"),
406 }
407 }
408}
409
410impl std::error::Error for Preview2dError {}
411
412impl From<ComposeError> for Preview2dError {
413 fn from(value: ComposeError) -> Self {
414 Self::Compatible(value)
415 }
416}
417
418impl From<ExportError> for Preview2dError {
419 fn from(value: ExportError) -> Self {
420 Self::Export(value)
421 }
422}
423
424/// Compose + relight one view. Missing maps become an explicit error (no silent substitute).
425pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
426 let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
427 runs.push(req.body);
428 for run in req.equipment {
429 runs.push(run);
430 }
431 assert_compatible(&runs)?;
432
433 let mut layers = Vec::with_capacity(runs.len());
434 let body_set = req.body.render.set.as_str();
435 match LayerMaps::from_export_run(
436 req.body,
437 body_set,
438 req.clip_token,
439 req.frame,
440 req.pitch,
441 req.yaw,
442 ) {
443 Ok(layer) => layers.push(layer),
444 Err(err) => {
445 return Err(Preview2dError::Missing {
446 set: body_set.to_string(),
447 clip: req.clip_token.to_string(),
448 frame: req.frame,
449 pitch: req.pitch,
450 yaw: req.yaw,
451 detail: err.to_string(),
452 });
453 }
454 }
455 for run in req.equipment {
456 let set = run.render.set.as_str();
457 match LayerMaps::from_export_run(run, set, req.clip_token, req.frame, req.pitch, req.yaw) {
458 Ok(layer) => layers.push(layer),
459 Err(err) => {
460 return Err(Preview2dError::Missing {
461 set: set.to_string(),
462 clip: req.clip_token.to_string(),
463 frame: req.frame,
464 pitch: req.pitch,
465 yaw: req.yaw,
466 detail: err.to_string(),
467 });
468 }
469 }
470 }
471 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
472 Ok(lit)
473}
474
475/// Open body + selected equipment runs for one sex, validating compatibility.
476pub fn open_selection(
477 catalog: &[CatalogEntry],
478 sex: Sex,
479 equipment_sets: &[String],
480) -> Result<(ExportRun, Vec<ExportRun>), Preview2dError> {
481 let sex_token = match sex {
482 Sex::Male => "male",
483 Sex::Female => "female",
484 };
485 let body_entry = catalog
486 .iter()
487 .filter(|e| e.sex == sex_token && e.is_body())
488 .max_by_key(|e| e.path.clone())
489 .ok_or(Preview2dError::NoBody)?;
490 let body = ExportRun::open(&body_entry.path)?;
491 let mut equipment = Vec::new();
492 for set in equipment_sets {
493 let entry = catalog
494 .iter()
495 .filter(|e| e.sex == sex_token && e.set == *set && !e.is_body())
496 .max_by_key(|e| e.path.clone())
497 .ok_or_else(|| Preview2dError::Missing {
498 set: set.clone(),
499 clip: String::new(),
500 frame: 0,
501 pitch: 0,
502 yaw: 0,
503 detail: "geen voltooide exportset voor dit equipment".into(),
504 })?;
505 equipment.push(ExportRun::open(&entry.path)?);
506 }
507 let mut refs: Vec<&ExportRun> = vec![&body];
508 refs.extend(equipment.iter());
509 assert_compatible(&refs)?;
510 Ok((body, equipment))
511}
512
513/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).
514pub fn compatible_equipment_sets(catalog: &[CatalogEntry], sex: Sex) -> Vec<String> {
515 let sex_token = match sex {
516 Sex::Male => "male",
517 Sex::Female => "female",
518 };
519 let Some(body) = catalog
520 .iter()
521 .filter(|e| e.sex == sex_token && e.is_body())
522 .max_by_key(|e| e.path.clone())
523 else {
524 return Vec::new();
525 };
526 let mut names = BTreeMap::new();
527 for entry in catalog {
528 if entry.sex != sex_token || entry.is_body() {
529 continue;
530 }
531 if entry.key.project_id != body.key.project_id
532 || entry.key.sex != body.key.sex
533 || entry.key.body_model_sha256 != body.key.body_model_sha256
534 || entry.key.target != body.key.target
535 || entry.key.fps != body.key.fps
536 || entry.key.framing != body.key.framing
537 || (entry.key.near - body.key.near).abs() > f32::EPSILON
538 || (entry.key.far - body.key.far).abs() > f32::EPSILON
539 || entry.key.clip_tokens != body.key.clip_tokens
540 {
541 continue;
542 }
543 names.insert(entry.set.clone(), ());
544 }
545 names.into_keys().collect()
546}
547
548/// Apply catalog defaults when sex/clip inventory changes.
549pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEntry]) {
550 let sex_token = state.sex_token();
551 let body = catalog
552 .iter()
553 .filter(|e| e.sex == sex_token && e.is_body())
554 .max_by_key(|e| e.path.clone());
555 let Some(body) = body else {
556 state.diagnostic = Some("geen voltooide body-export voor dit geslacht".into());
557 return;
558 };
559 state.fps = body.fps;
560 let allowed = compatible_equipment_sets(catalog, state.sex);
561 state
562 .selected_equipment
563 .retain(|s| allowed.iter().any(|a| a == s));
564 if state.clip_token.is_empty() || !body.clips.iter().any(|(t, _, _, _)| t == &state.clip_token)
565 {
566 if let Some((token, _name, frames, duration)) = body.clips.first() {
567 state.set_clip(token.clone(), *frames, *duration);
568 }
569 } else if let Some((_, _, frames, duration)) = body
570 .clips
571 .iter()
572 .find(|(t, _, _, _)| t == &state.clip_token)
573 {
574 state.clip_frames = (*frames).max(1);
575 state.clip_duration = *duration;
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::digest;
583 use crate::export_set::complete_identity_json;
584 use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
585 use crate::project::CameraFraming;
586 use std::collections::BTreeMap as Map;
587 use std::sync::atomic::{AtomicU64, Ordering};
588
589 static COUNTER: AtomicU64 = AtomicU64::new(0);
590
591 struct Temp(PathBuf);
592 impl Temp {
593 fn new() -> Self {
594 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
595 let path = std::env::temp_dir().join(format!(
596 "paperdoll-preview2d-{}-{}-{}",
597 std::process::id(),
598 n,
599 std::time::SystemTime::now()
600 .duration_since(std::time::UNIX_EPOCH)
601 .unwrap()
602 .as_nanos()
603 ));
604 fs::create_dir_all(&path).unwrap();
605 Self(path)
606 }
607 }
608 impl Drop for Temp {
609 fn drop(&mut self) {
610 let _ = fs::remove_dir_all(&self.0);
611 }
612 }
613
614 fn framing() -> CameraFraming {
615 CameraFraming {
616 height_milli: 1800,
617 center_y_milli: 900,
618 pivot_y_milli: Some(1500),
619 }
620 }
621
622 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
623 let data = serde_json::to_vec_pretty(value).unwrap();
624 let path = root.join(name);
625 let temp = path.with_extension(format!("part-{}", std::process::id()));
626 fs::write(&temp, &data).unwrap();
627 fs::hard_link(&temp, &path).unwrap();
628 let _ = fs::remove_file(temp);
629 }
630
631 #[allow(clippy::too_many_arguments)]
632 fn publish_pose(
633 root: &Path,
634 sex: &str,
635 set: &str,
636 clip: &str,
637 frame: u32,
638 pitch: i32,
639 yaw: u32,
640 diffuse: [u8; 4],
641 normal: [u8; 4],
642 specular: [u8; 4],
643 depth_gray: u16,
644 ) -> Map<String, String> {
645 let mut map = Map::new();
646 let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
647 let rels = [
648 (format!("{base}_diffuse.png"), "diffuse"),
649 (format!("{base}_depth.png"), "depth"),
650 (format!("{base}_normal.png"), "normal"),
651 (format!("{base}_specular.png"), "specular"),
652 ];
653 for (rel, kind) in rels {
654 let path = Path::new(&rel);
655 match kind {
656 "depth" => {
657 write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
658 }
659 "normal" => {
660 write_rgba(
661 root,
662 path,
663 &ImageBuf {
664 width: 1,
665 height: 1,
666 rgba: normal.to_vec(),
667 },
668 )
669 .unwrap();
670 }
671 "specular" => {
672 write_rgba(
673 root,
674 path,
675 &ImageBuf {
676 width: 1,
677 height: 1,
678 rgba: specular.to_vec(),
679 },
680 )
681 .unwrap();
682 }
683 _ => {
684 write_rgba(
685 root,
686 path,
687 &ImageBuf {
688 width: 1,
689 height: 1,
690 rgba: diffuse.to_vec(),
691 },
692 )
693 .unwrap();
694 }
695 }
696 let bytes = fs::read(root.join(&rel)).unwrap();
697 map.insert(rel, digest::sha256_hex(&bytes));
698 }
699 map
700 }
701
702 #[allow(clippy::too_many_arguments)]
703 fn write_run(
704 root: &Path,
705 project_id: &str,
706 sex: &str,
707 set: &str,
708 equipment_sha: Option<&str>,
709 body_bytes: &[u8],
710 clips: &[(&str, &str, u32, f32)],
711 checksums: Map<String, String>,
712 ) {
713 let body_hash = digest::sha256_hex(body_bytes);
714 let clips_json: Vec<_> = clips
715 .iter()
716 .map(|(name, token, frames, duration)| {
717 serde_json::json!({
718 "name": name,
719 "token": token,
720 "frames": frames,
721 "duration": duration
722 })
723 })
724 .collect();
725 let images: u32 = clips.iter().map(|(_, _, f, _)| *f).sum::<u32>() * 36;
726 let png_files = checksums.len() as u32;
727 let mut render = serde_json::json!({
728 "format_version": 3,
729 "status": "incomplete",
730 "project": {"id": project_id, "name": "Demo"},
731 "sex": sex,
732 "set": set,
733 "body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
734 "equipment_preset": null,
735 "target": [1, 1],
736 "fps": 12,
737 "highres_scale": 8,
738 "framing": framing(),
739 "pivot_source": {"kind": "manual"},
740 "near": NEAR,
741 "far": FAR,
742 "camera_distance": 10.0,
743 "yaw_zero": "camera on -Z",
744 "normal_space": "view (+Y up), PNG rows top-down",
745 "pitches": [-30, 0, 30],
746 "yaw_step": 30,
747 "sampling": "test",
748 "alpha": "straight",
749 "diffuse": "unlit",
750 "depth": "16-bit",
751 "specular": "scalar",
752 "pass_order": PASSES,
753 "saved_maps": ["diffuse", "depth", "normal", "specular"],
754 "clips": clips_json,
755 "images": images,
756 "passes": images * 6,
757 "png_files": png_files
758 });
759 if let Some(sha) = equipment_sha {
760 render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
761 }
762 write_atomic_json(root, "render.json", &render);
763 let identity = complete_identity_json(&render).unwrap();
764 write_atomic_json(
765 root,
766 "complete.json",
767 &serde_json::json!({
768 "format_version": 3,
769 "status": "complete",
770 "identity": identity,
771 "passes": images * 6,
772 "png_files": png_files,
773 "png_sha256": checksums
774 }),
775 );
776 }
777
778 #[test]
779 fn yaw_wraps_twelve_steps_pitch_clamps_three_rows() {
780 let mut state = Preview2dState {
781 elapsed_secs: 1.25,
782 ..Preview2dState::default()
783 };
784 assert_eq!(state.yaw_deg(), 0);
785 state.step_yaw(-1);
786 assert_eq!(state.yaw_deg(), 330);
787 assert_eq!(state.elapsed_secs, 1.25);
788 state.step_yaw(1);
789 assert_eq!(state.yaw_deg(), 0);
790 for _ in 0..12 {
791 state.step_yaw(1);
792 }
793 assert_eq!(state.yaw_deg(), 0);
794 assert_eq!(PAPERDOLL_YAW_COUNT, 12);
795
796 state.pitch_row = 1;
797 state.step_pitch(1);
798 assert_eq!(state.pitch_deg(), 30);
799 state.step_pitch(1);
800 assert_eq!(state.pitch_deg(), 30);
801 state.step_pitch(-1);
802 assert_eq!(state.pitch_deg(), 0);
803 state.step_pitch(-1);
804 assert_eq!(state.pitch_deg(), -30);
805 state.step_pitch(-1);
806 assert_eq!(state.pitch_deg(), -30);
807 assert_eq!(state.elapsed_secs, 1.25);
808 }
809
810 #[test]
811 fn playback_accumulates_elapsed_and_loops_by_fps() {
812 let mut state = Preview2dState {
813 fps: 10,
814 clip_frames: 4,
815 clip_duration: 0.4,
816 playback_speed: 1.0,
817 playing: true,
818 ..Preview2dState::default()
819 };
820 assert_eq!(state.frame_index(), 0);
821 state.tick(0.15);
822 assert_eq!(state.frame_index(), 1);
823 state.tick(0.25);
824 assert_eq!(state.frame_index(), 0); // wrapped: 0.4s * 10fps = frame 0 again after loop
825 // 0.15+0.25=0.40 → wrap to 0 → frame 0
826 state.playing = false;
827 let frozen = state.elapsed_secs;
828 state.tick(1.0);
829 assert_eq!(state.elapsed_secs, frozen);
830 state.playing = true;
831 state.playback_speed = 2.0;
832 state.elapsed_secs = 0.0;
833 state.tick(0.05);
834 // elapsed += 0.05*2 = 0.10; samples = 0.10*10 = 1 → frame 1
835 assert_eq!(state.frame_index(), 1);
836 }
837
838 #[test]
839 fn light_hold_uses_dt_and_stops_on_release_or_cancel() {
840 let mut state = Preview2dState::default();
841 let start_az = state.light_azimuth_deg;
842 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
843 // Per-tick dt is capped at 0.25s (hitch guard).
844 state.tick(0.25);
845 state.tick(0.25);
846 let mid = state.light_azimuth_deg;
847 assert!((mid - start_az - 45.0).abs() < 1e-3);
848 state.release_light(LightAxis::Azimuth);
849 state.tick(0.25);
850 assert_eq!(state.light_azimuth_deg, mid);
851
852 state.hold_light(LightAxis::Elevation, HoldSign::Positive);
853 state.tick(0.25); // +15°
854 assert!(state.light_elevation_deg <= LIGHT_ELEVATION_MAX_DEG);
855 // Drive to clamp
856 state.light_elevation_deg = LIGHT_ELEVATION_MAX_DEG - 1.0;
857 state.tick(0.25);
858 assert_eq!(state.light_elevation_deg, LIGHT_ELEVATION_MAX_DEG);
859 state.on_interaction_cancel();
860 assert!(state.hold_azimuth.is_none() && state.hold_elevation.is_none());
861
862 state.light_azimuth_deg = 350.0;
863 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
864 state.tick(0.2); // +18 → 368 → 8
865 assert!((state.light_azimuth_deg - 8.0).abs() < 1e-3);
866 }
867
868 #[test]
869 fn light_direction_convention_front_and_up() {
870 let front = light_direction_view(0.0, 0.0);
871 assert!((front[0]).abs() < 1e-5);
872 assert!((front[1]).abs() < 1e-5);
873 assert!((front[2] - 1.0).abs() < 1e-5);
874 let up = light_direction_view(0.0, 90.0);
875 assert!((up[1] - 1.0).abs() < 1e-5);
876 let leftish = light_direction_view(90.0, 0.0);
877 assert!((leftish[0] - 1.0).abs() < 1e-5);
878 }
879
880 #[test]
881 fn missing_frame_is_diagnostic_not_silent_substitute() {
882 let body_tmp = Temp::new();
883 let clips = [("walk", "clip000-walk", 2u32, 0.1667f32)];
884 let mut checksums = Map::new();
885 // Only frame 0 / pitch 0 / yaw 0 published — frame 1 missing.
886 checksums.extend(publish_pose(
887 &body_tmp.0,
888 "male",
889 "body",
890 "clip000-walk",
891 0,
892 0,
893 0,
894 [10, 20, 30, 255],
895 [128, 128, 255, 255],
896 [0, 0, 0, 255],
897 20000,
898 ));
899 write_run(
900 &body_tmp.0,
901 "demo",
902 "male",
903 "body",
904 None,
905 b"shared-body",
906 &clips,
907 checksums,
908 );
909 let run = ExportRun::open(&body_tmp.0).unwrap();
910 let ok = compose_preview_frame(ComposeRequest {
911 body: &run,
912 equipment: &[],
913 clip_token: "clip000-walk",
914 frame: 0,
915 pitch: 0,
916 yaw: 0,
917 light: Light::default(),
918 })
919 .unwrap();
920 assert_eq!(ok.width, 1);
921 let err = compose_preview_frame(ComposeRequest {
922 body: &run,
923 equipment: &[],
924 clip_token: "clip000-walk",
925 frame: 1,
926 pitch: 0,
927 yaw: 0,
928 light: Light::default(),
929 })
930 .unwrap_err();
931 match err {
932 Preview2dError::Missing {
933 set, clip, frame, ..
934 } => {
935 assert_eq!(set, "body");
936 assert_eq!(clip, "clip000-walk");
937 assert_eq!(frame, 1);
938 }
939 other => panic!("expected Missing, got {other}"),
940 }
941 }
942
943 #[test]
944 fn catalog_compatibility_filters_equipment_by_export_contract() {
945 let project = Temp::new();
946 let renders = project.0.join("renders");
947 fs::create_dir_all(&renders).unwrap();
948 let body_dir = renders.join("run-body");
949 let good_dir = renders.join("run-sword");
950 let bad_dir = renders.join("run-bad");
951 fs::create_dir_all(&body_dir).unwrap();
952 fs::create_dir_all(&good_dir).unwrap();
953 fs::create_dir_all(&bad_dir).unwrap();
954 let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
955 let body_bytes = b"shared-body";
956 let mut body_maps = Map::new();
957 body_maps.extend(publish_pose(
958 &body_dir,
959 "male",
960 "body",
961 "clip000-bind",
962 0,
963 0,
964 0,
965 [1, 2, 3, 255],
966 [128, 128, 255, 255],
967 [0, 0, 0, 255],
968 1000,
969 ));
970 write_run(
971 &body_dir, "proj-a", "male", "body", None, body_bytes, &clips, body_maps,
972 );
973 let mut good_maps = Map::new();
974 good_maps.extend(publish_pose(
975 &good_dir,
976 "male",
977 "sword",
978 "clip000-bind",
979 0,
980 0,
981 0,
982 [200, 10, 10, 255],
983 [255, 128, 128, 255],
984 [180, 180, 180, 255],
985 500,
986 ));
987 write_run(
988 &good_dir,
989 "proj-a",
990 "male",
991 "sword",
992 Some(&digest::sha256_hex(b"sword")),
993 body_bytes,
994 &clips,
995 good_maps,
996 );
997 let mut bad_maps = Map::new();
998 bad_maps.extend(publish_pose(
999 &bad_dir,
1000 "male",
1001 "helm",
1002 "clip000-bind",
1003 0,
1004 0,
1005 0,
1006 [9, 9, 9, 255],
1007 [128, 128, 255, 255],
1008 [0, 0, 0, 255],
1009 800,
1010 ));
1011 // Different body fingerprint → incompatible.
1012 write_run(
1013 &bad_dir,
1014 "proj-a",
1015 "male",
1016 "helm",
1017 Some(&digest::sha256_hex(b"helm")),
1018 b"other-body",
1019 &clips,
1020 bad_maps,
1021 );
1022
1023 let catalog = discover_completed_runs(&project.0);
1024 assert_eq!(catalog.len(), 3);
1025 let allowed = compatible_equipment_sets(&catalog, Sex::Male);
1026 assert_eq!(allowed, vec!["sword".to_string()]);
1027 assert!(!allowed.iter().any(|s| s == "helm"));
1028
1029 let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();
1030 assert_eq!(body.render.set, "body");
1031 assert_eq!(gear.len(), 1);
1032 let lit = compose_preview_frame(ComposeRequest {
1033 body: &body,
1034 equipment: &gear,
1035 clip_token: "clip000-bind",
1036 frame: 0,
1037 pitch: 0,
1038 yaw: 0,
1039 light: Light::default(),
1040 })
1041 .unwrap();
1042 assert_eq!(lit.rgba[3], 255);
1043 // Foreground equipment (closer depth) wins.
1044 assert!(lit.rgba[0] > 100);
1045 }
1046
1047 #[test]
1048 fn discover_skips_incomplete_and_wrong_schema() {
1049 let project = Temp::new();
1050 let renders = project.0.join("renders");
1051 let incomplete = renders.join("run-incomplete");
1052 fs::create_dir_all(&incomplete).unwrap();
1053 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1054 // no complete.json
1055 let catalog = discover_completed_runs(&project.0);
1056 assert!(catalog.is_empty());
1057 }
1058}
src/session.rs +8 −0
@@ -37,6 +37,7 @@ pub enum Screen {
37 37 Body(Sex),
38 38 EquipmentList,
39 39 Equipment(usize),
40 Preview2d,
40 41 }
41 42
42 43 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -175,11 +176,18 @@ impl Session {
175 176 }
176 177 }
177 178
179 pub fn show_preview2d(&mut self) {
180 if self.opened.is_some() {
181 self.screen = Screen::Preview2d;
182 }
183 }
184
178 185 pub fn back(&mut self) {
179 186 self.screen = match self.screen {
180 187 Screen::Body(_) => Screen::Bodies,
181 188 Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
182 189 Screen::Equipment(_) => Screen::EquipmentList,
190 Screen::Preview2d => Screen::Bodies,
183 191 other => other,
184 192 };
185 193 }
@@ -37,6 +37,7 @@ pub enum Screen {
37 Body(Sex),
37 Body(Sex),
38 EquipmentList,
38 EquipmentList,
39 Equipment(usize),
39 Equipment(usize),
40 Preview2d,
40}
41}
41
42
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -175,11 +176,18 @@ impl Session {
175 }
176 }
176 }
177 }
177
178
179 pub fn show_preview2d(&mut self) {
180 if self.opened.is_some() {
181 self.screen = Screen::Preview2d;
182 }
183 }
184
178 pub fn back(&mut self) {
185 pub fn back(&mut self) {
179 self.screen = match self.screen {
186 self.screen = match self.screen {
180 Screen::Body(_) => Screen::Bodies,
187 Screen::Body(_) => Screen::Bodies,
181 Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
188 Screen::EquipmentList => self.active_body.map(Screen::Body).unwrap_or(Screen::Bodies),
182 Screen::Equipment(_) => Screen::EquipmentList,
189 Screen::Equipment(_) => Screen::EquipmentList,
190 Screen::Preview2d => Screen::Bodies,
183 other => other,
191 other => other,
184 };
192 };
185 }
193 }