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
+72
View File
@@ -470,3 +470,75 @@ Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
before moving anything — five silent failures so far.
PROGRESS 2026-09-23 — wiki half done (commit to follow): reach wiki stats + gttr-hook, both output-identical to the originals (stats byte-identical; all 301 gttr hooks identical). wiki_sync.generate_wiki/import_from_wiki moved but NOT exposed: a pre-port run of the old --generate deleted ~10,700 lines of committed pages -> T-1292. Archive: tooling/archive/{pql-migrate,wiki-bootstrap}/ with a README (one-shots + the destructive process-wiki-system-changes + backfill_cultural_corridor, a raw DB patch script outside D-262). find-stubs archived too: it finds 0 stubs, Phase-1 job done. Side fix: tooling/godot-cold-parse + godot-parse-sweep were never retired after T-1283 and pr-process still told agents to run them — removed, skill repointed. Remaining: assets, character.', NULL, '2026-09-23 14:25:42', '2026-09-23 14:25:42.440', '2026-09-23 14:25:42.440', NULL, '50093acafd657096ec7f86acfa339f78', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', 'description', 'The tail of the tree — three domains, ~26 files, batched because none of them
entangles with the spatial ladder or the systems.db stamp.
`reach wiki` — `tooling/wiki/`, `db/wiki_sync.py`, `db/populate_gttr_hook.py`,
`db/backfill_cultural_corridor.py`, `assign-astro-ids.py`,
`fill-missing-globes.py`, `migrate-s-to-gj.py`, `patch-core-sector.py`,
`process-wiki-system-changes`.
READ `Skill(wiki)` FIRST. The wiki has two ownership models running in opposite
directions and these tools sit on the seam: `star-systems/` (93% of the wiki) is
OUTPUT generated FROM systems.db by `wiki_sync.py`, while the flat categories are
INPUT. A verb that writes to the wrong side is silently reverted on the next sync.
Several of these are one-shot migrations (`migrate-s-to-gj`, `patch-core-sector`) —
decide per file whether it is a live verb or provenance bound for `tooling/archive/`,
the same call T-1271 made for `pql-migrate/`.
`reach assets` — `db/audio_*.py`, `db/audio-*`, `db/image_connector.py`,
`db/trellis_connector.py`, `db/common.py`, `trellis-batch.sh`, `synth_ui_sounds.py`.
These call tower-of-joy over the network (Stable Audio :11500, Trellis :11510, by
IP per `.claude/rules/local-services.md`). Endpoints live in `tooling/db/config.json`
— that config must keep working or move deliberately, not incidentally. Network
failure is the normal case here, so `ReachError` messages need to distinguish
"the box is off" from "the request was wrong"; `missing_fix=` and `fix=` both matter.
`reach character` — `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`.
Much smaller than `garment-fit/`''s 23 files suggest — 22 of them are Blender
payloads that belong to the carve-out (T-1273) and MUST NOT import the package.
Do T-1273 first or the boundary will blur.
Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
before moving anything — five silent failures so far.
PROGRESS 2026-09-23 — wiki half done (commit to follow): reach wiki stats + gttr-hook, both output-identical to the originals (stats byte-identical; all 301 gttr hooks identical). wiki_sync.generate_wiki/import_from_wiki moved but NOT exposed: a pre-port run of the old --generate deleted ~10,700 lines of committed pages -> T-1292. Archive: tooling/archive/{pql-migrate,wiki-bootstrap}/ with a README (one-shots + the destructive process-wiki-system-changes + backfill_cultural_corridor, a raw DB patch script outside D-262). find-stubs archived too: it finds 0 stubs, Phase-1 job done. Side fix: tooling/godot-cold-parse + godot-parse-sweep were never retired after T-1283 and pr-process still told agents to run them — removed, skill repointed. Remaining: assets, character.', 'The tail of the tree — three domains, ~26 files, batched because none of them
entangles with the spatial ladder or the systems.db stamp.
`reach wiki` — `tooling/wiki/`, `db/wiki_sync.py`, `db/populate_gttr_hook.py`,
`db/backfill_cultural_corridor.py`, `assign-astro-ids.py`,
`fill-missing-globes.py`, `migrate-s-to-gj.py`, `patch-core-sector.py`,
`process-wiki-system-changes`.
READ `Skill(wiki)` FIRST. The wiki has two ownership models running in opposite
directions and these tools sit on the seam: `star-systems/` (93% of the wiki) is
OUTPUT generated FROM systems.db by `wiki_sync.py`, while the flat categories are
INPUT. A verb that writes to the wrong side is silently reverted on the next sync.
Several of these are one-shot migrations (`migrate-s-to-gj`, `patch-core-sector`) —
decide per file whether it is a live verb or provenance bound for `tooling/archive/`,
the same call T-1271 made for `pql-migrate/`.
`reach assets` — `db/audio_*.py`, `db/audio-*`, `db/image_connector.py`,
`db/trellis_connector.py`, `db/common.py`, `trellis-batch.sh`, `synth_ui_sounds.py`.
These call tower-of-joy over the network (Stable Audio :11500, Trellis :11510, by
IP per `.claude/rules/local-services.md`). Endpoints live in `tooling/db/config.json`
— that config must keep working or move deliberately, not incidentally. Network
failure is the normal case here, so `ReachError` messages need to distinguish
"the box is off" from "the request was wrong"; `missing_fix=` and `fix=` both matter.
`reach character` — `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`.
Much smaller than `garment-fit/`''s 23 files suggest — 22 of them are Blender
payloads that belong to the carve-out (T-1273) and MUST NOT import the package.
Do T-1273 first or the boundary will blur.
Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
before moving anything — five silent failures so far.
PROGRESS 2026-09-23 — wiki half done (commit to follow): reach wiki stats + gttr-hook, both output-identical to the originals (stats byte-identical; all 301 gttr hooks identical). wiki_sync.generate_wiki/import_from_wiki moved but NOT exposed: a pre-port run of the old --generate deleted ~10,700 lines of committed pages -> T-1292. Archive: tooling/archive/{pql-migrate,wiki-bootstrap}/ with a README (one-shots + the destructive process-wiki-system-changes + backfill_cultural_corridor, a raw DB patch script outside D-262). find-stubs archived too: it finds 0 stubs, Phase-1 job done. Side fix: tooling/godot-cold-parse + godot-parse-sweep were never retired after T-1283 and pr-process still told agents to run them — removed, skill repointed. Remaining: assets, character.
DONE 2026-09-23. Three commits: wiki (reach wiki stats/gttr-hook; renderer not exposed -> T-1292), assets (reach assets audio/image/trellis/synth-ui; OFF-vs-rejected classification; test_assets.py fake Gradio), character (reach character logo/strip-glb/qa/qa-analyze; test_character.py). Parity evidence per domain in the commit messages: every byte-comparable output (stats, 301 gttr hooks, 4 UI WAVs, harmonic WAVs, pipeline decoded PCM, logo PNG, stripped GLB, QA report.json) identical before/after. Map corrections: 6 of the 10 ''character'' files import bpy -> blender_* payloads (carve-out now 41); setup_clothing_metadata + wipe-bodies.sh archived (dead bootstrap / destructive DB patch). Behaviour changes, all failures-that-read-as-success: audio batch, trellis batch and the garment-QA analyzer exited 0 with failures; trellis generate crashed (TypeError) on a missing image; glb strip re-reported orphaned nodes as stripped on every re-run. Removed functions (noted because the rule is to ask): common.ensure_venv (an os.execv re-exec D-263 forbids) and audio_post.check_ffmpeg (replaced by process.run''s missing-binary remedy). Known quirk left alone: every qa_configs/*.json hardcodes out_dir to the main checkout''s .cache, so a worktree QA run writes into main.', NULL, '2026-09-23 17:55:35', '2026-09-23 17:55:35.868', '2026-09-23 17:55:35.868', NULL, '4c4c046c858168a23f2de7d1cdd53dba', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', 'status', 'backlog', 'done', NULL, '2026-09-23 17:55:36', '2026-09-23 17:55:36.277', '2026-09-23 17:55:36.277', NULL, '164508550dfd263ddbc7f21928baec26', 2) ON CONFLICT(hash) DO NOTHING;
+74
View File
@@ -597,3 +597,77 @@ Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
before moving anything — five silent failures so far.
PROGRESS 2026-09-23 — wiki half done (commit to follow): reach wiki stats + gttr-hook, both output-identical to the originals (stats byte-identical; all 301 gttr hooks identical). wiki_sync.generate_wiki/import_from_wiki moved but NOT exposed: a pre-port run of the old --generate deleted ~10,700 lines of committed pages -> T-1292. Archive: tooling/archive/{pql-migrate,wiki-bootstrap}/ with a README (one-shots + the destructive process-wiki-system-changes + backfill_cultural_corridor, a raw DB patch script outside D-262). find-stubs archived too: it finds 0 stubs, Phase-1 job done. Side fix: tooling/godot-cold-parse + godot-parse-sweep were never retired after T-1283 and pr-process still told agents to run them — removed, skill repointed. Remaining: assets, character.', 'backlog', 'medium', NULL, 'tooling', 'D-263', '2026-09-02 16:17:45.770', '2026-09-23 14:25:42.440', NULL, 'f5425885aa0e5f99f744ab4cdaaf5d63', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port wiki, assets and character — the three small remaining domains', 'The tail of the tree — three domains, ~26 files, batched because none of them
entangles with the spatial ladder or the systems.db stamp.
`reach wiki` — `tooling/wiki/`, `db/wiki_sync.py`, `db/populate_gttr_hook.py`,
`db/backfill_cultural_corridor.py`, `assign-astro-ids.py`,
`fill-missing-globes.py`, `migrate-s-to-gj.py`, `patch-core-sector.py`,
`process-wiki-system-changes`.
READ `Skill(wiki)` FIRST. The wiki has two ownership models running in opposite
directions and these tools sit on the seam: `star-systems/` (93% of the wiki) is
OUTPUT generated FROM systems.db by `wiki_sync.py`, while the flat categories are
INPUT. A verb that writes to the wrong side is silently reverted on the next sync.
Several of these are one-shot migrations (`migrate-s-to-gj`, `patch-core-sector`) —
decide per file whether it is a live verb or provenance bound for `tooling/archive/`,
the same call T-1271 made for `pql-migrate/`.
`reach assets` — `db/audio_*.py`, `db/audio-*`, `db/image_connector.py`,
`db/trellis_connector.py`, `db/common.py`, `trellis-batch.sh`, `synth_ui_sounds.py`.
These call tower-of-joy over the network (Stable Audio :11500, Trellis :11510, by
IP per `.claude/rules/local-services.md`). Endpoints live in `tooling/db/config.json`
— that config must keep working or move deliberately, not incidentally. Network
failure is the normal case here, so `ReachError` messages need to distinguish
"the box is off" from "the request was wrong"; `missing_fix=` and `fix=` both matter.
`reach character` — `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`.
Much smaller than `garment-fit/`''s 23 files suggest — 22 of them are Blender
payloads that belong to the carve-out (T-1273) and MUST NOT import the package.
Do T-1273 first or the boundary will blur.
Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
before moving anything — five silent failures so far.
PROGRESS 2026-09-23 — wiki half done (commit to follow): reach wiki stats + gttr-hook, both output-identical to the originals (stats byte-identical; all 301 gttr hooks identical). wiki_sync.generate_wiki/import_from_wiki moved but NOT exposed: a pre-port run of the old --generate deleted ~10,700 lines of committed pages -> T-1292. Archive: tooling/archive/{pql-migrate,wiki-bootstrap}/ with a README (one-shots + the destructive process-wiki-system-changes + backfill_cultural_corridor, a raw DB patch script outside D-262). find-stubs archived too: it finds 0 stubs, Phase-1 job done. Side fix: tooling/godot-cold-parse + godot-parse-sweep were never retired after T-1283 and pr-process still told agents to run them — removed, skill repointed. Remaining: assets, character.
DONE 2026-09-23. Three commits: wiki (reach wiki stats/gttr-hook; renderer not exposed -> T-1292), assets (reach assets audio/image/trellis/synth-ui; OFF-vs-rejected classification; test_assets.py fake Gradio), character (reach character logo/strip-glb/qa/qa-analyze; test_character.py). Parity evidence per domain in the commit messages: every byte-comparable output (stats, 301 gttr hooks, 4 UI WAVs, harmonic WAVs, pipeline decoded PCM, logo PNG, stripped GLB, QA report.json) identical before/after. Map corrections: 6 of the 10 ''character'' files import bpy -> blender_* payloads (carve-out now 41); setup_clothing_metadata + wipe-bodies.sh archived (dead bootstrap / destructive DB patch). Behaviour changes, all failures-that-read-as-success: audio batch, trellis batch and the garment-QA analyzer exited 0 with failures; trellis generate crashed (TypeError) on a missing image; glb strip re-reported orphaned nodes as stripped on every re-run. Removed functions (noted because the rule is to ask): common.ensure_venv (an os.execv re-exec D-263 forbids) and audio_post.check_ffmpeg (replaced by process.run''s missing-binary remedy). Known quirk left alone: every qa_configs/*.json hardcodes out_dir to the main checkout''s .cache, so a worktree QA run writes into main.', 'backlog', 'medium', NULL, 'tooling', 'D-263', '2026-09-02 16:17:45.770', '2026-09-23 17:55:35.868', NULL, '3bcab3403c9e6605f27b10a4a22701db', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port wiki, assets and character — the three small remaining domains', 'The tail of the tree — three domains, ~26 files, batched because none of them
entangles with the spatial ladder or the systems.db stamp.
`reach wiki` — `tooling/wiki/`, `db/wiki_sync.py`, `db/populate_gttr_hook.py`,
`db/backfill_cultural_corridor.py`, `assign-astro-ids.py`,
`fill-missing-globes.py`, `migrate-s-to-gj.py`, `patch-core-sector.py`,
`process-wiki-system-changes`.
READ `Skill(wiki)` FIRST. The wiki has two ownership models running in opposite
directions and these tools sit on the seam: `star-systems/` (93% of the wiki) is
OUTPUT generated FROM systems.db by `wiki_sync.py`, while the flat categories are
INPUT. A verb that writes to the wrong side is silently reverted on the next sync.
Several of these are one-shot migrations (`migrate-s-to-gj`, `patch-core-sector`) —
decide per file whether it is a live verb or provenance bound for `tooling/archive/`,
the same call T-1271 made for `pql-migrate/`.
`reach assets` — `db/audio_*.py`, `db/audio-*`, `db/image_connector.py`,
`db/trellis_connector.py`, `db/common.py`, `trellis-batch.sh`, `synth_ui_sounds.py`.
These call tower-of-joy over the network (Stable Audio :11500, Trellis :11510, by
IP per `.claude/rules/local-services.md`). Endpoints live in `tooling/db/config.json`
— that config must keep working or move deliberately, not incidentally. Network
failure is the normal case here, so `ReachError` messages need to distinguish
"the box is off" from "the request was wrong"; `missing_fix=` and `fix=` both matter.
`reach character` — `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`.
Much smaller than `garment-fit/`''s 23 files suggest — 22 of them are Blender
payloads that belong to the carve-out (T-1273) and MUST NOT import the package.
Do T-1273 first or the boundary will blur.
Standard port acceptance as on T-1281/T-1286. Check `__file__`-relative roots
before moving anything — five silent failures so far.
PROGRESS 2026-09-23 — wiki half done (commit to follow): reach wiki stats + gttr-hook, both output-identical to the originals (stats byte-identical; all 301 gttr hooks identical). wiki_sync.generate_wiki/import_from_wiki moved but NOT exposed: a pre-port run of the old --generate deleted ~10,700 lines of committed pages -> T-1292. Archive: tooling/archive/{pql-migrate,wiki-bootstrap}/ with a README (one-shots + the destructive process-wiki-system-changes + backfill_cultural_corridor, a raw DB patch script outside D-262). find-stubs archived too: it finds 0 stubs, Phase-1 job done. Side fix: tooling/godot-cold-parse + godot-parse-sweep were never retired after T-1283 and pr-process still told agents to run them — removed, skill repointed. Remaining: assets, character.
DONE 2026-09-23. Three commits: wiki (reach wiki stats/gttr-hook; renderer not exposed -> T-1292), assets (reach assets audio/image/trellis/synth-ui; OFF-vs-rejected classification; test_assets.py fake Gradio), character (reach character logo/strip-glb/qa/qa-analyze; test_character.py). Parity evidence per domain in the commit messages: every byte-comparable output (stats, 301 gttr hooks, 4 UI WAVs, harmonic WAVs, pipeline decoded PCM, logo PNG, stripped GLB, QA report.json) identical before/after. Map corrections: 6 of the 10 ''character'' files import bpy -> blender_* payloads (carve-out now 41); setup_clothing_metadata + wipe-bodies.sh archived (dead bootstrap / destructive DB patch). Behaviour changes, all failures-that-read-as-success: audio batch, trellis batch and the garment-QA analyzer exited 0 with failures; trellis generate crashed (TypeError) on a missing image; glb strip re-reported orphaned nodes as stripped on every re-run. Removed functions (noted because the rule is to ask): common.ensure_venv (an os.execv re-exec D-263 forbids) and audio_post.check_ffmpeg (replaced by process.run''s missing-binary remedy). Known quirk left alone: every qa_configs/*.json hardcodes out_dir to the main checkout''s .cache, so a worktree QA run writes into main.', 'done', 'medium', NULL, 'tooling', 'D-263', '2026-09-02 16:17:45.770', '2026-09-23 17:55:36.277', NULL, '231af57682599f487ebd3fb0d396870c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+3
View File
@@ -299,6 +299,9 @@ test-tooling:
@echo " [test-tooling] reach assets connectors against a fake Gradio (T-1290)..."
@$(VENV_PY) tooling/test_assets.py 2> .cache/test-tooling-assets.log || \
{ echo " FAIL: assets connectors — log follows:"; cat .cache/test-tooling-assets.log; exit 1; }
@echo " [test-tooling] reach character — QA plan and GLB strip (T-1290)..."
@$(VENV_PY) tooling/test_character.py 2> .cache/test-tooling-character.log || \
{ echo " FAIL: character decisions — log follows:"; cat .cache/test-tooling-character.log; exit 1; }
@echo " [test-tooling] canvas-generation version gate units (T-1242)..."
@mkdir -p .cache
@python3 tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \
+1 -1
View File
@@ -25,7 +25,7 @@ extends Node3D
## the garment GLBs itself — mirroring character_visual.gd:517-535.
##
## This scene lives under client/ (not tooling/) because Godot res:// paths cannot
## leave the client project root — see tooling/garment-qa/README.md. Config is a JSON
## leave the client project root — see tooling/domains/character/GARMENT_QA.md. Config is a JSON
## file whose path arrives in the GARMENT_QA_CONFIG env var:
## {
## "garments": [{"item_id": "peasant_tunic", "slot": "torso"}, ...]
@@ -817,7 +817,7 @@ animate on the shared skeleton -- why it was superseded, not merely extended).
### QA: chromakey acceptance gate
`tooling/garment-qa` (`run-garment-qa <config>`) is the acceptance gate every garment in this
`reach character qa <config>` (`tooling/domains/character/`) is the acceptance gate every garment in this
pipeline runs before it ships: a two-pass depth-proximity chromakey renders the same frozen
frame twice -- once keying claimed-covered body segments magenta, once with the garment
nudged a few cm toward the camera and re-keyed cyan. A body-key pixel that flips from magenta
+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]
+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())