`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.
Parity, from baselines taken before anything moved:
- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
(the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
summary
run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.
The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.
Archived, with reasons in tooling/archive/README.md:
- setup_clothing_metadata.py wrote coverage data for five garments that no
longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db
segment_reference_distribution.md moved to docs/assets/visual/.
Behaviour changes:
- The QA analyzer exited 0 whatever it found, though its own README says
clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
rewrote an unchanged file: it left them as orphans and then found them
again. Only nodes still linked into the graph count now, and a first pass
writes the same bytes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
3.5 KiB
Python
92 lines
3.5 KiB
Python
"""Drive the chromakey garment-clipping QA harness (T-1089) — capture, then analyze.
|
|
|
|
Step 1 launches Godot on the client project with the capture scene, handing it
|
|
the config through GARMENT_QA_CONFIG. Step 2 runs the pixel analyzer over the
|
|
PNGs the scene wrote. See GARMENT_QA.md beside this file for what the two
|
|
passes measure.
|
|
|
|
Formerly tooling/garment-qa/run-garment-qa, a bash script (T-1290). Rewritten
|
|
per D-263's guarded-exec rule: the DECISIONS — which config, which Godot,
|
|
whether a virtual display is needed — are `capture_plan()`, pure and testable
|
|
without launching anything; only the launch goes through core/process. The
|
|
bash exit codes are kept: 2 for a missing config, 3 for no Godot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from tooling.core import config, console, process
|
|
from tooling.core.errors import ReachError
|
|
|
|
CONFIG_DIR = Path(__file__).resolve().parent / "qa_configs"
|
|
DEFAULT_CONFIG = "peasant"
|
|
SCENE = "res://tools/garment_qa/chromakey_scene.tscn"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CapturePlan:
|
|
config: Path
|
|
argv: list[str]
|
|
env_config: str # the value for GARMENT_QA_CONFIG
|
|
|
|
|
|
def resolve_config(name_or_path: str | None) -> Path:
|
|
"""A config name (`peasant`) resolves inside qa_configs/; anything else is a path."""
|
|
given = name_or_path or DEFAULT_CONFIG
|
|
if "/" not in given and not given.endswith(".json"):
|
|
path = CONFIG_DIR / f"{given}.json"
|
|
else:
|
|
path = Path(given)
|
|
if not path.is_file():
|
|
known = ", ".join(sorted(p.stem for p in CONFIG_DIR.glob("*.json")))
|
|
raise ReachError(
|
|
f"garment-qa config not found: {path}",
|
|
fix=f"pass a config path, or one of: {known}",
|
|
exit_code=2,
|
|
)
|
|
return path
|
|
|
|
|
|
def resolve_godot(env: dict[str, str], home: Path, which=shutil.which) -> str:
|
|
"""$GODOT, else ~/bin/godot4, else godot4/godot on PATH — the bash script's order."""
|
|
candidate = env.get("GODOT") or str(home / "bin" / "godot4")
|
|
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
return candidate
|
|
found = which("godot4") or which("godot")
|
|
if found:
|
|
return found
|
|
raise ReachError(
|
|
"godot binary not found",
|
|
fix="set GODOT=/path/to/godot4, or install it: make setup-godot",
|
|
exit_code=3,
|
|
)
|
|
|
|
|
|
def capture_plan(config_arg: str | None, env: dict[str, str], home: Path, which=shutil.which) -> CapturePlan:
|
|
"""What the capture step would run — decided, not performed."""
|
|
cfg = resolve_config(config_arg)
|
|
godot = resolve_godot(env, home, which)
|
|
argv = [godot, "--path", str(config.repo_root() / "client"), "--rendering-driver", "opengl3", SCENE]
|
|
# The capture needs a display. Headless (no $DISPLAY) runs under a virtual one.
|
|
if not env.get("DISPLAY"):
|
|
argv = ["xvfb-run", "-a", *argv]
|
|
return CapturePlan(config=cfg, argv=argv, env_config=str(cfg.resolve()))
|
|
|
|
|
|
def capture(plan: CapturePlan) -> None:
|
|
"""Perform the capture step. Godot's own output streams straight through."""
|
|
console.event(f"config = {plan.config}")
|
|
console.event(f"godot = {plan.argv[2] if plan.argv[0] == 'xvfb-run' else plan.argv[0]}")
|
|
env = {**os.environ, "GARMENT_QA_CONFIG": plan.env_config}
|
|
process.run(
|
|
plan.argv,
|
|
env=env,
|
|
capture=False,
|
|
missing_fix="install xvfb-run (it provides the virtual display a headless capture needs), "
|
|
"or run from a session with $DISPLAY set",
|
|
)
|