# Paperdoll Renderer — design **Status: design recorded with a foundation shell only.** Prepared 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. Anonymous 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`. ## 1. Product, scope and non-goals Build 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. The 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. The 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. BlueGit 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. ## 2. Requirements and traceability | ID | Requirement | Verification | |---|---|---| | R01 | Rust application with user-selected male/female models | Open/save missing and populated slots; later manual desktop smoke test | | R02 | Discover all supported embedded animation clips | Synthetic licensed fixture inventory, including unnamed/duplicate clips | | R03 | Body and equipment modes | Export body plus isolated equipment from identical scene state | | R04 | Three pitches times twelve yaw positions | Exactly 36 unique orientations; yaw starts facing viewer | | R05 | Initial geometry render at 8x target dimensions and configured FPS | Explicit render manifest and dimension/frame-count tests | | R06 | Edge/detail-guided downsampling | Image fixtures retain thin details better than baseline without alpha fringes | | R07 | Target-resolution diffuse, depth, normal maps | Round-trip decoding, finite normalized normals and shared depth contract | | R08 | Subtle diffuse edge highlights from depth/normals | Deterministic fixture with bounded highlight effect | | R09 | Required filename structure | Golden path examples plus sanitization/collision tests | | R10 | Bone-relative XYZ position and rotation with live orbitable 3D preview | Transform-chain unit tests and manual viewport test | | R11 | Binary equipment presets embedding models and attachment metadata | Round-trip, version/truncation/resource-limit tests | | R12 | Equipment set name lowercase without spaces | Validation; reject reserved body name and path traversal | | R13 | Equipment-only rendering with shinymap | No body occlusion/write contamination; specular fixture | | R14 | Folder plus .ppd manifest describing assets, presets, outputs | Portable relative paths and safe atomic saves | | R15 | 2D looping animation, sex/equipment/clip selection and 36-angle navigation | State-machine and depth-compositing tests | | R16 | Held light-control buttons rotate continuously in degrees | Delta-time tests including key/button release/focus loss | | R17 | Per-feature branch, tests, non-FF master merge, no initial MRs | Two-parent merge topology and CI checks | | R18 | Tag tests gate AppImage, deb, Arch package, Windows build and release | Packaging smoke tests and complete-artifact release gate | | R19 | Minimum import goals OBJ/MTL/textures and FBX; broader support where feasible | Format matrix and fixture-based import tests | ## 3. Proposed architecture and stack **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. A 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. Proposed workspace boundaries: - `crates/ppd-core`: project types, validation, portable paths, render schedule, filename codec and format versions. - `crates/ppd-import`: import adapters, coordinate normalization, normalized assets and animation discovery. - `crates/ppd-render`: shared camera, scene sampling, offscreen maps and readback. - `crates/ppd-image`: linear-color downsampling, detail extraction, edge highlight and PNG codecs. - `crates/ppd-preview`: deterministic depth compositing and preview/light state independent of UI where possible. - `crates/paperdoll-renderer`: desktop shell, model/preset editors, render progress and preview tabs. - `tests/fixtures`: tiny synthetic or permissively licensed fixtures, with provenance and licenses. - `packaging` and `scripts`: verified platform packaging and CI helpers. Do 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. ## 4. Import format advice and support matrix **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. ### Import implementation status The 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. This 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. FBX 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). | Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status | |---|---|---|---|---|---| | 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. | | 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). | | 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. | | Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass | | PLY / STL | Static geometry | PLY colors possible; STL no conventional textures | No | No | Optional static equipment only; low priority | | .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 | FBX/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. Import 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. OBJ 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. **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`. Format 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. ## 5. .ppd project and asset ownership **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. Suggested 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. Validate 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. ### Implemented format_version 3 (versions 1/2 migration) `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. Paths 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. ## 6. Camera, axes, alignment and animation sampling Required views: pitches -30, 0, +30 degrees; 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. **Implemented fixed framing (Tim t952u):** orthographic projection, +Y world up, yaw 0 camera on −Z looking toward the character; 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. **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`. **Implemented batch nesting:** source clip order → sampled frame → pitch **−30,0,+30** → 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. Highres 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. ## 7. Export naming and metadata The current hierarchical layout supersedes the earlier flat filename proposal (the original brief is retained verbatim in the historical appendix): ```text renders/run--/// clip-/f/p_y_.png # example: .../male/body/clip001-walk/f000012/p-30_y330_diffuse.png ``` `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. `render.json` (output metadata schema **3**, 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 3) is published ONLY after all PNGs complete and contains a matching `identity` object plus `png_sha256` (relative path → hex digest) for every file; 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. **Safe export-set reader (`export_set` module):** `ExportRun::open` accepts a completed run directory only when both manifests are present, schema 3 matches, 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 <3), incomplete or corrupted sets are rejected. The 2D depth compositor consumes this reader rather than inventing a parallel contract. Automatic cleanup of old runs is still future work. **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 `.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-.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--`) 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`). ## 8. Map contracts and rendering algorithm **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. **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. **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. **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. Per pose/view, the actual completed-preview order is: 1. **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. 2. **Detail:** color/alpha/depth/normal neighbor discontinuities, bounded weights. Publish actual edge map immediately, no file saved. 3. **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. 4. **Depth:** write target 16-bit gray+alpha, publish its gray8 visualization (the saved depth keeps 16-bit precision). 5. **Normal:** write/publish target normal. 6. **Specular:** write/publish target scalar map, including body zero. Every 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. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo. Current 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). Equipment 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. ## 9. Equipment editor and binary presets The 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`). Persisted 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. The 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. ### Implemented `.ppe` container, version 1 All integers below are unsigned little-endian. The container is uncompressed (no Rust memory-layout serialization and no decompression stage): | Bytes | Meaning | |---|---| | 0–7 | Exact magic `PPEQ\r\n\x1a\n` | | 8–11 | Schema version, exactly 1 | | 12–15 | UTF-8 JSON metadata length | | 16–19 | IEEE CRC32 of the **entire payload** after byte 19 | | 20… | Bounded metadata, then one `u32 length + raw bytes` chunk per asset in declared order | Metadata 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**. Limits: 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. Project save creates immutable `presets/--.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. ## 10. Rendered 2D preview Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory through the completed-run `export_set` reader (schema 3 identity + checksums), not by guessing from filenames. Play actual exported frames on a loop with elapsed-time accumulation; 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 composed+relit+rim frame with aspect-preserving nearest-neighbor letterboxing. A **Randlicht** strength slider (default subtle, zero disables) drives consumer-side rim. The library depth compositor, relighting and rim API (`compositor`) consumes the safe map reader; lighting/rim are preview-only and do not alter saved unlit diffuse. Left/right controls wrap among twelve yaw values. Up/down selects one of three pitch rows 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. Depth 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` applies linear ambient + Lambertian diffuse + Blinn-Phong specular with the light already in view space (matching exported normals; view = +Z). `apply_rim` then adds a bounded Fresnel-like `(1 − N·V)^power` highlight plus an optional depth-discontinuity term (`RimParams`; intensity clamped to `MAX_RIM_INTENSITY`); straight alpha is preserved byte-for-byte and zero intensity is identical to relight-only. It is a consumer proof, not the future game controller. Saved production diffuse remains unlit albedo (Tim t953u); rim is never baked into target PNGs. ## 11. Development workflow, quality and packaging Each 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. After 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. The 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`. Live 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 -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. 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. CI 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. Tests 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. Tag 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. Release 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. Verified 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. ## 11a. Tim’s desktop workflow requirements (6 November 2026) These requirements are durable even where the implementation is staged. They supersede the earlier generic workspace layout. - 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. - 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. - 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. - `.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. - 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. - 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. Current 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 × 36 views with the six real pass previews and four target maps described above, 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, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) 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. ## 12. Incremental feature plan and acceptance gates 0. 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. 1. Project model and validation: .ppd open/save/new, portable paths, schema/version checks, model slots and render settings; atomic persistence/invalid paths tests. 2. 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. 3. Desktop 3D viewport and animation sampling: visible real imported model, orbit, clip controls, source-axis normalization; transform tests and manual visual test. 4. Equipment attachments and binary presets: bone tree, sex choice, numeric offsets/rotations, live placement, embedded multi-mesh assets; round-trip and rig mismatch tests. 5. Shared batch camera/sampling and raw buffers: 36 views, common framing, 8x geometry output/readback, diffuse/depth/normal alignment; orientation/occlusion fixture before aesthetic postprocessing. 6. 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. (Rim accents are consumer-side in the compositor — **done** with point 7/R08; not baked into production diffuse.) 7. Rendered 2D compositor preview (**done**): real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls, continuous held light controls and bounded rim/edge lighting; deterministic state tests and GUI smoke. 8. 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. Every 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. ## 13. Open decisions and risks - Confirm target resolution/FPS defaults, output color treatment, root-motion policy, front-axis and positive-yaw convention with a labelled fixture before production content. - FBX exporter variants/native Assimp packaging and skeletal animation fidelity are high-risk; prototype early rather than after UI completion. - Eightfold dimensions impose 64x pixels; support tiling or reject unsupported render sizes with estimates, not GPU crashes. - Shared framing/depth/normal contracts are essential; independently normalized equipment maps cannot be depth-composited correctly. - Alpha-blended materials cannot be fully represented by one surface depth; document cutout/opaque MVP and approximation. - Bone attachment supports rigid equipment, not automatic skinning/retargeting; expose that limit. - glTF PBR/FBX legacy materials to a single shinymap is lossy; retain source metadata and editable conversion settings. - Package version mapping, Windows runner/toolchain and BlueGit group runner availability must be verified, not assumed. - Keep model provenance/licensing with embedded presets; importing assets does not grant redistribution rights. ## 14. Research references and verification limits - Bevy project and documentation: https://bevy.org/ (preferred-stack proposal; exact dependency versions and API compatibility still require implementation verification). - Assimp repository: https://github.com/assimp/assimp (candidate broad-format import dependency, not universal file compatibility guarantee). - Rust Assimp binding docs inspected: https://docs.rs/russimp/latest/russimp/ (reported russimp 3.2.1 at inspection; no build/compatibility test performed). - glTF specification reference: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html (canonical interchange recommendation; implementation must test supported subsets). - 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). - 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`. - Release implementation read in `timfa/bluegit` `internal/server/ops_ci.go` (`opPublishRelease`) and `internal/server/release_replace.go` (`checkReplace`). ## 15. Verified BlueGit CI and the release-trigger limit Inspected sources, all public on 6 November 2026: | Source | What it shows | |---|---| | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | Consequences for `blue-ci-cd.yml` in this repository: 1. 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`). 2. 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. 3. 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. 4. `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. 5. 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. 6. 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. 7. posprint states BlueGit does not set `CI_PROJECT_DIR` and that jobs start in the checkout. Scripts use `$PWD`. The 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. ### Release routes that exist, and the missing attach call Rechecked 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. | Method | Path | What it does | |---|---|---| | GET | `/api/v1/projects/{owner}/{repo}/releases` | List releases. | | POST | `/api/v1/projects/{owner}/{repo}/releases` | `createRelease` with a user token. `opPublishRelease` copies job artifacts, then `gitrepo.Tag` runs `git tag -a`. | | GET | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Download one asset. | | DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}` | Delete the release. Query `delete_tag=true` also deletes the git tag. | | DELETE | `/api/v1/projects/{owner}/{repo}/releases/{tag}/assets/{name}` | Delete one asset. The tag stays. This does not upload. | | 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. | | POST | `/api/v1/projects/{owner}/{repo}/repository/tags` | Create a git tag. No release assets. | | GET | `/api/v1/projects/{owner}/{repo}/pipelines/{id}/jobs/{job}/artifacts` and `.../artifacts/{path}` | List or download a job artifact. Not a release. | Minimal 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. ## 16. Synthetic test bodies `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. Each 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. Male and female differ in shoulder width, hip width and hip height so a later importer can tell them apart. `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. # Appendix A — Tim's original proposal (verbatim) The following block preserves the original Dutch text including spelling and filename placeholders. It is not a paraphrase or implementation status. ```text Ik heb een idee voor een projectje; Een Paperdoll Renderer. Het idee; We 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) Bij 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: ------f.png met een vooringestelde (hoge, acht keer de doelresolutie) resolutie en framerate. We 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. Daarna maken we een depth-map op de doelresolutie, en een normal map. (de maptypes in de filename zijn dus: diffuse, depth, normal) Als 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. Daarna 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. De 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. Als 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). Wanneer 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. Zo 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. Stappenplan: - Maak een groep op BlueGit: `Golden Age` - Maak de repo, Paperdoll Renderer - Documenteer alle bovenstaande, incl. letterlijke tekst van mij, zeer gedetailleerd in een enkele design.md - 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. Houd me bij elke feature op de hoogte hoe het gaat. ``` # Appendix B — Additional import requirement (verbatim) ```text Bepaal 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. ``` ## Decision 2026-11-07 (Tim t955u): pitch ±60, per-map atlases, `.ppda`, default 4 fps - **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. - **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: `---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. - **`.ppda` (PaPerDollAtlas):** a strict UTF-8 JSON file next to the atlases, named `--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. - **Default framerate:** new projects default to **4 fps** instead of 12. Existing `.ppd` files keep their stored `fps`. - **`.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. ## Decision 2026-11-07 (Tim t958u): full-circle light azimuth, rim lighting removed - **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. - **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.