BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / design.md

design.md

design.md 99.4 KiB Download Raw
1# Paperdoll Renderer — design
2
3**Status: design recorded with a foundation shell only.**
4Prepared 6 November 2026 (Europe/Amsterdam). Requirements originate with Tim Falken. Sections marked **proposal** remain future decisions; the appendix preserves the original brief. Current functionality is described in §§5–11: project/equipment authoring, shared-depth live 3D and streaming all-clips/frames/36-view production target maps are implemented. Later corrections (target-only four maps and strictly unlit diffuse) supersede older filename/rim wording in the historical appendix.
5
6Anonymous HTTP requests to `https://git.timfalken.com/groups/golden-age` and `https://git.timfalken.com/golden-age/paperdoll-renderer` returned 404. BlueGit also answers 404 when a private project is not visible to the caller, so those responses do not prove the private repository is absent. This workspace does not create the group or the repository. The requested BlueGit destination stays group **Golden Age**, slug `golden-age`, repository **Paperdoll Renderer**, slug `paperdoll-renderer`.
7
8## 1. Product, scope and non-goals
9
10Build a native Rust desktop asset-authoring application for rendering animated human bodies and bone-attached equipment into aligned 2D paperdoll PNG sequences. A project contains male and female source models, their imported animation inventories, equipment presets, render settings, output paths and output metadata. Either model slot may remain empty while authoring. Missing models must be reported before a render starts, not replaced by invented characters.
11
12The application has three distinct experiences: a project/model workspace, an interactive **3D attachment editor**, and a **rendered 2D paperdoll preview**. The latter must consume actual exported maps; it must not disguise a 3D viewport as a finished sprite renderer.
13
14The later game character-renderer/controller is OUT OF SCOPE. The in-tool compositing preview is in scope because it verifies asset compatibility. No bundled commercial models, automatic retargeting, physics-based cloth, equipment deformation/weight painting, game controllers or multiplayer are promised. Equipment is initially rigid geometry attached to bones. Imported skinned equipment can be a later explicitly scoped extension.
15
16BlueGit destination requested: group **Golden Age**, proposed slug `golden-age`; repository **Paperdoll Renderer**, slug `paperdoll-renderer`. Check existence before creating either. Private visibility is a provisional safe default, not a claim about an existing group's settings. Do not change unrelated group/repository access.
17
18## 2. Requirements and traceability
19
20| ID | Requirement | Verification |
21|---|---|---|
22| R01 | Rust application with user-selected male/female models | Open/save missing and populated slots; later manual desktop smoke test |
23| R02 | Discover all supported embedded animation clips | Synthetic licensed fixture inventory, including unnamed/duplicate clips |
24| R03 | Body and equipment modes | Export body plus isolated equipment from identical scene state |
25| R04 | Five pitches times twelve yaw positions (t955u; was three) | Exactly 60 unique orientations; yaw starts facing viewer |
26| R05 | Initial geometry render at 8x target dimensions and configured FPS | Explicit render manifest and dimension/frame-count tests |
27| R06 | Edge/detail-guided downsampling | Image fixtures retain thin details better than baseline without alpha fringes |
28| R07 | Target-resolution diffuse, depth, normal maps | Round-trip decoding, finite normalized normals and shared depth contract |
29| R08 | Subtle diffuse edge highlights from depth/normals | Deterministic fixture with bounded highlight effect |
30| R09 | Required filename structure | Golden path examples plus sanitization/collision tests |
31| R10 | Bone-relative XYZ position and rotation with live orbitable 3D preview | Transform-chain unit tests and manual viewport test |
32| R11 | Binary equipment presets embedding models and attachment metadata | Round-trip, version/truncation/resource-limit tests |
33| R12 | Equipment set name lowercase without spaces | Validation; reject reserved body name and path traversal |
34| R13 | Equipment-only rendering with shinymap | No body occlusion/write contamination; specular fixture |
35| R14 | Folder plus .ppd manifest describing assets, presets, outputs | Portable relative paths and safe atomic saves |
36| R15 | 2D looping animation, sex/equipment/clip selection and 60-angle navigation | State-machine and depth-compositing tests |
37| R16 | Held light-control buttons rotate continuously in degrees | Delta-time tests including key/button release/focus loss |
38| R17 | Per-feature branch, tests, non-FF master merge, no initial MRs | Two-parent merge topology and CI checks |
39| R18 | Tag tests gate AppImage, deb, Arch package, Windows build and release | Packaging smoke tests and complete-artifact release gate |
40| R19 | Minimum import goals OBJ/MTL/textures and FBX; broader support where feasible | Format matrix and fixture-based import tests |
41
42## 3. Proposed architecture and stack
43
44**Proposal, not compatibility certification:** use Bevy for windowing, scene graph, skeletal animation, glTF loading and GPU rendering (wgpu), with a compatible bevy_egui integration for authoring panels. Choose and pin compatible published versions when implementation starts; do not guess latest-version APIs or dependency compatibility. Use ordinary Rust libraries with no GUI/GPU requirements for project validation, naming, render schedules, transforms and image processing. Tests for this core must run headlessly.
45
46A dependency-minimal executable skeleton may precede the full viewport; it must say which capabilities are unimplemented. An application that only prints a placeholder message is not the completed desktop renderer. Rust edition/toolchain must be explicit, version-pinned after confirming installed/build-runner availability. Commit Cargo.lock for reproducibility.
47
48Proposed workspace boundaries:
49- `crates/ppd-core`: project types, validation, portable paths, render schedule, filename codec and format versions.
50- `crates/ppd-import`: import adapters, coordinate normalization, normalized assets and animation discovery.
51- `crates/ppd-render`: shared camera, scene sampling, offscreen maps and readback.
52- `crates/ppd-image`: linear-color downsampling, detail extraction, edge highlight and PNG codecs.
53- `crates/ppd-preview`: deterministic depth compositing and preview/light state independent of UI where possible.
54- `crates/paperdoll-renderer`: desktop shell, model/preset editors, render progress and preview tabs.
55- `tests/fixtures`: tiny synthetic or permissively licensed fixtures, with provenance and licenses.
56- `packaging` and `scripts`: verified platform packaging and CI helpers.
57
58Do not create empty crates solely to imply completion. Establish boundaries as features land. Rendering jobs operate on immutable validated snapshots so editing a project cannot silently change an active batch. Worker messages report progress and cancellation; UI/GPU ownership stays on the appropriate thread.
59
60## 4. Import format advice and support matrix
61
62**Recommendation:** glTF 2.0/GLB is the preferred canonical interchange for animated characters. GLB is convenient for distributing a self-contained body and avoids accidental texture-path breakage. Preserve original sources and conversion/import provenance. OBJ and FBX are minimum product goals, not necessarily native Bevy capabilities. A Bevy glTF loader must not be advertised as an FBX or OBJ loader.
63
64### Import implementation status
65
66The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only to parse files would replace the window stack already used for project editing, and it is not required to read glTF or OBJ. Import uses the `gltf` 1.4 crate and `tobj` 4. Both are pure Rust. The live preview loads glTF/GLB on its own and skins the mesh; the inventory records stay the import report.
67
68This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.
69
70FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. **`fbx_scene` builds a normalized in-process drawable/animated scene** (right-handed Y-up meters, ModifyGeometry space/geometry-transform handling, materials/textures ufbx reports, skin clusters with inverse-binds, every anim stack baked to sampleable TRS channels) with retained conversion provenance; fixture tests compare bind-equivalent and animated joint samples against the matching GLB. Unsupported skinning methods and leftover geometry-transform helpers fail explicitly. The live Glow viewport and the production orthographic four-map batch load bodies through `preview::load_body_file`, which dispatches `.glb/.gltf` to the glTF reader and `.fbx` to `fbx_scene`; the FBX conversion is applied once there and nothing downstream re-applies axis/unit correction. FBX UVs are converted once to the top-left `PreviewCharacter` convention used by the body production path. Rigid FBX equipment (`equipment::import_fbx`) parses only the in-memory file bytes with the same normalization (ModifyGeometry, node geometry-to-world baked into vertices, normal matrix for normals, reversed winding fixed), stores one mesh per material part, embeds the original FBX plus embedded or relative-inside-folder PNG textures in the `.ppe`, treats a connected diffuse texture as replacing the FBX diffuse colour, and rejects skinned meshes, blend shapes and non-PNG textures. Tests compare the FBX body production silhouette (walk) and the animated attack arm chain against the GLB, the neck pivot within 30 mm, FBX equipment axis/unit/UV/texture fidelity, and exact attachment to the animated FBX joint; a GPU fixture (`examples/fbx_live_fixture.rs`) screenshots the live view and renders and verifies both export sets. Note: the Blender-exported fixture's `attack` stack contains a leftover walk leg pose for bones the GLB clip does not key; the loader reproduces the file as stored. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).
71
72| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
73|---|---|---|---|---|---|
74| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |
75| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Rigid equipment, and static bodies drawn in live view and production (one bind sample × 36 views). |
76| FBX ASCII/binary | Yes | Diffuse/PBR color and file or embedded textures that ufbx reports | Skin clusters, bone parents, inverse-binds, rest/global transforms | Takes as animation stacks; baked TRS channels sampleable like glTF | Inventory via `import`; normalized drawable scene via `fbx_scene` drawn in the live viewport and production batch; rigid FBX equipment via `equipment::import_fbx`. Not every FBX dialect. |
77| Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |
78| PLY / STL | Static geometry | PLY colors possible; STL no conventional textures | No | No | Optional static equipment only; low priority |
79| .blend | Not a portable interchange contract | Requires Blender/version matching | Possible through export | Possible through export | No direct minimum support; explicit external export to GLB preferred |
80
81FBX/OBJ adapter decision remains **open**: evaluate Assimp via maintained Rust bindings (e.g. russimp, native Assimp build dependency), dedicated format loaders, or explicit Blender conversion. Assimp offers a broad-format normalization route but its Rust binding/native ABI, packaging, material conversion, skin weights and animation correctness must be proven on Linux AND Windows. A native library must be bundled/licensed correctly or statically linked where permitted. Do not silently require an installed Blender; if used as an optional converter, show the requirement and invoke it only after explicit user selection. Avoid a heavyweight runtime dependency when an in-process adapter passes tests.
82
83Import normalizes axes, handedness, units, winding, inverse-bind matrices and texture color spaces ONCE, then retains conversion metadata. Detect singular transforms, invalid weights, missing bones/textures and unsupported materials. Imported file paths and embedded archives are untrusted: no traversal, arbitrary script execution or unbounded decompression. Keep parse limits and import failure diagnostics.
84
85OBJ bodies can be displayed as static geometry, but must not pretend to have a skeleton or the requested animations. A UI must clearly disable unavailable bone/clip features. Separate equipment geometry from body rig; bone matching uses stable joint identity/path, not only an ambiguous display name.
86
87**Implemented static bodies (remaining #9, 7 November 2026):** `preview::load_body_file` loads bodies without a skin as `BodyRig::Static` — `.obj` (OBJ+MTL+PNG), glTF/GLB with no `skins`, and FBX whose meshes have no skin deformer (`fbx_scene::load_fbx_drawable` returns `NO_SKINNED_MESH`; skinned FBX stays on `fbx_scene`). OBJ and FBX reuse the rigid equipment readers (`equipment::import_obj`/`import_fbx`, FBX ufbx normalization applied once there) with a body vertex budget of 1,000,000; the only extra conversion is the UV origin (bottom-left → top-left `PreviewCharacter` convention), applied once. Static glTF bakes each mesh node's rest world transform of the default scene into positions once, the inverse-transpose into normals, and flips winding for mirrored (negative-determinant) transforms; singular transforms are rejected. A static body has empty nodes/joints/inverse-binds and **no clips** (glTF node animations are not applied; the editor message names how many). Every skinning path resolves to identity (no bones, weight 1 on slot 0), so live view and production draw the stored pose. **Camera:** `camera::neck_height` does not guess a neck from geometry for a static body; `resolve` falls back to `center_y_milli` (`pivot_source` `fallback`, reason "Statisch lichaam zonder skeleton: geen nekbot"); a manual `pivot_y_milli` still wins; fixed per-sex height/zoom is unchanged. **Production:** the existing no-clip plan renders one `clip000-bind` sample × 36 views with the same six passes and only the four target maps, readable through `export_set`. **UI:** the body view shows the static reason; the clip picker and Play/Pause are disabled; the equipment editor disables the bone tree and **Render equipment**, explains why, and the live view draws no equipment layers on a static body; `production::contract` refuses an equipment job on a static body and `Preset::bind` has no bone to bind. The live viewport remains a lit base-colour placement view. Fixtures: `tests/fixtures/bodies/static/` (generated by `scripts/generate_static_bodies.py`; the GLB stores pre-transformed geometry under a translated/scaled parent and rotated child node plus one node animation) and the rigid `sword-fbx.fbx` as unskinned FBX; tests compare static geometry with the skinned GLB bind pose; GPU proof `examples/static_body_fixture.rs`.
88
89Format acceptance gate: fixtures cover mesh topology, alpha materials, normal maps, external relative texture paths, one animated skinned body, multiple clips, bone-local transforms, malformed files and missing resources. A green fixture does not certify every exporter dialect.
90
91## 5. .ppd project and asset ownership
92
93**Proposal:** `.ppd` is a versioned UTF-8 JSON manifest in a project directory, with `format_version`, project identifier, display name, optional male/female model references, import transforms, animation inventory overrides, equipment preset references, render settings, output locations and generated-set metadata. Keep source references and normalized cache separate. Use project-relative paths by default; importing may copy models plus dependencies into `assets/` after user choice. External references need explicit portability warnings and relinking, never silent data loss.
94
95Suggested layout: `character.ppd`, `assets/body/`, `assets/equipment/`, `presets/`, `cache/`, `renders/`. The .ppd manifest is not the binary equipment preset. Cache is disposable and content-addressed; source and preset files are not. Save through a temporary sibling plus atomic rename, preserve valid previous state on failure, reject unsupported newer schema versions with a useful message. Unknown fields should not be silently discarded on rewrite without a documented migration policy.
96
97Validate finite numeric values, dimensions > 0, bounded dimensions and memory estimate, positive FPS, valid clip references, existing model/preset paths and output directories distinct from sources. A missing optional sex slot is a valid project, but cannot render that slot. No absolute-path leakage into exported manifests unless user explicitly chooses external references.
98
99### Implemented format_version 3 (versions 1/2 migration)
100
101`character.ppd` is a strict UTF-8 JSON manifest. Fields remain `format_version`, `id`, `name`, nullable `male_model`/`female_model`, `equipment_presets`, `output_directory`, and `render` (`target_width`, `target_height`, `fps`, `framing`). Version 2 introduced `render.framing`, an array **[male, female]** of `{height_milli, center_y_milli}`: integer world-unit millimetres, default height 2600 and center Y 1000. Version 3 adds optional `pivot_y_milli` on each framing entry (`None` = auto rest-pose neck, `Some` = manual override). Heights 100…10000 and center/pivot −10000…10000 are validated. Each sex has its own fixed camera, shared by every body/equipment export. Opening version 1 or 2 migrates in memory (missing pivot stays auto); the next save writes version 3. Version 4+ and unknown fields are rejected, not silently discarded. This does not add source-axis/import-transform overrides or animation inventory overrides.
102
103Paths use `/` and stay inside the project directory. Save writes/fsyncs a temporary sibling and atomically replaces the manifest; failure preserves the prior manifest. Binary equipment snapshots remain separate. Per-sex framing is part of the normal unsaved-change/save/discard gate, not transient orbit state.
104
105## 6. Camera, axes, alignment and animation sampling
106
107Required views: pitches −60, −30, 0, +30, +60 degrees (t955u; originally −30/0/+30); yaw 0, 30, 60, ..., 330 degrees. **Proposal:** yaw zero means the standardized character front faces the camera, world +Y is up, and positive yaw is defined by a documented right-hand convention. Distinguish camera elevation from model rotation: use orbiting orthographic camera around fixed character origin. Golden orientation fixture must show labelled front/back/left/right to remove ambiguity before production export.
108
109**Implemented fixed framing (Tim t952u):** orthographic projection, +Y world up, yaw 0 camera on +Z looking toward the character's front (glTF convention, see Tim t991u decision); positive yaw follows the existing orbit convention. The **orbit pivot** is independent of the vertical image center: target is `(0, pivot_y/1000, 0)` with camera distance **10**. `pivot_y_milli = None` resolves the unique rest-pose neck joint (name tokens such as `Neck`, `mixamorigNeck`, `Bip001 Neck`, `CC_Base_Neck`); missing/ambiguous necks fall back to `center_y_milli` with a UI diagnostic; an explicit `Some` value is a manual override. Vertical world extent is `height_milli/1000`, horizontal extent vertical × target aspect. Orthographic Y translation `(pivot − center) / half_height` keeps zero-pitch image framing (feet / image center) stable when the pivot is raised to the neck. Near **0.05**, far **40** are shared absolutely across all layers, frames and views. Body, equipment layered preview, fixed live frame and production `camera()` all share `camera::resolve` + `camera::matrices`. No per-frame/per-equipment auto-framing or depth normalization. Source root motion is retained as imported (no extraction). User adjusts height/center/pivot in the body view; automatic animation envelopes are not promised.
110
111**Vast renderkader tonen** switches live placement to this same orthographic camera and target-aspect, centered yellow frame. Drag still changes yaw/pitch; orbit-scroll zoom is ignored while the fixed frame is shown. Uncheck it for the independent perspective orbit/scroll camera. Neither free orbit zoom nor live selected clip/time changes a production job. Render snapshots the current project settings at job start (including resolved pivot and `pivot_source`); later edits do not change that in-flight batch. **Opslaan** persists male and female fixed framing (including optional manual pivot) in `.ppd`.
112
113**Implemented batch nesting:** source clip order → sampled frame → pitch **−60,−30,0,+30,+60** → yaw **0…330 step30** → six completed passes. Samples are `t=frame/FPS`, `0 <= frame < max(1, ceil(duration*FPS))`. To avoid exporter float noise creating a near-duplicate terminal frame, the product snaps to its nearest integer when within `4*f32::EPSILON*max(1,product)` before ceiling; a regression covers an exported 1-second endpoint one ULP above 1. Otherwise the full fractional duration is sampled; a zero-duration clip has one static sample; no clips produces one bind-pose clip. Duplicate display names get indexed tokens and internally unique sampling names, not accidental reuse of the first clip. All clips/frames/views are rendered regardless of current live selection. FPS is sampling, not playback rate.
114
115Highres is exactly **8× width AND height** (64× pixels); targets currently **1…256 per axis**, highres max **2048 per axis**. Larger projects may be opened but their batch render is explicitly rejected, never silently reduced to 1×. A batch is limited to 500000 PNGs and 16 GiB estimated uncompressed target data. Render streams one pose/view at a time; no whole-batch image allocation. GPU service is one offscreen draw/readback unit per native UI update, with real depth24 attachment/test/write/clear; skinning, detail/resolve and PNG writes run on a background worker. The GUI keeps updating; a large individual GPU unit can still take a frame. The latest-pass mailbox retains **one** completed image, one progress update, and a terminal event; no unbounded preview backlog. No sleeps, pass playback delay or preview acknowledgment. Fast passes can be visually skipped, never faked.
116
117## 7. Export naming and metadata
118
119The current hierarchical layout supersedes the earlier flat filename proposal (the original brief is retained verbatim in the historical appendix):
120
121```text
122renders/run-<unix-nanoseconds>-<pid>/<male|female>/<body|equipment-slug>/
123 clip<source-index:03>-<safe-name>/f<frame:06>/p<pitch:+03>_y<yaw:03>_<map>.png
124# example: .../male/body/clip001-walk/f000012/p-30_y330_diffuse.png
125```
126
127`output_directory` may change the `renders/` root. Safe lowercase clip slugs replace non-ASCII/non-alphanumerics with `-`, truncate to 48 characters, and prefix source index (so duplicate names cannot collide). Equipment slugs use the existing strict preset validator; `body` is reserved. Frame index is zero-based. Map tokens are **only `diffuse`, `depth`, `normal`, `specular`**, for body AND equipment, all exactly target width/height. **Highres and detail are preview-only intermediates: never PNG exports.** Each run is a fresh directory, never replaces any previous run or named image. Canonical parent checks reject traversal/outside symlinks; sibling temporary file + fsync + no-clobber hardlink publication prevents a partial PNG appearing as a valid final file. Filesystems must support hardlinks; unsupported filesystems fail explicitly. Existing or foreign temporary files are never deleted on creation failure.
128
129`render.json` (output metadata schema **4** since t955u — adds `pitches`/`yaws` and the atlas record; schema 3 introduced identity/checksums; separate from `.ppd` schema 3) is written at run start with `status: "incomplete"` and records the full export identity/contract: `project.{id,name}`, sex/set, `body_model.{path,sha256}` (SHA-256 of the body file bytes used for the job), optional `equipment_preset.{name,sha256}` (SHA-256 of the encoded `.ppe` bytes), target size, FPS, highres scale, resolved framing (including `pivot_y_milli`), `pivot_source` (`manual` / `neck` / `fallback`), common near/far, camera distance, conventions, clip inventory (names/tokens/durations/frames), six-pass order, four saved map tokens and planned counts. Frame timestamps follow the recorded sampling equation. Each published target PNG is hashed (SHA-256 of the on-disk PNG bytes) as it is written. `complete.json` (schema 4) is published ONLY after all PNGs complete and the atlases/`.ppda` are written, and contains a matching `identity` object, `png_sha256` (relative path → hex digest) for every file and `atlas` (`ppda`, `ppda_sha256`, `maps.<map>.{file,sha256}`); cancellation/failure best-effort writes `cancelled.json`/`failed.json` and leaves valid partial files without a complete marker. `render.json` intentionally remains an incomplete-start record; consumers must require `complete.json` and must not infer compatibility from filenames alone.
130
131**Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 4 matches (pitches/yaws exactly −60…+60 × 0…330, atlas names match sex/set), identity fields agree, every listed PNG is a regular file inside the run (no path traversal, no escaping symlinks), each checksum verifies, and resource caps hold (target ≤256, ≤500000 files, bounded PNG/manifest sizes). `load_map` / `load_relative` decode actual RGBA8 diffuse/normal/specular and gray-alpha16 depth with strict color-type/bit-depth/dimension checks; normals require near-unit length on covered pixels and zero RGB when transparent; specular RGB channels must be equal; transparent depth gray must be zero. Incompatible, stale (schema <4), incomplete or corrupted sets are rejected. `ExportRun` is the loose-PNG reader; atlas bytes are verified by the `.ppda` reader (`atlas::AtlasSet`). The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work.
132
133**Implemented resume (`resume` module, decision 7 November 2026):** every run directory holds `run.lock` (exclusive OS file lock held by the writing worker for the whole attempt; released by the OS on a crash) and an append-only `progress.jsonl` with one `{"path","sha256"}` line per published target PNG, appended and fsynced *after* the PNG's no-clobber publication. A selected incomplete run (no `complete.json`; marker `cancelled.json`, `failed.json` or none = crash) is resumable only when it is a direct `run-*` child of the output directory, its lock is free and its stored `render.json` equals, as JSON, exactly the document the current job would write (project id/name, sex/set, body path+SHA-256, optional equipment SHA-256, target, FPS, highres scale, resolved framing/pivot/pivot_source, near/far/camera, conventions, clip inventory, pass/map lists and counts). Any difference refuses the resume and names the differing top-level fields; the run is left untouched. Before rendering, every planned PNG is classified: journaled with a matching on-disk SHA-256 → kept and never rewritten (same inode); absent → regenerated; corrupt, oversized or non-regular (e.g. symlink) → moved aside with a no-clobber hard link to `<name>.corrupt-aNNN-K` and regenerated (never deleted); a directory in place of a PNG refuses the resume; an existing file without a journal entry (crash between publication and journal append) is compared with the regenerated bytes and kept when identical, otherwise moved aside. Journal lines naming a path outside the plan refuse the resume; torn/unparseable lines only make that file unverified. Images whose four maps are all verified skip the GPU; progress starts at the verified pass count and labels them *al aanwezig, checksums kloppen*. A previous top-level `cancelled.json`/`failed.json` is archived as `attempt-NNN-<marker>.json` (attempt 0 = original run), so a resumed run can be cancelled again (new `cancelled.json` with `attempt`, `completed_passes`, `png_files_done`) and `export_set` still opens it once complete. `resume.jsonl` appends one record per resume attempt (verified/missing/quarantined/unjournaled/damaged-journal counts and archived marker). `complete.json` is written only when all planned PNGs have verified checksums and is identical in form to an uninterrupted run. Foreign `.part-*` temporaries are never deleted; atomic writes now use per-write unique temp names (`part-<pid>-<seq>`) so a stale temp from a crashed process with a reused PID cannot block publication. Other runs are never opened for writing. Runs created before this change have no journal: all their existing PNGs are treated as unjournaled and verified by byte comparison with regenerated output. The editor lists matching incomplete runs (same sex/set, newest first, at most 20) under **Onderbroken renders** in the body and equipment views; mismatched or locked runs are shown with the reason and no **Hervat** action. Real Xvfb/Mesa proof: an `abort()`-crashed run, resumed, cancelled again, damaged and resumed to completion produced a `png_sha256` map identical to an uninterrupted run of the same project, while the earlier completed run stayed byte-identical (`examples/resume_fixture.rs`).
134
135## 8. Map contracts and rendering algorithm
136
137**Diffuse (Tim t953u): unlit albedo only.** Linear material base color × sRGB-decoded base-color texture × linear vertex COLOR_0 (white if absent), then sRGB encode at the PNG boundary. Preserve straight alpha/coverage. **No lighting, shading, shadows, specular or rim accents are baked in.** Live 3D may stay lit. A native GPU texture×vertex-color regression checks exact expected albedo/alpha and byte-identical diffuse under three opposed light directions.
138
139**Depth:** actual depth24 GPU attachment readback, resolved to **16-bit grayscale+alpha PNG**, same coverage as other maps; `distance = near + (gray/65535)*(far-near)`. Smaller covered depth is nearer. Transparent zero values are invalid, not a near plane. Fixed near/far and camera distance are identical for body/equipment and every frame. No luminance-derived pseudo-depth or per-frame rescaling.
140
141**Normal:** view-space skinned geometric/shading normal, RGB8 encodes `n*0.5+0.5`, straight coverage alpha. Camera +Y is up; PNG rows are top-down. Normals are transformed by inverse-transpose skin transforms and the common view rotation, then normalized after resolve. Transparent RGB is zero and must be ignored. Tangent-space source normal textures and full PBR material conversion are not implemented.
142
143**Specular:** RGBA8, linear scalar repeated in RGB, shared coverage alpha; body defaults to zero. Equipment uses its editable strength × shinymap luminance (when present), not a baked highlight. Output token is `specular` (not the old provisional `shiny`). The future consumer chooses the lighting/exponent; this is not physically exact metallic/roughness PBR.
144
145Per pose/view, the actual completed-preview order is:
1461. **Highres:** evaluate skeleton/attachments at exact sample time; offscreen MRT draws unlit albedo, view normal, scalar specular and true depth at exact 8× dimensions. Publish highres albedo immediately, no file saved.
1472. **Detail:** color/alpha/depth/normal neighbor discontinuities, bounded weights. Publish actual edge map immediately, no file saved.
1483. **Target-res / diffuse:** for each 8×8 block compute coverage, choose nearest covered depth and a coherent front-surface cluster (depth tolerance 0.0025 of the common near/far interval), apply alpha × `(1+4*detail)` weights, and resolve albedo in linear light. Color/normal/specular all use that same cluster, coverage comes from all covered samples; this explicit front-feature retention policy is an approximation, not unbiased area filtering. Normalize normal. Write target diffuse, publish NN-upscaled preview.
1494. **Depth:** write target 16-bit gray+alpha, publish its gray8 visualization (the saved depth keeps 16-bit precision).
1505. **Normal:** write/publish target normal.
1516. **Specular:** write/publish target scalar map, including body zero.
152
153Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Preview lighting is consumer-side relight only (no rim); never baked into pure diffuse albedo.
154
155Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX bodies use the normalized drawable/animated scene (`fbx_scene`) in both the live and production GPU paths, with the same draw/material rules (first skinned mesh, per material part surfaces, PNG albedo).
156
157Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.
158
159## 9. Equipment editor and binary presets
160
161The equipment editor is implemented for **rigid OBJ + MTL + PNG and rigid FBX (embedded or relative PNG)** parts over a skinned glTF/GLB or FBX body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
162
163Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
164
165The equipment configuration draws the body plus all meshes in the selected preset once its attachments are configured through the actual Glow callback, sharing the native 24-bit depth attachment. Clear once for the body, never between equipment meshes; depth test/write makes self/body occlusion independent of submission order. Orbit/zoom and body animations continue to work. A grayscale/luminance PNG shinymap multiplies the scalar specular strength in placement shading; this is not a baked production shiny output map. The real GPU probe tests body/equipment front/back ordering, a sampled red albedo texture, alpha discard, and white-versus-black shinymap highlights. The generated CC0 sword OBJ/MTL/checker/shiny fixture and binary golden file are under `tests/fixtures/equipment/`; the example opens an actual seeded project, not a screenshot mock.
166
167### Implemented `.ppe` container, version 1
168
169All integers below are unsigned little-endian. The container is uncompressed (no Rust memory-layout serialization and no decompression stage):
170
171| Bytes | Meaning |
172|---|---|
173| 0–7 | Exact magic `PPEQ\r\n\x1a\n` |
174| 8–11 | Schema version, exactly 1 |
175| 12–15 | UTF-8 JSON metadata length |
176| 16–19 | IEEE CRC32 of the **entire payload** after byte 19 |
177| 20… | Bounded metadata, then one `u32 length + raw bytes` chunk per asset in declared order |
178
179Metadata stores the validated name, provenance, `[male, female]` applicability flags and optional rig signatures, parts with per-sex bone paths/XYZ transforms/scalar specular/shiny index, normalized meshes (positions/normals/UVs/u32 indices/material diffuse/albedo index), and logical asset names. Asset bytes are omitted from JSON and carried in their binary chunks. Each part explicitly owns the indices of its original OBJ/MTL/declared dependency payloads. Removing a part or clearing/replacing a shiny override compacts unused assets and remaps all retained indices; adding another part cannot collide with an existing logical prefix. The reader rejects unknown fields/versions, truncation, checksum mismatches, trailing bytes, invalid links/numerics/geometry and excessive resources. CRC is corruption detection, **not authentication**.
180
181Limits: 32 MiB per complete container; 4 MiB metadata; 32 attachments; 32 raw assets of at most 8 MiB each; 80,000 total normalized vertices; bounded mesh/index counts; textures at most 2048×2048 and 64 MiB decoded total per preset. The shared CPU texture cache is capped at 128 MiB. Logical asset names are portable relative paths of at most 240 bytes; imports canonicalize dependencies within the chosen model directory and reject traversal/absolute references and symlink escapes. Assets are never extracted to disk on decode. Provenance is at most 4096 UTF-8 bytes; names are unique lowercase ASCII slugs of at most 64 bytes, excluding `body`. Imported duplicate names get a valid bounded suffix. Projects allow at most 64 presets, 64 MiB aggregate encoded preset data, a 4 MiB manifest and 64 KiB legacy JSON records; aggregate checks run before installing imports and before writing saved snapshots, and bounded reads preserve unloaded/error references rather than dropping them.
182
183Project save creates immutable `presets/<name>-<file-crc32>-<byte-length>.ppe` snapshots, then atomically saves the `.ppd` references. Flush a unique sibling temporary file, publish with a no-overwrite hardlink, and flush the directory on Unix. A filesystem without hardlink support fails safely; no unsafe overwrite fallback. Existing identical snapshots are reused, conflicting bytes never overwrite an old snapshot. Failure of the final manifest save leaves old references and bytes intact; unreferenced snapshots remain recoverable. Earlier JSON equipment records remain readable and preserved until replaced with a configured binary preset. New snapshots round-trip without consulting external model/texture files.
184
185## 10. Rendered 2D preview
186
187Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the validating `.ppda`/atlas reader (`atlas::AtlasSet`; identity + SHA-256), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation over exactly `frames/fps` seconds so every frame lasts `1/fps`; FPS and playback speed are explicit. Missing maps/frames show a diagnostic rather than silently using another animation. The **2D-preview** editor tab (`preview2d`) discovers completed runs under `renders/`, filters equipment by `CompatKey` / `assert_compatible`, and shows the frame with aspect-preserving nearest-neighbor letterboxing. View modes: **Resultaat** (relit composite), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map**, **Alleen specular map** (map modes show the depth-composited winning-layer map unlit). Light azimuth wraps full 360°; elevation stays −80…+80; light affects Resultaat only. Rim/edge lighting is removed (t958u). The library depth compositor and relighting API (`compositor`) consumes the safe map reader; lighting is preview-only and does not alter saved unlit diffuse.
188
189Left/right controls wrap among twelve yaw values. Up/down selects one of five pitch rows (−60…+60, start 0) with clamping, not cyclic hidden extra angles. The orientation zero/positive direction agrees with export. Changing angle preserves animation time. Holding light buttons rotates azimuth/elevation continuously in degrees per second using delta time; releasing, focus loss or pointer cancellation stops motion. Azimuth 0° is light from +Z (front); positive azimuth toward +X; elevation clamps −80…+80. These controls are continuous even though model view selection is discrete.
190
191Depth compositor (`src/compositor.rs`) selects the nearest valid surface per pixel from body and chosen equipment. Valid samples need non-zero depth alpha and non-zero diffuse alpha. Depths within `DEPTH_EPSILON_U16` (1 code) are ties; the lower caller-supplied layer index wins (body first, then equipment in stable name order). Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Alpha-edge policy: winner straight alpha is kept; losing layers never blend in — single-depth compositing cannot reconstruct arbitrary order-dependent translucency. `relight` / `compose_and_relight` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). Rim/edge lighting was removed (Tim t958u). It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u).
192
193## 11. Development workflow, quality and packaging
194
195Each feature starts on its own `feature/...` branch from current master. Implement its tests and acceptance criteria, run local checks, commit, then `git merge --no-ff` into master and push. No initial-phase MRs. Keep branch/feature and merge SHAs in the coordinator status file, report completion before the next feature. For this production-render task the latest explicit instruction overrides publication: merge/cleanup locally only, **no push, tags, releases or package builds**; the parent handles subsequent publication. Setup/design may use `feature/project-design`; establish a baseline master commit first if required to obtain a genuine two-parent merge. Never fabricate a merge on an unborn branch.
196
197After every merge, `README.md` describes only the current behavior: what the program can do and how to use it. It does not list absent features, a roadmap, or negative claims. A screenshot belongs in the README only when it shows how to use the program. This convention applies to every later feature. Design limits stay in this file.
198
199The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
200
201Live 3D preview, as built: the body screen draws the skinned glTF/GLB or FBX mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches −60/−30/0/+30/+60 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on +Z because glTF defines +Z as the front (Tim t991u). Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ or FBX equipment (§9); FBX bodies are drawn from the normalized `fbx_scene`. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
202
203CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
204
205Tests run on MR events (future-proof despite no current MRs), master pushes AND tags. Quality gates: `cargo fmt --check`, `cargo check --workspace --all-targets --locked`, `cargo clippy --workspace --all-targets --locked -- -D warnings`, `cargo test --workspace --locked`; feature combinations should be covered explicitly when introduced. Pin toolchain/components and package tooling. Core tests require no GPU; separate GPU integration tests may use a supported software adapter, with explicit results rather than silent skip-as-pass.
206
207Tag pipeline graph: quality/tests → four packaging jobs → artifact verification → one release publish. A failure of ANY required artifact prevents release. Build Linux x86_64 AppImage, Debian/Ubuntu .deb, Arch .pkg.tar.zst and Windows x86_64 .exe in a .zip. Verify target architecture and contents; packaging a Linux binary into a Windows archive is not a build. Prefer native Windows runner when available, or verified mingw target/toolchain with dependency support. AppImage and distribution packages need desktop integration/icon/license, runtime library audit and version mapping from tag to package version. Include checksums and build provenance; signing is optional future work, never invent signatures.
208
209Release publication uses documented BlueGit release/artifact API and job-scoped least-privilege credentials; no tokens in source/logs/status. All assets attach only after all outputs pass verification, retries are idempotent and do not delete unrelated releases. Distinguish local packaging smoke tests from a real successful tag/release pipeline. **No tag or release is authorized during setup.** A configured future tag trigger is not permission to create a tag now.
210
211Verified BlueGit behaviour, inspected 6 November 2026, is recorded in section 15. The configured tag pipeline calls that API. With the API as it exists today, publishing the same tag that started the pipeline is rejected. That limit is part of the design record, not a claim that a release was created.
212
213## 11a. Tim’s desktop workflow requirements (6 November 2026)
214
215These requirements are durable even where the implementation is staged. They supersede the earlier generic workspace layout.
216
217- Launch without a project shows a small start window with **Project laden** and **Nieuw project**. Both invoke an OS-native picker: choose an existing `.ppd` file or a new project directory. Cancellation keeps the current project and edits unchanged. A new project prepares the CC0 male/female fixtures in `assets/body/`; existing assets are never silently overwritten.
218- The first project view is **Bodies**, with male/female model slots and native asset-pick buttons. Navigation is **Bodies → body → Equipment → equipment configuration**. A body has the live orbit/zoom mesh preview, animation controls, a **Render** action and an **Equipment** action. Equipment list entries open their configuration; **Nieuw** creates a local definition. Keep body context when going back. Remove the old right-hand folder explorer entirely.
219- Closing the OS window or choosing another/new project with unsaved changes asks **Opslaan / Niet opslaan / Annuleren**. Failed saves do not close or switch. A canceled picker does not discard edits, even after choosing Niet opslaan: the actual replacement only happens after another project is loaded successfully. Asset adoption is copy-on-write so discarding metadata cannot secretly change an existing saved body. Equipment config snapshots remain immutable until the manifest’s atomic save succeeds; unreferenced snapshots are retained for recovery.
220- `.ppd` files support the file manager’s **Open with / Openen met** on every supported packaging platform (Windows, Linux AppImage, Debian and Arch). The process accepts a single project path, including spaces, and directly opens that project. Invalid files give an actionable error without corrupting the current session. Linux uses MIME XML, desktop `MimeType` and `%f`; portable binaries/AppImages provide `--install-desktop`. Windows registers a per-user OpenWithProgids entry and a quoted executable/file command, preserving the user’s existing default application. No administrator privileges are required for the Windows entry.
221- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
222- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
223
224Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 60 views with the six real pass previews and four target maps described above, then per-map atlases plus `.ppda`, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting and the 2D-preview GUI/tab/playback (view modes Resultaat/map-only, full-circle light azimuth, loop on frames/fps; rim removed per t958u) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
225
226## 12. Incremental feature plan and acceptance gates
227
2280. Foundation/design/CI: group and repo existence checks; private repo; single detailed design.md with verbatim appendix; minimal honest compileable Rust shell; locked dependencies/toolchain; genuine two-parent setup merge; tests and verified CI schema. No claim that rendering/import works. Test packaging scripts locally where possible without publishing.
2291. Project model and validation: .ppd open/save/new, portable paths, schema/version checks, model slots and render settings; atomic persistence/invalid paths tests.
2302. Normalized import and animation inventory: glTF/GLB fixtures first, required OBJ+MTL/textures and FBX adapter proof; format-specific truthful UI/errors and skeleton/clip inventories; integration fixture tests before declaring format support.
2313. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test.
2324. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests.
2335. Shared batch camera/sampling and raw buffers: 60 views (t955u; was 36), common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing.
2346. Production image pipeline: detail-aware downsampling, depth/normal resolve, shiny conversion, filename/manifest export, progress/cancel/resume (resume **done**, §7); compare actual output and memory budgets. (No rim accents; preview uses relight only — t958u.)
2357. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping on frames/fps, sex/equipment/clip selection, view modes (Resultaat + map-only), discrete view controls, continuous held light controls with full-circle azimuth; deterministic state tests and GUI smoke.
2368. Packaging/hardening: verify all platform packages and install/run behavior, imports with native dependencies, accessibility and failure recovery; actual releases only on later explicit tag authorization.
237
238Every feature is independently reported and merged non-FF only when its acceptance gate passes. If compatibility or rendering remains untested, name that limitation and do not merge a false completion claim.
239
240## 13. Open decisions and risks
241
242- Confirm target resolution/FPS defaults, output color treatment, root-motion policy, front-axis and positive-yaw convention with a labelled fixture before production content.
243- FBX exporter variants/native Assimp packaging and skeletal animation fidelity are high-risk; prototype early rather than after UI completion.
244- Eightfold dimensions impose 64x pixels; support tiling or reject unsupported render sizes with estimates, not GPU crashes.
245- Shared framing/depth/normal contracts are essential; independently normalized equipment maps cannot be depth-composited correctly.
246- Alpha-blended materials cannot be fully represented by one surface depth; document cutout/opaque MVP and approximation.
247- Bone attachment supports rigid equipment, not automatic skinning/retargeting; expose that limit.
248- glTF PBR/FBX legacy materials to a single shinymap is lossy; retain source metadata and editable conversion settings.
249- Package version mapping, Windows runner/toolchain and BlueGit group runner availability must be verified, not assumed.
250- Keep model provenance/licensing with embedded presets; importing assets does not grant redistribution rights.
251
252## 14. Research references and verification limits
253
254- Bevy project and documentation: https://bevy.org/ (preferred-stack proposal; exact dependency versions and API compatibility still require implementation verification).
255- Assimp repository: https://github.com/assimp/assimp (candidate broad-format import dependency, not universal file compatibility guarantee).
256- Rust Assimp binding docs inspected: https://docs.rs/russimp/latest/russimp/ (reported russimp 3.2.1 at inspection; no build/compatibility test performed).
257- glTF specification reference: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html (canonical interchange recommendation; implementation must test supported subsets).
258- BlueGit CI specification: `GET https://git.timfalken.com/api/v1/ci/spec` and `docs/CI.md` in `timfa/bluegit` (file name `blue-ci-cd.yml`, job fields, variables, release API).
259- Working pipelines read in public repositories: `timfa/posprint` `blue-ci-cd.yml` and `scripts/ci/release.sh`; `timfa/minecart-trains` `blue-ci-cd.yml`; `timfa/click-tween` `blue-ci-cd.yml` and `scripts/release.sh`.
260- Release implementation read in `timfa/bluegit` `internal/server/ops_ci.go` (`opPublishRelease`) and `internal/server/release_replace.go` (`checkReplace`).
261
262## 15. Verified BlueGit CI and the release-trigger limit
263
264Inspected sources, all public on 6 November 2026:
265
266| Source | What it shows |
267|---|---|
268| `GET /api/v1/ci/spec` and `timfa/bluegit` `docs/CI.md` | The pipeline file is `blue-ci-cd.yml`. Jobs may be top-level. `rules` / `only` / `needs` / `artifacts.paths` / `artifacts.expire_in` / `extends` / `resource_group` / `workflow` are real fields. `CI_COMMIT_TAG` is empty on a branch and `CI_COMMIT_BRANCH` is empty on a tag. `CI_JOB_TOKEN` exists only inside a job container. Image presets include `bluegit/base` and `bluegit/rust` (Rust 1.81). Any other image name, such as `rust:1.83-bookworm`, is a normal Docker image. |
269| `timfa/posprint` `blue-ci-cd.yml` | Tests on merge requests and on `master`. Package jobs use `needs: [test]`. Artifacts are exact file paths, not globs. The release job runs on `master` and calls `POST $CI_API_URL/repos/$CI_PROJECT_PATH/releases` with `Authorization: Bearer $CI_JOB_TOKEN`. Tag pushes are disabled with `if: $CI_COMMIT_TAG != ""` / `when: never`. The comment in that file says tag pipelines must not rebuild old tags. Windows is cross-compiled with MinGW because that comment states there is no Windows runner. |
270| `timfa/posprint` `scripts/ci/release.sh` | The script says the release API creates the tag itself. It refuses to release when `HEAD` is already tagged. |
271| `timfa/minecart-trains` `blue-ci-cd.yml` | States the GitLab project used to release on a new tag, and that on BlueGit the release API creates the tag, so tag pushes start no pipeline. |
272| `timfa/click-tween` `blue-ci-cd.yml` | A tag pipeline may build and verify. The job that publishes the release is manual on `master` and creates the tag through the API. |
273| `timfa/bluegit` `opPublishRelease` | If the tag already exists and `replace` is not set, the API returns HTTP 409 (`err.tagExists`). `replace: true` is checked by `checkReplace`, which rejects a tag pipeline (`err.replaceRef`, HTTP 403). A tag pipeline therefore cannot attach assets to the tag that started it, and cannot replace that release either. |
274| `timfa/legacy-cycles` | The project page redirects to releases and says only the releases are public. `GET /api/v1/projects/timfa/legacy-cycles/repository/tree` and the CI file returned 404. The public release assets for v0.0.38 are a Debian package, an Arch `.pkg.tar.zst`, a Windows zip, and Linux tar archives. No AppImage is listed. Its pipeline YAML was not available to copy. |
275
276Consequences for `blue-ci-cd.yml` in this repository:
277
2781. Syntax is limited to fields confirmed in the CI spec or in the pipelines above. No GitLab-only keyword is added (`pages`, `spec`, child pipelines, `id_tokens`).
2792. Tests run for merge-request pipelines, for pushes to `master`, for pushes to `main`, for `CI_DEFAULT_BRANCH`, and for tags. This workspace's default branch is `main`. Tim's existing BlueGit projects use `master`. Covering both avoids a silent skip after the project moves.
2803. On a tag push the four package jobs run only after `test`, then `verify:packages` checks magic bytes so a Linux executable is not renamed into the Windows zip. `release` needs `verify:packages`, so any failed package skips publication.
2814. `release` runs `scripts/ci/release.sh` after the four packages and `verify:packages`. That script does not call the create-release route and does not send `replace`. A second inspection of public BlueGit master (below) found no way to attach artifacts to the tag that already started the pipeline. The script exits 3 with `release-blocked: no-attach-endpoint`. That is a refused publish, not a created release. The tag order stays a tag push. No tag is created by this repository.
2825. Package scripts map a tag `vX.Y.Z` or `X.Y.Z` to package version `X.Y.Z`. Other tag spellings fail the package job instead of inventing a version.
2836. The Rust image is `rust:1.96-bookworm`. That tag is the one `timfa/posprint` already runs on BlueGit. `rust-toolchain.toml` pins the same compiler, 1.96.0, which was installed here with rustup. The machine default before that pin was 1.83.0. 1.83.0 cannot resolve the current eframe 0.29.1 graph: several transitive crates require edition 2024, which Cargo 1.83 does not parse. `bluegit/rust` is Rust 1.81 on Alpine and is the wrong image for this X11 shell. `bluegit/base` is used for the verify and release jobs, as in posprint.
2847. posprint states BlueGit does not set `CI_PROJECT_DIR` and that jobs start in the checkout. Scripts use `$PWD`.
285
286The toolchain is Rust 1.96.0, edition 2021. Edition 2021 remains explicit; the crate does not opt into edition 2024. `Cargo.lock` is committed. `cargo fmt`, `cargo check`, `cargo clippy -D warnings` and `cargo test` are the test job. No GPU test is required for the foundation shell.
287
288### Release routes that exist, and the missing attach call
289
290Rechecked against public `timfa/bluegit` master (`internal/server/api_routes.go`, `releaseRequest` in `api.go`, `opPublishRelease` in `ops_ci.go`, `checkReplace` in `release_replace.go`, `gitrepo.Tag` in `internal/gitrepo/repo.go`) and `GET /api/v1/openapi.json`. No PUT or PATCH release route exists. `releaseRequest` has `tag`, `commit`, `name`, `body`, `replace` and `assets`. There is no field that means "use the tag that is already there". `encoding/json` drops unknown fields, so a new client flag would still reach `HasTag` and return 409.
291
292| Method | Path | What it does |
293|---|---|---|
294| GET | `/api/v1/projects/{owner}/{repo}/releases` | List releases. |
295| POST | `/api/v1/projects/{owner}/{repo}/releases` | `createRelease` with a user token. `opPublishRelease` copies job artifacts, then `gitrepo.Tag` runs `git tag -a`. |
296| GET | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Download one asset. |
297| DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}` | Delete the release. Query `delete_tag=true` also deletes the git tag. |
298| DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Delete one asset. The tag stays. This does not upload. |
299| POST | `/api/v1/repos/{owner}/{repo}/releases` | Same `createRelease`, and the route a job token may call. `replace: true` is allowed only by `checkReplace`: running job of this project, pipeline on an unprotected branch, not `forks/*`, actor maintainer or higher, existing release made by a job on that same branch. A tag pipeline is rejected with HTTP 403. |
300| POST | `/api/v1/projects/{owner}/{repo}/repository/tags` | Create a git tag. No release assets. |
301| GET | `/api/v1/projects/{owner}/{repo}/pipelines/{id}/jobs/{job}/artifacts` and `.../artifacts/{path}` | List or download a job artifact. Not a release. |
302
303Minimal server change, not applied in this repository: add `POST /api/v1/repos/{owner}/{repo}/releases/{tag}/assets`, job token only. Body: `commit` (40 hex), optional `name` and `body`, and `assets` as `{job, path, name}` already used by `opPublishRelease`. Do not call `gitrepo.Tag` and do not accept `replace`. Return 404 when the git tag is absent. Return 409 when the tag points at a commit other than `commit`. Require a running job in this repository whose pipeline ref is `refs/tags/{tag}` and whose `CI_COMMIT_SHA` equals `commit`. Resolve each asset with `Store.ArtifactPath` from that same pipeline and copy it under `releases/{owner}/{repo}/{tag}/{name}`. If no release row exists, insert one for the existing tag. If a release row exists from a job on this same tag, replace only those named asset files after the new copies succeed, and leave the git tag and every other release alone. If the release was made by hand or from another ref, return 409 and change nothing. Reject asset names that contain `/` or `..`. Until that route exists, this project's tag job cannot publish.
304
305## 16. Synthetic test bodies
306
307`tests/fixtures/bodies/test-body-male.glb` and `test-body-female.glb` are generated by `scripts/generate_test_bodies.py`. No external model is downloaded. Both files are self-contained glTF 2.0 binary, CC0-1.0, and labelled in `asset.extras` as test assets with `finalCharacter: false`. They are not final characters.
308
309Each file has one skin, 19 named joints, rigid skin weights, and two clips: `walk` (1 second, arms opposite the legs) and `attack` (0.8 seconds, the right arm swings forward). Bind rotations are identity. The documented axes are +Y up and forward +Z (glTF convention), left side at +X. Male and female differ in shoulder width, hip width and hip height so a later importer can tell them apart.
310
311`tests/test_body_assets.rs` checks that both files exist, parse as GLB, contain that skeleton, contain both clips, and byte-match a fresh run of the generator. That test is a fixture check. It is not the product importer, it does not play the clips, and it does not render. The current editor imports and animates these CC0 fixtures; native GPU and batch probes test actual rendering separately from this asset-structure test.
312
313# Appendix A — Tim's original proposal (verbatim)
314
315The following block preserves the original Dutch text including spelling and filename placeholders. It is not a paraphrase or implementation status.
316
317```text
318Ik heb een idee voor een projectje;
319Een Paperdoll Renderer.
320
321Het idee;
322We importeren twee menselijke 3D-modellen; een man en een vrouw. Die hebben een set animaties, zoals lopen, springen, bukken, aanvallen, blokkeren, geraakt worden, etc. (we lezen gewoon alles uit het model)
323
324Bij renderen hebben we twee modussen: Karaktermodel, en uitrusting (hier later meer over). Als je rendert, dan hebben we 3*12 posities; 3 pitches (-30 graden, 0 graden, +30 graden), en 12 yaw (beginnend met recht naar de camera kijken en dan steeds een stapje ronddraaien tot we alle hoeken hebben). We renderen die naar PNG's met de naam: <geslacht>-<settype>-<animatienaam>-<pitch>-<yaw>-<maptype>-f<framenummer>.png met een vooringestelde (hoge, acht keer de doelresolutie) resolutie en framerate.
325We pakken dan die hoge resolutie, en maken een detail-map; dat is effectief een edge-detection algoritme. We schalen de paperdoll dan omlaag naar de doelresolutie, en we gebruiken de detail-map om pixels extra gewicht te geven om te kiezen welke kleur we een pixel geven.
326Daarna maken we een depth-map op de doelresolutie, en een normal map. (de maptypes in de filename zijn dus: diffuse, depth, normal)
327Als we de normalmap en depthmap hebben, doen we nog een pass over de diffuse om randen een beetje te highlighten. Daarna slaan we de hele set op volgens het afgesproken naamformat (allemaal op de doelresolutie). Dit is is settype=body.
328
329Daarna hebben we ook equipment. Je kiest een geslacht, en je moet per lichaamsdeel (op de skeleton van het karaktermodel) een 3D-model kunnen importeren, en op de bone kunnen positioneren. Als je alle modellen van een stuk equipment op de juiste positie gezet hebt, kun je dat opslaan als een preset (binary file met de modellen en posities op bones, alsmede een flag voor of het male of female is). Je kunt hem ook renderen; dan kies je een naam voor je equipment. Die naam is de settype; allemaal lowercase, geen spaties toegestaan. Dit rendert dan nogmaals alles, net als bij de body, alleen is de body dan doorzichtig, waardoor je alleen de equipment-modellen tekent. Equipment heeft ook een shinymap; dat is hoeveel specular een pixel moet hebben voor later bij de renderer.
330
331De bedoeling is dat we die paperdolls later kunnen gebruiken om een renderer te schrijven. Die gebruikt de diffuse maps en laagt met equipments over elkaar; het gebruikt de depth-map om te bepalen welke pixel van welke map (body of equipment) getekent moet worden (soort z-check), en de normal map om bumpmapping en lighting te tekenen. Dat wordt dan deel van een character-renderer en controller.
332
333Als stap 1 wil ik graag de paperdoll renderer maken. Ik heb nog geen modellen, die moet ik dus later zelf in het programma kunnen selecteren. Voor positioneren op bones zijn XYZ voor relatieve positie en rotatie voldoende voor nu, met dan een live preview waarop je het lichaam ziet, die je kunt roteren (3D model view, niet de gerenderde plaatjes want die zijn er dan nog niet).
334
335Wanneer je sets maakt, is dat in een .ppd (PaPerDoll) project; een map met een .ppd file die verteld welke modellen gebruikt worden, welke binary files van equipment sets er zijn, en waar gerenderde output staat.
336Zo kunnen we ook een preview tab maken; kies een geslacht, kies equipments om te layeren, en kies een animatie. Je ziet dan de animatie op loop spelen (de gerenderde 2D paperdolls), en kunt met pijltjes orbiten (boven, midden, onder, en dan voor links en rechts de 12 rotaties). Ook moet je met knopjes die je ingedrukt kan houden de positie van de lichtbron kunnen roteren. Dat gaat in graden, niet in stappen.
337
338Stappenplan:
339
340- Maak een groep op BlueGit: `Golden Age`
341- Maak de repo, Paperdoll Renderer
342
343- Documenteer alle bovenstaande, incl. letterlijke tekst van mij, zeer gedetailleerd in een enkele design.md
344- Bouw dan alle features een voor een in Rust. Elke feature op een nieuwe branch, en als die feature af is, niet-FF mergen naar master (Geen MR's tijdens eerste ontwikkelfase) en door naar de volgende feature. Elke feature krijgt een unit test die we in de CI-CD activeren op een merge request (ook al gebruiken we die nog niet) en op een master push. Een tag push krijgt, als tests slagen, ook een build; AppImage, Ubuntu Package, Arch Package, Windows Build, welke we dan als een release pushen.
345
346Houd me bij elke feature op de hoogte hoe het gaat.
347```
348
349# Appendix B — Additional import requirement (verbatim)
350
351```text
352Bepaal ook welk 3D-model format goed is; liefst ondersteunen we natuurlijk meerdere. In ieder geval .obj (met mtl en texture) en .fbx, maar hoe meer hoe beter.
353```
354
355## Decision 2026-11-07 (Tim t955u): pitch ±60, per-map atlases, `.ppda`, default 4 fps
356
357- **Pitches:** production renders pitches **−60, −30, 0, +30, +60** × twelve yaw values (0…330, step 30), so **60 views** per sample. This supersedes the earlier 36-view (three pitches) wording wherever this document mentions it. The 2D preview's up/down control clamps across the five pitch rows.
358- **Atlases:** in addition to the loose PNGs (which stay), each completed export writes one atlas per map type (diffuse, normal, depth, specular), containing every angle of every frame of every clip. Name: `<sex>-<settype>-<maptype>-atlas.png`, following the convention of the loose images. Cells are exactly the target resolution, with no padding or scaling. Layout is deterministic: one row per (clip, frame) in source clip order and frame order; columns are pitch (−60→+60) × yaw (0→330). All four atlases share identical cell coordinates. Atlases are written atomically, do not overwrite existing files, and are only written after the loose maps are complete (including after a resume). When the atlas dimensions would exceed the PNG/GPU limit, export fails with a clear message rather than silently splitting the atlas.
359- **`.ppda` (PaPerDollAtlas):** a strict UTF-8 JSON file next to the atlases, named `<sex>-<settype>-atlas.ppda`. Fields: `format_version` (1), `sex`, `settype`, project id/name, `fps`, `cell_width`/`cell_height`, `pitches` and `yaws` (degrees, in column order), a `maps` object mapping each map type to its atlas PNG filename plus SHA-256, and `clips`: per clip its source index, name, `frame_count` and `duration`, with each frame listing `{pitch, yaw, x, y, w, h}` for every view in atlas pixels. Unknown fields or versions are rejected when read.
360- **Default framerate:** new projects default to **4 fps** instead of 12. Existing `.ppd` files keep their stored `fps`.
361- **`.ppda` as the consumer format (Tim t957u):** the `.ppda` plus its atlases is the primary format for consumers. The editor's 2D previewer loads an export through the `.ppda` and its atlases rather than the loose PNGs, and the eventual game renderer uses the same format. The loose PNGs stay available as export output. The `.ppda` reader validates the version, filenames (no paths outside the export folder), atlas dimensions, cell bounds and SHA-256 before use.
362
363**Implemented (9b, `atlas` module):**
364- Production plans 60 views per sample (`preview::PRODUCTION_PITCHES` × `production_yaws()`); `render.json`/`complete.json` are output schema 4. New projects default to `project::DEFAULT_FPS = 4`. The legacy `.ppd` shape-validation memory heuristic keeps its factor 36 so 256×256 targets stay valid (production streams one view at a time).
365- Location: atlases and `.ppda` sit in the run root (`renders/run-…/male-body-diffuse-atlas.png`, `…/male-body-atlas.ppda`); one run holds one sex/set.
366- Write order after the last loose PNG is verified (fresh or resumed run): four atlases (diffuse, normal, depth, specular) → `.ppda` → `complete.json` (lists the atlas/`.ppda` names + SHA-256). Each atlas is streamed one cell row at a time from the checksum-verified loose PNGs (re-decoded under the map pixel contract), hashed while written, published via temp file + fsync + no-clobber hard link. If an atlas or `.ppda` already exists (crash after publication), it is kept when byte-identical to the regenerated output (encoding is deterministic) and otherwise moved aside as `<name>.corrupt-aNNN-K`. Cancellation is checked per row; `cancelled.json`/`failed.json` follow the normal rules.
367- Limit: `MAX_ATLAS_DIM = 16384` per side. `production::contract` refuses the job before rendering when 60 × cell width or rows × cell height would exceed it; atlases are never split.
368- Diffuse atlas carries the sRGB chunk like the loose diffuse maps; depth atlas is 16-bit gray+alpha; normal/specular RGBA8.
369- `.ppda` fields as listed above, with two additions needed by consumers: `identity` (body/equipment SHA-256, framing, pivot-source kind, near/far, camera distance, normal and depth encoding) so body and equipment atlases can be checked for compositing compatibility without the run manifests, and per-clip `token`; each frame is `{index, views:[{pitch,yaw,x,y,w,h}×60]}`; `maps.<map>` also records `width`/`height`. All structs use `deny_unknown_fields`.
370- Reader `atlas::AtlasSet::open`/`open_run` (exactly one `*-atlas.ppda` per folder): bounded read, strict JSON, version 1, name = `<sex>-<settype>-atlas.ppda`, sex/settype/project/identity/fps/cell/pitch/yaw checks, every view must equal the deterministic grid cell (implies in-bounds), map file names must equal `<sex>-<settype>-<map>-atlas.png` (no paths), regular non-symlink files inside the folder, SHA-256, PNG header size/colour type. `load()` decodes all four with the loose-map pixel rules (2 GiB decoded budget per set); `LoadedAtlas::cell` crops one view. `CompatKey::from_ppda` + `assert_compatible_keys` and `LayerMaps::from_atlas` feed the compositor. `atlas::cross_check_run` compares every cell with its loose PNG (used by the GPU proofs).
371- Preview2d discovers runs through `AtlasSet::open_run` (runs without a valid `.ppda`, including older schema-3 runs, are skipped and counted with reasons), decodes the selected body + equipment once and caches them until the selection or catalog changes.
372
373## Decision 2026-11-07 (Tim t958u): full-circle light azimuth, rim lighting removed
374
375- **Light azimuth:** in the 2D previewer, holding the light buttons rotates the azimuth through the full 360 degrees and keeps going (wrapping modulo 360, no clamp). Elevation keeps its −80…+80 clamp.
376- **Rim/edge lighting removed:** the consumer-side rim/edge lighting (§8/R08, the **Randlicht** slider, `RimParams`/`apply_rim`/`compose_relight_and_rim`) is removed completely because it does not look good. Relighting is ambient + Lambert + Blinn-Phong from the normal, depth and specular maps only. Saved diffuse stays unlit albedo. This supersedes the earlier rim decision.
377
378## Decision 2026-11-07 (Tim t960u): 2D previewer view modes
379
380The 2D previewer gets a mode picker: **Resultaat** (default; the real relit composite as the game would show it), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** and **Alleen specular map**. The map modes show the depth-composited map of the winning layer per pixel directly as the image, unlit, so you can inspect each exported map. Depth is shown as grayscale and normal as its stored RGB encoding. Playback, view selection and equipment selection work the same in every mode; the light controls only affect Resultaat.
381
382## Decision 2026-11-07 (Tim t961u): 2D playback loops on whole frames
383
384Cause found: the 2D preview wrapped its clock on the source clip duration, while the export holds `ceil(duration × fps)` frames. As a result, the last frame of the loop was shown for only `duration − (frames−1)/fps` instead of a full `1/fps`. For example, at 4 fps a clip of 1.1 s showed its fifth frame for only 0.1 s. Fix: playback loops over exactly `frames / fps` seconds, so every frame, including the last, is shown for an equal `1/fps`. A regression test covers a non-whole duration.
385
386## Decision 2026-11-07 (Tim t963u/t964u): narrow fixed depth range per body; light hold-button fix
387
388- **Depth range:** the exported depth no longer uses the global near 0.05 / far 40, where a body filled only about 1.3% of the 16-bit range. Each sex gets a **fixed, narrow depth interval around the orbit pivot**, derived deterministically from its fixed framing: `near = camera_distance − R`, `far = camera_distance + R`, where R is a half-depth derived from the framing height (default R = framing height × 0.5, at least 0.5 m). For the default 2.6 m framing, that is roughly 8.7 m to 11.3 m. All clips, frames, views and the equipment of that sex share exactly the same interval, so depth compositing stays consistent. Geometry outside the interval is clamped and reported as a diagnostic, never silently rescaled per frame. The encoding stays `distance = near + (gray/65535)·(far−near)`. near/far per sex are stored in `render.json`, the export contract and the **`.ppda` metadata** (`depth_near`, `depth_far`, `depth_encoding`), so a renderer can convert depth back to distance. Exports that use the old global range are recognized as such through their contract and are not mixed with exports that use the new range.
389- **Light hold buttons:** holding Azimut ± / Elevatie ± in the 2D previewer stops after about 1 second. The likely cause is egui's click-sense, which stops reporting a press as `is_pointer_button_down_on` once it lasts longer than a click. Fix: drive holds from the raw pointer state (primary button down, press started on the button, pointer still over the button), so a hold lasts as long as it is held. Release, losing focus or the pointer leaving the window still stop it. Add a test that a hold of several seconds keeps rotating.
390
391## Implementation note 2026-11-07 (9f/9g)
392
393- **Depth half-range R amended:** with pitch ±60, `R = height × 0.5` clamped about 14% of the covered pixels at pitch −60 on Tim's planar male rig (feet below the neck pivot come towards the camera). R is now `max(height × 0.5 + |pivot_y − center_y|, 0.5 m)`: the radius from the pivot to the farthest frame edge, still derived only from the fixed per-sex framing. Default framing with pivot = center keeps 8.7–11.3 m; with a 1.46 m neck pivot it is 8.24–11.76 m. On the planar rigs nothing clamps and covered depth spans roughly gray 28–222 of 255 (previously only a few gray tones).
394- Clamping uses GL depth clamp (out-of-range geometry is kept at near/far, not clipped away); covered highres pixels on a plane are counted, shown as a progress warning and stored in `complete.json` `depth_clamped`.
395- render.json schema 5 (`near`/`far` = `depth_near`/`depth_far`, `depth_encoding`); `.ppda` format_version 2 with top-level `depth_near`, `depth_far`, `depth_encoding` (identity.near/far kept equal). Readers recompute the range from the recorded framing and reject mismatches; schema 4 / `.ppda` v1 (global 0.05–40 m) are refused with a "re-render" message, so old and new ranges are never mixed. The compositor and 2D preview use the per-run near/far.
396- Light hold buttons: held = primary down ∧ press origin on the button ∧ pointer over the button (`preview2d::raw_pointer_hold`); the egui test reproduces the old drop after ~0.8 s and passes a 3.5 s hold with the new logic.
397
398## Decision 2026-11-07 (Tim t966u): "Render alles" button
399
400A **Render alles** button renders the whole project in one batch: every configured body (male and female, when present) and then every configured equipment preset for each applicable sex. Each item gets its full production export (all clips × frames × all views, four maps, atlases + `.ppda`) at the project's target resolution and framerate. It is a queue of the existing per-item jobs, sharing the same progress/preview, cancel and resume behavior. Overall progress shows the item and pass counts. Items that cannot be rendered (no body, equipment without a binding for that sex, invalid setup) are skipped with a clear diagnostic per item, and the rest of the queue continues. Afterwards, a summary lists what was rendered, what was skipped and what failed.
401
402Live preview during Render alles (Tim t990u): the live render pass preview stays visible and updates exactly as it does when rendering a single body or equipment set (same highres/detail/target passes, same progress bar and cancel button), following whichever sex/set is currently rendering; the current item (sex · set, item k/n) is shown above it.
403
404## Decision 2026-11-07 (Tim t968u): one result folder per sex + settype, newest only
405
406Exports no longer pile up as `renders/run-<id>/` folders. Each result lives in `renders/<sex>-<settype>-atlas/` and holds only the newest complete render (loose PNGs, atlases, `.ppda`, manifests). A new render is built in a temporary in-progress folder inside `renders/` (also used for resume). Only after it is fully complete and verified is the previous `<sex>-<settype>-atlas/` removed and the new folder renamed into place. A crash or cancel therefore never loses the last good result, and an unfinished run can still be resumed. Older `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a notice. The 2D preview and **Render alles** use these fixed folders.
407
408Implementation (2026-11-07): the build folder is `renders/inprogress-<stamp>-<pid>-<sex>-<settype>/` (the only resumable kind). After `complete.json` the run is re-validated with `AtlasSet::open_run`, its lock released, the old result renamed to `.replaced-<name>-<stamp>`, the build renamed to `<sex>-<settype>-atlas`, then the old one removed (restored if the rename fails). `resume::recover` runs before every render: a `.replaced-*` folder is restored when its result folder is missing (else removed), and a completed, unlocked, validating `inprogress-*` folder is promoted. The 2D preview only opens `*-atlas` folders whose name matches their `.ppda` sex/settype; `run-*` folders are skipped with a notice; `inprogress-*`/`.replaced-*` are ignored silently.
409
410## Decision 2026-11-07 (Tim t984u, amended t985u): default and configurable depth range
411
412Supersedes the `R = half render height + |pivot − centre|` rule. (1) The default per-sex depth range is the **T-pose arm span** (fingertip to fingertip, measured in the bind pose) **plus one arm length of margin on each side**, centred on the pivot depth: `total = span + 2·arm`, `near = D − total/2`, `far = D + total/2` (camera distance D = 10 m). This covers the model in any rotation plus long weapons. Implementation (pose-independent, so A-/I-pose binds give the T-pose value): with left/right upper-arm and hand joints identified by name, arm = |upper arm → hand| (rest pose, mean of both sides) × 1.2 for hand and fingers, and span = shoulder width (distance between the upper-arm joints) + 2·arm. Without those joints (incl. static bodies): span = max(bind-pose width, height), arm = 0.4·span. Test bodies (arms down): male ≈ 3.15 m total (near 8.42 / far 11.58 m), no clamped pixels. Geometry still outside the range is clamped and reported (progress + `complete.json` `depth_clamped`). (2) The range is configurable per sex in the `.ppd` (`depth_range_milli`: optional total range in millimetres, centred on the pivot) and editable in the GUI next to framing ("Dieptebereik", with an "automatisch" choice). It stays fixed for all frames, views and equipment of that sex and is stored in `render.json` and `.ppda` `depth_near`/`depth_far`; readers use the recorded values (validated: finite, 0 < near < far, equal to identity near/far) instead of recomputing them.
413
414## Decision 2026-11-07 (Tim t980u, amended t981u/t983u/t987u): depth bit depth + depth encoding
415
416Two project settings in `.ppd` (`render`): `depth_bits` = **8 (default)** or 16, and `depth_encoding` = **`gray` (default)**, `rgb_bands` or `rgb_packed`. The default export is therefore 8-bit gray. With M = 2^bits − 1 (255 or 65535), B = 2^bits and `t = (d − near)/(far − near)` clamped to 0..1:
417- `gray`: gray+alpha PNG at that bit depth, `gray = round(t·M)`; decode `t = gray/M`.
418- `rgb_bands`: RGBA PNG at that bit depth, `v = round(t·3M)`, `R = min(v, M)`, `G = clamp(v − M, 0, M)`, `B = clamp(v − 2M, 0, M)`; decode `t = (R+G+B)/(3M)` (3M+1 levels; 766 at 8-bit).
419- `rgb_packed`: RGBA PNG at that bit depth, positional base B over three channels: `v = round(t·(B³ − 1))`, `R = v / B²`, `G = (v / B) mod B`, `B = v mod B` (8-bit: 24-bit value, `R = v>>16`, `G = (v>>8)&255`, `B = v&255`; 16-bit: 48-bit value); decode `t = (R·B² + G·B + Bc)/(B³ − 1)`. **rgb_packed requires nearest sampling, no mipmaps/filtering and lossless storage** (no texture compression); any interpolation between texels or lossy compression corrupts the value.
420Alpha is always kept (scaled to the bit depth). Distance = near + t·(far − near). `render.json` and the `.ppda` record `depth_bits` and `depth_encoding`, so the game renderer and the 2D previewer decode all six combinations; the previewer/compositor support all of them. Older `.ppda`/`render.json` without `depth_bits` are read as 16-bit gray (their actual format). Both settings are part of the render contract (changing them is a contract difference for resume).
421
422## Decision 2026-11-07 (#12 glTF fidelity, implementation choice)
423- A skinned glTF body loads every mesh node and every skin; joints of all skins are merged into one skeleton (shared joints once, max 128).
424- A mesh node without a skin moves rigidly with its nearest joint ancestor (else the first joint); its rest-pose scene placement is baked once so it is exact at rest.
425- KHR_texture_transform on the base-color texture is baked into the UVs at load. Sampler wrap modes stay unread (nearest + REPEAT everywhere); metallic/roughness, normal/occlusion/emissive textures and morph targets stay unused, body specular stays zero. These limits are documented in the README rather than approximated.
426
427## Decision 2026-11-07 (#13 orientation + envelope, implementation choice)
428- Orientation convention fixed and test-guarded with labelled markers: +Y up, character faces +Z, its left is +X (glTF convention; originally −Z, superseded by Tim t991u); yaw 0 = front (character's right on image left), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above.
429- "Omhullende animaties" is a one-shot, user-triggered button: height and center Y are set so every clip, every frame at project fps and all 60 views fit, +5% margin per side; the pivot is unchanged. Framing stays fixed afterwards: no per-frame, per-clip or per-layer autozoom. Equipment allowance stays manual. Root motion stays retained, so a travelling clip widens the envelope.
430
431## Decision 2026-11-07 (#14 robustness gate, implementation choice)
432- Robustness acceptance is an automated, deterministic mutation suite (`tests/robustness.rs`): corrupt/truncated/mutated glTF/GLB, FBX, OBJ bodies, .ppe presets, .ppd manifests and .ppda files must yield an error or a valid result, never a panic or a non-finite body.
433- glTF loading rejects non-finite positions/normals/weights, mismatched attribute lengths and out-of-range indices; panics inside the gltf parser are caught and reported as load errors (editor import overview included).
434- Not covered (no automated gate possible here): accessibility, real OS file pickers and non-Linux platforms; these stay manual acceptance items.
435
436## Decision 2026-11-07 (Tim t965u): orientation and animation corrections on source bodies
437Findings on Tim's placeholders (maleb/femaleb.glb, Blender glTF export), verified independently in Python against the glTF spec. The renderer's joint maths matched the spec exactly, so the faults are in the source data:
4381. The meshes face +Z (toes in front of the ankles, nose/chest at +Z; the glTF default), while the renderer assumes −Z, so y000 showed the back.
4392. The foot chains (footControl → footArch/footIK/footToes) hang under the root (an IK rig) and carry only constant keys, so the feet stay planted while thigh and calf rotate. The legs stretch into strange shapes.
4403. The baked thigh/calf/arm motion bends the knees backwards relative to the mesh's own front (32/32 and 40/40 measured poses) and swings the attack arm behind the body. The motion is front/back mirrored relative to the mesh.
441
442Decision (superseded and reverted per Tim t991u, see below): automatic per-body detection at load (`orient::detect`), applied in one place (`preview::posed_worlds`) so live, still, production and equipment agree:
443- Forward from the feet shape (lowest 4% vs 6–10% of height, ≥1000 vertices, ≥1% of height difference). +Z ⇒ turn the body 180° about Y. Static bodies get their geometry turned directly.
444- A static (non-animated, at-rest) joint chain whose rest origin lies on the bone axis at the end of an animated leaf bone follows that bone rigidly.
445- Knee-direction check over all clips at 24 samples/s: at least 4 wrong poses and more than 3× as many wrong as right ⇒ mirror the motion front/back. Per-joint world delta D = W·W0⁻¹ becomes S·D·S (S = diag(1,1,−1)), so the rest pose is unchanged.
446- The notes are shown in the editor. There is no manual override yet; the synthetic test bodies are guarded to stay uncorrected.
447
448## Decision 2026-11-07 (Tim t991u): models and animations load as-is
449- The program does no model or animation fixes. Bodies, equipment and clips are loaded exactly as the file describes them: no facing flip, no reattaching of static joint chains (IK feet), no front/back motion mirroring, no other heuristic correction.
450- If a model or animation looks wrong, the animator fixes the source file. The t965u findings above (IK feet without keys, mirrored knee motion) stay source-data issues for the animator.
451- The t965u automatic corrections (`orient.rs`, the editor "Automatische correctie" notes, their tests and README text) are removed.
452
453## Decision 2026-11-07 (Tim t991u): front follows the glTF convention (+Z)
454- This is a convention, not a model fix. glTF 2.0 §3.4 says +Y is up, +Z is forward and −X is right, so the front of an asset faces +Z. The camera follows that for every model: yaw 0 puts the camera on +Z and looks at the front. The character's left side (+X) is at yaw 90, the back at 180 and the right side at 270. Positive pitch looks from above, and light azimuth 0 is also the +Z front.
455- This supersedes the earlier −Z convention (t952u fixed framing, #13 orientation). The bundled synthetic bodies (generator scripts, test-body glbs/fbx, static glb/obj) have been regenerated to be spec-correct: front +Z, left limbs at +X, walk/attack rotations mirrored to match, extras `forward: "+Z"`. The labelled-marker orientation tests, preview/still tests and the binary preset snapshot follow the new convention.
456- FBX goes through the same axis conversion as before. A model that faces another way is shown that way; reorienting it is the animator's job.
457
458## Decision 2026-11-07 (Tim t992u): 2D-preview map modes show maps as stored
459- The map-only view modes (Alleen diffuse / normal map / depth map / specular map) show the atlas pixels exactly as stored, with no decoding or translation. The diffuse, normal and specular maps are RGBA8 and are shown as-is. The depth map with `rgb_bands` or `rgb_packed` encoding shows its raw R, G, B and alpha channels, not the decoded depth as gray. `gray` shows its gray and alpha.
460- 16-bit maps are shown at ordinary display precision: each channel is rounded to 8 bits (`v·255/65535`), with no other transformation.
461- For each pixel the depth composition still picks the winning layer, and the stored depth pixel of that layer is the one shown. Pixels with no winner stay transparent.
462- Only **Resultaat** decodes depth into the canonical 16-bit value, for the composition and the relighting. It looks the same for every depth format, within one quantization level.
463- Synthetic layers with no stored file pixels (tests) fall back to the canonical gray.
464
465## Decision 2026-11-07 (Tim t993u/t994u): finished result folder holds only consumer output
466- A finished `renders/<sex>-<settype>-atlas/` contains only the `.ppda`, the four atlas PNGs and a `raw files/` subfolder. `raw files/` groups every loose per-frame map folder (`raw files/<sex>/<set>/<clip>/f……/p…_y…_<map>.png`). There are no other JSON files.
467- `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` and archived `attempt-*` markers exist only in the temporary `inprogress-*` build folder. After the build is verified (atlas reader + export_set reader) and the lock is released, they are removed, with `complete.json` last, and only then is the folder swapped into place.
468- What still matters after completion goes into the `.ppda`, not into `raw files/`. `.ppda` format 4 adds `export`: the full render contract (formerly `render.json`), `passes` and `png_sha256` (SHA-256 per loose PNG, keyed by its `raw files/…` path). The export_set reader rebuilds its render/complete view from that, and checks the `.ppda` SHA-256 and every loose PNG checksum as before. Formats 2–3 and older result folders (loose maps and manifests at the root) are still read.
469- `depth_clamped` statistics are progress-only now; they are not persisted.
470- Crash safety: a build is complete when it has `complete.json`, or when it has no `render.json` and does have a `.ppda` (already stripped). `recover` verifies such an unlocked build, strips any remaining bookkeeping (idempotent) and promotes it. Resume refuses completed builds as before. Quarantined `.corrupt-aNNN-K` files are still kept, never deleted; that is the only exception to the consumer-only layout.
471
472## Decision 2026-11-07 (Tim t998u.1): recent projects on the start screen
473- Below **Project laden** / **Nieuw project**, the start screen lists the last 5 opened or created projects, newest first. Clicking one opens it, the same as loading it.
474- The list is stored per user as `recent.json` in the user config directory: `$XDG_CONFIG_HOME/paperdoll-renderer/` (default `~/.config/paperdoll-renderer/`), or `%APPDATA%\paperdoll-renderer\` on Windows. `PAPERDOLL_CONFIG_DIR` overrides the location. It holds absolute `character.ppd` paths, with duplicates removed. It is written atomically and only after a successful open or create.
475- Projects whose `character.ppd` no longer exists stay in the list, greyed out and not clickable, with the note "niet gevonden". They drop off as newer projects are added. An unreadable or damaged list is treated as empty and is never fatal.
476
477## Decision 2026-11-07 (Tim t998u.2): window icon and app id under Wayland
478- The window carries the app id `paperdoll-renderer`, set with `ViewportBuilder::with_app_id`. This equals the `.desktop` file name and `StartupWMClass=paperdoll-renderer`. On Wayland, KDE/GNOME use the xdg-shell app_id to find the desktop entry and its icon, because Wayland has no per-window icon protocol. On X11/XWayland the same value becomes WM_CLASS.
479- The bundled puppet icon is still set with `with_icon`, for X11 and Windows.
480- eframe is built with both the `x11` and the `wayland` backends, so a Wayland session gets a native Wayland window with that app_id.
481- The icon only shows after the desktop entry is installed: through a package, or through the in-app file association that writes `paperdoll-renderer.desktop`.
482- A test checks that the app id constant, the `.desktop` name and `StartupWMClass` all match.
483
484## Decision 2026-11-07 (Tim t998u.3): new project = parent folder + project name
485- **Nieuw project** opens a small form. **Map kiezen…** picks a parent folder with the system folder picker. The project name is typed in. **Aanmaken** creates `<parent>/<name>/` and initializes the project there: `character.ppd` with the typed name, the standard folders and the seeded test bodies.
486- Name validation (Dutch message shown in the form, nothing is created on error):
487 - the trimmed name must not be empty;
488 - it must be at most 100 characters;
489 - it must not contain `/`, `\`, control characters, or the Windows-reserved characters `<>:"|?*`;
490 - it must not be `.` or `..`, and must not end in a dot or space.
491- The parent must be an existing folder. `<parent>/<name>` must not exist yet, or must be an empty folder.
492
493## Decision 2026-11-07 (Tim t999u): a fresh render discards older unfinished builds of the same target
494- When a **fresh** (not resumed) render of `<sex>-<settype>` starts, the unfinished `inprogress-*` folders of that same target are deleted, whether cancelled, failed or interrupted, before the new build folder is created. Only the newest build of a target is kept, and that is what **Hervatten** offers.
495- The target is matched by the build's `render.json` sex/set, or by the folder-name suffix when `render.json` is unreadable.
496- Never deleted:
497 - the finished `<sex>-<settype>-atlas/` result folder;
498 - builds of other targets;
499 - a build that is complete but not yet promoted (`recover` promotes it first);
500 - a locked build (another render is writing it).
501- Resuming does not delete anything.
502- The deletions are reported as progress steps.
503
504## Decision 2026-11-07 (Tim t1005u): render live preview shows the depth map as written
505- During a render, the live pass preview of the depth map shows the bytes actually written to the depth PNG, in the chosen encoding and bit depth. It uses the same display rule as the 2D previewer map modes (t992u): `gray` is shown as grey, `rgb_bands`/`rgb_packed` as raw RGB, and 16-bit samples are rounded to 8 bits for display.
506- A depth file kept by a resume is shown from its stored bytes.
507- The former separate high-byte grey preview image (`depth_preview`) is removed. No depth translation exists for display anywhere. Only the compositor/relighting decodes depth.