BlueGit

golden-age paperdoll-renderer

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)

timfa Grok Bot · pushed by timfa

Authored 2026-11-08 · 6 days ago

Hash
6be5fca5b5b80865c2be99df795e17c269cf2667
Parent
ff1cc5ca
14 files changed +164 −6
README.md +3 −3
@@ -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 ![Body view with animation and camera controls](docs/screenshots/body-live.png)
@@ -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** selects
29- Pick an animation clip and use **Pause / Play**.
29- Pick an animation clip and use **Pause / Play**.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes. **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![Body view with animation and camera controls](docs/screenshots/body-live.png)
35![Body view with animation and camera controls](docs/screenshots/body-live.png)
@@ -79,7 +79,7 @@ From a body, **Equipment** opens the list. Choose a definition or **New**.
794. 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.
794. 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.
804b. 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.
804b. 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.
815. **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.
815. **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.
826. **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.
826. **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 write
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- **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### Resuming
161### Resuming
assets/default-equipment/female-hair.ppe Binary file +0 −0

The contents of this binary file are not shown.

examples/auto_pivot_probe.rs +1 −0
@@ -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,
src/atlas.rs +12 −1
@@ -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_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 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() {
src/editor.rs +55 −0
@@ -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 applicable
735 /// 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 equipment
2597/// preset's own setting, otherwise the body setting in the `.ppd`.
2598pub 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 a
2605/// Bone links of a skinned equipment part (Tim t1027u): per equipment bone a
2587/// character bone (optional; at least one), with its own position/rotation.
2606/// character bone (optional; at least one), with its own position/rotation.
2588fn skinned_links_ui(
2607fn 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.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 #[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();
src/equipment.rs +5 −1
@@ -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
15pub const MAGIC: &[u8; 8] = b"PPEQ\r\n\x1a\n";
15pub 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.
17pub const VERSION: u32 = 2;
17pub const VERSION: u32 = 3;
18pub const MAX_FILE: usize = 32 * 1024 * 1024;
18pub const MAX_FILE: usize = 32 * 1024 * 1024;
19const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_META: usize = 4 * 1024 * 1024;
20const MAX_ASSET: usize = 8 * 1024 * 1024;
20const 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}
96impl Preset {
99impl 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(
src/i18n_table.rs +2 −0
@@ -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"),
src/production.rs +71 −1
@@ -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)]
100pub struct ClipPlan {
102pub 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.
1101pub 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
1097fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1119fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1098 let relative = relative
1120 let relative = relative
1099 .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,
src/project.rs +5 −0
@@ -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
97fn default_true() -> bool {
100fn 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()
tests/fbx_integration.rs +1 −0
@@ -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,
tests/fixtures/equipment/sword-v2.ppe added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/equipment/sword.ppe Binary file +0 −0

The contents of this binary file are not shown.

tests/skinned_equipment.rs +8 −0
@@ -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()),
tests/static_body.rs +1 −0
@@ -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,