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. 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; 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. 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; 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)..." @echo " [test-tooling] reach assets connectors against a fake Gradio (T-1290)..."
@$(VENV_PY) tooling/test_assets.py 2> .cache/test-tooling-assets.log || \ @$(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 " 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)..." @echo " [test-tooling] canvas-generation version gate units (T-1242)..."
@mkdir -p .cache @mkdir -p .cache
@python3 tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \ @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. ## the garment GLBs itself — mirroring character_visual.gd:517-535.
## ##
## This scene lives under client/ (not tooling/) because Godot res:// paths cannot ## 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: ## file whose path arrives in the GARMENT_QA_CONFIG env var:
## { ## {
## "garments": [{"item_id": "peasant_tunic", "slot": "torso"}, ...] ## "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 ### 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 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 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 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 | | `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/` | | `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" | | `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` | | `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 | | `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 | | `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` | | `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 | | `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 ## 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/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) | | `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). 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 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). (all of which stay magenta because the cloth behind them is > ε away).
**Run:** `tooling/garment-qa/run-garment-qa [config.json]` (defaults to **Run:** `reach character qa [config]` — a name from `qa_configs/` (`peasant`, the
`configs/peasant.json`). It launches Godot (`~/bin/godot4`, `opengl3`, `xvfb-run` when default, `hoodie_modern`, …) or a path. It launches Godot (`$GODOT`, else `~/bin/godot4`,
headless) on the capture scene, then runs the analyzer. `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` **Config** (path passed to the scene via `GARMENT_QA_CONFIG`): `garments`
(`{item_id,slot}` catalogue items, or `{glb,covers[]}` for a raw WIP garment), (`{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 glb_strip — remove utility nodes from a GLB's scene graph.
Usage: python3 tooling/glb_strip_utility_nodes.py <input.glb> [<output.glb>] 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. Removes utility mesh nodes from a GLB's GLTF scene graph.
A "utility node" is any mesh node where: 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 json
import struct
import sys
import os import os
import re import re
import shutil
import struct
from tooling.core import console
GLB_MAGIC = 0x46546C67 GLB_MAGIC = 0x46546C67
CHUNK_JSON = 0x4E4F534A CHUNK_JSON = 0x4E4F534A
@@ -118,10 +123,16 @@ def strip_utility_nodes(gltf: dict) -> tuple[int, list[str]]:
if not utility_mesh_indices: if not utility_mesh_indices:
return 0, [] 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 = { utility_node_indices = {
i for i, n in enumerate(nodes) 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: 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 return len(utility_node_indices), removed_names
def process_file(input_path: str, output_path: str): def process_file(input_path: str, output_path: str) -> dict:
print(f"Processing: {input_path}") console.event(f"Processing: {input_path}")
gltf, bin_data = read_glb(input_path) gltf, bin_data = read_glb(input_path)
count, names = strip_utility_nodes(gltf) count, names = strip_utility_nodes(gltf)
if count == 0: if count == 0:
print(" No utility nodes found — file unchanged") console.event("No utility nodes found — file unchanged")
if output_path != input_path: if output_path != input_path:
import shutil
shutil.copy2(input_path, output_path) 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) 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): def process_directory(input_dir: str) -> dict:
"""Process all .glb files in a directory tree.""" """Process all .glb files in a directory tree, in place."""
total = 0 total = 0
for root, dirs, files in os.walk(input_dir): changed = []
for fname in files: for root, _dirs, files in os.walk(input_dir):
if fname.lower().endswith('.glb'): for fname in sorted(files):
if fname.lower().endswith(".glb"):
fpath = os.path.join(root, fname) fpath = os.path.join(root, fname)
gltf, bin_data = read_glb(fpath) gltf, bin_data = read_glb(fpath)
count, names = strip_utility_nodes(gltf) count, names = strip_utility_nodes(gltf)
if count > 0: if count > 0:
write_glb(gltf, bin_data, fpath) 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 total += count
print(f"\nTotal utility nodes stripped: {total}") return {"dir": input_dir, "stripped": total, "files": changed}
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)
@@ -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. render now, and doubles as the deterministic fallback generator.
Run: 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 Example (the canonical Braemar fiber co-op, wiki/corporations/thrds.md — always
lowercase): lowercase):
python3 tooling/garment-fit/make_logo.py thrds \ reach character logo thrds client/assets/characters/logos/thrds.png
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 import os
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from tooling.core import console
FONT_CANDIDATES = [ FONT_CANDIDATES = [
"/usr/share/fonts/fira-code/FiraCode-Bold.ttf", "/usr/share/fonts/fira-code/FiraCode-Bold.ttf",
"/usr/share/fonts/adwaita-mono-fonts/AdwaitaMono-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), 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) 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). """Chromakey garment-clipping analyzer (T-1089, two-pass).
The capture scene writes two PNGs per view at the identical (paused) animation frame: 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. <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, 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 from __future__ import annotations
import argparse
import collections import collections
import json import json
import sys
from pathlib import Path from pathlib import Path
from PIL import Image, ImageChops, ImageDraw 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 # 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, # 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. # 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: def summary_lines(report: dict) -> list[str]:
print("=" * 74) """The human summary table — the command's stdout."""
print("garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)") lines = [
print(f" out_dir : {report['out_dir']}") "=" * 74,
print(f" min clip blob pixels : {report['min_component_pixels']}") "garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)",
print(f" captures : {report['total_captures']}") f" out_dir : {report['out_dir']}",
print(f" CLIP-THROUGH failures: {report['clip_through_failures']}") f" min clip blob pixels : {report['min_component_pixels']}",
print("-" * 74) f" captures : {report['total_captures']}",
hdr = f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}" f" CLIP-THROUGH failures: {report['clip_through_failures']}",
print(hdr) "-" * 74,
f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}",
]
for group, g in sorted(report["by_group"].items()): for group, g in sorted(report["by_group"].items()):
print( lines.append(
f" {group:<26}{g['captures']:>6}{g['clip_failures']:>10}" f" {group:<26}{g['captures']:>6}{g['clip_failures']:>10}"
f"{g['worst_clip']:>11}{g['worst_exposed']:>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: def resolve_out_dir(config_path: str | None, dir_path: str | None) -> Path:
if args.dir: """The capture directory: --dir wins, else the config's out_dir."""
return Path(args.dir) if dir_path:
if args.config: return Path(dir_path)
cfg = json.loads(Path(args.config).read_text()) if config_path:
cfg = json.loads(Path(config_path).read_text())
out = cfg.get("out_dir") out = cfg.get("out_dir")
if not out: 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) 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: def run(
parser = argparse.ArgumentParser(description="Chromakey garment-clipping analyzer") config_path: str | None = None,
parser.add_argument("--config", help="capture config JSON (reads out_dir from it)") dir_path: str | None = None,
parser.add_argument("--dir", help="directory of capture PNGs (overrides --config out_dir)") min_pixels: int = 8,
parser.add_argument( report_path: str | None = None,
"--min-pixels", ) -> tuple[dict, Path]:
type=int, """Analyze a capture directory; writes report.json and returns (report, path)."""
default=8, out_dir = resolve_out_dir(config_path, dir_path)
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)
if not out_dir.is_dir(): if not out_dir.is_dir():
sys.exit(f"analyze_captures: not a directory: {out_dir}") raise ReachError(
f"not a directory: {out_dir}",
report = analyze_dir(out_dir, args.min_pixels) fix="run the capture first (reach character qa <config>), or pass --dir",
report_path = Path(args.report) if args.report else out_dir / "report.json" )
report_path.write_text(json.dumps(report, indent=2)) report = analyze_dir(out_dir, min_pixels)
print_summary(report) path = Path(report_path) if report_path else out_dir / "report.json"
print(f" report : {report_path}") path.write_text(json.dumps(report, indent=2))
if report["clip_through_failures"]: return report, path
print(f" highlighted frames : {out_dir / 'failures'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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", "tooling.domains.assets.router:app",
"Asset generators — Stable Audio, Gemini images, Trellis 3D", "Asset generators — Stable Audio, Gemini images, Trellis 3D",
), ),
"character": (
"tooling.domains.character.router:app",
"Bodies, garments, GLBs — logos, the GLB strip, garment QA",
),
"godot": ( "godot": (
"tooling.domains.godot.router:app", "tooling.domains.godot.router:app",
"Does the client parse, and does it parse cold", "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): Usage (boots_modern reference invocation):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/boots_modern \ client/assets/characters/clothing/boots_modern \
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \ [--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): Run (per-body only — this garment ships the per-body route):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/buttondown_modern \ client/assets/characters/clothing/buttondown_modern \
[--bodies average_m,child,...] [--offset 0.009] [--bodies average_m,child,...] [--offset 0.009]
@@ -39,7 +39,7 @@ modelled — no 3D pocket geometry (texture carries identity).
Usage (cargo_pants reference invocation): Usage (cargo_pants reference invocation):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/cargo_pants \ client/assets/characters/clothing/cargo_pants \
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \ [--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): Usage (jeans_modern reference invocation):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/jeans_modern \ client/assets/characters/clothing/jeans_modern \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \ [--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): PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
tooling/blender --background --python \ 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 \ client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
[--bodies a,b,c] [--offset M] [--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. guidance) — there is no single-reference mode here.
tooling/blender --background --python \ 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_root> <out_dir> \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85] [--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): Usage (joggers_modern reference invocation):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/joggers_modern \ client/assets/characters/clothing/joggers_modern \
[--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \ [--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: Usage:
tooling/blender --background --python \ 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_root> <out_dir> \
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \ [--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
[--waistband-frac 1.0] [--cuff-frac 0.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): Per-body only (this garment ships per-body per the Q-060 route guidance):
tooling/blender --background --python \ 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] <bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
Writes per body: 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. derived/hand-authored garments that share one UV layout + one mask.
tooling/blender --background --python \ 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] <bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
Writes: Writes:
@@ -52,7 +52,7 @@ from each body's OWN bone landmarks using the same proportional ratios
regions stay consistent across bodies. regions stay consistent across bodies.
tooling/blender --background --python \ 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_dir> <out_dir> --per-body \
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40] [--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): PER-BODY (preferred for offset shells, Q-060):
tooling/blender --background --python \ 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_dir> <out_dir> --per-body \
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35] [--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: SINGLE-REFERENCE:
tooling/blender --background --python \ 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] [...] <bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe). 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): Per-body only (this family is the poster child for the Q-060 per-body route):
tooling/blender --background --python \ tooling/blender --background --python \
tooling/garment-fit/blender_author_outerwear.py -- \ tooling/scripts/blender/blender_author_outerwear.py -- \
<bodies_root> <out_dir> \ <bodies_root> <out_dir> \
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88] [--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): Per-body only (Q-060: offset shells author per body, never SD-fit):
tooling/blender --background --python \ 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 \ client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15] [--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): Usage (shoes_formal reference invocation):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/shoes_formal \ client/assets/characters/clothing/shoes_formal \
[--bodies average_m,child,...] [--offset 0.006] [--sole-mm 0.008] \ [--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): Usage (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/slides \ client/assets/characters/clothing/slides \
[--bodies average_m,child,...] [--offset 0.005] \ [--bodies average_m,child,...] [--offset 0.005] \
@@ -57,7 +57,7 @@ symmetric, so overlapping texels agree by construction.
Usage (sneakers_modern reference invocation): Usage (sneakers_modern reference invocation):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/sneakers_modern \ client/assets/characters/clothing/sneakers_modern \
[--bodies average_m,child,...] [--offset 0.009] [--sole-drop 0.015] \ [--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): PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/sweater_modern \ client/assets/characters/clothing/sweater_modern \
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \ [--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): Usage (swim_trunks reference invocation):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/swim_trunks \ client/assets/characters/clothing/swim_trunks \
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \ [--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): Per-body mode only (offset shells author per body, Q-060):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/swimsuit_onepiece \ client/assets/characters/clothing/swimsuit_onepiece \
[--bodies average_m,child,...] [--offset 0.012] [--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): Run (per-body only — offset shells author per body, Q-060):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/tank_top \ client/assets/characters/clothing/tank_top \
[--bodies average_m,child,...] [--offset 0.012] \ [--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): PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
tooling/blender --background --python \ 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/bodies \
client/assets/characters/clothing/track_jacket \ client/assets/characters/clothing/track_jacket \
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90] [--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
@@ -26,7 +26,7 @@ Per body type:
Run: Run:
tooling/blender --background --python \ 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> \ <reference_glb> <bodies_dir> <output_dir> \
[--only average_m,average_f,...] [--solidify 0.0] [--self-check] [--only average_m,average_f,...] [--solidify 0.0] [--self-check]
@@ -7,7 +7,7 @@ CLOTHING_THICKNESS = 0.025 # 25mm outward solidify
if __name__ == "__main__": if __name__ == "__main__":
argv = sys.argv argv = sys.argv
if "--" not in 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) sys.exit(1)
args = argv[argv.index("--") + 1:] args = argv[argv.index("--") + 1:]
input_path = args[0] input_path = args[0]
@@ -5,7 +5,7 @@ import bpy
if __name__ == "__main__": if __name__ == "__main__":
argv = sys.argv argv = sys.argv
if "--" not in 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) sys.exit(1)
args = argv[argv.index("--") + 1:] args = argv[argv.index("--") + 1:]
input_path = args[0] input_path = args[0]
@@ -5,7 +5,7 @@ import bpy
if __name__ == "__main__": if __name__ == "__main__":
argv = sys.argv argv = sys.argv
if "--" not in 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) sys.exit(1)
output = argv[argv.index("--") + 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())