# Paperdoll Renderer — design

**Status: design recorded with a foundation shell only.**
Prepared 6 November 2026 (Europe/Amsterdam). Requirements originate with Tim Falken. Sections explicitly marked **proposal** remain implementation decisions to validate; the verbatim appendix is authoritative for user intent. The Rust program shipped with this design is the section 12 step 0 shell: it compiles, opens a status window, and tells the truth about what is not built. It does not import models and it does not render paperdolls. Synthetic test bodies, when present under `tests/fixtures/bodies/`, are fixtures for later features; this program does not load them.

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. A later viewport can consume the same CPU records (`MeshRecord`, `JointRecord`, `ClipRecord`) without treating this importer as a renderer.

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 recognized and refused. `libassimp` is not present here, and `russimp` was not added. Assimp still needs a proven native build, packaging and animation fixtures on Linux and Windows before it can be called an FBX importer. The import entry point returns that explanation instead of an empty success. Playback, the 3D viewport and paperdoll rendering remain absent.

| 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. Not rendered. |
| 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. Not rendered. |
| FBX ASCII/binary | Yes | Varies; embedded/external textures and legacy material conversion | Commonly | Commonly multiple clips/takes | Recognized and refused. Assimp is not linked. Not an importer yet. |
| 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.

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.

### format_version 1

Implemented project file: one UTF-8 JSON manifest per project directory, default name `character.ppd`. Fields are `format_version` (exactly 1), `id`, `name`, `male_model`, `female_model`, `equipment_presets`, `output_directory`, and `render` with `target_width`, `target_height` and `fps`. Sex slots are JSON null when empty. Paths use `/` and stay inside the project directory. A preset file stem is a lowercase slug and is not `body`. Unknown fields are rejected, so a rewrite cannot drop them. Any other `format_version` is rejected before the rest of the object is accepted. Save writes a temporary sibling in the same directory and renames it over the manifest only after the bytes are on disk. A failed save leaves the previous manifest in place. Naming a model file does not import or render it. Animation inventory and import transforms are not part of version 1.

## 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.

Use orthographic projection for aligned layering. Shared camera framing, pivot, near/far, canvas size and unit normalization are stored per sex/export set and must match across body/equipment. Determine a stable envelope across selected animations, with configurable margin and equipment allowance. Never independently auto-frame each equipment piece or animation frame. Source-model forward-axis correction is explicit in project import settings.

**Proposal:** sample looping clips at t=i/FPS for 0 <= i < ceil(duration*FPS), excluding a duplicate terminal sample; validate zero-duration clips and offer a bind/static frame instead. Store actual sample timestamps and duration in output metadata. Root motion is initially in-place with a documented extraction policy; preserve original clips and allow future explicit mode. No interpolation of ordinal frames as though every clip had the same duration.

The 8x requirement means **8x width AND 8x height**, hence 64x pixel count. FPS controls animation sampling rather than interactive preview performance. Render maps in a bounded streaming pipeline, not all frames in RAM. Estimate disk/GPU/memory cost before starting: frame count times 36 views times map count. Cancellation stops between safe work units and leaves an incomplete marker, never an apparently complete asset set.

## 7. Export naming and metadata

Required schema: `<geslacht>-<settype>-<animatienaam>-<pitch>-<yaw>-<maptype>-f<framenummer>.png`.

**Proposal:** sex tokens `male` and `female`; body set type exactly `body`; equipment name validated as a lowercase slug without spaces, reject reserved `body`. Use deterministic safe animation slugs plus collision registry preserving original names in a companion manifest. Decide delimiter escaping explicitly: negative pitch creates adjacent hyphens, so parse from the known suffix rather than naive splitting. Example `male-body-walk--30-0-diffuse-f000000.png` corresponds to pitch -30/yaw 0/frame 0. A six-digit frame width is a provisional minimum, not a cap; width and naming are stored in metadata.

The companion output manifest stores original clip IDs/names, name tokens, timestamps, dimensions, FPS, camera matrices/conventions, shared depth range, normal space, alpha mode, specular convention, render settings/version, source fingerprints and completion status. This metadata supplements rather than replaces the required PNG filenames. Output writes use temporary files and explicit overwrite choice. Sanitize names without creating collisions or interpreting slashes as directories.

## 8. Map contracts and rendering algorithm

Diffuse is an **unlit base color** suitable for later lighting, not an already lit screenshot. Preserve alpha, use sRGB encoding only at output boundaries and process color in linear light. Transparency outside geometry remains zero; distinguish uncovered pixels from valid near-depth pixels.

Depth **proposal:** 16-bit grayscale PNG representing linear camera-space depth normalized to one shared near/far interval for all compatible layers. Smaller valid values mean nearer the viewer. Use diffuse alpha/coverage as validity; never interpret the transparent background as the closest surface. No per-frame or per-equipment depth rescaling. Record the exact decode equation in metadata. Depth ordering tests must include equipment in front of and behind different body regions.

Normals **proposal:** view-space geometric/shading normal, xyz encoded from [-1,1] to [0,1] in RGB PNG, with documented image Y convention; normal space must match the preview light transform. Normalize after interpolation/filtering. Uncovered pixels have alpha zero and a neutral placeholder normal. Include tangent-space source normal maps where supported and explicitly test their conversion; a geometry-normal bake is not automatically full source-material normal fidelity.

Equipment shinymap **proposal:** grayscale scalar specular strength (0..1), with a documented default exponent/roughness in metadata. Filename token `shiny` is provisional because Tim named the concept but did not mandate a token spelling. Metallic/roughness and Phong materials do not map uniquely to one scalar; expose a conservative conversion/default and retain editable overrides. Do not label it physically exact PBR. Body has diffuse/depth/normal; equipment additionally shiny.

Pipeline per shared scene sample/view:
1. Evaluate skeleton and bone world transforms at the exact sample time.
2. Render high-resolution unlit color/coverage and geometry buffers at 8x target dimensions. Depth/normals originate from the actual same surfaces, not image luminance.
3. Compute an internal detail map using color gradients plus alpha/depth/normal discontinuities, bounded weights and transparent-neighbor handling. Detail map is intermediate, not a fourth body output map.
4. Downsample diffuse with deterministic coverage-aware weighted reconstruction in linear premultiplied color; unpremultiply safely for straight-alpha PNG output. Tune thin-feature retention on fixtures, not on a named but unimplemented algorithm.
5. Resolve target-resolution depth/normals from coherent contributing surfaces. Avoid mixing background with foreground or averaging front and back depths into nonphysical geometry. Align the chosen representative/coverage policy across maps. Renormalize normals.
6. Add a subtle configurable rim/detail accent to diffuse based on normal/depth discontinuities, never a light-direction bake. Clamp magnitude, preserve alpha; default must be validated visually to avoid double-lighting later.
7. Encode all target-resolution PNGs atomically and record checksums/metadata. Test deterministic CPU stages independently from GPU tolerance.

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

UI selects sex, target skeleton/bone, imported equipment mesh, XYZ translation and XYZ Euler rotation in degrees. **Proposal:** persisted rotation is a quaternion with UI Euler values/order documented; test round-trip and avoid cumulative rotations each frame. Attachment transform is `body_world * animated_bone_world * attachment_local`; clarify inverse-bind usage in importer and test with nonidentity parents. Scale is initially asset import normalization, not an unrequested body-part editor feature.

Live 3D preview displays body and selected equipment, allows free orbit/zoom and optional animation selection for placement checks. It is separate from the discrete 2D preview. Display a bone tree and highlight unresolved attachments. A preset supports multiple meshes attached to multiple bones, sex flag, stable bone paths, original/import transforms and embedded geometry/material/texture payloads.

Binary preset **proposal:** magic header, schema version, bounded metadata length and asset count, checksummed entries and compressed payload chunks. Choose a documented serialization/container implementation in the preset feature; do not dump Rust memory layouts. Include rig fingerprint and reject mismatched skeletons with an actionable remapping prompt. Preserve original or normalized embedded asset representation plus license/provenance, protect against decompression bombs and traversal, save atomically. Filename extension for presets remains a proposal; .ppd is reserved for project manifests.

## 10. Rendered 2D preview

Select sex, animation and zero or more compatible equipment sets. Validate same project/rig/export contract and sample inventory. 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.

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. Clamp elevation/wrap azimuth with clear coordinate convention. These controls are continuous even though model view selection is discrete.

Depth compositor selects the nearest valid surface per pixel from body and chosen equipment, with stable epsilon/tie-break ordering. Chosen pixel supplies diffuse, normal and shiny from the **same layer**. Blend alpha edges using a documented approximation; single-depth compositing cannot reconstruct arbitrary order-dependent translucent surfaces. A simple ambient + diffuse + specular preview proves maps and lighting; it is not the future game controller. Transform the continuous light into the same view space used by each normal map.

## 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. 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.

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.

## 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, rim accents, shiny conversion, filename/manifest export, progress/cancel/resume; compare actual output and memory budgets.
7. Rendered 2D compositor preview: real exported frames, depth ordering, looping, sex/equipment/clip selection, discrete view controls and continuous held light controls; deterministic state tests and manual lighting test.
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 desktop shell still reports glTF/GLB as not built and does not open these files.

# 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: <geslacht>-<settype>-<animatienaam>-<pitch>-<yaw>-<maptype>-f<framenummer>.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.
```
