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>
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.pyresolvesextends(single parent, path relative toconfigs/): 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>whereBLENDERdefaults toC:/Program Files/Blender Foundation/Blender 5.1/blender.exe(envBLENDERor--blenderoverrides). - MD stages:
python tools/tailor/draft_garment.py --config work/<name>/resolved.json --emit work/<name>/md_script.py --stage <draft|drape|publish>thenpython 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.pydoes accept--stage(draft|drape|publish|all, defaultall) and additionally--timeout-hint, which prints the MD-bridge timeout this garment needs instead of emitting. register: emit-not-edit — writework/<name>/register.cs.txtcontaining the exactAdd(...)line(s) +BaseDirForcase if the set's folder is new. Data comes fromcatalog+export+parts[].slot. [as-built] theBaseDirForres:// path is derived from whereexport.out_dirsits inside the game repo, not from its basename; an out_dir outside the repo emits a placeholder plus a loud note.import: agent E'stargeted_reimport.sh— see the CLI below.verify: agent E'sclothing_motion_qa.sh— see the CLI below.bakeis 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_GENDERis0 = Male,1 = Female(ClothingTestBed.cs:27).garment.pyderives it fromexport.gender(leading "F" → 1).- Baselines live at
<animation>/clothing/baselines/<slug>/; capture out-dir is<game>/.game-cli/clothing-qa/<slug>.<slug>isexport.setlowercased (agent E's own example:BED_SET=Kapahaka→.../kapahaka). Override with the config's optionalverifyblock:slug,baseline_dir,out_dir,clips,frames. - Bless flow.
garment.pypicks the mode from the filesystem, not from a flag nobody remembers:- baseline dir exists →
--diff(regression tripwire). GARMENT_BLESS=1in the environment, or--bless-baselineon 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 withGARMENT_BLESS=1. Blessing before looking freezes a defect as the thing every future run is compared to.
- baseline dir exists →
- Both scripts speak the gate exit-code contract and write their own
qc/*.json, sogarment.pytreats an exit 2 fromimport/verifyas 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.pyis the only Blender gate (it importsbpyand opens20_fit.blend/50_skin.blend).g1,g2,g8are 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 warning —
sanitize_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(+_SkirtRigvariant). 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_slotanimates 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.blendcontainsBODY,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.Printdoes not feed/console;ClothingTestBed.ReportLoadhad 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).python3is a broken Windows Store stub — do not use it. Only stdlib + PIL; check before importing, degrade gracefully.