BlueGit

golden-age paperdoll-renderer

Trimmed atlas frames: per-view content crop with offset/frame/empty in .ppda v5, .ppd v4 trim_body/trim_equipment (default on) (Tim t1030u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-08 · 6 days ago

Hash
120d40a051d647d9b2368a3d38b34e28ea6d546e
Parent
6ce46586
11 files changed +627 −66
README.md +5 −4
@@ -29,7 +29,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
29 29 - Pick an animation clip and use **Pause / Play**.
30 30 - **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31 31 - **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32 - Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes.
32 - Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes. **Trim atlas: body / equipment** (both on by default; older `.ppd` files load with both on) crops the atlas views to their content, see *Atlases and .ppda*.
33 33 - **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34 34
35 35 ![Body view with animation and camera controls](docs/screenshots/body-live.png)
@@ -151,11 +151,12 @@ While building: `render.json` (schema 6; with `depth_near`, `depth_far`, `depth_
151 151
152 152 When all loose maps are done and verified, also after resuming, the render writes one atlas per map type into the (temporary) run folder: `<sex>-<settype>-<maptype>-atlas.png` (e.g. `male-sword-normal-atlas.png`). Then follows `<sex>-<settype>-atlas.ppda`, and only after that `complete.json` (temporary folder only).
153 153
154 - A cell is exactly the target resolution, without border or scaling. Each row is one (clip, frame) in source order. The 60 columns are pitch −60→+60 and within that yaw 0→330. The four atlases share the same cell coordinates. Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
154 - Each atlas row is one (clip, frame) in source order. Within a row the 60 views run from pitch −60 to +60, and within each pitch from yaw 0 to 330. The four atlases share the same coordinates.
155 - **Trimming** (project setting, on by default, separately for body and equipment: **Trim atlas: body / equipment** in the Body view): each view is cropped to the box around its non-empty pixels in any of the four maps, plus 2 px padding (never outside the frame). All four maps use the same crop. A view without content is marked `empty` and takes no space. Within a row the crops sit side by side without gaps; the row is as tall as its tallest crop. Without trimming, every view is a full cell of exactly the target resolution, without border or scaling. The loose PNGs under `raw files/` always stay full size. On the test body (92 × 128, 8 fps) trimming makes the atlas 2770 × 1301 instead of 5520 × 1920 pixels (−66%); on the test sword equipment 1560 × 498 (−93%). Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
155 156 - The atlases are built row by row from the verified loose PNGs, published atomically and overwrite nothing. If an atlas already exists after a crash, it stays if it is byte-identical to the new one; otherwise it is kept as `.corrupt-aNNN-K` and rewritten.
156 157 - If an atlas would exceed 16384 pixels in width or height, the render refuses before starting with a clear message (lower fps, resolution or animation length). Atlases are never split.
157 - `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 4 (versions 2–3 are still read; version 2 as 16-bit gray), `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h}` in atlas pixels) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).
158 - The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, cell coordinates off the grid and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps; `cell()` crops one angle. `atlas::cross_check_run` compares every atlas cell with the loose PNG.
158 - `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 5 (versions 2–4 are still read; version 2 as 16-bit gray), `trimmed`, `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h, offset, frame, empty}`: the rect in the atlas, its `[x, y]` position within the full frame, the full frame size `[w, h]`, and `empty` for a view without content, whose rect is `0, 0, 0, 0`) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).
159 - The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, rects that are not where the packing puts them (or that fall outside the frame or atlas) and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps. `cell()` returns the full frame of one angle: the rect pasted at its offset into an empty frame. The 2D preview and the compositor therefore place every view at character position + offset, pixel-identical to an untrimmed export. `atlas::cross_check_run` compares every atlas cell with the loose PNG.
159 160
160 161 ### Resuming
161 162
@@ -29,7 +29,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
29- Pick an animation clip and use **Pause / Play**.
29- Pick an animation clip and use **Pause / Play**.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes.
32- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes. **Trim atlas: body / equipment** (both on by default; older `.ppd` files load with both on) crops the atlas views to their content, see *Atlases and .ppda*.
33- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
33- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34
34
35![Body view with animation and camera controls](docs/screenshots/body-live.png)
35![Body view with animation and camera controls](docs/screenshots/body-live.png)
@@ -151,11 +151,12 @@ While building: `render.json` (schema 6; with `depth_near`, `depth_far`, `depth_
151
151
152When all loose maps are done and verified, also after resuming, the render writes one atlas per map type into the (temporary) run folder: `<sex>-<settype>-<maptype>-atlas.png` (e.g. `male-sword-normal-atlas.png`). Then follows `<sex>-<settype>-atlas.ppda`, and only after that `complete.json` (temporary folder only).
152When all loose maps are done and verified, also after resuming, the render writes one atlas per map type into the (temporary) run folder: `<sex>-<settype>-<maptype>-atlas.png` (e.g. `male-sword-normal-atlas.png`). Then follows `<sex>-<settype>-atlas.ppda`, and only after that `complete.json` (temporary folder only).
153
153
154- A cell is exactly the target resolution, without border or scaling. Each row is one (clip, frame) in source order. The 60 columns are pitch −60→+60 and within that yaw 0→330. The four atlases share the same cell coordinates. Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
154- Each atlas row is one (clip, frame) in source order. Within a row the 60 views run from pitch −60 to +60, and within each pitch from yaw 0 to 330. The four atlases share the same coordinates.
155- **Trimming** (project setting, on by default, separately for body and equipment: **Trim atlas: body / equipment** in the Body view): each view is cropped to the box around its non-empty pixels in any of the four maps, plus 2 px padding (never outside the frame). All four maps use the same crop. A view without content is marked `empty` and takes no space. Within a row the crops sit side by side without gaps; the row is as tall as its tallest crop. Without trimming, every view is a full cell of exactly the target resolution, without border or scaling. The loose PNGs under `raw files/` always stay full size. On the test body (92 × 128, 8 fps) trimming makes the atlas 2770 × 1301 instead of 5520 × 1920 pixels (−66%); on the test sword equipment 1560 × 498 (−93%). Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
155- The atlases are built row by row from the verified loose PNGs, published atomically and overwrite nothing. If an atlas already exists after a crash, it stays if it is byte-identical to the new one; otherwise it is kept as `.corrupt-aNNN-K` and rewritten.
156- The atlases are built row by row from the verified loose PNGs, published atomically and overwrite nothing. If an atlas already exists after a crash, it stays if it is byte-identical to the new one; otherwise it is kept as `.corrupt-aNNN-K` and rewritten.
156- If an atlas would exceed 16384 pixels in width or height, the render refuses before starting with a clear message (lower fps, resolution or animation length). Atlases are never split.
157- If an atlas would exceed 16384 pixels in width or height, the render refuses before starting with a clear message (lower fps, resolution or animation length). Atlases are never split.
157- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 4 (versions 2–3 are still read; version 2 as 16-bit gray), `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h}` in atlas pixels) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).
158- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 5 (versions 2–4 are still read; version 2 as 16-bit gray), `trimmed`, `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h, offset, frame, empty}`: the rect in the atlas, its `[x, y]` position within the full frame, the full frame size `[w, h]`, and `empty` for a view without content, whose rect is `0, 0, 0, 0`) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).
158- The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, cell coordinates off the grid and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps; `cell()` crops one angle. `atlas::cross_check_run` compares every atlas cell with the loose PNG.
159- The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, rects that are not where the packing puts them (or that fall outside the frame or atlas) and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps. `cell()` returns the full frame of one angle: the rect pasted at its offset into an empty frame. The 2D preview and the compositor therefore place every view at character position + offset, pixel-identical to an untrimmed export. `atlas::cross_check_run` compares every atlas cell with the loose PNG.
159
160
160### Resuming
161### Resuming
161
162
examples/auto_pivot_probe.rs +1 −0
@@ -79,6 +79,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
79 79 let yaw = 90.;
80 80 let mut job = production::RenderJob {
81 81 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
82 atlas_trim: false,
82 83 character: body,
83 84 sex: "male".into(),
84 85 preset: None,
@@ -79,6 +79,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
79 let yaw = 90.;
79 let yaw = 90.;
80 let mut job = production::RenderJob {
80 let mut job = production::RenderJob {
81 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
81 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
82 atlas_trim: false,
82 character: body,
83 character: body,
83 sex: "male".into(),
84 sex: "male".into(),
84 preset: None,
85 preset: None,
examples/production_fixture.rs +7 −2
@@ -1,5 +1,5 @@
1 1 //! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2 //! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]
2 //! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N] [--no-trim]
3 3 use eframe::App;
4 4 use paperdoll_renderer::{
5 5 editor::EditorApp,
@@ -48,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
48 48 let a = std::env::args().skip(1).collect::<Vec<_>>();
49 49 if a.len() < 7 {
50 50 return Err(
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]".into(),
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N] [--no-trim]".into(),
52 52 );
53 53 }
54 54 let mut root = PathBuf::from(&a[0]);
@@ -77,6 +77,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
77 77 let equipment = a.get(7).is_some_and(|v| v == "equipment");
78 78 let skinned = a.get(7).is_some_and(|v| v == "skinned-equipment");
79 79 let open_equipment = equipment || default_hair || skinned;
80 if a.iter().any(|v| v == "--no-trim") {
81 let r = &mut s.opened.as_mut().unwrap().document.render;
82 r.trim_body = false;
83 r.trim_equipment = false;
84 }
80 85 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {
81 86 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
82 87 .height_milli = value.parse()?;
@@ -1,5 +1,5 @@
1//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
1//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]
2//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N] [--no-trim]
3use eframe::App;
3use eframe::App;
4use paperdoll_renderer::{
4use paperdoll_renderer::{
5 editor::EditorApp,
5 editor::EditorApp,
@@ -48,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
48 let a = std::env::args().skip(1).collect::<Vec<_>>();
48 let a = std::env::args().skip(1).collect::<Vec<_>>();
49 if a.len() < 7 {
49 if a.len() < 7 {
50 return Err(
50 return Err(
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N]".into(),
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment|skinned-equipment|default-hair|default-body] [--height-milli=N] [--center-milli=N] [--no-trim]".into(),
52 );
52 );
53 }
53 }
54 let mut root = PathBuf::from(&a[0]);
54 let mut root = PathBuf::from(&a[0]);
@@ -77,6 +77,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
77 let equipment = a.get(7).is_some_and(|v| v == "equipment");
77 let equipment = a.get(7).is_some_and(|v| v == "equipment");
78 let skinned = a.get(7).is_some_and(|v| v == "skinned-equipment");
78 let skinned = a.get(7).is_some_and(|v| v == "skinned-equipment");
79 let open_equipment = equipment || default_hair || skinned;
79 let open_equipment = equipment || default_hair || skinned;
80 if a.iter().any(|v| v == "--no-trim") {
81 let r = &mut s.opened.as_mut().unwrap().document.render;
82 r.trim_body = false;
83 r.trim_equipment = false;
84 }
80 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {
85 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--height-milli=")) {
81 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
86 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
82 .height_milli = value.parse()?;
87 .height_milli = value.parse()?;
src/atlas.rs +498 −50
@@ -31,7 +31,11 @@ use std::{
31 31 /// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
32 32 /// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
33 33 /// still read (16-bit gray).
34 pub const PPDA_FORMAT_VERSION: u32 = 4;
34 /// 4: export contract embedded. 5 (t1030u): optional trimmed views with
35 /// per-view offset/frame/empty; versions 2–4 are still read.
36 pub const PPDA_FORMAT_VERSION: u32 = 5;
37 /// Padding around the content bounding box of a trimmed view.
38 pub const TRIM_PADDING: u32 = 2;
35 39 /// Largest atlas side in pixels (common GPU texture limit). Larger exports are
36 40 /// refused up front with a clear message; atlases are never split.
37 41 pub const MAX_ATLAS_DIM: u32 = 16384;
@@ -131,10 +135,48 @@ impl AtlasLayout {
131 135 y: row * self.cell_height,
132 136 w: self.cell_width,
133 137 h: self.cell_height,
138 offset: None,
139 frame: None,
140 empty: None,
134 141 }
135 142 }
136 143 }
137 144
145 /// One view's place in a (possibly trimmed) atlas.
146 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
147 pub struct ViewRect {
148 pub x: u32,
149 pub y: u32,
150 pub w: u32,
151 pub h: u32,
152 pub ox: u32,
153 pub oy: u32,
154 pub empty: bool,
155 }
156
157 /// Deterministic row packing: per atlas row the views left to right without
158 /// gaps; row height = tallest view; size at least 1×1. Returns the (x, y) of
159 /// every view and the atlas size.
160 pub fn pack(sizes: &[Vec<(u32, u32)>]) -> (Vec<Vec<(u32, u32)>>, u32, u32) {
161 let mut y = 0u32;
162 let mut width = 0u32;
163 let mut out = Vec::with_capacity(sizes.len());
164 for row in sizes {
165 let mut x = 0u32;
166 let mut height = 0u32;
167 let mut places = Vec::with_capacity(row.len());
168 for &(w, h) in row {
169 places.push((x, y));
170 x = x.saturating_add(w);
171 height = height.max(h);
172 }
173 width = width.max(x);
174 y = y.saturating_add(height);
175 out.push(places);
176 }
177 (out, width.max(1), y.max(1))
178 }
179
138 180 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139 181 #[serde(deny_unknown_fields)]
140 182 pub struct Ppda {
@@ -164,6 +206,9 @@ pub struct Ppda {
164 206 /// folder needs no other JSON. Absent in formats 2–3.
165 207 #[serde(default, skip_serializing_if = "Option::is_none")]
166 208 pub export: Option<PpdaExport>,
209 /// Format 5: whether views are trimmed to their content (t1030u).
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub trimmed: Option<bool>,
167 212 }
168 213
169 214 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -234,6 +279,15 @@ pub struct PpdaView {
234 279 pub y: u32,
235 280 pub w: u32,
236 281 pub h: u32,
282 /// Format 5: position of the rect within the full frame.
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub offset: Option<[u32; 2]>,
285 /// Format 5: full frame size (= cell size).
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub frame: Option<[u32; 2]>,
288 /// Format 5: no content; the view takes no atlas space.
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub empty: Option<bool>,
237 291 }
238 292
239 293 /// What production records in `complete.json` after writing the atlases.
@@ -290,6 +344,8 @@ pub struct AtlasJob<'a> {
290 344 /// Resume attempt number for quarantine names of stale atlas files.
291 345 pub attempt: u32,
292 346 pub cancel: &'a AtomicBool,
347 /// Crop every view to its content (t1030u).
348 pub trim: bool,
293 349 }
294 350
295 351 fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
@@ -352,6 +408,7 @@ fn load_cell(
352 408 fn encode_atlas(
353 409 job: &AtlasJob<'_>,
354 410 layout: &AtlasLayout,
411 rects: &[Vec<ViewRect>],
355 412 kind: MapKind,
356 413 out: &mut dyn Write,
357 414 progress: &mut dyn FnMut(String),
@@ -372,13 +429,13 @@ fn encode_atlas(
372 429 }
373 430 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
374 431 let row_bytes = layout.width as usize * bpp;
375 let cell_row_bytes = layout.cell_width as usize * bpp;
432 let cell_w = layout.cell_width as usize;
376 433 let mut stream = writer
377 434 .stream_writer_with_size(row_bytes.max(4096))
378 435 .map_err(|e| e.to_string())?;
379 436 let rows = rows_of(job.plan);
380 437 let total = rows.len();
381 let mut band = vec![0u8; row_bytes * layout.cell_height as usize];
438 let mut written = 0u32;
382 439 for (row, (clip, frame)) in rows.into_iter().enumerate() {
383 440 if job.cancel.load(Ordering::Relaxed) {
384 441 return Err("geannuleerd".into());
@@ -392,16 +449,37 @@ fn encode_atlas(
392 449 ));
393 450 }
394 451 let token = &job.plan.clips[clip].token;
452 let row_h = rects[row]
453 .iter()
454 .filter(|r| !r.empty)
455 .map(|r| r.h)
456 .max()
457 .unwrap_or(0) as usize;
458 if row_h == 0 {
459 continue;
460 }
461 let mut band = vec![0u8; row_bytes * row_h];
395 462 for column in 0..layout.columns {
463 let r = rects[row][column as usize];
464 if r.empty {
465 continue;
466 }
396 467 let view = layout.cell(0, column);
397 468 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
398 for y in 0..layout.cell_height as usize {
399 let dst = y * row_bytes + view.x as usize * bpp;
400 band[dst..dst + cell_row_bytes]
401 .copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);
469 let w = r.w as usize * bpp;
470 for y in 0..r.h as usize {
471 let src = ((r.oy as usize + y) * cell_w + r.ox as usize) * bpp;
472 let dst = y * row_bytes + r.x as usize * bpp;
473 band[dst..dst + w].copy_from_slice(&cell[src..src + w]);
402 474 }
403 475 }
404 476 stream.write_all(&band).map_err(|e| e.to_string())?;
477 written += row_h as u32;
478 }
479 // An atlas of only empty views is one transparent pixel row high.
480 if written < layout.height {
481 let pad = vec![0u8; row_bytes * (layout.height - written) as usize];
482 stream.write_all(&pad).map_err(|e| e.to_string())?;
405 483 }
406 484 stream.finish().map_err(|e| e.to_string())?;
407 485 writer.finish().map_err(|e| e.to_string())?;
@@ -457,6 +535,7 @@ pub fn build_ppda(
457 535 render_doc: &serde_json::Value,
458 536 plan: &Plan,
459 537 layout: &AtlasLayout,
538 rects: &[Vec<ViewRect>],
460 539 sex: &str,
461 540 set: &str,
462 541 map_digests: &BTreeMap<String, String>,
@@ -498,7 +577,23 @@ pub fn build_ppda(
498 577 for index in 0..clip.frames {
499 578 frames.push(PpdaFrame {
500 579 index,
501 views: (0..layout.columns).map(|c| layout.cell(row, c)).collect(),
580 views: (0..layout.columns)
581 .map(|c| {
582 let r = rects[row as usize][c as usize];
583 let grid = layout.cell(row, c);
584 PpdaView {
585 pitch: grid.pitch,
586 yaw: grid.yaw,
587 x: r.x,
588 y: r.y,
589 w: r.w,
590 h: r.h,
591 offset: Some([r.ox, r.oy]),
592 frame: Some([layout.cell_width, layout.cell_height]),
593 empty: Some(r.empty),
594 }
595 })
596 .collect(),
502 597 });
503 598 row += 1;
504 599 }
@@ -550,24 +645,123 @@ pub fn build_ppda(
550 645 maps,
551 646 clips,
552 647 export: None,
648 trimmed: Some(false),
553 649 })
554 650 }
555 651
652 /// Rects of every view: the fixed grid, or (trim) the content bounding box
653 /// plus padding over all four maps, packed with [`pack`].
654 fn plan_rects(
655 job: &AtlasJob<'_>,
656 grid: &AtlasLayout,
657 ) -> Result<(Vec<Vec<ViewRect>>, u32, u32), String> {
658 let rows = rows_of(job.plan);
659 let (cw, ch) = (grid.cell_width, grid.cell_height);
660 type Bbox = Option<(u32, u32, u32, u32)>;
661 let mut boxes: Vec<Vec<Bbox>> = Vec::with_capacity(rows.len());
662 for &(clip, frame) in &rows {
663 if job.cancel.load(Ordering::Relaxed) {
664 return Err("geannuleerd".into());
665 }
666 let token = &job.plan.clips[clip].token;
667 let mut row = Vec::with_capacity(grid.columns as usize);
668 for column in 0..grid.columns {
669 if !job.trim {
670 row.push(Some((0, 0, cw - 1, ch - 1)));
671 continue;
672 }
673 let view = grid.cell(0, column);
674 let mut bbox: Option<(u32, u32, u32, u32)> = None;
675 for kind in ATLAS_MAPS {
676 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
677 let bpp = cell.len() / (cw as usize * ch as usize);
678 for (i, px) in cell.chunks_exact(bpp).enumerate() {
679 if px.iter().any(|b| *b != 0) {
680 let (x, y) = ((i % cw as usize) as u32, (i / cw as usize) as u32);
681 bbox = Some(match bbox {
682 None => (x, y, x, y),
683 Some((a, b, c, d)) => (a.min(x), b.min(y), c.max(x), d.max(y)),
684 });
685 }
686 }
687 }
688 row.push(bbox.map(|(a, b, c, d)| {
689 (
690 a.saturating_sub(TRIM_PADDING),
691 b.saturating_sub(TRIM_PADDING),
692 (c + TRIM_PADDING).min(cw - 1),
693 (d + TRIM_PADDING).min(ch - 1),
694 )
695 }));
696 }
697 boxes.push(row);
698 }
699 let sizes: Vec<Vec<(u32, u32)>> = boxes
700 .iter()
701 .map(|r| {
702 r.iter()
703 .map(|b| {
704 b.map(|(a, b, c, d)| (c - a + 1, d - b + 1))
705 .unwrap_or((0, 0))
706 })
707 .collect()
708 })
709 .collect();
710 let (places, width, height) = pack(&sizes);
711 let rects = boxes
712 .iter()
713 .zip(&places)
714 .map(|(r, p)| {
715 r.iter()
716 .zip(p)
717 .map(|(b, &(x, y))| match b {
718 None => ViewRect {
719 x: 0,
720 y: 0,
721 w: 0,
722 h: 0,
723 ox: 0,
724 oy: 0,
725 empty: true,
726 },
727 Some((a, bb, c, d)) => ViewRect {
728 x,
729 y,
730 w: c - a + 1,
731 h: d - bb + 1,
732 ox: *a,
733 oy: *bb,
734 empty: false,
735 },
736 })
737 .collect()
738 })
739 .collect();
740 Ok((rects, width, height))
741 }
742
556 743 /// Write the four atlases and the `.ppda` after all loose maps are verified.
557 744 pub fn write_atlases(
558 745 job: &AtlasJob<'_>,
559 746 progress: &mut dyn FnMut(String),
560 747 ) -> Result<AtlasOutput, String> {
561 let layout = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;
748 let grid = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;
562 749 if job.png_sha256.len() as u64 != u64::from(job.plan.files) {
563 750 return Err("atlas: niet alle losse maps zijn geverifieerd".into());
564 751 }
752 progress("atlas · uitsnede bepalen".into());
753 let (rects, width, height) = plan_rects(job, &grid)?;
754 let layout = AtlasLayout {
755 width,
756 height,
757 ..grid
758 };
565 759 let mut maps = BTreeMap::new();
566 760 let mut digests = BTreeMap::new();
567 761 for kind in ATLAS_MAPS {
568 762 let name = atlas_png_name(job.sex, job.set, kind);
569 763 let digest = publish_streamed(job.root, &name, job.attempt, |out| {
570 encode_atlas(job, &layout, kind, out, progress)
764 encode_atlas(job, &layout, &rects, kind, out, progress)
571 765 })?;
572 766 digests.insert(kind.token().to_string(), digest.clone());
573 767 maps.insert(kind.token().to_string(), (name, digest));
@@ -576,10 +770,12 @@ pub fn write_atlases(
576 770 job.render_doc,
577 771 job.plan,
578 772 &layout,
773 &rects,
579 774 job.sex,
580 775 job.set,
581 776 &digests,
582 777 )?;
778 ppda.trimmed = Some(job.trim);
583 779 ppda.export = Some(PpdaExport {
584 780 render: job.render_doc.clone(),
585 781 passes: job.plan.passes,
@@ -694,8 +890,30 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
694 890 .iter()
695 891 .try_fold(0u32, |n, c| n.checked_add(c.frame_count))
696 892 .ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?;
697 let layout =
893 let grid =
698 894 AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;
895 let v5 = p.format_version >= 5;
896 if v5 != p.trimmed.is_some() {
897 return Err(ident(".ppda: trimmed past niet bij format_version"));
898 }
899 let trimmed = p.trimmed.unwrap_or(false);
900 // Recompute the packing from the stored sizes (v5); v2–4 use the grid.
901 let sizes: Vec<Vec<(u32, u32)>> = p
902 .clips
903 .iter()
904 .flat_map(|c| c.frames.iter())
905 .map(|f| f.views.iter().map(|v| (v.w, v.h)).collect())
906 .collect();
907 let (places, pw, ph) = pack(&sizes);
908 let layout = if v5 {
909 AtlasLayout {
910 width: pw,
911 height: ph,
912 ..grid
913 }
914 } else {
915 grid
916 };
699 917 let mut tokens = std::collections::BTreeSet::new();
700 918 let mut row = 0u32;
701 919 for (i, clip) in p.clips.iter().enumerate() {
@@ -723,10 +941,33 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
723 941 return Err(ident(".ppda: frame-index of aantal hoeken ongeldig"));
724 942 }
725 943 for (c, view) in frame.views.iter().enumerate() {
726 let expected = layout.cell(row, c as u32);
944 let grid_view = grid.cell(row, c as u32);
727 945 let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width)
728 946 && u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height);
729 if *view != expected || !inside {
947 let ok = if !v5 {
948 *view == grid_view
949 } else {
950 let (Some([ox, oy]), Some(fr), Some(empty)) =
951 (view.offset, view.frame, view.empty)
952 else {
953 return Err(ident(".ppda: offset/frame/empty ontbreken"));
954 };
955 let same_angle = view.pitch == grid_view.pitch && view.yaw == grid_view.yaw;
956 let shape = if empty {
957 trimmed && (view.x, view.y, view.w, view.h, ox, oy) == (0, 0, 0, 0, 0, 0)
958 } else {
959 view.w >= 1
960 && view.h >= 1
961 && u64::from(ox) + u64::from(view.w) <= u64::from(p.cell_width)
962 && u64::from(oy) + u64::from(view.h) <= u64::from(p.cell_height)
963 && (view.x, view.y) == places[row as usize][c]
964 && (trimmed
965 || (view.x, view.y, view.w, view.h, ox, oy)
966 == (grid_view.x, grid_view.y, grid_view.w, grid_view.h, 0, 0))
967 };
968 same_angle && fr == [p.cell_width, p.cell_height] && shape
969 };
970 if !ok || !inside {
730 971 return Err(ExportError::Corrupt(format!(
731 972 ".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet",
732 973 clip.token, view.pitch, view.yaw
@@ -965,14 +1206,15 @@ impl LoadedAtlas {
965 1206 yaw: u32,
966 1207 ) -> Result<Vec<u8>, ExportError> {
967 1208 let v = self.view(clip_token, frame, pitch, yaw)?;
968 let atlas_w = self.set.layout.width as usize;
969 let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);
970 let mut out = Vec::with_capacity(w * h * 4);
971 for row in y..y + h {
972 let start = (row * atlas_w + x) * 4;
973 out.extend_from_slice(&self.depth_display[start..start + w * 4]);
974 }
975 Ok(out)
1209 let fmt = self.set.ppda.depth_format()?;
1210 let fill = export_set::depth_display_rgba(&vec![0u8; fmt.bytes_per_pixel()], fmt);
1211 Ok(paste_full(
1212 &self.depth_display,
1213 self.set.layout.width,
1214 &v,
1215 4,
1216 &fill,
1217 ))
976 1218 }
977 1219
978 1220 /// Crop one map cell exactly as stored.
@@ -985,37 +1227,49 @@ impl LoadedAtlas {
985 1227 kind: MapKind,
986 1228 ) -> Result<MapImage, ExportError> {
987 1229 let v = self.view(clip_token, frame, pitch, yaw)?;
988 let atlas_w = self.set.layout.width as usize;
989 let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);
1230 let [fw, fh] = v.frame.unwrap_or([v.w, v.h]);
1231 let atlas_w = self.set.layout.width;
990 1232 match &self.maps[kind.token()] {
991 MapImage::Rgba8 { rgba, .. } => {
992 let mut out = Vec::with_capacity(w * h * 4);
993 for row in y..y + h {
994 let start = (row * atlas_w + x) * 4;
995 out.extend_from_slice(&rgba[start..start + w * 4]);
996 }
997 Ok(MapImage::Rgba8 {
998 width: v.w,
999 height: v.h,
1000 rgba: out,
1001 })
1002 }
1003 MapImage::Depth16 { samples, .. } => {
1004 let mut out = Vec::with_capacity(w * h * 2);
1005 for row in y..y + h {
1006 let start = (row * atlas_w + x) * 2;
1007 out.extend_from_slice(&samples[start..start + w * 2]);
1008 }
1009 Ok(MapImage::Depth16 {
1010 width: v.w,
1011 height: v.h,
1012 samples: out,
1013 })
1014 }
1233 MapImage::Rgba8 { rgba, .. } => Ok(MapImage::Rgba8 {
1234 width: fw,
1235 height: fh,
1236 rgba: paste_full(rgba, atlas_w, &v, 4, &[0; 4]),
1237 }),
1238 MapImage::Depth16 { samples, .. } => Ok(MapImage::Depth16 {
1239 width: fw,
1240 height: fh,
1241 samples: paste_full(samples, atlas_w, &v, 2, &[0; 2]),
1242 }),
1015 1243 }
1016 1244 }
1017 1245 }
1018 1246
1247 /// The full frame of a view: its atlas rect pasted at its offset into a frame
1248 /// filled with `fill` (one pixel, `per_px` elements). Untrimmed views have
1249 /// offset 0 and the full size, so this is the plain crop.
1250 fn paste_full<T: Copy>(src: &[T], atlas_w: u32, v: &PpdaView, per_px: usize, fill: &[T]) -> Vec<T> {
1251 let [fw, fh] = v.frame.unwrap_or([v.w, v.h]);
1252 let [ox, oy] = v.offset.unwrap_or([0, 0]);
1253 let (fw, fh) = (fw as usize, fh as usize);
1254 let mut out: Vec<T> = fill
1255 .iter()
1256 .copied()
1257 .cycle()
1258 .take(fw * fh * per_px)
1259 .collect();
1260 if v.empty == Some(true) {
1261 return out;
1262 }
1263 let atlas_w = atlas_w as usize;
1264 let w = v.w as usize * per_px;
1265 for row in 0..v.h as usize {
1266 let s = ((v.y as usize + row) * atlas_w + v.x as usize) * per_px;
1267 let d = ((oy as usize + row) * fw + ox as usize) * per_px;
1268 out[d..d + w].copy_from_slice(&src[s..s + w]);
1269 }
1270 out
1271 }
1272
1019 1273 /// Open a completed run through both readers and compare every atlas cell of
1020 1274 /// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
1021 1275 pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
@@ -1172,10 +1426,186 @@ mod tests {
1172 1426 }
1173 1427 }
1174 1428
1429 /// Strip the format-5 fields (back to the v2–v4 view shape).
1430 fn strip_v5(d: &mut serde_json::Value) {
1431 d.as_object_mut().unwrap().remove("trimmed");
1432 for clip in d["clips"].as_array_mut().unwrap() {
1433 for frame in clip["frames"].as_array_mut().unwrap() {
1434 for view in frame["views"].as_array_mut().unwrap() {
1435 let v = view.as_object_mut().unwrap();
1436 v.remove("offset");
1437 v.remove("frame");
1438 v.remove("empty");
1439 }
1440 }
1441 }
1442 }
1443
1444 /// Body + equipment pair, trimmed or not, with views of varying content
1445 /// boxes and some completely empty views (t1030u).
1446 fn trim_pair(dir: &Path, trim: bool, fmt: crate::camera::DepthFormat) -> [LoadedAtlas; 2] {
1447 let mut out = Vec::new();
1448 for set in ["body", "sword"] {
1449 let mut spec = SyntheticRun::body("trim-test", "male", b"body");
1450 if set != "body" {
1451 spec.set = set.into();
1452 spec.equipment_sha256 = Some(crate::digest::sha256_hex(b"sword"));
1453 }
1454 spec.clips = vec![("walk".into(), 2, 0.5)];
1455 spec.target = [12, 16];
1456 spec.depth_format = fmt;
1457 spec.trim = trim;
1458 spec.mask = Some(if set == "body" {
1459 |_, f, p, y, x, yy| {
1460 let s = (y / 30 + f) % 5;
1461 x >= 2 + s && x < 8 + s % 4 && yy > s && yy < 12 + (p.unsigned_abs() / 30)
1462 }
1463 } else {
1464 // The sword is only drawn in some views; the rest are empty.
1465 |_, f, _, y, x, yy| (y / 30 + f) % 3 == 0 && x == 9 && (4..9).contains(&yy)
1466 });
1467 let root = dir.join(set);
1468 spec.write(&root, |_, f, p, y| Pixel {
1469 diffuse: [40 + f as u8, (p + 60) as u8, (y / 30) as u8 * 20, 255],
1470 normal: [120, 130, 250, 255],
1471 specular: [7, 7, 7, 200],
1472 depth: 9000 + y as u16 * 3 + f as u16,
1473 })
1474 .unwrap();
1475 out.push(
1476 AtlasSet::open(&root.join(ppda_name("male", set)))
1477 .unwrap()
1478 .load()
1479 .unwrap(),
1480 );
1481 }
1482 out.try_into().unwrap()
1483 }
1484
1485 #[test]
1486 fn trimmed_atlases_composite_pixel_exact_like_untrimmed() {
1487 for fmt in [
1488 crate::camera::DepthFormat::GRAY16,
1489 crate::camera::DepthFormat::new(8, crate::camera::DepthEncoding::RgbPacked).unwrap(),
1490 ] {
1491 let tmp = Temp::new();
1492 let full = trim_pair(&tmp.0.join("full"), false, fmt);
1493 let trim = trim_pair(&tmp.0.join("trim"), true, fmt);
1494 for (a, b) in full.iter().zip(&trim) {
1495 assert_eq!(a.ppda().trimmed, Some(false));
1496 assert_eq!(b.ppda().trimmed, Some(true));
1497 assert!(
1498 u64::from(b.set.layout.width) * u64::from(b.set.layout.height)
1499 < u64::from(a.set.layout.width) * u64::from(a.set.layout.height),
1500 "trimmed atlas is smaller"
1501 );
1502 }
1503 // The sword has empty views: no atlas space, all-zero full frames.
1504 let empties = trim[1]
1505 .ppda()
1506 .clips
1507 .iter()
1508 .flat_map(|c| &c.frames)
1509 .flat_map(|f| &f.views)
1510 .filter(|v| v.empty == Some(true))
1511 .inspect(|v| assert_eq!((v.w, v.h), (0, 0)))
1512 .count();
1513 assert!(empties > 0);
1514 let light = crate::compositor::Light::default();
1515 for clip in &full[0].ppda().clips {
1516 for frame in &clip.frames {
1517 for view in &frame.views {
1518 let (t, f, p, y) = (&clip.token, frame.index, view.pitch, view.yaw);
1519 for i in 0..2 {
1520 for kind in ATLAS_MAPS {
1521 assert_eq!(
1522 full[i].cell(t, f, p, y, kind).unwrap(),
1523 trim[i].cell(t, f, p, y, kind).unwrap()
1524 );
1525 }
1526 assert_eq!(
1527 full[i].depth_display_cell(t, f, p, y).unwrap(),
1528 trim[i].depth_display_cell(t, f, p, y).unwrap()
1529 );
1530 }
1531 let layers = |set: &[LoadedAtlas; 2]| {
1532 set.iter()
1533 .map(|a| {
1534 crate::compositor::LayerMaps::from_atlas(a, "l", t, f, p, y)
1535 .unwrap()
1536 })
1537 .collect::<Vec<_>>()
1538 };
1539 let a = crate::compositor::compose(&layers(&full)).unwrap();
1540 let b = crate::compositor::compose(&layers(&trim)).unwrap();
1541 assert_eq!(
1542 (&a.diffuse, &a.normal, &a.specular, &a.depth),
1543 (&b.diffuse, &b.normal, &b.specular, &b.depth)
1544 );
1545 assert_eq!(
1546 crate::compositor::relight(&a, &light).unwrap().rgba,
1547 crate::compositor::relight(&b, &light).unwrap().rgba
1548 );
1549 }
1550 }
1551 }
1552 }
1553 }
1554
1555 #[test]
1556 fn all_empty_trimmed_set_is_one_transparent_pixel_and_tampering_is_refused() {
1557 let tmp = Temp::new();
1558 let mut spec = SyntheticRun::body("trim-empty", "female", b"body");
1559 spec.target = [4, 4];
1560 spec.trim = true;
1561 spec.mask = Some(|_, _, _, _, _, _| false);
1562 spec.write(&tmp.0, |_, _, _, _| Pixel {
1563 diffuse: [1, 2, 3, 255],
1564 normal: [128, 128, 255, 255],
1565 specular: [0, 0, 0, 255],
1566 depth: 5,
1567 })
1568 .unwrap();
1569 let ppda = tmp.0.join("female-body-atlas.ppda");
1570 let set = AtlasSet::open(&ppda).unwrap();
1571 assert_eq!((set.layout.width, set.layout.height), (1, 1));
1572 let loaded = set.load().unwrap();
1573 let cell = loaded
1574 .cell("clip000-bind", 0, 0, 0, MapKind::Diffuse)
1575 .unwrap();
1576 assert_eq!(
1577 cell,
1578 MapImage::Rgba8 {
1579 width: 4,
1580 height: 4,
1581 rgba: vec![0; 64]
1582 }
1583 );
1584 // A non-empty rect where the packing does not put it is refused.
1585 let mut spec = SyntheticRun::body("trim-tamper", "female", b"body");
1586 spec.target = [4, 4];
1587 spec.trim = true;
1588 spec.mask = Some(|_, _, _, y, x, _| y == 0 && x == 1);
1589 let dir = tmp.0.join("t");
1590 spec.write(&dir, |_, _, _, _| Pixel {
1591 diffuse: [1, 2, 3, 255],
1592 normal: [128, 128, 255, 255],
1593 specular: [0, 0, 0, 255],
1594 depth: 5,
1595 })
1596 .unwrap();
1597 AtlasSet::open(&dir.join("female-body-atlas.ppda")).unwrap();
1598 rewrite_ppda(&dir, |d| {
1599 d["clips"][0]["frames"][0]["views"][0]["x"] = 1.into();
1600 });
1601 assert!(AtlasSet::open(&dir.join("female-body-atlas.ppda")).is_err());
1602 }
1603
1175 1604 #[test]
1176 1605 fn legacy_ppda_v2_gray16_is_still_read() {
1177 1606 let tmp = fixture();
1178 1607 rewrite_ppda(&tmp.0, |d| {
1608 strip_v5(d);
1179 1609 d["format_version"] = 2.into();
1180 1610 d.as_object_mut().unwrap().remove("depth_bits");
1181 1611 d.as_object_mut().unwrap().remove("export");
@@ -1219,7 +1649,8 @@ mod tests {
1219 1649 let tmp = fixture();
1220 1650 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1221 1651 let p = &set.ppda;
1222 assert_eq!(p.format_version, 4);
1652 assert_eq!(p.format_version, 5);
1653 assert_eq!(p.trimmed, Some(false));
1223 1654 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1224 1655 // Format 4 embeds the export contract and every loose-map checksum.
1225 1656 let export = p.export.as_ref().unwrap();
@@ -1323,7 +1754,7 @@ mod tests {
1323 1754 Err(ExportError::Identity(_))
1324 1755 ));
1325 1756 restore();
1326 rewrite_ppda(&tmp.0, |d| d["format_version"] = 5.into());
1757 rewrite_ppda(&tmp.0, |d| d["format_version"] = 6.into());
1327 1758 assert!(matches!(
1328 1759 AtlasSet::open(&ppda),
1329 1760 Err(ExportError::Identity(_))
@@ -1337,8 +1768,24 @@ mod tests {
1337 1768 Err(ExportError::Identity(_))
1338 1769 ));
1339 1770 restore();
1771 // A v4 file never carries the format-5 view fields.
1772 rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());
1773 assert!(matches!(
1774 AtlasSet::open(&ppda),
1775 Err(ExportError::Identity(_))
1776 ));
1777 restore();
1778 rewrite_ppda(&tmp.0, |d| {
1779 strip_v5(d);
1780 d["format_version"] = 4.into();
1781 });
1782 AtlasSet::open(&ppda).unwrap();
1783 restore();
1340 1784 // Format 2 never carries depth_bits; format 3 always does.
1341 rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());
1785 rewrite_ppda(&tmp.0, |d| {
1786 strip_v5(d);
1787 d["format_version"] = 2.into();
1788 });
1342 1789 assert!(matches!(
1343 1790 AtlasSet::open(&ppda),
1344 1791 Err(ExportError::Identity(_))
@@ -1447,6 +1894,7 @@ mod tests {
1447 1894 png_sha256: &sums,
1448 1895 attempt: 1,
1449 1896 cancel: &cancel,
1897 trim: false,
1450 1898 };
1451 1899 // Re-running over identical files keeps them (no clobber, same result).
1452 1900 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
@@ -31,7 +31,11 @@ use std::{
31/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
31/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
32/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
32/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
33/// still read (16-bit gray).
33/// still read (16-bit gray).
34pub const PPDA_FORMAT_VERSION: u32 = 4;
34/// 4: export contract embedded. 5 (t1030u): optional trimmed views with
35/// per-view offset/frame/empty; versions 2–4 are still read.
36pub const PPDA_FORMAT_VERSION: u32 = 5;
37/// Padding around the content bounding box of a trimmed view.
38pub const TRIM_PADDING: u32 = 2;
35/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
39/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
36/// refused up front with a clear message; atlases are never split.
40/// refused up front with a clear message; atlases are never split.
37pub const MAX_ATLAS_DIM: u32 = 16384;
41pub const MAX_ATLAS_DIM: u32 = 16384;
@@ -131,10 +135,48 @@ impl AtlasLayout {
131 y: row * self.cell_height,
135 y: row * self.cell_height,
132 w: self.cell_width,
136 w: self.cell_width,
133 h: self.cell_height,
137 h: self.cell_height,
138 offset: None,
139 frame: None,
140 empty: None,
134 }
141 }
135 }
142 }
136}
143}
137
144
145/// One view's place in a (possibly trimmed) atlas.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ViewRect {
148 pub x: u32,
149 pub y: u32,
150 pub w: u32,
151 pub h: u32,
152 pub ox: u32,
153 pub oy: u32,
154 pub empty: bool,
155}
156
157/// Deterministic row packing: per atlas row the views left to right without
158/// gaps; row height = tallest view; size at least 1×1. Returns the (x, y) of
159/// every view and the atlas size.
160pub fn pack(sizes: &[Vec<(u32, u32)>]) -> (Vec<Vec<(u32, u32)>>, u32, u32) {
161 let mut y = 0u32;
162 let mut width = 0u32;
163 let mut out = Vec::with_capacity(sizes.len());
164 for row in sizes {
165 let mut x = 0u32;
166 let mut height = 0u32;
167 let mut places = Vec::with_capacity(row.len());
168 for &(w, h) in row {
169 places.push((x, y));
170 x = x.saturating_add(w);
171 height = height.max(h);
172 }
173 width = width.max(x);
174 y = y.saturating_add(height);
175 out.push(places);
176 }
177 (out, width.max(1), y.max(1))
178}
179
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
181#[serde(deny_unknown_fields)]
140pub struct Ppda {
182pub struct Ppda {
@@ -164,6 +206,9 @@ pub struct Ppda {
164 /// folder needs no other JSON. Absent in formats 2–3.
206 /// folder needs no other JSON. Absent in formats 2–3.
165 #[serde(default, skip_serializing_if = "Option::is_none")]
207 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub export: Option<PpdaExport>,
208 pub export: Option<PpdaExport>,
209 /// Format 5: whether views are trimmed to their content (t1030u).
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub trimmed: Option<bool>,
167}
212}
168
213
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -234,6 +279,15 @@ pub struct PpdaView {
234 pub y: u32,
279 pub y: u32,
235 pub w: u32,
280 pub w: u32,
236 pub h: u32,
281 pub h: u32,
282 /// Format 5: position of the rect within the full frame.
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub offset: Option<[u32; 2]>,
285 /// Format 5: full frame size (= cell size).
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub frame: Option<[u32; 2]>,
288 /// Format 5: no content; the view takes no atlas space.
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub empty: Option<bool>,
237}
291}
238
292
239/// What production records in `complete.json` after writing the atlases.
293/// What production records in `complete.json` after writing the atlases.
@@ -290,6 +344,8 @@ pub struct AtlasJob<'a> {
290 /// Resume attempt number for quarantine names of stale atlas files.
344 /// Resume attempt number for quarantine names of stale atlas files.
291 pub attempt: u32,
345 pub attempt: u32,
292 pub cancel: &'a AtomicBool,
346 pub cancel: &'a AtomicBool,
347 /// Crop every view to its content (t1030u).
348 pub trim: bool,
293}
349}
294
350
295fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
351fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
@@ -352,6 +408,7 @@ fn load_cell(
352fn encode_atlas(
408fn encode_atlas(
353 job: &AtlasJob<'_>,
409 job: &AtlasJob<'_>,
354 layout: &AtlasLayout,
410 layout: &AtlasLayout,
411 rects: &[Vec<ViewRect>],
355 kind: MapKind,
412 kind: MapKind,
356 out: &mut dyn Write,
413 out: &mut dyn Write,
357 progress: &mut dyn FnMut(String),
414 progress: &mut dyn FnMut(String),
@@ -372,13 +429,13 @@ fn encode_atlas(
372 }
429 }
373 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
430 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
374 let row_bytes = layout.width as usize * bpp;
431 let row_bytes = layout.width as usize * bpp;
375 let cell_row_bytes = layout.cell_width as usize * bpp;
432 let cell_w = layout.cell_width as usize;
376 let mut stream = writer
433 let mut stream = writer
377 .stream_writer_with_size(row_bytes.max(4096))
434 .stream_writer_with_size(row_bytes.max(4096))
378 .map_err(|e| e.to_string())?;
435 .map_err(|e| e.to_string())?;
379 let rows = rows_of(job.plan);
436 let rows = rows_of(job.plan);
380 let total = rows.len();
437 let total = rows.len();
381 let mut band = vec![0u8; row_bytes * layout.cell_height as usize];
438 let mut written = 0u32;
382 for (row, (clip, frame)) in rows.into_iter().enumerate() {
439 for (row, (clip, frame)) in rows.into_iter().enumerate() {
383 if job.cancel.load(Ordering::Relaxed) {
440 if job.cancel.load(Ordering::Relaxed) {
384 return Err("geannuleerd".into());
441 return Err("geannuleerd".into());
@@ -392,16 +449,37 @@ fn encode_atlas(
392 ));
449 ));
393 }
450 }
394 let token = &job.plan.clips[clip].token;
451 let token = &job.plan.clips[clip].token;
452 let row_h = rects[row]
453 .iter()
454 .filter(|r| !r.empty)
455 .map(|r| r.h)
456 .max()
457 .unwrap_or(0) as usize;
458 if row_h == 0 {
459 continue;
460 }
461 let mut band = vec![0u8; row_bytes * row_h];
395 for column in 0..layout.columns {
462 for column in 0..layout.columns {
463 let r = rects[row][column as usize];
464 if r.empty {
465 continue;
466 }
396 let view = layout.cell(0, column);
467 let view = layout.cell(0, column);
397 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
468 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
398 for y in 0..layout.cell_height as usize {
469 let w = r.w as usize * bpp;
399 let dst = y * row_bytes + view.x as usize * bpp;
470 for y in 0..r.h as usize {
400 band[dst..dst + cell_row_bytes]
471 let src = ((r.oy as usize + y) * cell_w + r.ox as usize) * bpp;
401 .copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);
472 let dst = y * row_bytes + r.x as usize * bpp;
473 band[dst..dst + w].copy_from_slice(&cell[src..src + w]);
402 }
474 }
403 }
475 }
404 stream.write_all(&band).map_err(|e| e.to_string())?;
476 stream.write_all(&band).map_err(|e| e.to_string())?;
477 written += row_h as u32;
478 }
479 // An atlas of only empty views is one transparent pixel row high.
480 if written < layout.height {
481 let pad = vec![0u8; row_bytes * (layout.height - written) as usize];
482 stream.write_all(&pad).map_err(|e| e.to_string())?;
405 }
483 }
406 stream.finish().map_err(|e| e.to_string())?;
484 stream.finish().map_err(|e| e.to_string())?;
407 writer.finish().map_err(|e| e.to_string())?;
485 writer.finish().map_err(|e| e.to_string())?;
@@ -457,6 +535,7 @@ pub fn build_ppda(
457 render_doc: &serde_json::Value,
535 render_doc: &serde_json::Value,
458 plan: &Plan,
536 plan: &Plan,
459 layout: &AtlasLayout,
537 layout: &AtlasLayout,
538 rects: &[Vec<ViewRect>],
460 sex: &str,
539 sex: &str,
461 set: &str,
540 set: &str,
462 map_digests: &BTreeMap<String, String>,
541 map_digests: &BTreeMap<String, String>,
@@ -498,7 +577,23 @@ pub fn build_ppda(
498 for index in 0..clip.frames {
577 for index in 0..clip.frames {
499 frames.push(PpdaFrame {
578 frames.push(PpdaFrame {
500 index,
579 index,
501 views: (0..layout.columns).map(|c| layout.cell(row, c)).collect(),
580 views: (0..layout.columns)
581 .map(|c| {
582 let r = rects[row as usize][c as usize];
583 let grid = layout.cell(row, c);
584 PpdaView {
585 pitch: grid.pitch,
586 yaw: grid.yaw,
587 x: r.x,
588 y: r.y,
589 w: r.w,
590 h: r.h,
591 offset: Some([r.ox, r.oy]),
592 frame: Some([layout.cell_width, layout.cell_height]),
593 empty: Some(r.empty),
594 }
595 })
596 .collect(),
502 });
597 });
503 row += 1;
598 row += 1;
504 }
599 }
@@ -550,24 +645,123 @@ pub fn build_ppda(
550 maps,
645 maps,
551 clips,
646 clips,
552 export: None,
647 export: None,
648 trimmed: Some(false),
553 })
649 })
554}
650}
555
651
652/// Rects of every view: the fixed grid, or (trim) the content bounding box
653/// plus padding over all four maps, packed with [`pack`].
654fn plan_rects(
655 job: &AtlasJob<'_>,
656 grid: &AtlasLayout,
657) -> Result<(Vec<Vec<ViewRect>>, u32, u32), String> {
658 let rows = rows_of(job.plan);
659 let (cw, ch) = (grid.cell_width, grid.cell_height);
660 type Bbox = Option<(u32, u32, u32, u32)>;
661 let mut boxes: Vec<Vec<Bbox>> = Vec::with_capacity(rows.len());
662 for &(clip, frame) in &rows {
663 if job.cancel.load(Ordering::Relaxed) {
664 return Err("geannuleerd".into());
665 }
666 let token = &job.plan.clips[clip].token;
667 let mut row = Vec::with_capacity(grid.columns as usize);
668 for column in 0..grid.columns {
669 if !job.trim {
670 row.push(Some((0, 0, cw - 1, ch - 1)));
671 continue;
672 }
673 let view = grid.cell(0, column);
674 let mut bbox: Option<(u32, u32, u32, u32)> = None;
675 for kind in ATLAS_MAPS {
676 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
677 let bpp = cell.len() / (cw as usize * ch as usize);
678 for (i, px) in cell.chunks_exact(bpp).enumerate() {
679 if px.iter().any(|b| *b != 0) {
680 let (x, y) = ((i % cw as usize) as u32, (i / cw as usize) as u32);
681 bbox = Some(match bbox {
682 None => (x, y, x, y),
683 Some((a, b, c, d)) => (a.min(x), b.min(y), c.max(x), d.max(y)),
684 });
685 }
686 }
687 }
688 row.push(bbox.map(|(a, b, c, d)| {
689 (
690 a.saturating_sub(TRIM_PADDING),
691 b.saturating_sub(TRIM_PADDING),
692 (c + TRIM_PADDING).min(cw - 1),
693 (d + TRIM_PADDING).min(ch - 1),
694 )
695 }));
696 }
697 boxes.push(row);
698 }
699 let sizes: Vec<Vec<(u32, u32)>> = boxes
700 .iter()
701 .map(|r| {
702 r.iter()
703 .map(|b| {
704 b.map(|(a, b, c, d)| (c - a + 1, d - b + 1))
705 .unwrap_or((0, 0))
706 })
707 .collect()
708 })
709 .collect();
710 let (places, width, height) = pack(&sizes);
711 let rects = boxes
712 .iter()
713 .zip(&places)
714 .map(|(r, p)| {
715 r.iter()
716 .zip(p)
717 .map(|(b, &(x, y))| match b {
718 None => ViewRect {
719 x: 0,
720 y: 0,
721 w: 0,
722 h: 0,
723 ox: 0,
724 oy: 0,
725 empty: true,
726 },
727 Some((a, bb, c, d)) => ViewRect {
728 x,
729 y,
730 w: c - a + 1,
731 h: d - bb + 1,
732 ox: *a,
733 oy: *bb,
734 empty: false,
735 },
736 })
737 .collect()
738 })
739 .collect();
740 Ok((rects, width, height))
741}
742
556/// Write the four atlases and the `.ppda` after all loose maps are verified.
743/// Write the four atlases and the `.ppda` after all loose maps are verified.
557pub fn write_atlases(
744pub fn write_atlases(
558 job: &AtlasJob<'_>,
745 job: &AtlasJob<'_>,
559 progress: &mut dyn FnMut(String),
746 progress: &mut dyn FnMut(String),
560) -> Result<AtlasOutput, String> {
747) -> Result<AtlasOutput, String> {
561 let layout = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;
748 let grid = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;
562 if job.png_sha256.len() as u64 != u64::from(job.plan.files) {
749 if job.png_sha256.len() as u64 != u64::from(job.plan.files) {
563 return Err("atlas: niet alle losse maps zijn geverifieerd".into());
750 return Err("atlas: niet alle losse maps zijn geverifieerd".into());
564 }
751 }
752 progress("atlas · uitsnede bepalen".into());
753 let (rects, width, height) = plan_rects(job, &grid)?;
754 let layout = AtlasLayout {
755 width,
756 height,
757 ..grid
758 };
565 let mut maps = BTreeMap::new();
759 let mut maps = BTreeMap::new();
566 let mut digests = BTreeMap::new();
760 let mut digests = BTreeMap::new();
567 for kind in ATLAS_MAPS {
761 for kind in ATLAS_MAPS {
568 let name = atlas_png_name(job.sex, job.set, kind);
762 let name = atlas_png_name(job.sex, job.set, kind);
569 let digest = publish_streamed(job.root, &name, job.attempt, |out| {
763 let digest = publish_streamed(job.root, &name, job.attempt, |out| {
570 encode_atlas(job, &layout, kind, out, progress)
764 encode_atlas(job, &layout, &rects, kind, out, progress)
571 })?;
765 })?;
572 digests.insert(kind.token().to_string(), digest.clone());
766 digests.insert(kind.token().to_string(), digest.clone());
573 maps.insert(kind.token().to_string(), (name, digest));
767 maps.insert(kind.token().to_string(), (name, digest));
@@ -576,10 +770,12 @@ pub fn write_atlases(
576 job.render_doc,
770 job.render_doc,
577 job.plan,
771 job.plan,
578 &layout,
772 &layout,
773 &rects,
579 job.sex,
774 job.sex,
580 job.set,
775 job.set,
581 &digests,
776 &digests,
582 )?;
777 )?;
778 ppda.trimmed = Some(job.trim);
583 ppda.export = Some(PpdaExport {
779 ppda.export = Some(PpdaExport {
584 render: job.render_doc.clone(),
780 render: job.render_doc.clone(),
585 passes: job.plan.passes,
781 passes: job.plan.passes,
@@ -694,8 +890,30 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
694 .iter()
890 .iter()
695 .try_fold(0u32, |n, c| n.checked_add(c.frame_count))
891 .try_fold(0u32, |n, c| n.checked_add(c.frame_count))
696 .ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?;
892 .ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?;
697 let layout =
893 let grid =
698 AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;
894 AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;
895 let v5 = p.format_version >= 5;
896 if v5 != p.trimmed.is_some() {
897 return Err(ident(".ppda: trimmed past niet bij format_version"));
898 }
899 let trimmed = p.trimmed.unwrap_or(false);
900 // Recompute the packing from the stored sizes (v5); v2–4 use the grid.
901 let sizes: Vec<Vec<(u32, u32)>> = p
902 .clips
903 .iter()
904 .flat_map(|c| c.frames.iter())
905 .map(|f| f.views.iter().map(|v| (v.w, v.h)).collect())
906 .collect();
907 let (places, pw, ph) = pack(&sizes);
908 let layout = if v5 {
909 AtlasLayout {
910 width: pw,
911 height: ph,
912 ..grid
913 }
914 } else {
915 grid
916 };
699 let mut tokens = std::collections::BTreeSet::new();
917 let mut tokens = std::collections::BTreeSet::new();
700 let mut row = 0u32;
918 let mut row = 0u32;
701 for (i, clip) in p.clips.iter().enumerate() {
919 for (i, clip) in p.clips.iter().enumerate() {
@@ -723,10 +941,33 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
723 return Err(ident(".ppda: frame-index of aantal hoeken ongeldig"));
941 return Err(ident(".ppda: frame-index of aantal hoeken ongeldig"));
724 }
942 }
725 for (c, view) in frame.views.iter().enumerate() {
943 for (c, view) in frame.views.iter().enumerate() {
726 let expected = layout.cell(row, c as u32);
944 let grid_view = grid.cell(row, c as u32);
727 let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width)
945 let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width)
728 && u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height);
946 && u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height);
729 if *view != expected || !inside {
947 let ok = if !v5 {
948 *view == grid_view
949 } else {
950 let (Some([ox, oy]), Some(fr), Some(empty)) =
951 (view.offset, view.frame, view.empty)
952 else {
953 return Err(ident(".ppda: offset/frame/empty ontbreken"));
954 };
955 let same_angle = view.pitch == grid_view.pitch && view.yaw == grid_view.yaw;
956 let shape = if empty {
957 trimmed && (view.x, view.y, view.w, view.h, ox, oy) == (0, 0, 0, 0, 0, 0)
958 } else {
959 view.w >= 1
960 && view.h >= 1
961 && u64::from(ox) + u64::from(view.w) <= u64::from(p.cell_width)
962 && u64::from(oy) + u64::from(view.h) <= u64::from(p.cell_height)
963 && (view.x, view.y) == places[row as usize][c]
964 && (trimmed
965 || (view.x, view.y, view.w, view.h, ox, oy)
966 == (grid_view.x, grid_view.y, grid_view.w, grid_view.h, 0, 0))
967 };
968 same_angle && fr == [p.cell_width, p.cell_height] && shape
969 };
970 if !ok || !inside {
730 return Err(ExportError::Corrupt(format!(
971 return Err(ExportError::Corrupt(format!(
731 ".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet",
972 ".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet",
732 clip.token, view.pitch, view.yaw
973 clip.token, view.pitch, view.yaw
@@ -965,14 +1206,15 @@ impl LoadedAtlas {
965 yaw: u32,
1206 yaw: u32,
966 ) -> Result<Vec<u8>, ExportError> {
1207 ) -> Result<Vec<u8>, ExportError> {
967 let v = self.view(clip_token, frame, pitch, yaw)?;
1208 let v = self.view(clip_token, frame, pitch, yaw)?;
968 let atlas_w = self.set.layout.width as usize;
1209 let fmt = self.set.ppda.depth_format()?;
969 let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);
1210 let fill = export_set::depth_display_rgba(&vec![0u8; fmt.bytes_per_pixel()], fmt);
970 let mut out = Vec::with_capacity(w * h * 4);
1211 Ok(paste_full(
971 for row in y..y + h {
1212 &self.depth_display,
972 let start = (row * atlas_w + x) * 4;
1213 self.set.layout.width,
973 out.extend_from_slice(&self.depth_display[start..start + w * 4]);
1214 &v,
974 }
1215 4,
975 Ok(out)
1216 &fill,
1217 ))
976 }
1218 }
977
1219
978 /// Crop one map cell exactly as stored.
1220 /// Crop one map cell exactly as stored.
@@ -985,37 +1227,49 @@ impl LoadedAtlas {
985 kind: MapKind,
1227 kind: MapKind,
986 ) -> Result<MapImage, ExportError> {
1228 ) -> Result<MapImage, ExportError> {
987 let v = self.view(clip_token, frame, pitch, yaw)?;
1229 let v = self.view(clip_token, frame, pitch, yaw)?;
988 let atlas_w = self.set.layout.width as usize;
1230 let [fw, fh] = v.frame.unwrap_or([v.w, v.h]);
989 let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);
1231 let atlas_w = self.set.layout.width;
990 match &self.maps[kind.token()] {
1232 match &self.maps[kind.token()] {
991 MapImage::Rgba8 { rgba, .. } => {
1233 MapImage::Rgba8 { rgba, .. } => Ok(MapImage::Rgba8 {
992 let mut out = Vec::with_capacity(w * h * 4);
1234 width: fw,
993 for row in y..y + h {
1235 height: fh,
994 let start = (row * atlas_w + x) * 4;
1236 rgba: paste_full(rgba, atlas_w, &v, 4, &[0; 4]),
995 out.extend_from_slice(&rgba[start..start + w * 4]);
1237 }),
996 }
1238 MapImage::Depth16 { samples, .. } => Ok(MapImage::Depth16 {
997 Ok(MapImage::Rgba8 {
1239 width: fw,
998 width: v.w,
1240 height: fh,
999 height: v.h,
1241 samples: paste_full(samples, atlas_w, &v, 2, &[0; 2]),
1000 rgba: out,
1242 }),
1001 })
1002 }
1003 MapImage::Depth16 { samples, .. } => {
1004 let mut out = Vec::with_capacity(w * h * 2);
1005 for row in y..y + h {
1006 let start = (row * atlas_w + x) * 2;
1007 out.extend_from_slice(&samples[start..start + w * 2]);
1008 }
1009 Ok(MapImage::Depth16 {
1010 width: v.w,
1011 height: v.h,
1012 samples: out,
1013 })
1014 }
1015 }
1243 }
1016 }
1244 }
1017}
1245}
1018
1246
1247/// The full frame of a view: its atlas rect pasted at its offset into a frame
1248/// filled with `fill` (one pixel, `per_px` elements). Untrimmed views have
1249/// offset 0 and the full size, so this is the plain crop.
1250fn paste_full<T: Copy>(src: &[T], atlas_w: u32, v: &PpdaView, per_px: usize, fill: &[T]) -> Vec<T> {
1251 let [fw, fh] = v.frame.unwrap_or([v.w, v.h]);
1252 let [ox, oy] = v.offset.unwrap_or([0, 0]);
1253 let (fw, fh) = (fw as usize, fh as usize);
1254 let mut out: Vec<T> = fill
1255 .iter()
1256 .copied()
1257 .cycle()
1258 .take(fw * fh * per_px)
1259 .collect();
1260 if v.empty == Some(true) {
1261 return out;
1262 }
1263 let atlas_w = atlas_w as usize;
1264 let w = v.w as usize * per_px;
1265 for row in 0..v.h as usize {
1266 let s = ((v.y as usize + row) * atlas_w + v.x as usize) * per_px;
1267 let d = ((oy as usize + row) * fw + ox as usize) * per_px;
1268 out[d..d + w].copy_from_slice(&src[s..s + w]);
1269 }
1270 out
1271}
1272
1019/// Open a completed run through both readers and compare every atlas cell of
1273/// Open a completed run through both readers and compare every atlas cell of
1020/// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
1274/// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
1021pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
1275pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
@@ -1172,10 +1426,186 @@ mod tests {
1172 }
1426 }
1173 }
1427 }
1174
1428
1429 /// Strip the format-5 fields (back to the v2–v4 view shape).
1430 fn strip_v5(d: &mut serde_json::Value) {
1431 d.as_object_mut().unwrap().remove("trimmed");
1432 for clip in d["clips"].as_array_mut().unwrap() {
1433 for frame in clip["frames"].as_array_mut().unwrap() {
1434 for view in frame["views"].as_array_mut().unwrap() {
1435 let v = view.as_object_mut().unwrap();
1436 v.remove("offset");
1437 v.remove("frame");
1438 v.remove("empty");
1439 }
1440 }
1441 }
1442 }
1443
1444 /// Body + equipment pair, trimmed or not, with views of varying content
1445 /// boxes and some completely empty views (t1030u).
1446 fn trim_pair(dir: &Path, trim: bool, fmt: crate::camera::DepthFormat) -> [LoadedAtlas; 2] {
1447 let mut out = Vec::new();
1448 for set in ["body", "sword"] {
1449 let mut spec = SyntheticRun::body("trim-test", "male", b"body");
1450 if set != "body" {
1451 spec.set = set.into();
1452 spec.equipment_sha256 = Some(crate::digest::sha256_hex(b"sword"));
1453 }
1454 spec.clips = vec![("walk".into(), 2, 0.5)];
1455 spec.target = [12, 16];
1456 spec.depth_format = fmt;
1457 spec.trim = trim;
1458 spec.mask = Some(if set == "body" {
1459 |_, f, p, y, x, yy| {
1460 let s = (y / 30 + f) % 5;
1461 x >= 2 + s && x < 8 + s % 4 && yy > s && yy < 12 + (p.unsigned_abs() / 30)
1462 }
1463 } else {
1464 // The sword is only drawn in some views; the rest are empty.
1465 |_, f, _, y, x, yy| (y / 30 + f) % 3 == 0 && x == 9 && (4..9).contains(&yy)
1466 });
1467 let root = dir.join(set);
1468 spec.write(&root, |_, f, p, y| Pixel {
1469 diffuse: [40 + f as u8, (p + 60) as u8, (y / 30) as u8 * 20, 255],
1470 normal: [120, 130, 250, 255],
1471 specular: [7, 7, 7, 200],
1472 depth: 9000 + y as u16 * 3 + f as u16,
1473 })
1474 .unwrap();
1475 out.push(
1476 AtlasSet::open(&root.join(ppda_name("male", set)))
1477 .unwrap()
1478 .load()
1479 .unwrap(),
1480 );
1481 }
1482 out.try_into().unwrap()
1483 }
1484
1485 #[test]
1486 fn trimmed_atlases_composite_pixel_exact_like_untrimmed() {
1487 for fmt in [
1488 crate::camera::DepthFormat::GRAY16,
1489 crate::camera::DepthFormat::new(8, crate::camera::DepthEncoding::RgbPacked).unwrap(),
1490 ] {
1491 let tmp = Temp::new();
1492 let full = trim_pair(&tmp.0.join("full"), false, fmt);
1493 let trim = trim_pair(&tmp.0.join("trim"), true, fmt);
1494 for (a, b) in full.iter().zip(&trim) {
1495 assert_eq!(a.ppda().trimmed, Some(false));
1496 assert_eq!(b.ppda().trimmed, Some(true));
1497 assert!(
1498 u64::from(b.set.layout.width) * u64::from(b.set.layout.height)
1499 < u64::from(a.set.layout.width) * u64::from(a.set.layout.height),
1500 "trimmed atlas is smaller"
1501 );
1502 }
1503 // The sword has empty views: no atlas space, all-zero full frames.
1504 let empties = trim[1]
1505 .ppda()
1506 .clips
1507 .iter()
1508 .flat_map(|c| &c.frames)
1509 .flat_map(|f| &f.views)
1510 .filter(|v| v.empty == Some(true))
1511 .inspect(|v| assert_eq!((v.w, v.h), (0, 0)))
1512 .count();
1513 assert!(empties > 0);
1514 let light = crate::compositor::Light::default();
1515 for clip in &full[0].ppda().clips {
1516 for frame in &clip.frames {
1517 for view in &frame.views {
1518 let (t, f, p, y) = (&clip.token, frame.index, view.pitch, view.yaw);
1519 for i in 0..2 {
1520 for kind in ATLAS_MAPS {
1521 assert_eq!(
1522 full[i].cell(t, f, p, y, kind).unwrap(),
1523 trim[i].cell(t, f, p, y, kind).unwrap()
1524 );
1525 }
1526 assert_eq!(
1527 full[i].depth_display_cell(t, f, p, y).unwrap(),
1528 trim[i].depth_display_cell(t, f, p, y).unwrap()
1529 );
1530 }
1531 let layers = |set: &[LoadedAtlas; 2]| {
1532 set.iter()
1533 .map(|a| {
1534 crate::compositor::LayerMaps::from_atlas(a, "l", t, f, p, y)
1535 .unwrap()
1536 })
1537 .collect::<Vec<_>>()
1538 };
1539 let a = crate::compositor::compose(&layers(&full)).unwrap();
1540 let b = crate::compositor::compose(&layers(&trim)).unwrap();
1541 assert_eq!(
1542 (&a.diffuse, &a.normal, &a.specular, &a.depth),
1543 (&b.diffuse, &b.normal, &b.specular, &b.depth)
1544 );
1545 assert_eq!(
1546 crate::compositor::relight(&a, &light).unwrap().rgba,
1547 crate::compositor::relight(&b, &light).unwrap().rgba
1548 );
1549 }
1550 }
1551 }
1552 }
1553 }
1554
1555 #[test]
1556 fn all_empty_trimmed_set_is_one_transparent_pixel_and_tampering_is_refused() {
1557 let tmp = Temp::new();
1558 let mut spec = SyntheticRun::body("trim-empty", "female", b"body");
1559 spec.target = [4, 4];
1560 spec.trim = true;
1561 spec.mask = Some(|_, _, _, _, _, _| false);
1562 spec.write(&tmp.0, |_, _, _, _| Pixel {
1563 diffuse: [1, 2, 3, 255],
1564 normal: [128, 128, 255, 255],
1565 specular: [0, 0, 0, 255],
1566 depth: 5,
1567 })
1568 .unwrap();
1569 let ppda = tmp.0.join("female-body-atlas.ppda");
1570 let set = AtlasSet::open(&ppda).unwrap();
1571 assert_eq!((set.layout.width, set.layout.height), (1, 1));
1572 let loaded = set.load().unwrap();
1573 let cell = loaded
1574 .cell("clip000-bind", 0, 0, 0, MapKind::Diffuse)
1575 .unwrap();
1576 assert_eq!(
1577 cell,
1578 MapImage::Rgba8 {
1579 width: 4,
1580 height: 4,
1581 rgba: vec![0; 64]
1582 }
1583 );
1584 // A non-empty rect where the packing does not put it is refused.
1585 let mut spec = SyntheticRun::body("trim-tamper", "female", b"body");
1586 spec.target = [4, 4];
1587 spec.trim = true;
1588 spec.mask = Some(|_, _, _, y, x, _| y == 0 && x == 1);
1589 let dir = tmp.0.join("t");
1590 spec.write(&dir, |_, _, _, _| Pixel {
1591 diffuse: [1, 2, 3, 255],
1592 normal: [128, 128, 255, 255],
1593 specular: [0, 0, 0, 255],
1594 depth: 5,
1595 })
1596 .unwrap();
1597 AtlasSet::open(&dir.join("female-body-atlas.ppda")).unwrap();
1598 rewrite_ppda(&dir, |d| {
1599 d["clips"][0]["frames"][0]["views"][0]["x"] = 1.into();
1600 });
1601 assert!(AtlasSet::open(&dir.join("female-body-atlas.ppda")).is_err());
1602 }
1603
1175 #[test]
1604 #[test]
1176 fn legacy_ppda_v2_gray16_is_still_read() {
1605 fn legacy_ppda_v2_gray16_is_still_read() {
1177 let tmp = fixture();
1606 let tmp = fixture();
1178 rewrite_ppda(&tmp.0, |d| {
1607 rewrite_ppda(&tmp.0, |d| {
1608 strip_v5(d);
1179 d["format_version"] = 2.into();
1609 d["format_version"] = 2.into();
1180 d.as_object_mut().unwrap().remove("depth_bits");
1610 d.as_object_mut().unwrap().remove("depth_bits");
1181 d.as_object_mut().unwrap().remove("export");
1611 d.as_object_mut().unwrap().remove("export");
@@ -1219,7 +1649,8 @@ mod tests {
1219 let tmp = fixture();
1649 let tmp = fixture();
1220 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1650 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1221 let p = &set.ppda;
1651 let p = &set.ppda;
1222 assert_eq!(p.format_version, 4);
1652 assert_eq!(p.format_version, 5);
1653 assert_eq!(p.trimmed, Some(false));
1223 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1654 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1224 // Format 4 embeds the export contract and every loose-map checksum.
1655 // Format 4 embeds the export contract and every loose-map checksum.
1225 let export = p.export.as_ref().unwrap();
1656 let export = p.export.as_ref().unwrap();
@@ -1323,7 +1754,7 @@ mod tests {
1323 Err(ExportError::Identity(_))
1754 Err(ExportError::Identity(_))
1324 ));
1755 ));
1325 restore();
1756 restore();
1326 rewrite_ppda(&tmp.0, |d| d["format_version"] = 5.into());
1757 rewrite_ppda(&tmp.0, |d| d["format_version"] = 6.into());
1327 assert!(matches!(
1758 assert!(matches!(
1328 AtlasSet::open(&ppda),
1759 AtlasSet::open(&ppda),
1329 Err(ExportError::Identity(_))
1760 Err(ExportError::Identity(_))
@@ -1337,8 +1768,24 @@ mod tests {
1337 Err(ExportError::Identity(_))
1768 Err(ExportError::Identity(_))
1338 ));
1769 ));
1339 restore();
1770 restore();
1771 // A v4 file never carries the format-5 view fields.
1772 rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());
1773 assert!(matches!(
1774 AtlasSet::open(&ppda),
1775 Err(ExportError::Identity(_))
1776 ));
1777 restore();
1778 rewrite_ppda(&tmp.0, |d| {
1779 strip_v5(d);
1780 d["format_version"] = 4.into();
1781 });
1782 AtlasSet::open(&ppda).unwrap();
1783 restore();
1340 // Format 2 never carries depth_bits; format 3 always does.
1784 // Format 2 never carries depth_bits; format 3 always does.
1341 rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());
1785 rewrite_ppda(&tmp.0, |d| {
1786 strip_v5(d);
1787 d["format_version"] = 2.into();
1788 });
1342 assert!(matches!(
1789 assert!(matches!(
1343 AtlasSet::open(&ppda),
1790 AtlasSet::open(&ppda),
1344 Err(ExportError::Identity(_))
1791 Err(ExportError::Identity(_))
@@ -1447,6 +1894,7 @@ mod tests {
1447 png_sha256: &sums,
1894 png_sha256: &sums,
1448 attempt: 1,
1895 attempt: 1,
1449 cancel: &cancel,
1896 cancel: &cancel,
1897 trim: false,
1450 };
1898 };
1451 // Re-running over identical files keeps them (no clobber, same result).
1899 // Re-running over identical files keeps them (no clobber, same result).
1452 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
1900 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
src/editor.rs +54 −0
@@ -706,6 +706,7 @@ impl EditorApp {
706 706 .map_err(|e| format!("equipment-fingerprint: {e}"))?,
707 707 )),
708 708 };
709 let atlas_trim = atlas_trim_for(&opened.document.render, preset.is_some());
709 710 Ok(RenderJob {
710 711 character,
711 712 sex: if sex == 0 { "male" } else { "female" }.into(),
@@ -726,6 +727,7 @@ impl EditorApp {
726 727 opened.document.render.depth_bits,
727 728 opened.document.render.depth_encoding,
728 729 )?,
730 atlas_trim,
729 731 })
730 732 }
731 733 /// Every body (male, female) and then every equipment item per applicable
@@ -1296,6 +1298,14 @@ impl EditorApp {
1296 1298 ui.weak(crate::i18n::t("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie)."));
1297 1299 }
1298 1300 self.set_depth_format(bits, encoding);
1301 if let Some(opened) = &mut self.session.opened {
1302 let r = &mut opened.document.render;
1303 ui.horizontal_wrapped(|ui| {
1304 ui.label(crate::i18n::t("Atlas bijsnijden:"));
1305 ui.checkbox(&mut r.trim_body, crate::i18n::t("lichaam"));
1306 ui.checkbox(&mut r.trim_equipment, crate::i18n::t("equipment"));
1307 });
1308 }
1299 1309 }
1300 1310 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
1301 1311 if !self.show_render_frame {
@@ -2564,6 +2574,15 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)
2564 2574 choice
2565 2575 }
2566 2576
2577 /// Atlas trim setting for a body or equipment render (t1030u).
2578 pub fn atlas_trim_for(render: &crate::project::RenderSettings, equipment: bool) -> bool {
2579 if equipment {
2580 render.trim_equipment
2581 } else {
2582 render.trim_body
2583 }
2584 }
2585
2567 2586 /// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2568 2587 /// character bone (optional; at least one), with its own position/rotation.
2569 2588 fn skinned_links_ui(
@@ -2986,6 +3005,41 @@ mod tests {
2986 3005 let _ = std::fs::remove_dir_all(root);
2987 3006 }
2988 3007
3008 #[test]
3009 fn atlas_trim_toggles_default_on_and_select_body_or_equipment() {
3010 let mut r = crate::project::RenderSettings::default();
3011 assert!(r.trim_body && r.trim_equipment);
3012 r.trim_body = false;
3013 assert!(!atlas_trim_for(&r, false));
3014 assert!(atlas_trim_for(&r, true));
3015 r.trim_body = true;
3016 r.trim_equipment = false;
3017 assert!(atlas_trim_for(&r, false));
3018 assert!(!atlas_trim_for(&r, true));
3019 // The body render job follows the body toggle.
3020 let root = root();
3021 let opened = crate::session::create_seeded_project(&root).unwrap();
3022 let mut app = EditorApp::default();
3023 app.open_path(opened.manifest_path.clone());
3024 app.session
3025 .opened
3026 .as_mut()
3027 .unwrap()
3028 .document
3029 .render
3030 .trim_body = false;
3031 assert!(!app.job_for(0, None).unwrap().atlas_trim);
3032 app.session
3033 .opened
3034 .as_mut()
3035 .unwrap()
3036 .document
3037 .render
3038 .trim_body = true;
3039 assert!(app.job_for(0, None).unwrap().atlas_trim);
3040 let _ = std::fs::remove_dir_all(root);
3041 }
3042
2989 3043 #[test]
2990 3044 fn render_all_end_toasts_once_only_when_unfocused() {
2991 3045 let root = root();
@@ -706,6 +706,7 @@ impl EditorApp {
706 .map_err(|e| format!("equipment-fingerprint: {e}"))?,
706 .map_err(|e| format!("equipment-fingerprint: {e}"))?,
707 )),
707 )),
708 };
708 };
709 let atlas_trim = atlas_trim_for(&opened.document.render, preset.is_some());
709 Ok(RenderJob {
710 Ok(RenderJob {
710 character,
711 character,
711 sex: if sex == 0 { "male" } else { "female" }.into(),
712 sex: if sex == 0 { "male" } else { "female" }.into(),
@@ -726,6 +727,7 @@ impl EditorApp {
726 opened.document.render.depth_bits,
727 opened.document.render.depth_bits,
727 opened.document.render.depth_encoding,
728 opened.document.render.depth_encoding,
728 )?,
729 )?,
730 atlas_trim,
729 })
731 })
730 }
732 }
731 /// Every body (male, female) and then every equipment item per applicable
733 /// Every body (male, female) and then every equipment item per applicable
@@ -1296,6 +1298,14 @@ impl EditorApp {
1296 ui.weak(crate::i18n::t("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie)."));
1298 ui.weak(crate::i18n::t("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie)."));
1297 }
1299 }
1298 self.set_depth_format(bits, encoding);
1300 self.set_depth_format(bits, encoding);
1301 if let Some(opened) = &mut self.session.opened {
1302 let r = &mut opened.document.render;
1303 ui.horizontal_wrapped(|ui| {
1304 ui.label(crate::i18n::t("Atlas bijsnijden:"));
1305 ui.checkbox(&mut r.trim_body, crate::i18n::t("lichaam"));
1306 ui.checkbox(&mut r.trim_equipment, crate::i18n::t("equipment"));
1307 });
1308 }
1299 }
1309 }
1300 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
1310 fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
1301 if !self.show_render_frame {
1311 if !self.show_render_frame {
@@ -2564,6 +2574,15 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)
2564 choice
2574 choice
2565}
2575}
2566
2576
2577/// Atlas trim setting for a body or equipment render (t1030u).
2578pub fn atlas_trim_for(render: &crate::project::RenderSettings, equipment: bool) -> bool {
2579 if equipment {
2580 render.trim_equipment
2581 } else {
2582 render.trim_body
2583 }
2584}
2585
2567/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2586/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2568/// character bone (optional; at least one), with its own position/rotation.
2587/// character bone (optional; at least one), with its own position/rotation.
2569fn skinned_links_ui(
2588fn skinned_links_ui(
@@ -2986,6 +3005,41 @@ mod tests {
2986 let _ = std::fs::remove_dir_all(root);
3005 let _ = std::fs::remove_dir_all(root);
2987 }
3006 }
2988
3007
3008 #[test]
3009 fn atlas_trim_toggles_default_on_and_select_body_or_equipment() {
3010 let mut r = crate::project::RenderSettings::default();
3011 assert!(r.trim_body && r.trim_equipment);
3012 r.trim_body = false;
3013 assert!(!atlas_trim_for(&r, false));
3014 assert!(atlas_trim_for(&r, true));
3015 r.trim_body = true;
3016 r.trim_equipment = false;
3017 assert!(atlas_trim_for(&r, false));
3018 assert!(!atlas_trim_for(&r, true));
3019 // The body render job follows the body toggle.
3020 let root = root();
3021 let opened = crate::session::create_seeded_project(&root).unwrap();
3022 let mut app = EditorApp::default();
3023 app.open_path(opened.manifest_path.clone());
3024 app.session
3025 .opened
3026 .as_mut()
3027 .unwrap()
3028 .document
3029 .render
3030 .trim_body = false;
3031 assert!(!app.job_for(0, None).unwrap().atlas_trim);
3032 app.session
3033 .opened
3034 .as_mut()
3035 .unwrap()
3036 .document
3037 .render
3038 .trim_body = true;
3039 assert!(app.job_for(0, None).unwrap().atlas_trim);
3040 let _ = std::fs::remove_dir_all(root);
3041 }
3042
2989 #[test]
3043 #[test]
2990 fn render_all_end_toasts_once_only_when_unfocused() {
3044 fn render_all_end_toasts_once_only_when_unfocused() {
2991 let root = root();
3045 let root = root();
src/export_fixture.rs +25 −2
@@ -23,6 +23,9 @@ pub struct Pixel {
23 23 pub depth: u16,
24 24 }
25 25
26 /// (clip, frame, pitch, yaw, x, y) → pixel has content.
27 pub type PixelMask = fn(usize, u32, i32, u32, u32, u32) -> bool;
28
26 29 #[derive(Debug, Clone)]
27 30 pub struct SyntheticRun {
28 31 pub project_id: String,
@@ -37,6 +40,11 @@ pub struct SyntheticRun {
37 40 pub depth_format: crate::camera::DepthFormat,
38 41 /// (source name, frames, duration seconds)
39 42 pub clips: Vec<(String, u32, f32)>,
43 /// Trimmed atlases (t1030u); off by default in fixtures.
44 pub trim: bool,
45 /// Optional per-pixel content mask (clip, frame, pitch, yaw, x, y); pixels
46 /// outside the mask are all-zero in every map.
47 pub mask: Option<PixelMask>,
40 48 }
41 49
42 50 impl SyntheticRun {
@@ -57,6 +65,8 @@ impl SyntheticRun {
57 65 },
58 66 clips: vec![("bind".into(), 1, 0.0)],
59 67 depth_format: crate::camera::DepthFormat::GRAY16,
68 trim: false,
69 mask: None,
60 70 }
61 71 }
62 72
@@ -154,6 +164,12 @@ impl SyntheticRun {
154 164 let mut sums = BTreeMap::new();
155 165 for s in production::samples(&plan) {
156 166 let px = pixel(s.clip, s.frame, s.pitch, s.yaw);
167 let inside: Vec<bool> = (0..n as u32)
168 .map(|i| {
169 self.mask
170 .is_none_or(|m| m(s.clip, s.frame, s.pitch, s.yaw, i % w, i / w))
171 })
172 .collect();
157 173 for map in production::SAVED_ORDER {
158 174 let rel = production::output_path(
159 175 &self.sex,
@@ -170,7 +186,10 @@ impl SyntheticRun {
170 186 } else {
171 187 (f64::from(px.depth) / 65535., 1.)
172 188 };
173 let pixels = vec![pair; n];
189 let pixels: Vec<(f64, f64)> = inside
190 .iter()
191 .map(|&i| if i { pair } else { (0., 0.) })
192 .collect();
174 193 production::write_depth_fmt(root, &rel, w, h, &pixels, self.depth_format)?
175 194 } else {
176 195 let rgba = match map {
@@ -184,7 +203,10 @@ impl SyntheticRun {
184 203 &ImageBuf {
185 204 width: w,
186 205 height: h,
187 rgba: rgba.repeat(n),
206 rgba: inside
207 .iter()
208 .flat_map(|&i| if i { rgba } else { [0; 4] })
209 .collect(),
188 210 },
189 211 )?
190 212 };
@@ -205,6 +227,7 @@ impl SyntheticRun {
205 227 png_sha256: &sums,
206 228 attempt: 0,
207 229 cancel: &cancel,
230 trim: self.trim,
208 231 },
209 232 &mut |_| {},
210 233 )?;
@@ -23,6 +23,9 @@ pub struct Pixel {
23 pub depth: u16,
23 pub depth: u16,
24}
24}
25
25
26/// (clip, frame, pitch, yaw, x, y) → pixel has content.
27pub type PixelMask = fn(usize, u32, i32, u32, u32, u32) -> bool;
28
26#[derive(Debug, Clone)]
29#[derive(Debug, Clone)]
27pub struct SyntheticRun {
30pub struct SyntheticRun {
28 pub project_id: String,
31 pub project_id: String,
@@ -37,6 +40,11 @@ pub struct SyntheticRun {
37 pub depth_format: crate::camera::DepthFormat,
40 pub depth_format: crate::camera::DepthFormat,
38 /// (source name, frames, duration seconds)
41 /// (source name, frames, duration seconds)
39 pub clips: Vec<(String, u32, f32)>,
42 pub clips: Vec<(String, u32, f32)>,
43 /// Trimmed atlases (t1030u); off by default in fixtures.
44 pub trim: bool,
45 /// Optional per-pixel content mask (clip, frame, pitch, yaw, x, y); pixels
46 /// outside the mask are all-zero in every map.
47 pub mask: Option<PixelMask>,
40}
48}
41
49
42impl SyntheticRun {
50impl SyntheticRun {
@@ -57,6 +65,8 @@ impl SyntheticRun {
57 },
65 },
58 clips: vec![("bind".into(), 1, 0.0)],
66 clips: vec![("bind".into(), 1, 0.0)],
59 depth_format: crate::camera::DepthFormat::GRAY16,
67 depth_format: crate::camera::DepthFormat::GRAY16,
68 trim: false,
69 mask: None,
60 }
70 }
61 }
71 }
62
72
@@ -154,6 +164,12 @@ impl SyntheticRun {
154 let mut sums = BTreeMap::new();
164 let mut sums = BTreeMap::new();
155 for s in production::samples(&plan) {
165 for s in production::samples(&plan) {
156 let px = pixel(s.clip, s.frame, s.pitch, s.yaw);
166 let px = pixel(s.clip, s.frame, s.pitch, s.yaw);
167 let inside: Vec<bool> = (0..n as u32)
168 .map(|i| {
169 self.mask
170 .is_none_or(|m| m(s.clip, s.frame, s.pitch, s.yaw, i % w, i / w))
171 })
172 .collect();
157 for map in production::SAVED_ORDER {
173 for map in production::SAVED_ORDER {
158 let rel = production::output_path(
174 let rel = production::output_path(
159 &self.sex,
175 &self.sex,
@@ -170,7 +186,10 @@ impl SyntheticRun {
170 } else {
186 } else {
171 (f64::from(px.depth) / 65535., 1.)
187 (f64::from(px.depth) / 65535., 1.)
172 };
188 };
173 let pixels = vec![pair; n];
189 let pixels: Vec<(f64, f64)> = inside
190 .iter()
191 .map(|&i| if i { pair } else { (0., 0.) })
192 .collect();
174 production::write_depth_fmt(root, &rel, w, h, &pixels, self.depth_format)?
193 production::write_depth_fmt(root, &rel, w, h, &pixels, self.depth_format)?
175 } else {
194 } else {
176 let rgba = match map {
195 let rgba = match map {
@@ -184,7 +203,10 @@ impl SyntheticRun {
184 &ImageBuf {
203 &ImageBuf {
185 width: w,
204 width: w,
186 height: h,
205 height: h,
187 rgba: rgba.repeat(n),
206 rgba: inside
207 .iter()
208 .flat_map(|&i| if i { rgba } else { [0; 4] })
209 .collect(),
188 },
210 },
189 )?
211 )?
190 };
212 };
@@ -205,6 +227,7 @@ impl SyntheticRun {
205 png_sha256: &sums,
227 png_sha256: &sums,
206 attempt: 0,
228 attempt: 0,
207 cancel: &cancel,
229 cancel: &cancel,
230 trim: self.trim,
208 },
231 },
209 &mut |_| {},
232 &mut |_| {},
210 )?;
233 )?;
src/i18n_table.rs +6 −0
@@ -161,6 +161,12 @@ pub const TABLE: &[(&str, &str)] = &[
161 161 ("Glow-context ontbreekt", "Glow context missing"),
162 162 ("Batch opgeslagen: {path}", "Batch saved: {path}"),
163 163 ("Render klaar", "Render finished"),
164 ("Atlas bijsnijden:", "Trim atlas:"),
165 ("lichaam", "body"),
166 ("equipment", "equipment"),
167 ("atlas · uitsnede bepalen", "atlas · finding crops"),
168 (".ppda: trimmed past niet bij format_version", ".ppda: trimmed does not match format_version"),
169 (".ppda: offset/frame/empty ontbreken", ".ppda: offset/frame/empty missing"),
164 170 ("Equipment toevoegen", "Add equipment"),
165 171 ("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model", "choose an OBJ (with MTL/PNG), FBX or skinned glTF/GLB model"),
166 172 ("Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling.", "Part with skeleton embedded; bones linked by name. Check the bone links."),
@@ -161,6 +161,12 @@ pub const TABLE: &[(&str, &str)] = &[
161 ("Glow-context ontbreekt", "Glow context missing"),
161 ("Glow-context ontbreekt", "Glow context missing"),
162 ("Batch opgeslagen: {path}", "Batch saved: {path}"),
162 ("Batch opgeslagen: {path}", "Batch saved: {path}"),
163 ("Render klaar", "Render finished"),
163 ("Render klaar", "Render finished"),
164 ("Atlas bijsnijden:", "Trim atlas:"),
165 ("lichaam", "body"),
166 ("equipment", "equipment"),
167 ("atlas · uitsnede bepalen", "atlas · finding crops"),
168 (".ppda: trimmed past niet bij format_version", ".ppda: trimmed does not match format_version"),
169 (".ppda: offset/frame/empty ontbreken", ".ppda: offset/frame/empty missing"),
164 ("Equipment toevoegen", "Add equipment"),
170 ("Equipment toevoegen", "Add equipment"),
165 ("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model", "choose an OBJ (with MTL/PNG), FBX or skinned glTF/GLB model"),
171 ("kies een OBJ- (met MTL/PNG), FBX- of geskind glTF/GLB-model", "choose an OBJ (with MTL/PNG), FBX or skinned glTF/GLB model"),
166 ("Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling.", "Part with skeleton embedded; bones linked by name. Check the bone links."),
172 ("Onderdeel met skelet ingesloten; botten op naam gekoppeld. Controleer de botkoppeling.", "Part with skeleton embedded; bones linked by name. Check the bone links."),
src/production.rs +7 −0
@@ -93,6 +93,8 @@ pub struct RenderJob {
93 93 pub preset_sha256: Option<String>,
94 94 /// Depth PNG bit depth + encoding (project setting).
95 95 pub depth_format: crate::camera::DepthFormat,
96 /// Crop atlas views to their content (project setting, t1030u).
97 pub atlas_trim: bool,
96 98 }
97 99 #[derive(Debug, Clone, PartialEq)]
98 100 pub struct ClipPlan {
@@ -1876,6 +1878,7 @@ fn execute_target(
1876 1878 png_sha256: &png_sha256,
1877 1879 attempt: session.attempt,
1878 1880 cancel,
1881 trim: job.atlas_trim,
1879 1882 },
1880 1883 &mut |label| {
1881 1884 emit(RenderEvent::Step {
@@ -2362,6 +2365,7 @@ mod tests {
2362 2365 let temp = Temp::new();
2363 2366 let mut job = RenderJob {
2364 2367 depth_format: crate::camera::DepthFormat::GRAY16,
2368 atlas_trim: false,
2365 2369 character: body(),
2366 2370 sex: "male".into(),
2367 2371 preset: None,
@@ -2490,6 +2494,7 @@ mod tests {
2490 2494 assert!(!missing.source_normals_complete);
2491 2495 let job = RenderJob {
2492 2496 depth_format: crate::camera::DepthFormat::GRAY16,
2497 atlas_trim: false,
2493 2498 character: missing,
2494 2499 sex: "male".into(),
2495 2500 preset: None,
@@ -2528,6 +2533,7 @@ mod tests {
2528 2533 b.clips.clear();
2529 2534 let job = RenderJob {
2530 2535 depth_format: crate::camera::DepthFormat::GRAY16,
2536 atlas_trim: false,
2531 2537 character: b,
2532 2538 sex: "male".into(),
2533 2539 preset: None,
@@ -2703,6 +2709,7 @@ mod tests {
2703 2709 b.clips.clear();
2704 2710 RenderJob {
2705 2711 depth_format: crate::camera::DepthFormat::GRAY16,
2712 atlas_trim: false,
2706 2713 character: b,
2707 2714 sex: "male".into(),
2708 2715 preset: None,
@@ -93,6 +93,8 @@ pub struct RenderJob {
93 pub preset_sha256: Option<String>,
93 pub preset_sha256: Option<String>,
94 /// Depth PNG bit depth + encoding (project setting).
94 /// Depth PNG bit depth + encoding (project setting).
95 pub depth_format: crate::camera::DepthFormat,
95 pub depth_format: crate::camera::DepthFormat,
96 /// Crop atlas views to their content (project setting, t1030u).
97 pub atlas_trim: bool,
96}
98}
97#[derive(Debug, Clone, PartialEq)]
99#[derive(Debug, Clone, PartialEq)]
98pub struct ClipPlan {
100pub struct ClipPlan {
@@ -1876,6 +1878,7 @@ fn execute_target(
1876 png_sha256: &png_sha256,
1878 png_sha256: &png_sha256,
1877 attempt: session.attempt,
1879 attempt: session.attempt,
1878 cancel,
1880 cancel,
1881 trim: job.atlas_trim,
1879 },
1882 },
1880 &mut |label| {
1883 &mut |label| {
1881 emit(RenderEvent::Step {
1884 emit(RenderEvent::Step {
@@ -2362,6 +2365,7 @@ mod tests {
2362 let temp = Temp::new();
2365 let temp = Temp::new();
2363 let mut job = RenderJob {
2366 let mut job = RenderJob {
2364 depth_format: crate::camera::DepthFormat::GRAY16,
2367 depth_format: crate::camera::DepthFormat::GRAY16,
2368 atlas_trim: false,
2365 character: body(),
2369 character: body(),
2366 sex: "male".into(),
2370 sex: "male".into(),
2367 preset: None,
2371 preset: None,
@@ -2490,6 +2494,7 @@ mod tests {
2490 assert!(!missing.source_normals_complete);
2494 assert!(!missing.source_normals_complete);
2491 let job = RenderJob {
2495 let job = RenderJob {
2492 depth_format: crate::camera::DepthFormat::GRAY16,
2496 depth_format: crate::camera::DepthFormat::GRAY16,
2497 atlas_trim: false,
2493 character: missing,
2498 character: missing,
2494 sex: "male".into(),
2499 sex: "male".into(),
2495 preset: None,
2500 preset: None,
@@ -2528,6 +2533,7 @@ mod tests {
2528 b.clips.clear();
2533 b.clips.clear();
2529 let job = RenderJob {
2534 let job = RenderJob {
2530 depth_format: crate::camera::DepthFormat::GRAY16,
2535 depth_format: crate::camera::DepthFormat::GRAY16,
2536 atlas_trim: false,
2531 character: b,
2537 character: b,
2532 sex: "male".into(),
2538 sex: "male".into(),
2533 preset: None,
2539 preset: None,
@@ -2703,6 +2709,7 @@ mod tests {
2703 b.clips.clear();
2709 b.clips.clear();
2704 RenderJob {
2710 RenderJob {
2705 depth_format: crate::camera::DepthFormat::GRAY16,
2711 depth_format: crate::camera::DepthFormat::GRAY16,
2712 atlas_trim: false,
2706 character: b,
2713 character: b,
2707 sex: "male".into(),
2714 sex: "male".into(),
2708 preset: None,
2715 preset: None,
src/project.rs +22 −8
@@ -2,7 +2,7 @@
2 2 //!
3 3 //! Version 1 stores paths and render settings. It does not import models and
4 4 //! it does not render paperdolls. Unknown JSON fields are rejected so a save
5 //! cannot drop them. Schemas 1/2 migrate to 3; unsupported `format_version` values are rejected first.
5 //! cannot drop them. Schemas 1–3 migrate to 4 (version 4 adds the atlas trim settings, default on); unsupported `format_version` values are rejected first.
6 6
7 7 use std::fs::{self, File};
8 8 use std::io::Write;
@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
12 12 use serde::{Deserialize, Serialize};
13 13 use serde_json::Value;
14 14
15 pub const FORMAT_VERSION: u32 = 3;
15 pub const FORMAT_VERSION: u32 = 4;
16 16 pub const MANIFEST_NAME: &str = "character.ppd";
17 17 pub const MAX_PROJECT_PRESETS: usize = 64;
18 18 pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
@@ -86,6 +86,16 @@ pub struct RenderSettings {
86 86 /// Depth PNG encoding: gray (default), rgb_bands or rgb_packed (t987u).
87 87 #[serde(default)]
88 88 pub depth_encoding: crate::camera::DepthEncoding,
89 /// Crop body atlas views to their content (t1030u; default on).
90 #[serde(default = "default_true")]
91 pub trim_body: bool,
92 /// Crop equipment atlas views to their content (t1030u; default on).
93 #[serde(default = "default_true")]
94 pub trim_equipment: bool,
95 }
96
97 fn default_true() -> bool {
98 true
89 99 }
90 100
91 101 fn default_depth_bits() -> u8 {
@@ -101,6 +111,8 @@ impl Default for RenderSettings {
101 111 framing: [CameraFraming::default(); 2],
102 112 depth_bits: 8,
103 113 depth_encoding: crate::camera::DepthEncoding::Gray,
114 trim_body: true,
115 trim_equipment: true,
104 116 }
105 117 }
106 118 }
@@ -895,16 +907,16 @@ mod tests {
895 907
896 908 #[test]
897 909 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
898 let newer = r#"{"format_version":4,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
910 let newer = r#"{"format_version":5,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
899 911 let error = parse_manifest(newer).unwrap_err();
900 912 assert!(matches!(
901 913 error,
902 914 ProjectError::UnsupportedVersion {
903 found: 4,
904 supported: 3
915 found: 5,
916 supported: 4
905 917 }
906 918 ));
907 assert!(error.to_string().contains("format_version 4"));
919 assert!(error.to_string().contains("format_version 5"));
908 920 let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
909 921 let error = parse_manifest(unknown).unwrap_err();
910 922 assert!(matches!(error, ProjectError::UnknownField(_)));
@@ -1092,8 +1104,10 @@ mod framing_tests {
1092 1104 value["format_version"] = serde_json::json!(1);
1093 1105 value["render"].as_object_mut().unwrap().remove("framing");
1094 1106 let migrated = parse_manifest(&value.to_string()).unwrap();
1095 assert_eq!(migrated.format_version, 3);
1107 assert_eq!(migrated.format_version, 4);
1096 1108 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
1109 // t1030u: older files get both atlas trim settings on.
1110 assert!(migrated.render.trim_body && migrated.render.trim_equipment);
1097 1111 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
1098 1112 assert!(parse_manifest(&value.to_string())
1099 1113 .unwrap()
@@ -1105,7 +1119,7 @@ mod framing_tests {
1105 1119 {"height_milli":2600,"center_y_milli":1000}
1106 1120 ]);
1107 1121 let migrated_v2 = parse_manifest(&value.to_string()).unwrap();
1108 assert_eq!(migrated_v2.format_version, 3);
1122 assert_eq!(migrated_v2.format_version, 4);
1109 1123 assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None);
1110 1124 assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None);
1111 1125 }
@@ -2,7 +2,7 @@
2//!
2//!
3//! Version 1 stores paths and render settings. It does not import models and
3//! Version 1 stores paths and render settings. It does not import models and
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
4//! it does not render paperdolls. Unknown JSON fields are rejected so a save
5//! cannot drop them. Schemas 1/2 migrate to 3; unsupported `format_version` values are rejected first.
5//! cannot drop them. Schemas 1–3 migrate to 4 (version 4 adds the atlas trim settings, default on); unsupported `format_version` values are rejected first.
6
6
7use std::fs::{self, File};
7use std::fs::{self, File};
8use std::io::Write;
8use std::io::Write;
@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
12use serde::{Deserialize, Serialize};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
13use serde_json::Value;
14
14
15pub const FORMAT_VERSION: u32 = 3;
15pub const FORMAT_VERSION: u32 = 4;
16pub const MANIFEST_NAME: &str = "character.ppd";
16pub const MANIFEST_NAME: &str = "character.ppd";
17pub const MAX_PROJECT_PRESETS: usize = 64;
17pub const MAX_PROJECT_PRESETS: usize = 64;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
18pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
@@ -86,6 +86,16 @@ pub struct RenderSettings {
86 /// Depth PNG encoding: gray (default), rgb_bands or rgb_packed (t987u).
86 /// Depth PNG encoding: gray (default), rgb_bands or rgb_packed (t987u).
87 #[serde(default)]
87 #[serde(default)]
88 pub depth_encoding: crate::camera::DepthEncoding,
88 pub depth_encoding: crate::camera::DepthEncoding,
89 /// Crop body atlas views to their content (t1030u; default on).
90 #[serde(default = "default_true")]
91 pub trim_body: bool,
92 /// Crop equipment atlas views to their content (t1030u; default on).
93 #[serde(default = "default_true")]
94 pub trim_equipment: bool,
95}
96
97fn default_true() -> bool {
98 true
89}
99}
90
100
91fn default_depth_bits() -> u8 {
101fn default_depth_bits() -> u8 {
@@ -101,6 +111,8 @@ impl Default for RenderSettings {
101 framing: [CameraFraming::default(); 2],
111 framing: [CameraFraming::default(); 2],
102 depth_bits: 8,
112 depth_bits: 8,
103 depth_encoding: crate::camera::DepthEncoding::Gray,
113 depth_encoding: crate::camera::DepthEncoding::Gray,
114 trim_body: true,
115 trim_equipment: true,
104 }
116 }
105 }
117 }
106}
118}
@@ -895,16 +907,16 @@ mod tests {
895
907
896 #[test]
908 #[test]
897 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
909 fn newer_version_and_unknown_field_are_recoverable_parse_errors() {
898 let newer = r#"{"format_version":4,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
910 let newer = r#"{"format_version":5,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
899 let error = parse_manifest(newer).unwrap_err();
911 let error = parse_manifest(newer).unwrap_err();
900 assert!(matches!(
912 assert!(matches!(
901 error,
913 error,
902 ProjectError::UnsupportedVersion {
914 ProjectError::UnsupportedVersion {
903 found: 4,
915 found: 5,
904 supported: 3
916 supported: 4
905 }
917 }
906 ));
918 ));
907 assert!(error.to_string().contains("format_version 4"));
919 assert!(error.to_string().contains("format_version 5"));
908 let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
920 let unknown = r#"{"format_version":1,"id":"a","name":"A","male_model":null,"female_model":null,"equipment_presets":[],"output_directory":"renders","render":{"target_width":128,"target_height":128,"fps":12},"extra":true}"#;
909 let error = parse_manifest(unknown).unwrap_err();
921 let error = parse_manifest(unknown).unwrap_err();
910 assert!(matches!(error, ProjectError::UnknownField(_)));
922 assert!(matches!(error, ProjectError::UnknownField(_)));
@@ -1092,8 +1104,10 @@ mod framing_tests {
1092 value["format_version"] = serde_json::json!(1);
1104 value["format_version"] = serde_json::json!(1);
1093 value["render"].as_object_mut().unwrap().remove("framing");
1105 value["render"].as_object_mut().unwrap().remove("framing");
1094 let migrated = parse_manifest(&value.to_string()).unwrap();
1106 let migrated = parse_manifest(&value.to_string()).unwrap();
1095 assert_eq!(migrated.format_version, 3);
1107 assert_eq!(migrated.format_version, 4);
1096 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
1108 assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
1109 // t1030u: older files get both atlas trim settings on.
1110 assert!(migrated.render.trim_body && migrated.render.trim_equipment);
1097 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
1111 value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
1098 assert!(parse_manifest(&value.to_string())
1112 assert!(parse_manifest(&value.to_string())
1099 .unwrap()
1113 .unwrap()
@@ -1105,7 +1119,7 @@ mod framing_tests {
1105 {"height_milli":2600,"center_y_milli":1000}
1119 {"height_milli":2600,"center_y_milli":1000}
1106 ]);
1120 ]);
1107 let migrated_v2 = parse_manifest(&value.to_string()).unwrap();
1121 let migrated_v2 = parse_manifest(&value.to_string()).unwrap();
1108 assert_eq!(migrated_v2.format_version, 3);
1122 assert_eq!(migrated_v2.format_version, 4);
1109 assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None);
1123 assert_eq!(migrated_v2.render.framing[0].pivot_y_milli, None);
1110 assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None);
1124 assert_eq!(migrated_v2.render.framing[1].pivot_y_milli, None);
1111 }
1125 }
tests/fbx_integration.rs +1 −0
@@ -28,6 +28,7 @@ fn scratch(tag: &str) -> PathBuf {
28 28 fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
29 29 RenderJob {
30 30 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
31 atlas_trim: false,
31 32 character,
32 33 sex: "male".into(),
33 34 preset,
@@ -28,6 +28,7 @@ fn scratch(tag: &str) -> PathBuf {
28fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
28fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
29 RenderJob {
29 RenderJob {
30 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
30 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
31 atlas_trim: false,
31 character,
32 character,
32 sex: "male".into(),
33 sex: "male".into(),
33 preset,
34 preset,
tests/static_body.rs +1 −0
@@ -54,6 +54,7 @@ fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) {
54 54 fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {
55 55 RenderJob {
56 56 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
57 atlas_trim: false,
57 58 character,
58 59 sex: "male".into(),
59 60 preset,
@@ -54,6 +54,7 @@ fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) {
54fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {
54fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {
55 RenderJob {
55 RenderJob {
56 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
56 depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
57 atlas_trim: false,
57 character,
58 character,
58 sex: "male".into(),
59 sex: "male".into(),
59 preset,
60 preset,