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:
@@ -0,0 +1 @@
|
||||
work/
|
||||
@@ -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.
|
||||
@@ -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 15–20mm 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.
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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 |
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 |
Reference in New Issue
Block a user