diff --git a/.pql/changelog/ticket_history/2026-09.sql b/.pql/changelog/ticket_history/2026-09.sql index 0a33c3578..f5e1bbc57 100644 --- a/.pql/changelog/ticket_history/2026-09.sql +++ b/.pql/changelog/ticket_history/2026-09.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-09.sql b/.pql/changelog/tickets/2026-09.sql index 75648ac16..a4bf3f5ce 100644 --- a/.pql/changelog/tickets/2026-09.sql +++ b/.pql/changelog/tickets/2026-09.sql @@ -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; diff --git a/Makefile b/Makefile index 26325907f..3a7249e16 100644 --- a/Makefile +++ b/Makefile @@ -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 || \ diff --git a/client/tools/garment_qa/chromakey_scene.gd b/client/tools/garment_qa/chromakey_scene.gd index 084071069..8850f72c0 100644 --- a/client/tools/garment_qa/chromakey_scene.gd +++ b/client/tools/garment_qa/chromakey_scene.gd @@ -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"}, ...] diff --git a/docs/architecture/character-asset-organization.md b/docs/architecture/character-asset-organization.md index 6975e1692..0c36f97c8 100644 --- a/docs/architecture/character-asset-organization.md +++ b/docs/architecture/character-asset-organization.md @@ -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 `) is the acceptance gate every garment in this +`reach character qa ` (`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 diff --git a/tooling/segment_reference_distribution.md b/docs/assets/visual/segment-reference-distribution.md similarity index 100% rename from tooling/segment_reference_distribution.md rename to docs/assets/visual/segment-reference-distribution.md diff --git a/tooling/DOMAINS.md b/tooling/DOMAINS.md index 6e11e82a5..614146ff9 100644 --- a/tooling/DOMAINS.md +++ b/tooling/DOMAINS.md @@ -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 diff --git a/tooling/archive/README.md b/tooling/archive/README.md index c36f9e59f..56ed7dc27 100644 --- a/tooling/archive/README.md +++ b/tooling/archive/README.md @@ -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). diff --git a/tooling/wipe-bodies.sh b/tooling/archive/atlas-bootstrap/wipe-bodies.sh similarity index 100% rename from tooling/wipe-bodies.sh rename to tooling/archive/atlas-bootstrap/wipe-bodies.sh diff --git a/tooling/setup_clothing_metadata.py b/tooling/archive/character-bootstrap/setup_clothing_metadata.py similarity index 100% rename from tooling/setup_clothing_metadata.py rename to tooling/archive/character-bootstrap/setup_clothing_metadata.py diff --git a/tooling/domains/assets/image.py b/tooling/domains/assets/image.py old mode 100755 new mode 100644 diff --git a/tooling/domains/assets/trellis.py b/tooling/domains/assets/trellis.py old mode 100755 new mode 100644 diff --git a/tooling/garment-qa/README.md b/tooling/domains/character/GARMENT_QA.md similarity index 81% rename from tooling/garment-qa/README.md rename to tooling/domains/character/GARMENT_QA.md index 4da2a7a48..a29c122fd 100644 --- a/tooling/garment-qa/README.md +++ b/tooling/domains/character/GARMENT_QA.md @@ -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), diff --git a/tooling/domains/character/__init__.py b/tooling/domains/character/__init__.py new file mode 100644 index 000000000..ec32dfb21 --- /dev/null +++ b/tooling/domains/character/__init__.py @@ -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). +""" diff --git a/tooling/glb_strip_utility_nodes.py b/tooling/domains/character/glb_strip.py similarity index 73% rename from tooling/glb_strip_utility_nodes.py rename to tooling/domains/character/glb_strip.py index f036708be..88474b4ae 100644 --- a/tooling/glb_strip_utility_nodes.py +++ b/tooling/domains/character/glb_strip.py @@ -1,7 +1,10 @@ -#!/usr/bin/env python3 """ -glb_strip_utility_nodes.py -Usage: python3 tooling/glb_strip_utility_nodes.py [] +glb_strip — remove utility nodes from a GLB's scene graph. +Usage: reach character strip-glb [] + reach character strip-glb --dir + +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 []") - print(" python3 glb_strip_utility_nodes.py --dir ") - 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} diff --git a/tooling/garment-fit/make_logo.py b/tooling/domains/character/logo.py similarity index 78% rename from tooling/garment-fit/make_logo.py rename to tooling/domains/character/logo.py index 9fd65a866..df84c0f57 100644 --- a/tooling/garment-fit/make_logo.py +++ b/tooling/domains/character/logo.py @@ -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 [--size 256] + reach character logo [--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 [--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) diff --git a/tooling/domains/character/qa.py b/tooling/domains/character/qa.py new file mode 100644 index 000000000..d569704f8 --- /dev/null +++ b/tooling/domains/character/qa.py @@ -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", + ) diff --git a/tooling/garment-qa/analyze_captures.py b/tooling/domains/character/qa_analyze.py similarity index 80% rename from tooling/garment-qa/analyze_captures.py rename to tooling/domains/character/qa_analyze.py index 359779141..17ed7755b 100644 --- a/tooling/garment-qa/analyze_captures.py +++ b/tooling/domains/character/qa_analyze.py @@ -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 /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 or --config ") -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: /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 ), 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 diff --git a/tooling/garment-qa/configs/boots_modern.json b/tooling/domains/character/qa_configs/boots_modern.json similarity index 100% rename from tooling/garment-qa/configs/boots_modern.json rename to tooling/domains/character/qa_configs/boots_modern.json diff --git a/tooling/garment-qa/configs/buttondown_modern.json b/tooling/domains/character/qa_configs/buttondown_modern.json similarity index 100% rename from tooling/garment-qa/configs/buttondown_modern.json rename to tooling/domains/character/qa_configs/buttondown_modern.json diff --git a/tooling/garment-qa/configs/cargo_pants.json b/tooling/domains/character/qa_configs/cargo_pants.json similarity index 100% rename from tooling/garment-qa/configs/cargo_pants.json rename to tooling/domains/character/qa_configs/cargo_pants.json diff --git a/tooling/garment-qa/configs/hoodie_modern.json b/tooling/domains/character/qa_configs/hoodie_modern.json similarity index 100% rename from tooling/garment-qa/configs/hoodie_modern.json rename to tooling/domains/character/qa_configs/hoodie_modern.json diff --git a/tooling/garment-qa/configs/jacket_modern.json b/tooling/domains/character/qa_configs/jacket_modern.json similarity index 100% rename from tooling/garment-qa/configs/jacket_modern.json rename to tooling/domains/character/qa_configs/jacket_modern.json diff --git a/tooling/garment-qa/configs/jeans_modern.json b/tooling/domains/character/qa_configs/jeans_modern.json similarity index 100% rename from tooling/garment-qa/configs/jeans_modern.json rename to tooling/domains/character/qa_configs/jeans_modern.json diff --git a/tooling/garment-qa/configs/joggers_modern.json b/tooling/domains/character/qa_configs/joggers_modern.json similarity index 100% rename from tooling/garment-qa/configs/joggers_modern.json rename to tooling/domains/character/qa_configs/joggers_modern.json diff --git a/tooling/garment-qa/configs/pants_formal.json b/tooling/domains/character/qa_configs/pants_formal.json similarity index 100% rename from tooling/garment-qa/configs/pants_formal.json rename to tooling/domains/character/qa_configs/pants_formal.json diff --git a/tooling/garment-qa/configs/parka_thrds.json b/tooling/domains/character/qa_configs/parka_thrds.json similarity index 100% rename from tooling/garment-qa/configs/parka_thrds.json rename to tooling/domains/character/qa_configs/parka_thrds.json diff --git a/tooling/garment-qa/configs/peasant.json b/tooling/domains/character/qa_configs/peasant.json similarity index 100% rename from tooling/garment-qa/configs/peasant.json rename to tooling/domains/character/qa_configs/peasant.json diff --git a/tooling/garment-qa/configs/shoes_formal.json b/tooling/domains/character/qa_configs/shoes_formal.json similarity index 100% rename from tooling/garment-qa/configs/shoes_formal.json rename to tooling/domains/character/qa_configs/shoes_formal.json diff --git a/tooling/garment-qa/configs/shorts_modern.json b/tooling/domains/character/qa_configs/shorts_modern.json similarity index 100% rename from tooling/garment-qa/configs/shorts_modern.json rename to tooling/domains/character/qa_configs/shorts_modern.json diff --git a/tooling/garment-qa/configs/slides.json b/tooling/domains/character/qa_configs/slides.json similarity index 100% rename from tooling/garment-qa/configs/slides.json rename to tooling/domains/character/qa_configs/slides.json diff --git a/tooling/garment-qa/configs/sneakers_modern.json b/tooling/domains/character/qa_configs/sneakers_modern.json similarity index 100% rename from tooling/garment-qa/configs/sneakers_modern.json rename to tooling/domains/character/qa_configs/sneakers_modern.json diff --git a/tooling/garment-qa/configs/suit_jacket_black.json b/tooling/domains/character/qa_configs/suit_jacket_black.json similarity index 100% rename from tooling/garment-qa/configs/suit_jacket_black.json rename to tooling/domains/character/qa_configs/suit_jacket_black.json diff --git a/tooling/garment-qa/configs/sweater_modern.json b/tooling/domains/character/qa_configs/sweater_modern.json similarity index 100% rename from tooling/garment-qa/configs/sweater_modern.json rename to tooling/domains/character/qa_configs/sweater_modern.json diff --git a/tooling/garment-qa/configs/swim_trunks.json b/tooling/domains/character/qa_configs/swim_trunks.json similarity index 100% rename from tooling/garment-qa/configs/swim_trunks.json rename to tooling/domains/character/qa_configs/swim_trunks.json diff --git a/tooling/garment-qa/configs/swimsuit_onepiece.json b/tooling/domains/character/qa_configs/swimsuit_onepiece.json similarity index 100% rename from tooling/garment-qa/configs/swimsuit_onepiece.json rename to tooling/domains/character/qa_configs/swimsuit_onepiece.json diff --git a/tooling/garment-qa/configs/tank_top.json b/tooling/domains/character/qa_configs/tank_top.json similarity index 100% rename from tooling/garment-qa/configs/tank_top.json rename to tooling/domains/character/qa_configs/tank_top.json diff --git a/tooling/garment-qa/configs/track_jacket.json b/tooling/domains/character/qa_configs/track_jacket.json similarity index 100% rename from tooling/garment-qa/configs/track_jacket.json rename to tooling/domains/character/qa_configs/track_jacket.json diff --git a/tooling/garment-qa/configs/tshirt_modern.json b/tooling/domains/character/qa_configs/tshirt_modern.json similarity index 100% rename from tooling/garment-qa/configs/tshirt_modern.json rename to tooling/domains/character/qa_configs/tshirt_modern.json diff --git a/tooling/garment-qa/configs/uniform_utility.json b/tooling/domains/character/qa_configs/uniform_utility.json similarity index 100% rename from tooling/garment-qa/configs/uniform_utility.json rename to tooling/domains/character/qa_configs/uniform_utility.json diff --git a/tooling/domains/character/router.py b/tooling/domains/character/router.py new file mode 100644 index 000000000..87a9e2a49 --- /dev/null +++ b/tooling/domains/character/router.py @@ -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 ") + 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: /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") diff --git a/tooling/garment-qa/run-garment-qa b/tooling/garment-qa/run-garment-qa deleted file mode 100755 index edbcc53c4..000000000 --- a/tooling/garment-qa/run-garment-qa +++ /dev/null @@ -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" diff --git a/tooling/main.py b/tooling/main.py index 1dbbfa232..f97947141 100644 --- a/tooling/main.py +++ b/tooling/main.py @@ -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", diff --git a/tooling/scripts/blender/blender_author_boots.py b/tooling/scripts/blender/blender_author_boots.py index f85b3c9a0..a6b8395ce 100644 --- a/tooling/scripts/blender/blender_author_boots.py +++ b/tooling/scripts/blender/blender_author_boots.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_buttondown.py b/tooling/scripts/blender/blender_author_buttondown.py index eb5fc9abe..52d754ed0 100644 --- a/tooling/scripts/blender/blender_author_buttondown.py +++ b/tooling/scripts/blender/blender_author_buttondown.py @@ -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] diff --git a/tooling/scripts/blender/blender_author_cargo_pants.py b/tooling/scripts/blender/blender_author_cargo_pants.py index e567d6ef1..cd7e5b4f8 100644 --- a/tooling/scripts/blender/blender_author_cargo_pants.py +++ b/tooling/scripts/blender/blender_author_cargo_pants.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_denim_pants.py b/tooling/scripts/blender/blender_author_denim_pants.py index b7a5cd8d8..c52544a8a 100644 --- a/tooling/scripts/blender/blender_author_denim_pants.py +++ b/tooling/scripts/blender/blender_author_denim_pants.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_hoodie.py b/tooling/scripts/blender/blender_author_hoodie.py index 4db6245ff..44673e2a8 100644 --- a/tooling/scripts/blender/blender_author_hoodie.py +++ b/tooling/scripts/blender/blender_author_hoodie.py @@ -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] diff --git a/tooling/scripts/blender/blender_author_jacket_shell.py b/tooling/scripts/blender/blender_author_jacket_shell.py index 4906de9e6..7c6a476ad 100644 --- a/tooling/scripts/blender/blender_author_jacket_shell.py +++ b/tooling/scripts/blender/blender_author_jacket_shell.py @@ -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 average_m,child,...] [--offset 0.018] [--wrist-frac 0.85] diff --git a/tooling/scripts/blender/blender_author_joggers.py b/tooling/scripts/blender/blender_author_joggers.py index 051ce1941..78d944ee5 100644 --- a/tooling/scripts/blender/blender_author_joggers.py +++ b/tooling/scripts/blender/blender_author_joggers.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_lower_shell.py b/tooling/scripts/blender/blender_author_lower_shell.py index 7eeaf94d8..633873c66 100644 --- a/tooling/scripts/blender/blender_author_lower_shell.py +++ b/tooling/scripts/blender/blender_author_lower_shell.py @@ -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 a,b,c] [--offset 0.010] [--leg-frac 1.0] \ [--waistband-frac 1.0] [--cuff-frac 0.0] \ diff --git a/tooling/scripts/blender/blender_author_offset_coverall.py b/tooling/scripts/blender/blender_author_offset_coverall.py index ad00382cc..7d77038b5 100644 --- a/tooling/scripts/blender/blender_author_offset_coverall.py +++ b/tooling/scripts/blender/blender_author_offset_coverall.py @@ -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 a,b,c] [--offset 0.012] Writes per body: diff --git a/tooling/scripts/blender/blender_author_offset_shell.py b/tooling/scripts/blender/blender_author_offset_shell.py index 60b2f345c..570ad1af6 100644 --- a/tooling/scripts/blender/blender_author_offset_shell.py +++ b/tooling/scripts/blender/blender_author_offset_shell.py @@ -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 -- \ /average_m [--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 -- \ --per-body \ [--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40] diff --git a/tooling/scripts/blender/blender_author_offset_shell_legs.py b/tooling/scripts/blender/blender_author_offset_shell_legs.py index 9a0b33fa7..3bb74f167 100644 --- a/tooling/scripts/blender/blender_author_offset_shell_legs.py +++ b/tooling/scripts/blender/blender_author_offset_shell_legs.py @@ -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 -- \ --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 -- \ /average_m [--offset 0.020] [...] Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe). diff --git a/tooling/scripts/blender/blender_author_outerwear.py b/tooling/scripts/blender/blender_author_outerwear.py index 442c8f84c..c256e8c60 100644 --- a/tooling/scripts/blender/blender_author_outerwear.py +++ b/tooling/scripts/blender/blender_author_outerwear.py @@ -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 a,b,c] [--offset 0.022] [--cuff-keep 0.88] diff --git a/tooling/scripts/blender/blender_author_parka.py b/tooling/scripts/blender/blender_author_parka.py index efea2fb63..fe1c5a127 100644 --- a/tooling/scripts/blender/blender_author_parka.py +++ b/tooling/scripts/blender/blender_author_parka.py @@ -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] diff --git a/tooling/scripts/blender/blender_author_shoes_formal.py b/tooling/scripts/blender/blender_author_shoes_formal.py index f5792c030..3c72229ba 100644 --- a/tooling/scripts/blender/blender_author_shoes_formal.py +++ b/tooling/scripts/blender/blender_author_shoes_formal.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_slides.py b/tooling/scripts/blender/blender_author_slides.py index b1a7f2382..90b0aef7e 100644 --- a/tooling/scripts/blender/blender_author_slides.py +++ b/tooling/scripts/blender/blender_author_slides.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_sneakers.py b/tooling/scripts/blender/blender_author_sneakers.py index f1f757095..31528e68e 100644 --- a/tooling/scripts/blender/blender_author_sneakers.py +++ b/tooling/scripts/blender/blender_author_sneakers.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_sweater.py b/tooling/scripts/blender/blender_author_sweater.py index 6f35b22d1..2e6267edb 100644 --- a/tooling/scripts/blender/blender_author_sweater.py +++ b/tooling/scripts/blender/blender_author_sweater.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_swim_trunks.py b/tooling/scripts/blender/blender_author_swim_trunks.py index a4eb4644d..d122f7143 100644 --- a/tooling/scripts/blender/blender_author_swim_trunks.py +++ b/tooling/scripts/blender/blender_author_swim_trunks.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_swimsuit.py b/tooling/scripts/blender/blender_author_swimsuit.py index 5ca8d636d..2a7f1cd15 100644 --- a/tooling/scripts/blender/blender_author_swimsuit.py +++ b/tooling/scripts/blender/blender_author_swimsuit.py @@ -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] diff --git a/tooling/scripts/blender/blender_author_tank_top.py b/tooling/scripts/blender/blender_author_tank_top.py index cb2b50e3e..dbcdb1501 100644 --- a/tooling/scripts/blender/blender_author_tank_top.py +++ b/tooling/scripts/blender/blender_author_tank_top.py @@ -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] \ diff --git a/tooling/scripts/blender/blender_author_track_jacket.py b/tooling/scripts/blender/blender_author_track_jacket.py index 8bfe1ee14..2140723c1 100644 --- a/tooling/scripts/blender/blender_author_track_jacket.py +++ b/tooling/scripts/blender/blender_author_track_jacket.py @@ -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] diff --git a/tooling/scripts/blender/blender_batch_fit_skinned.py b/tooling/scripts/blender/blender_batch_fit_skinned.py index f2c5a226c..8fa5d8107 100644 --- a/tooling/scripts/blender/blender_batch_fit_skinned.py +++ b/tooling/scripts/blender/blender_batch_fit_skinned.py @@ -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 -- \ \ [--only average_m,average_f,...] [--solidify 0.0] [--self-check] diff --git a/tooling/check_hair_symmetry.py b/tooling/scripts/blender/blender_check_hair_symmetry.py similarity index 100% rename from tooling/check_hair_symmetry.py rename to tooling/scripts/blender/blender_check_hair_symmetry.py diff --git a/tooling/check_icosphere.py b/tooling/scripts/blender/blender_check_icosphere.py similarity index 100% rename from tooling/check_icosphere.py rename to tooling/scripts/blender/blender_check_icosphere.py diff --git a/tooling/convert_outfit.py b/tooling/scripts/blender/blender_convert_outfit.py similarity index 94% rename from tooling/convert_outfit.py rename to tooling/scripts/blender/blender_convert_outfit.py index 8a1a1f931..5a00ec78f 100644 --- a/tooling/convert_outfit.py +++ b/tooling/scripts/blender/blender_convert_outfit.py @@ -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 -- ") + print("Usage: reach blender run blender_convert_outfit ") sys.exit(1) args = argv[argv.index("--") + 1:] input_path = args[0] diff --git a/tooling/inspect_glb.py b/tooling/scripts/blender/blender_inspect_glb.py similarity index 94% rename from tooling/inspect_glb.py rename to tooling/scripts/blender/blender_inspect_glb.py index ae88f5b4f..3cd3848f1 100644 --- a/tooling/inspect_glb.py +++ b/tooling/scripts/blender/blender_inspect_glb.py @@ -5,7 +5,7 @@ import bpy if __name__ == "__main__": argv = sys.argv if "--" not in argv: - print("Usage: blender --background --python inspect_glb.py -- ") + print("Usage: reach blender run blender_inspect_glb ") sys.exit(1) args = argv[argv.index("--") + 1:] input_path = args[0] diff --git a/tooling/test_quaternius_raw.py b/tooling/scripts/blender/blender_quaternius_raw.py similarity index 94% rename from tooling/test_quaternius_raw.py rename to tooling/scripts/blender/blender_quaternius_raw.py index 576240d20..71a8c8756 100644 --- a/tooling/test_quaternius_raw.py +++ b/tooling/scripts/blender/blender_quaternius_raw.py @@ -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 -- ") + print("Usage: reach blender run blender_quaternius_raw ") sys.exit(1) output = argv[argv.index("--") + 1] diff --git a/tooling/render_quaternius_test.py b/tooling/scripts/blender/blender_render_quaternius_test.py similarity index 100% rename from tooling/render_quaternius_test.py rename to tooling/scripts/blender/blender_render_quaternius_test.py diff --git a/tooling/test_character.py b/tooling/test_character.py new file mode 100644 index 000000000..e3b1ae8a4 --- /dev/null +++ b/tooling/test_character.py @@ -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())