# 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 `//`. 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). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again. - 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. - **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/--atlas/`. The preview reads every export through its `.ppda` and the four atlases (validated, see *Atlases and .ppda*), not through the loose PNGs. 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; the numeric offsets are shared. 4b. For a skinned part, **Bone links** lists every bone of the equipment model. For each one, choose a character bone, or leave it on *follows parent*. At least one link is needed before the part is drawn. When the part is added, and with **Auto-fill by name**, bones are linked by name: an exact match first, then a match after normalising (case, prefixes such as `mixamorig:` or `Armature|`, side markers `L`/`R`/`Left`/`Right`, numeric suffixes such as `.001`), then a partial match on the same side. Auto-fill only fills unlinked bones. Each linked bone has its own position XYZ and rotation XYZ in degrees relative to its character bone. A new link starts at the offset that keeps the bone where it sits in the model's rest pose, so equipment made on the same rig fits at once. An unlinked bone moves rigidly with its nearest linked parent bone (or with the first link if it has none). The part stays rigid per bone: each vertex follows the bone with the largest skin weight, without blending or deformation. Links are kept per sex; renders and the live preview use them the same way. 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 and origin; container version 2, version 1 presets still load and are written as version 2 on the next save). No source paths outside the project are needed to reload them. **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//`, 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 `.provenance.json` with the source path and the SHA-256 of every copied file. Choosing the same model again creates `-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 `; 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/--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/raw files/male/body/clip001-walk/f000012/p-60_y330_diffuse.png renders/male-body-atlas/male-body-diffuse-atlas.png (and -normal, -depth, -specular) renders/male-body-atlas/male-body-atlas.ppda ``` Per sex and set type there is one result folder `renders/--atlas/` holding only the newest complete render. A render is built in a temporary folder `renders/inprogress-