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,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())
|
||||
Reference in New Issue
Block a user