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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || \
|
||||
|
||||
@@ -25,7 +25,7 @@ extends Node3D
|
||||
## the garment GLBs itself — mirroring character_visual.gd:517-535.
|
||||
##
|
||||
## This scene lives under client/ (not tooling/) because Godot res:// paths cannot
|
||||
## leave the client project root — see tooling/garment-qa/README.md. Config is a JSON
|
||||
## leave the client project root — see tooling/domains/character/GARMENT_QA.md. Config is a JSON
|
||||
## file whose path arrives in the GARMENT_QA_CONFIG env var:
|
||||
## {
|
||||
## "garments": [{"item_id": "peasant_tunic", "slot": "torso"}, ...]
|
||||
|
||||
@@ -817,7 +817,7 @@ animate on the shared skeleton -- why it was superseded, not merely extended).
|
||||
|
||||
### QA: chromakey acceptance gate
|
||||
|
||||
`tooling/garment-qa` (`run-garment-qa <config>`) is the acceptance gate every garment in this
|
||||
`reach character qa <config>` (`tooling/domains/character/`) is the acceptance gate every garment in this
|
||||
pipeline runs before it ships: a two-pass depth-proximity chromakey renders the same frozen
|
||||
frame twice -- once keying claimed-covered body segments magenta, once with the garment
|
||||
nudged a few cm toward the camera and re-keyed cyan. A body-key pixel that flips from magenta
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -18,4 +18,7 @@ them would lose the provenance; keeping them live would imply they still work
|
||||
| `wiki-bootstrap/backfill_cultural_corridor.py` | copied `geographic_sector` into `cultural_corridor` | a raw systems.db patch script, the kind `.claude/rules/asset-pipeline.md` forbids; absent from the D-262 flow |
|
||||
| `wiki-bootstrap/process-wiki-system-changes` | wiki → systems.db → star-map.json → re-rendered system pages | **destructive today** — its last step is `wiki_sync.generate_wiki()`, which no longer reproduces the committed pages (T-1290 finding, see the wiki domain's router) |
|
||||
|
||||
| `character-bootstrap/setup_clothing_metadata.py` | wrote `coverage.json` + a placeholder mask for the first five clothing items | all five (`coveralls_basic`, `jacket_utility`, `pants_cargo`, `shirt_henley`, `boots_work`) are gone from today's 24-garment wardrobe; re-running would recreate them |
|
||||
| `atlas-bootstrap/wipe-bodies.sh` | `DELETE FROM bodies; DELETE FROM stations;` on systems.db | **destructive** — a raw DB patch of the kind `.claude/rules/asset-pipeline.md` forbids, against the atlas-CLI-owned bodies catalog |
|
||||
|
||||
Archived in T-1250 / T-1290 (2026-09-23).
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -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),
|
||||
@@ -0,0 +1,13 @@
|
||||
"""`character` — bodies, garments and GLB handling (D-263).
|
||||
|
||||
`character` rather than `garment`: the tooling covers bodies, hair, GLB
|
||||
utilities and garments, and naming it for one of those would leave the others
|
||||
looking misfiled (tooling/DOMAINS.md).
|
||||
|
||||
What lives here is only the part that runs under reach's own Python: the
|
||||
brand-logo stub, the GLB utility-node strip, and the garment-QA harness
|
||||
(its configs in qa_configs/, its method in GARMENT_QA.md). Everything that runs
|
||||
INSIDE Blender is a payload in tooling/scripts/blender/ and runs via
|
||||
`reach blender run` — six files the domain map had assigned here turned out to
|
||||
import bpy and went there instead (T-1290).
|
||||
"""
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
glb_strip_utility_nodes.py
|
||||
Usage: python3 tooling/glb_strip_utility_nodes.py <input.glb> [<output.glb>]
|
||||
glb_strip — remove utility nodes from a GLB's scene graph.
|
||||
Usage: reach character strip-glb <input.glb> [<output.glb>]
|
||||
reach character strip-glb --dir <directory>
|
||||
|
||||
Formerly tooling/glb_strip_utility_nodes.py (T-1290); the GLB rewriting is
|
||||
unchanged and writes the same bytes.
|
||||
|
||||
Removes utility mesh nodes from a GLB's GLTF scene graph.
|
||||
A "utility node" is any mesh node where:
|
||||
@@ -22,10 +25,12 @@ If no output path is given, overwrites the input file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
GLB_MAGIC = 0x46546C67
|
||||
CHUNK_JSON = 0x4E4F534A
|
||||
@@ -118,10 +123,16 @@ def strip_utility_nodes(gltf: dict) -> tuple[int, list[str]]:
|
||||
if not utility_mesh_indices:
|
||||
return 0, []
|
||||
|
||||
# Find node indices that reference utility meshes
|
||||
# Only nodes still LINKED into the graph count. Stripping leaves nodes in
|
||||
# place as orphans (see below), so without this a second run re-reported the
|
||||
# same nodes as stripped and rewrote an unchanged file (T-1290).
|
||||
linked = {n for scene in scenes for n in scene.get('nodes', [])}
|
||||
linked.update(c for node in nodes for c in node.get('children', []))
|
||||
|
||||
# Find linked node indices that reference utility meshes
|
||||
utility_node_indices = {
|
||||
i for i, n in enumerate(nodes)
|
||||
if n.get('mesh') in utility_mesh_indices
|
||||
if n.get('mesh') in utility_mesh_indices and i in linked
|
||||
}
|
||||
|
||||
if not utility_node_indices:
|
||||
@@ -147,53 +158,38 @@ def strip_utility_nodes(gltf: dict) -> tuple[int, list[str]]:
|
||||
return len(utility_node_indices), removed_names
|
||||
|
||||
|
||||
def process_file(input_path: str, output_path: str):
|
||||
print(f"Processing: {input_path}")
|
||||
def process_file(input_path: str, output_path: str) -> dict:
|
||||
console.event(f"Processing: {input_path}")
|
||||
gltf, bin_data = read_glb(input_path)
|
||||
|
||||
count, names = strip_utility_nodes(gltf)
|
||||
|
||||
if count == 0:
|
||||
print(" No utility nodes found — file unchanged")
|
||||
console.event("No utility nodes found — file unchanged")
|
||||
if output_path != input_path:
|
||||
import shutil
|
||||
shutil.copy2(input_path, output_path)
|
||||
return
|
||||
return {"file": output_path, "stripped": 0, "names": []}
|
||||
|
||||
print(f" Stripped {count} utility node(s): {names}")
|
||||
write_glb(gltf, bin_data, output_path)
|
||||
print(f" Written: {output_path} ({os.path.getsize(output_path):,} bytes)")
|
||||
console.event(f"Stripped {count} utility node(s): {names}")
|
||||
console.event(f"Written: {output_path} ({os.path.getsize(output_path):,} bytes)")
|
||||
return {"file": output_path, "stripped": count, "names": names}
|
||||
|
||||
|
||||
def process_directory(input_dir: str):
|
||||
"""Process all .glb files in a directory tree."""
|
||||
def process_directory(input_dir: str) -> dict:
|
||||
"""Process all .glb files in a directory tree, in place."""
|
||||
total = 0
|
||||
for root, dirs, files in os.walk(input_dir):
|
||||
for fname in files:
|
||||
if fname.lower().endswith('.glb'):
|
||||
changed = []
|
||||
for root, _dirs, files in os.walk(input_dir):
|
||||
for fname in sorted(files):
|
||||
if fname.lower().endswith(".glb"):
|
||||
fpath = os.path.join(root, fname)
|
||||
gltf, bin_data = read_glb(fpath)
|
||||
count, names = strip_utility_nodes(gltf)
|
||||
if count > 0:
|
||||
write_glb(gltf, bin_data, fpath)
|
||||
print(f" {os.path.relpath(fpath, input_dir)}: stripped {count} nodes {names}")
|
||||
rel = os.path.relpath(fpath, input_dir)
|
||||
console.event(f"{rel}: stripped {count} nodes {names}")
|
||||
changed.append({"file": rel, "stripped": count, "names": names})
|
||||
total += count
|
||||
print(f"\nTotal utility nodes stripped: {total}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print("Usage: python3 glb_strip_utility_nodes.py <input.glb> [<output.glb>]")
|
||||
print(" python3 glb_strip_utility_nodes.py --dir <directory>")
|
||||
sys.exit(1)
|
||||
|
||||
if args[0] == '--dir':
|
||||
if len(args) < 2:
|
||||
print("ERROR: --dir requires a directory path")
|
||||
sys.exit(1)
|
||||
process_directory(args[1])
|
||||
else:
|
||||
input_path = args[0]
|
||||
output_path = args[1] if len(args) > 1 else args[0]
|
||||
process_file(input_path, output_path)
|
||||
return {"dir": input_dir, "stripped": total, "files": changed}
|
||||
@@ -11,17 +11,21 @@ per the feasibility §4 pipeline; this script gives the engine a real decal to
|
||||
render now, and doubles as the deterministic fallback generator.
|
||||
|
||||
Run:
|
||||
python3 tooling/garment-fit/make_logo.py <text> <out_png> [--size 256]
|
||||
reach character logo <text> <out_png> [--size 256]
|
||||
|
||||
Example (the canonical Braemar fiber co-op, wiki/corporations/thrds.md — always
|
||||
lowercase):
|
||||
python3 tooling/garment-fit/make_logo.py thrds \
|
||||
client/assets/characters/logos/thrds.png
|
||||
reach character logo thrds client/assets/characters/logos/thrds.png
|
||||
|
||||
Formerly tooling/garment-fit/make_logo.py (T-1290); the drawing is unchanged
|
||||
and renders the same PNG bytes.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from tooling.core import console
|
||||
|
||||
FONT_CANDIDATES = [
|
||||
"/usr/share/fonts/fira-code/FiraCode-Bold.ttf",
|
||||
"/usr/share/fonts/adwaita-mono-fonts/AdwaitaMono-Bold.ttf",
|
||||
@@ -65,18 +69,8 @@ def make_logo(text, out_png, size=256):
|
||||
fill=(255, 255, 255, 255),
|
||||
)
|
||||
|
||||
os.makedirs(os.path.dirname(out_png), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_png)), exist_ok=True)
|
||||
img.save(out_png)
|
||||
print(f"wrote {out_png} ({size}x{size}, font={font_name}, glyph_px={px})")
|
||||
console.event(f"wrote {out_png} ({size}x{size}, font={font_name}, glyph_px={px})")
|
||||
return {"file": out_png, "size": size, "font": font_name, "glyph_px": px}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: make_logo.py <text> <out_png> [--size N]")
|
||||
sys.exit(1)
|
||||
text = sys.argv[1]
|
||||
out = sys.argv[2]
|
||||
size = 256
|
||||
if "--size" in sys.argv:
|
||||
size = int(sys.argv[sys.argv.index("--size") + 1])
|
||||
make_logo(text, out, size)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Drive the chromakey garment-clipping QA harness (T-1089) — capture, then analyze.
|
||||
|
||||
Step 1 launches Godot on the client project with the capture scene, handing it
|
||||
the config through GARMENT_QA_CONFIG. Step 2 runs the pixel analyzer over the
|
||||
PNGs the scene wrote. See GARMENT_QA.md beside this file for what the two
|
||||
passes measure.
|
||||
|
||||
Formerly tooling/garment-qa/run-garment-qa, a bash script (T-1290). Rewritten
|
||||
per D-263's guarded-exec rule: the DECISIONS — which config, which Godot,
|
||||
whether a virtual display is needed — are `capture_plan()`, pure and testable
|
||||
without launching anything; only the launch goes through core/process. The
|
||||
bash exit codes are kept: 2 for a missing config, 3 for no Godot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console, process
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
CONFIG_DIR = Path(__file__).resolve().parent / "qa_configs"
|
||||
DEFAULT_CONFIG = "peasant"
|
||||
SCENE = "res://tools/garment_qa/chromakey_scene.tscn"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapturePlan:
|
||||
config: Path
|
||||
argv: list[str]
|
||||
env_config: str # the value for GARMENT_QA_CONFIG
|
||||
|
||||
|
||||
def resolve_config(name_or_path: str | None) -> Path:
|
||||
"""A config name (`peasant`) resolves inside qa_configs/; anything else is a path."""
|
||||
given = name_or_path or DEFAULT_CONFIG
|
||||
if "/" not in given and not given.endswith(".json"):
|
||||
path = CONFIG_DIR / f"{given}.json"
|
||||
else:
|
||||
path = Path(given)
|
||||
if not path.is_file():
|
||||
known = ", ".join(sorted(p.stem for p in CONFIG_DIR.glob("*.json")))
|
||||
raise ReachError(
|
||||
f"garment-qa config not found: {path}",
|
||||
fix=f"pass a config path, or one of: {known}",
|
||||
exit_code=2,
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def resolve_godot(env: dict[str, str], home: Path, which=shutil.which) -> str:
|
||||
"""$GODOT, else ~/bin/godot4, else godot4/godot on PATH — the bash script's order."""
|
||||
candidate = env.get("GODOT") or str(home / "bin" / "godot4")
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
found = which("godot4") or which("godot")
|
||||
if found:
|
||||
return found
|
||||
raise ReachError(
|
||||
"godot binary not found",
|
||||
fix="set GODOT=/path/to/godot4, or install it: make setup-godot",
|
||||
exit_code=3,
|
||||
)
|
||||
|
||||
|
||||
def capture_plan(config_arg: str | None, env: dict[str, str], home: Path, which=shutil.which) -> CapturePlan:
|
||||
"""What the capture step would run — decided, not performed."""
|
||||
cfg = resolve_config(config_arg)
|
||||
godot = resolve_godot(env, home, which)
|
||||
argv = [godot, "--path", str(config.repo_root() / "client"), "--rendering-driver", "opengl3", SCENE]
|
||||
# The capture needs a display. Headless (no $DISPLAY) runs under a virtual one.
|
||||
if not env.get("DISPLAY"):
|
||||
argv = ["xvfb-run", "-a", *argv]
|
||||
return CapturePlan(config=cfg, argv=argv, env_config=str(cfg.resolve()))
|
||||
|
||||
|
||||
def capture(plan: CapturePlan) -> None:
|
||||
"""Perform the capture step. Godot's own output streams straight through."""
|
||||
console.event(f"config = {plan.config}")
|
||||
console.event(f"godot = {plan.argv[2] if plan.argv[0] == 'xvfb-run' else plan.argv[0]}")
|
||||
env = {**os.environ, "GARMENT_QA_CONFIG": plan.env_config}
|
||||
process.run(
|
||||
plan.argv,
|
||||
env=env,
|
||||
capture=False,
|
||||
missing_fix="install xvfb-run (it provides the virtual display a headless capture needs), "
|
||||
"or run from a session with $DISPLAY set",
|
||||
)
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Chromakey garment-clipping analyzer (T-1089, two-pass).
|
||||
|
||||
The capture scene writes two PNGs per view at the identical (paused) animation frame:
|
||||
@@ -21,19 +20,20 @@ recoloured lime and clip_through filled red (+ red box per clip blob). Results l
|
||||
<out_dir>/report.json plus a one-screen summary on stdout.
|
||||
|
||||
Reads the same JSON config as the capture scene (via --config) to locate out_dir,
|
||||
or takes --dir directly.
|
||||
or takes --dir directly. Formerly tooling/garment-qa/analyze_captures.py (T-1290);
|
||||
the analysis is unchanged and produces the same report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
# Key-colour gate (pass A). Pure magenta is (255, 0, 255); the blue channel is the
|
||||
# decisive discriminator — skin/garment texture is never simultaneously high-red,
|
||||
# low-green AND high-blue, so this never fires on legitimate body or cloth pixels.
|
||||
@@ -209,62 +209,54 @@ def summarize(out_dir: Path, min_pixels: int, results: list[dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def print_summary(report: dict) -> None:
|
||||
print("=" * 74)
|
||||
print("garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)")
|
||||
print(f" out_dir : {report['out_dir']}")
|
||||
print(f" min clip blob pixels : {report['min_component_pixels']}")
|
||||
print(f" captures : {report['total_captures']}")
|
||||
print(f" CLIP-THROUGH failures: {report['clip_through_failures']}")
|
||||
print("-" * 74)
|
||||
hdr = f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}"
|
||||
print(hdr)
|
||||
def summary_lines(report: dict) -> list[str]:
|
||||
"""The human summary table — the command's stdout."""
|
||||
lines = [
|
||||
"=" * 74,
|
||||
"garment-qa chromakey analysis (two-pass: clip_through gates, exposed_skin info)",
|
||||
f" out_dir : {report['out_dir']}",
|
||||
f" min clip blob pixels : {report['min_component_pixels']}",
|
||||
f" captures : {report['total_captures']}",
|
||||
f" CLIP-THROUGH failures: {report['clip_through_failures']}",
|
||||
"-" * 74,
|
||||
f" {'group (body__clip)':<26}{'caps':>6}{'clipfail':>10}{'worstClip':>11}{'worstExp':>10}",
|
||||
]
|
||||
for group, g in sorted(report["by_group"].items()):
|
||||
print(
|
||||
lines.append(
|
||||
f" {group:<26}{g['captures']:>6}{g['clip_failures']:>10}"
|
||||
f"{g['worst_clip']:>11}{g['worst_exposed']:>10}"
|
||||
)
|
||||
print("=" * 74)
|
||||
lines.append("=" * 74)
|
||||
return lines
|
||||
|
||||
|
||||
def resolve_out_dir(args: argparse.Namespace) -> Path:
|
||||
if args.dir:
|
||||
return Path(args.dir)
|
||||
if args.config:
|
||||
cfg = json.loads(Path(args.config).read_text())
|
||||
def resolve_out_dir(config_path: str | None, dir_path: str | None) -> Path:
|
||||
"""The capture directory: --dir wins, else the config's out_dir."""
|
||||
if dir_path:
|
||||
return Path(dir_path)
|
||||
if config_path:
|
||||
cfg = json.loads(Path(config_path).read_text())
|
||||
out = cfg.get("out_dir")
|
||||
if not out:
|
||||
sys.exit("analyze_captures: config has no 'out_dir'")
|
||||
raise ReachError(f"{config_path} has no 'out_dir'", fix="add out_dir to the config, or pass --dir")
|
||||
return Path(out)
|
||||
sys.exit("analyze_captures: pass --dir or --config")
|
||||
raise ReachError("no capture directory given", fix="pass --dir <captures> or --config <config.json>")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Chromakey garment-clipping analyzer")
|
||||
parser.add_argument("--config", help="capture config JSON (reads out_dir from it)")
|
||||
parser.add_argument("--dir", help="directory of capture PNGs (overrides --config out_dir)")
|
||||
parser.add_argument(
|
||||
"--min-pixels",
|
||||
type=int,
|
||||
default=8,
|
||||
help="largest connected clip-through blob that counts as a failure (default 8)",
|
||||
)
|
||||
parser.add_argument("--report", help="report JSON path (default: <out_dir>/report.json)")
|
||||
args = parser.parse_args()
|
||||
|
||||
out_dir = resolve_out_dir(args)
|
||||
def run(
|
||||
config_path: str | None = None,
|
||||
dir_path: str | None = None,
|
||||
min_pixels: int = 8,
|
||||
report_path: str | None = None,
|
||||
) -> tuple[dict, Path]:
|
||||
"""Analyze a capture directory; writes report.json and returns (report, path)."""
|
||||
out_dir = resolve_out_dir(config_path, dir_path)
|
||||
if not out_dir.is_dir():
|
||||
sys.exit(f"analyze_captures: not a directory: {out_dir}")
|
||||
|
||||
report = analyze_dir(out_dir, args.min_pixels)
|
||||
report_path = Path(args.report) if args.report else out_dir / "report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2))
|
||||
print_summary(report)
|
||||
print(f" report : {report_path}")
|
||||
if report["clip_through_failures"]:
|
||||
print(f" highlighted frames : {out_dir / 'failures'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise ReachError(
|
||||
f"not a directory: {out_dir}",
|
||||
fix="run the capture first (reach character qa <config>), or pass --dir",
|
||||
)
|
||||
report = analyze_dir(out_dir, min_pixels)
|
||||
path = Path(report_path) if report_path else out_dir / "report.json"
|
||||
path.write_text(json.dumps(report, indent=2))
|
||||
return report, path
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -71,6 +71,10 @@ DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"tooling.domains.assets.router:app",
|
||||
"Asset generators — Stable Audio, Gemini images, Trellis 3D",
|
||||
),
|
||||
"character": (
|
||||
"tooling.domains.character.router:app",
|
||||
"Bodies, garments, GLBs — logos, the GLB strip, garment QA",
|
||||
),
|
||||
"godot": (
|
||||
"tooling.domains.godot.router:app",
|
||||
"Does the client parse, and does it parse cold",
|
||||
|
||||
@@ -71,7 +71,7 @@ default_tints (sole near-black, upper work brown, laces dark).
|
||||
|
||||
Usage (boots_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_boots.py -- \
|
||||
tooling/scripts/blender/blender_author_boots.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/boots_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \
|
||||
|
||||
@@ -29,7 +29,7 @@ channel is still authored (costs nothing; enables future brand variants).
|
||||
Run (per-body only — this garment ships the per-body route):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_buttondown.py -- \
|
||||
tooling/scripts/blender/blender_author_buttondown.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/buttondown_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.009]
|
||||
|
||||
@@ -39,7 +39,7 @@ modelled — no 3D pocket geometry (texture carries identity).
|
||||
|
||||
Usage (cargo_pants reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_cargo_pants.py -- \
|
||||
tooling/scripts/blender/blender_author_cargo_pants.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/cargo_pants \
|
||||
[--bodies average_m,child,...] [--offset 0.013] [--hem-frac 1.0] \
|
||||
|
||||
@@ -52,7 +52,7 @@ bodies. Per-body mode only (offset shells author per body, Q-060).
|
||||
|
||||
Usage (jeans_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_denim_pants.py -- \
|
||||
tooling/scripts/blender/blender_author_denim_pants.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/jeans_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 1.0] \
|
||||
|
||||
@@ -28,7 +28,7 @@ Hoodie deltas over the base t-shirt shell:
|
||||
PER-BODY ONLY (Q-060: offset-shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_hoodie.py -- \
|
||||
tooling/scripts/blender/blender_author_hoodie.py -- \
|
||||
client/assets/characters/bodies client/assets/characters/clothing/hoodie_modern \
|
||||
[--bodies a,b,c] [--offset M]
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ PER-BODY ONLY: offset-shell garments are authored per body (Q-060 route
|
||||
guidance) — there is no single-reference mode here.
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_jacket_shell.py -- \
|
||||
tooling/scripts/blender/blender_author_jacket_shell.py -- \
|
||||
<bodies_root> <out_dir> \
|
||||
[--bodies average_m,child,...] [--offset 0.018] [--wrist-frac 0.85]
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ boundaries give the waist opening and ankle hems for free. Per-body mode only
|
||||
|
||||
Usage (joggers_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_joggers.py -- \
|
||||
tooling/scripts/blender/blender_author_joggers.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/joggers_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.014] [--mid-offset 0.010] \
|
||||
|
||||
@@ -30,7 +30,7 @@ offset-shell garments author per body; weights inherited by construction).
|
||||
|
||||
Usage:
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_lower_shell.py -- \
|
||||
tooling/scripts/blender/blender_author_lower_shell.py -- \
|
||||
<bodies_root> <out_dir> \
|
||||
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
|
||||
[--waistband-frac 1.0] [--cuff-frac 0.0] \
|
||||
|
||||
@@ -38,7 +38,7 @@ Painted albedo details (flat, toon-friendly; identity lives in the texture):
|
||||
Per-body only (this garment ships per-body per the Q-060 route guidance):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_coverall.py -- \
|
||||
tooling/scripts/blender/blender_author_offset_coverall.py -- \
|
||||
<bodies_root> <out_dir> [--bodies a,b,c] [--offset 0.012]
|
||||
|
||||
Writes per body:
|
||||
|
||||
@@ -34,7 +34,7 @@ SINGLE-REFERENCE (default) — author on one body (average_m); G1
|
||||
derived/hand-authored garments that share one UV layout + one mask.
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell.py -- \
|
||||
tooling/scripts/blender/blender_author_offset_shell.py -- \
|
||||
<bodies_dir>/average_m <out_dir> [--offset 0.020] [--sleeve-frac 0.40]
|
||||
|
||||
Writes:
|
||||
@@ -52,7 +52,7 @@ from each body's OWN bone landmarks using the same proportional ratios
|
||||
regions stay consistent across bodies.
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell.py -- \
|
||||
tooling/scripts/blender/blender_author_offset_shell.py -- \
|
||||
<bodies_dir> <out_dir> --per-body \
|
||||
[--bodies average_m,child,...] [--offset 0.012] [--sleeve-frac 0.40]
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Modes mirror the base script:
|
||||
|
||||
PER-BODY (preferred for offset shells, Q-060):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
|
||||
tooling/scripts/blender/blender_author_offset_shell_legs.py -- \
|
||||
<bodies_dir> <out_dir> --per-body \
|
||||
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
|
||||
|
||||
@@ -47,7 +47,7 @@ PER-BODY (preferred for offset shells, Q-060):
|
||||
|
||||
SINGLE-REFERENCE:
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_offset_shell_legs.py -- \
|
||||
tooling/scripts/blender/blender_author_offset_shell_legs.py -- \
|
||||
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
|
||||
|
||||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
|
||||
|
||||
@@ -26,7 +26,7 @@ What it adds over the base t-shirt shell:
|
||||
Per-body only (this family is the poster child for the Q-060 per-body route):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_outerwear.py -- \
|
||||
tooling/scripts/blender/blender_author_outerwear.py -- \
|
||||
<bodies_root> <out_dir> \
|
||||
[--bodies a,b,c] [--offset 0.022] [--cuff-keep 0.88]
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ top, combining the proven practices of the wave-1 family:
|
||||
Per-body only (Q-060: offset shells author per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_parka.py -- \
|
||||
tooling/scripts/blender/blender_author_parka.py -- \
|
||||
client/assets/characters/bodies client/assets/characters/clothing/parka_thrds \
|
||||
[--bodies a,b,c] [--offset 0.026] [--hem-frac 0.15]
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ only (offset shells author per body, Q-060).
|
||||
|
||||
Usage (shoes_formal reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_shoes_formal.py -- \
|
||||
tooling/scripts/blender/blender_author_shoes_formal.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/shoes_formal \
|
||||
[--bodies average_m,child,...] [--offset 0.006] [--sole-mm 0.008] \
|
||||
|
||||
@@ -53,7 +53,7 @@ torso/leg companion provides, as reusable parameters:
|
||||
|
||||
Usage (per-body only — offset shells author per body, Q-060):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_slides.py -- \
|
||||
tooling/scripts/blender/blender_author_slides.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/slides \
|
||||
[--bodies average_m,child,...] [--offset 0.005] \
|
||||
|
||||
@@ -57,7 +57,7 @@ symmetric, so overlapping texels agree by construction.
|
||||
|
||||
Usage (sneakers_modern reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_sneakers.py -- \
|
||||
tooling/scripts/blender/blender_author_sneakers.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/sneakers_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.009] [--sole-drop 0.015] \
|
||||
|
||||
@@ -51,7 +51,7 @@ as reusable parameters:
|
||||
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_sweater.py -- \
|
||||
tooling/scripts/blender/blender_author_sweater.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/sweater_modern \
|
||||
[--bodies average_m,child,...] [--offset 0.014] [--hem-drop 0.30] \
|
||||
|
||||
@@ -41,7 +41,7 @@ proportional-ratio philosophy as base.derive_thresholds. Per-body mode only
|
||||
|
||||
Usage (swim_trunks reference invocation):
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_swim_trunks.py -- \
|
||||
tooling/scripts/blender/blender_author_swim_trunks.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/swim_trunks \
|
||||
[--bodies average_m,child,...] [--offset 0.012] [--hem-frac 0.60] \
|
||||
|
||||
@@ -46,7 +46,7 @@ logo_uv TEXCOORD_1 layer ships for channel consistency (not logo-capable).
|
||||
Per-body mode only (offset shells author per body, Q-060):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_swimsuit.py -- \
|
||||
tooling/scripts/blender/blender_author_swimsuit.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/swimsuit_onepiece \
|
||||
[--bodies average_m,child,...] [--offset 0.012]
|
||||
|
||||
@@ -42,7 +42,7 @@ texture carries identity, flat toon-friendly).
|
||||
Run (per-body only — offset shells author per body, Q-060):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_tank_top.py -- \
|
||||
tooling/scripts/blender/blender_author_tank_top.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/tank_top \
|
||||
[--bodies average_m,child,...] [--offset 0.012] \
|
||||
|
||||
@@ -40,7 +40,7 @@ kit needs beyond that script is delivered as reusable parameters, not hacks:
|
||||
PER-BODY ONLY (Q-060: offset shells are authored per body, never SD-fit):
|
||||
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_author_track_jacket.py -- \
|
||||
tooling/scripts/blender/blender_author_track_jacket.py -- \
|
||||
client/assets/characters/bodies \
|
||||
client/assets/characters/clothing/track_jacket \
|
||||
[--bodies average_m,child,...] [--offset 0.018] [--wrist-keep 0.90]
|
||||
|
||||
@@ -26,7 +26,7 @@ Per body type:
|
||||
|
||||
Run:
|
||||
tooling/blender --background --python \
|
||||
tooling/garment-fit/blender_batch_fit_skinned.py -- \
|
||||
tooling/scripts/blender/blender_batch_fit_skinned.py -- \
|
||||
<reference_glb> <bodies_dir> <output_dir> \
|
||||
[--only average_m,average_f,...] [--solidify 0.0] [--self-check]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ CLOTHING_THICKNESS = 0.025 # 25mm outward solidify
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: blender --background --python convert_outfit.py -- <input.gltf> <output.glb>")
|
||||
print("Usage: reach blender run blender_convert_outfit <input.gltf> <output.glb>")
|
||||
sys.exit(1)
|
||||
args = argv[argv.index("--") + 1:]
|
||||
input_path = args[0]
|
||||
@@ -5,7 +5,7 @@ import bpy
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: blender --background --python inspect_glb.py -- <input.glb>")
|
||||
print("Usage: reach blender run blender_inspect_glb <input.glb>")
|
||||
sys.exit(1)
|
||||
args = argv[argv.index("--") + 1:]
|
||||
input_path = args[0]
|
||||
@@ -5,7 +5,7 @@ import bpy
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: blender --background --python test_quaternius_raw.py -- <output.glb>")
|
||||
print("Usage: reach blender run blender_quaternius_raw <output.glb>")
|
||||
sys.exit(1)
|
||||
output = argv[argv.index("--") + 1]
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user