`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>
111 lines
4.3 KiB
Python
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")
|