Files
animation/clothing/PIPELINE-CONTRACT.md
T
jeremy 3ba86b2ea8 feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC.

Content:
- characters/  Lena/male body lanes, bakes, texture work, run logs
- clothing/    garment pipeline, configs, gates, contract docs
- garments/    MD-authored garment sources (.zprj/.zpac)
- UAL-Lib/     Universal Animation Library 2 source (.blend/.fbx/.glb)
- tools/       blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent
- docs/, plans/, dev/, .agents/plans/

Repo hygiene:
- .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the
  Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the
  ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out
  on purpose — ~250 are already tracked raw and converting them would
  rewrite every one without shrinking history.
- .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable
  from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies
  dropped by a `>/dev/null` redirect on Windows).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:55:43 -07:00

19 KiB

Pipeline contract — AS-BUILT (2026-07-31)

This started as the build agreement between five parallel agents. It is now the as-built description of what actually exists and what the interfaces really are; where the original agreement and the code diverged, the code won and this file was corrected. Source design: .agents/plans/clothing-pipeline-unification-2026-07-31.md.

Corrections against the original (2026-07-31 pre-build) text are flagged [as-built] so anyone holding the old version can see what moved.

File ownership (parallel build — do not edit outside your lane)

Owner Files
A (orchestrator) clothing/garment.py
B (lint gates) clothing/gates/g2_census.py, clothing/gates/g8_catalog_lint.py
C (pose gates) clothing/gates/g5_posed_sweep.py (G3 = its --rest mode)
D (MD template) tools/tailor/draft_garment.py, tools/tailor/qc_placement.py, clothing/gates/g1_drape.py
E (game side) ariki-game/tools/targeted_reimport.sh, ariki-game/tools/clothing_motion_qa.sh, ariki-game/src/Testing/ClothingTestBed.cs (env override only)

Nobody touches: clothing/garment_pipeline.py, configs/pari.json, configs/piupiu.json (weight-fix agent owns those), configs/piupiu_sb.json, .agents/wiki/**. New test/demo configs go in clothing/configs/tests/. No commits, no pushes.

Config resolution

  • garment.py resolves extends (single parent, path relative to configs/): deep-merge child over parent; arrays and scalars replace whole, objects merge per-key.
  • The resolved config is written to work/<name>/resolved.json. Every stage and gate is invoked with that file, never the raw config. <name> comes from the child config.

Path absolutization [as-built]

Before resolved.json is written, garment.py rewrites these fields from relative to absolute, resolving against the clothing/ directory (the same base garment_pipeline.py uses as HERE). Already-absolute values are untouched:

source · body · parts.*.texture · md.avatar_fbx · md.zfab · md.texture
md.snapshot · md.presim_snapshot · md.export_dir · export.out_dir

This is not cosmetic. The three consumer families disagree about what a relative path is relative to:

Consumer Base for a relative path
garment_pipeline.py HERE = clothing/ (garment_pipeline.py:265, :1005)
tools/tailor/draft_garment.py the config file's own directory, then repo root
clothing/gates/g1_drape.py (_resolve) the config file's own directory, then repo root, then cwd

resolved.json lives in work/<name>/, so "texture": "../tools/tailor/textures/piupiu.png" resolves correctly for the pipeline and to the non-existent clothing/work/tools/... for the other two. Absolutizing at the resolver makes all three agree. Asserted by garment.py --selftest. Config authors keep writing relative paths — they are relative to clothing/, as they always were.

Stage list (orchestrator)

draft drape publish | census prepare fit reduce bake skin export | register import verify
+---- MD bridge ----+ +-------- Blender headless -------------+ +---- ariki-game ----+
  • Blender stages shell to (unchanged CLI): "$BLENDER" --background --python clothing/garment_pipeline.py -- --config work/<name>/resolved.json --stage <s> where BLENDER defaults to C:/Program Files/Blender Foundation/Blender 5.1/blender.exe (env BLENDER or --blender overrides).
  • MD stages: python tools/tailor/draft_garment.py --config work/<name>/resolved.json --emit work/<name>/md_script.py --stage <draft|drape|publish> then python tools/md_bridge.py --file work/<name>/md_script.py --timeout <n>; orchestrator pings first (md_bridge.py --ping) and fails fast with the human instruction ("click Plugin > TinqsMDBridge in MD") when no session. [as-built] draft_garment.py does accept --stage (draft|drape|publish|all, default all) and additionally --timeout-hint, which prints the MD-bridge timeout this garment needs instead of emitting.
  • register: emit-not-edit — write work/<name>/register.cs.txt containing the exact Add(...) line(s) + BaseDirFor case if the set's folder is new. Data comes from catalog + export + parts[].slot. [as-built] the BaseDirFor res:// path is derived from where export.out_dir sits inside the game repo, not from its basename; an out_dir outside the repo emits a placeholder plus a loud note.
  • import: agent E's targeted_reimport.sh — see the CLI below.
  • verify: agent E's clothing_motion_qa.sh — see the CLI below.
  • bake is skipped unless the resolved config sets "bake": true (matches today's "optional, slow" reality).

Agent E's real CLIs [as-built]

The original contract sketched these as bash <script> <args...>. The built interfaces:

# import (gate G7)
bash <game>/tools/targeted_reimport.sh --report <work>/qc/g7.json <asset paths...>
#   assets accept repo-relative, absolute, or res:// paths
#   also: --dry-run (show which .godot/imported entries would be deleted, then stop)
#   exit 0 reimported+verified · 2 verification failed · 3 could not evaluate

# verify (gate G6)
BED_SET=<Set> BED_GENDER=<0|1> \
bash <game>/tools/clothing_motion_qa.sh \
    --clips "Idle Walk Dance" --frames 3 \
    [--bless <baseline-dir> | --diff <baseline-dir>] \
    --report <work>/qc/g6.json \
    <out-dir>
#   exit 0 pass · 2 outfit load error or a diffed frame over --max-diff · 3 could not spawn
  • BED_GENDER is 0 = Male, 1 = Female (ClothingTestBed.cs:27). garment.py derives it from export.gender (leading "F" → 1).
  • Baselines live at <animation>/clothing/baselines/<slug>/; capture out-dir is <game>/.game-cli/clothing-qa/<slug>. <slug> is export.set lowercased (agent E's own example: BED_SET=Kapahaka.../kapahaka). Override with the config's optional verify block: slug, baseline_dir, out_dir, clips, frames.
  • Bless flow. garment.py picks the mode from the filesystem, not from a flag nobody remembers:
    • baseline dir exists → --diff (regression tripwire).
    • GARMENT_BLESS=1 in the environment, or --bless-baseline on the command line → --bless (freeze the captured frames as the baseline).
    • neither → run with no --bless/--diff: the hard signal (outfit load errors on /console + engine stdout) still applies, the frames are captured as evidence, and a prominent banner tells you to look at them and then re-run with GARMENT_BLESS=1. Blessing before looking freezes a defect as the thing every future run is compared to.
  • Both scripts speak the gate exit-code contract and write their own qc/*.json, so garment.py treats an exit 2 from import/verify as a gate failure (honouring --no-gate-stop), not a tool crash.

Gate contract

  • Plain-python gates: python clothing/gates/<gate>.py --config <resolved.json> --work work/<name>
  • Blender gates: "$BLENDER" --background --python clothing/gates/<gate>.py -- --config <resolved.json> --work work/<name> (+ gate-specific flags)
  • [as-built] which is which: g5_posed_sweep.py is the only Blender gate (it imports bpy and opens 20_fit.blend/50_skin.blend). g1, g2, g8 are plain python (stdlib, plus PIL for g1). Overridable per gate in the resolved config: "gates": {"g5": {"kind": "plain"}}.
  • Exit codes: 0 pass · 2 fail (thresholds violated) · 3 error (could not evaluate).
  • Every gate writes work/<name>/qc/<gate_id>.json:
{ "gate": "g5", "pass": false, "checked_at_stage": "skin",
  "metrics": { "...": 0 }, "failures": [ {"part": "...", "detail": "...", "frame": "Walk[Walk_Fwd_Loop]@13"} ],
  "artifacts": ["qc/g5_frame_012.png"] }

[as-built] failures[].frame is a STRING label, not an int — "rest", "Walk[Walk_Fwd_Loop]@13", "Synthetic:arm_raise_70". A bare frame number is ambiguous once more than one clip is sampled.

[as-built] every entry in G5's metrics.per_frame also carries "source": "rest" | "clip" | "synthetic", and metrics splits the labels into clips and synthetic (+ synthetic_frames count) — a mixed sweep (pose_source: "both", i.e. --qc deep) is then readable without parsing label strings.

Orchestrator gate map [as-built]

Stage Gate Invocation Runs when Report file
drape G1 plain expect.bands present qc/g1.json
census G2 plain expect.islands present qc/g2.json
fit G3 Blender, g5_posed_sweep.py --rest expect.penetration present qc/g3.json
skin G5 Blender, g5_posed_sweep.py expect.penetration present qc/g5.json
register G8 plain always qc/g8.json
import G7 E's script always (stage IS the gate) qc/g7.json
verify G6 E's script always (stage IS the gate) qc/g6.json

The fit row is the one that trips people: there is no g3_*.py. G3 is g5_posed_sweep.py --rest, it sets "gate": "g3" / "checked_at_stage": "fit" in its report, and it writes qc/g3.json, not qc/g5.json. Gate failure stops the run unless --no-gate-stop.

QC modes [as-built]

garment.py --qc light|deep. A mode is a profile: a small dict of sampling/cost overrides merged into the resolved config at resolve time, before resolved.json is written. Gates stay mode-unaware — they receive the same file they always did, with different numbers in it. Owned by garment.py (QC_PROFILES, apply_qc_mode()).

light (default) deep
expect.penetration.clips ["Walk"] ["Idle", "Walk", "Dance"]
expect.penetration.frames_per_clip 2 8
expect.penetration.anim_pack [UAL2.glb] [UAL1.glb, UAL2.glb]
expect.penetration.pose_source auto both (clips + synthetic extremes)
expect.penetration.max_render_frames 2 12
verify.clips / verify.frames "Walk" / 1 "Idle Walk Dance" / 3
gate failure reports, run continues stops the run

light uses UAL2 alone because Walk aliases onto UAL2's Walk_Fwd_Loop; the second pack costs ~7 s of import and contributes nothing when Idle/Dance are not sampled. Measured on configs/tests/piupiu_sb_test.json --from census --to export: 33.3 s light vs 42.6 s deep end to end, all of the delta in G5 (9.9 s vs 19.1 s).

Which keys a profile may touch

Only these (QC_PROFILE_KEYS in garment.py):

expect.penetration : clips  frames_per_clip  anim_pack  pose_source  max_render_frames
verify             : clips  frames
(top level)        : gate_stop

Anything else in a config's qc.<mode> block is dropped with a warningsanitize_qc_profile(). A profile can therefore never move a THRESHOLD: depth_mm, max_verts, max_verts_rest, gape.* budgets, expect.islands, expect.bands, expect.budget belong to the config author in both modes. Switching modes changes how hard the pipeline looks, never what counts as a defect.

expect.penetration is only touched when it already exists — its presence is what arms G3/G5 (see the gate map), so a profile must not conjure a gate the author never asked for. A verify block is created if missing (harmless: the stage runs only when named).

Precedence

builtin QC_PROFILES[mode]  ->  config `qc.<mode>` block  ->  CLI

for the cost knobs above, applied over whatever the config's own expect/verify says for those keys — a mode that could not override the config as written would not be a mode. Everything outside the whitelist keeps the config's value. A config that wants to keep its own value for one cost knob lists it in qc.pin:

"qc": {
  "default_mode": "deep",                 // used when --qc is omitted
  "pin": ["clips", "verify.frames"],      // shorthand = expect.penetration.<key>
  "light": { "expect": { "penetration": { "frames_per_clip": 4 } },
             "gate_stop": true },
  "deep":  { "expect": { "penetration": { "clips": ["Idle", "Walk", "Dance", "Haka"] } } }
}

Mode selection: --qc > qc.default_mode > light.

Stopping, and the reminder

light implies --no-gate-stop (failures are reported, the run finishes, exit code is still 2). --gate-stop forces stop-on-failure inside light; --no-gate-stop forces continue inside deep; the two flags together are an error. CLI beats the profile either way. A run with --qc omitted prints QC mode: light -- run --qc deep before sign-off at the end of the summary.

Recorded in resolved.json

_resolved.qc_mode carries the applied mode and the effective knob values, so a QC report can always be traced to how hard it looked:

"_resolved": { "qc_mode": {
  "mode": "light", "source": "default",        // or "--qc" / "config qc.default_mode"
  "gate_stop": false, "gate_stop_source": "profile",
  "applied": { "expect.penetration.clips": ["Walk"], "...": "..." },
  "pinned": [], "notes": [] } }

The same values are printed in the run banner.

expect block schema

Contract baseline (unchanged — a config carrying only these keys still works):

"expect": {
  "islands": { "count": 7, "mapped": { "0": {"verts": [4000, 6000], "z": [0.90, 1.35]} } },
  "penetration": {
    "max_verts": 0, "depth_mm": 1.0,             // garment verts inside body
    "gape": { "<part>": {"band_z": [0.95, 1.30], "max_exposed_verts": 0} },
    "clips": ["Idle", "Walk"],                   // g5 posed mode; pack clips by name
    "frames_per_clip": 6
  },
  "bands": { "<band-name>": {"top_m": 1.31, "bottom_m": 1.05, "tol_m": 0.03} },
  "budget": { "<part>": {"tris": 2200, "min_iou": 0.985} }
}

Each gate's docstring is the authority on its own extensions. As built:

expect.islands (G2) [as-built]

Extra keys beyond the baseline: require_all_mapped (bool; unmapped non-trivial islands fail instead of warn) and, per mapped island, tris, z_min, z_max, z_span, x. mapped keys may be an island index or a part name (when that part maps exactly one island). Every range accepts [lo, hi] (either endpoint may be null), a bare number (exact match), or {"min":, "max":}.

UNITS: census units, i.e. pre-scale source-mesh units, NOT metres. align.scale_z is applied later in prepare, so the piupiu census reads z 5.184..11.126, not 0.52..1.11. Read the numbers off work/<name>/census.json rather than converting.

G2 also always cross-checks that every index in parts[*].islands exists in census.json, with or without an expect block — that is the check that catches an MD re-export silently renumbering islands.

expect.penetration (G3/G5) [as-built]

All optional, all defaulted:

Key Default What
anim_pack both anim/UAL1.glb and anim/UAL2.glb GLB(s) to pull clips from. Both, because the game binds idle/dance from UAL1 but aliases "walk" onto UAL2's Walk_Fwd_Loop.
clip_aliases built-in map mirroring the game's bindings {"Idle": "Idle_Loop", ...}; then fuzzy match (shortest containing name wins).
pose_source "auto" auto | clips | synthetic | both. auto = clips, falling back to synthetic extremes when nothing resolves. [as-built] both = the clips and then the synthetic extremes (what --qc deep sets); it degrades to synthetic-only if no clip resolves. The gate never passes for lack of animation.
ignore_parts [] Parts to skip. "type": "bodyshell" parts are always skipped — they are the body.
max_render_frames 6 Cap on failing-frame QA PNGs (budget is spread across clips). 0 disables.
max_verts_rest = max_verts Separate, looser rest-pose budget in full mode.

gape.<Part> entries: band_z required ([lo, hi], rest-pose world Z); optional band_x, band_y, facing + facing_min, ray_mm (50), gap_mm (25; a hit farther than this counts as exposed — ballooned fabric), max_exposed_verts (0), max_exposed_delta (threshold on posed-minus-rest exposure; when both are present a frame must satisfy both), check_rest (false), parts (which garment parts count as cover). Band vertices are selected once in rest pose and the same indices re-tested every frame, because anatomy is stable and posed Z is not.

expect.bands + expect.drape (G1) [as-built]

expect.bands is what schedules the gate. Per band: top_m, bottom_m, tol_m (+ per-edge top_tol_m / bottom_tol_m), from ("cover" default = rows where the garment covers ≥ min_row_cover of the body's width, i.e. the band proper; "extent" = the loose mask, which also picks up straps, ties and fringes), band_index.

expect.drape carries the measurement parameters: snapshot, height_m (1.777), landmarks, min_row_cover (0.40), min_row_extent (0.05), scale, z_range, and mask. G1 is image-based because MD exposes no mesh introspection to Python (GetClothPositions() returns nothing), so it measures the rendered viewport.

The mask block is effectively required in practice. Its default mode "auto" is a crude skin/background heuristic that counts baked-in underwear and shaded skin as garment. Any garment with a generated texture — i.e. all of ours — needs "mask": {"mode": "colors", "colors": [[173, 37, 39]], "tol": 30} (or "mode": "sat"). Snapshot resolution order: --snapshot > expect.drape.snapshot > md.snapshot.

Shared facts

  • Body GLBs: ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb (+ _SkirtRig variant). 65-bone Quaternius skeleton, Lena height 1.777 m.
  • Anim packs for posed sweeps: ariki-game/assets/quaternius/anim/ (UAL GLBs) — verified to be the same 65-bone skeleton as the derived-body RIG (exact name-set match), so G5 assigns actions directly with no retarget. Rigs with EXTRA bones (piupiu_sb's 16 skirt bones) are fine: unanimated bones ride their parents.
  • Blender 4.4+/5.x slotted actions: an action with no bound action_slot animates nothing, silently. G5 binds the slot explicitly; anything else posing a rig must too.
  • Existing checkpoints for testing: clothing/work/piupiu/, work/piupiu_sb/, work/pari/50_skin.blend contains BODY, RIG, GARM_*, BODY_SHELL.
  • Known-bug ground truth for G5 validation: pari neckline gapes at sternum in Walk/Dance; a thigh punches through the piupiu skirt. A correct G5 FAILS on these checkpoints. [as-built] the piupiu thigh poke reproduces headlessly and G5 catches it. The pari neckline gape does not reproduce in the Blender checkpoint — it is not present in the skinned mesh G5 measures, so its origin is in-game (attach/material/LOD), which makes it G6 territory rather than G5.
  • Game agent API (when a bed is spawned): /health /screenshot /navigate /state /scene /ui /console. [as-built] GD.Print does not feed /console; ClothingTestBed.ReportLoad had to be added before load failures surfaced there at all.
  • Python for plain gates: system python (3.12 on this box; PIL yes, numpy no). python3 is a broken Windows Store stub — do not use it. Only stdlib + PIL; check before importing, degrade gracefully.