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>
This commit is contained in:
2026-08-06 15:55:43 -07:00
parent 3363209cac
commit 3ba86b2ea8
558 changed files with 68622 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
work/
+358
View File
@@ -0,0 +1,358 @@
# 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.
+270
View File
@@ -0,0 +1,270 @@
# Clothing Pipeline — garment mesh → game-ready outfit part
**What this is:** a headless-Blender pipeline that takes a clothing mesh from any
source (Marvelous Designer export, marketplace FBX/OBJ/GLB, hand-modeled) and turns
it into outfit-part GLBs that ariki-game characters actually wear — fitted to the
real body, reduced to game budget, skinned to the shared 65-bone Quaternius
skeleton, and registered in the game's `OutfitCatalog`.
Built 2026-07-30 after the CC5/Reallusion clothing lane was investigated and
rejected (CC5 provably refuses clothing tools on AccuRig "Humanoid" characters —
verified via its Python API *and* GUI; see `ariki-game` session notes). Everything
here is Blender-only, deterministic, and agent-drivable end to end.
## Why each stage exists
A downloaded garment is useless to the game for four reasons; the pipeline is the
four fixes in order:
| Problem | Fix | Stage |
|---|---|---|
| Not shaped like our character | Shrinkwrap-fit to the real body GLB (inflated "inner shell" target guarantees clearance) | `fit` |
| Millions of tris vs ~1.5k/part budget | Split by material into parts, rebuild/decimate, bake lost detail to normal+AO maps | `reduce`, `bake` |
| No idea how to move | Copy skin weights from the character's own body (special cone-proxy recipe for skirts) | `skin` |
| Game has never heard of it | Export per-slot GLBs on the shared skeleton + register in `OutfitCatalog` | `export` + manual catalog entry |
## Design decisions (and where they came from)
Reviewed by GLM-5.2 and glm-4.6 before implementation
(`../garments/glm-advice-glm52.md`, `../garments/glm-advice.md`) plus web research
on Marvelous Designer game workflows. Key adopted corrections:
1. **Fit BEFORE decimate, bake AFTER** — shrinkwrapping the high-poly garment
molds real pleat geometry; fitting a faceted low-poly loses the silhouette.
2. **Pleated skirts are not decimated** — a clean low-poly cylinder proxy is
generated and shrinkwrapped to the high-poly skirt; pleats live in the baked
normal map (at 1.5k tris geometric pleats read as noise).
3. **Skirt weights come from a "cone proxy"** — a leg-bridging tapered cylinder
weighted with a Z-gradient (body weights at waist → pelvis-only at hem), then
transferred to the skirt. Kills the hard left/right-leg seam that tears skirts
in walk cycles. Forward-compatible with skirt bones later.
4. **Clearance via inner shell** — garments wrap to a ~4mm inflated copy of the
body, not the body itself: mathematical clearance, not hope.
5. **Layer stacking** — bow wraps to bodice, bodice wraps to body shell
(cumulative offsets), so layered cloth keeps separation.
6. **Weight transfer uses Data Transfer 'nearest face interpolated'**, not raw
nearest-vertex KDTree (fails on overhangs like collars/bow tails).
7. Per-part tri budgets vary (bow ~400, bodice ~1200, skirt ~2200);
verify in the game test bed EARLY (after skin), not at the end.
Sources also agree: if a garment came from Marvelous Designer, re-exporting from
MD with lower particle density / MD's own quad remesh skips the worst reduction
pain at the source. Prefer that when the .zprj is available.
## Files
| File | What |
|---|---|
| `garment_pipeline.py` | The whole pipeline. One Blender headless script, staged; each stage checkpoints a .blend into `work/` so stages can be re-run/iterated independently. |
| `configs/<garment>.json` | Per-garment recipe: source file, island→part mapping, slots, budgets, alignment, fit, cuts. The pipeline is data-driven; new garment = new config, not new code. |
| `work/` | Intermediates (checkpoint .blends, census data, QA renders). Disposable, gitignored. |
| `README.md` | This file. |
## Stages as-built (2026-07-30, after the dress pilot)
| Stage | What it does | Hard lessons baked in |
|---|---|---|
| `census` | Import garment, drop configured junk materials, weld, split into loose **islands**, render each in a distinct color + write `work/<name>/census.json` | MD material names lie (all fabric on one material; 4M faces were topstitch threads → `drop_materials`). Parts are configured as island index lists, not material names. |
| `prepare` | Import body+rig, build configured parts from islands, **bodyshell** parts cut from the body itself, sleeve pose-warp (garment-local, pre-align), anisotropic align (`scale_xy`/`scale_z` — heroic bodies are wide, not tall), radial `torso_boost`, post-align `cut_above_z`/`cut_outboard_x`, `prune_scraps_x` (drop disconnected leftovers by centroid), fabric `texture` on MD's own UVs | Fitted tops on a bust bigger than the garment shred under any shrinkwrap — use a **bodyshell** part (body region copy + normal offset, Islander technique): perfect fit and weights by construction. Cuts must run post-align in body space. MD's exported UVs already sample the fabric PNG correctly — leave `texture_offset` at zero, and judge any change in the game, since Workbench QA renders ignore shader Mapping nodes. |
| `fit` | Inflated `BODY_SHELL` (+4mm) target; shrinkwrap mode **OUTSIDE** (push out penetrating verts ONLY — never snap the whole cloth, that shreds layered garments); optional `hem_mm` adds a second OUTSIDE pass masked 0 at hip → 1 at hem for graded leg clearance on skirts | `OUTSIDE_SURFACE` destroyed multi-layer bodices; `OUTSIDE` unmasked is safe everywhere since it can't pull cloth inward. A uniform 4mm fit clips in walk cycles — hems need 1520mm where legs travel (`hem_mm`), while the waist stays snug. |
| `reduce` | Keep `HI_<part>` copy for baking; `weld_band` fuses stacked layers (waistband/belt sandwiches) before decimation; planar dissolve → collapse to per-part budget; optional cylinder proxy for standalone skirts | Decimating coincident stacked layers = swiss cheese; protecting zones from the decimator just moves the damage elsewhere — **weld the sandwich first**. |
| `bake` | (optional, Cycles) normals+AO from HI onto a second UV map | |
| `skin` | Data Transfer (nearest-face-interpolated) from body; `weights: dress` adds pelvis-gradient below hip so the hem doesn't tear between legs; bodyshell parts keep inherited weights; smooth+normalize; bind to RIG | |
| `export` | One GLB per outfit slot (multi-part slots joined by selection), full 65-bone armature included, vertex hash printed for determinism | |
Related repo tooling this builds on (in `ariki-game/tools/`):
`cc_clothing_to_quaternius.py` (earlier one-shot converter — superseded by this
for production but its skeleton-frame alignment math is the reference),
`make_islander_outfits.py` (KDTree weight-copy precedent + tri budgets),
`make_fitted_body.py` / `race-body-fitting` skill (measure-then-warp doctrine).
## Usage
```bash
BLENDER="/c/Program Files/Blender Foundation/Blender 5.1/blender.exe"
cd /c/Users/Jeremy/tinqs/animation/clothing
# run stages in order (each saves work/<NN>_<stage>.blend + QA renders)
"$BLENDER" --background --python garment_pipeline.py -- --config configs/dress.json --stage prepare
"$BLENDER" --background --python garment_pipeline.py -- --config configs/dress.json --stage fit
"$BLENDER" --background --python garment_pipeline.py -- --config configs/dress.json --stage reduce
"$BLENDER" --background --python garment_pipeline.py -- --config configs/dress.json --stage bake # optional, slow (Cycles)
"$BLENDER" --background --python garment_pipeline.py -- --config configs/dress.json --stage skin
"$BLENDER" --background --python garment_pipeline.py -- --config configs/dress.json --stage export
```
Outputs land in the config's `export.out_dir` (default
`ariki-game/assets/quaternius/outfits/<set>/`), one GLB per outfit slot, ready for
an `OutfitCatalog.Register` entry (see `ariki-game/src/Character/OutfitCatalog.cs`
`Initialize()` — path pattern `{Gender}_{Set}_{Slot}`).
## Shipped garments
| Config | Source | In game as | Notes |
|---|---|---|---|
| `pari.json` | `../tools/tailor/lena_pari_v3_garment.fbx` (MD, ours) | `Kapahaka` / Body | Tāniko bodice. First **MD-authored** garment through the pipeline (2026-07-31) — the upstream/downstream seam. |
| `piupiu.json` | `../tools/tailor/lena_piupiu_v2_garment.fbx` (MD, ours) | `Kapahaka` / Legs | Flax skirt, `hem_mm: 14`. |
| `dress.json` | `../garments/thin-unweld.fbx` (downloaded) | `MDDress` / Body | The pilot: 2.49M verts, 12 materials, bodice + belt/bow + pleated skirt. |
MD-authored garments are the easy case and confirm the `clothing-lane.md`
prediction: both kapa haka pieces arrived at game budget already (their 4k/8k tri
ceilings never bit) and needed no alignment beyond a `z_nudge`, because they were
draped on `Ariki_Female_QuatSkin.glb` itself. `reduce` is only the hard stage for
downloaded meshes.
Deferred on the dress: the sleeved jacket (`cape*` materials) — sleeves need a
pose-warp to T-pose (GLM advice §4 has the recipe). Unknown `Material*` slots are
excluded pending the per-material stats `prepare` prints; reassign them in the
config if they turn out to be buttons/trim.
## QA renders only prove the pose that cannot fail
Every `qa_*` render this pipeline writes is the **rest pose** — the pose the
garment was fitted in. A garment that clears the body perfectly there can still
open at the neckline or let a limb through the moment the skeleton moves, because
the weights (not the fit) decide that. Both kapa haka pieces did exactly this: the
Blender QA showed a closed scoop neck and a hem covering both thighs; in the game
bed, the neckline opens over the sternum and a thigh comes through the skirt.
So `skin` is not the last checkpoint — **the game test bed is**, and it has to be
driven through more than one clip (`Idle` / `Walk` / `Dance` buttons in
`ClothingTestBed`; the AnimationTree used to override them, fixed 2026-07-31).
Treat a rest-pose-only sign-off as unverified.
## Unified orchestrator + QC gates (2026-07-31)
The stage-at-a-time invocations above still work and still own the logic. What
changed is that you no longer have to type them, and that each seam now has a
check that fails **before** the defect ships.
```bash
cd /c/Users/Jeremy/tinqs/animation
python clothing/garment.py configs/piupiu.json --from census --to export
```
`garment.py` is a driver, not a rewrite: every stage shells out to the tool that
already owns it (`garment_pipeline.py` for Blender, `md_bridge.py` for MD, agent
E's scripts for the game). It resolves the config's `extends` chain, absolutizes
its paths and writes `work/<name>/resolved.json` — every stage and gate is then
handed that one file, never the raw config.
| | Stage | Runs where |
|---|---|---|
| upstream | `draft` `drape` `publish` | Marvelous Designer, over the MD bridge |
| core | `census` `prepare` `fit` `reduce` `bake` `skin` `export` | Blender headless |
| downstream | `register` `import` `verify` | ariki-game |
Default range is `census..export` — the Blender half. MD and game stages run only
when you ask for them by name (`--from`/`--to`/`--only`). Other flags worth
knowing: `--list` (what would run, and which gates are armed), `--dry-run` (print
the exact commands), `--no-gate-stop` / `--gate-stop` (override the QC mode's
stop-on-failure behaviour), `--selftest`.
### QC modes — `--qc light` (default) and `--qc deep`
Two ways to use the pipeline, two costs.
```bash
python clothing/garment.py configs/x.json --from census --to export # light
python clothing/garment.py configs/x.json --qc deep --from census --to export
```
**light** is the iterate-on-a-garment loop: one clip (`Walk`), two frames, one
anim pack, two QA renders, and a failing gate **reports instead of stopping** so
one run tells you everything that is wrong with a work-in-progress garment.
**deep** is the sign-off sweep: `Idle Walk Dance`, eight frames each, both packs,
the synthetic extremes appended on top, twelve renders, and a failing gate stops
the run. Measured end to end on `configs/tests/piupiu_sb_test.json`,
`--from census --to export` (Blender 5.1, this box):
| | census | g2 | prepare | fit | **G3** | reduce | skin | **G5** | export | **total** |
|---|---|---|---|---|---|---|---|---|---|---|
| light | 3.3s | 0.1s | 4.2s | 3.3s | 2.9s | 3.2s | 3.6s | **9.9s** | 2.8s | **33.3s** |
| deep | 3.1s | 1.1s | 4.0s | 3.1s | 2.9s | 3.1s | 3.4s | **19.1s** | 2.8s | **42.6s** |
All of the difference is G5: 13 s of gate time in light (1 rest + 2 Walk frames,
UAL2 only) against 23 s in deep (1 rest + 24 clip frames + 4 synthetic extremes,
both packs). ~7 s of light's G5 is importing the one anim pack — loading UAL1 as
well costs another ~7 s and buys nothing when only `Walk` is sampled, and `Walk`
is aliased onto **UAL2's** `Walk_Fwd_Loop`. Light still catches the shipped
thigh-poke on both of its Walk frames (168 and 90 verts inside the body, ~80 mm
deep) — the bug fires on every frame, which is exactly why two are enough to
iterate against. Deep is what tells you the whole shape of it: worst frame 231
verts (Idle), the Dance clip light never samples, and a synthetic `step_thigh_60`
extreme at 187 verts / 95 mm.
A mode only ever changes **how hard the pipeline looks** — the sampling and cost
knobs. It can never touch a threshold (`depth_mm`, `max_verts`, gape budgets):
those are the config's, so a garment that passes light and fails deep failed on
frames light did not sample, never on a moved goalpost. Per-garment overrides go
in the config's `qc` block; full precedence and the key whitelist are in
[`PIPELINE-CONTRACT.md`](PIPELINE-CONTRACT.md#qc-modes-as-built).
Omitting `--qc` gives you light plus a one-line reminder at the end of the run
(`QC mode: light -- run --qc deep before sign-off`). Run deep before you bless a
baseline or register a set.
### The gates
A gate runs after its stage, and only when the config carries the `expect` block
that gives it thresholds — no thresholds, no opinion. All of them write
`work/<name>/qc/<gate>.json` and speak one exit code: **0 pass · 2 fail · 3 could
not evaluate**.
| Gate | After | What it catches |
|---|---|---|
| G1 | `drape` | Band placement in the MD snapshot — hem/top edge off its target in metres. Found the shipped pari hem sitting **+3.9 cm** and the piupiu bands **+8/+14 cm** above their own written targets. Needs `expect.bands` (+ a `drape.mask` palette; the default heuristic counts shaded skin as fabric). |
| G2 | `census` | An MD re-export renumbering islands, so `parts[*].islands` now points at the wrong geometry and every later stage builds the wrong garment. Needs `expect.islands` — in **census units** (pre-`align.scale_*`), read straight off `census.json`. |
| G3 | `fit` | Rest-pose clearance. Same script as G5 (`g5_posed_sweep.py --rest`) — there is no `g3_*.py` — and it writes **`qc/g3.json`**. Needs `expect.penetration`. |
| G5 | `skin` | **The one that matters.** Poses the skinned checkpoint with the real game clips and counts garment verts inside the body, per frame. This is the gate that turns "QA renders only prove the pose that cannot fail" (above) into a number. Needs `expect.penetration`. |
| G8 | `register` | Catalog↔asset lint: a missing `.gltf` (warn-only at runtime — the character just goes naked), a set with no `BaseDirFor` arm (silently resolves to the *Fantasy* pack folder), duplicate ids, unregistered exports. Always runs. |
| G7 | `import` | `targeted_reimport.sh` — deletes just this asset's `.godot/imported/` entries, re-imports, and verifies they came back fresh. The blunt fix for the `.bin`-hash problem in "Known limitations" below, without a 17k-entry full reimport. |
| G6 | `verify` | `clothing_motion_qa.sh` — spawns `ClothingTestBed` on the set, drives Idle/Walk/Dance, captures frames, and fails on any outfit load error. **Spawns with `SPAWN_BUILD=0`** — after editing game C# (catalog/bed entries), run `bash tools/game.sh build` first or G6 silently QAs the stale binary (its fallback messages will quote constants your edit already changed — that's the tell). |
G5 is the only gate that runs under Blender; the rest are plain `python`.
### First sign-off and blessing (G6)
G6's hard signal is deterministic (an outfit that failed to load says so). Its
soft signal is a pixel diff against a blessed baseline, and that has a measured
floor: re-running the same outfit moves ~0.002 of the frame, swapping the outfit
**entirely** moves 0.014. So the diff is a regression tripwire for "the garment
vanished or grossly changed" — it will not see a gaping neckline. G5 owns that
class of bug.
Which means the first sign-off on a new garment is a human or VLM looking at the
frames, and the flow is deliberately two-step:
```bash
# 1. no baseline yet -> captures evidence, no diff, and tells you to go look
python clothing/garment.py configs/piupiu.json --only verify
# 2. once the frames are actually good, freeze them
GARMENT_BLESS=1 python clothing/garment.py configs/piupiu.json --only verify
# (or --bless-baseline)
# 3. every later run diffs against that baseline automatically
python clothing/garment.py configs/piupiu.json --only verify
```
Baselines live in `clothing/baselines/<set-lowercased>/`. Blessing before looking
freezes a defect as the thing every future run is compared to — that is why step 1
refuses to bless for you.
Interfaces, schemas and the full `expect` reference:
[`PIPELINE-CONTRACT.md`](PIPELINE-CONTRACT.md).
## Known limitations (v1)
- Skirt will still favor standing/dance poses; deep leg swings compress the hem
(no skirt bones yet — the Z-gradient weights are ready for them later).
- Albedo is the MD fabric PNG re-applied over MD's own exported UVs (part
`texture` in the config); the bake stage still only produces normal+AO. Parts
without a `texture` fall back to the flat `color`.
- Body stays under garments (no hide-mask system in game yet) — clearance is the
only poke-through defense; report tunnel-through in the test bed per animation.
- Re-export alone does not reach the game. `GLTF_SEPARATE` keeps geometry and UVs
in the sidecar `.bin`, but Godot's reimport check hashes only the `.gltf`, so a
vertex- or UV-only change is silently skipped. Delete that asset's entries in
`.godot/imported/` (those two files only — never the folder) and run
`godot --headless --import --path .` to force it.
+105
View File
@@ -0,0 +1,105 @@
{
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig_4seg.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"min_island_verts": 40,
"name": "bottomTest1",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v3_garment.fbx",
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Bottom": {
"slot": "Legs",
"islands": [
0
],
"tris": 8000,
"planar_deg": 0,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 80,
"_note_hem": "80mm, up from the 14 the old piupiu used. This is what stops the leg clipping through: it gives the skirt a real BELL that the leg swings INSIDE, rather than cloth lying on the thigh for the leg to punch through. Graded 0 at the hip to full at the hem, so the waistband still rides the hip. 45 was not enough; 80 holds through Walk and Dance."
},
"weights": "skirt_bones",
"color": [
0.45,
0.35,
0.15
],
"texture": "../tools/tailor/textures/bottom_test1.png",
"_note_planar": "0, NOT the default 5. reduce applies a planar DISSOLVE unconditionally before the tri-budget check, and these strands are flat strips, so deg 5 collapsed 3012 verts -> 884, destroying the vertical rows the 4-segment skirt ring needs to bend smoothly (the rig session's ask 4). The 8000-tri budget was never the constraint (4136).",
"_note_texture": "bottom_test1.png is generated by tools/tailor/textures/make_bottom_test1.py and MUST stay V-only (constant along U). A strand garment samples a different U slice per strand, so any horizontal variation makes the woven bands misalign strand-to-strand and read as noise. Measured before/after: mean horizontal stdev per row 50.2 -> 5.9. The pre-strand original is kept as bottom_test1_prestrand.png."
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/test1",
"gender": "Female",
"set": "Test1",
"note": "32 discrete strands from a 60 mm rigid waistband - Legs slot. Bound to the 4-SEGMENT skirt ring."
},
"catalog": {
"id": "bottom_test1_f",
"displayName": "Bottom Test 1",
"charisma": 0.1,
"workSpeed": 0.02,
"category": "ceremonial"
},
"expect": {
"penetration": {
"depth_mm": 1.0,
"max_verts": 0,
"clips": [
"Idle",
"Walk"
],
"frames_per_clip": 6
},
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [
2861,
3162
],
"tris": [
3929,
4342
],
"z": [
4.21,
11.26
],
"x": [
-2.73,
2.79
]
}
}
}
},
"post_export": {
"_why": "MANUAL, REQUIRED, ORDER-SENSITIVE. clothing/skirt_garment_weights.py owns skirt weights; garment_pipeline.py's own 2-segment strand_weights is overwritten by it and whichever runs LAST wins. Must run AFTER export, BEFORE import. Nothing in garment.py enforces this \u2014 use clothing/reproduce_test1.py, which reads this block.",
"tool": "clothing/skirt_garment_weights.py",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig_4seg.glb",
"env": {
"SKIRT_HEM_FREE": "0.02",
"SKIRT_FOLLOW_MAX": "0.70",
"SKIRT_CONTACT_R": "0.22",
"SKIRT_FALLOFF": "0.14"
},
"_why_env": "The tool's defaults (FOLLOW_MAX 0.20, HEM_FREE 0.55) were tuned against the SHORT continuous piupiu. This skirt hangs to y 0.451 with the knee at ~0.517, so HEM_FREE 0.55 put the whole knee region in the no-follow band and the knee came through the cloth. These values lift mean thigh follow 0.128 -> 0.698, which is what carries cloth WITH the knee. Env overrides were ADDED to that script by this session; its defaults are unchanged so other garments are unaffected.",
"expect": "mean thigh follow ~0.70, NO dead strands (all 8 non-zero)"
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"min_island_verts": 40,
"name": "bottomTest2",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_skirt_v1_garment.fbx",
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Bottom": {
"slot": "Legs",
"islands": [
0
],
"tris": 4000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 30,
"_note_hem": "Short A-line mini: 30mm graded hip->hem clearance so leg swing stays inside the hem. Standard dress weights (no skirt bones) - this is a first-pass review asset on the STANDARD body; expect the known no-skirt-bones hem compression on deep leg swings."
},
"weights": "dress",
"color": [
0.15,
0.25,
0.75
]
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/test2",
"gender": "Female",
"set": "Test2",
"note": "Blue A-line mini skirt (lena_skirt_v1, MD-authored proof garment 2026-07-30) - Legs slot, dress-gradient weights on the standard body."
},
"catalog": {
"id": "bottom_test2_f",
"displayName": "Bottom Test 2",
"charisma": 0.06,
"workSpeed": 0.05,
"category": "casual"
},
"expect": {
"penetration": {
"depth_mm": 1.0,
"max_verts": 0,
"clips": [
"Idle",
"Walk"
],
"frames_per_clip": 6
},
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [
1851,
2045
],
"tris": [
3583,
3961
],
"z": [
8.2,
11.7
],
"x": [
-2.2,
2.4
]
}
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"name": "bottom_test1",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v3_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig_4seg.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.028,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Bottom": {
"slot": "Legs",
"islands": [
0
],
"tris": 8000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 14
},
"weights": "skirt_bones",
"color": [
0.45,
0.35,
0.15
],
"texture": "../tools/tailor/textures/bottom_test1.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/test1",
"gender": "Female",
"set": "Test1",
"note": "piupiu v3 (32 discrete flax strands from a 60mm rigid waistband) as the neutral-named bottom_test1_f. Exported against the 4-SEGMENT SkirtRig body so the skin carries all 32 skirt joints."
},
"catalog": {
"id": "bottom_test1_f",
"displayName": "Bottom Test 1",
"charisma": 0.1,
"workSpeed": 0.02,
"category": "ceremonial"
},
"expect": {
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [
2900,
3100
],
"tris": [
3950,
4300
],
"z": [
4.4,
11.2
],
"x": [
-2.6,
2.7
]
}
}
},
"penetration": {
"depth_mm": 1.0,
"max_verts": 0,
"clips": [
"Idle",
"Walk"
],
"frames_per_clip": 6
}
},
"min_island_verts": 40,
"_note": "NAMING: no cultural names on test garments (Jeremy 2026-07-31) - see ariki-game/src/Character/OutfitCatalog.cs class doc. Item id snake_case + gender suffix (bottom_test1_f), set id PascalCase (Test1) because it composes {Gender}_{Set}_{Slot}.gltf, folder lowercase (outfits/test1/).\n\nexpect.islands is MEASURED from work/bottom_test1/census.json (2026-07-31), not carried over from v2: 1 island, 3002 verts, 4120 tris, z 4.685..10.893, x -2.338..2.391 in PRE-SCALE census units (align.scale_* 0.1 applied later in `prepare`), i.e. hem 0.469 m and band top 1.089 m, matching the MD-side measurement. Bounds widened for tolerance, not tightened. v3 renumbered the MD patterns to Pattern_18856 / Pattern_20333 (v2's were Pattern_31891 / Pattern_32004) - README calls a config mapping the wrong islands 'the silent killer', so G2 was run first and passed on 1 island.\n\nSKIRT WEIGHTS HAVE ONE OWNER. garment_pipeline.py:876 strand_weights is hardcoded to two segments; this garment needs four. Run animation/clothing/skirt_garment_weights.py AFTER export - it reads segment count off the rig - or the game gets a 2-segment blend that reads as a rig regression."
}
+88
View File
@@ -0,0 +1,88 @@
{
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"min_island_verts": 40,
"name": "capeTest1",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_cape_v2_garment.fbx",
"align": {
"top_bone": "neck_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Cape": {
"slot": "Body",
"islands": [
0
],
"tris": 8000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "dress",
"color": [
0.88,
0.88,
0.86
],
"_note_weights": "dress gradient: cape hem hangs to mid-thigh; nearest-face transfer alone would split the hem between legs in walk cycles, the pelvis gradient below hip keeps it hanging as one panel."
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/capetest1",
"gender": "Female",
"set": "CapeTest1",
"note": "Broad mantle cape (lena_cape_v2, 2026-08-04, ref tools/tailor/references/cape_test1_ref.png) - Body-slot overlay like the feather cloak. Flat color until textured."
},
"catalog": {
"id": "cape_test1_f",
"displayName": "Cape Test 1",
"charisma": 0.12,
"workSpeed": 0.01,
"category": "ceremonial"
},
"expect": {
"penetration": {
"depth_mm": 1.0,
"max_verts": 0,
"clips": [
"Idle",
"Walk"
],
"frames_per_clip": 6
},
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [
4375,
4835
],
"tris": [
8480,
9372
],
"z": [
4.0,
14.7
],
"x": [
-2.65,
2.77
]
}
}
}
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"name": "dress",
"source": "C:/Users/Jeremy/tinqs/animation/garments/thin-unweld.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "neck_01",
"scale_xy": 1.18,
"scale_z": 0.95,
"z_nudge": 0.03,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Dress": {
"slot": "Body",
"islands": [
0,
10,
11,
12,
13
],
"cut_above_z": 1.13,
"tris": 4500,
"planar_deg": 5,
"bake_size": 1024,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "dress",
"color": [
0.72,
0.3,
0.28
],
"cut_outboard_x": 0.26,
"cut_outboard_z_above": 1.02,
"prune_scraps_x": 0.2,
"weld_band": [
0.93,
1.14,
0.008
]
},
"Bodice": {
"slot": "Body",
"type": "bodyshell",
"z0": 1.1,
"z1": 1.385,
"bones": [
"spine_01",
"spine_02",
"spine_03"
],
"offset_mm": 6,
"tris": 1400,
"planar_deg": 3,
"bake_size": 512,
"color": [
0.72,
0.3,
0.28
]
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/mddress",
"gender": "Female",
"set": "MDDress",
"note": "v1 = one-piece long-sleeved dress (ISL_000 + center buttons). Jacket (ISL_001) + its buttons (ISL_002-009) deferred to v2."
},
"drop_materials": [
"Material441866",
"Material441989"
],
"min_island_verts": 40,
"torso_boost": {
"z0": 1.0,
"z1": 1.1,
"xy": 1.2
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "pari",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_pari_v3_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "neck_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0483,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Pari": {
"slot": "Body",
"islands": [
0
],
"tris": 4000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "dress",
"color": [
0.5,
0.2,
0.15
],
"texture": "../tools/tailor/textures/taniko.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "Kapahaka",
"note": "T\u00c4\u0081niko bodice (pari) - Body slot"
},
"min_island_verts": 40
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "piupiu",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v2_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.037,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Piupiu": {
"slot": "Legs",
"islands": [
0
],
"tris": 8000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 14
},
"weights": "dress",
"color": [
0.45,
0.35,
0.15
],
"texture": "../tools/tailor/textures/piupiu.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "Kapahaka",
"note": "Flax skirt (piupiu) - Legs slot"
},
"min_island_verts": 40
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "piupiu_sb",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v2_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.037,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Piupiu": {
"slot": "Legs",
"islands": [
0
],
"tris": 8000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 14
},
"weights": "skirt_bones",
"color": [
0.45,
0.35,
0.15
],
"texture": "../tools/tailor/textures/piupiu.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "KapahakaSB",
"note": "Flax skirt (piupiu) on the skirt-boned SkirtRig Lena copy - Legs slot. Same garment as piupiu.json; differs only in body + weights mode."
},
"min_island_verts": 40
}
+29
View File
@@ -0,0 +1,29 @@
{
"extends": "tests/extends_parent.json",
"name": "extends_child",
"weld_threshold": 0.0009,
"align": {
"z_nudge": 0.042,
"xy_nudge": [0.01, 0.0]
},
"parts": {
"Skirt": {
"islands": [2],
"fit": { "offset_mm": 7 }
},
"Belt": {
"slot": "Body",
"islands": [3],
"tris": 600
}
},
"export": { "set": "TestSetChild" },
"catalog": { "displayName": "Child Skirt" },
"_note": "Fixture for garment.py --selftest ONLY. Exercises: scalar override (weld_threshold), object merge-per-key (align.z_nudge overrides while top_bone/scale_* survive), array-replaces-whole (align.xy_nudge, parts.Skirt.islands), nested part override (parts.Skirt.fit.offset_mm changes, fit.mask survives), part addition (Belt), and inheritance of everything untouched."
}
@@ -0,0 +1,46 @@
{
"name": "extends_parent",
"source": "C:/parent/source.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.037,
"xy_nudge": [0.0, 0.0]
},
"parts": {
"Skirt": {
"slot": "Legs",
"islands": [0, 1],
"tris": 8000,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "surface",
"color": [0.45, 0.35, 0.15]
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/testset",
"gender": "Female",
"set": "TestSetParent"
},
"catalog": {
"id": "testset_legs_f",
"displayName": "Parent Skirt",
"charisma": 0.05,
"workSpeed": 0.04,
"category": "casual"
},
"expect": {
"islands": { "count": 2 }
},
"min_island_verts": 40,
"_note": "Fixture for garment.py --selftest ONLY. Not a real garment; never built."
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "piupiu_sb",
"_note": "G2 self-test config (agent B lane). Mirrors configs/piupiu_sb.json's parts/export, plus an expect.islands block whose bounds were DERIVED from the real clothing/work/piupiu_sb/census.json (island 0: verts 3620, tris 7104, z [5.184, 11.126]). Run: python clothing/gates/g2_census.py --config clothing/configs/tests/g2_selftest.json --work clothing/work/piupiu_sb. Bounds are in census (pre-align) units, not metres — align.scale_z 0.1 is applied later in prepare.",
"parts": {
"Piupiu": {
"slot": "Legs",
"islands": [0],
"tris": 8000
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "KapahakaSB"
},
"min_island_verts": 40,
"expect": {
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [3550, 3700],
"tris": [7000, 7200],
"z": [5.0, 11.3],
"z_span": [5.8, 6.05],
"x": [-2.5, 2.6]
}
}
}
}
}
@@ -0,0 +1,49 @@
{
"name": "pari",
"_note": "G5/G3 validation config (agent C). Mirrors configs/pari.json and adds an expect block. Ground truth: the neckline gapes off the sternum in walk/dance, so a correct G5 FAILS on the gape band here while --rest (G3) passes.",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_pari_v3_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": { "top_bone": "neck_01", "scale_xy": 0.1, "scale_z": 0.1, "z_nudge": 0.0483, "xy_nudge": [0.0, 0.0] },
"parts": {
"Pari": {
"slot": "Body",
"islands": [0],
"tris": 4000,
"planar_deg": 5,
"fit": { "target": "BODY_SHELL", "mask": "full", "offset_mm": 3, "wrap_mode": "OUTSIDE" },
"weights": "dress",
"color": [0.5, 0.2, 0.15],
"texture": "../tools/tailor/textures/taniko.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "Kapahaka",
"note": "Taniko bodice (pari) - Body slot"
},
"min_island_verts": 40,
"expect": {
"penetration": {
"max_verts": 0,
"depth_mm": 1.0,
"clips": ["Idle", "Walk", "Dance"],
"frames_per_clip": 6,
"gape": {
"Pari": {
"_note": "Calibrated by scanning body exposure in 3 cm z-slices (agent C). The pari is a scoop-neck crop top: its hem sits at z~1.06 and the neckline scoop bottoms out at z~1.325, so z 1.15-1.30 is the strip the garment MUST cover. band_x clips the arms out of the band (they fall in the same z range and are never covered, which swamped a naive band). gap_mm 0 = score only rays that miss the fabric entirely.",
"band_z": [1.15, 1.30],
"band_x": [-0.11, 0.11],
"facing": [0.0, -1.0, 0.0],
"facing_min": 0.35,
"ray_mm": 50,
"gap_mm": 0,
"max_exposed_verts": 60,
"max_exposed_delta": 25
}
}
}
}
}
@@ -0,0 +1,38 @@
{
"name": "piupiu",
"_note": "G5/G3 validation config (agent C). Mirrors configs/piupiu.json and adds an expect block. Ground truth: a thigh punches through this skirt in motion, so a correct G5 FAILS here while --rest (G3) passes.",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v2_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": { "top_bone": "spine_01", "scale_xy": 0.1, "scale_z": 0.1, "z_nudge": 0.037, "xy_nudge": [0.0, 0.0] },
"parts": {
"Piupiu": {
"slot": "Legs",
"islands": [0],
"tris": 8000,
"planar_deg": 5,
"fit": { "target": "BODY_SHELL", "mask": "full", "offset_mm": 3, "wrap_mode": "OUTSIDE", "hem_mm": 14 },
"weights": "dress",
"color": [0.45, 0.35, 0.15],
"texture": "../tools/tailor/textures/piupiu.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/kapahaka",
"gender": "Female",
"set": "Kapahaka",
"note": "Flax skirt (piupiu) - Legs slot"
},
"min_island_verts": 40,
"expect": {
"penetration": {
"max_verts": 0,
"depth_mm": 1.0,
"clips": ["Idle", "Walk", "Dance"],
"frames_per_clip": 6,
"max_verts_rest": 2,
"_note_rest": "The shipped piupiu has ONE 8 mm nick in the fitting pose; the pose-dependent defect is 78-197 verts up to 93 mm. A rest budget of 2 keeps G3 honest about the fit while letting the posed sweep own the real signal."
}
}
}
@@ -0,0 +1,96 @@
{
"name": "md_pari_backfill",
"_note": [
"BACKFILL PROOF, not a shipping config. Transcribes tools/tailor/md_pari.py (pari v2,",
"the kapa haka taniko bodice) into the `md` block schema so draft_garment.py can be",
"diffed call-for-call against the hand-written original. Semantic differences must be",
"zero. It carries no Blender (`align`/`parts`) blocks -- MD stage + gate G1 only.",
"md.exports is deliberately empty because md_pari.py never exported; a shipping config",
"sets exports to all four with a versioned export_basename (see md.export_basename).",
"md.snapshot is md_pari.py's tempfile path verbatim; a shipping config points it at",
"work/<name>/qc/drape.png. expect.drape.snapshot overrides it here so G1 can be run",
"against the committed review render tools/tailor/screenshots/lena_pari_v2.png."
],
"md": {
"reset": "new_project",
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
"avatar_scale": 10.0,
"add_arrangement_points": true,
"auto_translate": true,
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "C:/Users/Jeremy/tinqs/animation/tools/tailor/textures/taniko.png",
"texture_dpi": 96.012,
"panels": [
{
"name": "front",
"note": "tank-style, shoulder->waist 350 tall, 550/panel (snug over 1083 bust), straps 90 wide; scoop 105 deep so its floor = band top ~ z 1.30",
"dx": 0.0,
"points": [
[65.0, 325.0], [155.0, 350.0], [205.0, 271.25], [275.0, 245.0],
[345.0, 271.25], [395.0, 350.0], [485.0, 325.0],
[550.0, 185.0], [550.0, 0.0], [0.0, 0.0], [0.0, 185.0]
]
},
{
"name": "back",
"note": "same block, shallower 45 scoop; lines 0 shoulder_L | 1-4 scoop (open) | 5 shoulder_R | 6 armhole_R | 7 side_R | 8 hem | 9 side_L | 10 armhole_L",
"dx": 800.0,
"points": [
[65.0, 325.0], [155.0, 350.0], [205.0, 316.25], [275.0, 305.0],
[345.0, 316.25], [395.0, 350.0], [485.0, 325.0],
[550.0, 185.0], [550.0, 0.0], [0.0, 0.0], [0.0, 185.0]
]
}
],
"seams": [
{ "a": "front", "b": "back", "lines": [0, 5, 7, 9] }
],
"arrangements": [
{ "panel": "front", "point": "Body_Front_Center_1", "offset": [50, 55, 50] },
{ "panel": "back", "point": "Body_Back_Center_1", "offset": [0, 55, 50] }
],
"sim": {
"strengthen": false,
"settle_frames": 300,
"relax_frames": 0
},
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_pari_v2.png",
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
"export_basename": "lena_pari_v3",
"exports": []
},
"expect": {
"bands": {
"taniko": {
"top_m": 1.31,
"bottom_m": 1.05,
"tol_m": 0.03,
"from": "cover"
}
},
"drape": {
"snapshot": "C:/Users/Jeremy/tinqs/animation/tools/tailor/screenshots/lena_pari_v2.png",
"height_m": 1.777,
"min_row_cover": 0.4,
"z_range": [0.3, 1.6],
"mask": {
"mode": "colors",
"tol": 30,
"_note": "taniko palette: three reds, the black outline, two cream whites",
"colors": [
[173, 37, 39], [130, 26, 29], [146, 29, 31],
[36, 32, 32], [194, 186, 168], [230, 225, 215]
]
}
}
}
}
@@ -0,0 +1,87 @@
{
"name": "md_piupiu_backfill",
"_note": [
"BACKFILL PROOF, not a shipping config. Transcribes tools/tailor/md_piupiu.py (piupiu",
"v2, the kapa haka flax skirt) into the `md` block schema. Exercises the half of the",
"schema md_pari_backfill.json does not: the strengthen/relax two-pass settle and the",
"two-point A-line panel. Semantic differences against the original must be zero.",
"md.exports is empty because md_piupiu.py never exported; the v2 .zprj/.fbx/.obj/.zpac",
"on disk were produced by separate --exec calls."
],
"md": {
"reset": "new_project",
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
"avatar_scale": 10.0,
"add_arrangement_points": true,
"auto_translate": true,
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
"texture": "C:/Users/Jeremy/tinqs/animation/tools/tailor/textures/piupiu.png",
"texture_dpi": 43.307,
"panels": [
{
"name": "front",
"note": "length 600 (waist 1.05 -> hem 0.45); tension waist 500/panel (< 1095 hip circ, no elastic); A-line hem 820",
"dx": 0.0,
"points": [[160.0, 600.0], [660.0, 600.0], [820.0, 0.0], [0.0, 0.0]]
},
{
"name": "back",
"note": "identical block; lines 0 waist | 1 side_R | 2 hem | 3 side_L",
"dx": 1000.0,
"points": [[160.0, 600.0], [660.0, 600.0], [820.0, 0.0], [0.0, 0.0]]
}
],
"seams": [
{ "a": "front", "b": "back", "lines": [1] },
{ "a": "front", "b": "back", "lines": [3] }
],
"arrangements": [
{ "panel": "front", "point": "Body_Front_Waist", "offset": [50, 30, 50] },
{ "panel": "back", "point": "Body_Back_Waist", "offset": [0, 30, 50] }
],
"sim": {
"strengthen": true,
"settle_frames": 250,
"relax_frames": 50
},
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_piupiu_v2.png",
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
"export_basename": "lena_piupiu_v2",
"exports": []
},
"expect": {
"bands": {
"flax": {
"top_m": 1.05,
"bottom_m": 0.45,
"tol_m": 0.03,
"bottom_tol_m": 0.04,
"from": "cover"
}
},
"drape": {
"snapshot": "C:/Users/Jeremy/tinqs/animation/tools/tailor/screenshots/lena_piupiu_v2.png",
"height_m": 1.777,
"min_row_cover": 0.4,
"z_range": [0.2, 1.3],
"mask": {
"mode": "colors",
"tol": 24,
"_note": "flax strand yellows + the black geometric banding; tol 24 (not 30) keeps the body's baked-in beige underwear out of the mask",
"colors": [
[184, 152, 104], [168, 136, 104], [200, 168, 136], [152, 136, 88],
[184, 168, 120], [200, 184, 136], [24, 24, 24], [40, 24, 24]
]
}
}
}
}
@@ -0,0 +1,90 @@
{
"name": "piupiu_sb_test",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_piupiu_v2_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "spine_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.037,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Piupiu": {
"slot": "Legs",
"islands": [
0
],
"tris": 8000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE",
"hem_mm": 14
},
"weights": "skirt_bones",
"color": [
0.45,
0.35,
0.15
],
"texture": "../tools/tailor/textures/piupiu.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/animation/clothing/work/piupiu_sb_test/export",
"gender": "Female",
"set": "KapahakaSBTest",
"note": "SCRATCH export target. Deliberately NOT the shipped kapahaka folder and NOT the real KapahakaSB set id - see _note."
},
"catalog": {
"id": "kapahaka_legs_sbtest_f",
"displayName": "Piupiu (skirt bones, test)",
"charisma": 0.1,
"workSpeed": 0.02,
"category": "ceremonial"
},
"expect": {
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [
3400,
3800
],
"tris": [
6800,
7400
],
"z": [
5.0,
11.5
],
"x": [
-2.6,
2.9
]
}
}
},
"penetration": {
"depth_mm": 1.0,
"max_verts": 0,
"clips": [
"Idle",
"Walk"
],
"frames_per_clip": 6
}
},
"min_island_verts": 40,
"_note": "TEST FIXTURE for the unified clothing pipeline (garment.py + gates). Geometry/align/parts are a byte-copy of configs/piupiu_sb.json; everything else differs on purpose.\n\nDEFUSED 2026-07-31 (integration): export.out_dir used to point at the LIVE ariki-game/assets/quaternius/outfits/kapahaka and export.set was the real 'KapahakaSB', so a --to export run would have OVERWRITTEN the shipped Female_KapahakaSB_Legs.gltf. out_dir is now this config's own work dir and the set id is 'KapahakaSBTest', which collides with nothing and is deliberately absent from OutfitCatalog.cs (so G8 warns 'not registered' - that is the correct answer for a test set, and the run is safe to do end to end).\n\nexpect.islands is the measured truth from work/piupiu_sb_test/census.json: 1 island, 3620 verts, 7104 tris, z 5.184..11.126, x -2.228..2.517, in PRE-SCALE census units (align.scale_* 0.1 is applied later in `prepare`) - bounds are widened for tolerance, not tightened.\n\nexpect.penetration carries the contract defaults and IS EXPECTED TO FAIL at the `skin` stage: this garment is the shipped thigh-through-the-piupiu bug, and reproducing it headlessly is the point of G5. At `fit` (G5 --rest, written as qc/g3.json) the same block FAILS on a genuine 1-vert / 8.0mm rest-pose nick in 20_fit.blend - a real finding, deliberately not tuned away; set max_verts_rest: 1 here if a 1-vert rest budget is ever ruled acceptable."
}
+88
View File
@@ -0,0 +1,88 @@
{
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig_4seg.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"min_island_verts": 40,
"name": "topTest1",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_pari_v4_garment.fbx",
"align": {
"top_bone": "neck_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Top": {
"slot": "Body",
"islands": [
0
],
"tris": 4000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "dress",
"color": [
0.5,
0.2,
0.15
],
"texture": "../tools/tailor/textures/top_test1.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/test1",
"gender": "Female",
"set": "Test1",
"note": "Flat front/back re-cut bodice - Body slot."
},
"catalog": {
"id": "top_test1_f",
"displayName": "Top Test 1",
"charisma": 0.14,
"workSpeed": 0.01,
"category": "ceremonial"
},
"expect": {
"penetration": {
"depth_mm": 1.0,
"max_verts": 0,
"clips": [
"Idle",
"Walk"
],
"frames_per_clip": 6
},
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [
1301,
1438
],
"tris": [
2511,
2776
],
"z": [
10.42,
14.82
],
"x": [
-2.43,
2.38
]
}
}
}
}
}
+87
View File
@@ -0,0 +1,87 @@
{
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"min_island_verts": 40,
"name": "topTest2",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_tee_v1_garment.fbx",
"align": {
"top_bone": "neck_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Top": {
"slot": "Body",
"islands": [
0
],
"tris": 4000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "dress",
"color": [
0.85,
0.85,
0.85
]
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/test2",
"gender": "Female",
"set": "Test2",
"note": "White tank/tee (lena_tee_v1, first MD-authored proof garment 2026-07-30) - Body slot. Flat color until a texture is authored."
},
"catalog": {
"id": "top_test2_f",
"displayName": "Top Test 2",
"charisma": 0.06,
"workSpeed": 0.05,
"category": "casual"
},
"expect": {
"penetration": {
"depth_mm": 1.0,
"max_verts": 0,
"clips": [
"Idle",
"Walk"
],
"frames_per_clip": 6
},
"islands": {
"count": 1,
"mapped": {
"0": {
"verts": [
2176,
2406
],
"tris": [
4227,
4671
],
"z": [
9.9,
14.9
],
"x": [
-2.2,
2.25
]
}
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
{
"name": "top_test1",
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_pari_v4_garment.fbx",
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig_4seg.glb",
"weld_threshold": 0.0006,
"shell_mm": 4,
"align": {
"top_bone": "neck_01",
"scale_xy": 0.1,
"scale_z": 0.1,
"z_nudge": 0.0483,
"xy_nudge": [
0.0,
0.0
]
},
"parts": {
"Top": {
"slot": "Body",
"islands": [
0
],
"tris": 4000,
"planar_deg": 5,
"fit": {
"target": "BODY_SHELL",
"mask": "full",
"offset_mm": 3,
"wrap_mode": "OUTSIDE"
},
"weights": "dress",
"color": [
0.5,
0.2,
0.15
],
"texture": "../tools/tailor/textures/top_test1.png"
}
},
"export": {
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/test1",
"gender": "Female",
"set": "Test1",
"note": "pari v4 (twisted side seam fixed with (True,True), tapered 95mm, symmetric arrangement) as the neutral-named top_test1_f. Body slot, no skirt bones."
},
"catalog": {
"id": "top_test1_f",
"displayName": "Top Test 1",
"charisma": 0.14,
"workSpeed": 0.01,
"category": "ceremonial"
},
"min_island_verts": 40,
"_note": "NAMING: no cultural names on test garments (Jeremy 2026-07-31) - see ariki-game/src/Character/OutfitCatalog.cs class doc.\n\nBody is the *_SkirtRig_4seg.glb copy purely so the top and bottom of this set are fitted against the SAME body the bed spawns; a Body-slot garment uses weights=dress and never touches the skirt ring, so the extra 32 joints are inert here.\n\nNo `expect` block yet - the pari has no measured penetration baseline, and its known open defect is the in-game neckline gape, which did NOT reproduce in the Blender checkpoint (so it is G6/in-game territory: attach, material or LOD, not authoring)."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

+1535
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env python
"""G1 -- drape placement gate (runs after the MD `drape` stage).
python clothing/gates/g1_drape.py --config <resolved.json> --work work/<name>
[--snapshot <png>] [--quiet]
Exit codes (pipeline contract): 0 pass · 2 fail (thresholds violated) · 3 error
(could not evaluate). Writes `<work>/qc/g1.json`.
WHY THIS GATE IS IMAGE-BASED. MD exposes no mesh introspection to Python
(`GetClothPositions()` returns nothing), so every upstream drape judgement has to
be made from the rendered viewport. G1 turns the prose QC targets that used to
live in each garment script's header -- `md_pari.py:4` "band top ~1.31 m (above
bust), hem ~1.05 m (waist) +-3 cm" -- into machine-checked numbers.
--------------------------------------------------------------------------------
`expect` KEYS THIS GATE READS
--------------------------------------------------------------------------------
```jsonc
"expect": {
"bands": { // REQUIRED -- the gate map keys off this
"taniko": {
"top_m": 1.31, // where the band's top edge should sit, metres
"bottom_m": 1.05, // where its bottom edge (hem) should sit
"tol_m": 0.03, // symmetric tolerance for both edges
"top_tol_m": 0.03, // optional per-edge override
"bottom_tol_m": 0.04, // optional per-edge override
"from": "cover", // "cover" (default) = rows where the garment
// covers >= min_row_cover of the body's width
// -- the band proper.
// "extent" = the loose mask, which also picks up
// straps, ties and fringes.
"band_index": 0 // optional: force which measured band to use
// (default: the one nearest the expectation)
}
},
"drape": { // OPTIONAL measurement parameters
"snapshot": "work/pari/qc/drape.png", // overrides md.snapshot
"height_m": 1.777, // body height for the px->m calibration
"landmarks": {"waist": 1.089, ...}, // overrides the Lena landmark table
"mask": { // HOW garment pixels are recognised
"mode": "colors", // "auto" crude skin/bg heuristic (default;
"colors": [[173, 37, 39]], // counts baked-in underwear and
"tol": 30 // shaded skin as garment)
}, // "colors" per-garment palette + tolerance --
// use this whenever the garment has a
// generated texture, i.e. always
// "sat" anything strongly saturated
"min_row_cover": 0.40, // band threshold, fraction of that row's BODY px
"min_row_extent": 0.05, // loose threshold for `extent` bands
"scale": 3, // integer downscale for speed
"z_range": [0.2, 1.6] // ignore rows outside this height window (kills
// hair/brow pixels that collide with a dark
// garment palette)
}
}
```
Snapshot resolution order: `--snapshot` > `expect.drape.snapshot` > `md.snapshot`.
Relative paths resolve against the config's directory, then the repo root.
The measurement itself lives in `tools/tailor/qc_placement.measure_bands()` so the
standalone `python tools/tailor/qc_placement.py <png>` report and this gate share
one implementation.
"""
import argparse
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.dirname(os.path.dirname(HERE))
sys.path.insert(0, os.path.join(REPO_ROOT, "tools", "tailor"))
GATE_ID = "g1"
STAGE = "drape"
PASS, FAIL, ERROR = 0, 2, 3
def _resolve(path, config_dir):
if not path:
return None
p = os.path.expanduser(str(path))
if os.path.isabs(p):
return os.path.normpath(p)
for base in (config_dir, REPO_ROOT, os.getcwd()):
cand = os.path.normpath(os.path.join(base, p))
if os.path.exists(cand):
return cand
return os.path.normpath(os.path.join(config_dir, p))
def _write(work, payload):
qc_dir = os.path.join(work, "qc")
os.makedirs(qc_dir, exist_ok=True)
out = os.path.join(qc_dir, "{}.json".format(GATE_ID))
with open(out, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
return out
def _error(work, detail, quiet=False):
payload = {"gate": GATE_ID, "pass": False, "checked_at_stage": STAGE,
"metrics": {}, "failures": [{"part": None, "detail": detail}],
"artifacts": [], "error": detail}
try:
_write(work, payload)
except OSError:
pass
if not quiet:
print("g1 ERROR: {}".format(detail), file=sys.stderr)
return ERROR
def evaluate(measurement, bands_spec):
"""Match each expected band to a measured one and check both edges.
Matching is greedy nearest-first on |top - top_m| + |bottom - bottom_m|, so the
ordering of `expect.bands` does not matter and one measured band is never
claimed twice.
"""
failures, metrics = [], {}
claimed = {"cover": set(), "extent": set()}
order = []
for name, spec in bands_spec.items():
source = "extent" if str(spec.get("from", "cover")).lower() == "extent" else "cover"
pool = measurement["extent_bands"] if source == "extent" else measurement["bands"]
order.append((name, spec, source, pool))
for name, spec, source, pool in order:
top_m, bot_m = spec.get("top_m"), spec.get("bottom_m")
tol = float(spec.get("tol_m", 0.03))
top_tol = float(spec.get("top_tol_m", tol))
bot_tol = float(spec.get("bottom_tol_m", tol))
idx = spec.get("band_index")
if idx is None:
best, best_d = None, None
for i, band in enumerate(pool):
if i in claimed[source]:
continue
d = 0.0
if top_m is not None:
d += abs(band["top_m"] - float(top_m))
if bot_m is not None:
d += abs(band["bottom_m"] - float(bot_m))
if best_d is None or d < best_d:
best, best_d = i, d
idx = best
if idx is None or not (0 <= int(idx) < len(pool)):
metrics[name] = {"matched": False, "source": source,
"expected": {"top_m": top_m, "bottom_m": bot_m}}
failures.append({"part": name,
"detail": "no {} band found to match (measured {} band(s)); the "
"garment may not have draped, or the mask matched "
"nothing".format(source, len(pool))})
continue
idx = int(idx)
claimed[source].add(idx)
band = pool[idx]
entry = {"matched": True, "source": source, "band_index": idx,
"measured_top_m": band["top_m"], "measured_bottom_m": band["bottom_m"],
"span_m": band["span_m"], "peak_cover": band["peak_cover"],
"near_top": band["near_top"], "near_bottom": band["near_bottom"],
"expected": {"top_m": top_m, "bottom_m": bot_m,
"top_tol_m": top_tol, "bottom_tol_m": bot_tol}}
for edge, want, got, etol in (("top", top_m, band["top_m"], top_tol),
("bottom", bot_m, band["bottom_m"], bot_tol)):
if want is None:
continue
delta = got - float(want)
entry["{}_delta_m".format(edge)] = round(delta, 4)
entry["{}_pass".format(edge)] = abs(delta) <= etol
if abs(delta) > etol:
failures.append({
"part": name,
"detail": "{} edge at {:.3f} m, expected {:.3f} +-{:.3f} m "
"({:+.1f} cm, {:+.1f} cm outside tolerance)".format(
edge, got, float(want), etol, delta * 100.0,
(abs(delta) - etol) * 100.0 * (1 if delta > 0 else -1)),
})
metrics[name] = entry
return metrics, failures
def main(argv=None):
ap = argparse.ArgumentParser(description="G1 -- drape placement gate.")
ap.add_argument("--config", required=True, help="resolved.json")
ap.add_argument("--work", required=True, help="work/<name> directory")
ap.add_argument("--snapshot", help="override the drape snapshot PNG to measure")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args(argv)
work = os.path.abspath(args.work)
try:
with open(args.config, "r", encoding="utf-8") as fh:
config = json.load(fh)
except (OSError, ValueError) as exc:
return _error(work, "could not read config {}: {}".format(args.config, exc), args.quiet)
config_dir = os.path.dirname(os.path.abspath(args.config))
expect = config.get("expect") or {}
bands_spec = expect.get("bands")
if not isinstance(bands_spec, dict) or not bands_spec:
return _error(work, "config has no `expect.bands` -- nothing for G1 to check",
args.quiet)
drape = expect.get("drape") or {}
md = config.get("md") or {}
snapshot = _resolve(args.snapshot or drape.get("snapshot") or md.get("snapshot"), config_dir)
if not snapshot:
return _error(work, "no drape snapshot: set md.snapshot, expect.drape.snapshot, "
"or pass --snapshot", args.quiet)
if not os.path.exists(snapshot):
return _error(work, "drape snapshot does not exist: {} (did the drape stage run?)"
.format(snapshot), args.quiet)
try:
from qc_placement import measure_bands, LANDMARKS
except ImportError as exc:
return _error(work, "cannot import tools/tailor/qc_placement.py ({}) -- G1 needs "
"Pillow".format(exc), args.quiet)
kwargs = {"mask": drape.get("mask"),
"landmarks": drape.get("landmarks") or LANDMARKS,
"height": float(drape.get("height_m", 1.777)),
"scale": int(drape.get("scale", 3)),
"min_row_cover": float(drape.get("min_row_cover", 0.40)),
"min_row_extent": float(drape.get("min_row_extent", 0.05)),
"gap_rows": int(drape.get("gap_rows", 3)),
"z_range": drape.get("z_range")}
try:
measurement = measure_bands(snapshot, **kwargs)
except Exception as exc: # noqa: BLE001
return _error(work, "measurement failed on {}: {}".format(snapshot, exc), args.quiet)
metrics, failures = evaluate(measurement, bands_spec)
payload = {
"gate": GATE_ID,
"pass": not failures,
"checked_at_stage": STAGE,
"metrics": {"snapshot": snapshot,
"px_per_m": measurement["px_per_m"],
"body_rows": measurement["body_rows"],
"garment_px": measurement["garment_px"],
"bands": metrics,
"measured": {"bands": measurement["bands"],
"extent_bands": measurement["extent_bands"]}},
"failures": failures,
"artifacts": [snapshot],
}
out = _write(work, payload)
if not args.quiet:
print("g1 drape placement -- {}".format(os.path.basename(snapshot)))
print(" scale {:.1f} px/m over body rows {}".format(
measurement["px_per_m"], measurement["body_rows"]))
for name, m in metrics.items():
if not m.get("matched"):
print(" {:<16} NO MATCH ({} bands)".format(name, m["source"]))
continue
exp = m["expected"]
bits = []
for edge in ("top", "bottom"):
if exp.get("{}_m".format(edge)) is None:
continue
bits.append("{} {:.3f} m (want {:.3f} +-{:.3f}, {:+.1f} cm) {}".format(
edge, m["measured_{}_m".format(edge)], float(exp["{}_m".format(edge)]),
exp["{}_tol_m".format(edge)], m["{}_delta_m".format(edge)] * 100.0,
"PASS" if m["{}_pass".format(edge)] else "FAIL"))
print(" {:<16} {}".format(name, ("\n" + " " * 19).join(bits)))
print(" -> {} ({})".format("PASS" if not failures else "FAIL", out))
return PASS if not failures else FAIL
if __name__ == "__main__":
sys.exit(main())
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""G2 — census sanity gate (design doc §3, gate table row "G2").
Runs after the `census` stage. Reads `<work>/census.json` (written by
`garment_pipeline.py` stage_census) and checks it against the resolved config's
`expect.islands` block.
Catches the silent killer the design calls out: an MD re-export reorders or
renumbers islands, the config's `parts[*].islands` indices now point at the
wrong geometry, and every downstream stage happily builds the wrong garment.
python clothing/gates/g2_census.py --config work/<name>/resolved.json --work work/<name>
Exit codes: 0 pass · 2 fail (thresholds violated) · 3 error (could not evaluate).
Writes `<work>/qc/g2.json` in the contract shape.
census.json shape (verified against work/piupiu_sb, work/piupiu, work/pari):
[ {"island": 0, "verts": 3620, "tris": 7104,
"z": [5.184, 11.126], "x": [-2.228, 2.517],
"centroid": [-0.034, 0.315, 8.416], "color": [0.9, 0.1, 0.1]} ]
A list, one record per island, `island` is the index the config's
`parts[*].islands` refers to. UNITS: raw source-mesh units, NOT metres — the
piupiu census reads z 5.18..11.13 because `align.scale_z` (0.1) is applied later
in `prepare`. Author `expect.islands` z bounds in census units (divide the metre
figure by the align scale, or just read them off census.json).
`expect.islands` schema understood by this gate (superset of PIPELINE-CONTRACT.md):
"islands": {
"count": 1, // exact island count in census.json
"require_all_mapped": false, // unmapped non-trivial islands => fail (default warn)
"mapped": {
"0": { // key: island index, or a part name from parts{}
"verts": [3500, 3700], // inclusive range on vert count
"tris": [7000, 7200], // optional
"z": [5.0, 11.3], // island z extent must be CONTAINED in this range
"z_min": [5.1, 5.3], // optional: range on the low z endpoint
"z_max": [11.0, 11.2], // optional: range on the high z endpoint
"z_span": [5.8, 6.1], // optional: range on (zmax - zmin)
"x": [-2.4, 2.6] // optional: x extent containment
}
}
}
Every range accepts `[lo, hi]` (either endpoint may be null for one-sided), a
bare number (exact match), or `{"min": lo, "max": hi}`.
Independent of `expect`, the gate ALWAYS cross-checks that every island index in
`parts[*].islands` exists in census.json — that check needs no thresholds, so a
config with no `expect.islands` still gets it (and passes with a note rather
than erroring; the orchestrator only schedules this gate when the key exists).
Stdlib only.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
GATE_ID = "g2"
STAGE = "census"
EXIT_PASS, EXIT_FAIL, EXIT_ERROR = 0, 2, 3
# ── report plumbing ──────────────────────────────────────────────────────────
class Report:
def __init__(self):
self.metrics = {}
self.failures = []
self.warnings = []
self.notes = []
self.artifacts = []
def fail(self, part, detail, **extra):
entry = {"part": part, "detail": detail}
entry.update(extra)
self.failures.append(entry)
def warn(self, part, detail, **extra):
entry = {"part": part, "detail": detail}
entry.update(extra)
self.warnings.append(entry)
def to_dict(self):
return {
"gate": GATE_ID,
"pass": not self.failures,
"checked_at_stage": STAGE,
"metrics": self.metrics,
"failures": self.failures,
"warnings": self.warnings,
"notes": self.notes,
"artifacts": self.artifacts,
}
def write_report(work, report, out=None):
"""Write <work>/qc/g2.json (or --out). Never raises; returns the path or None."""
path = out
if not path:
if not work:
return None
path = os.path.join(work, "qc", "%s.json" % GATE_ID)
try:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
fh.write("\n")
return path
except OSError as exc: # pragma: no cover - disk/permission edge
sys.stderr.write("[g2] WARNING could not write report %s: %s\n" % (path, exc))
return None
def die(work, detail, out=None):
"""Exit 3 with a contract-shaped error report on disk."""
rep = Report()
rep.fail("config", detail, code="gate_error")
doc = rep.to_dict()
doc["error"] = detail
write_report(work, doc, out)
sys.stderr.write("[g2] ERROR %s\n" % detail)
print(json.dumps(doc, indent=2))
return EXIT_ERROR
# ── range helpers ────────────────────────────────────────────────────────────
def parse_range(spec):
"""Normalize a threshold spec to (lo, hi); either may be None. Raises ValueError."""
if spec is None:
return (None, None)
if isinstance(spec, (int, float)) and not isinstance(spec, bool):
return (float(spec), float(spec))
if isinstance(spec, dict):
lo, hi = spec.get("min"), spec.get("max")
return (None if lo is None else float(lo), None if hi is None else float(hi))
if isinstance(spec, (list, tuple)):
if len(spec) != 2:
raise ValueError("range must have exactly 2 entries, got %r" % (spec,))
lo, hi = spec
return (None if lo is None else float(lo), None if hi is None else float(hi))
raise ValueError("unsupported range spec %r" % (spec,))
def fmt_range(lo, hi):
return "[%s, %s]" % ("-inf" if lo is None else round(lo, 4),
"inf" if hi is None else round(hi, 4))
def check_scalar(report, part, island, label, value, spec):
"""value must sit inside the range. Returns True on pass."""
try:
lo, hi = parse_range(spec)
except ValueError as exc:
report.fail(part, "expect.islands[%s].%s: %s" % (island, label, exc),
island=island, code="bad_expect")
return False
if (lo is not None and value < lo) or (hi is not None and value > hi):
report.fail(part, "%s = %s, outside expected %s" % (label, round(value, 4), fmt_range(lo, hi)),
island=island, code="out_of_range", value=value,
expected=[lo, hi], metric=label)
return False
return True
def check_extent(report, part, island, label, extent, spec):
"""The [min,max] extent must be CONTAINED in the range. Returns True on pass."""
try:
lo, hi = parse_range(spec)
except ValueError as exc:
report.fail(part, "expect.islands[%s].%s: %s" % (island, label, exc),
island=island, code="bad_expect")
return False
emin, emax = float(extent[0]), float(extent[1])
bad = (lo is not None and emin < lo) or (hi is not None and emax > hi)
if bad:
report.fail(part, "%s extent [%s, %s] not contained in expected %s"
% (label, round(emin, 4), round(emax, 4), fmt_range(lo, hi)),
island=island, code="out_of_range",
value=[emin, emax], expected=[lo, hi], metric=label)
return False
return True
# ── census access ────────────────────────────────────────────────────────────
def load_json(path):
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def census_index(records):
"""{island index: record}. Tolerates a dict-wrapped census ({"islands": [...]})."""
out = {}
for i, rec in enumerate(records):
if not isinstance(rec, dict):
raise ValueError("census entry %d is %s, expected an object" % (i, type(rec).__name__))
idx = rec.get("island", rec.get("index", i))
out[int(idx)] = rec
return out
def resolve_mapped_key(key, parts):
"""expect.islands.mapped key -> island index. Accepts an index or a part name."""
try:
return int(key), None
except (TypeError, ValueError):
pass
part = parts.get(key)
if part is None:
return None, "no island index and no part named %r in parts{}" % (key,)
islands = part.get("islands") or []
if len(islands) != 1:
return None, ("part %r maps %d islands (%r) — key expect.islands.mapped by "
"island index instead" % (key, len(islands), islands))
return int(islands[0]), None
def part_for_island(parts, idx):
for name, cfg in parts.items():
if idx in [int(i) for i in (cfg.get("islands") or [])]:
return name
return None
# ── main ─────────────────────────────────────────────────────────────────────
def main(argv=None):
ap = argparse.ArgumentParser(description="G2 census sanity gate")
ap.add_argument("--config", required=True, help="resolved config JSON (work/<name>/resolved.json)")
ap.add_argument("--work", help="work dir (default: the config's directory)")
ap.add_argument("--census", help="census.json override (default: <work>/census.json)")
ap.add_argument("--out", help="report path override (default: <work>/qc/g2.json)")
args = ap.parse_args(argv)
work = args.work or os.path.dirname(os.path.abspath(args.config))
try:
cfg = load_json(args.config)
except (OSError, ValueError) as exc:
return die(work, "cannot read config %s: %s" % (args.config, exc), args.out)
if not isinstance(cfg, dict):
return die(work, "config %s is not a JSON object" % args.config, args.out)
census_path = args.census or os.path.join(work, "census.json")
try:
raw = load_json(census_path)
except OSError as exc:
return die(work, "census.json not found — run the census stage first (%s)" % exc, args.out)
except ValueError as exc:
return die(work, "census.json is not valid JSON: %s" % exc, args.out)
if isinstance(raw, dict): # tolerate a future {"islands": [...]} wrapper
raw = raw.get("islands", raw.get("records"))
if not isinstance(raw, list):
return die(work, "census.json must be a list of island records (got %s)"
% type(raw).__name__, args.out)
try:
islands = census_index(raw)
except ValueError as exc:
return die(work, str(exc), args.out)
parts = cfg.get("parts") or {}
if not isinstance(parts, dict):
return die(work, "config parts must be an object", args.out)
expect = ((cfg.get("expect") or {}).get("islands")) or {}
if not isinstance(expect, dict):
return die(work, "expect.islands must be an object", args.out)
rep = Report()
try:
rel_census = os.path.relpath(census_path, work).replace("\\", "/")
except ValueError: # different drive
rel_census = census_path.replace("\\", "/")
rep.artifacts.append(rel_census)
for name in ("qa_00_census_front.png", "qa_00_census_quarter.png"):
if os.path.exists(os.path.join(work, name)):
rep.artifacts.append(name)
rep.metrics["census_islands"] = len(islands)
rep.metrics["config_parts"] = len(parts)
# ── 1. config parts[*].islands must exist in census.json (always) ────────
referenced = []
for part_name, part_cfg in sorted(parts.items()):
if not isinstance(part_cfg, dict):
rep.fail(part_name, "parts[%r] is not an object" % part_name, code="bad_config")
continue
idxs = part_cfg.get("islands")
if idxs is None:
rep.warn(part_name, "part declares no islands[] — nothing to cross-check",
code="no_islands")
continue
if not isinstance(idxs, list):
rep.fail(part_name, "parts[%r].islands must be a list" % part_name, code="bad_config")
continue
for raw_idx in idxs:
try:
idx = int(raw_idx)
except (TypeError, ValueError):
rep.fail(part_name, "island index %r is not an integer" % (raw_idx,),
code="bad_config")
continue
referenced.append(idx)
if idx not in islands:
rep.fail(part_name,
"config maps island %d but census.json has no such island "
"(present: %s) — the source mesh was probably re-exported and "
"islands renumbered" % (idx, sorted(islands)),
island=idx, code="missing_island")
rep.metrics["config_islands_referenced"] = len(referenced)
# ── 2. island count ─────────────────────────────────────────────────────
if "count" in expect:
try:
want = int(expect["count"])
except (TypeError, ValueError):
rep.fail("config", "expect.islands.count must be an integer, got %r"
% (expect["count"],), code="bad_expect")
else:
rep.metrics["expected_islands"] = want
if want != len(islands):
rep.fail("config",
"island count = %d, expected %d — source mesh changed "
"(island->part mapping is no longer trustworthy)"
% (len(islands), want),
code="island_count", value=len(islands), expected=want,
metric="count")
# ── 3. per-island thresholds ────────────────────────────────────────────
mapped = expect.get("mapped") or {}
if not isinstance(mapped, dict):
return die(work, "expect.islands.mapped must be an object", args.out)
checked = 0
for key in sorted(mapped, key=lambda k: str(k)):
spec = mapped[key]
if not isinstance(spec, dict):
rep.fail("config", "expect.islands.mapped[%r] must be an object" % (key,),
code="bad_expect")
continue
idx, err = resolve_mapped_key(key, parts)
if err:
rep.fail("config", "expect.islands.mapped[%r]: %s" % (key, err), code="bad_expect")
continue
rec = islands.get(idx)
label = part_for_island(parts, idx) or ("island_%d" % idx)
if rec is None:
rep.fail(label, "expect.islands.mapped[%r] targets island %d, absent from "
"census.json (present: %s)" % (key, idx, sorted(islands)),
island=idx, code="missing_island")
continue
checked += 1
if "verts" in spec:
check_scalar(rep, label, idx, "verts", float(rec.get("verts", -1)), spec["verts"])
if "tris" in spec:
check_scalar(rep, label, idx, "tris", float(rec.get("tris", -1)), spec["tris"])
z = rec.get("z")
if z and len(z) == 2:
if "z" in spec:
check_extent(rep, label, idx, "z", z, spec["z"])
if "z_min" in spec:
check_scalar(rep, label, idx, "z_min", float(z[0]), spec["z_min"])
if "z_max" in spec:
check_scalar(rep, label, idx, "z_max", float(z[1]), spec["z_max"])
if "z_span" in spec:
check_scalar(rep, label, idx, "z_span", float(z[1]) - float(z[0]), spec["z_span"])
elif any(k in spec for k in ("z", "z_min", "z_max", "z_span")):
rep.fail(label, "census island %d has no usable z extent (%r)" % (idx, z),
island=idx, code="bad_census")
x = rec.get("x")
if "x" in spec:
if x and len(x) == 2:
check_extent(rep, label, idx, "x", x, spec["x"])
else:
rep.fail(label, "census island %d has no usable x extent (%r)" % (idx, x),
island=idx, code="bad_census")
rep.metrics["mapped_checked"] = checked
# ── 4. unmapped islands ─────────────────────────────────────────────────
min_verts = cfg.get("min_island_verts", 0) or 0
unmapped = [i for i in sorted(islands)
if i not in referenced and int(islands[i].get("verts", 0)) >= min_verts]
rep.metrics["unmapped_islands"] = len(unmapped)
if unmapped:
detail = ("islands %s (>= min_island_verts %s) are in census.json but no part "
"claims them" % (unmapped, min_verts))
if expect.get("require_all_mapped"):
rep.fail("config", detail, code="unmapped_island")
else:
rep.warn("config", detail + " — set expect.islands.require_all_mapped to fail on this",
code="unmapped_island")
if not expect:
rep.notes.append("no expect.islands block in the config — ran the config/census "
"island cross-check only; add expect.islands for vert/z thresholds")
doc = rep.to_dict()
path = write_report(work, doc, args.out)
if path:
doc["report_path"] = path.replace("\\", "/")
print(json.dumps(doc, indent=2))
if rep.failures:
sys.stderr.write("[g2] FAIL %d check(s)\n" % len(rep.failures))
return EXIT_FAIL
sys.stderr.write("[g2] PASS (%d island(s), %d mapped checked, %d warning(s))\n"
% (len(islands), checked, len(rep.warnings)))
return EXIT_PASS
if __name__ == "__main__":
sys.exit(main())
+784
View File
@@ -0,0 +1,784 @@
# G5 — posed penetration + gape sweep (and G3 = its `--rest` mode).
#
# The pipeline's every other QA artifact is the fitting pose — the one pose that
# cannot fail. Both shipped kapa haka defects (pari neckline gaping at the
# sternum, a thigh punching through the piupiu) were invisible to it. This gate
# poses the skinned checkpoint with real game clips and measures, per frame:
#
# (a) PENETRATION — garment verts strictly inside the BODY mesh, deeper than
# `expect.penetration.depth_mm`.
# (b) GAPE — body verts inside a part's declared coverage band whose outward
# normal ray misses the garment (or hits it only far away), i.e. skin the
# garment is supposed to cover but no longer does.
#
# Invocation (see PIPELINE-CONTRACT.md):
#
# "$BLENDER" --background --python gates/g5_posed_sweep.py -- \
# --config work/<name>/resolved.json --work work/<name> [--rest]
#
# full mode (G5, after `skin`): opens <work>/50_skin.blend, poses RIG.
# --rest (G3, after `fit`) : opens <work>/20_fit.blend, static, no armature.
#
# Exit codes: 0 pass · 2 fail (thresholds violated) · 3 error (could not evaluate).
# Report: <work>/qc/g5.json (or g3.json in --rest mode).
#
# One deviation from the contract's example report: `failures[].frame` is a
# STRING label ("Walk[Walk_Fwd_Loop]@13", "rest", "Synthetic:arm_raise_70"), not
# an int — a bare frame number is ambiguous once more than one clip is sampled.
# Every entry in metrics.per_frame also carries `"source"`:
# "rest" | "clip" | "synthetic", so a mixed sweep (pose_source "both") can be
# read without parsing labels; metrics.synthetic_frames counts the latter.
#
# ── expect schema ────────────────────────────────────────────────────────────
# Contract baseline (unchanged):
#
# "expect": { "penetration": {
# "max_verts": 0, # garment verts inside body, per frame
# "depth_mm": 1.0, # how deep before a vert counts
# "clips": ["Idle", "Walk"], # pack clips, matched by name
# "frames_per_clip": 6,
# "gape": { "<Part>": { "band_z": [0.95, 1.30], "max_exposed_verts": 0 } }
# } }
#
# EXTENSIONS added by this gate (all optional, all defaulted — a config that
# only carries the contract keys above works unchanged):
#
# penetration.anim_pack str|list GLB(s) to pull clips from. Default: BOTH
# anim/UAL1.glb and anim/UAL2.glb, because
# the game binds idle/dance from UAL1 but
# aliases "walk" onto UAL2's Walk_Fwd_Loop.
# penetration.clip_aliases obj {"Idle": "Idle_Loop", ...}. Overrides the
# built-in name map (which mirrors the
# game's own bindings), then fuzzy match.
# penetration.pose_source str "auto" (default) | "clips" | "synthetic"
# | "both".
# "auto" = clips, falling back to synthetic
# extremes when no clip resolves. "both" =
# the clips AND the synthetic extremes
# appended after them (the deep-QC sweep);
# it degrades to synthetic-only if no clip
# resolves. The gate NEVER passes for lack
# of animation.
# penetration.ignore_parts list part names to skip entirely.
# (parts with "type":"bodyshell" are always
# skipped — they ARE the body.)
# penetration.max_render_frames int QA PNGs are rendered for at most this many
# failing frames (default 6). 0 disables.
# penetration.max_verts_rest int separate, looser rest-pose budget in full
# mode (defaults to max_verts).
#
# gape.<Part> entries:
# band_z [lo, hi] REQUIRED. Rest-pose world Z band of body verts
# the part must cover. Selection happens ONCE in
# rest pose (anatomy is stable); the SAME vertex
# indices are then re-tested in every posed frame.
# band_x/band_y [lo, hi] extra rest-pose spatial restriction.
# facing [x,y,z] only body verts whose rest normal points this
# way are selected (character front is -Y).
# facing_min float dot(normal, facing) threshold, default 0.25.
# ray_mm float outward ray length, default 50 mm. A miss = the
# garment is simply not there any more.
# gap_mm float a hit FARTHER than this also counts as exposed —
# fabric ballooned off the skin, you can see in.
# Default 25 mm. Set 0 to score misses only.
# max_exposed_verts int threshold on (misses + far hits). Default 0.
# max_exposed_delta int alternative threshold on (posed - rest) exposure,
# so a band that is imperfectly drawn in rest pose
# still yields a meaningful signal. When both are
# present a frame must satisfy BOTH.
# check_rest bool apply the thresholds to the rest pose too
# (default false — rest exposure is reported as a
# baseline metric either way).
# parts list garment parts that count as cover for this band.
# Default: every garment part in the file.
#
# ── implementation notes ─────────────────────────────────────────────────────
# * Meshes are read through the depsgraph (`evaluated_get`), so the ARMATURE
# modifier is applied — we measure the deformed geometry, not the rest cage.
# * Inside/outside uses BVHTree.find_nearest + sign of dot(p - hit, hit_normal);
# ~2.5k garment verts cost ~15 ms per frame, so a full sweep is BVH-bound on
# the body rebuild (one per frame), not on the queries.
# * The UAL packs are authored on the same 65-bone Quaternius skeleton as the
# derived-body RIG (verified: exact name-set match), so actions are assigned
# directly — no retarget. Rigs with EXTRA bones (piupiu_sb's 16 skirt bones)
# are fine: unanimated bones ride their parents.
import argparse
import json
import math
import os
import sys
import time
import bpy
import bmesh
from mathutils import Vector, Matrix
from mathutils.bvhtree import BVHTree
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) # gates -> clothing -> animation -> tinqs
ANIM_DIR = os.path.join(REPO, "ariki-game", "assets", "quaternius", "anim")
# Both packs, because the game pulls from both: PlayerController.SetupAnimations
# binds "idle"/"dance" from UAL1 but aliases "walk" onto UAL2's Walk_Fwd_Loop.
DEFAULT_PACKS = [os.path.join(ANIM_DIR, f).replace("\\", "/")
for f in ("UAL1.glb", "UAL2.glb")]
MM = 0.001
def log(msg):
print(f"[g5] {msg}", flush=True)
class GateError(Exception):
"""Could not evaluate — exit 3."""
# ── geometry helpers ─────────────────────────────────────────────────────────
def eval_world_verts(o, dg):
"""World-space vertex coords of `o` with modifiers applied."""
ev = o.evaluated_get(dg)
me = ev.to_mesh()
mw = o.matrix_world
pts = [mw @ v.co for v in me.vertices]
ev.to_mesh_clear()
return pts
def eval_world_verts_normals(o, dg):
ev = o.evaluated_get(dg)
me = ev.to_mesh()
mw = o.matrix_world
nm = mw.to_3x3().inverted_safe().transposed()
pts = [mw @ v.co for v in me.vertices]
nrm = [(nm @ n.vector).normalized() for n in me.vertex_normals]
ev.to_mesh_clear()
return pts, nrm
def body_bvh(body, dg):
return BVHTree.FromObject(body, dg)
def garment_bvh(parts, dg):
"""One BVH over every garment part — cover is cover, whoever provides it."""
verts, faces = [], []
for o in parts:
ev = o.evaluated_get(dg)
me = ev.to_mesh()
mw = o.matrix_world
base = len(verts)
verts.extend([mw @ v.co for v in me.vertices])
me.calc_loop_triangles()
faces.extend([[base + i for i in t.vertices] for t in me.loop_triangles])
ev.to_mesh_clear()
if not faces:
return None
return BVHTree.FromPolygons(verts, faces, all_triangles=True)
def penetrating(points, bvh, depth_m):
"""-> (count, [(index, depth_m, point)]) for verts deeper than depth_m inside."""
hits = []
for i, p in enumerate(points):
loc, nor, _idx, dist = bvh.find_nearest(p)
if loc is None:
continue
if (p - loc).dot(nor) < 0.0 and dist > depth_m:
hits.append((i, dist, p))
return len(hits), hits
# ── pose sources ─────────────────────────────────────────────────────────────
# The game's own bindings, so the gate poses what ClothingTestBed's Idle/Walk/
# Dance buttons actually play (ariki-game src/Viewer/PlayerController.cs
# SetupAnimations: idle/dance from UAL1, walk aliased to UAL2's Walk_Fwd_Loop).
BUILTIN_ALIASES = {
"idle": "Idle_Loop", "walk": "Walk_Fwd_Loop", "dance": "Dance_Loop",
"run": "Jog_Fwd_Loop", "jog": "Jog_Fwd_Loop", "sprint": "Sprint_Loop",
}
def resolve_clip(name, aliases):
"""Requested clip name -> an action in bpy.data.actions, or None."""
want = aliases.get(name) or BUILTIN_ALIASES.get(name.lower(), name)
for cand in (want, name):
if cand in bpy.data.actions:
return bpy.data.actions[cand]
low = name.lower()
matches = [a for a in bpy.data.actions if low in a.name.lower()]
if matches:
# shortest name wins: "Walk" -> Walk_Loop, not Walk_Formal_Loop
return sorted(matches, key=lambda a: (len(a.name), a.name))[0]
return None
def import_anim_pack(path):
"""Import clip actions from a GLB, then drop its mesh/armature objects."""
if not os.path.exists(path):
raise GateError(f"anim pack not found: {path}")
before = set(bpy.data.objects.keys())
t = time.time()
bpy.ops.import_scene.gltf(filepath=path)
added = [o for o in bpy.data.objects if o.name not in before]
pack_rig = next((o for o in added if o.type == "ARMATURE"), None)
bones = sorted(b.name for b in pack_rig.data.bones) if pack_rig else []
for a in bpy.data.actions:
a.use_fake_user = True # survive the object purge
for o in added:
bpy.data.objects.remove(o, do_unlink=True)
log(f"anim pack imported in {time.time() - t:.1f}s: {os.path.basename(path)} "
f"({len(bpy.data.actions)} actions, {len(bones)} pack bones)")
return bones
def bone_compat(rig, pack_bones):
rig_bones = set(b.name for b in rig.data.bones)
pack = set(pack_bones)
return {
"rig_bones": len(rig_bones),
"pack_bones": len(pack),
"shared": len(rig_bones & pack),
"pack_only": sorted(pack - rig_bones),
"rig_only": sorted(rig_bones - pack),
}
def assign_action(rig, action):
ad = rig.animation_data or rig.animation_data_create()
ad.action = action
# Blender 4.4+/5.x slotted actions: an action without a bound slot animates
# nothing at all, and does so silently.
if hasattr(ad, "action_slot") and getattr(action, "slots", None):
slot = next((s for s in action.slots if s.target_id_type == "OBJECT"),
action.slots[0])
ad.action_slot = slot
bpy.context.view_layer.update()
def clear_pose(rig):
if rig.animation_data:
rig.animation_data.action = None
for pb in rig.pose.bones:
pb.matrix_basis = Matrix()
bpy.context.view_layer.update()
def _rotate_bone_world(rig, bone, axis, deg):
"""Rotate a pose bone about a world axis through its own head."""
pb = rig.pose.bones[bone]
R = Matrix.Rotation(math.radians(deg), 4, axis)
M = pb.matrix.copy()
piv = M.translation.copy()
pb.matrix = Matrix.Translation(piv) @ R @ Matrix.Translation(-piv) @ M
bpy.context.view_layer.update()
def _rotate_toward(rig, bone, axis, deg, probe, objective):
"""Rotate `bone`, choosing the sign that maximises `objective(probe head)`.
The Quaternius bone roll is not documented anywhere we control, so instead of
hard-coding a sign we try both and keep whichever actually moves the limb the
way the pose is named for. Makes the synthetic fallback self-correcting.
"""
pb = rig.pose.bones[bone]
saved = pb.matrix_basis.copy()
best, best_score = None, None
for s in (1.0, -1.0):
pb.matrix_basis = saved.copy()
bpy.context.view_layer.update()
_rotate_bone_world(rig, bone, axis, deg * s)
score = objective(rig.matrix_world @ rig.pose.bones[probe].head)
if best_score is None or score > best_score:
best, best_score = pb.matrix_basis.copy(), score
pb.matrix_basis = best
bpy.context.view_layer.update()
def synthetic_poses(rig):
"""Named extreme poses, applied by direct bone rotation.
Deliberately harsher than any shipped clip: if a garment survives these it
is not going to fail on Idle. Used when no pack clip resolves.
"""
have = set(b.name for b in rig.pose.bones)
def arm_raise():
for side in ("l", "r"):
b = f"upperarm_{side}"
if b in have and f"hand_{side}" in have:
_rotate_toward(rig, b, "Y", 70, f"hand_{side}", lambda p: p.z)
def step_flex():
if "thigh_l" in have and "foot_l" in have:
_rotate_toward(rig, "thigh_l", "X", 60, "foot_l", lambda p: -p.y)
if "thigh_r" in have and "foot_r" in have:
_rotate_toward(rig, "thigh_r", "X", 35, "foot_r", lambda p: p.y)
def torso_twist():
spine = "spine_02" if "spine_02" in have else "spine_01"
probe = "clavicle_l" if "clavicle_l" in have else "Head"
if spine in have and probe in have:
_rotate_toward(rig, spine, "Z", 30, probe, lambda p: -p.x)
def combo():
step_flex()
torso_twist()
arm_raise()
return [("Synthetic:arm_raise_70", arm_raise),
("Synthetic:step_thigh_60", step_flex),
("Synthetic:torso_twist_30", torso_twist),
("Synthetic:combined", combo)]
def sample_frames(action, n):
lo, hi = action.frame_range
lo, hi = int(math.floor(lo)), int(math.ceil(hi))
if n <= 1 or hi <= lo:
return [lo]
step = (hi - lo) / float(n)
# sample inside the range; the last frame of a loop duplicates the first
return [int(round(lo + step * i)) for i in range(n)]
# ── QA artifacts ─────────────────────────────────────────────────────────────
def make_markers(points, name, size=0.008, cap=500):
me = bpy.data.meshes.new(name)
bm = bmesh.new()
step = max(1, len(points) // cap + (1 if len(points) % cap else 0))
for p in points[::step]:
bmesh.ops.create_cube(bm, size=size, matrix=Matrix.Translation(p))
bm.to_mesh(me)
bm.free()
o = bpy.data.objects.new(name, me)
bpy.context.scene.collection.objects.link(o)
return o
def render_failure(work, tag, body, parts, pen_pts, gape_pts):
"""Front + closeup Workbench renders with failing verts marked. -> [paths]"""
scene = bpy.context.scene
hidden = []
for o in bpy.data.objects:
if o.type == "MESH" and (o.name == "BODY_SHELL" or o.name.startswith("HI_")):
if not o.hide_render:
o.hide_render = True
hidden.append(o)
markers = []
if pen_pts:
m = make_markers(pen_pts, "_g5_pen")
m.color = (1.0, 0.05, 0.05, 1.0)
markers.append(m)
if gape_pts:
m = make_markers(gape_pts, "_g5_gape", size=0.010)
m.color = (1.0, 0.85, 0.0, 1.0)
markers.append(m)
body.color = (0.86, 0.70, 0.60, 1.0)
for p in parts:
p.color = (0.20, 0.35, 0.75, 1.0)
scene.render.engine = "BLENDER_WORKBENCH"
scene.display.shading.light = "STUDIO"
scene.display.shading.color_type = "OBJECT"
scene.render.resolution_x, scene.render.resolution_y = 640, 860
scene.render.film_transparent = False
body_pts = list(pen_pts) + list(gape_pts)
allz = [(body.matrix_world @ Vector(c)).z for c in body.bound_box]
zmin, zmax = min(allz), max(allz)
shots = [("front", Vector((0, -4, (zmin + zmax) / 2)),
(math.pi / 2, 0, 0), (zmax - zmin) * 1.15)]
if body_pts:
c = sum(body_pts, Vector((0, 0, 0))) / len(body_pts)
span = max(0.25, max((p - c).length for p in body_pts) * 2.4)
shots.append(("closeup", Vector((c.x + span * 0.9, c.y - span * 1.5, c.z + span * 0.25)),
(math.radians(83), 0, math.radians(31)), span))
out = []
qc = os.path.join(work, "qc")
os.makedirs(qc, exist_ok=True)
for view, loc, rot, ortho in shots:
cam_name = f"_g5_cam_{view}"
cam = bpy.data.objects.get(cam_name)
if cam is None:
cam = bpy.data.objects.new(cam_name, bpy.data.cameras.new(cam_name))
bpy.context.scene.collection.objects.link(cam)
cam.data.type = "ORTHO"
cam.data.ortho_scale = ortho
cam.location, cam.rotation_euler = loc, rot
scene.camera = cam
path = os.path.join(qc, f"{tag}_{view}.png")
scene.render.filepath = path
bpy.ops.render.render(write_still=True)
out.append("qc/" + os.path.basename(path))
for m in markers:
bpy.data.objects.remove(m, do_unlink=True)
for o in hidden:
o.hide_render = False
return out
# ── gape band ────────────────────────────────────────────────────────────────
class GapeBand:
def __init__(self, part_name, spec, body, dg, all_parts):
self.part = part_name
self.spec = spec
band_z = spec.get("band_z")
if not band_z or len(band_z) != 2:
raise GateError(f"gape.{part_name}: band_z [lo, hi] is required")
self.ray_m = float(spec.get("ray_mm", 50.0)) * MM
self.gap_m = float(spec.get("gap_mm", 25.0)) * MM
self.max_exposed = spec.get("max_exposed_verts")
self.max_delta = spec.get("max_exposed_delta")
if self.max_exposed is None and self.max_delta is None:
self.max_exposed = 0
self.check_rest = bool(spec.get("check_rest", False))
cover = spec.get("parts")
self.cover_parts = ([p for p in all_parts if p.name.replace("GARM_", "") in cover]
if cover else list(all_parts))
# Select the band ONCE, in rest pose: anatomy is stable, posed Z is not.
pts, nrm = eval_world_verts_normals(body, dg)
facing = spec.get("facing")
fvec = Vector(facing).normalized() if facing else None
fmin = float(spec.get("facing_min", 0.25))
bx, by = spec.get("band_x"), spec.get("band_y")
idx = []
for i, p in enumerate(pts):
if not (band_z[0] <= p.z <= band_z[1]):
continue
if bx and not (bx[0] <= p.x <= bx[1]):
continue
if by and not (by[0] <= p.y <= by[1]):
continue
if fvec is not None and nrm[i].dot(fvec) < fmin:
continue
idx.append(i)
self.indices = idx
self.rest_exposed = None
def measure(self, body, dg):
"""-> (exposed_count, miss, far, [world points]) for the current frame."""
gb = garment_bvh(self.cover_parts, dg)
if gb is None:
raise GateError(f"gape.{self.part}: no garment parts to test cover against")
pts, nrm = eval_world_verts_normals(body, dg)
miss, far, out = 0, 0, []
for i in self.indices:
p, n = pts[i], nrm[i]
loc, _nor, _idx, d = gb.ray_cast(p + n * 0.0005, n, self.ray_m)
if loc is None:
miss += 1
out.append(p)
elif self.gap_m > 0 and d > self.gap_m:
far += 1
out.append(p)
return miss + far, miss, far, out
# ── main ─────────────────────────────────────────────────────────────────────
def parse_args():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(prog="g5_posed_sweep")
ap.add_argument("--config", required=True)
ap.add_argument("--work", required=True)
ap.add_argument("--rest", action="store_true",
help="G3: static rest-pose test on 20_fit.blend")
ap.add_argument("--blend", default=None, help="override the checkpoint to open")
ap.add_argument("--anim-pack", default=None)
ap.add_argument("--pose-source", default=None,
choices=["auto", "clips", "synthetic", "both"])
ap.add_argument("--max-render-frames", type=int, default=None)
return ap.parse_args(argv)
def run(args):
with open(args.config, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
exp = (cfg.get("expect") or {}).get("penetration")
if exp is None:
raise GateError("config has no expect.penetration block — nothing to check")
gate_id = "g3" if args.rest else "g5"
stage = "fit" if args.rest else "skin"
work = os.path.abspath(args.work)
qc = os.path.join(work, "qc")
os.makedirs(qc, exist_ok=True)
blend = args.blend or os.path.join(work, "20_fit.blend" if args.rest else "50_skin.blend")
if not os.path.exists(blend):
raise GateError(f"missing checkpoint {blend} — run stage '{stage}' first")
bpy.ops.wm.open_mainfile(filepath=blend)
depth_m = float(exp.get("depth_mm", 1.0)) * MM
max_verts = int(exp.get("max_verts", 0))
max_verts_rest = int(exp.get("max_verts_rest", max_verts))
max_renders = (args.max_render_frames if args.max_render_frames is not None
else int(exp.get("max_render_frames", 6)))
ignore = set(exp.get("ignore_parts") or [])
for name, pc in (cfg.get("parts") or {}).items():
if pc.get("type") == "bodyshell":
ignore.add(name)
body = bpy.data.objects.get("BODY")
if body is None:
raise GateError(f"{os.path.basename(blend)} has no BODY mesh")
parts = [o for o in bpy.data.objects
if o.type == "MESH" and o.name.startswith("GARM_")
and o.name.replace("GARM_", "") not in ignore]
if not parts:
raise GateError("no GARM_* parts to test (all ignored?)")
log(f"{gate_id}: {os.path.basename(blend)} — body {len(body.data.vertices)}v, "
f"parts {[p.name for p in parts]}")
rig = bpy.data.objects.get("RIG")
dg = bpy.context.evaluated_depsgraph_get()
failures, artifacts, frames_report = [], [], []
rendered = 0
# Render budget is spread across clips — a first-come budget spent every PNG
# on frame 0..N of the first clip and never showed what Walk/Dance did.
rendered_in_group = {}
per_group = [max_renders]
# Mutable global render cap. `pose_source: "both"` runs the clips and THEN the
# synthetic extremes; without reserving part of the budget the clips would spend
# all of it and the extremes — the frames deep QC is there for — would ship
# numbers with no picture.
render_cap = [max_renders]
# ── gape bands (selected in rest pose) ───────────────────────────────────
bands = []
for pname, spec in (exp.get("gape") or {}).items():
if pname in ignore:
continue
b = GapeBand(pname, spec, body, dg, parts)
log(f"gape band {pname}: {len(b.indices)} body verts selected "
f"(z {spec.get('band_z')}, ray {b.ray_m*1000:.0f}mm, gap {b.gap_m*1000:.0f}mm)")
if not b.indices:
raise GateError(f"gape.{pname}: band selected 0 body verts — check band_z")
bands.append(b)
def evaluate(label, do_render=True, is_rest=False, group=None, source="clip"):
"""Test one pose. -> dict of per-frame metrics; appends failures."""
nonlocal rendered
group = group or label
n_fail_before = len(failures)
d = bpy.context.evaluated_depsgraph_get()
bvh = body_bvh(body, d)
rec = {"frame": label, "source": "rest" if is_rest else source,
"penetrating": 0, "max_depth_mm": 0.0, "parts": {}}
pen_pts = []
budget = max_verts_rest if is_rest else max_verts
for p in parts:
pts = eval_world_verts(p, d)
n, hits = penetrating(pts, bvh, depth_m)
deepest = max((h[1] for h in hits), default=0.0)
rec["parts"][p.name] = {"penetrating": n, "max_depth_mm": round(deepest * 1000, 2),
"verts": len(pts)}
rec["penetrating"] += n
rec["max_depth_mm"] = max(rec["max_depth_mm"], round(deepest * 1000, 2))
pen_pts.extend(h[2] for h in hits)
if n > budget:
failures.append({
"part": p.name, "frame": label, "kind": "penetration",
"detail": f"{n} verts inside BODY deeper than {depth_m*1000:.1f} mm "
f"(max {deepest*1000:.1f} mm), budget {budget}"})
gape_pts = []
for b in bands:
total, miss, far, pts = b.measure(body, d)
if is_rest and b.rest_exposed is None:
b.rest_exposed = total
entry = {"exposed": total, "miss": miss, "far": far,
"band_verts": len(b.indices)}
if b.rest_exposed is not None:
entry["delta_vs_rest"] = total - b.rest_exposed
rec.setdefault("gape", {})[b.part] = entry
if is_rest and not b.check_rest:
continue
bad = []
if b.max_exposed is not None and total > b.max_exposed:
bad.append(f"{total} exposed verts (miss {miss}, far {far}) "
f"> max_exposed_verts {b.max_exposed}")
if b.max_delta is not None and b.rest_exposed is not None \
and (total - b.rest_exposed) > b.max_delta:
bad.append(f"exposure +{total - b.rest_exposed} vs rest ({b.rest_exposed}) "
f"> max_exposed_delta {b.max_delta}")
if bad:
gape_pts.extend(pts)
failures.append({"part": b.part, "frame": label, "kind": "gape",
"detail": "; ".join(bad)})
if (len(failures) > n_fail_before and do_render and rendered < render_cap[0]
and rendered_in_group.get(group, 0) < per_group[0]):
tag = f"{gate_id}_" + "".join(
c if (c.isalnum() or c in "._-") else "_" for c in label)
artifacts.extend(render_failure(work, tag, body, parts, pen_pts, gape_pts))
rendered += 1
rendered_in_group[group] = rendered_in_group.get(group, 0) + 1
rec["artifact_tag"] = tag
frames_report.append(rec)
return rec
pose_source = args.pose_source or exp.get("pose_source", "auto")
t0 = time.time()
# ── rest baseline (both modes) ───────────────────────────────────────────
if rig is not None and not args.rest:
clear_pose(rig)
rest = evaluate("rest", is_rest=True)
log(f"rest: penetrating={rest['penetrating']} "
f"gape={ {k: v['exposed'] for k, v in rest.get('gape', {}).items()} }")
compat = None
used_source = "rest-only"
if not args.rest:
if rig is None:
raise GateError("50_skin.blend has no RIG armature — cannot pose")
packs = args.anim_pack or exp.get("anim_pack") or DEFAULT_PACKS
packs = [packs] if isinstance(packs, str) else list(packs)
clips = list(exp.get("clips") or ["Idle", "Walk"])
n_frames = int(exp.get("frames_per_clip", 6))
aliases = exp.get("clip_aliases") or {}
resolved = []
if pose_source in ("auto", "clips", "both"):
pack_bones = set()
for p in packs:
pack_bones |= set(import_anim_pack(p))
compat = bone_compat(rig, sorted(pack_bones))
log(f"bone compat: {compat['shared']}/{compat['pack_bones']} pack bones "
f"present on RIG; rig-only {compat['rig_only']}; "
f"pack-only {compat['pack_only']}")
if compat["shared"] < 0.8 * compat["pack_bones"]:
log("bone overlap too low for direct assignment — using synthetic poses")
resolved = []
else:
for c in clips:
a = resolve_clip(c, aliases)
if a is None:
log(f"clip '{c}' not found in pack — skipped")
else:
resolved.append((c, a))
did_clips = False
if resolved and pose_source != "synthetic":
did_clips = True
used_source = "clips:" + "+".join(os.path.basename(p) for p in packs)
log("clips: " + ", ".join(f"{c}->{a.name}" for c, a in resolved))
# "both" appends the synthetic extremes afterwards — hold a quarter of the
# render budget (at least one frame) back for them.
reserve = min(2, max(1, max_renders // 4)) if pose_source == "both" else 0
render_cap[0] = max(1, max_renders - reserve) if max_renders else 0
per_group[0] = max(1, render_cap[0] // len(resolved))
for cname, act in resolved:
assign_action(rig, act)
for f in sample_frames(act, n_frames):
bpy.context.scene.frame_set(f)
bpy.context.view_layer.update()
r = evaluate(f"{cname}[{act.name}]@{f}", group=cname, source="clip")
log(f" {cname}@{f}: pen={r['penetrating']} "
f"gape={ {k: v['exposed'] for k, v in r.get('gape', {}).items()} }")
clear_pose(rig)
# Synthetic extremes: the fallback when nothing resolved, and the deliberate
# SECOND PASS when pose_source is "both" (deep QC). Deliberately harsher than
# any shipped clip.
if not did_clips or pose_source == "both":
if not did_clips and pose_source == "clips":
raise GateError(
"pose_source=clips but no requested clip resolved in "
+ ", ".join(packs))
used_source = f"{used_source}+synthetic" if did_clips else "synthetic"
render_cap[0] = max_renders
per_group[0] = max_renders
log("pose source: SYNTHETIC extremes ("
+ ("appended, pose_source=both" if did_clips
else "no pack clip resolved") + ")")
for label, apply in synthetic_poses(rig):
clear_pose(rig)
apply()
r = evaluate(label, source="synthetic")
log(f" {label}: pen={r['penetrating']} "
f"gape={ {k: v['exposed'] for k, v in r.get('gape', {}).items()} }")
clear_pose(rig)
elapsed = round(time.time() - t0, 1)
worst_pen = max((f["penetrating"] for f in frames_report), default=0)
worst_gape = {}
for f in frames_report:
for k, v in (f.get("gape") or {}).items():
worst_gape[k] = max(worst_gape.get(k, 0), v["exposed"])
report = {
"gate": gate_id,
"pass": not failures,
"checked_at_stage": stage,
"metrics": {
"frames": len(frames_report),
"pose_source": used_source,
"clips": [f["frame"] for f in frames_report if f.get("source") == "clip"],
"synthetic": [f["frame"] for f in frames_report
if f.get("source") == "synthetic"],
"synthetic_frames": sum(1 for f in frames_report
if f.get("source") == "synthetic"),
"worst_penetrating_verts": worst_pen,
"rest_penetrating_verts": rest["penetrating"],
"worst_exposed_verts": worst_gape,
"rest_exposed_verts": {k: v["exposed"] for k, v in (rest.get("gape") or {}).items()},
"depth_mm": round(depth_m * 1000, 3),
"max_verts": max_verts,
"seconds": elapsed,
"bone_compat": compat,
"per_frame": frames_report,
},
"failures": failures,
"artifacts": artifacts,
}
out = os.path.join(qc, f"{gate_id}.json")
with open(out, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
log(f"report: {out}")
n_syn = report["metrics"]["synthetic_frames"]
log(f"{gate_id} {'PASS' if not failures else 'FAIL'}{len(failures)} failure(s), "
f"{len(frames_report)} frames ({n_syn} synthetic), {elapsed}s, "
f"pose source {used_source}")
for f in failures[:12]:
log(f" FAIL {f['kind']} {f['part']} @{f['frame']}: {f['detail']}")
return 0 if not failures else 2
def main():
args = parse_args()
try:
code = run(args)
except GateError as e:
log(f"ERROR: {e}")
try:
qc = os.path.join(os.path.abspath(args.work), "qc")
os.makedirs(qc, exist_ok=True)
gid = "g3" if args.rest else "g5"
with open(os.path.join(qc, f"{gid}.json"), "w", encoding="utf-8") as fh:
json.dump({"gate": gid, "pass": False,
"checked_at_stage": "fit" if args.rest else "skin",
"metrics": {}, "error": str(e),
"failures": [{"part": "-", "detail": str(e), "frame": -1}],
"artifacts": []}, fh, indent=2)
except Exception:
pass
code = 3
except Exception as e: # noqa: BLE001 — gate must not hang
import traceback
traceback.print_exc()
log(f"ERROR: unexpected: {e}")
code = 3
sys.stdout.flush()
sys.exit(code)
if __name__ == "__main__":
main()
+676
View File
@@ -0,0 +1,676 @@
#!/usr/bin/env python3
"""G8 — catalog <-> asset lint (design doc §3 "G8", §1.3 item 2).
`ariki-game/src/Character/OutfitCatalog.cs` resolves every item's asset path as
`{BaseDirFor(set)}/{Gender}_{Set}_{SlotFileName}.gltf`, and a missing file is
**warn-only** at runtime (`OutfitCatalog.cs:71-78`) — the character just stays
naked. Worse, `BaseDirFor`'s `_ =>` arm silently resolves an unknown/typo'd set
to the Fantasy pack folder. This gate makes both failures loud and pre-game.
Two modes:
# lint the whole live catalog, no config needed
python clothing/gates/g8_catalog_lint.py --all [--out report.json]
# pipeline mode: whole-catalog lint + "is THIS garment registered?"
python clothing/gates/g8_catalog_lint.py --config work/<name>/resolved.json \\
--work work/<name> [--require-registered]
Exit codes: 0 pass · 2 fail (lint violations) · 3 error (could not evaluate).
`--config` mode writes `<work>/qc/g8.json`; `--all` prints the report to stdout
(and to `--out` if given). Stdlib only.
Checks
------
forward every catalog entry's resolved .gltf exists on disk (case-sensitively —
Windows dev boxes are case-insensitive, Godot exports need not be)
fallback an entry whose `set:` is NOT a named `BaseDirFor` arm *and* whose file
is missing from the Fantasy default folder — the silent-typo case
buffers each existing .gltf's `buffers[].uri` sidecar (`.bin`) exists
duplicate two `Add()` calls sharing an id (the C# dict assignment silently wins)
reverse every `{Gender}_{Set}_{Slot}.gltf` under assets/quaternius/outfits/ has
a catalog entry that resolves to it
config (--config only) `export.out_dir` agrees with `BaseDirFor(export.set)`,
and each part slot has a catalog entry
Reverse-check scope decision (verified 2026-07-31, do not "fix" without re-checking):
the Fantasy pack ships exactly 20 `.gltf` files under
`Modular Character Outfits - Fantasy[Standard]/.../Modular Parts` and the catalog
registers exactly those 20 (Peasant m/f x 4 slots = 8, Ranger m/f x 6 = 12). There
are therefore **no legitimately-unregistered pack parts today**, so the reverse
check treats every outfit folder — Fantasy included — as a hard failure. If a
future pack drop adds parts nobody intends to register, downgrade that folder
with `--info-dir "Fantasy[Standard]"` (repeatable, substring match) rather than
silently narrowing the scan.
`expect` block: this gate reads no thresholds — per the contract it runs after
`register` unconditionally.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
GATE_ID = "g8"
STAGE = "register"
EXIT_PASS, EXIT_FAIL, EXIT_ERROR = 0, 2, 3
RES_PREFIX = "res://"
OUTFITS_REL = "assets/quaternius/outfits"
# Mirrors OutfitCatalog.SlotToFileName's plain arms (OutfitCatalog.cs:101-113).
SLOT_DEFAULT_FILENAME = {
"Body": "Body",
"Arms": "Arms",
"Legs": "Legs",
"Feet": "Feet",
"HeadGear": "Head_Hood",
"Accessories": "Acc_Pauldron",
}
# ── report plumbing ──────────────────────────────────────────────────────────
class Report:
def __init__(self):
self.metrics = {}
self.failures = []
self.warnings = []
self.notes = []
self.artifacts = []
def fail(self, part, detail, **extra):
e = {"part": part, "detail": detail}
e.update(extra)
self.failures.append(e)
def warn(self, part, detail, **extra):
e = {"part": part, "detail": detail}
e.update(extra)
self.warnings.append(e)
def to_dict(self):
return {
"gate": GATE_ID,
"pass": not self.failures,
"checked_at_stage": STAGE,
"metrics": self.metrics,
"failures": self.failures,
"warnings": self.warnings,
"notes": self.notes,
"artifacts": self.artifacts,
}
def emit(report, work, out, all_mode):
path = out
if not path and work and not all_mode:
path = os.path.join(work, "qc", "%s.json" % GATE_ID)
if path:
try:
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(report, fh, indent=2)
fh.write("\n")
report = dict(report, report_path=path)
except OSError as exc: # pragma: no cover
sys.stderr.write("[g8] WARNING could not write %s: %s\n" % (path, exc))
print(json.dumps(report, indent=2))
return report
def die(detail, work, out, all_mode):
rep = Report()
rep.fail("catalog", detail, code="gate_error")
doc = rep.to_dict()
doc["error"] = detail
sys.stderr.write("[g8] ERROR %s\n" % detail)
emit(doc, work, out, all_mode)
return EXIT_ERROR
# ── OutfitCatalog.cs parsing ─────────────────────────────────────────────────
STRING_LIT = re.compile(r'"((?:[^"\\]|\\.)*)"')
def _unescape(s):
return (s.replace('\\"', '"').replace("\\\\", "\\")
.replace("\\n", "\n").replace("\\t", "\t"))
def _strip_comments(src):
"""Blank out // and /* */ so commented-out Add() lines are not parsed.
String literals are preserved (a `//` inside one would otherwise eat the rest
of the line). Newlines are kept so reported line numbers stay correct.
"""
out = []
i, n = 0, len(src)
while i < n:
c = src[i]
if c == '"':
j = i + 1
while j < n:
if src[j] == "\\":
j += 2
continue
if src[j] == '"':
j += 1
break
j += 1
out.append(src[i:j])
i = j
elif src.startswith("//", i):
j = src.find("\n", i)
j = n if j < 0 else j
out.append(" " * (j - i))
i = j
elif src.startswith("/*", i):
j = src.find("*/", i + 2)
j = n if j < 0 else j + 2
out.append("".join(ch if ch == "\n" else " " for ch in src[i:j]))
i = j
else:
out.append(c)
i += 1
return "".join(out)
def parse_base_dirs(src):
"""-> ({setId: baseDir}, defaultBaseDir) from the BaseDirFor switch expression."""
m = re.search(r"BaseDirFor\s*\(\s*string\s+\w+\s*\)\s*=>\s*\w+\s+switch\s*\{",
src, re.S)
if not m:
raise ValueError("could not locate the BaseDirFor switch expression")
body_start = m.end()
depth = 1
i = body_start
while i < len(src) and depth:
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
body = src[body_start:i - 1]
arms, default = {}, None
for raw_arm in body.split(","):
arm = raw_arm.strip()
if not arm or "=>" not in arm:
continue
lhs, rhs = arm.split("=>", 1)
literals = [_unescape(x) for x in STRING_LIT.findall(rhs)]
if not literals:
continue
value = "".join(literals) # C# folds `"a" + "b"` across lines
lhs = lhs.strip()
if lhs == "_":
default = value
else:
key = STRING_LIT.findall(lhs)
if key:
arms[_unescape(key[0])] = value
if default is None:
raise ValueError("BaseDirFor has no `_ =>` default arm")
return arms, default
def split_args(arglist):
"""Split a C# argument list on top-level commas, respecting strings/nesting."""
args, buf, depth, i, n = [], [], 0, 0, len(arglist)
while i < n:
c = arglist[i]
if c == '"':
j = i + 1
while j < n:
if arglist[j] == "\\":
j += 2
continue
if arglist[j] == '"':
j += 1
break
j += 1
buf.append(arglist[i:j])
i = j
continue
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
if c == "," and depth == 0:
args.append("".join(buf).strip())
buf = []
else:
buf.append(c)
i += 1
tail = "".join(buf).strip()
if tail:
args.append(tail)
return args
# `Add(` but not `AddDefault(` — tolerant of the file's multi-space column alignment
# and of entries spread over several lines.
ADD_CALL = re.compile(r"(?<![A-Za-z0-9_])Add\s*\(")
def parse_add_calls(src):
"""-> list of dicts: id, displayName, slot, slotSuffix, category, set, gender, line."""
entries = []
for m in ADD_CALL.finditer(src):
start = m.end()
depth, i, n = 1, start, len(src)
while i < n and depth:
c = src[i]
if c == '"':
j = i + 1
while j < n:
if src[j] == "\\":
j += 2
continue
if src[j] == '"':
j += 1
break
j += 1
i = j
continue
if c == "(":
depth += 1
elif c == ")":
depth -= 1
i += 1
args = split_args(src[start:i - 1])
if len(args) < 4:
continue # not the Add(id, name, slot, suffix, ...) overload
if not STRING_LIT.match(args[0].strip()):
continue # the `static void Add(string id, ...)` declaration itself
entry = {"line": src.count("\n", 0, m.start()) + 1,
"set": None, "gender": -1, "category": None, "slotSuffix": None}
positional = []
for arg in args:
named = re.match(r"^([A-Za-z_]\w*)\s*:\s*(.+)$", arg, re.S)
if named and not arg.startswith('"'):
entry["_" + named.group(1)] = named.group(2).strip()
else:
positional.append(arg)
def lit(idx):
if idx >= len(positional):
return None
v = positional[idx].strip()
if v == "null":
return None
s = STRING_LIT.match(v)
return _unescape(s.group(1)) if s else v
entry["id"] = lit(0)
entry["displayName"] = lit(1)
slot = positional[2].strip() if len(positional) > 2 else ""
entry["slot"] = slot.split(".")[-1] if slot.startswith("OutfitSlot.") else slot
entry["slotSuffix"] = lit(3)
entry["category"] = lit(6)
if "_set" in entry:
s = STRING_LIT.match(entry.pop("_set"))
entry["set"] = _unescape(s.group(1)) if s else None
if "_gender" in entry:
g = entry.pop("_gender").strip()
try:
entry["gender"] = int(g)
except ValueError:
entry["gender"] = -1
for stale in [k for k in entry if k.startswith("_")]:
entry.pop(stale)
if not entry["id"] or not entry["slot"]:
continue
entries.append(entry)
return entries
def slot_to_file_name(slot, set_id, gender):
"""Replicates OutfitCatalog.SlotToFileName incl. the two Ranger exceptions."""
if slot == "Feet":
return "Feet_Boots" if (set_id == "Ranger" and gender == 0) else "Feet"
if slot == "Accessories":
return "Acc_Pauldrons" if (set_id == "Ranger" and gender == 1) else "Acc_Pauldron"
return SLOT_DEFAULT_FILENAME.get(slot)
def resolve_asset_path(base_dirs, default_dir, set_id, slot, gender, slot_suffix):
"""Replicates OutfitCatalog.ResolveAssetPath. -> (res_path, used_default_arm)."""
if not set_id or set_id == "none":
return None, False
g = "Female" if gender == 1 else "Male"
slot_name = slot_suffix or slot_to_file_name(slot, set_id, gender)
if not slot_name:
return None, False
used_default = set_id not in base_dirs
base = base_dirs.get(set_id, default_dir)
return "%s/%s_%s_%s.gltf" % (base, g, set_id, slot_name), used_default
# ── filesystem ───────────────────────────────────────────────────────────────
_LISTING = {}
def _listing(d):
key = os.path.normcase(os.path.abspath(d))
if key not in _LISTING:
try:
_LISTING[key] = set(os.listdir(d))
except OSError:
_LISTING[key] = None
return _LISTING[key]
def exists_exact(path):
"""os.path.exists + exact-case name match (Windows FS is case-insensitive)."""
if not os.path.exists(path):
return False, False
d, name = os.path.split(path)
names = _listing(d)
if names is None:
return True, True
return True, name in names
def res_to_disk(res_path, game_root):
if res_path.startswith(RES_PREFIX):
return os.path.join(game_root, res_path[len(RES_PREFIX):].replace("/", os.sep))
return res_path.replace("/", os.sep)
def disk_to_res(disk_path, game_root):
rel = os.path.relpath(disk_path, game_root).replace("\\", "/")
return RES_PREFIX + rel
def gltf_buffer_uris(path):
"""-> list of sidecar file names referenced by the .gltf (data: URIs skipped)."""
try:
with open(path, "r", encoding="utf-8") as fh:
doc = json.load(fh)
except (OSError, ValueError):
return None
uris = []
for buf in doc.get("buffers", []) or []:
uri = buf.get("uri")
if uri and not uri.startswith("data:"):
uris.append(uri)
return uris
# ── lint ─────────────────────────────────────────────────────────────────────
ASSET_NAME = re.compile(r"^(Male|Female)_([^_]+)_(.+)\.gltf$")
def lint_catalog(rep, entries, base_dirs, default_dir, game_root, info_dirs):
expected_disk = {} # normcase disk path -> entry id
seen_ids = {}
for e in entries:
eid = e["id"]
if eid in seen_ids:
rep.fail(eid, "duplicate catalog id (also at line %d) — the later Add() "
"silently overwrites the earlier one" % seen_ids[eid],
code="duplicate_id", line=e["line"])
seen_ids[eid] = e["line"]
if not e["set"]:
rep.warn(eid, "no set: argument — AssetPath stays null (defaults-only entry)",
code="no_set", line=e["line"])
continue
res_path, used_default = resolve_asset_path(
base_dirs, default_dir, e["set"], e["slot"], e["gender"], e["slotSuffix"])
if not res_path:
rep.fail(eid, "slot %r has no file-name mapping and no explicit slotSuffix — "
"ResolveAssetPath returns null" % e["slot"],
code="unmappable_slot", line=e["line"])
continue
disk = res_to_disk(res_path, game_root)
expected_disk[os.path.normcase(os.path.abspath(disk))] = eid
present, exact = exists_exact(disk)
if not present:
if used_default:
rep.fail(eid, "set %r is not a named BaseDirFor arm, so it fell through "
"`_ =>` to the Fantasy pack folder and %s does not exist "
"there — typo'd set name or a missing BaseDirFor case"
% (e["set"], res_path),
code="fallback_missing", line=e["line"], path=res_path,
set=e["set"])
else:
rep.fail(eid, "missing asset %s (runtime is warn-only: the character just "
"stays naked)" % res_path,
code="missing_asset", line=e["line"], path=res_path, set=e["set"])
continue
if not exact:
rep.fail(eid, "asset %s exists only under different letter case on disk — "
"breaks on a case-sensitive filesystem" % res_path,
code="case_mismatch", line=e["line"], path=res_path)
continue
uris = gltf_buffer_uris(disk)
if uris is None:
rep.warn(eid, "could not parse %s as glTF JSON" % res_path,
code="unparsable_gltf", path=res_path)
else:
for uri in uris:
side = os.path.join(os.path.dirname(disk),
uri.replace("/", os.sep))
ok, exact_side = exists_exact(side)
if not ok or not exact_side:
rep.fail(eid, "%s references buffer %r which is missing on disk — "
"the mesh will not load" % (res_path, uri),
code="missing_buffer", path=res_path, buffer=uri)
# ── reverse: assets on disk with no catalog entry ───────────────────────
outfits_root = os.path.join(game_root, OUTFITS_REL.replace("/", os.sep))
scanned = 0
if not os.path.isdir(outfits_root):
rep.warn("catalog", "outfits root not found: %s (reverse check skipped)"
% outfits_root, code="no_outfits_root")
else:
for dirpath, _dirnames, filenames in os.walk(outfits_root):
for fn in filenames:
if not fn.endswith(".gltf") or not ASSET_NAME.match(fn):
continue
scanned += 1
disk = os.path.join(dirpath, fn)
if os.path.normcase(os.path.abspath(disk)) in expected_disk:
continue
res_path = disk_to_res(disk, game_root)
downgrade = any(sub in res_path for sub in info_dirs)
detail = ("asset %s has no OutfitCatalog entry — unreachable in game"
% res_path)
if downgrade:
rep.warn("catalog", detail + " (folder downgraded via --info-dir)",
code="unregistered_asset", path=res_path)
else:
rep.fail("catalog", detail, code="unregistered_asset", path=res_path)
rep.metrics["assets_scanned"] = scanned
rep.metrics["catalog_entries"] = len(entries)
rep.metrics["entries_with_set"] = sum(1 for e in entries if e["set"])
rep.metrics["sets"] = sorted({e["set"] for e in entries if e["set"]})
rep.metrics["fallback_sets"] = sorted({e["set"] for e in entries
if e["set"] and e["set"] not in base_dirs})
return expected_disk
def lint_config(rep, cfg, entries, base_dirs, default_dir, game_root, require_registered):
"""--config mode: does this garment's export line up with the catalog?"""
export = cfg.get("export") or {}
set_id = export.get("set")
gender_name = export.get("gender")
out_dir = export.get("out_dir")
if not set_id or not gender_name:
rep.fail("config", "export.set / export.gender missing — cannot check registration",
code="bad_config")
return
gender = 1 if str(gender_name).lower().startswith("f") else 0
rep.metrics["config_set"] = set_id
rep.metrics["config_gender"] = gender_name
# 1. does BaseDirFor know this set, and does it agree with export.out_dir?
if set_id not in base_dirs:
rep.fail("config", "export.set %r has no BaseDirFor arm — the game will look for "
"it in the Fantasy pack folder (%s). Add a case to "
"OutfitCatalog.BaseDirFor." % (set_id, default_dir),
code="missing_basedir", set=set_id)
elif out_dir:
want = os.path.normcase(os.path.abspath(res_to_disk(base_dirs[set_id], game_root)))
got = os.path.normcase(os.path.abspath(out_dir))
if want != got:
rep.fail("config", "export.out_dir (%s) != BaseDirFor(%r) (%s) — the pipeline "
"writes where the game will not look"
% (out_dir, set_id, base_dirs[set_id]),
code="outdir_mismatch", set=set_id)
# 2. one catalog entry per exported slot
by_slot = {}
for e in entries:
if e["set"] == set_id and e["gender"] == gender:
by_slot.setdefault(e["slotSuffix"] or slot_to_file_name(
e["slot"], e["set"], e["gender"]), []).append(e)
slots = sorted({(p or {}).get("slot") for p in (cfg.get("parts") or {}).values()
if isinstance(p, dict) and p.get("slot")})
rep.metrics["config_slots"] = slots
registered = 0
for slot in slots:
fname = "%s_%s_%s.gltf" % (gender_name, set_id, slot)
# exported file present?
if out_dir:
present, exact = exists_exact(os.path.join(out_dir, fname))
if not present:
rep.warn("config", "%s not found in export.out_dir — run the export stage"
% fname, code="not_exported", path=fname)
elif not exact:
rep.fail("config", "%s exists under different letter case in export.out_dir"
% fname, code="case_mismatch", path=fname)
hits = by_slot.get(slot) or []
if hits:
registered += 1
continue
detail = ("no OutfitCatalog entry resolves to %s (need an Add(..., OutfitSlot.%s, "
"%r, ..., set: %r, gender: %d) line — see work/<name>/register.cs.txt)"
% (fname, slot, slot, set_id, gender))
if require_registered:
rep.fail("config", detail, code="not_registered", path=fname)
else:
rep.warn("config", detail + " [warn: register runs before paste-in; pass "
"--require-registered to fail]",
code="not_registered", path=fname)
rep.metrics["config_slots_registered"] = registered
# ── main ─────────────────────────────────────────────────────────────────────
def default_game_root(cfg):
"""ariki-game repo root: env, then the sibling of this repo, then the config."""
env = os.environ.get("ARIKI_GAME_ROOT")
if env and os.path.isdir(env):
return os.path.abspath(env)
here = os.path.dirname(os.path.abspath(__file__)) # clothing/gates
sibling = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(here))),
"ariki-game") # <parent>/ariki-game
if os.path.isdir(sibling):
return os.path.abspath(sibling)
out_dir = ((cfg or {}).get("export") or {}).get("out_dir") or ""
marker = out_dir.replace("\\", "/").find("/" + OUTFITS_REL)
if marker > 0:
return os.path.abspath(out_dir.replace("\\", "/")[:marker])
return None
def main(argv=None):
ap = argparse.ArgumentParser(description="G8 catalog <-> asset lint")
ap.add_argument("--config", help="resolved config JSON (pipeline mode)")
ap.add_argument("--work", help="work dir (default: the config's directory)")
ap.add_argument("--all", action="store_true",
help="standalone: lint the whole catalog, no config")
ap.add_argument("--game-root", help="ariki-game repo root (default: env ARIKI_GAME_ROOT "
"or the sibling checkout)")
ap.add_argument("--catalog", help="OutfitCatalog.cs override")
ap.add_argument("--require-registered", action="store_true",
help="--config mode: unregistered export slots fail instead of warn")
ap.add_argument("--info-dir", action="append", default=[], metavar="SUBSTR",
help="downgrade unregistered-asset findings whose res:// path contains "
"SUBSTR to warnings (repeatable)")
ap.add_argument("--out", help="report path override")
args = ap.parse_args(argv)
if not args.all and not args.config:
ap.error("pass --config <resolved.json> or --all")
work = args.work
cfg = None
if args.config:
try:
with open(args.config, "r", encoding="utf-8") as fh:
cfg = json.load(fh)
except (OSError, ValueError) as exc:
return die("cannot read config %s: %s" % (args.config, exc), work, args.out, args.all)
if not work:
work = os.path.dirname(os.path.abspath(args.config))
game_root = args.game_root or default_game_root(cfg)
if not game_root or not os.path.isdir(game_root):
return die("ariki-game repo root not found (%r) — pass --game-root or set "
"ARIKI_GAME_ROOT" % game_root, work, args.out, args.all)
catalog_path = args.catalog or os.path.join(
game_root, "src", "Character", "OutfitCatalog.cs")
try:
with open(catalog_path, "r", encoding="utf-8-sig") as fh:
src = _strip_comments(fh.read())
except OSError as exc:
return die("cannot read %s: %s" % (catalog_path, exc), work, args.out, args.all)
try:
base_dirs, default_dir = parse_base_dirs(src)
except ValueError as exc:
return die("OutfitCatalog.cs parse failed: %s" % exc, work, args.out, args.all)
entries = parse_add_calls(src)
if not entries:
return die("parsed 0 Add() entries from %s — the file's shape changed, fix the "
"parser before trusting this gate" % catalog_path, work, args.out, args.all)
rep = Report()
rep.notes.append("catalog: %s" % catalog_path.replace("\\", "/"))
rep.notes.append("game root: %s" % game_root.replace("\\", "/"))
rep.metrics["base_dir_arms"] = sorted(base_dirs)
lint_catalog(rep, entries, base_dirs, default_dir, game_root, args.info_dir)
if cfg is not None:
lint_config(rep, cfg, entries, base_dirs, default_dir, game_root,
args.require_registered)
doc = rep.to_dict()
emit(doc, work, args.out, args.all)
if rep.failures:
sys.stderr.write("[g8] FAIL %d finding(s), %d warning(s)\n"
% (len(rep.failures), len(rep.warnings)))
return EXIT_FAIL
sys.stderr.write("[g8] PASS %d catalog entries, %d assets scanned, %d warning(s)\n"
% (len(entries), rep.metrics.get("assets_scanned", 0), len(rep.warnings)))
return EXIT_PASS
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 KiB

+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
reproduce_test1.py — rebuild the Test1 outfit end to end, in the ONE correct order.
python clothing/reproduce_test1.py # both garments
python clothing/reproduce_test1.py --only bottom
python clothing/reproduce_test1.py --dry-run
WHY THIS SCRIPT EXISTS
`garment.py` cannot express this build on its own. The skirt's weights are owned by
`skirt_garment_weights.py`, which must run **after `export` and before `import`** —
`garment_pipeline.py`'s own 2-segment `strand_weights` is overwritten by it, and
whichever runs LAST wins. Nothing enforces that, so re-running the pipeline the
obvious way (`--from census --to import`) silently ships the 2-segment blend and
reads as a rig regression. This script is the enforcement.
It reads the tuning from the config's `post_export` block, so the config stays the
single source of truth and nobody has to remember four env vars.
ORDER (per garment):
census -> prepare -> fit -> reduce -> skin -> export
[bottom only] skirt_garment_weights.py <-- the step that gets forgotten
register -> import
The texture is a build input too: bottom_test1.png is generated and must stay V-only
(see make_bottom_test1.py). Pass --texture to regenerate it first.
AFTERWARDS, to look at it (BODY_OVERRIDE is mandatory — the 4-segment skirt ring
exists only on that body copy; on the standard Lena the garment's skirt bones do not
exist and the hem collapses):
SESSION_ID=<you> MOCK_ONLY=1 SCENE=clothing_test_bed BED_GENDER=1 SKIRT_RIG_DEBUG=1 \\
BODY_OVERRIDE=res://assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig_4seg.glb \\
WAIT=1 bash tools/game.sh spawn
"""
import argparse
import json
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PY = sys.executable
GARMENT = os.path.join(ROOT, "clothing", "garment.py")
CONFIGS = {"bottom": "clothing/configs/bottomTest1.json",
"top": "clothing/configs/topTest1.json"}
# The bottom must be built first only for tidiness; they are independent.
ORDER = ["bottom", "top"]
def run(cmd, env=None, dry=False):
shown = " ".join(cmd)
if env:
shown = " ".join("%s=%s" % kv for kv in sorted(env.items())) + " " + shown
print("\n$ " + shown, flush=True)
if dry:
return 0
e = dict(os.environ)
if env:
e.update(env)
return subprocess.call(cmd, cwd=ROOT, env=e)
def die(msg):
print("[reproduce] FATAL: " + msg, file=sys.stderr)
raise SystemExit(1)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", choices=ORDER, help="build just one garment")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--texture", action="store_true",
help="regenerate bottom_test1.png first (must stay V-only)")
a = ap.parse_args()
names = [a.only] if a.only else ORDER
if a.texture:
gen = os.path.join(ROOT, "tools", "tailor", "textures", "make_bottom_test1.py")
if run([PY, gen], dry=a.dry_run) != 0:
die("texture generation failed")
for name in names:
cfg_rel = CONFIGS[name]
cfg = json.load(open(os.path.join(ROOT, cfg_rel)))
# 1. Blender half, up to and including export.
if run([PY, GARMENT, cfg_rel, "--from", "census", "--to", "export",
"--no-gate-stop"], dry=a.dry_run) != 0:
die("%s: pipeline census..export failed" % name)
# 2. THE STEP THAT GETS FORGOTTEN. Config-driven so the tuning cannot drift.
pe = cfg.get("post_export")
if pe:
tool = os.path.join(ROOT, pe["tool"].replace("/", os.sep))
exp = cfg["export"]
target = "%s/%s_%s_%s.gltf" % (
exp["out_dir"].rstrip("/"), exp["gender"], exp["set"],
list(cfg["parts"].values())[0]["slot"])
if not a.dry_run and not os.path.exists(target):
die("%s: expected export at %s" % (name, target))
if run([PY, tool, target, pe["body"]],
env=pe.get("env"), dry=a.dry_run) != 0:
die("%s: %s failed" % (name, pe["tool"]))
print("[reproduce] %s: post_export weights applied — check the log above for "
"'%s'" % (name, pe.get("expect", "no dead strands")))
# 3. Game half. Import LAST so Godot caches the corrected weights.
if run([PY, GARMENT, cfg_rel, "--only", "register,import",
"--no-gate-stop"], dry=a.dry_run) != 0:
die("%s: register/import failed" % name)
print("\n[reproduce] done. Spawn the bed with BODY_OVERRIDE=…_SkirtRig_4seg.glb "
"(see this file's docstring) — the standard Lena body has no skirt ring.")
if __name__ == "__main__":
main()
+454
View File
@@ -0,0 +1,454 @@
#!/usr/bin/env python3
"""
skirt_garment_weights.py — weight a skirt garment to the skirt_* spring ring.
python clothing/skirt_garment_weights.py [garment.gltf]
(default: assets/quaternius/outfits/kapahaka/Female_KapahakaSB_Legs.gltf)
Rewrites JOINTS_0/WEIGHTS_0 in place on a garment whose skin ALREADY carries the
SAME skirt ring as the body it is weighted against — both the strand count and the
segment count are read off that body, and a mismatch is a hard failure, not a
silent partial rebind (see skirt_rig_body.py). Geometry, materials,
UVs and the joint list are untouched — only the skin weights are recomputed, and
they are derived from vertex POSITION, so re-running is idempotent.
Why this exists: the first skirt-boned piupiu was weighted by an ad-hoc script
that wasn't kept, and it was badly lopsided — strand 1 got ZERO weight, strand 2
got 8x strand 6, and only 31% of the garment sat on the ring at all (measured
2026-07-31). The spring sim then moved a few strands and left the rest skinned to
the thigh, so the skirt hitched to one side instead of hanging. Weights are the
product here, not the sim tuning.
The three influences, per vertex:
WAISTBAND (top few cm) → pelvis alone. A waistband that swings looks broken;
it must ride the hip exactly as the body does.
PANEL → the two nearest strands by AZIMUTH, blended linearly
across the 45 degree gap. Bone names carry no
directional meaning; ring positions do, so azimuths
are read off the bones rather than assumed from the
NN index. Uniform coverage is guaranteed by
construction — no strand can come out dead.
THIGH FOLLOW → mixed in by PROXIMITY to the thigh segment, so cloth
resting on the thigh is carried up WITH the knee and
keeps covering it. This is the difference between a
skirt that drapes over a raised knee and one the
spring flicks aside to bare the thigh. It fades out
over the bottom of the panel: the hem must stay free
for the spring to swing it, or the skirt turns into
a pair of trousers painted on the legs.
Godot takes only 4 influences per vertex, so the assembled set is truncated to
the 4 heaviest and renormalised.
"""
import json
import math
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM_ROOT = os.path.dirname(HERE)
# This repo AUTHORS clothing; ariki-game only RECEIVES delivered outfits and bodies, so
# every asset path below points into the game checkout (a sibling of this one). Override
# with ARIKI_GAME_ROOT if the checkouts are not side by side.
GAME = os.environ.get("ARIKI_GAME_ROOT") or os.path.join(
os.path.dirname(ANIM_ROOT), "ariki-game")
DEFAULT = os.path.join(
GAME, "assets/quaternius/outfits/kapahaka/Female_KapahakaSB_Legs.gltf")
SRC = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
BODY = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig.glb")
# Strand + segment counts are READ OFF THE RIG (see body_ring in main), never constants.
# Everything ABOVE the ring top is pelvis by necessity — no skirt bone reaches it.
# On the piupiu that is already ~870 verts (the garment runs to the true waist at
# y=1.113 while the ring starts at the hip, y=0.952, where a piupiu is actually
# tied), so this only adds a thin band BELOW the ring top and must stay small or
# it freezes the top third of the panel.
BAND = 0.02
# Thigh follow is the one influence that can LIFT cloth: collision only ever pushes, but a
# thigh-weighted vertex is carried wherever the thigh goes. At 0.55 the swinging leg hauled
# the hem up with it — the skirt rode up on the upswing while the standing side hung long
# (back view, walk 2026-07-31). Keep just enough to drape cloth over a raised knee, and let
# collision do the rest now that it actually runs.
# ENV-OVERRIDABLE (defaults unchanged — an unset env behaves exactly as before).
# Added because these were tuned against the SHORT continuous piupiu, and a longer
# strand skirt needs different numbers: bottomTest1 hangs to y 0.451, so HEM_FREE 0.55
# puts its whole KNEE region (y~0.517) inside the no-follow band and the knee comes
# straight through the cloth. Per-garment tuning without touching the shared defaults.
def _envf(key, default):
import os
try:
return float(os.environ[key])
except (KeyError, ValueError):
return default
FOLLOW_MAX = _envf("SKIRT_FOLLOW_MAX", 0.20)
CONTACT_R = _envf("SKIRT_CONTACT_R", 0.13) # at/inside this distance from the thigh axis = full contact
FALLOFF = _envf("SKIRT_FALLOFF", 0.10) # follow decays to 0 over this much extra distance
HEM_FREE = _envf("SKIRT_HEM_FREE", 0.55) # bottom fraction of panel with NO thigh follow
MAX_INFLUENCES = 4
COMP_FMT = {5120: "b", 5121: "B", 5122: "h", 5123: "H", 5125: "I", 5126: "f"}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
def log(msg):
print(f"[skirt_weights] {msg}", flush=True)
def smoothstep(x):
x = max(0.0, min(1.0, x))
return x * x * (3.0 - 2.0 * x)
def mat4_inverse_translation(m):
"""Translation of inverse(m), where m is a glTF 4x4 — COLUMN-major, so the
flat index for row r / col c is c*4+r (reading it row-major silently yields
the transpose, which lands every bone at ~the origin). Gauss-Jordan rather
than a rigid-transform shortcut because IBMs may carry scale.
Returns (x, y, z) — the bone head in the space POSITION lives in."""
a = [[m[c * 4 + r] for c in range(4)] + [1.0 if r == i else 0.0 for i in range(4)]
for r in range(4)]
for col in range(4):
piv = max(range(col, 4), key=lambda r: abs(a[r][col]))
if abs(a[piv][col]) < 1e-12:
raise ValueError("singular inverse-bind matrix")
a[col], a[piv] = a[piv], a[col]
d = a[col][col]
a[col] = [v / d for v in a[col]]
for r in range(4):
if r != col and a[r][col] != 0.0:
f = a[r][col]
a[r] = [v - f * w for v, w in zip(a[r], a[col])]
return (a[0][7], a[1][7], a[2][7]) # inverse's 4th column = translation
def point_seg_distance(p, a, b):
"""Distance from p to segment ab (thigh head→knee)."""
ab = tuple(b[i] - a[i] for i in range(3))
ap = tuple(p[i] - a[i] for i in range(3))
ll = sum(v * v for v in ab)
t = 0.0 if ll < 1e-12 else max(0.0, min(1.0, sum(ap[i] * ab[i] for i in range(3)) / ll))
closest = tuple(a[i] + ab[i] * t for i in range(3))
return math.sqrt(sum((p[i] - closest[i]) ** 2 for i in range(3)))
class Gltf:
def __init__(self, path):
self.path = path
if path.lower().endswith(".glb"):
# GLB: 12-byte header, then JSON chunk, then the BIN chunk.
with open(path, "rb") as f:
blob = f.read()
_, _, _ = struct.unpack_from("<III", blob, 0)
clen, _ = struct.unpack_from("<II", blob, 12)
self.d = json.loads(blob[20:20 + clen])
blen, _ = struct.unpack_from("<II", blob, 20 + clen)
start = 20 + clen + 8
self.buf = bytearray(blob[start:start + blen])
self.bin_path = None # GLB is read-only here (body donor)
return
with open(path) as f:
self.d = json.load(f)
self.bin_path = os.path.join(os.path.dirname(path), self.d["buffers"][0]["uri"])
with open(self.bin_path, "rb") as f:
self.buf = bytearray(f.read())
def read(self, idx):
a = self.d["accessors"][idx]
bv = self.d["bufferViews"][a["bufferView"]]
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
fmt = COMP_FMT[a["componentType"]]
n = NCOMP[a["type"]]
size = struct.calcsize("<" + fmt * n)
stride = bv.get("byteStride") or size
return [struct.unpack_from("<" + fmt * n, self.buf, off + i * stride)
for i in range(a["count"])]
def overwrite(self, idx, rows):
"""Rewrite an accessor's bytes IN PLACE, keeping its declared layout.
Appending fresh bufferViews instead would orphan the old ones and grow
the .bin on every run, so the tool would stop being safely re-runnable.
Both skin attributes here own a tightly-packed dedicated bufferView, so
an in-place write is exact — verified against the layout, not assumed."""
a = self.d["accessors"][idx]
bv = self.d["bufferViews"][a["bufferView"]]
fmt = COMP_FMT[a["componentType"]]
n = NCOMP[a["type"]]
size = struct.calcsize("<" + fmt * n)
if len(rows) != a["count"]:
raise SystemExit(f"[skirt_weights] FATAL: accessor {idx} holds "
f"{a['count']} rows, got {len(rows)}")
if bv.get("byteStride") not in (None, size) or bv["byteLength"] != size * len(rows):
raise SystemExit(f"[skirt_weights] FATAL: accessor {idx} shares or "
"interleaves its bufferView — in-place write unsafe")
if a["componentType"] == 5121 and any(v > 255 for row in rows for v in row):
raise SystemExit("[skirt_weights] FATAL: joint index over 255 will not fit "
"the garment's unsigned-byte JOINTS_0")
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
for i, row in enumerate(rows):
struct.pack_into("<" + fmt * n, self.buf, off + i * size, *row)
def save(self):
self.d["buffers"][0]["byteLength"] = len(self.buf)
with open(self.bin_path, "wb") as f:
f.write(self.buf)
with open(self.path, "w") as f:
json.dump(self.d, f, separators=(",", ":"))
def main():
g = Gltf(SRC)
skin = g.d["skins"][0]
joints = skin["joints"]
names = [g.d["nodes"][j].get("name") for j in joints]
slot = {n: i for i, n in enumerate(names)}
for required in ("pelvis", "thigh_l", "thigh_r", "calf_l", "calf_r", "skirt_00"):
if required not in slot:
raise SystemExit(f"[skirt_weights] FATAL: no '{required}' joint in {SRC} "
"— is this a *_SkirtRig-skinned garment?")
# ── REBIND the skirt bones from the body ────────────────────────────────────
# The garment's inverseBindMatrices describe the ring it was armatured against.
# Re-proportioning the ring in make_skirt_rig_body.py moves those bones, and a
# garment still bound to the OLD ring is skinned to a skeleton that no longer
# exists — it deforms toward stale rest positions. Names all still resolve, so no
# name gate catches it; only the positions differ. Copy the body's skirt-bone
# binds over so the garment is bound to the ring it will actually be worn on.
body = Gltf(BODY)
body_skin = body.d["skins"][0]
body_names = [body.d["nodes"][j].get("name") for j in body_skin["joints"]]
body_ibms = body.read(body_skin["inverseBindMatrices"])
body_ibm = {n: m for n, m in zip(body_names, body_ibms) if n}
ibms = g.read(skin["inverseBindMatrices"])
# ── The RIG defines the ring; the garment must carry ALL of it ──────────────
# STRAND count comes from the body, exactly like segment count. Hardcoding
# either lets a garment silently keep an old ring. Measured failure: a 32-chain
# body against an 8-chain garment rebound only the 8 names that happened to
# match, then weighted a ring the mesh was never fitted to — 46:1 strand
# imbalance, garbage on the character, and NO gate fired, because the old gate
# compared the garment's bones against the garment's own joint list (2026-07-31).
body_ring = {}
for nm in body_ibm:
if not nm.startswith("skirt_"):
continue
parts = nm.split("_")
if len(parts) < 2 or not parts[1].isdigit():
continue
body_ring.setdefault(int(parts[1]), set()).add(nm)
if not body_ring:
raise SystemExit("[skirt_weights] FATAL: no skirt_* bones in "
f"{os.path.basename(BODY)} — is it a *_SkirtRig body?")
absent = sorted(b for bones in body_ring.values() for b in bones if b not in slot)
if absent:
raise SystemExit(
f"[skirt_weights] FATAL: {os.path.basename(BODY)} has "
f"{len(body_ring)} strands / {sum(len(v) for v in body_ring.values())} skirt "
f"bones, but this garment's skin cannot reference {len(absent)} of them "
f"(e.g. {absent[:4]}).\nA garment can only be weighted to a ring its SKIN "
"carries. Re-export the garment against this body first (the tailor lane's "
"pipeline picks up every armature bone automatically), then re-run this.")
stale = sorted(n for n in names
if n and n.startswith("skirt_") and n not in body_ibm)
if stale:
raise SystemExit(
f"[skirt_weights] FATAL: this garment's skin carries {len(stale)} skirt bones "
f"that do not exist on {os.path.basename(BODY)} (e.g. {stale[:4]}) — it was "
"fitted to a DIFFERENT ring. Re-export against this body, or point --body at "
"the one it was built for.")
rebound, moved = 0, 0.0
for i, nm in enumerate(names):
if nm is None or not nm.startswith("skirt_") or nm not in body_ibm:
continue
before = mat4_inverse_translation(ibms[i])
after = mat4_inverse_translation(body_ibm[nm])
moved = max(moved, math.dist(before, after))
ibms[i] = body_ibm[nm]
rebound += 1
log(f"rebound {rebound} skirt joints from {os.path.basename(BODY)} "
f"({len(body_ring)} strands) — largest bind move {moved * 100:.1f} cm")
# NOTE: the IBM write is deliberately AFTER the gates above. It used to run first,
# so a rejected garment still had its bind matrices rewritten on the way out.
g.overwrite(skin["inverseBindMatrices"], ibms)
origin = {}
for i, nm in enumerate(names):
if nm is None:
continue
origin[nm] = mat4_inverse_translation(ibms[i])
# glTF is Y-up: vertical is Y, the ring azimuth lives in the XZ plane.
pelvis = origin["pelvis"]
strands = []
for k in sorted(body_ring):
# Segment count comes from the rig, not a constant here — make_skirt_rig_body.py
# owns N, and reading it back keeps the two tools from silently disagreeing.
segs = [f"skirt_{k:02d}"]
while f"{segs[0]}_{len(segs):02d}" in origin:
segs.append(f"{segs[0]}_{len(segs):02d}")
top = origin[segs[0]]
# Segment HEAD heights are the real boundaries. Segment lengths are NOT uniform
# (the rig deliberately uses a short upper segment), so nothing here may assume
# span/N. The final segment simply owns everything below its head — the hem is a
# bone TAIL, which glTF does not store, and deriving it is unnecessary.
strands.append({
"k": k, "segs": segs, "top_y": top[1],
"heads": [origin[s][1] for s in segs],
"az": math.atan2(top[2] - pelvis[2], top[0] - pelvis[0]),
})
strands.sort(key=lambda s: s["az"])
n_seg = len(strands[0]["segs"])
log(f"{n_seg} segments per strand (read off the rig), "
f"boundaries y " + ", ".join(f"{y:.3f}" for y in strands[0]["heads"]))
y_top = max(s["top_y"] for s in strands)
ring_drop = y_top - min(s["heads"][-1] for s in strands)
ring_radius = max(math.dist((s_top[0], s_top[2]), (pelvis[0], pelvis[2]))
for s_top in (origin[s["segs"][0]] for s in strands))
log(f"ring: {len(strands)} strands, top y {y_top:.3f}, radius {ring_radius:.3f}, "
f"last boundary {y_top - ring_drop:.3f}")
if ring_drop < 0.02 or ring_radius < 0.03:
raise SystemExit("[skirt_weights] FATAL: the ring has collapsed to a point, so "
"the bone origins are wrong — check the inverseBindMatrices "
"read (glTF matrices are COLUMN-major) before trusting weights")
for s in strands:
missing = [b for b in s["segs"] if b not in slot]
if missing:
raise SystemExit(f"[skirt_weights] FATAL: the rig has bones this garment's "
f"skin cannot reference: {missing}. A garment must be "
"re-armatured onto the current SkirtRig skeleton before it "
"can be weighted to it.")
legs = [("thigh_l", origin["thigh_l"], origin["calf_l"]),
("thigh_r", origin["thigh_r"], origin["calf_r"])]
prim = g.d["meshes"][0]["primitives"][0]
pos = g.read(prim["attributes"]["POSITION"])
# The vertical ramp (segment pick, thigh-follow hem fade) is measured against the
# CLOTH, not the ring: the last bone's tail is the hem and glTF stores no tails, and
# measuring to the last bone HEAD instead made the ramp collapse into the top 11 cm
# the moment the ring stopped being an even 50/50 split.
span = y_top - min(p[1] for p in pos)
log(f"{os.path.basename(SRC)}: {len(pos)} verts, "
f"cloth hangs {span:.3f} below the ring top")
out_j, out_w = [], []
stats = {"waistband": 0, "follow_sum": 0.0}
per_strand = {k: 0.0 for k in body_ring}
thigh_total = 0.0
for v in pos:
w = {}
if v[1] > y_top - BAND:
w[slot["pelvis"]] = 1.0
stats["waistband"] += 1
else:
# ── azimuth → the two neighbouring strands, linear across the gap ──
az = math.atan2(v[2] - pelvis[2], v[0] - pelvis[0])
lo = None
for i, s in enumerate(strands):
nxt = strands[(i + 1) % len(strands)]
d = (nxt["az"] - s["az"]) % (2 * math.pi)
off = (az - s["az"]) % (2 * math.pi)
if off <= d:
lo, hi, frac = s, nxt, (off / d if d > 1e-9 else 0.0)
break
if lo is None: # numerically outside every gap
lo = hi = min(strands, key=lambda s: abs((az - s["az"] + math.pi)
% (2 * math.pi) - math.pi))
frac = 0.0
share = {id(lo): (lo, 1.0 - frac)}
if id(hi) in share:
share[id(hi)] = (hi, share[id(hi)][1] + frac)
else:
share[id(hi)] = (hi, frac)
# ── height → which segment(s) of the strand ──
# Blend across the neighbouring pair rather than snapping, or the seam
# between segments creases when the spring bends the strand.
t = max(0.0, min(1.0, (y_top - v[1]) / span)) if span > 1e-9 else 0.0
# ── thigh follow by proximity, faded out across the free hem ──
near, dist = None, 1e9
for bone, a, b in legs:
dd = point_seg_distance(v, a, b)
if dd < dist:
near, dist = bone, dd
contact = 1.0 - smoothstep((dist - CONTACT_R) / FALLOFF)
hem_fade = 1.0 - smoothstep((t - (1.0 - HEM_FREE)) / HEM_FREE)
follow = FOLLOW_MAX * contact * hem_fade
stats["follow_sum"] += follow
if follow > 0.0:
w[slot[near]] = w.get(slot[near], 0.0) + follow
for strand, sh in share.values():
ring = (1.0 - follow) * sh
if ring <= 0.0:
continue
segs, heads = strand["segs"], strand["heads"]
# Continuous position in SEGMENT-INDEX units, from the real boundaries —
# segment lengths are non-uniform, so t * len(segs) would misplace it.
# Segment i spans [heads[i+1], heads[i]]; below the last head everything
# belongs to the last bone.
f = float(len(segs) - 1)
for i in range(len(heads) - 1):
if v[1] > heads[i + 1]:
h = heads[i] - heads[i + 1]
f = i + (0.0 if h < 1e-9
else max(0.0, min(1.0, (heads[i] - v[1]) / h)))
break
i0 = min(len(segs) - 1, int(f))
i1 = min(len(segs) - 1, i0 + 1)
frac_seg = f - i0
for si, part in ((i0, 1.0 - frac_seg), (i1, frac_seg)):
if part <= 0.0:
continue
s = slot[segs[si]]
w[s] = w.get(s, 0.0) + ring * part
per_strand[strand["k"]] += ring
thigh_total += follow
# Godot reads 4 influences — keep the heaviest and renormalise.
top4 = sorted(w.items(), key=lambda kv: -kv[1])[:MAX_INFLUENCES]
total = sum(x for _, x in top4) or 1.0
top4 = [(j, x / total) for j, x in top4]
while len(top4) < MAX_INFLUENCES:
top4.append((0, 0.0))
out_j.append(tuple(j for j, _ in top4))
out_w.append(tuple(x for _, x in top4))
# ── gates FIRST: never leave a broken garment on disk ──
n = len(pos)
log(f"waistband (pelvis only): {stats['waistband']} verts")
log(f"mean thigh follow on panel verts: "
f"{stats['follow_sum'] / max(1, n - stats['waistband']):.3f}")
log("per-strand ring weight (must be non-zero everywhere):")
for k in sorted(per_strand):
peak = max(per_strand.values())
bar = "#" * int(per_strand[k] / peak * 40) if peak else ""
log(f" strand {k}: {per_strand[k]:7.1f} {bar}")
dead = [k for k in sorted(per_strand) if per_strand[k] <= 0.0]
if dead:
raise SystemExit(f"[skirt_weights] FATAL: strands {dead} got no weight — "
"the ring is not covered, the spring sim will tear the skirt")
# Per-strand totals track VERTEX DENSITY per azimuth, not correctness — a
# wrapped piupiu really does carry more geometry at the front overlap. The
# gate that matters is the one above: every strand must be driving something,
# or the spring sim pulls a panel the mesh can't follow and the skirt tears.
log(f"heaviest/lightest strand ratio {max(per_strand.values()) / max(1e-9, min(per_strand.values())):.2f} "
"(density, not a defect — the dead-strand gate above is the real check)")
g.overwrite(prim["attributes"]["JOINTS_0"], out_j)
g.overwrite(prim["attributes"]["WEIGHTS_0"], out_w)
g.save()
log(f"WROTE {SRC} + {os.path.basename(g.bin_path)} ({len(g.buf)} bytes)")
main()
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
make_skirt_rig_body.py — derive a skirt-boned COPY of a Quaternius-skeleton body.
"<BLENDER>" --background --python clothing/skirt_rig_body.py -- \
[src.glb] [dst.glb]
Takes Ariki_Female_QuatSkin.glb (default) and writes
Ariki_Female_QuatSkin_SkirtRig.glb: the identical body — every mesh, weight and
the full 65-bone skeleton untouched — plus a ring of 8 MULTI-SEGMENT deform
strands parented to `pelvis` (skirt_NN → skirt_NN_01 → …, waist perimeter down
to knee-hem), hanging straight down in rest.
Segment COUNT is load-bearing. The spring sim puts a collision particle at each
bone END and treats the chain root as a fixed anchor, so the panel can only be
pushed below its first joint. One segment collides at the hem alone and a lifted
knee strikes mid-panel unopposed; two put the first joint at mid-panel and the
thigh still clipped straight through the panel ABOVE it, at hip height, where no
particle existed (measured 2026-07-31). Over-fat capsules are not the answer —
they just rotate whole panels up and bare the thigh under the hem.
Why a copy: the shared skeleton is the hub the whole animation lane targets;
skirt bones stay opt-in (BODY_OVERRIDE / BodyOverridePath) until proven.
Nothing animates these bones — SkirtSpringRig (C#) attaches a
SpringBoneSimulator3D at runtime and lets thigh capsules push them around.
Garments weight to them via garment_pipeline.py's "skirt" weights mode, which
maps each vertex to the nearest two bones by azimuth — bone NAMES carry no
directional meaning, positions do.
"""
import math
import os
import sys
import bpy
ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
HERE = os.path.dirname(os.path.abspath(__file__))
ANIM_ROOT = os.path.dirname(HERE)
# This repo AUTHORS clothing; ariki-game only RECEIVES delivered outfits and bodies, so
# every asset path below points into the game checkout (a sibling of this one). Override
# with ARIKI_GAME_ROOT if the checkouts are not side by side.
GAME = os.environ.get("ARIKI_GAME_ROOT") or os.path.join(
os.path.dirname(ANIM_ROOT), "ariki-game")
SRC = ARGS[0] if len(ARGS) > 0 else os.path.join(
GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb")
DST = ARGS[1] if len(ARGS) > 1 else os.path.join(
GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin_SkirtRig.glb")
N_BONES = 8
# Override without editing this file: SKIRT_STRANDS=32
# One chain per VISIBLE flax strand is the point of raising this. Measured 2026-07-31 on
# bottom_test1: the mesh has 32 strands (all 32 azimuth wedges occupied) but only 8 chains,
# so four strands moved as one stiff bundle. A discrete-strand garment also has no reason to
# blend a vertex across neighbouring chains, so one chain per strand both frees each strand
# AND drops each vertex from 4 influences to 2.
#
# CEILING: a garment's JOINTS_0 is UNSIGNED_BYTE, so its skin holds at most 256 joints. With
# 65 body bones that caps N_BONES * N_SEGMENTS at ~190 — 32x5 = 160 (225 total) fits, 32x8
# = 192 (257 total) does NOT. skirt_garment_weights.py gates on this rather than letting the
# joint index silently wrap.
if os.environ.get("SKIRT_STRANDS"):
N_BONES = int(os.environ["SKIRT_STRANDS"])
# Segments per strand. The spring sim puts a collision particle at each bone END and
# treats the chain ROOT as a fixed anchor, so N segments can only push the panel below
# the first joint: with 2 the highest particle was mid-panel (y~0.73) while the thigh
# clipped through at y 0.85-0.95, above every particle, uncorrectable (front view
# 2026-07-31). 4 puts the first joint at ~0.84 and gives the upper panel something to
# rotate. Raise it, don't lower it.
# NON-UNIFORM on purpose. Fractions of the waist→hem span, top-down. A short upper
# segment puts a particle high (0.25 → z 0.838, inside the hip-height band the thigh was
# clipping through) while the long lower segment stays the swinging panel. Uniform
# quarters would work as well but need 4 bones per strand, and a garment's skin has to
# carry every bone it references — adding segments means re-armaturing every skirt, while
# re-proportioning two costs nothing. Must sum to 1.0.
SEGMENT_FRACS = (0.25, 0.75)
# Override without editing this file: SKIRT_SEGMENT_FRACS=0.15,0.2,0.3,0.35
# Exists so a deeper ring can be built to a SIDE path and handed to the tailor lane while
# the live body keeps working — the garment's skin must carry every bone it references, so
# a body and its garments have to change segment count together.
if os.environ.get("SKIRT_SEGMENT_FRACS"):
SEGMENT_FRACS = tuple(float(x) for x in
os.environ["SKIRT_SEGMENT_FRACS"].replace(" ", "").split(","))
N_SEGMENTS = len(SEGMENT_FRACS)
# Ring radius is MEASURED per strand off the body silhouette, not scaled off the thigh.
# The old `thigh|x| * 1.2` put the whole ring at r=0.120 while the body runs 0.18-0.22 and
# the cloth sits at 0.19-0.24 through the same height band — every skirt bone was 6-10 cm
# INSIDE the hips. Collision then had to shove cloth outward from within the body, which
# flares the panel where it reaches and leaves cloth buried in the thigh where it does not
# (measured 2026-07-31). Hips are elliptical, so one radius cannot fit: each strand takes
# the widest body radius in its own azimuth wedge, plus this clearance.
RING_CLEARANCE = 0.015
TOP_LIFT = 0.02 # ring sits this far above the pelvis head (waistband pivot)
HEM_DROP = 0.02 # tails end this far below the knee (calf head)
def log(msg):
print(f"[skirt_rig] {msg}", flush=True)
def measure_ring_radii(rig, meshes, pelvis, z_lo, z_hi):
"""Widest body radius per strand azimuth, within the skirt's height band.
Each body vertex in the band is bucketed to the strand azimuth it is nearest,
and each strand takes the largest radius in its bucket. Vertices are pulled
into ARMATURE space (edit_bones are authored there) — mesh object space is not
the same thing once the rig has a transform. The band excludes the torso and
arms by construction, so this measures hips/thighs only."""
inv = rig.matrix_world.inverted()
step = 2 * math.pi / N_BONES
widest = [0.0] * N_BONES
counted = 0
# Only meshes actually bound to this rig describe the body. Ariki_Female_QuatSkin.glb
# also carries a stray unparented "Icosphere" (42 verts, radius 1.0) that sits right
# across the skirt band and, measured, reported hip radii of 0.5-0.9 m.
skinned = [o for o in meshes if o.parent == rig]
skipped = [o.name for o in meshes if o.parent != rig]
if skipped:
log(f"NOT measuring unrigged mesh(es): {', '.join(skipped)}")
if not skinned:
raise SystemExit("[skirt_rig] FATAL: no mesh is parented to the armature")
for o in skinned:
mw = o.matrix_world
for v in o.data.vertices:
p = inv @ (mw @ v.co)
if not (z_lo <= p.z <= z_hi):
continue
dx, dy = p.x - pelvis.x, p.y - pelvis.y
k = int(round(math.atan2(dy, dx) / step)) % N_BONES
r = math.hypot(dx, dy)
if r > widest[k]:
widest[k] = r
counted += 1
if counted < 100 or min(widest) <= 0.0:
raise SystemExit(f"[skirt_rig] FATAL: only {counted} body verts in the skirt band "
f"z {z_lo:.3f}-{z_hi:.3f} (widest={widest}) — cannot measure the "
"ring, refusing to fall back to a magic scale factor")
log(f"measured {counted} body verts in the skirt band")
return [w + RING_CLEARANCE for w in widest]
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
rig = next(o for o in bpy.data.objects if o.type == "ARMATURE")
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
log(f"imported {os.path.basename(SRC)}: {len(rig.data.bones)} bones, "
f"{len(meshes)} meshes ({sum(len(m.data.vertices) for m in meshes)} verts)")
for required in ("pelvis", "thigh_l", "calf_l"):
if required not in rig.data.bones:
raise SystemExit(f"[skirt_rig] FATAL: no '{required}' bone in {SRC}")
if "skirt_00" in rig.data.bones:
raise SystemExit(f"[skirt_rig] FATAL: {SRC} already has skirt bones")
# Landmarks in armature-local space (same space edit_bones are authored in).
pelvis = rig.data.bones["pelvis"].head_local
thigh_x = abs(rig.data.bones["thigh_l"].head_local.x)
knee_z = rig.data.bones["calf_l"].head_local.z
z_top = pelvis.z + TOP_LIFT
z_hem = knee_z - HEM_DROP
log(f"pelvis z={pelvis.z:.3f} thigh |x|={thigh_x:.3f} knee z={knee_z:.3f}")
radii = measure_ring_radii(rig, meshes, pelvis, z_hem, z_top)
log(f"ring z {z_top:.3f} -> {z_hem:.3f} (bone length {z_top - z_hem:.3f}); "
f"measured radii " + ", ".join(f"{r:.3f}" for r in radii))
if abs(sum(SEGMENT_FRACS) - 1.0) > 1e-6:
raise SystemExit(f"[skirt_rig] FATAL: SEGMENT_FRACS must sum to 1.0, "
f"got {sum(SEGMENT_FRACS)}")
span = z_top - z_hem
# Cumulative z of each segment head, plus the hem as the final entry.
knots = [z_top]
for f in SEGMENT_FRACS:
knots.append(knots[-1] - span * f)
bpy.context.view_layer.objects.active = rig
bpy.ops.object.mode_set(mode="EDIT")
eb = rig.data.edit_bones
pelvis_eb = eb["pelvis"]
for k in range(N_BONES):
az = 2 * math.pi * k / N_BONES
x = pelvis.x + radii[k] * math.cos(az)
y = pelvis.y + radii[k] * math.sin(az)
# Segment 0 is the strand ROOT (skirt_NN, child of pelvis); segments 1.. are
# skirt_NN_01, _02, ... The runtime finds roots by PARENTAGE, not by name — the
# strand at ring index 1 is itself "skirt_01", so a name-suffix test cannot tell
# a root from a tip (that bug dropped one panel from the sim entirely).
prev = None
for s in range(N_SEGMENTS):
name = f"skirt_{k:02d}" if s == 0 else f"skirt_{k:02d}_{s:02d}"
b = eb.new(name)
b.head = (x, y, knots[s])
b.tail = (x, y, knots[s + 1])
b.parent = pelvis_eb if s == 0 else prev
b.use_connect = s != 0
b.use_deform = True
prev = b
bpy.ops.object.mode_set(mode="OBJECT")
log(f"added {N_BONES} strands x {N_SEGMENTS} segments under pelvis; "
f"fracs {SEGMENT_FRACS}; collision particles at z "
+ ", ".join(f"{z:.3f}" for z in knots[1:]))
# Whole scene out — every mesh rides along, weights and skeleton untouched.
for o in bpy.data.objects:
o.select_set(True)
bpy.ops.export_scene.gltf(filepath=DST, export_format="GLB",
export_skins=True, export_animations=True,
export_yup=True)
log(f"EXPORTED {DST} ({os.path.getsize(DST)} bytes)")
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 925 KiB