# Paperdoll Renderer

Desktop editor for `.ppd` projects, bone-bound equipment and an animated 3D preview, with production export to paperdoll maps.

## Getting started

Rust 1.96.0 is pinned in `rust-toolchain.toml`. On Debian/Ubuntu, building and the window need `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` and `libgl1-mesa-dev`. The native file picker uses the XDG Desktop Portal with a GTK/KDE backend, or Zenity. Live 3D needs OpenGL 3.3.

```bash
cargo run
cargo run -- "/path with spaces/character.ppd"
cargo run -- --preview-fixture
cargo run -- --status
```

## Language and settings

The UI is available in English (default) and Nederlands. **Settings** (on the start screen and in the project toolbar) holds the **Language** choice; switching takes effect immediately, including error and status messages. The choice is stored as `settings.json` in the user config directory (see below) and is restored at the next start. All UI text lives in one translation table (`src/i18n_table.rs`, Dutch source text → English, including format templates); tests check that every user-visible string has an entry in both languages with matching placeholders.

## Project and bodies

The start screen offers **Load project** and **New project**, both with a system file picker. New project asks for a parent folder (**Choose folder…**) and a project name, and creates `<folder>/<name>/`. The name must not be empty, must not contain `/ \ < > : " | ? *` or control characters and must not end with a dot; if the folder already exists it must be empty. Errors are shown in the form and nothing is created. The new folder receives `character.ppd` (with that name) and the folders `assets/body/`, `assets/equipment/`, `presets/`, `cache/` and `renders/`. The bundled default bodies (see *Attribution*) are copied into `assets/body/male/` and `assets/body/female/`, each with its `license.txt`, and set as the male and female body, so the project renders immediately. The equipment list also gets the female-only hair preset `presets/female-hair.ppe`. (`--preview-fixture` and the tests use the small CC0 test bodies instead.) Below the buttons the last 5 opened or created projects are listed (**Recently opened**); click a name to open it. A project that no longer exists is greyed out with *not found*. The list is `recent.json` in the user config directory (`$XDG_CONFIG_HOME/paperdoll-renderer/`, by default `~/.config/paperdoll-renderer/`, on Windows `%APPDATA%\paperdoll-renderer\`; override with `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` creates a temporary sample project and opens the male body.

The first project view shows the bodies. **Choose male / Choose female** selects a model. An external file is copied into the project folder; if the name exists the copy gets its own name. Models with side files can be placed in the project folder with their relative folder structure and selected there. **Import overview** lists meshes, materials, textures, joints and clips of glTF/GLB, FBX and OBJ+MTL. FBX bodies are normalized once on load (right-handed Y-up, meters) and then drawn like glTF/GLB in the live 3D preview and production render, with all their animation stacks as clips.

**Open male / Open female** opens the body. A skinned glTF/GLB or FBX model, or a static body (see below), is shown in the live preview:

- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the camera orbits around the vertical centre of the model's rest-pose bounds (or **Center Y** as a fallback with a diagnostic when there is no geometry); **Auto centre** clears a manual override. The fixed frame and production are orthographic, so there is no perspective distortion at any pitch. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom, seen from 2.5× further away with a narrower field of view (≈15.4°) for weak perspective; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
- Pick an animation clip and use **Pause / Play**.
- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). It builds one atlas per map type (from in-memory cell bytes) plus a `.ppda` file; loose per-view PNGs are only temporary checkpoints (see *Atlases and .ppda*). Production keeps several GPU views in flight (PBO readback ring) and encodes/writes PNGs on a bounded background IO queue so the next view can start without waiting on disk; atlases are built from in-memory PNG bytes when available instead of re-decoding from disk. The live preview of the latest finished pass is a latest-wins slot: the render worker never waits on texture upload or UI repaint, and the UI may skip intermediate frames. Shown passes still follow highres → detail → target/diffuse → depth → normal → specular, keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes. **Trim atlas: body / equipment** (both on by default; older `.ppd` files load with both on) crops the atlas views to their content, see *Atlases and .ppda*. **Semi-transparency allowed (body)** is off by default (also for older `.ppd` files). Off means binary alpha: after the resolve, a pixel with coverage ≥ 0.5 becomes fully opaque in every map (diffuse, normal, specular, depth) and anything below becomes fully empty in every map, so all masks match. On keeps the soft edges. Each equipment preset has its own **Semi-transparency allowed** checkbox (also off by default). The bundled `female-hair` preset has it on. The setting is in `render.json` (`semi_transparency`), so a run only resumes with the same setting.
- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.

![Body view with animation and camera controls](docs/screenshots/body-live.png)

### Multiple meshes and skins (glTF/GLB)

A skinned glTF/GLB body is loaded completely: all mesh nodes and all skins. The joints of all skins are merged into one skeleton (joints present in several skins count once; at most 128). A mesh node without a skin moves rigidly with the nearest ancestor that is a joint; if it has none, it hangs from the first joint. Its rest position in the scene is kept exactly (mirrored transforms flip the triangle winding). Materials stay per primitive: base color factor × albedo texture, alpha cutoff and vertex colors; every primitive uses its own material, in the live preview and in production. Materials with `alphaMode` `BLEND` (eyes' cornea, eyelashes, hair cards) are drawn after the opaque ones and alpha-blended over them, without writing depth until a final depth-only pass; blending between overlapping BLEND layers follows file order (no sorting). External image files (`uri` next to the `.gltf`) are resolved relative to the model, like embedded and `data:` images. `KHR_texture_transform` on the base color texture (offset, rotation, scale and `texCoord`) is applied once to the UVs on load. The sampler wrap mode is not read: textures are always sampled nearest with REPEAT, so CLAMP/MIRROR look different outside the 0–1 UV range. `KHR_materials_specular` feeds the body's specular map per primitive: `specularFactor` × the alpha of `specularTexture` (sampled with the base-colour UVs; `specularColor*` is not used); in the live preview it drives the highlight. Without the extension a body's specular is zero. Metallic/roughness, normal, occlusion and emissive textures and morph targets are not used; the normal map comes from geometry (metallic/roughness is not converted; a scalar specular cannot reproduce PBR physically).

### Static bodies (OBJ, glTF/FBX without skin)

A body without a skin can also be chosen as male or female model: **OBJ+MTL** (with PNG textures next to the model), **glTF/GLB without `skins`** and **FBX without skin deformer**. Such a body is shown and rendered as stored, without skeleton or animation:

- OBJ and FBX go through the same rigid reader as equipment (FBX with the same axis/unit normalization, once); for glTF the scene's node transforms are baked into the geometry once (mirrored transforms flip the winding). Animations in a glTF without skin are not applied; the message says how many.
- The body view reports that the body is static. The animation choice and **Play/Pause** are disabled.
- **Pivot Y:** as for skinned bodies, the orbit point is the vertical centre of the stored geometry; a manual **Pivot Y** overrides it. Fixed render height/zoom per body stays the same.
- **Render all angles** makes one bind-pose sample `clip000-bind` × 60 angles with the same four maps (diffuse as unlit albedo, normal, depth, specular) at target resolution, plus atlases and `.ppda`; the set opens through `export_set` and the 2D preview.
- Equipment needs a bone: in the equipment editor **Skeleton / bones** and **Render equipment · all angles** are disabled with an explanation, and the live view draws no equipment on a static body. An equipment render on a static body is refused.

## 2D preview

From **Body** or the body view, **2D preview** opens the rendered compositor over the result folders `renders/<sex>-<settype>-atlas/`. The preview reads every export through its `.ppda` and the four atlases (validated, see *Atlases and .ppda*), not through loose PNGs. **Exporteer frame** writes the current pose's composed maps (diffuse, normal, depth in the atlas encoding, specular) plus the lit preview image (current light/rim) as separate PNGs into a folder you pick. Runs without a valid `.ppda` are skipped; their count is shown below the export counter, with the reason on hover. The decoded atlases of the current selection stay in memory until you choose another selection or use **Refresh export sets**.

- Choose **Male / Female**. Only export sets with the same project/rig/export contract (`CompatKey`) can be combined; equipment appears as a multi-select when those contracts match.
- Choose a clip from the body export inventory. **Play / Pause**, speed and export fps drive a looping frame time (elapsed × fps); missing maps/frames show a diagnostic instead of a silent substitute.
- **Yaw − / Yaw +** wrap over 0…330° (step 30). **Pitch − / Pitch +** choose −60 / −30 / 0 / +30 / +60 with clamping (start at 0). Angle changes keep the animation time.
- Choose view mode **Result** (default; lit composition), **Diffuse only**, **Normal map only**, **Depth map only** or **Specular map only**. Map modes show, per pixel, the winning layer of the depth composition, unlit and exactly as stored in the atlas, without decoding: diffuse, normal and specular as their RGBA, depth as its stored channels (`gray` as gray, `rgb_bands` and `rgb_packed` as raw RGB). 16-bit depth is shown at plain display precision (8 bits per channel, rounded). Only Result decodes depth for composition and lighting. The live pass image during rendering also shows the depth map exactly as it is written (same display rule). Playback, angle and equipment work in every mode; light only in Result.
- Hold **Azimuth / Elevation** for continuous light rotation in °/s (delta time); azimuth wraps over 360° without clamping, elevation stays within −80…+80. The buttons follow the real mouse button state, so they keep turning while held (also longer than a second). Releasing, moving off the button, losing focus or the pointer leaving the window stops the motion. Lighting is for Result only; stored diffuse remains unlit albedo.
- Playback runs over exactly `frames / fps` seconds, so every exported frame (including the last) lasts equally long (`1/fps`).
- The composed frame uses nearest-neighbor display with uniform scale and centered letterboxing, like the production pass previews.

![2D preview at pitch +60 with a real FBX export via .ppda and atlases](docs/screenshots/preview2d-ppda.png)

```bash
# Native GUI smoke (Xvfb): synthetic export, loose PNGs removed, preview via .ppda/atlases
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
# Same smoke on an existing project with real exports
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live
```

## Equipment and saving

From a body, **Equipment** opens the list. Choose a definition or **New**.

1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
2. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
3. **Add part (OBJ/FBX/glTF)** opens the system file picker; you choose the model first. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Morph targets and non-PNG textures are refused with a message. A model with its own skeleton (skinned glTF/GLB or FBX) becomes a *skinned part*, see step 4b. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
4. For a rigid part (no skeleton): expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding and its own position/rotation. The Male / Female switch in the equipment editor chooses which sex you are editing.
4b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links, positions and rotations are kept per sex; renders and the live preview use the mapping of the body's sex. When you enable Male or Female on a preset that has no mapping for that sex yet, the other sex's mapping and offsets are copied for every bone whose name exists on both body skeletons. Bones without a matching name stay unlinked; if nothing could be copied, the editor warns and you choose the bones yourself.
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings and bone links, sex flags, the semi-transparency setting and origin; container version 4, versions 1–3 still load (shared rigid offsets are applied to every enabled sex; semi-transparency off on versions 1–2) and are written as version 4 on the next save). No source paths outside the project are needed to reload them.

**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.

A preset is limited to 32 MiB, 32 parts/files and textures of at most 2048×2048. A project has at most 64 presets and 64 MiB of preset files. The full container description is in `design.md`.

```bash
# Self-contained CC0 sword sample in a new temporary project
cargo run --example equipment_fixture --locked
```

![Real equipment editor with animated body and hand-bound OBJ](docs/screenshots/equipment-live.png)

When closing or choosing **Other project / New project** with changes, choose **Save**, **Don't save** or **Cancel**. If saving fails, the project stays open. Cancelling the file picker keeps your current work.

## Models in the project

**Choose male/female** copies a model from outside the project into its own folder `assets/body/<name>/`, together with all its side files: for glTF the external buffers and images (`uri`, except `data:`), for OBJ the `mtllib` files and their textures (`map_*`, `bump`, `norm`). The relative layout is kept. If a side file is missing, or a reference points outside the model folder (`..`, absolute path), nothing is copied and the message names the files. Next to the model goes `<model>.provenance.json` with the source path and the SHA-256 of every copied file. Choosing the same model again creates `<name>-2/`; nothing is overwritten. A model already in the project is used in place. If a project model fails to load, the error also names missing side files. Axes, units and winding are normalized once on load (FBX via ufbx); the source files stay unchanged.

## Animations

All clips from the source file are used. glTF interpolation is kept: LINEAR (linear, rotations via slerp), STEP (holds the previous key) and CUBICSPLINE (Hermite with in/out tangents, scaled by the key interval; rotations normalized). Channels with non-increasing or non-finite times, non-finite values or a wrong number of values make loading fail with a clear message instead of silently playing wrong. Morph target weights are not applied; this is reported per clip when rendering. Unnamed clips are called `animatie <index>`; duplicate names stay separate (production chooses clips by index). In the live preview an exact clip name wins over a partial match. Root motion is kept as in the source. FBX clips are baked by ufbx and played linearly.

## Production output

### Render all

**Render all** in the Body screen renders the whole project in one queue: first the male and female body (if set), then every equipment preset for each sex it is valid for. Each item gets the full export (all clips × frames × 60 angles, four maps, atlases + `.ppda`) into its own `renders/<sex>-<settype>-atlas/`. Progress shows *item k/n* plus the normal pass count; **Cancel render** stops the queue (the running item stays resumable, the rest is marked *not started*). If an item has a matching interrupted run, it is resumed instead of restarted. Items that cannot run (no body, equipment not configured or without parts, invalid settings) are skipped with a reason and the queue continues. During Render all the live pass preview in the Body screen stays visible and follows the item currently rendering, as with a single render. Afterwards a summary lists what was rendered, skipped, failed or not started.

### Notification when a render ends

When a single render or **Render all** ends (finished, failed or cancelled) while the app window does not have focus, the app shows one desktop notification with the same status line as the window (one for the whole Render all queue, not one per item). On Linux this is a freedesktop notification (D-Bus) with the app icon and the `paperdoll-renderer` desktop entry; on Windows it is a toast. Without a notification service nothing is shown and the render is not affected.

The body button renders only the body; **Render equipment · all angles** in the equipment editor renders the selected preset, animated on the chosen body but **without body occlusion or body pixels**. The fixed camera of that sex stays the same, also if you zoomed freely in the live preview.

Per image **only four target-res PNGs** are stored: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **unlit albedo** (base color factor × texture × vertex color, alpha kept), never baked lighting/shadow/gloss. Depth is linear within a fixed depth range per sex, centered on the orbit point: `near/far = 10 m ∓ total/2`. By default the total is the T-pose arm span plus one arm length of margin on each side (arm = upper arm→hand bone distance × 1.2, span = shoulder width + 2 arms, independent of the bind pose; without recognizable arm/hand bones: span = max(width, height) and arm = 0.4·span). In the body screen, below the render frame, **Depth range** is *automatic* or a custom total in meters (`depth_range_milli` per sex in the `.ppd`), with the resulting near/far. All clips, frames, angles and equipment of that sex share the same range.

The depth format is chosen in the Body screen (**Depth map**), stored in the `.ppd` as `render.depth_bits` (8, default, or 16) and `render.depth_encoding`. With `t = (distance − near)/(far − near)` and M = 2^bits − 1:
- `gray` (default): gray+alpha, `gray = round(t·M)`.
- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 levels at 8 bit).
- `rgb_packed`: RGBA, positional in base 2^bits (24-bit or 48-bit value), R most significant; needs nearest sampling, no mipmaps and lossless storage.
Alpha is kept (scaled to the bit depth); transparent pixels have value 0. Geometry outside the range is clamped (not clipped) and reported in the progress; normal is view-space RGB+alpha; specular is linear strength (body: KHR_materials_specular or zero; equipment including the configured shiny map). Highres and detail are visible intermediate steps only and are not stored.

```text
renders/male-body-atlas/male-body-diffuse-atlas.png   (and -normal, -depth, -specular)
renders/male-body-atlas/male-body-atlas.ppda
# during an incomplete run only:
renders/inprogress-…/checkpoint/raw files/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
```

Per sex and set type there is one result folder `renders/<sex>-<settype>-atlas/` holding only the newest complete render. A render is built in a temporary folder `renders/inprogress-<time>-<pid>-<sex>-<settype>/`. Only when it is complete and the atlases/`.ppda` are verified is the previous result folder moved aside (`.replaced-…`), the new one renamed into place and the old one deleted. On cancel, error or crash the last good result folder therefore stays. An interrupted swap is repaired at the next render: a moved-aside old folder comes back, and a finished but not yet placed render is placed. Old `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a message.

A finished result folder contains only consumer output (Tim t1065u):

```
renders/male-body-atlas/
  male-body-atlas.ppda
  male-body-diffuse-atlas.png
  male-body-normal-atlas.png
  male-body-depth-atlas.png
  male-body-specular-atlas.png
```

Loose per-view PNGs are **not** kept in the result. During an in-progress run they live under `checkpoint/raw files/…` as Resume checkpoints (journal keys stay `raw files/…` for `.ppda` identity); atlases are built from in-memory PNG bytes when available. After the atlases/`.ppda` are verified the checkpoint tree is deleted, then bookkeeping (`render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock`, attempt markers) is stripped and the folder is put in place. The `.ppda` still embeds the export contract and the SHA-256 of every logical cell. Quarantined `.corrupt-aNNN-K` files (and leftover `.part-*`) are rescued to the result root so they survive checkpoint cleanup. Older result folders that still have `raw files/` or `render.json`/`complete.json` are still read.

While building: `render.json` (schema 6; with `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) describes project identity, source fingerprints (SHA-256 of the body file and optionally the encoded equipment), camera/pivot/sample/material contract (pitches −60…+60, yaws 0…330), clip inventory and count. In the temporary folder only `complete.json` (same schema, with per-PNG SHA-256 and the names and SHA-256 of atlases and `.ppda`) marks a complete set. Schema 5 (before `depth_bits`) is still read as 16-bit gray. Schema 4 and older (including the old global 0.05–40 m range) are refused by the readers and not mixed; render those again. `cancelled.json`/`failed.json` mark a partial run without a complete marker. The library module `export_set` reads a finished result folder through the `.ppda` (older folders through their `render.json`/`complete.json`), opens only finished runs with matching identity and verified checksums, and reads the real RGBA8 and depth maps (all six formats, converted to 16-bit depth for the compositor) with strict path/format validation. The map/camera/pixel contracts are in `design.md`.

### Atlases and .ppda

When all loose maps are done and verified, also after resuming, the render writes one atlas per map type into the (temporary) run folder: `<sex>-<settype>-<maptype>-atlas.png` (e.g. `male-sword-normal-atlas.png`). Then follows `<sex>-<settype>-atlas.ppda`, and only after that `complete.json` (temporary folder only).

- Each atlas row is one (clip, frame) in source order. Within a row the 60 views run from pitch −60 to +60, and within each pitch from yaw 0 to 330. The four atlases share the same coordinates.
- **Trimming** (project setting, on by default, separately for body and equipment: **Trim atlas: body / equipment** in the Body view): each view is cropped to the box around its non-empty pixels in any of the four maps, plus 2 px padding (never outside the frame). All four maps use the same crop. A view without content is marked `empty` and takes no space. Within a row the crops sit side by side without gaps; the row is as tall as its tallest crop. Without trimming, every view is a full cell of exactly the target resolution, without border or scaling. The loose PNGs under `raw files/` always stay full size. On the test body (92 × 128, 8 fps) trimming makes the atlas 2770 × 1301 instead of 5520 × 1920 pixels (−66%); on the test sword equipment 1560 × 498 (−93%). Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
- The atlases are built row by row from the verified loose PNGs, published atomically and overwrite nothing. If an atlas already exists after a crash, it stays if it is byte-identical to the new one; otherwise it is kept as `.corrupt-aNNN-K` and rewritten.
- If an atlas would exceed 16384 pixels in width or height, the render refuses before starting with a clear message (lower fps, resolution or animation length). Atlases are never split.
- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 5 (versions 2–4 are still read; version 2 as 16-bit gray), `trimmed`, `semi_transparency` (whether this layer allows soft alpha; `false` means every alpha is 0 or 1; older files count as semi-transparent), `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h, offset, frame, empty}`: the rect in the atlas, its `[x, y]` position within the full frame, the full frame size `[w, h]`, and `empty` for a view without content, whose rect is `0, 0, 0, 0`) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).
- The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, rects that are not where the packing puts them (or that fall outside the frame or atlas) and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps. `cell()` returns the full frame of one angle: the rect pasted at its offset into an empty frame. The 2D preview and the compositor therefore place every view at character position + offset, pixel-identical to an untrimmed export. `atlas::cross_check_run` compares every atlas cell with the loose PNG.

### Resuming

A resumed run continues writing in the same temporary `inprogress-*` folder; the result folder is untouched until the run is done. If you start a new (not resumed) render of the same sex and set type, older unfinished `inprogress-*` folders of that target are deleted first, so only the newest remains; the finished result folder, other targets and a folder currently being rendered into are kept.

- Every run keeps `progress.jsonl`: per published cell the logical path (`raw files/…`) and SHA-256. Cell bytes are stored under `checkpoint/` until the atlases are verified. `run.lock` prevents two renders writing into the same run at once.
- Resuming is only possible if `render.json` exactly matches what the current job would write: project, sex/set, SHA-256 of body and equipment, target, fps, framing/pivot, camera, clips and maps. A finished, locked or different run is refused.
- Before rendering, every planned cell is checked under `checkpoint/` (or a legacy root-level `raw files/`). Matching checksums stay unchanged and are not rendered again. Missing cells are recreated. Damaged files are quarantined as `<name>.corrupt-aNNN-K` at the run root and then recreated; they are not deleted. An existing file without a journal line (crash between writing and recording) stays if the re-rendered bytes are equal. Leftover `.part-*` files are rescued to the run root when checkpoints are cleared.
- After the cell checkpoints, the atlases and `.ppda` are written from in-memory bytes when present (see above); then checkpoints are removed.
- Progress starts at the work that is already correct; skipped images are labelled *already present, checksums match*. You can cancel again and resume later. A previous `cancelled.json`/`failed.json` is kept as `attempt-NNN-…json`, and `resume.jsonl` records per attempt what was verified, missing and quarantined. Only when everything matches is `complete.json` with all checksums written. Then these bookkeeping files are cleaned up (`complete.json` last) and the folder is put in place. A crash between cleanup and placement is repaired at the next render: the finished folder is cleaned and placed.

![Resuming an interrupted run in the body view](docs/screenshots/body-resume.png)

## 2D depth compositor (library)

The `compositor` module combines a body export with zero or more compatible equipment exports (same project id, sex, body fingerprint, target, fps, framing, near/far and clip inventory, read from `export_set` or from the `.ppda`). Per pixel the nearest valid surface wins (depth gray closer = smaller; epsilon `DEPTH_EPSILON_U16 = 1` depth code; at equal depth the lowest layer index wins — pass the body first, then equipment in fixed name order). Diffuse, normal and specular always come from the **same** winning layer. Soft alpha of that winner is kept; losing layers do not mix in (single depth cannot reconstruct arbitrary translucency).

`relight` / `compose_and_relight` applies linear ambient + Lambert diffuse + Blinn-Phong specular. The light vector is already in view space, like the exported normal maps. There is no rim light: the preview shows only this relight in Result mode. Stored diffuse remains unlit albedo elsewhere.

```rust
use paperdoll_renderer::compositor::{
    assert_compatible, compose_and_relight, LayerMaps, Light,
};
use paperdoll_renderer::export_set::ExportRun;

let body = ExportRun::open(&body_run_dir)?;
let sword = ExportRun::open(&sword_run_dir)?;
assert_compatible(&[&body, &sword])?;
let layers = [
    LayerMaps::from_export_run(&body, "body", "clip000-walk", 0, 0, 0)?,
    LayerMaps::from_export_run(&sword, "sword", "clip000-walk", 0, 0, 0)?,
];
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;

// Same layers via the consumer form (.ppda + atlases):
use paperdoll_renderer::atlas::AtlasSet;
let body = AtlasSet::open_run(&body_run_dir)?.load()?;
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
```

Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, centre pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.

## Releases and packages

A pushed `vX.Y.Z` tag (protected pattern `v*`) runs the tag pipeline: tests, then the AppImage, Debian/Ubuntu `.deb`, Arch `.pkg.tar.zst` and Windows zip, a package check with `SHA256SUMS`, and the release job, which publishes those files as the release of that existing tag on BlueGit (`https://git.timfalken.com/golden-age/paperdoll-renderer/-/releases`). It then updates the AUR package `paperdoll-renderer-bin` from the release's `.deb` (`yay -S paperdoll-renderer-bin`). The AppImage job downloads nothing from GitHub. It gets the pinned appimagetool 1.9.1 and type2-runtime 20251108 from the release assets of BlueGit's `golden-age/appimage-tools`, checks their SHA-256 values against the ones pinned in `scripts/package/appimage.sh`, and builds with `--runtime-file`.

## Open with

- **Windows:** run the unpacked `.exe` once to add the editor to *Open with* for the current account. Then choose **Open with → Paperdoll Renderer** on a `.ppd` (or select the `.exe` via Browse).
- **Debian/Arch:** the packages install the `.ppd` MIME association, desktop launcher and icons. Choose the editor in **Open with**.
- **Linux AppImage/plain binary:** keep the file in its permanent location and run `"/path/Paperdoll.AppImage" --install-desktop`. This needs `shared-mime-info` and `desktop-file-utils`. The editor then appears in **Open with**. Repeat after moving the AppImage.

The window, the Linux launcher and the Windows executable use Tim's application icon. The window has app id `paperdoll-renderer` (equal to `paperdoll-renderer.desktop` and `StartupWMClass`), so KDE and GNOME under Wayland map the window to the launcher and icon; the app runs natively under Wayland and via X11 under X11. Under Wayland a program cannot send its own window icon, so the desktop takes the icon from the installed `paperdoll-renderer.desktop`. With the AUR, Arch or Debian package this works out of the box. A binary run without a package (a `cargo build` in `target/release`, the AppImage or an unpacked copy) shows the generic Wayland icon until you run it once with `--install-desktop`. Restart the editor after that.

## Attribution

The default starter bodies are third-party assets, bundled in `assets/default-bodies/` and embedded in the executable:

- **Animated walking Male Base Mesh** by assetfactory — https://sketchfab.com/3d-models/animated-walking-male-base-mesh-cf6fd4382d7745468328115b49223d90 — Sketchfab Standard licence.
- **Animated Female Base Mesh** by assetfactory — https://sketchfab.com/3d-models/animated-female-base-mesh-52fc9c15cf3e49d2a5a9cee6444964ec — Sketchfab Standard licence.

The bundled female has its hair removed (and one stray one-key animation); the hair is the bundled female-only equipment preset `female-hair`, rigid on the head joint. Details are in `THIRD_PARTY_NOTICES.md`, which every package ships next to the licence. The synthetic test bodies in `tests/fixtures/bodies/` are CC0.

## Tests

`tests/robustness.rs` makes sure damaged input gives an ordinary error and never a crash: hundreds of deterministic mutations (truncation, bit flips, zero and 0xFF blocks) of the glTF/GLB, FBX and OBJ bodies, the equipment preset and the project manifest, broken `.ppda` files, and glTF nodes with scale 0 or an overflowing scale. When loading glTF, non-finite positions/normals/weights, wrong attribute lengths and indices outside the vertex range are refused; a panic in the glTF parser becomes a load error.

```bash
cargo fmt --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked   # incl. tests/robustness.rs
# Linux GPU regression: real depth occlusion, normal/depth/specular and light-independent albedo
xvfb-run -a cargo run --example preview_depth_probe --locked
# Production on a real GPU (body or equipment); then checks every atlas cell against the loose PNG
xvfb-run -a cargo run --example production_fixture --locked -- /tmp/prod tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb male 32 48 4
# Resuming on a real GPU: setup, render with crash (abort) or cancel, resume, export_set and atlas check
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
cargo run --release --example resume_fixture --locked -- verify /tmp/resume
# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
# Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor, plus KHR_materials_specular);
# screenshots the live view and checks every material colour in the production diffuse and the specular strengths
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --release --example textured_body_fixture --locked -- /tmp/tex /tmp/tex-proof tests/fixtures/bodies/multi-material body.gltf --expect-multi-material
# Depth range check on the CPU: per pitch the depth extent of every clip frame and view versus near/far
cargo run --release --example depth_clamp_probe --locked -- tests/fixtures/bodies/test-body-male.glb 2600 1000 4
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
```

The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The multi-material body in `tests/fixtures/bodies/multi-material/` (external `body.bin`, `textures/*.png`) comes from `scripts/generate_multi_material_body.py`. The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.
