Semi-transparency setting per layer, default off: binary alpha at 0.5 across all maps; .ppe v3; recorded in render.json and .ppda (Tim t1031u)
@@ -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. **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*.
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*. **Semi-transparency allowed (body)** is off by default (also for older `.ppd` files). Off means binary alpha: after the resolve, a pixel with coverage ≥ 0.5 becomes fully opaque in every map (diffuse, normal, specular, depth) and anything below becomes fully empty in every map, so all masks match. On keeps the soft edges. Each equipment preset has its own **Semi-transparency allowed** checkbox (also off by default). The bundled `female-hair` preset has it on. The setting is in `render.json` (`semi_transparency`), so a run only resumes with the same setting.
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

@@ -79,7 +79,7 @@ From a body, **Equipment** opens the list. Choose a definition or **New**.
79
79
4. For a rigid part (no skeleton): expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.
80
80
4b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links are kept per sex; renders and the live preview use them the same way.
81
81
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
82
6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags and origin; container version 2, version 1 presets still load and are written as version 2 on the next save). No source paths outside the project are needed to reload them.
82
6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags, the semi-transparency setting and origin; container version 3, version 1–2 presets still load (semi-transparency off) and are written as version 3 on the next save). No source paths outside the project are needed to reload them.
83
83
84
84
**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.
85
85
@@ -155,7 +155,7 @@ When all loose maps are done and verified, also after resuming, the render write
155
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.
156
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.
157
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.
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
- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 5 (versions 2–4 are still read; version 2 as 16-bit gray), `trimmed`, `semi_transparency` (whether this layer allows soft alpha; `false` means every alpha is 0 or 1; older files count as semi-transparent), `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
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.
160
160
161
161
### Resuming
@@ -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. **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*.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*. **Semi-transparency allowed (body)** is off by default (also for older `.ppd` files). Off means binary alpha: after the resolve, a pixel with coverage ≥ 0.5 becomes fully opaque in every map (diffuse, normal, specular, depth) and anything below becomes fully empty in every map, so all masks match. On keeps the soft edges. Each equipment preset has its own **Semi-transparency allowed** checkbox (also off by default). The bundled `female-hair` preset has it on. The setting is in `render.json` (`semi_transparency`), so a run only resumes with the same setting.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
@@ -79,7 +79,7 @@ From a body, **Equipment** opens the list. Choose a definition or **New**.79
4. For a rigid part (no skeleton): expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.79
4. For a rigid part (no skeleton): expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.80
4b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links are kept per sex; renders and the live preview use them the same way.80
4b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links are kept per sex; renders and the live preview use them the same way.81
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.81
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.82
6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags and origin; container version 2, version 1 presets still load and are written as version 2 on the next save). No source paths outside the project are needed to reload them.82
6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags, the semi-transparency setting and origin; container version 3, version 1–2 presets still load (semi-transparency off) and are written as version 3 on the next save). No source paths outside the project are needed to reload them.83
83
84
**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.84
**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.85
85
@@ -155,7 +155,7 @@ When all loose maps are done and verified, also after resuming, the render write155
- **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
- **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.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
- 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.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
- 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.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
- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 5 (versions 2–4 are still read; version 2 as 16-bit gray), `trimmed`, `semi_transparency` (whether this layer allows soft alpha; `false` means every alpha is 0 or 1; older files count as semi-transparent), `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
- 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.160
160
161
### Resuming161
### ResumingThe contents of this binary file are not shown.
@@ -80,6 +80,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
80
80
let mut job = production::RenderJob {
81
81
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
82
82
atlas_trim: false,
83
semi_transparency: true,
83
84
character: body,
84
85
sex: "male".into(),
85
86
preset: None,
@@ -80,6 +80,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {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
atlas_trim: false,83
semi_transparency: true,83
character: body,84
character: body,84
sex: "male".into(),85
sex: "male".into(),85
preset: None,86
preset: None,@@ -209,6 +209,10 @@ pub struct Ppda {
209
209
/// Format 5: whether views are trimmed to their content (t1030u).
210
210
#[serde(default, skip_serializing_if = "Option::is_none")]
211
211
pub trimmed: Option<bool>,
212
/// Format 5: whether this layer allows semi-transparent pixels (t1031u);
213
/// `false` means every alpha is 0 or 1. Older files: semi-transparent.
214
#[serde(default, skip_serializing_if = "Option::is_none")]
215
pub semi_transparency: Option<bool>,
212
216
}
213
217
214
218
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -646,6 +650,12 @@ pub fn build_ppda(
646
650
clips,
647
651
export: None,
648
652
trimmed: Some(false),
653
semi_transparency: Some(
654
render_doc
655
.get("semi_transparency")
656
.and_then(serde_json::Value::as_bool)
657
.unwrap_or(true),
658
),
649
659
})
650
660
}
651
661
@@ -893,7 +903,7 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
893
903
let grid =
894
904
AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;
895
905
let v5 = p.format_version >= 5;
896
if v5 != p.trimmed.is_some() {
906
if v5 != p.trimmed.is_some() || v5 != p.semi_transparency.is_some() {
897
907
return Err(ident(".ppda: trimmed past niet bij format_version"));
898
908
}
899
909
let trimmed = p.trimmed.unwrap_or(false);
@@ -1429,6 +1439,7 @@ mod tests {
1429
1439
/// Strip the format-5 fields (back to the v2–v4 view shape).
1430
1440
fn strip_v5(d: &mut serde_json::Value) {
1431
1441
d.as_object_mut().unwrap().remove("trimmed");
1442
d.as_object_mut().unwrap().remove("semi_transparency");
1432
1443
for clip in d["clips"].as_array_mut().unwrap() {
1433
1444
for frame in clip["frames"].as_array_mut().unwrap() {
1434
1445
for view in frame["views"].as_array_mut().unwrap() {
@@ -209,6 +209,10 @@ pub struct Ppda {209
/// Format 5: whether views are trimmed to their content (t1030u).209
/// Format 5: whether views are trimmed to their content (t1030u).210
#[serde(default, skip_serializing_if = "Option::is_none")]210
#[serde(default, skip_serializing_if = "Option::is_none")]211
pub trimmed: Option<bool>,211
pub trimmed: Option<bool>,212
/// Format 5: whether this layer allows semi-transparent pixels (t1031u);213
/// `false` means every alpha is 0 or 1. Older files: semi-transparent.214
#[serde(default, skip_serializing_if = "Option::is_none")]215
pub semi_transparency: Option<bool>,212
}216
}213
217
214
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]218
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]@@ -646,6 +650,12 @@ pub fn build_ppda(646
clips,650
clips,647
export: None,651
export: None,648
trimmed: Some(false),652
trimmed: Some(false),653
semi_transparency: Some(654
render_doc655
.get("semi_transparency")656
.and_then(serde_json::Value::as_bool)657
.unwrap_or(true),658
),649
})659
})650
}660
}651
661
@@ -893,7 +903,7 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>893
let grid =903
let grid =894
AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;904
AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;895
let v5 = p.format_version >= 5;905
let v5 = p.format_version >= 5;896
if v5 != p.trimmed.is_some() {906
if v5 != p.trimmed.is_some() || v5 != p.semi_transparency.is_some() {897
return Err(ident(".ppda: trimmed past niet bij format_version"));907
return Err(ident(".ppda: trimmed past niet bij format_version"));898
}908
}899
let trimmed = p.trimmed.unwrap_or(false);909
let trimmed = p.trimmed.unwrap_or(false);@@ -1429,6 +1439,7 @@ mod tests {1429
/// Strip the format-5 fields (back to the v2–v4 view shape).1439
/// Strip the format-5 fields (back to the v2–v4 view shape).1430
fn strip_v5(d: &mut serde_json::Value) {1440
fn strip_v5(d: &mut serde_json::Value) {1431
d.as_object_mut().unwrap().remove("trimmed");1441
d.as_object_mut().unwrap().remove("trimmed");1442
d.as_object_mut().unwrap().remove("semi_transparency");1432
for clip in d["clips"].as_array_mut().unwrap() {1443
for clip in d["clips"].as_array_mut().unwrap() {1433
for frame in clip["frames"].as_array_mut().unwrap() {1444
for frame in clip["frames"].as_array_mut().unwrap() {1434
for view in frame["views"].as_array_mut().unwrap() {1445
for view in frame["views"].as_array_mut().unwrap() {@@ -707,6 +707,7 @@ impl EditorApp {
707
707
)),
708
708
};
709
709
let atlas_trim = atlas_trim_for(&opened.document.render, preset.is_some());
710
let semi_transparency = semi_transparency_for(&opened.document.render, preset.as_ref());
710
711
Ok(RenderJob {
711
712
character,
712
713
sex: if sex == 0 { "male" } else { "female" }.into(),
@@ -728,6 +729,7 @@ impl EditorApp {
728
729
opened.document.render.depth_encoding,
729
730
)?,
730
731
atlas_trim,
732
semi_transparency,
731
733
})
732
734
}
733
735
/// Every body (male, female) and then every equipment item per applicable
@@ -1305,6 +1307,10 @@ impl EditorApp {
1305
1307
ui.checkbox(&mut r.trim_body, crate::i18n::t("lichaam"));
1306
1308
ui.checkbox(&mut r.trim_equipment, crate::i18n::t("equipment"));
1307
1309
});
1310
ui.checkbox(
1311
&mut r.semi_transparency_body,
1312
crate::i18n::t("Semi-transparantie toegestaan (lichaam)"),
1313
);
1308
1314
}
1309
1315
}
1310
1316
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {
@@ -1802,6 +1808,10 @@ impl EditorApp {
1802
1808
ui.checkbox(&mut preset.applies[0], crate::i18n::t("Man"));
1803
1809
ui.checkbox(&mut preset.applies[1], crate::i18n::t("Vrouw"));
1804
1810
});
1811
ui.checkbox(
1812
&mut preset.semi_transparency,
1813
crate::i18n::t("Semi-transparantie toegestaan"),
1814
);
1805
1815
if !preset.applies[0] && !preset.applies[1] {
1806
1816
ui.colored_label(
1807
1817
egui::Color32::LIGHT_RED,
@@ -2583,6 +2593,15 @@ pub fn atlas_trim_for(render: &crate::project::RenderSettings, equipment: bool)
2583
2593
}
2584
2594
}
2585
2595
2596
/// Semi-transparency setting of the rendered layer (t1031u): the equipment
2597
/// preset's own setting, otherwise the body setting in the `.ppd`.
2598
pub fn semi_transparency_for(
2599
render: &crate::project::RenderSettings,
2600
preset: Option<&crate::equipment::Preset>,
2601
) -> bool {
2602
preset.map_or(render.semi_transparency_body, |p| p.semi_transparency)
2603
}
2604
2586
2605
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2587
2606
/// character bone (optional; at least one), with its own position/rotation.
2588
2607
fn skinned_links_ui(
@@ -3005,6 +3024,42 @@ mod tests {
3005
3024
let _ = std::fs::remove_dir_all(root);
3006
3025
}
3007
3026
3027
#[test]
3028
fn semi_transparency_defaults_off_per_layer_and_hair_preset_is_on() {
3029
let mut r = crate::project::RenderSettings::default();
3030
assert!(!r.semi_transparency_body);
3031
let hair =
3032
crate::equipment::Preset::decode(crate::default_bodies::FEMALE_HAIR_PPE).unwrap();
3033
assert!(
3034
hair.semi_transparency,
3035
"bundled female-hair is semi-transparent"
3036
);
3037
let plain = crate::equipment::Preset::new("x".into());
3038
assert!(!plain.semi_transparency);
3039
assert!(!semi_transparency_for(&r, None));
3040
assert!(semi_transparency_for(&r, Some(&hair)));
3041
r.semi_transparency_body = true;
3042
assert!(semi_transparency_for(&r, None));
3043
assert!(!semi_transparency_for(&r, Some(&plain)));
3044
// Round trip, and v2 files (no field) load with the setting off.
3045
let back = crate::equipment::Preset::decode(&hair.encode().unwrap()).unwrap();
3046
assert!(back.semi_transparency);
3047
let root = root();
3048
let opened = crate::session::create_seeded_project(&root).unwrap();
3049
let mut app = EditorApp::default();
3050
app.open_path(opened.manifest_path.clone());
3051
assert!(!app.job_for(0, None).unwrap().semi_transparency);
3052
app.session
3053
.opened
3054
.as_mut()
3055
.unwrap()
3056
.document
3057
.render
3058
.semi_transparency_body = true;
3059
assert!(app.job_for(0, None).unwrap().semi_transparency);
3060
let _ = std::fs::remove_dir_all(root);
3061
}
3062
3008
3063
#[test]
3009
3064
fn atlas_trim_toggles_default_on_and_select_body_or_equipment() {
3010
3065
let mut r = crate::project::RenderSettings::default();
@@ -707,6 +707,7 @@ impl EditorApp {707
)),707
)),708
};708
};709
let atlas_trim = atlas_trim_for(&opened.document.render, preset.is_some());709
let atlas_trim = atlas_trim_for(&opened.document.render, preset.is_some());710
let semi_transparency = semi_transparency_for(&opened.document.render, preset.as_ref());710
Ok(RenderJob {711
Ok(RenderJob {711
character,712
character,712
sex: if sex == 0 { "male" } else { "female" }.into(),713
sex: if sex == 0 { "male" } else { "female" }.into(),@@ -728,6 +729,7 @@ impl EditorApp {728
opened.document.render.depth_encoding,729
opened.document.render.depth_encoding,729
)?,730
)?,730
atlas_trim,731
atlas_trim,732
semi_transparency,731
})733
})732
}734
}733
/// Every body (male, female) and then every equipment item per applicable735
/// Every body (male, female) and then every equipment item per applicable@@ -1305,6 +1307,10 @@ impl EditorApp {1305
ui.checkbox(&mut r.trim_body, crate::i18n::t("lichaam"));1307
ui.checkbox(&mut r.trim_body, crate::i18n::t("lichaam"));1306
ui.checkbox(&mut r.trim_equipment, crate::i18n::t("equipment"));1308
ui.checkbox(&mut r.trim_equipment, crate::i18n::t("equipment"));1307
});1309
});1310
ui.checkbox(1311
&mut r.semi_transparency_body,1312
crate::i18n::t("Semi-transparantie toegestaan (lichaam)"),1313
);1308
}1314
}1309
}1315
}1310
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {1316
fn fixed_camera(&self) -> Option<(crate::project::CameraFraming, [u32; 2])> {@@ -1802,6 +1808,10 @@ impl EditorApp {1802
ui.checkbox(&mut preset.applies[0], crate::i18n::t("Man"));1808
ui.checkbox(&mut preset.applies[0], crate::i18n::t("Man"));1803
ui.checkbox(&mut preset.applies[1], crate::i18n::t("Vrouw"));1809
ui.checkbox(&mut preset.applies[1], crate::i18n::t("Vrouw"));1804
});1810
});1811
ui.checkbox(1812
&mut preset.semi_transparency,1813
crate::i18n::t("Semi-transparantie toegestaan"),1814
);1805
if !preset.applies[0] && !preset.applies[1] {1815
if !preset.applies[0] && !preset.applies[1] {1806
ui.colored_label(1816
ui.colored_label(1807
egui::Color32::LIGHT_RED,1817
egui::Color32::LIGHT_RED,@@ -2583,6 +2593,15 @@ pub fn atlas_trim_for(render: &crate::project::RenderSettings, equipment: bool)2583
}2593
}2584
}2594
}2585
2595
2596
/// Semi-transparency setting of the rendered layer (t1031u): the equipment2597
/// preset's own setting, otherwise the body setting in the `.ppd`.2598
pub fn semi_transparency_for(2599
render: &crate::project::RenderSettings,2600
preset: Option<&crate::equipment::Preset>,2601
) -> bool {2602
preset.map_or(render.semi_transparency_body, |p| p.semi_transparency)2603
}2604
2586
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a2605
/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a2587
/// character bone (optional; at least one), with its own position/rotation.2606
/// character bone (optional; at least one), with its own position/rotation.2588
fn skinned_links_ui(2607
fn skinned_links_ui(@@ -3005,6 +3024,42 @@ mod tests {3005
let _ = std::fs::remove_dir_all(root);3024
let _ = std::fs::remove_dir_all(root);3006
}3025
}3007
3026
3027
#[test]3028
fn semi_transparency_defaults_off_per_layer_and_hair_preset_is_on() {3029
let mut r = crate::project::RenderSettings::default();3030
assert!(!r.semi_transparency_body);3031
let hair =3032
crate::equipment::Preset::decode(crate::default_bodies::FEMALE_HAIR_PPE).unwrap();3033
assert!(3034
hair.semi_transparency,3035
"bundled female-hair is semi-transparent"3036
);3037
let plain = crate::equipment::Preset::new("x".into());3038
assert!(!plain.semi_transparency);3039
assert!(!semi_transparency_for(&r, None));3040
assert!(semi_transparency_for(&r, Some(&hair)));3041
r.semi_transparency_body = true;3042
assert!(semi_transparency_for(&r, None));3043
assert!(!semi_transparency_for(&r, Some(&plain)));3044
// Round trip, and v2 files (no field) load with the setting off.3045
let back = crate::equipment::Preset::decode(&hair.encode().unwrap()).unwrap();3046
assert!(back.semi_transparency);3047
let root = root();3048
let opened = crate::session::create_seeded_project(&root).unwrap();3049
let mut app = EditorApp::default();3050
app.open_path(opened.manifest_path.clone());3051
assert!(!app.job_for(0, None).unwrap().semi_transparency);3052
app.session3053
.opened3054
.as_mut()3055
.unwrap()3056
.document3057
.render3058
.semi_transparency_body = true;3059
assert!(app.job_for(0, None).unwrap().semi_transparency);3060
let _ = std::fs::remove_dir_all(root);3061
}3062
3008
#[test]3063
#[test]3009
fn atlas_trim_toggles_default_on_and_select_body_or_equipment() {3064
fn atlas_trim_toggles_default_on_and_select_body_or_equipment() {3010
let mut r = crate::project::RenderSettings::default();3065
let mut r = crate::project::RenderSettings::default();@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
14
14
15
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
16
16
/// Container version written; version 1 (rigid parts only) is still read.
17
pub const VERSION: u32 = 2;
17
pub const VERSION: u32 = 3;
18
18
pub const MAX_FILE: usize = 32 * 1024 * 1024;
19
19
const MAX_META: usize = 4 * 1024 * 1024;
20
20
const MAX_ASSET: usize = 8 * 1024 * 1024;
@@ -92,6 +92,9 @@ pub struct Preset {
92
92
pub rigs: [Option<RigSignature>; 2],
93
93
pub attachments: Vec<Attachment>,
94
94
pub assets: Vec<EmbeddedAsset>,
95
/// Semi-transparency allowed for this layer (t1031u; default off = binary alpha).
96
#[serde(default)]
97
pub semi_transparency: bool,
95
98
}
96
99
impl Preset {
97
100
pub fn new(name: String) -> Self {
@@ -102,6 +105,7 @@ impl Preset {
102
105
rigs: [None, None],
103
106
attachments: Vec::new(),
104
107
assets: Vec::new(),
108
semi_transparency: false,
105
109
}
106
110
}
107
111
pub fn bind(
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};14
14
15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";15
pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";16
/// Container version written; version 1 (rigid parts only) is still read.16
/// Container version written; version 1 (rigid parts only) is still read.17
pub const VERSION: u32 = 2;17
pub const VERSION: u32 = 3;18
pub const MAX_FILE: usize = 32 * 1024 * 1024;18
pub const MAX_FILE: usize = 32 * 1024 * 1024;19
const MAX_META: usize = 4 * 1024 * 1024;19
const MAX_META: usize = 4 * 1024 * 1024;20
const MAX_ASSET: usize = 8 * 1024 * 1024;20
const MAX_ASSET: usize = 8 * 1024 * 1024;@@ -92,6 +92,9 @@ pub struct Preset {92
pub rigs: [Option<RigSignature>; 2],92
pub rigs: [Option<RigSignature>; 2],93
pub attachments: Vec<Attachment>,93
pub attachments: Vec<Attachment>,94
pub assets: Vec<EmbeddedAsset>,94
pub assets: Vec<EmbeddedAsset>,95
/// Semi-transparency allowed for this layer (t1031u; default off = binary alpha).96
#[serde(default)]97
pub semi_transparency: bool,95
}98
}96
impl Preset {99
impl Preset {97
pub fn new(name: String) -> Self {100
pub fn new(name: String) -> Self {@@ -102,6 +105,7 @@ impl Preset {102
rigs: [None, None],105
rigs: [None, None],103
attachments: Vec::new(),106
attachments: Vec::new(),104
assets: Vec::new(),107
assets: Vec::new(),108
semi_transparency: false,105
}109
}106
}110
}107
pub fn bind(111
pub fn bind(@@ -162,6 +162,8 @@ pub const TABLE: &[(&str, &str)] = &[
162
162
("Batch opgeslagen: {path}", "Batch saved: {path}"),
163
163
("Render klaar", "Render finished"),
164
164
("Atlas bijsnijden:", "Trim atlas:"),
165
("Semi-transparantie toegestaan (lichaam)", "Semi-transparency allowed (body)"),
166
("Semi-transparantie toegestaan", "Semi-transparency allowed"),
165
167
("lichaam", "body"),
166
168
("equipment", "equipment"),
167
169
("atlas · uitsnede bepalen", "atlas · finding crops"),
@@ -162,6 +162,8 @@ pub const TABLE: &[(&str, &str)] = &[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:"),164
("Atlas bijsnijden:", "Trim atlas:"),165
("Semi-transparantie toegestaan (lichaam)", "Semi-transparency allowed (body)"),166
("Semi-transparantie toegestaan", "Semi-transparency allowed"),165
("lichaam", "body"),167
("lichaam", "body"),166
("equipment", "equipment"),168
("equipment", "equipment"),167
("atlas · uitsnede bepalen", "atlas · finding crops"),169
("atlas · uitsnede bepalen", "atlas · finding crops"),@@ -95,6 +95,8 @@ pub struct RenderJob {
95
95
pub depth_format: crate::camera::DepthFormat,
96
96
/// Crop atlas views to their content (project setting, t1030u).
97
97
pub atlas_trim: bool,
98
/// Semi-transparency allowed (t1031u); off = binary alpha at 0.5 in all maps.
99
pub semi_transparency: bool,
98
100
}
99
101
#[derive(Debug, Clone, PartialEq)]
100
102
pub struct ClipPlan {
@@ -1094,6 +1096,26 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S
1094
1096
})
1095
1097
}
1096
1098
1099
/// Binary alpha (t1031u): coverage ≥ 0.5 becomes fully opaque in every map,
1100
/// anything below becomes all zeros in every map, so the masks always match.
1101
pub fn binarize_alpha(t: &mut Target) {
1102
for i in 0..t.depth_t.len() {
1103
if t.diffuse.rgba[i * 4 + 3] >= 128 {
1104
for map in [&mut t.diffuse, &mut t.normal, &mut t.specular] {
1105
map.rgba[i * 4 + 3] = 255;
1106
}
1107
t.depth[i * 2 + 1] = 65535;
1108
} else {
1109
for map in [&mut t.diffuse, &mut t.normal, &mut t.specular] {
1110
map.rgba[i * 4..i * 4 + 4].fill(0);
1111
}
1112
t.depth[i * 2] = 0;
1113
t.depth[i * 2 + 1] = 0;
1114
t.depth_t[i] = 0.;
1115
}
1116
}
1117
}
1118
1097
1119
fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1098
1120
let relative = relative
1099
1121
.to_str()
@@ -1525,6 +1547,7 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1525
1547
"depth_far": depth_far,
1526
1548
"depth_encoding": job.depth_format.encoding.token(),
1527
1549
"depth_bits": job.depth_format.bits,
1550
"semi_transparency": job.semi_transparency,
1528
1551
"normal_space": "view (+Y up), PNG rows top-down",
1529
1552
"camera_distance": crate::camera::CAMERA_DISTANCE,
1530
1553
"yaw_zero": "camera on +Z",
@@ -1777,7 +1800,10 @@ fn execute_target(
1777
1800
total: plan.passes,
1778
1801
label: format!("{pose} · target-res / diffuse"),
1779
1802
});
1780
let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1803
let mut target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1804
if !job.semi_transparency {
1805
binarize_alpha(&mut target);
1806
}
1781
1807
let depth_placeholder = image(job.target_width, job.target_height, Vec::new());
1782
1808
for ((map, mut preview), rel) in [
1783
1809
("diffuse", target.diffuse.clone()),
@@ -2219,6 +2245,46 @@ mod tests {
2219
2245
assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());
2220
2246
}
2221
2247
#[test]
2248
fn binary_alpha_when_off_unchanged_when_on() {
2249
// 2×1 target: left pixel 75% covered, right pixel 25% covered.
2250
let mut r = raw(16, 8);
2251
r.depth.fill(0.5);
2252
for y in 0..8 {
2253
for x in 0..16 {
2254
let j = (y * 16 + x) * 4;
2255
let covered = if x < 8 { x < 6 } else { x < 10 };
2256
r.diffuse[j..j + 4].copy_from_slice(&[200, 100, 50, if covered { 255 } else { 0 }]);
2257
r.normal[j + 3] = r.diffuse[j + 3];
2258
r.specular[j + 3] = r.diffuse[j + 3];
2259
}
2260
}
2261
let d = detail_map(&r).unwrap();
2262
let soft = resolve(&r, &d, 2, 1).unwrap();
2263
let a = |t: &Target| [t.diffuse.rgba[3], t.diffuse.rgba[7]];
2264
assert!(a(&soft)[0] > 128 && a(&soft)[0] < 255, "{:?}", a(&soft));
2265
assert!(a(&soft)[1] > 0 && a(&soft)[1] < 128, "{:?}", a(&soft));
2266
let mut hard = resolve(&r, &d, 2, 1).unwrap();
2267
binarize_alpha(&mut hard);
2268
// Covered pixel: opaque in every map, values kept.
2269
for m in [&hard.diffuse, &hard.normal, &hard.specular] {
2270
assert_eq!(m.rgba[3], 255);
2271
assert_eq!(m.rgba[4..8], [0, 0, 0, 0]);
2272
}
2273
assert_eq!(hard.diffuse.rgba[..3], soft.diffuse.rgba[..3]);
2274
assert_eq!(hard.normal.rgba[..3], soft.normal.rgba[..3]);
2275
assert_eq!(hard.depth[..2], [soft.depth[0], 65535]);
2276
assert_eq!(hard.depth[2..], [0, 0]);
2277
assert_eq!(hard.depth_t[1], 0.);
2278
// Every alpha is 0 or 1 and the masks of all maps match.
2279
for i in 0..2 {
2280
let da = hard.diffuse.rgba[i * 4 + 3];
2281
assert!(da == 0 || da == 255);
2282
assert_eq!(hard.normal.rgba[i * 4 + 3], da);
2283
assert_eq!(hard.specular.rgba[i * 4 + 3], da);
2284
assert_eq!(hard.depth[i * 2 + 1], u16::from(da) * 257);
2285
}
2286
}
2287
#[test]
2222
2288
fn resolve_preserves_thin_foreground_and_matching_maps() {
2223
2289
let mut r = raw(8, 8);
2224
2290
r.diffuse = [0, 255, 0, 255].repeat(64);
@@ -2366,6 +2432,7 @@ mod tests {
2366
2432
let mut job = RenderJob {
2367
2433
depth_format: crate::camera::DepthFormat::GRAY16,
2368
2434
atlas_trim: false,
2435
semi_transparency: true,
2369
2436
character: body(),
2370
2437
sex: "male".into(),
2371
2438
preset: None,
@@ -2495,6 +2562,7 @@ mod tests {
2495
2562
let job = RenderJob {
2496
2563
depth_format: crate::camera::DepthFormat::GRAY16,
2497
2564
atlas_trim: false,
2565
semi_transparency: true,
2498
2566
character: missing,
2499
2567
sex: "male".into(),
2500
2568
preset: None,
@@ -2534,6 +2602,7 @@ mod tests {
2534
2602
let job = RenderJob {
2535
2603
depth_format: crate::camera::DepthFormat::GRAY16,
2536
2604
atlas_trim: false,
2605
semi_transparency: true,
2537
2606
character: b,
2538
2607
sex: "male".into(),
2539
2608
preset: None,
@@ -2710,6 +2779,7 @@ mod tests {
2710
2779
RenderJob {
2711
2780
depth_format: crate::camera::DepthFormat::GRAY16,
2712
2781
atlas_trim: false,
2782
semi_transparency: true,
2713
2783
character: b,
2714
2784
sex: "male".into(),
2715
2785
preset: None,
@@ -95,6 +95,8 @@ pub struct RenderJob {95
pub depth_format: crate::camera::DepthFormat,95
pub depth_format: crate::camera::DepthFormat,96
/// Crop atlas views to their content (project setting, t1030u).96
/// Crop atlas views to their content (project setting, t1030u).97
pub atlas_trim: bool,97
pub atlas_trim: bool,98
/// Semi-transparency allowed (t1031u); off = binary alpha at 0.5 in all maps.99
pub semi_transparency: bool,98
}100
}99
#[derive(Debug, Clone, PartialEq)]101
#[derive(Debug, Clone, PartialEq)]100
pub struct ClipPlan {102
pub struct ClipPlan {@@ -1094,6 +1096,26 @@ pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, S1094
})1096
})1095
}1097
}1096
1098
1099
/// Binary alpha (t1031u): coverage ≥ 0.5 becomes fully opaque in every map,1100
/// anything below becomes all zeros in every map, so the masks always match.1101
pub fn binarize_alpha(t: &mut Target) {1102
for i in 0..t.depth_t.len() {1103
if t.diffuse.rgba[i * 4 + 3] >= 128 {1104
for map in [&mut t.diffuse, &mut t.normal, &mut t.specular] {1105
map.rgba[i * 4 + 3] = 255;1106
}1107
t.depth[i * 2 + 1] = 65535;1108
} else {1109
for map in [&mut t.diffuse, &mut t.normal, &mut t.specular] {1110
map.rgba[i * 4..i * 4 + 4].fill(0);1111
}1112
t.depth[i * 2] = 0;1113
t.depth[i * 2 + 1] = 0;1114
t.depth_t[i] = 0.;1115
}1116
}1117
}1118
1097
fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {1119
fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {1098
let relative = relative1120
let relative = relative1099
.to_str()1121
.to_str()@@ -1525,6 +1547,7 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {1525
"depth_far": depth_far,1547
"depth_far": depth_far,1526
"depth_encoding": job.depth_format.encoding.token(),1548
"depth_encoding": job.depth_format.encoding.token(),1527
"depth_bits": job.depth_format.bits,1549
"depth_bits": job.depth_format.bits,1550
"semi_transparency": job.semi_transparency,1528
"normal_space": "view (+Y up), PNG rows top-down",1551
"normal_space": "view (+Y up), PNG rows top-down",1529
"camera_distance": crate::camera::CAMERA_DISTANCE,1552
"camera_distance": crate::camera::CAMERA_DISTANCE,1530
"yaw_zero": "camera on +Z",1553
"yaw_zero": "camera on +Z",@@ -1777,7 +1800,10 @@ fn execute_target(1777
total: plan.passes,1800
total: plan.passes,1778
label: format!("{pose} · target-res / diffuse"),1801
label: format!("{pose} · target-res / diffuse"),1779
});1802
});1780
let target = resolve(&raw, &detail, job.target_width, job.target_height)?;1803
let mut target = resolve(&raw, &detail, job.target_width, job.target_height)?;1804
if !job.semi_transparency {1805
binarize_alpha(&mut target);1806
}1781
let depth_placeholder = image(job.target_width, job.target_height, Vec::new());1807
let depth_placeholder = image(job.target_width, job.target_height, Vec::new());1782
for ((map, mut preview), rel) in [1808
for ((map, mut preview), rel) in [1783
("diffuse", target.diffuse.clone()),1809
("diffuse", target.diffuse.clone()),@@ -2219,6 +2245,46 @@ mod tests {2219
assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());2245
assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());2220
}2246
}2221
#[test]2247
#[test]2248
fn binary_alpha_when_off_unchanged_when_on() {2249
// 2×1 target: left pixel 75% covered, right pixel 25% covered.2250
let mut r = raw(16, 8);2251
r.depth.fill(0.5);2252
for y in 0..8 {2253
for x in 0..16 {2254
let j = (y * 16 + x) * 4;2255
let covered = if x < 8 { x < 6 } else { x < 10 };2256
r.diffuse[j..j + 4].copy_from_slice(&[200, 100, 50, if covered { 255 } else { 0 }]);2257
r.normal[j + 3] = r.diffuse[j + 3];2258
r.specular[j + 3] = r.diffuse[j + 3];2259
}2260
}2261
let d = detail_map(&r).unwrap();2262
let soft = resolve(&r, &d, 2, 1).unwrap();2263
let a = |t: &Target| [t.diffuse.rgba[3], t.diffuse.rgba[7]];2264
assert!(a(&soft)[0] > 128 && a(&soft)[0] < 255, "{:?}", a(&soft));2265
assert!(a(&soft)[1] > 0 && a(&soft)[1] < 128, "{:?}", a(&soft));2266
let mut hard = resolve(&r, &d, 2, 1).unwrap();2267
binarize_alpha(&mut hard);2268
// Covered pixel: opaque in every map, values kept.2269
for m in [&hard.diffuse, &hard.normal, &hard.specular] {2270
assert_eq!(m.rgba[3], 255);2271
assert_eq!(m.rgba[4..8], [0, 0, 0, 0]);2272
}2273
assert_eq!(hard.diffuse.rgba[..3], soft.diffuse.rgba[..3]);2274
assert_eq!(hard.normal.rgba[..3], soft.normal.rgba[..3]);2275
assert_eq!(hard.depth[..2], [soft.depth[0], 65535]);2276
assert_eq!(hard.depth[2..], [0, 0]);2277
assert_eq!(hard.depth_t[1], 0.);2278
// Every alpha is 0 or 1 and the masks of all maps match.2279
for i in 0..2 {2280
let da = hard.diffuse.rgba[i * 4 + 3];2281
assert!(da == 0 || da == 255);2282
assert_eq!(hard.normal.rgba[i * 4 + 3], da);2283
assert_eq!(hard.specular.rgba[i * 4 + 3], da);2284
assert_eq!(hard.depth[i * 2 + 1], u16::from(da) * 257);2285
}2286
}2287
#[test]2222
fn resolve_preserves_thin_foreground_and_matching_maps() {2288
fn resolve_preserves_thin_foreground_and_matching_maps() {2223
let mut r = raw(8, 8);2289
let mut r = raw(8, 8);2224
r.diffuse = [0, 255, 0, 255].repeat(64);2290
r.diffuse = [0, 255, 0, 255].repeat(64);@@ -2366,6 +2432,7 @@ mod tests {2366
let mut job = RenderJob {2432
let mut job = RenderJob {2367
depth_format: crate::camera::DepthFormat::GRAY16,2433
depth_format: crate::camera::DepthFormat::GRAY16,2368
atlas_trim: false,2434
atlas_trim: false,2435
semi_transparency: true,2369
character: body(),2436
character: body(),2370
sex: "male".into(),2437
sex: "male".into(),2371
preset: None,2438
preset: None,@@ -2495,6 +2562,7 @@ mod tests {2495
let job = RenderJob {2562
let job = RenderJob {2496
depth_format: crate::camera::DepthFormat::GRAY16,2563
depth_format: crate::camera::DepthFormat::GRAY16,2497
atlas_trim: false,2564
atlas_trim: false,2565
semi_transparency: true,2498
character: missing,2566
character: missing,2499
sex: "male".into(),2567
sex: "male".into(),2500
preset: None,2568
preset: None,@@ -2534,6 +2602,7 @@ mod tests {2534
let job = RenderJob {2602
let job = RenderJob {2535
depth_format: crate::camera::DepthFormat::GRAY16,2603
depth_format: crate::camera::DepthFormat::GRAY16,2536
atlas_trim: false,2604
atlas_trim: false,2605
semi_transparency: true,2537
character: b,2606
character: b,2538
sex: "male".into(),2607
sex: "male".into(),2539
preset: None,2608
preset: None,@@ -2710,6 +2779,7 @@ mod tests {2710
RenderJob {2779
RenderJob {2711
depth_format: crate::camera::DepthFormat::GRAY16,2780
depth_format: crate::camera::DepthFormat::GRAY16,2712
atlas_trim: false,2781
atlas_trim: false,2782
semi_transparency: true,2713
character: b,2783
character: b,2714
sex: "male".into(),2784
sex: "male".into(),2715
preset: None,2785
preset: None,@@ -92,6 +92,9 @@ pub struct RenderSettings {
92
92
/// Crop equipment atlas views to their content (t1030u; default on).
93
93
#[serde(default = "default_true")]
94
94
pub trim_equipment: bool,
95
/// Allow semi-transparent body pixels (t1031u; default off = binary alpha).
96
#[serde(default)]
97
pub semi_transparency_body: bool,
95
98
}
96
99
97
100
fn default_true() -> bool {
@@ -113,6 +116,7 @@ impl Default for RenderSettings {
113
116
depth_encoding: crate::camera::DepthEncoding::Gray,
114
117
trim_body: true,
115
118
trim_equipment: true,
119
semi_transparency_body: false,
116
120
}
117
121
}
118
122
}
@@ -1108,6 +1112,7 @@ mod framing_tests {
1108
1112
assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);
1109
1113
// t1030u: older files get both atlas trim settings on.
1110
1114
assert!(migrated.render.trim_body && migrated.render.trim_equipment);
1115
assert!(!migrated.render.semi_transparency_body);
1111
1116
value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);
1112
1117
assert!(parse_manifest(&value.to_string())
1113
1118
.unwrap()
@@ -92,6 +92,9 @@ pub struct RenderSettings {92
/// Crop equipment atlas views to their content (t1030u; default on).92
/// Crop equipment atlas views to their content (t1030u; default on).93
#[serde(default = "default_true")]93
#[serde(default = "default_true")]94
pub trim_equipment: bool,94
pub trim_equipment: bool,95
/// Allow semi-transparent body pixels (t1031u; default off = binary alpha).96
#[serde(default)]97
pub semi_transparency_body: bool,95
}98
}96
99
97
fn default_true() -> bool {100
fn default_true() -> bool {@@ -113,6 +116,7 @@ impl Default for RenderSettings {113
depth_encoding: crate::camera::DepthEncoding::Gray,116
depth_encoding: crate::camera::DepthEncoding::Gray,114
trim_body: true,117
trim_body: true,115
trim_equipment: true,118
trim_equipment: true,119
semi_transparency_body: false,116
}120
}117
}121
}118
}122
}@@ -1108,6 +1112,7 @@ mod framing_tests {1108
assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);1112
assert_eq!(migrated.render.framing, [CameraFraming::default(); 2]);1109
// t1030u: older files get both atlas trim settings on.1113
// t1030u: older files get both atlas trim settings on.1110
assert!(migrated.render.trim_body && migrated.render.trim_equipment);1114
assert!(migrated.render.trim_body && migrated.render.trim_equipment);1115
assert!(!migrated.render.semi_transparency_body);1111
value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);1116
value["render"]["framing"] = serde_json::json!([{"height_milli":2600,"center_y_milli":-2147483648},{"height_milli":2600,"center_y_milli":1000}]);1112
assert!(parse_manifest(&value.to_string())1117
assert!(parse_manifest(&value.to_string())1113
.unwrap()1118
.unwrap()@@ -29,6 +29,7 @@ fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
29
29
RenderJob {
30
30
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
31
31
atlas_trim: false,
32
semi_transparency: true,
32
33
character,
33
34
sex: "male".into(),
34
35
preset,
@@ -29,6 +29,7 @@ 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
atlas_trim: false,32
semi_transparency: true,32
character,33
character,33
sex: "male".into(),34
sex: "male".into(),34
preset,35
preset,The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -148,6 +148,14 @@ fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() {
148
148
assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1);
149
149
let migrated = Preset::decode(&old).unwrap();
150
150
assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none()));
151
assert!(!migrated.semi_transparency);
152
// A version-2 file (0.1.3) loads with semi-transparency off (t1031u).
153
let v2 = std::fs::read(
154
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v2.ppe"),
155
)
156
.unwrap();
157
assert_eq!(u32::from_le_bytes(v2[8..12].try_into().unwrap()), 2);
158
assert!(!Preset::decode(&v2).unwrap().semi_transparency);
151
159
let bytes = migrated.encode().unwrap();
152
160
assert_eq!(
153
161
u32::from_le_bytes(bytes[8..12].try_into().unwrap()),
@@ -148,6 +148,14 @@ fn version_1_presets_migrate_and_mapping_round_trips_in_version_2() {148
assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1);148
assert_eq!(u32::from_le_bytes(old[8..12].try_into().unwrap()), 1);149
let migrated = Preset::decode(&old).unwrap();149
let migrated = Preset::decode(&old).unwrap();150
assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none()));150
assert!(migrated.attachments.iter().all(|a| a.skeleton.is_none()));151
assert!(!migrated.semi_transparency);152
// A version-2 file (0.1.3) loads with semi-transparency off (t1031u).153
let v2 = std::fs::read(154
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword-v2.ppe"),155
)156
.unwrap();157
assert_eq!(u32::from_le_bytes(v2[8..12].try_into().unwrap()), 2);158
assert!(!Preset::decode(&v2).unwrap().semi_transparency);151
let bytes = migrated.encode().unwrap();159
let bytes = migrated.encode().unwrap();152
assert_eq!(160
assert_eq!(153
u32::from_le_bytes(bytes[8..12].try_into().unwrap()),161
u32::from_le_bytes(bytes[8..12].try_into().unwrap()),@@ -55,6 +55,7 @@ fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>)
55
55
RenderJob {
56
56
depth_format: paperdoll_renderer::camera::DepthFormat::GRAY16,
57
57
atlas_trim: false,
58
semi_transparency: true,
58
59
character,
59
60
sex: "male".into(),
60
61
preset,
@@ -55,6 +55,7 @@ fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>)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
atlas_trim: false,58
semi_transparency: true,58
character,59
character,59
sex: "male".into(),60
sex: "male".into(),60
preset,61
preset,