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.
@@ -29,6 +29,21 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
29
29
30
30

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** selecteert29
29
30
30
31
31
32
## 2D-preview33
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
```bash43
# Native GUI-smoke met schema-3 fixture-export (Xvfb)44
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke45
```46
32
## Equipment en opslaan47
## Equipment en opslaan33
48
34
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.49
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.@@ -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` s180
180
181
## 10. Rendered 2D preview181
## 10. Rendered 2D preview182
182
183
Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The 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
Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo.187
Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). 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 sup217
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.217
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.218
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.218
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.219
219
220
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting 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
## 12. Incremental feature plan and acceptance gates222
## 12. Incremental feature plan and acceptance gates223
223
@@ -228,7 +228,7 @@ Current implementation checkpoint: native project/pickers, equipment authoring/p228
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.228
4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.229
5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.229
5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.230
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, rim accents, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets.230
6. Production image pipeline: detail-aware downsampling, depth/normal resolve, 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
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.232
8. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.233
233
234
Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.234
Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.@@ -0,0 +1,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_DIR3
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
true45
} else {46
false47
};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
writer82
.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.1667181
})];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_files237
});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": checksums250
}),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
}@@ -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
3
use std::path::PathBuf;3
use std::path::PathBuf;4
4
@@ -7,9 +7,13 @@ use eframe::egui;7
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};7
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};8
use crate::import::{import_project_model, ImportedModel};8
use crate::import::{import_project_model, ImportedModel};9
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};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
use crate::preview_gl::Viewport;14
use crate::preview_gl::Viewport;11
use crate::project::open_project;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
use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};17
use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};14
18
15
pub struct EditorApp {19
pub 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
38
impl EditorApp {48
impl 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.running699
|| matches!(self.session.screen, Screen::Preview2d)700
&& (self.preview2d.playing701
|| 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.preview2d1247
}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.focused1305
|| 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 ui1347
.selectable_label(self.preview2d.sex == Sex::Male, "Man")1348
.clicked()1349
{1350
set_sex = Some(Sex::Male);1351
}1352
if ui1353
.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<_> = self1383
.preview2d_catalog1384
.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 ui1402
.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_secs1423
));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.playing1544
|| 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
}@@ -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::SkeletalAnimation66
| Capability::SkeletalAnimation67
| Capability::AttachmentEditor67
| Capability::AttachmentEditor68
| 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
113
pub fn dutch_summary() -> &'static str {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
/// 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::Present239
Availability::Present@@ -256,18 +256,11 @@ mod tests {256
availability(Capability::PaperdollRender),256
availability(Capability::PaperdollRender),257
Availability::Present257
Availability::Present258
);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]@@ -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/identity5
//! Completed export runs are opened through `export_set` with checksum/identity6
//! 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
pub mod camera;8
pub mod camera;9
pub mod compositor;9
pub mod compositor;@@ -17,6 +17,7 @@ pub mod foundation;17
pub mod icon;17
pub mod icon;18
pub mod import;18
pub mod import;19
pub mod preview;19
pub mod preview;20
pub mod preview2d;20
pub mod preview_gl;21
pub mod preview_gl;21
pub mod production;22
pub mod production;22
pub mod project;23
pub mod project;@@ -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
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: run76
.render77
.clips78
.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: run83
.render84
.equipment_preset85
.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
out118
}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 * 30124
}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 i32129
}130
131
pub fn pitch_row_from_deg(pitch: i32) -> i32 {132
PAPERDOLL_PITCHES_DEG133
.iter()134
.position(|p| *p as i32 == pitch)135
.unwrap_or(1) as i32136
}137
138
/// View-space light direction toward the light.139
///140
/// Convention (right-hand, view +Y up, camera looks −Z toward the character so141
/// 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 light145
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
d158
}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
t173
}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 u32248
}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
duration258
} else {259
0.0260
};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.0311
};312
if self.playing && dt > 0.0 {313
let duration = if self.clip_duration > 0.0 {314
self.clip_duration315
} else if self.fps > 0 {316
self.clip_frames.max(1) as f32 / self.fps as f32317
} else {318
1.0319
};320
let speed = if self.playback_speed.is_finite() {321
self.playback_speed.max(0.0)322
} else {323
0.0324
};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 = catalog486
.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 = catalog494
.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) = catalog520
.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_id532
|| entry.key.sex != body.key.sex533
|| entry.key.body_model_sha256 != body.key.body_model_sha256534
|| entry.key.target != body.key.target535
|| entry.key.fps != body.key.fps536
|| entry.key.framing != body.key.framing537
|| (entry.key.near - body.key.near).abs() > f32::EPSILON538
|| (entry.key.far - body.key.far).abs() > f32::EPSILON539
|| entry.key.clip_tokens != body.key.clip_tokens540
{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 = catalog552
.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
state562
.selected_equipment563
.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)) = body570
.clips571
.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
map700
}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<_> = clips715
.iter()716
.map(|(name, token, frames, duration)| {717
serde_json::json!({718
"name": name,719
"token": token,720
"frames": frames,721
"duration": duration722
})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_files758
});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": checksums774
}),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 loop825
// 0.15+0.25=0.40 → wrap to 0 → frame 0826
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 1835
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 clamp856
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 → 8865
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.json1055
let catalog = discover_completed_runs(&project.0);1056
assert!(catalog.is_empty());1057
}1058
}@@ -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
}