Files
settled-reach/tooling/domains/character/router.py
T
jpmschweitzerandClaude Opus 5.5 26cc8de7f3 refactor(tooling): T-1290 — the character domain, and six payloads the map misfiled
`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>
2026-09-23 19:55:46 +02:00

111 lines
4.3 KiB
Python

"""Transport for the `character` domain — args in, delegate, format out."""
from __future__ import annotations
import json
import os
from pathlib import Path
import typer
from tooling.core import cli, console
from tooling.core.command import command
from tooling.core.errors import ReachError
app = cli.domain("character", "Bodies, garments, GLBs — logos, the GLB strip, garment QA.")
@app.callback()
def _domain() -> None:
"""Keeps `character` a group (Typer collapses a single-command app)."""
@app.command("logo")
@command
def logo(
text: str = typer.Argument(..., help="The wordmark, e.g. thrds (brands are lowercase)."),
out_png: Path = typer.Argument(..., help="Where to write the PNG."),
size: int = typer.Option(256, "--size", help="Square edge in px."),
) -> None:
"""Render a flat white-on-transparent brand wordmark decal (placeholder supply)."""
from tooling.domains.character import logo as service
result = service.make_logo(text, str(out_png), size)
console.out(json.dumps(result))
@app.command("strip-glb")
@command
def strip_glb(
input: Path = typer.Argument(None, help="A .glb to strip."),
output: Path = typer.Argument(None, help="Output .glb (default: overwrite the input)."),
dir: Path = typer.Option(None, "--dir", help="Strip every .glb under a directory, in place."),
) -> None:
"""Remove utility nodes (Icosphere, WGT-, DEF-, ORG-, empty meshes) from GLB scene graphs."""
from tooling.domains.character import glb_strip
if dir:
result = glb_strip.process_directory(str(dir))
elif input:
result = glb_strip.process_file(str(input), str(output or input))
else:
raise ReachError("nothing to strip", fix="pass a .glb, or --dir <directory>")
console.out(json.dumps(result))
console.verdict(f"strip-glb: {result['stripped']} utility node(s) removed")
@app.command("qa")
@command
def qa(
config: str = typer.Argument(None, help="Config name in qa_configs/ (e.g. peasant) or a path."),
min_pixels: int = typer.Option(8, "--min-pixels", help="Smallest clip blob that fails a capture."),
) -> None:
"""Capture in Godot, then analyze — the garment acceptance gate. Fails on clip-through."""
from tooling.domains.character import qa as capture_service
from tooling.domains.character import qa_analyze
plan = capture_service.capture_plan(config, dict(os.environ), Path.home())
capture_service.capture(plan)
report, path = qa_analyze.run(str(plan.config), None, min_pixels)
_report(report, path, min_pixels)
@app.command("qa-analyze")
@command
def qa_analyze_cmd(
config: Path = typer.Option(None, "--config", help="Capture config (reads its out_dir)."),
dir: Path = typer.Option(None, "--dir", help="Capture directory (overrides --config)."),
min_pixels: int = typer.Option(8, "--min-pixels", help="Smallest clip blob that fails a capture."),
report: Path = typer.Option(None, "--report", help="Report path (default: <out_dir>/report.json)."),
) -> None:
"""Re-analyze existing captures without re-rendering. Fails on clip-through."""
from tooling.domains.character import qa_analyze
result, path = qa_analyze.run(
str(config) if config else None, str(dir) if dir else None, min_pixels, str(report) if report else None
)
_report(result, path, min_pixels)
def _report(report: dict, path: Path, min_pixels: int) -> None:
"""Summary to stdout; a clip-through failure fails the command.
The analyzer used to exit 0 whatever it found, although its own README says
clip-through "is the real defect and it gates" (T-1290).
"""
from tooling.domains.character import qa_analyze
console.out("\n".join(qa_analyze.summary_lines(report)))
console.out(f" report : {path}")
failures = report["clip_through_failures"]
if failures:
out_dir = Path(report["out_dir"])
console.out(f" highlighted frames : {out_dir / 'failures'}")
raise ReachError(
f"garment-qa: {failures} of {report['total_captures']} captures clip through "
f"(largest blob >= {min_pixels}px)",
fix=f"inspect {out_dir / 'failures'}/*_HL.png; fix the garment, or re-run with a "
"deliberately looser --min-pixels (Wave 1/2 were accepted at 150)",
)
console.verdict(f"garment-qa: {report['total_captures']} captures, no clip-through")