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>
This commit is contained in:
2026-09-23 19:55:46 +02:00
co-authored by Claude Opus 5.5
parent ddce4441a9
commit 26cc8de7f3
72 changed files with 609 additions and 188 deletions
+2 -2
View File
@@ -92,13 +92,13 @@ invented.
| `ledger` | the economics pipeline, named for the UI component that will aggregate it | ✅ ported (T-1289). `economy-db/` → `domains/ledger/` (`economy_import/` kept by name; the entrypoint became `service.py`), `schema_version.py` with it. `reach ledger import`; `make regen-db` survives as a one-line delegate, `make economy-db` retired. `generated_brands.toml` byte-identical across the move |
| `wiki` | wiki sync and content maintenance | ✅ ported (T-1290): `reach wiki stats`, `reach wiki gttr-hook` (both output-identical to the originals). `wiki_sync.py` moved whole, but its renderer and importer are NOT verbs — re-rendering deletes ~10,700 lines of committed pages (T-1292). The seven one-shots (`assign-astro-ids`, `migrate-s-to-gj`, `patch-core-sector`, `fill-missing-globes`, `generate-stubs`/`find-stubs`, `backfill_cultural_corridor`) and the destructive `process-wiki-system-changes` went to `archive/wiki-bootstrap/` |
| `assets` | connectors to the tower-of-joy generators | ✅ ported (T-1290): `reach assets {audio,image,trellis} …` + `synth-ui`. The four audio bash wrappers and `trellis-batch.sh` retired; connectors call each other instead of spawning each other; `tooling/db/` is gone. Synth WAVs byte-identical, ffmpeg pipeline decode-identical; network paths pinned by `test_assets.py` against a fake Gradio. Unreachable reads as OFF (VRAM, D-17), never "restart it" |
| `character` | bodies, garments, GLB handling | `garment-fit/make_logo.py`, `garment-qa/analyze_captures.py`, `convert_outfit.py`, `glb_strip_utility_nodes.py`, `inspect_glb.py`, `check_hair_symmetry.py`, `check_icosphere.py`, `render_quaternius_test.py`, `setup_clothing_metadata.py` — **note this is far smaller than `garment-fit/`'s file count suggests; 22 of its 23 files are Blender payloads and belong to the carve-out** |
| `character` | bodies, garments, GLB handling | ✅ ported (T-1290): `reach character {logo,strip-glb,qa,qa-analyze}` — logo PNG, stripped GLB and QA report byte-identical; `run-garment-qa` (bash) rewritten with its decisions pinned by `test_character.py`. **Map correction:** six of the ten files assigned here (`convert_outfit`, `inspect_glb`, `check_hair_symmetry`, `check_icosphere`, `render_quaternius_test`, `test_quaternius_raw`) import `bpy` — they joined the carve-out as `blender_*` payloads. `setup_clothing_metadata` and `wipe-bodies.sh` archived; `segment_reference_distribution.md` moved to `docs/assets/visual/` |
| `visual` | screenshot and render comparison | `visual-diff`, `visual-thumbnail`, `visual-blank-check` |
| `godot` | Godot parse and cold-start checks | ✅ ported (T-1283); the two bash originals were left beside the port and retired in T-1290 |
| `generate` | content generators not owned elsewhere | ✅ ported (T-1286). `generate-brands` + `generate-corporations` collapsed into `core.process.cargo_binary` — they were the same 24 lines of bash a third time |
| `dev` | developer environment and workflow | ✅ ported (T-1286). The three environment scripts split decision from performing — `godot_plan`/`worktree_plan` are pure and pinned by `test_environment.py` |
| `pr` | the PR/review loop | ✅ ported (T-1286). `watchlist-diff` now reads the watched set from `generator_sources.py` instead of restating it |
| `blender` | **carve-out** — payloads run by Blender | ✅ done (T-1273). 35 payloads in `tooling/scripts/blender/`, excluded from package scope and from importability; the bash wrapper's install-resolution and path-absolutising became `domains/blender/service.py`, pinned by `test_blender.py` |
| `blender` | **carve-out** — payloads run by Blender | ✅ done (T-1273). 35 payloads in `tooling/scripts/blender/` (41 after T-1290 found six more among the `character` files), excluded from package scope and from importability; the bash wrapper's install-resolution and path-absolutising became `domains/blender/service.py`, pinned by `test_blender.py` |
## Judgment calls, with reasons
+3
View File
@@ -18,4 +18,7 @@ them would lose the provenance; keeping them live would imply they still work
| `wiki-bootstrap/backfill_cultural_corridor.py` | copied `geographic_sector` into `cultural_corridor` | a raw systems.db patch script, the kind `.claude/rules/asset-pipeline.md` forbids; absent from the D-262 flow |
| `wiki-bootstrap/process-wiki-system-changes` | wiki → systems.db → star-map.json → re-rendered system pages | **destructive today** — its last step is `wiki_sync.generate_wiki()`, which no longer reproduces the committed pages (T-1290 finding, see the wiki domain's router) |
| `character-bootstrap/setup_clothing_metadata.py` | wrote `coverage.json` + a placeholder mask for the first five clothing items | all five (`coveralls_basic`, `jacket_utility`, `pants_cargo`, `shirt_henley`, `boots_work`) are gone from today's 24-garment wardrobe; re-running would recreate them |
| `atlas-bootstrap/wipe-bodies.sh` | `DELETE FROM bodies; DELETE FROM stations;` on systems.db | **destructive** — a raw DB patch of the kind `.claude/rules/asset-pipeline.md` forbids, against the atlas-CLI-owned bodies catalog |
Archived in T-1250 / T-1290 (2026-09-23).
Executable → Regular
View File
View File
@@ -11,9 +11,12 @@ within ε *in front of* the cloth = poking through. This separates a true clip f
merely crossing in front of the torso, an open collar, or a bare arm over background
(all of which stay magenta because the cloth behind them is > ε away).
**Run:** `tooling/garment-qa/run-garment-qa [config.json]` (defaults to
`configs/peasant.json`). It launches Godot (`~/bin/godot4`, `opengl3`, `xvfb-run` when
headless) on the capture scene, then runs the analyzer.
**Run:** `reach character qa [config]` — a name from `qa_configs/` (`peasant`, the
default, `hoodie_modern`, …) or a path. It launches Godot (`$GODOT`, else `~/bin/godot4`,
`opengl3`, `xvfb-run` when headless) on the capture scene, then runs the analyzer.
Re-analyze existing captures without re-rendering: `reach character qa-analyze --config …`
(or `--dir`). **Both exit 1 when any capture clips through** — the gate the text below
describes; before T-1290 the analyzer exited 0 whatever it found.
**Config** (path passed to the scene via `GARMENT_QA_CONFIG`): `garments`
(`{item_id,slot}` catalogue items, or `{glb,covers[]}` for a raw WIP garment),
+13
View File
@@ -0,0 +1,13 @@
"""`character` — bodies, garments and GLB handling (D-263).
`character` rather than `garment`: the tooling covers bodies, hair, GLB
utilities and garments, and naming it for one of those would leave the others
looking misfiled (tooling/DOMAINS.md).
What lives here is only the part that runs under reach's own Python: the
brand-logo stub, the GLB utility-node strip, and the garment-QA harness
(its configs in qa_configs/, its method in GARMENT_QA.md). Everything that runs
INSIDE Blender is a payload in tooling/scripts/blender/ and runs via
`reach blender run` — six files the domain map had assigned here turned out to
import bpy and went there instead (T-1290).
"""
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""
glb_strip_utility_nodes.py
Usage: python3 tooling/glb_strip_utility_nodes.py <input.glb> [<output.glb>]
glb_strip — remove utility nodes from a GLB's scene graph.
Usage: reach character strip-glb <input.glb> [<output.glb>]
reach character strip-glb --dir <directory>
Formerly tooling/glb_strip_utility_nodes.py (T-1290); the GLB rewriting is
unchanged and writes the same bytes.
Removes utility mesh nodes from a GLB's GLTF scene graph.
A "utility node" is any mesh node where:
@@ -22,10 +25,12 @@ If no output path is given, overwrites the input file.
"""
import json
import struct
import sys
import os
import re
import shutil
import struct
from tooling.core import console
GLB_MAGIC = 0x46546C67
CHUNK_JSON = 0x4E4F534A
@@ -118,10 +123,16 @@ def strip_utility_nodes(gltf: dict) -> tuple[int, list[str]]:
if not utility_mesh_indices:
return 0, []
# Find node indices that reference utility meshes
# Only nodes still LINKED into the graph count. Stripping leaves nodes in
# place as orphans (see below), so without this a second run re-reported the
# same nodes as stripped and rewrote an unchanged file (T-1290).
linked = {n for scene in scenes for n in scene.get('nodes', [])}
linked.update(c for node in nodes for c in node.get('children', []))
# Find linked node indices that reference utility meshes
utility_node_indices = {
i for i, n in enumerate(nodes)
if n.get('mesh') in utility_mesh_indices
if n.get('mesh') in utility_mesh_indices and i in linked
}
if not utility_node_indices:
@@ -147,53 +158,38 @@ def strip_utility_nodes(gltf: dict) -> tuple[int, list[str]]:
return len(utility_node_indices), removed_names
def process_file(input_path: str, output_path: str):
print(f"Processing: {input_path}")
def process_file(input_path: str, output_path: str) -> dict:
console.event(f"Processing: {input_path}")
gltf, bin_data = read_glb(input_path)
count, names = strip_utility_nodes(gltf)
if count == 0:
print(" No utility nodes found — file unchanged")
console.event("No utility nodes found — file unchanged")
if output_path != input_path:
import shutil
shutil.copy2(input_path, output_path)
return
return {"file": output_path, "stripped": 0, "names": []}
print(f" Stripped {count} utility node(s): {names}")
write_glb(gltf, bin_data, output_path)
print(f" Written: {output_path} ({os.path.getsize(output_path):,} bytes)")
console.event(f"Stripped {count} utility node(s): {names}")
console.event(f"Written: {output_path} ({os.path.getsize(output_path):,} bytes)")
return {"file": output_path, "stripped": count, "names": names}
def process_directory(input_dir: str):
"""Process all .glb files in a directory tree."""
def process_directory(input_dir: str) -> dict:
"""Process all .glb files in a directory tree, in place."""
total = 0
for root, dirs, files in os.walk(input_dir):
for fname in files:
if fname.lower().endswith('.glb'):
changed = []
for root, _dirs, files in os.walk(input_dir):
for fname in sorted(files):
if fname.lower().endswith(".glb"):
fpath = os.path.join(root, fname)
gltf, bin_data = read_glb(fpath)
count, names = strip_utility_nodes(gltf)
if count > 0:
write_glb(gltf, bin_data, fpath)
print(f" {os.path.relpath(fpath, input_dir)}: stripped {count} nodes {names}")
rel = os.path.relpath(fpath, input_dir)
console.event(f"{rel}: stripped {count} nodes {names}")
changed.append({"file": rel, "stripped": count, "names": names})
total += count
print(f"\nTotal utility nodes stripped: {total}")
if __name__ == '__main__':
args = sys.argv[1:]
if not args:
print("Usage: python3 glb_strip_utility_nodes.py <input.glb> [<output.glb>]")
print(" python3 glb_strip_utility_nodes.py --dir <directory>")
sys.exit(1)
if args[0] == '--dir':
if len(args) < 2:
print("ERROR: --dir requires a directory path")
sys.exit(1)
process_directory(args[1])
else:
input_path = args[0]
output_path = args[1] if len(args) > 1 else args[0]
process_file(input_path, output_path)
return {"dir": input_dir, "stripped": total, "files": changed}
@@ -11,17 +11,21 @@ per the feasibility §4 pipeline; this script gives the engine a real decal to
render now, and doubles as the deterministic fallback generator.
Run:
python3 tooling/garment-fit/make_logo.py <text> <out_png> [--size 256]
reach character logo <text> <out_png> [--size 256]
Example (the canonical Braemar fiber co-op, wiki/corporations/thrds.md — always
lowercase):
python3 tooling/garment-fit/make_logo.py thrds \
client/assets/characters/logos/thrds.png
reach character logo thrds client/assets/characters/logos/thrds.png
Formerly tooling/garment-fit/make_logo.py (T-1290); the drawing is unchanged
and renders the same PNG bytes.
"""
import sys
import os
from PIL import Image, ImageDraw, ImageFont
from tooling.core import console
FONT_CANDIDATES = [
"/usr/share/fonts/fira-code/FiraCode-Bold.ttf",
"/usr/share/fonts/adwaita-mono-fonts/AdwaitaMono-Bold.ttf",
@@ -65,18 +69,8 @@ def make_logo(text, out_png, size=256):
fill=(255, 255, 255, 255),
)
os.makedirs(os.path.dirname(out_png), exist_ok=True)
os.makedirs(os.path.dirname(os.path.abspath(out_png)), exist_ok=True)
img.save(out_png)
print(f"wrote {out_png} ({size}x{size}, font={font_name}, glyph_px={px})")
console.event(f"wrote {out_png} ({size}x{size}, font={font_name}, glyph_px={px})")
return {"file": out_png, "size": size, "font": font_name, "glyph_px": px}
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: make_logo.py <text> <out_png> [--size N]")
sys.exit(1)
text = sys.argv[1]
out = sys.argv[2]
size = 256
if "--size" in sys.argv:
size = int(sys.argv[sys.argv.index("--size") + 1])
make_logo(text, out, size)
+91
View File
@@ -0,0 +1,91 @@
"""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",
)
@@ -1,4 +1,3 @@
#!/usr/bin/env python3
"""Chromakey garment-clipping analyzer (T-1089, two-pass).
The capture scene writes two PNGs per view at the identical (paused) animation frame:
@@ -21,19 +20,20 @@ recoloured lime and clip_through filled red (+ red box per clip blob). Results l
<out_dir>/report.json plus a one-screen summary on stdout.
Reads the same JSON config as the capture scene (via --config) to locate out_dir,
or takes --dir directly.
or takes --dir directly. Formerly tooling/garment-qa/analyze_captures.py (T-1290);
the analysis is unchanged and produces the same report.
"""
from __future__ import annotations
import argparse
import collections
import json
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
from tooling.core.errors import ReachError
# Key-colour gate (pass A). Pure magenta is (255, 0, 255); the blue channel is the
# decisive discriminator — skin/garment texture is never simultaneously high-red,
# low-green AND high-blue, so this never fires on legitimate body or cloth pixels.
@@ -209,62 +209,54 @@ def summarize(out_dir: Path, min_pixels: int, results: list[dict]) -> dict:
}
def print_summary(report: dict) -> None:
print("=" * 74)
print("garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)")
print(f" out_dir : {report['out_dir']}")
print(f" min clip blob pixels : {report['min_component_pixels']}")
print(f" captures : {report['total_captures']}")
print(f" CLIP-THROUGH failures: {report['clip_through_failures']}")
print("-" * 74)
hdr = f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}"
print(hdr)
def summary_lines(report: dict) -> list[str]:
"""The human summary table — the command's stdout."""
lines = [
"=" * 74,
"garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)",
f" out_dir : {report['out_dir']}",
f" min clip blob pixels : {report['min_component_pixels']}",
f" captures : {report['total_captures']}",
f" CLIP-THROUGH failures: {report['clip_through_failures']}",
"-" * 74,
f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}",
]
for group, g in sorted(report["by_group"].items()):
print(
lines.append(
f" {group:<26}{g['captures']:>6}{g['clip_failures']:>10}"
f"{g['worst_clip']:>11}{g['worst_exposed']:>10}"
)
print("=" * 74)
lines.append("=" * 74)
return lines
def resolve_out_dir(args: argparse.Namespace) -> Path:
if args.dir:
return Path(args.dir)
if args.config:
cfg = json.loads(Path(args.config).read_text())
def resolve_out_dir(config_path: str | None, dir_path: str | None) -> Path:
"""The capture directory: --dir wins, else the config's out_dir."""
if dir_path:
return Path(dir_path)
if config_path:
cfg = json.loads(Path(config_path).read_text())
out = cfg.get("out_dir")
if not out:
sys.exit("analyze_captures: config has no 'out_dir'")
raise ReachError(f"{config_path} has no 'out_dir'", fix="add out_dir to the config, or pass --dir")
return Path(out)
sys.exit("analyze_captures: pass --dir or --config")
raise ReachError("no capture directory given", fix="pass --dir <captures> or --config <config.json>")
def main() -> int:
parser = argparse.ArgumentParser(description="Chromakey garment-clipping analyzer")
parser.add_argument("--config", help="capture config JSON (reads out_dir from it)")
parser.add_argument("--dir", help="directory of capture PNGs (overrides --config out_dir)")
parser.add_argument(
"--min-pixels",
type=int,
default=8,
help="largest connected clip-through blob that counts as a failure (default 8)",
)
parser.add_argument("--report", help="report JSON path (default: <out_dir>/report.json)")
args = parser.parse_args()
out_dir = resolve_out_dir(args)
def run(
config_path: str | None = None,
dir_path: str | None = None,
min_pixels: int = 8,
report_path: str | None = None,
) -> tuple[dict, Path]:
"""Analyze a capture directory; writes report.json and returns (report, path)."""
out_dir = resolve_out_dir(config_path, dir_path)
if not out_dir.is_dir():
sys.exit(f"analyze_captures: not a directory: {out_dir}")
report = analyze_dir(out_dir, args.min_pixels)
report_path = Path(args.report) if args.report else out_dir / "report.json"
report_path.write_text(json.dumps(report, indent=2))
print_summary(report)
print(f" report : {report_path}")
if report["clip_through_failures"]:
print(f" highlighted frames : {out_dir / 'failures'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
raise ReachError(
f"not a directory: {out_dir}",
fix="run the capture first (reach character qa <config>), or pass --dir",
)
report = analyze_dir(out_dir, min_pixels)
path = Path(report_path) if report_path else out_dir / "report.json"
path.write_text(json.dumps(report, indent=2))
return report, path
+110
View File
@@ -0,0 +1,110 @@
"""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")
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env bash
# run-garment-qa: drive the chromakey garment-clipping QA harness (T-1089).
#
# Usage: tooling/garment-qa/run-garment-qa [config.json]
# config.json defaults to tooling/garment-qa/configs/peasant.json
#
# Step 1 launches Godot on the client project with the capture scene, pointing it
# at the config via GARMENT_QA_CONFIG. Step 2 runs the pixel analyzer over the PNGs
# the scene wrote. Both steps are deliberately kept to a single command each.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CONFIG="${1:-$SCRIPT_DIR/configs/peasant.json}"
if [[ ! -f "$CONFIG" ]]; then
echo "run-garment-qa: config not found: $CONFIG" >&2
exit 2
fi
GODOT="${GODOT:-$HOME/bin/godot4}"
if [[ ! -x "$GODOT" ]]; then
GODOT="$(command -v godot4 || command -v godot || true)"
fi
if [[ -z "$GODOT" ]]; then
echo "run-garment-qa: godot binary not found (set GODOT=/path/to/godot4)" >&2
exit 3
fi
PY="$REPO_ROOT/.venv/bin/python"
[[ -x "$PY" ]] || PY="python3"
SCENE="res://tools/garment_qa/chromakey_scene.tscn"
echo "run-garment-qa: config = $CONFIG"
echo "run-garment-qa: godot = $GODOT"
# Step 1 — capture. Needs a display; use xvfb-run when running headless.
export GARMENT_QA_CONFIG="$CONFIG"
if [[ -n "${DISPLAY:-}" ]]; then
"$GODOT" --path "$REPO_ROOT/client" --rendering-driver opengl3 "$SCENE"
else
xvfb-run -a "$GODOT" --path "$REPO_ROOT/client" --rendering-driver opengl3 "$SCENE"
fi
# Step 2 — analyze.
"$PY" "$SCRIPT_DIR/analyze_captures.py" --config "$CONFIG"
+4
View File
@@ -71,6 +71,10 @@ DOMAINS: dict[str, tuple[str, str]] = {
"tooling.domains.assets.router:app",
"Asset generators — Stable Audio, Gemini images, Trellis 3D",
),
"character": (
"tooling.domains.character.router:app",
"Bodies, garments, GLBs — logos, the GLB strip, garment QA",
),
"godot": (
"tooling.domains.godot.router:app",
"Does the client parse, and does it parse cold",
@@ -71,7 +71,7 @@ default_tints (sole near-black, upper work brown, laces dark).
Usage (boots_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_boots.py -- \
tooling/scripts/blender/blender_author_boots.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/boots_modern \
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \
@@ -29,7 +29,7 @@ channel is still authored (costs nothing; enables future brand variants).
Run (per-body only — this garment ships the per-body route):
tooling/blender --background --python \
tooling/garment-fit/blender_author_buttondown.py -- \
tooling/scripts/blender/blender_author_buttondown.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/buttondown_modern \
[--bodies average_m,child,...] [--offset 0.009]
@@ -39,7 +39,7 @@ modelled — no 3D pocket geometry (texture carries identity).
Usage (cargo_pants reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_cargo_pants.py -- \
tooling/scripts/blender/blender_author_cargo_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/cargo_pants \
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \
@@ -52,7 +52,7 @@ bodies. Per-body mode only (offset shells author per body, Q-060).
Usage (jeans_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_denim_pants.py -- \
tooling/scripts/blender/blender_author_denim_pants.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/jeans_modern \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
@@ -28,7 +28,7 @@ Hoodie deltas over the base t-shirt shell:
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_hoodie.py -- \
tooling/scripts/blender/blender_author_hoodie.py -- \
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
[--bodies a,b,c] [--offset M]
@@ -36,7 +36,7 @@ PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
guidance) — there is no single-reference mode here.
tooling/blender --background --python \
tooling/garment-fit/blender_author_jacket_shell.py -- \
tooling/scripts/blender/blender_author_jacket_shell.py -- \
<bodies_root> <out_dir> \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
@@ -48,7 +48,7 @@ boundaries give the waist opening and ankle hems for free. Per-body mode only
Usage (joggers_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_joggers.py -- \
tooling/scripts/blender/blender_author_joggers.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/joggers_modern \
[--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \
@@ -30,7 +30,7 @@ offset-shell garments author per body; weights inherited by construction).
Usage:
tooling/blender --background --python \
tooling/garment-fit/blender_author_lower_shell.py -- \
tooling/scripts/blender/blender_author_lower_shell.py -- \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
[--waistband-frac 1.0] [--cuff-frac 0.0] \
@@ -38,7 +38,7 @@ Painted albedo details (flat, toon-friendly; identity lives in the texture):
Per-body only (this garment ships per-body per the Q-060 route guidance):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_coverall.py -- \
tooling/scripts/blender/blender_author_offset_coverall.py -- \
<bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
Writes per body:
@@ -34,7 +34,7 @@ SINGLE-REFERENCE (default) — author on one body (average_m); G1
derived/hand-authored garments that share one UV layout + one mask.
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
tooling/scripts/blender/blender_author_offset_shell.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
Writes:
@@ -52,7 +52,7 @@ from each body's OWN bone landmarks using the same proportional ratios
regions stay consistent across bodies.
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell.py -- \
tooling/scripts/blender/blender_author_offset_shell.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
@@ -37,7 +37,7 @@ Modes mirror the base script:
PER-BODY (preferred for offset shells, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
tooling/scripts/blender/blender_author_offset_shell_legs.py -- \
<bodies_dir> <out_dir> --per-body \
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
@@ -47,7 +47,7 @@ PER-BODY (preferred for offset shells, Q-060):
SINGLE-REFERENCE:
tooling/blender --background --python \
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
tooling/scripts/blender/blender_author_offset_shell_legs.py -- \
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
@@ -26,7 +26,7 @@ What it adds over the base t-shirt shell:
Per-body only (this family is the poster child for the Q-060 per-body route):
tooling/blender --background --python \
tooling/garment-fit/blender_author_outerwear.py -- \
tooling/scripts/blender/blender_author_outerwear.py -- \
<bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
@@ -37,7 +37,7 @@ top, combining the proven practices of the wave-1 family:
Per-body only (Q-060: offset shells author per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_parka.py -- \
tooling/scripts/blender/blender_author_parka.py -- \
client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15]
@@ -64,7 +64,7 @@ only (offset shells author per body, Q-060).
Usage (shoes_formal reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_shoes_formal.py -- \
tooling/scripts/blender/blender_author_shoes_formal.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/shoes_formal \
[--bodies average_m,child,...] [--offset 0.006] [--sole-mm 0.008] \
@@ -53,7 +53,7 @@ torso/leg companion provides, as reusable parameters:
Usage (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_slides.py -- \
tooling/scripts/blender/blender_author_slides.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/slides \
[--bodies average_m,child,...] [--offset 0.005] \
@@ -57,7 +57,7 @@ symmetric, so overlapping texels agree by construction.
Usage (sneakers_modern reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_sneakers.py -- \
tooling/scripts/blender/blender_author_sneakers.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/sneakers_modern \
[--bodies average_m,child,...] [--offset 0.009] [--sole-drop 0.015] \
@@ -51,7 +51,7 @@ as reusable parameters:
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_sweater.py -- \
tooling/scripts/blender/blender_author_sweater.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/sweater_modern \
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
@@ -41,7 +41,7 @@ proportional-ratio philosophy as base.derive_thresholds. Per-body mode only
Usage (swim_trunks reference invocation):
tooling/blender --background --python \
tooling/garment-fit/blender_author_swim_trunks.py -- \
tooling/scripts/blender/blender_author_swim_trunks.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/swim_trunks \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \
@@ -46,7 +46,7 @@ logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable).
Per-body mode only (offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_swimsuit.py -- \
tooling/scripts/blender/blender_author_swimsuit.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/swimsuit_onepiece \
[--bodies average_m,child,...] [--offset 0.012]
@@ -42,7 +42,7 @@ texture carries identity, flat toon-friendly).
Run (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \
tooling/garment-fit/blender_author_tank_top.py -- \
tooling/scripts/blender/blender_author_tank_top.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/tank_top \
[--bodies average_m,child,...] [--offset 0.012] \
@@ -40,7 +40,7 @@ kit needs beyond that script is delivered as reusable parameters, not hacks:
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \
tooling/garment-fit/blender_author_track_jacket.py -- \
tooling/scripts/blender/blender_author_track_jacket.py -- \
client/assets/characters/bodies \
client/assets/characters/clothing/track_jacket \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
@@ -26,7 +26,7 @@ Per body type:
Run:
tooling/blender --background --python \
tooling/garment-fit/blender_batch_fit_skinned.py -- \
tooling/scripts/blender/blender_batch_fit_skinned.py -- \
<reference_glb> <bodies_dir> <output_dir> \
[--only average_m,average_f,...] [--solidify 0.0] [--self-check]
@@ -7,7 +7,7 @@ CLOTHING_THICKNESS = 0.025 # 25mm outward solidify
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: blender --background --python convert_outfit.py -- <input.gltf> <output.glb>")
print("Usage: reach blender run blender_convert_outfit <input.gltf> <output.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
input_path = args[0]
@@ -5,7 +5,7 @@ import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: blender --background --python inspect_glb.py -- <input.glb>")
print("Usage: reach blender run blender_inspect_glb <input.glb>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
input_path = args[0]
@@ -5,7 +5,7 @@ import bpy
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: blender --background --python test_quaternius_raw.py -- <output.glb>")
print("Usage: reach blender run blender_quaternius_raw <output.glb>")
sys.exit(1)
output = argv[argv.index("--") + 1]
-34
View File
@@ -1,34 +0,0 @@
# Segment Face Distribution Reference (average_m)
Source: Regular_Male_FullBody.gltf (7047 verts, 12128 faces)
Date: 2026-03-23
Method: face-based exclusive assignment (dominant bone weight per face)
Bone mapping:
torso: spine_01, spine_02
torso_upper: spine_03, clavicle_l, clavicle_r
hips: pelvis
arm_upper: upperarm_l/r (no clavicle)
| Segment | Faces | Status |
|---------|-------|--------|
| seg_head | 2552 | LOCKED |
| seg_neck | 200 | LOCKED |
| seg_torso | 631 | LOCKED |
| seg_torso_upper | 762 | LOCKED |
| seg_hips | 314 | LOCKED |
| seg_arm_upper_l | 274 | LOCKED |
| seg_arm_upper_r | 274 | LOCKED |
| seg_arm_lower_l | 293 | LOCKED |
| seg_arm_lower_r | 293 | LOCKED |
| seg_hand_l | 1722 | LOCKED |
| seg_hand_r | 1722 | LOCKED |
| seg_leg_upper_l | 406 | LOCKED |
| seg_leg_upper_r | 409 | LOCKED |
| seg_leg_lower_l | 439 | LOCKED |
| seg_leg_lower_r | 439 | LOCKED |
| seg_foot_l | 699 | LOCKED |
| seg_foot_r | 699 | LOCKED |
| seg_eyes | (sub-object) | LOCKED |
| seg_eyebrows | (sub-object) | LOCKED |
All segments locked. No overlaps, no variants — all independent.
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""`reach character` decisions, exercised without launching Godot or Blender (T-1290).
The garment-QA capture step is a guarded exec (D-263); what is testable is the
plan — which config, which Godot, whether a virtual display is needed — and the
bash script's exit codes (2 for a missing config, 3 for no Godot). The GLB
strip is pure bytes, so it gets a synthetic GLB with real utility nodes.
Run: .venv/bin/python tooling/test_character.py
"""
from __future__ import annotations
import contextlib
import io
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tooling.core.errors import ReachError # noqa: E402
from tooling.domains.character import glb_strip, qa # noqa: E402
def check(failures: list[str], cond: bool, message: str) -> None:
if not cond:
failures.append(message)
def expect_error(failures: list[str], fn, code: int, label: str) -> None:
try:
fn()
failures.append(f"{label}: did not fail")
except ReachError as exc:
check(failures, exc.exit_code == code, f"{label}: exit {exc.exit_code}, wanted {code}")
check(failures, bool(exc.fix), f"{label}: no remedy")
def test_capture_plan(failures: list[str]) -> None:
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
godot = home / "bin" / "godot4"
godot.parent.mkdir()
godot.write_text("#!/bin/sh\n")
godot.chmod(0o755)
never = lambda _name: None # noqa: E731
plan = qa.capture_plan("peasant", {"DISPLAY": ":0"}, home, which=never)
check(failures, plan.config.name == "peasant.json", f"config name not resolved: {plan.config}")
check(failures, plan.argv[0] == str(godot), f"~/bin/godot4 not preferred: {plan.argv}")
check(failures, plan.argv[-1] == qa.SCENE and "--rendering-driver" in plan.argv, "scene argv changed")
headless = qa.capture_plan(None, {}, home, which=never)
check(failures, headless.argv[:2] == ["xvfb-run", "-a"], f"no $DISPLAY did not add xvfb-run: {headless.argv}")
check(failures, headless.config.name == "peasant.json", "default config is no longer peasant")
explicit = qa.capture_plan(None, {"GODOT": "/nope/godot", "DISPLAY": ":0"}, Path("/nonexistent"),
which=lambda n: "/usr/bin/godot" if n == "godot" else None)
check(failures, explicit.argv[0] == "/usr/bin/godot", f"PATH fallback order wrong: {explicit.argv}")
expect_error(failures, lambda: qa.resolve_config("no_such_garment"), 2, "missing config")
expect_error(failures, lambda: qa.resolve_godot({}, Path("/nonexistent"), which=never), 3, "missing godot")
def test_glb_strip(failures: list[str]) -> None:
gltf = {
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 1, 2]}],
"nodes": [
{"name": "Body", "mesh": 0, "skin": 0},
{"name": "Icosphere", "mesh": 1},
{"name": "WGT-root", "mesh": 1, "children": [3]},
{"name": "Child", "mesh": 0, "skin": 0},
],
"meshes": [
{"name": "Body", "primitives": [{"attributes": {"POSITION": 0, "JOINTS_0": 1}}]},
{"name": "Icosphere", "primitives": [{"attributes": {"POSITION": 0}}]},
],
"skins": [{"joints": [0]}],
"buffers": [{"byteLength": 8}],
}
with tempfile.TemporaryDirectory() as tmp, contextlib.redirect_stderr(io.StringIO()):
path = os.path.join(tmp, "x.glb")
glb_strip.write_glb(gltf, b"\x00" * 8, path)
result = glb_strip.process_file(path, path)
check(failures, result["stripped"] == 2, f"expected 2 utility nodes stripped, got {result}")
stripped, _bin = glb_strip.read_glb(path)
kept = [stripped["nodes"][i]["name"] for i in stripped["scenes"][0]["nodes"]]
check(failures, kept == ["Body"], f"scene roots after strip: {kept}")
again = glb_strip.process_file(path, path)
check(failures, again["stripped"] == 0, "a second strip was not a no-op")
def main() -> int:
failures: list[str] = []
test_capture_plan(failures)
test_glb_strip(failures)
if failures:
print("test_character: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print("test_character: OK — QA capture plan (config, godot order, xvfb, exit codes 2/3) "
"and GLB strip decided without launching Godot")
return 0
if __name__ == "__main__":
sys.exit(main())