Merge feature/atlas-trim (Tim t1030u)
@@ -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

@@ -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** selects29
- 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
35
@@ -151,11 +151,12 @@ While building: `render.json` (schema 6; with `depth_near`, `depth_far`, `depth_151
151
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).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
- 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
### Resuming161
### Resuming161
162
@@ -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,@@ -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]3
use eframe::App;3
use eframe::App;4
use paperdoll_renderer::{4
use 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()?;@@ -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 is32
/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is33
/// still read (16-bit gray).33
/// still read (16-bit gray).34
pub const PPDA_FORMAT_VERSION: u32 = 4;34
/// 4: export contract embedded. 5 (t1030u): optional trimmed views with35
/// 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
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are39
/// Largest atlas side in pixels (common GPU texture limit). Larger exports are36
/// refused up front with a clear message; atlases are never split.40
/// refused up front with a clear message; atlases are never split.37
pub const MAX_ATLAS_DIM: u32 = 16384;41
pub 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)]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 without158
/// gaps; row height = tallest view; size at least 1×1. Returns the (x, y) of159
/// 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
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]180
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]139
#[serde(deny_unknown_fields)]181
#[serde(deny_unknown_fields)]140
pub struct Ppda {182
pub 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
295
fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {351
fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {@@ -352,6 +408,7 @@ fn load_cell(352
fn encode_atlas(408
fn 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 = writer433
let mut stream = writer377
.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 box653
/// 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)>> = boxes700
.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 = boxes712
.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.557
pub fn write_atlases(744
pub 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
..grid758
};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)>> = p902
.clips903
.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
..grid913
}914
} else {915
grid916
};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_view949
} 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 >= 1960
&& view.h >= 1961
&& 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
&& (trimmed965
|| (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] && shape969
};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.yaw973
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 frame1248
/// filled with `fill` (one pixel, `per_px` elements). Untrimmed views have1249
/// 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> = fill1255
.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
out1271
}1272
1019
/// Open a completed run through both readers and compare every atlas cell of1273
/// Open a completed run through both readers and compare every atlas cell of1020
/// 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).1021
pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {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 content1445
/// 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
.clips1507
.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().rgba1548
);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 = loaded1574
.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();@@ -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 applicable733
/// 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
choice2574
choice2565
}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_equipment2581
} else {2582
render.trim_body2583
}2584
}2585
2567
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a2586
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a2568
/// character bone (optional; at least one), with its own position/rotation.2587
/// character bone (optional; at least one), with its own position/rotation.2569
fn skinned_links_ui(2588
fn 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.session3025
.opened3026
.as_mut()3027
.unwrap()3028
.document3029
.render3030
.trim_body = false;3031
assert!(!app.job_for(0, None).unwrap().atlas_trim);3032
app.session3033
.opened3034
.as_mut()3035
.unwrap()3036
.document3037
.render3038
.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();@@ -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.27
pub type PixelMask = fn(usize, u32, i32, u32, u32, u32) -> bool;28
26
#[derive(Debug, Clone)]29
#[derive(Debug, Clone)]27
pub struct SyntheticRun {30
pub 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); pixels46
/// outside the mask are all-zero in every map.47
pub mask: Option<PixelMask>,40
}48
}41
49
42
impl SyntheticRun {50
impl 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.mask170
.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)> = inside190
.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: inside207
.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
)?;@@ -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."),@@ -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)]98
pub struct ClipPlan {100
pub 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,@@ -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 and3
//! Version 1 stores paths and render settings. It does not import models and4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save4
//! it does not render paperdolls. Unknown JSON fields are rejected so a save5
//! 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
use std::fs::{self, File};7
use std::fs::{self, File};8
use std::io::Write;8
use std::io::Write;@@ -12,7 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH};12
use serde::{Deserialize, Serialize};12
use serde::{Deserialize, Serialize};13
use serde_json::Value;13
use serde_json::Value;14
14
15
pub const FORMAT_VERSION: u32 = 3;15
pub const FORMAT_VERSION: u32 = 4;16
pub const MANIFEST_NAME: &str = "character.ppd";16
pub const MANIFEST_NAME: &str = "character.ppd";17
pub const MAX_PROJECT_PRESETS: usize = 64;17
pub const MAX_PROJECT_PRESETS: usize = 64;18
pub const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;18
pub 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
97
fn default_true() -> bool {98
true89
}99
}90
100
91
fn default_depth_bits() -> u8 {101
fn 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: 3916
supported: 4905
}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
}@@ -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 {28
fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {28
fn 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,@@ -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) {54
fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {54
fn 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,