diff --git a/.claude/skills/glb-gen/SKILL.md b/.claude/skills/glb-gen/SKILL.md index 27a1c4e75..a36de3a95 100644 --- a/.claude/skills/glb-gen/SKILL.md +++ b/.claude/skills/glb-gen/SKILL.md @@ -18,7 +18,7 @@ Convert approved concept images to game-ready .glb models. | Service | Check | |---------|-------| | Trellis | `tooling/db/trellis_connector.py health` | -| Blender | `tooling/blender --version` | +| Blender | `reach blender which` | Trellis runs on tower-of-joy and may be switched off. Check before batching. @@ -127,7 +127,7 @@ Normalize scale, center, generate recolor mask, adjust materials: Or directly: ```bash -tooling/blender --background \ +reach blender run \ --python .claude/skills/glb-gen/scripts/postprocess_glb.py \ -- input.glb output.glb [--target-width N] [--color-threshold N] ``` diff --git a/.claude/skills/glb-gen/scripts/postprocess b/.claude/skills/glb-gen/scripts/postprocess index 235818d30..b020cfc80 100755 --- a/.claude/skills/glb-gen/scripts/postprocess +++ b/.claude/skills/glb-gen/scripts/postprocess @@ -77,8 +77,7 @@ LOG="${OUTPUT_ABS%.glb}_blender.log" # (temporarily disabling -e so a non-zero Blender exit doesn't abort before # we can inspect it), then filter for the console summary. set +e -"$PROJECT_ROOT/tooling/blender" --background \ - --python "$BLENDER_SCRIPT" \ +reach blender run "$BLENDER_SCRIPT" \ -- "$INPUT_ABS" "$OUTPUT_ABS" "$@" > "$LOG" 2>&1 BLENDER_EXIT=$? set -e diff --git a/.claude/skills/glb-gen/scripts/postprocess_glb.py b/.claude/skills/glb-gen/scripts/postprocess_glb.py index a0c6a5a7c..03b7d20ee 100644 --- a/.claude/skills/glb-gen/scripts/postprocess_glb.py +++ b/.claude/skills/glb-gen/scripts/postprocess_glb.py @@ -3,7 +3,7 @@ GLB post-processor for The Settled Reach asset pipeline. Run via Blender headless: - tooling/blender --background --python postprocess_glb.py -- input.glb output.glb [options] + reach blender run postprocess_glb.py -- input.glb output.glb [options] Operations: 1. Normalize scale to fit a target bounding box (default 1x1x1 world units) diff --git a/.pql/changelog/ticket_deps/2026-09.sql b/.pql/changelog/ticket_deps/2026-09.sql index 24afca96f..41bb48829 100644 --- a/.pql/changelog/ticket_deps/2026-09.sql +++ b/.pql/changelog/ticket_deps/2026-09.sql @@ -1,2 +1,6 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZCVZXH6M00C57EXMZD0SG', '06G65T2N1WAT491WY8SV736J3G', '2026-09-02 16:19:43.328', '2026-09-02 16:19:43.328', NULL, '5ce8ab5d4936aef2df0ca4b5153e2d40', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at; INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', '06G65TC359QGCVBEPXHJ4N0MRG', '2026-09-02 16:19:43.349', '2026-09-02 16:19:43.349', NULL, '66d337f0362c0ecf64a4c4065a97d485', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at; +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZCVZXH6M00C57EXMZD0SG', '06G65T2N1WAT491WY8SV736J3G', '2026-09-02 16:19:43.328', '2026-09-02 16:20:33.246', '2026-09-02 16:20:33.246', 'd4db63f3638217bc23d27b974407ff30', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at; +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65T2N1WAT491WY8SV736J3G', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-09-02 16:20:33.265', '2026-09-02 16:20:33.265', NULL, '8148b701e111a64a6921913e25cab437', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at; +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65NSJ4RG6SJ12TQE78S6TKR', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-09-02 16:20:33.281', '2026-09-02 16:20:33.281', NULL, 'e41be4d7e61660697a2959307ebe1832', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at; +INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', '06G5FZCVZXH6M00C57EXMZD0SG', '2026-09-02 16:20:33.298', '2026-09-02 16:20:33.298', NULL, 'cc5a897d45144c728fd035766d432373', 2) ON CONFLICT(blocker_record_id, blocked_record_id) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_deps.updated_at; diff --git a/.pql/changelog/ticket_history/2026-09.sql b/.pql/changelog/ticket_history/2026-09.sql index 733e634b7..ed43495e7 100644 --- a/.pql/changelog/ticket_history/2026-09.sql +++ b/.pql/changelog/ticket_history/2026-09.sql @@ -243,3 +243,49 @@ 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.', NULL, '2026-09-02 16:19:29', '2026-09-02 16:19:29.675', '2026-09-02 16:19:29.675', NULL, '24b375349147ca97fec304c70086e503', 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 ('06G5FZDN9YBP0MZ021N3TJCFXM', 'description', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates. + +CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory.', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates. + +CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory. + +Done. 35 payloads carved out to `tooling/scripts/blender/`, fronted by +`reach blender`, and the exclusion now defends itself. + +COUNT CONFIRMED at 35, matching the epic and the 2026-08-31 correction: 13 +top-level `blender_*.py` + 22 in `garment-fit/`. Verified by import rather than +by filename — 33 of the 35 import `bpy`/`bmesh` directly, and the two that do +not (`blender_process_bodies`, `blender_rebuild_forks`) are still payloads, run +via `--python` per their own usage lines. `garment-fit/make_logo.py` is the one +genuine non-payload (PIL, no bpy) and stays put for the character port (T-1290), +which will find `garment-fit/` holding exactly one file. + +THE WRAPPER WAS RETIRED, NOT KEPT. The ticket said keep `tooling/blender` as +bash and front it with reach; that would have put the install-resolution logic +in two places, which is the duplication T-1286 had just finished collapsing +three copies of. Instead `domains/blender/service.py` owns the decisions — +`resolve_blender()` (native beats flatpak, ordering preserved from the +original), `resolve_payload()`, `absolutise()` — and only `run_payload()` +performs. `test_blender.py` pins all of them without launching Blender, which +matters more here than for the environment scripts: the thing being launched is +a 200 MB GUI application that writes GLBs. + +`run` takes EITHER a registered payload name OR a path to any script, because +the wrapper served both: our 35 by name, and one-off scripts by path (the +spikes and `.claude/skills/glb-gen/` do exactly that). Unknown names enumerate +all 35 and exit 2. + +THE EXCLUSION IS NOW SELF-DEFENDING (`check_carve_out_stays_carved`), because +the ticket is right that it reads like an oversight. Three arms, all +mutation-proved: + - `scripts` added to PACKAGE_ROOTS -> fails, naming the reason + - the payload directory emptied -> fails ("an empty exclusion proves nothing") + - `tooling/scripts/__init__.py` created -> fails (that would make the payloads + importable, which is the coupling the carve-out exists to prevent) + +Callers repointed: 14 spike/skill scripts, `.claude/skills/glb-gen/scripts/postprocess`, +and 3 paths in `docs/architecture/character-asset-organization.md`. + +Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks), +`test_lazy_domains` now sees 10 domains.', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.421', '2026-09-02 18:55:28.421', NULL, '1352ceb77da9a63b23ee98f26fa76b1e', 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 ('06G5FZDN9YBP0MZ021N3TJCFXM', 'status', 'backlog', 'done', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.441', '2026-09-02 18:55:28.441', NULL, 'effde7946e61e7d9e8cdbaf14324676d', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-09.sql b/.pql/changelog/tickets/2026-09.sql index ccfb3310d..bb5c40f9e 100644 --- a/.pql/changelog/tickets/2026-09.sql +++ b/.pql/changelog/tickets/2026-09.sql @@ -356,3 +356,89 @@ 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.', 'backlog', 'medium', NULL, 'tooling', 'D-263', '2026-09-02 16:17:45.770', '2026-09-02 16:19:29.675', NULL, '8b7c1faf88c3c056d1d8690ec23e0d4b', 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 ('06G5FZDN9YBP0MZ021N3TJCFXM', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The Blender carve-out — payloads that must never import the package', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates. + +CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory. + +Done. 35 payloads carved out to `tooling/scripts/blender/`, fronted by +`reach blender`, and the exclusion now defends itself. + +COUNT CONFIRMED at 35, matching the epic and the 2026-08-31 correction: 13 +top-level `blender_*.py` + 22 in `garment-fit/`. Verified by import rather than +by filename — 33 of the 35 import `bpy`/`bmesh` directly, and the two that do +not (`blender_process_bodies`, `blender_rebuild_forks`) are still payloads, run +via `--python` per their own usage lines. `garment-fit/make_logo.py` is the one +genuine non-payload (PIL, no bpy) and stays put for the character port (T-1290), +which will find `garment-fit/` holding exactly one file. + +THE WRAPPER WAS RETIRED, NOT KEPT. The ticket said keep `tooling/blender` as +bash and front it with reach; that would have put the install-resolution logic +in two places, which is the duplication T-1286 had just finished collapsing +three copies of. Instead `domains/blender/service.py` owns the decisions — +`resolve_blender()` (native beats flatpak, ordering preserved from the +original), `resolve_payload()`, `absolutise()` — and only `run_payload()` +performs. `test_blender.py` pins all of them without launching Blender, which +matters more here than for the environment scripts: the thing being launched is +a 200 MB GUI application that writes GLBs. + +`run` takes EITHER a registered payload name OR a path to any script, because +the wrapper served both: our 35 by name, and one-off scripts by path (the +spikes and `.claude/skills/glb-gen/` do exactly that). Unknown names enumerate +all 35 and exit 2. + +THE EXCLUSION IS NOW SELF-DEFENDING (`check_carve_out_stays_carved`), because +the ticket is right that it reads like an oversight. Three arms, all +mutation-proved: + - `scripts` added to PACKAGE_ROOTS -> fails, naming the reason + - the payload directory emptied -> fails ("an empty exclusion proves nothing") + - `tooling/scripts/__init__.py` created -> fails (that would make the payloads + importable, which is the coupling the carve-out exists to prevent) + +Callers repointed: 14 spike/skill scripts, `.claude/skills/glb-gen/scripts/postprocess`, +and 3 paths in `docs/architecture/character-asset-organization.md`. + +Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks), +`test_lazy_domains` now sees 10 domains.', 'backlog', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-09-02 18:55:28.420', NULL, '91aecb3a1f9b14628aead43f7517aea9', 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 ('06G5FZDN9YBP0MZ021N3TJCFXM', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'The Blender carve-out — payloads that must never import the package', 'The one legitimate exception to the no-tooling-outside-reach rule, and it needs to be written down as such rather than discovered later as an inconsistency. The blender_*.py files (14 at top level by the survey, not the 35 the epic states — verify the true count against subdirectories before moving) run under Blender''s BUNDLED Python interpreter, which has no access to the repo venv and cannot import tooling.core. They are payloads, not modules. Deliverables: move them to tooling/scripts/blender/ as a clearly-marked payload directory; keep tooling/blender as the bash wrapper that invokes Blender with a payload; front the whole thing with reach so the user-facing form is a normal verb. The payload directory is EXCLUDED from the conformance test''s package scope — those files legitimately use bare print() and cannot carry @command, and holding them to a contract they physically cannot satisfy would either fail the gate forever or force the contract to be weakened for everyone. Record the exclusion in the test with the reason inline, so the next person does not read it as an oversight and ''fix'' it. This carve-out is also one of the two exceptions T-1255 must name in the CLAUDE.md rule; the other is the Rust crates. + +CORRECTED 2026-08-31 by T-1271: the carve-out is 35 files, and the count in this ticket (which said 14 at top level, doubting the epic''s 35) was WRONG. 13 blender_*.py are at top level and 22 more are inside tooling/garment-fit/, which is a Blender payload directory wearing a domain''s name — 22 of its 23 files are blender_author_*. The epic''s figure of 35 was right. Practical consequence: garment-fit/ mostly moves to tooling/scripts/blender/ rather than becoming a character domain, so this ticket and the character port must be sequenced together or they will fight over the same directory. + +Done. 35 payloads carved out to `tooling/scripts/blender/`, fronted by +`reach blender`, and the exclusion now defends itself. + +COUNT CONFIRMED at 35, matching the epic and the 2026-08-31 correction: 13 +top-level `blender_*.py` + 22 in `garment-fit/`. Verified by import rather than +by filename — 33 of the 35 import `bpy`/`bmesh` directly, and the two that do +not (`blender_process_bodies`, `blender_rebuild_forks`) are still payloads, run +via `--python` per their own usage lines. `garment-fit/make_logo.py` is the one +genuine non-payload (PIL, no bpy) and stays put for the character port (T-1290), +which will find `garment-fit/` holding exactly one file. + +THE WRAPPER WAS RETIRED, NOT KEPT. The ticket said keep `tooling/blender` as +bash and front it with reach; that would have put the install-resolution logic +in two places, which is the duplication T-1286 had just finished collapsing +three copies of. Instead `domains/blender/service.py` owns the decisions — +`resolve_blender()` (native beats flatpak, ordering preserved from the +original), `resolve_payload()`, `absolutise()` — and only `run_payload()` +performs. `test_blender.py` pins all of them without launching Blender, which +matters more here than for the environment scripts: the thing being launched is +a 200 MB GUI application that writes GLBs. + +`run` takes EITHER a registered payload name OR a path to any script, because +the wrapper served both: our 35 by name, and one-off scripts by path (the +spikes and `.claude/skills/glb-gen/` do exactly that). Unknown names enumerate +all 35 and exit 2. + +THE EXCLUSION IS NOW SELF-DEFENDING (`check_carve_out_stays_carved`), because +the ticket is right that it reads like an oversight. Three arms, all +mutation-proved: + - `scripts` added to PACKAGE_ROOTS -> fails, naming the reason + - the payload directory emptied -> fails ("an empty exclusion proves nothing") + - `tooling/scripts/__init__.py` created -> fails (that would make the payloads + importable, which is the coupling the carve-out exists to prevent) + +Callers repointed: 14 spike/skill scripts, `.claude/skills/glb-gen/scripts/postprocess`, +and 3 paths in `docs/architecture/character-asset-organization.md`. + +Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks), +`test_lazy_domains` now sees 10 domains.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-09-02 18:55:28.441', NULL, '41fbc2c73d4d33a6f6224cf6e97f7aac', 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 8f47ef977..c2eb12eea 100644 --- a/Makefile +++ b/Makefile @@ -310,6 +310,10 @@ test-tooling: @mkdir -p .cache @$(VENV_PY) tooling/test_jobs.py 2> .cache/test-tooling-jobs.log || \ { echo " FAIL: jobs service — log follows:"; cat .cache/test-tooling-jobs.log; exit 1; } + @echo " [test-tooling] reach blender carve-out decisions (T-1273)..." + @mkdir -p .cache + @$(VENV_PY) tooling/test_blender.py 2> .cache/test-tooling-blender.log || \ + { echo " FAIL: blender decisions — log follows:"; cat .cache/test-tooling-blender.log; exit 1; } @echo " [test-tooling] reach dev environment decisions (T-1286)..." @mkdir -p .cache @$(VENV_PY) tooling/test_environment.py 2> .cache/test-tooling-environment.log || \ diff --git a/docs/architecture/character-asset-organization.md b/docs/architecture/character-asset-organization.md index 97fb14aca..6975e1692 100644 --- a/docs/architecture/character-asset-organization.md +++ b/docs/architecture/character-asset-organization.md @@ -606,7 +606,8 @@ clothing/coveralls_basic/ child.glb # Output: fitted variant ``` -The batch script lives in `tooling/blender/` (not in the client tree). It takes arguments: +The batch script lives in `tooling/scripts/blender/` (not in the client tree), +run as `reach blender run `. It takes arguments: - Input: path to reference GLB - Input: path to the 11 body type meshes (for Surface Deform targets) - Output: directory to write fitted variants @@ -761,7 +762,7 @@ single-channel convention as originally written. ### Authoring: offset-shell from our own body segments The preferred authoring route is **offset-shell** -(`tooling/garment-fit/blender_author_offset_shell.py`, one per-garment companion script per +(`tooling/scripts/blender/blender_author_offset_shell.py`, one per-garment companion script per item, e.g. `blender_author_hoodie.py`, `blender_author_boots.py`): extract the body segment(s) a garment covers, cut neckline/hem/sleeve boundaries via bone-plane thresholds, offset the surface outward along vertex normals, solidify for real cloth thickness. Because the shell @@ -785,7 +786,7 @@ shoe shell -- residual toe hints are accepted for first release (see Catalogue b ### Fitting: Surface-Deform batch route -`tooling/garment-fit/blender_batch_fit_skinned.py` remains the route for garments sharing one +`tooling/scripts/blender/blender_batch_fit_skinned.py` remains the route for garments sharing one reference mesh + mask across all 11 bodies -- Fantasy-pack-derived cuts and hand-authored pieces on a single UV layout (the suit, the uniform). SD-bind to each target body, bake the deformed rest shape, transfer + normalize weights, retarget the armature, export with diff --git a/spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py b/spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py index e900d57d8..9c4e42a68 100644 --- a/spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py +++ b/spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py @@ -17,7 +17,7 @@ Uses robust weight transfer with Laplacian inpainting for unmatched vertices, based on the SIGGRAPH Asia 2023 algorithm (MIT reference implementation). Run via: - tooling/blender --background --python \\ + reach blender run \\ spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py \\ -- body.gltf clothing.glb output.glb diff --git a/spikes/quaternius-aesthetic/scripts/blender/create_body_types.py b/spikes/quaternius-aesthetic/scripts/blender/create_body_types.py index 55e6abe0b..94d6b96c2 100644 --- a/spikes/quaternius-aesthetic/scripts/blender/create_body_types.py +++ b/spikes/quaternius-aesthetic/scripts/blender/create_body_types.py @@ -19,7 +19,7 @@ Original intent: baking to rest pose, and exporting. Run via: - tooling/blender --background --python \\ + reach blender run \\ spikes/quaternius-aesthetic/scripts/blender/create_body_types.py \\ -- """ diff --git a/spikes/quaternius-aesthetic/scripts/blender/create_placeholder_bodies.py b/spikes/quaternius-aesthetic/scripts/blender/create_placeholder_bodies.py index 9f10b9c8b..4247886d7 100644 --- a/spikes/quaternius-aesthetic/scripts/blender/create_placeholder_bodies.py +++ b/spikes/quaternius-aesthetic/scripts/blender/create_placeholder_bodies.py @@ -8,7 +8,7 @@ needed -- the body type pipeline will use hand-authored meshes instead. Preserved as documentation only. Run via: - tooling/blender --background --python \\ + reach blender run \\ spikes/quaternius-aesthetic/scripts/blender/create_placeholder_bodies.py \\ -- """ diff --git a/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py b/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py index e8ce523ce..9cbea6698 100644 --- a/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py +++ b/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py @@ -6,7 +6,7 @@ to Superhero and Teen bodies. Regular gets a straight copy since the outfits were authored for it. Run via: - tooling/blender --background --python \ + reach blender run \ spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py \ -- diff --git a/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py b/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py index fb2d13bd6..c895fff1d 100644 --- a/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py +++ b/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py @@ -39,7 +39,7 @@ Why Surface Deform + weight retarget rather than shape-key tricks: between muscular vs thin, etc.). Run via: - tooling/blender --background --python \\ + reach blender run \\ spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py \\ -- diff --git a/spikes/quaternius-aesthetic/scripts/blender/segment_body.py b/spikes/quaternius-aesthetic/scripts/blender/segment_body.py index d5e3f1f32..215cd7df0 100644 --- a/spikes/quaternius-aesthetic/scripts/blender/segment_body.py +++ b/spikes/quaternius-aesthetic/scripts/blender/segment_body.py @@ -23,7 +23,7 @@ Segments: foot_r — foot_r, ball_r, ball_leaf_r Run via: - tooling/blender --background --python \ + reach blender run \ spikes/quaternius-aesthetic/scripts/blender/segment_body.py \ -- diff --git a/spikes/quaternius-aesthetic/scripts/blender/separate_head.py b/spikes/quaternius-aesthetic/scripts/blender/separate_head.py index 4237e83db..14f143b78 100644 --- a/spikes/quaternius-aesthetic/scripts/blender/separate_head.py +++ b/spikes/quaternius-aesthetic/scripts/blender/separate_head.py @@ -8,7 +8,7 @@ segmentation (including the head as one of those regions). Use segment_body.py for all new work. This file is preserved as documentation. Run via: - tooling/blender --background --python \\ + reach blender run \\ spikes/quaternius-aesthetic/scripts/blender/separate_head.py \\ -- """ diff --git a/spikes/synty-intake/scripts/01_recon_rest_pose.py b/spikes/synty-intake/scripts/01_recon_rest_pose.py index 69a503c6c..325136be7 100644 --- a/spikes/synty-intake/scripts/01_recon_rest_pose.py +++ b/spikes/synty-intake/scripts/01_recon_rest_pose.py @@ -9,7 +9,7 @@ armature.glb + average_m body segments, then prints: - object transforms as-imported (scale factors etc.) Run: - tooling/blender --background --python \ + reach blender run \ spikes/synty-intake/scripts/01_recon_rest_pose.py """ diff --git a/spikes/synty-intake/scripts/02_transplant_garment.py b/spikes/synty-intake/scripts/02_transplant_garment.py index f5cc08fdc..4f3068677 100644 --- a/spikes/synty-intake/scripts/02_transplant_garment.py +++ b/spikes/synty-intake/scripts/02_transplant_garment.py @@ -18,7 +18,7 @@ Alignment transform (documented, measured from bone landmarks): then translate so the pelvis landmarks coincide. Run: - tooling/blender --background --python \ + reach blender run \ spikes/synty-intake/scripts/02_transplant_garment.py """ diff --git a/spikes/synty-intake/scripts/03_recon_bodies.py b/spikes/synty-intake/scripts/03_recon_bodies.py index 72ca71bb4..801842453 100644 --- a/spikes/synty-intake/scripts/03_recon_bodies.py +++ b/spikes/synty-intake/scripts/03_recon_bodies.py @@ -10,7 +10,7 @@ Questions this answers (feeds 04_batch_fit_bodies.py): bake), or is it the shared skeleton everywhere? Run: - tooling/blender --background --python \ + reach blender run \ spikes/synty-intake/scripts/03_recon_bodies.py """ diff --git a/spikes/synty-intake/scripts/04_batch_fit_bodies.py b/spikes/synty-intake/scripts/04_batch_fit_bodies.py index f5898a7cc..83322ba47 100644 --- a/spikes/synty-intake/scripts/04_batch_fit_bodies.py +++ b/spikes/synty-intake/scripts/04_batch_fit_bodies.py @@ -41,7 +41,7 @@ Per target body: (penetration detector). Run: - tooling/blender --background --python \ + reach blender run \ spikes/synty-intake/scripts/04_batch_fit_bodies.py """ diff --git a/spikes/synty-intake/scripts/recon_dump.py b/spikes/synty-intake/scripts/recon_dump.py index 24728548a..7c51ba060 100644 --- a/spikes/synty-intake/scripts/recon_dump.py +++ b/spikes/synty-intake/scripts/recon_dump.py @@ -1,6 +1,6 @@ """Blender recon: dump skeleton/mesh facts for a Sidekick FBX and our armature. -Usage: tooling/blender --background --python recon_dump.py -- +Usage: reach blender run recon_dump.py -- """ import sys diff --git a/tooling/DOMAINS.md b/tooling/DOMAINS.md index 9468273b0..869de7a13 100644 --- a/tooling/DOMAINS.md +++ b/tooling/DOMAINS.md @@ -98,7 +98,7 @@ invented. | `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 | **35** `blender_*.py` (13 top-level + 22 in `garment-fit/`), `blender` wrapper → `tooling/scripts/blender/` | +| `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` | ## Judgment calls, with reasons diff --git a/tooling/blender b/tooling/blender deleted file mode 100755 index 2e3643b19..000000000 --- a/tooling/blender +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Blender wrapper — resolves flatpak/native/brew installs to a single entry point. -# All project scripts should call this instead of 'blender' directly. -# -# Resolves relative paths in --python and -- args to absolute paths so flatpak -# sandboxing doesn't break file resolution. -# -# Usage: -# tooling/blender --background --python script.py -- input.glb output.glb - -# Convert relative paths to absolute -args=() -for arg in "$@"; do - if [[ -e "$arg" ]]; then - args+=("$(realpath "$arg")") - else - args+=("$arg") - fi -done - -if command -v blender &>/dev/null; then - exec blender "${args[@]}" -elif flatpak info org.blender.Blender &>/dev/null 2>&1; then - exec flatpak run org.blender.Blender "${args[@]}" -else - echo "ERROR: Blender not found (checked PATH and flatpak)" >&2 - exit 1 -fi diff --git a/tooling/domains/blender/__init__.py b/tooling/domains/blender/__init__.py new file mode 100644 index 000000000..ce3bb01ae --- /dev/null +++ b/tooling/domains/blender/__init__.py @@ -0,0 +1,16 @@ +"""The Blender carve-out — the one sanctioned exception to the reach rule. + +D-263 says tooling lives in the package and every verb carries `@command`. +`tooling/scripts/blender/` does not, and cannot: those 35 files run under +Blender's BUNDLED Python interpreter, which has no access to the repo venv and +therefore cannot `import tooling.core` at all. They are payloads handed to +another program, not modules this package imports. + +So the split is: the payloads stay dumb and stay out of package scope, and +everything that can be decided in our own interpreter — which Blender to use, +which payload a name refers to, which paths need absolutising — lives here and +is testable. The user-facing form is still a normal verb. + +The conformance test excludes `tooling/scripts/` for this reason, and says so +inline. It is an exception with a stated cause, not an oversight. +""" diff --git a/tooling/domains/blender/router.py b/tooling/domains/blender/router.py new file mode 100644 index 000000000..130e5f29b --- /dev/null +++ b/tooling/domains/blender/router.py @@ -0,0 +1,65 @@ +"""Transport for the `blender` domain — args in, delegate, format out.""" + +from __future__ import annotations + +import typer + +from tooling.core import cli, console +from tooling.core.command import command +from tooling.domains.blender import service + +app = cli.domain("blender", "Run the Blender payloads — the one carve-out.") + + +@app.callback() +def _domain() -> None: + """Keeps `blender` a group (Typer collapses a single-command app).""" + + +@app.command("list") +@command +def list_payloads() -> None: + """List every payload `run` accepts. + + Worth a verb of its own: the payload names are not guessable and this is + the only place the vocabulary is written down. + """ + names = service.payloads() + for name in names: + console.out(name) + console.verdict(f"{len(names)} payload(s) in {service.payload_dir().name}/") + + +@app.command("which") +@command +def which() -> None: + """Report which Blender would be used, without launching it.""" + invocation = service.resolve_blender() + console.out(f"kind {invocation.kind}") + console.out(f"command {' '.join(invocation.argv_prefix)}") + console.verdict( + "flatpak — paths are absolutised before they cross the sandbox" + if invocation.sandboxed + else "native install — preferred when both are present" + ) + + +@app.command("run", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) +@command +def run( + ctx: typer.Context, + payload: str = typer.Argument( + ..., help="Payload name (see `reach blender list`), or a path to any script." + ), + background: bool = typer.Option( + True, "--background/--no-background", help="Headless, or open the Blender GUI." + ), +) -> None: + """Run a payload under Blender, passing any remaining arguments through. + + Extra arguments go to the payload after `--`, and any that name an existing + path are made absolute first — flatpak's sandbox resolves relative paths + against a different root, and the resulting file-not-found comes from + inside Blender, a long way from its cause. + """ + service.run_payload(payload, list(ctx.args), background=background) diff --git a/tooling/domains/blender/service.py b/tooling/domains/blender/service.py new file mode 100644 index 000000000..895f1d875 --- /dev/null +++ b/tooling/domains/blender/service.py @@ -0,0 +1,133 @@ +"""Logic for the `blender` domain. Transport-agnostic (D-263). + +Everything the bash wrapper decided is decided here, and every decision is a +pure function: which Blender install to use, which payload a name refers to, +which arguments are paths that need absolutising. Only `run_payload` performs. + +That matters more than usual for this domain, because the thing being launched +is a 200 MB GUI application that opens files and writes GLBs. "Run it and see" +is not a test. +""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +from tooling.core import config, console, process +from tooling.core.errors import ReachError, unknown_choice + +PAYLOAD_DIR = ("tooling", "scripts", "blender") + +# Flatpak is checked second: a native install is faster and has no sandbox, so +# it wins when both are present. This ordering is the bash wrapper's and is +# preserved deliberately. +FLATPAK_APP = "org.blender.Blender" + + +@dataclass(frozen=True) +class BlenderInvocation: + """How Blender would be launched — decided without launching it.""" + + argv_prefix: list[str] + kind: str # "native" | "flatpak" + + @property + def sandboxed(self) -> bool: + """Flatpak cannot see paths outside its sandbox unless they are absolute.""" + return self.kind == "flatpak" + + +def resolve_blender() -> BlenderInvocation: + """Find Blender, preferring a native install over flatpak. + + Raises rather than falling through to a bare `blender` that is not there — + the original printed its own error and exited 1, and losing that would turn + a missing dependency into a confusing FileNotFoundError. + """ + native = shutil.which("blender") + if native: + return BlenderInvocation([native], "native") + + if shutil.which("flatpak"): + probe = process.run(["flatpak", "info", FLATPAK_APP], check=False) + if probe.returncode == 0: + return BlenderInvocation(["flatpak", "run", FLATPAK_APP], "flatpak") + + raise ReachError( + "Blender is not installed (checked PATH and flatpak)", + fix="install Blender natively, or run: flatpak install " + f"{FLATPAK_APP} — reach prefers the native install when both exist", + ) + + +def payload_dir() -> Path: + return config.path(*PAYLOAD_DIR) + + +def payloads() -> list[str]: + """Every runnable payload, by the name `run` accepts.""" + return sorted(p.stem for p in payload_dir().glob("*.py") if p.name != "__init__.py") + + +def resolve_payload(name: str) -> Path: + """Map a payload name — or a path to any script — to a file to run. + + Two forms, because the bash wrapper this replaces served both: our own 35 + registered payloads BY NAME, and any other script BY PATH (the spikes and + the glb-gen skill hand it one-off scripts of their own). A registered name + wins; anything that resolves to an existing file is accepted as a path. + + On a miss it enumerates, because a payload name is not guessable — the 35 + names are the only place that vocabulary is written down. + """ + stem = name[:-3] if name.endswith(".py") else name + registered = payload_dir() / f"{stem}.py" + if registered.is_file(): + return registered + + given = Path(name) + if given.is_file(): + return given.resolve() + + raise unknown_choice("payload", stem, payloads()) + + +def absolutise(args: list[str]) -> list[str]: + """Resolve any argument that names an existing path. + + The wrapper did this for every argument, not just `--python`, because + flatpak's sandbox resolves relative paths against a different root and the + failure is a file-not-found from inside Blender — far from the cause. An + argument that is not an existing path is passed through untouched, so flags + and values survive. + """ + return [str(Path(a).resolve()) if Path(a).exists() else a for a in args] + + +def run_payload(name: str, args: list[str], background: bool = True) -> None: + """Launch a payload under Blender. The performing half.""" + payload = resolve_payload(name) + invocation = resolve_blender() + + argv = [*invocation.argv_prefix] + if background: + argv.append("--background") + argv += ["--python", str(payload)] + if args: + argv += ["--", *absolutise(args)] + + console.event( + f"running {name} under {invocation.kind} Blender", + phase="blender", + ) + # capture=False: Blender streams its own progress, and a payload that + # takes minutes with no output reads as a hang. + process.run( + argv, + cwd=config.repo_root(), + capture=False, + fix=f"run `reach blender run {name}` with --no-background to watch it in the GUI", + ) + console.verdict(f"blender: {name} finished") diff --git a/tooling/main.py b/tooling/main.py index 3d62a84d0..248ba1e86 100644 --- a/tooling/main.py +++ b/tooling/main.py @@ -71,6 +71,10 @@ DOMAINS: dict[str, tuple[str, str]] = { "tooling.domains.generate.router:app", "Producers — brands, corporations, manifests and stubs", ), + "blender": ( + "tooling.domains.blender.router:app", + "Run the Blender payloads — bodies, garments, inspection", + ), "pr": ( "tooling.domains.pr.router:app", "The review loop — comments, watchlist and the board", diff --git a/tooling/garment-fit/blender_author_boots.py b/tooling/scripts/blender/blender_author_boots.py similarity index 100% rename from tooling/garment-fit/blender_author_boots.py rename to tooling/scripts/blender/blender_author_boots.py diff --git a/tooling/garment-fit/blender_author_buttondown.py b/tooling/scripts/blender/blender_author_buttondown.py similarity index 100% rename from tooling/garment-fit/blender_author_buttondown.py rename to tooling/scripts/blender/blender_author_buttondown.py diff --git a/tooling/garment-fit/blender_author_cargo_pants.py b/tooling/scripts/blender/blender_author_cargo_pants.py similarity index 100% rename from tooling/garment-fit/blender_author_cargo_pants.py rename to tooling/scripts/blender/blender_author_cargo_pants.py diff --git a/tooling/garment-fit/blender_author_denim_pants.py b/tooling/scripts/blender/blender_author_denim_pants.py similarity index 100% rename from tooling/garment-fit/blender_author_denim_pants.py rename to tooling/scripts/blender/blender_author_denim_pants.py diff --git a/tooling/garment-fit/blender_author_hoodie.py b/tooling/scripts/blender/blender_author_hoodie.py similarity index 100% rename from tooling/garment-fit/blender_author_hoodie.py rename to tooling/scripts/blender/blender_author_hoodie.py diff --git a/tooling/garment-fit/blender_author_jacket_shell.py b/tooling/scripts/blender/blender_author_jacket_shell.py similarity index 100% rename from tooling/garment-fit/blender_author_jacket_shell.py rename to tooling/scripts/blender/blender_author_jacket_shell.py diff --git a/tooling/garment-fit/blender_author_joggers.py b/tooling/scripts/blender/blender_author_joggers.py similarity index 100% rename from tooling/garment-fit/blender_author_joggers.py rename to tooling/scripts/blender/blender_author_joggers.py diff --git a/tooling/garment-fit/blender_author_lower_shell.py b/tooling/scripts/blender/blender_author_lower_shell.py similarity index 100% rename from tooling/garment-fit/blender_author_lower_shell.py rename to tooling/scripts/blender/blender_author_lower_shell.py diff --git a/tooling/garment-fit/blender_author_offset_coverall.py b/tooling/scripts/blender/blender_author_offset_coverall.py similarity index 100% rename from tooling/garment-fit/blender_author_offset_coverall.py rename to tooling/scripts/blender/blender_author_offset_coverall.py diff --git a/tooling/garment-fit/blender_author_offset_shell.py b/tooling/scripts/blender/blender_author_offset_shell.py similarity index 100% rename from tooling/garment-fit/blender_author_offset_shell.py rename to tooling/scripts/blender/blender_author_offset_shell.py diff --git a/tooling/garment-fit/blender_author_offset_shell_legs.py b/tooling/scripts/blender/blender_author_offset_shell_legs.py similarity index 100% rename from tooling/garment-fit/blender_author_offset_shell_legs.py rename to tooling/scripts/blender/blender_author_offset_shell_legs.py diff --git a/tooling/garment-fit/blender_author_outerwear.py b/tooling/scripts/blender/blender_author_outerwear.py similarity index 100% rename from tooling/garment-fit/blender_author_outerwear.py rename to tooling/scripts/blender/blender_author_outerwear.py diff --git a/tooling/garment-fit/blender_author_parka.py b/tooling/scripts/blender/blender_author_parka.py similarity index 100% rename from tooling/garment-fit/blender_author_parka.py rename to tooling/scripts/blender/blender_author_parka.py diff --git a/tooling/garment-fit/blender_author_shoes_formal.py b/tooling/scripts/blender/blender_author_shoes_formal.py similarity index 100% rename from tooling/garment-fit/blender_author_shoes_formal.py rename to tooling/scripts/blender/blender_author_shoes_formal.py diff --git a/tooling/garment-fit/blender_author_slides.py b/tooling/scripts/blender/blender_author_slides.py similarity index 100% rename from tooling/garment-fit/blender_author_slides.py rename to tooling/scripts/blender/blender_author_slides.py diff --git a/tooling/garment-fit/blender_author_sneakers.py b/tooling/scripts/blender/blender_author_sneakers.py similarity index 100% rename from tooling/garment-fit/blender_author_sneakers.py rename to tooling/scripts/blender/blender_author_sneakers.py diff --git a/tooling/garment-fit/blender_author_sweater.py b/tooling/scripts/blender/blender_author_sweater.py similarity index 100% rename from tooling/garment-fit/blender_author_sweater.py rename to tooling/scripts/blender/blender_author_sweater.py diff --git a/tooling/garment-fit/blender_author_swim_trunks.py b/tooling/scripts/blender/blender_author_swim_trunks.py similarity index 100% rename from tooling/garment-fit/blender_author_swim_trunks.py rename to tooling/scripts/blender/blender_author_swim_trunks.py diff --git a/tooling/garment-fit/blender_author_swimsuit.py b/tooling/scripts/blender/blender_author_swimsuit.py similarity index 100% rename from tooling/garment-fit/blender_author_swimsuit.py rename to tooling/scripts/blender/blender_author_swimsuit.py diff --git a/tooling/garment-fit/blender_author_tank_top.py b/tooling/scripts/blender/blender_author_tank_top.py similarity index 100% rename from tooling/garment-fit/blender_author_tank_top.py rename to tooling/scripts/blender/blender_author_tank_top.py diff --git a/tooling/garment-fit/blender_author_track_jacket.py b/tooling/scripts/blender/blender_author_track_jacket.py similarity index 100% rename from tooling/garment-fit/blender_author_track_jacket.py rename to tooling/scripts/blender/blender_author_track_jacket.py diff --git a/tooling/garment-fit/blender_batch_fit_skinned.py b/tooling/scripts/blender/blender_batch_fit_skinned.py similarity index 100% rename from tooling/garment-fit/blender_batch_fit_skinned.py rename to tooling/scripts/blender/blender_batch_fit_skinned.py diff --git a/tooling/blender_compare_bones.py b/tooling/scripts/blender/blender_compare_bones.py similarity index 100% rename from tooling/blender_compare_bones.py rename to tooling/scripts/blender/blender_compare_bones.py diff --git a/tooling/blender_create_clothing_refs.py b/tooling/scripts/blender/blender_create_clothing_refs.py similarity index 100% rename from tooling/blender_create_clothing_refs.py rename to tooling/scripts/blender/blender_create_clothing_refs.py diff --git a/tooling/blender_extract_armature.py b/tooling/scripts/blender/blender_extract_armature.py similarity index 100% rename from tooling/blender_extract_armature.py rename to tooling/scripts/blender/blender_extract_armature.py diff --git a/tooling/blender_inspect_body.py b/tooling/scripts/blender/blender_inspect_body.py similarity index 100% rename from tooling/blender_inspect_body.py rename to tooling/scripts/blender/blender_inspect_body.py diff --git a/tooling/blender_inspect_vgroups.py b/tooling/scripts/blender/blender_inspect_vgroups.py similarity index 100% rename from tooling/blender_inspect_vgroups.py rename to tooling/scripts/blender/blender_inspect_vgroups.py diff --git a/tooling/blender_list_animations.py b/tooling/scripts/blender/blender_list_animations.py similarity index 100% rename from tooling/blender_list_animations.py rename to tooling/scripts/blender/blender_list_animations.py diff --git a/tooling/blender_list_bones.py b/tooling/scripts/blender/blender_list_bones.py similarity index 100% rename from tooling/blender_list_bones.py rename to tooling/scripts/blender/blender_list_bones.py diff --git a/tooling/blender_process_bodies.py b/tooling/scripts/blender/blender_process_bodies.py similarity index 100% rename from tooling/blender_process_bodies.py rename to tooling/scripts/blender/blender_process_bodies.py diff --git a/tooling/blender_process_hair.py b/tooling/scripts/blender/blender_process_hair.py similarity index 100% rename from tooling/blender_process_hair.py rename to tooling/scripts/blender/blender_process_hair.py diff --git a/tooling/blender_process_heads.py b/tooling/scripts/blender/blender_process_heads.py similarity index 100% rename from tooling/blender_process_heads.py rename to tooling/scripts/blender/blender_process_heads.py diff --git a/tooling/blender_rebuild_forks.py b/tooling/scripts/blender/blender_rebuild_forks.py similarity index 100% rename from tooling/blender_rebuild_forks.py rename to tooling/scripts/blender/blender_rebuild_forks.py diff --git a/tooling/blender_segment_body.py b/tooling/scripts/blender/blender_segment_body.py similarity index 100% rename from tooling/blender_segment_body.py rename to tooling/scripts/blender/blender_segment_body.py diff --git a/tooling/blender_surface_deform_batch.py b/tooling/scripts/blender/blender_surface_deform_batch.py similarity index 100% rename from tooling/blender_surface_deform_batch.py rename to tooling/scripts/blender/blender_surface_deform_batch.py diff --git a/tooling/test_blender.py b/tooling/test_blender.py new file mode 100644 index 000000000..35d53d245 --- /dev/null +++ b/tooling/test_blender.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""The `reach blender` decisions, exercised without launching Blender (T-1273). + +Same principle as test_environment.py, and for a stronger reason: the thing +being launched is a 200 MB GUI application that opens files and writes GLBs, so +"run it and see" is not a test. Every decision the retired bash wrapper made — +which install to use, which script a name refers to, which arguments need +absolutising — is a pure function here, and this is what pins them. + +The absolutise rule is the one worth having a test for. Flatpak resolves +relative paths against a different root, so a relative path silently becomes a +file-not-found raised from inside Blender, a long way from its cause. + +Run: python3 tooling/test_blender.py +""" + +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from tooling.core.errors import ReachError # noqa: E402 +from tooling.domains.blender import service # noqa: E402 + + +def test_payloads_are_discoverable(failures: list[str]) -> None: + """The carve-out is the only place these names are written down.""" + names = service.payloads() + if len(names) < 30: + failures.append(f"expected the 35-file carve-out, found {len(names)} payloads") + if any(n.endswith(".py") for n in names): + failures.append("payload names should be stems, not filenames") + + +def test_registered_name_resolves(failures: list[str]) -> None: + """Both the stem and the filename form reach the same file.""" + first = service.payloads()[0] + by_stem = service.resolve_payload(first) + by_filename = service.resolve_payload(f"{first}.py") + if by_stem != by_filename: + failures.append(f"'{first}' and '{first}.py' resolved differently") + if by_stem.parent != service.payload_dir(): + failures.append(f"a registered name resolved outside the payload dir: {by_stem}") + + +def test_arbitrary_path_resolves(failures: list[str]) -> None: + """The wrapper ran one-off scripts too — the spikes and glb-gen still do.""" + with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as handle: + handle.write(b"# a one-off script\n") + loose = Path(handle.name) + try: + resolved = service.resolve_payload(str(loose)) + if resolved != loose.resolve(): + failures.append(f"a script path resolved to {resolved}, expected {loose}") + if not resolved.is_absolute(): + failures.append("a script path resolved to a relative path") + finally: + loose.unlink() + + +def test_unknown_name_enumerates(failures: list[str]) -> None: + """A payload name is not guessable, so a miss must list the options.""" + try: + service.resolve_payload("definitely_not_a_payload") + failures.append("an unknown payload resolved instead of raising") + except ReachError as exc: + if not exc.fix or "blender_" not in exc.fix: + failures.append("the unknown-payload error does not enumerate the payloads") + if exc.exit_code != 2: + failures.append( + f"an unknown payload exited {exc.exit_code}, expected 2 (usage error)" + ) + + +def test_absolutise_only_touches_real_paths(failures: list[str]) -> None: + """Flags and values must survive; existing paths must be made absolute.""" + with tempfile.TemporaryDirectory() as tmp: + real = Path(tmp) / "input.glb" + real.write_text("") + args = ["--mode", "fit", str(real), "not/a/real/path.glb", "12"] + out = service.absolutise(args) + + if out[0] != "--mode" or out[1] != "fit" or out[4] != "12": + failures.append(f"absolutise mangled non-path arguments: {out}") + if out[2] != str(real.resolve()): + failures.append(f"an existing path was not absolutised: {out[2]}") + if out[3] != "not/a/real/path.glb": + failures.append(f"a non-existent path was rewritten: {out[3]}") + + +def test_invocation_reports_its_sandbox(failures: list[str]) -> None: + """`sandboxed` is what decides whether absolutising is load-bearing.""" + native = service.BlenderInvocation(["blender"], "native") + flatpak = service.BlenderInvocation(["flatpak", "run", service.FLATPAK_APP], "flatpak") + if native.sandboxed: + failures.append("a native install was reported as sandboxed") + if not flatpak.sandboxed: + failures.append("a flatpak install was not reported as sandboxed") + + +def main() -> int: + failures: list[str] = [] + test_payloads_are_discoverable(failures) + test_registered_name_resolves(failures) + test_arbitrary_path_resolves(failures) + test_unknown_name_enumerates(failures) + test_absolutise_only_touches_real_paths(failures) + test_invocation_reports_its_sandbox(failures) + + if failures: + print("test_blender: FAIL", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + print( + "test_blender: OK — payload discovery, both resolve forms, enumeration " + "on a miss and the flatpak path rule, without launching Blender" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tooling/test_conformance.py b/tooling/test_conformance.py index 1c96a572a..ac92771ed 100644 --- a/tooling/test_conformance.py +++ b/tooling/test_conformance.py @@ -58,6 +58,18 @@ FORBIDDEN_IMPORTS = {"typer", "click"} # needing to be widened by hand. PACKAGE_ROOTS = ("main.py", "__init__.py", "core", "domains") +# tooling/scripts/blender/ is DELIBERATELY not in that tuple, and must never be +# added (T-1273). Those 35 files run under Blender's BUNDLED Python, which has +# no access to the repo venv — they physically cannot `import tooling.core`, so +# they cannot carry `@command` or print through `console`. Holding them to the +# contract would either fail this gate forever or force the contract to be +# weakened for everyone, and the second is how a gate stops meaning anything. +# +# It reads like an oversight, so `check_carve_out_stays_carved` below asserts +# the exclusion on purpose: widening PACKAGE_ROOTS to cover them fails loudly +# instead of quietly redefining what conformance means. +PAYLOAD_ROOTS = ("scripts",) + def _package_files() -> list[Path]: """Every .py that is part of the reach package — not the legacy scripts.""" @@ -303,6 +315,42 @@ def check_exit_codes(failures: list[str]) -> None: ) +def check_carve_out_stays_carved(failures: list[str]) -> None: + """(7) The Blender payloads stay outside package scope, and stay populated. + + Two failure modes, opposite directions: + + - Someone widens PACKAGE_ROOTS to cover `scripts/` because the exclusion + looks like an oversight. Every payload then fails five invariants it + cannot satisfy, and the likely repair is to weaken the invariants. + - The payload directory quietly empties — a move, a bad merge — and the + exclusion goes on passing because excluding nothing is trivially fine. + An exception that guards nothing should not read as healthy. + """ + for root in PAYLOAD_ROOTS: + if root in PACKAGE_ROOTS: + failures.append( + f"[carve-out] '{root}' was added to PACKAGE_ROOTS — the Blender " + "payloads run under Blender's bundled Python and cannot import " + "tooling.core; see the comment above PAYLOAD_ROOTS (T-1273)" + ) + + payloads = list((PACKAGE / "scripts" / "blender").glob("*.py")) + if not payloads: + failures.append( + "[carve-out] tooling/scripts/blender/ holds no payloads — either the " + "carve-out was undone or they moved; an empty exclusion proves nothing" + ) + + # The payloads must not be reachable as modules either: an __init__.py would + # make them importable and invite exactly the coupling the carve-out prevents. + if (PACKAGE / "scripts" / "__init__.py").exists(): + failures.append( + "[carve-out] tooling/scripts/__init__.py exists — that makes the " + "payload tree an importable package, which is what the carve-out avoids" + ) + + def main() -> int: if shutil.which("reach") is None: print("test_conformance: `reach` is not on PATH.\n Fix: make install-reach", file=sys.stderr) @@ -316,6 +364,7 @@ def main() -> int: check_commands_decorated(failures) check_errors_name_a_remedy(failures) check_exit_codes(failures) + check_carve_out_stays_carved(failures) if failures: print("test_conformance: FAIL", file=sys.stderr)