feat: clothing lane, character sources, and DCC bridges

Bulk import of the working lanes that were living untracked on the PC.

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

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

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