Files

359 lines
19 KiB
Markdown
Raw Permalink Normal View History

# 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:
```bash
# 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`:
```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`:
```jsonc
"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:
```jsonc
"_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):
```jsonc
"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.