From 59b3fc4caae2e22fd257c93c32b29a8f4aff7907 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 23 Sep 2026 20:00:05 +0200 Subject: [PATCH] =?UTF-8?q?refactor(tooling):=20T-1293=20=E2=80=94=20atlas?= =?UTF-8?q?=20map,=20and=20the=20generator=20that=20must=20not=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The star-map family was the last unported part of the tree, and it never had a ticket. Two of its scripts become `reach atlas map` verbs, nested under atlas like planet (D-243: the Reach map is the ladder's top rung): - `reach atlas map data [--check]` regenerates client/data/star_map_data.json. The regenerated file differs by one line: `_meta.note`, which named the old script's path. - `reach atlas map svg` renders the concentric SVG (+ PNG), byte-identical to the old script's output on the same data. make check-star-map and star-map-data stay as one-line delegates, because pre-pr-client and pre-pr-validate depend on check-star-map. generate-star-map.py, its seed, sculpt-star-map.py and tune-star-map-topology.py are archived, not ported. The generator rewrites docs/design/star-map.json unconditionally from an S-NNN-keyed seed, so re-running it would erase the GJ migration and every hand edit since; sculpt and tune only understand S-NNN edges. .claude/rules/diagrams.md was telling agents to "edit the generator and re-run it". It now distinguishes the live concentric render, the seven frozen S-keyed sector .d2 files (T-1294), and the two SVGs that never had a generator in the repo. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/diagrams.md | 25 +- .pql/changelog/ticket_history/2026-09.sql | 11 + .pql/changelog/ticket_idmap/2026-09.sql | 2 + .pql/changelog/tickets/2026-09.sql | 17 + Makefile | 18 +- client/data/star_map_data.json | 2 +- tooling/DOMAINS.md | 2 +- tooling/archive/README.md | 5 +- .../map-bootstrap}/generate-star-map.py | 0 .../map-bootstrap}/sculpt-star-map.py | 0 .../map-bootstrap}/star-map-seed.json | 0 .../map-bootstrap}/tune-star-map-topology.py | 0 tooling/domains/atlas/map/__init__.py | 6 + .../atlas/map/data.py} | 86 ++-- tooling/domains/atlas/map/router.py | 54 +++ tooling/domains/atlas/map/svg.py | 381 ++++++++++++++++++ tooling/domains/atlas/router.py | 13 +- tooling/generate-star-map-svg.py | 363 ----------------- 18 files changed, 548 insertions(+), 437 deletions(-) rename tooling/{ => archive/map-bootstrap}/generate-star-map.py (100%) rename tooling/{ => archive/map-bootstrap}/sculpt-star-map.py (100%) rename tooling/{ => archive/map-bootstrap}/star-map-seed.json (100%) rename tooling/{ => archive/map-bootstrap}/tune-star-map-topology.py (100%) create mode 100644 tooling/domains/atlas/map/__init__.py rename tooling/{generate-star-map-data.py => domains/atlas/map/data.py} (83%) create mode 100644 tooling/domains/atlas/map/router.py create mode 100644 tooling/domains/atlas/map/svg.py delete mode 100644 tooling/generate-star-map-svg.py diff --git a/.claude/rules/diagrams.md b/.claude/rules/diagrams.md index 7832992aa..5be3b7d4e 100644 --- a/.claude/rules/diagrams.md +++ b/.claude/rules/diagrams.md @@ -25,14 +25,25 @@ Scratch renders go in `.cache/` — never in `docs/diagrams/`, never in `/tmp`. ## Some `.d2` files are generator output — do not hand-edit -`docs/diagrams/design/star-map-*.d2` (×7: `overview`, `core`, -`north`/`south`/`east`/`west-reach`, `deep-frontier`) are written by -`tooling/generate-star-map.py` alongside `docs/design/star-map.json`. The -`concentric`/`realcoords`/`topology` SVGs come from a second script, -`tooling/generate-star-map-svg.py`, and never pass through d2 at all. +The star-map family is three different cases, and the difference matters +(T-1293): -Edit the generator and re-run it; a hand-edit is lost on the next run. Same -source-canonical rule as `.claude/rules/asset-pipeline.md`. +- **`star-map-concentric.svg` (+ its `.png`) is a live render.** Regenerate it + with `reach atlas map svg`; it reads the committed `docs/design/star-map.json` + and `systems.db` and never passes through d2. Edit the renderer, not the SVG. +- **`star-map-*.d2` (×7: `overview`, `core`, the four reaches, `deep-frontier`) + are FROZEN output — do not regenerate them.** Their generator is archived in + `tooling/archive/map-bootstrap/` because it rewrites + `docs/design/star-map.json` from an `S-NNN`-keyed seed, which would destroy the + GJ-keyed, hand-maintained map. The d2 files still carry `S-` ids from before + the GJ migration. Replacing them means a new renderer that reads + `star-map.json` — see T-1294. +- **`star-map-realcoords.svg` and `star-map-topology.svg` have no generator in + the repo** — committed once (2026-03-16) as static artefacts. + +Same source-canonical principle as `.claude/rules/asset-pipeline.md`, with one +trap: here the "source" the old generator would rebuild from is no longer the +source. ## A diagram that names files goes stale silently diff --git a/.pql/changelog/ticket_history/2026-09.sql b/.pql/changelog/ticket_history/2026-09.sql index f5e1bbc57..f991dd7fa 100644 --- a/.pql/changelog/ticket_history/2026-09.sql +++ b/.pql/changelog/ticket_history/2026-09.sql @@ -542,3 +542,14 @@ PROGRESS 2026-09-23 — wiki half done (commit to follow): reach wiki stats + gt 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; +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 ('06GCYTXYFNTZ828SM7KEREGHK0', 'description', 'Found in T-1293. docs/diagrams/design/star-map-*.d2 (x7) were written 2026-03-13 by tooling/generate-star-map.py (now tooling/archive/map-bootstrap/). They still carry S-NNN ids from before the GJ migration, so they no longer match docs/design/star-map.json (GJ-keyed, hand-maintained since). They cannot be regenerated: that generator rewrites star-map.json unconditionally from an S-keyed seed. Also: the committed star-map-concentric.svg no longer matches a fresh {"svg": "/var/mnt/data/projects/settled-reach/docs/diagrams/design/star-map-concentric.svg", "png": "/var/mnt/data/projects/settled-reach/docs/diagrams/design/star-map-concentric.png", "nodes": 301, "edges": 335} (data drifted since it was last rendered) — a one-command refresh, but it rewrites a committed PNG, so do it deliberately. Fix: a small verb that renders the seven sector diagrams FROM star-map.json + systems.db (the sector/topology logic is in the archived generator''s d2 emit functions), then regenerate. .claude/rules/diagrams.md now documents the frozen state.', 'Found in T-1293. docs/diagrams/design/star-map-*.d2 (x7) were written 2026-03-13 by tooling/generate-star-map.py (now tooling/archive/map-bootstrap/). They still carry S-NNN ids from before the GJ migration, so they no longer match docs/design/star-map.json (GJ-keyed, hand-maintained since). They cannot be regenerated: that generator rewrites star-map.json unconditionally from an S-keyed seed. + +Also: the committed star-map-concentric.svg no longer matches a fresh render by the atlas map svg verb (the data drifted since it was last rendered). Refreshing it is one command, but it rewrites a committed PNG as well, so do it deliberately. + +Fix: a small atlas map d2 verb that renders the seven sector diagrams FROM star-map.json + systems.db (the sector and topology logic is in the archived generator''s d2 emit functions), then regenerate. .claude/rules/diagrams.md now documents the frozen state.', NULL, '2026-09-23 17:59:13', '2026-09-23 17:59:13.666', '2026-09-23 17:59:13.666', NULL, 'c1c14d18ddfa2799f7e09b5ee921ce19', 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 ('06GCYT8JBRCX13ZD51SY4SX1WW', 'description', 'The last unported family, never ticketed (T-1285 named ''reach atlas map'' as a separate ticket; none was filed). Five scripts + a seed: generate-star-map.py, generate-star-map-svg.py, generate-star-map-data.py, sculpt-star-map.py, tune-star-map-topology.py, star-map-seed.json. Per .claude/rules/diagrams.md, generate-star-map.py WRITES docs/diagrams/design/star-map-*.d2 (x7) + docs/design/star-map.json, and generate-star-map-svg.py writes the concentric/realcoords/topology SVGs; the Makefile has star-map-data / check-star-map targets (client/data/star_map_data.json). Decide per file live verb vs provenance (sculpt/tune may be one-shot topology authoring now that star-map.json is canonical and GJ-keyed). Standard port acceptance; generated outputs must come back byte-identical.', 'The last unported family, never ticketed (T-1285 named ''reach atlas map'' as a separate ticket; none was filed). Five scripts + a seed: generate-star-map.py, generate-star-map-svg.py, generate-star-map-data.py, sculpt-star-map.py, tune-star-map-topology.py, star-map-seed.json. Per .claude/rules/diagrams.md, generate-star-map.py WRITES docs/diagrams/design/star-map-*.d2 (x7) + docs/design/star-map.json, and generate-star-map-svg.py writes the concentric/realcoords/topology SVGs; the Makefile has star-map-data / check-star-map targets (client/data/star_map_data.json). Decide per file live verb vs provenance (sculpt/tune may be one-shot topology authoring now that star-map.json is canonical and GJ-keyed). Standard port acceptance; generated outputs must come back byte-identical. + +DONE 2026-09-23. reach atlas map data [--check] + reach atlas map svg, nested under atlas like planet. Parity: star_map_data.json regenerates with a ONE-LINE diff, the _meta.note that named the old script path; the SVG is byte-identical to the old script''s output on the same data (the committed SVG itself is stale vs current data, T-1294). make check-star-map / star-map-data kept as one-line delegates because pre-pr-client and pre-pr-validate depend on check-star-map. + +ARCHIVED, NOT PORTED: generate-star-map.py + star-map-seed.json + sculpt + tune (tooling/archive/map-bootstrap/). The generator unconditionally rewrites docs/design/star-map.json from an S-NNN-keyed seed, so re-running it would erase the GJ migration and every hand edit since. sculpt and tune operate only on S-NNN edges. .claude/rules/diagrams.md used to tell agents to "edit the generator and re-run it"; it now documents three cases: concentric SVG is a live render, the 7 sector d2 files are frozen S-keyed output (T-1294), and realcoords/topology SVGs have no generator at all.', NULL, '2026-09-23 17:59:58', '2026-09-23 17:59:58.221', '2026-09-23 17:59:58.221', NULL, 'fa1c80235ce06db1f459dae246ba37dc', 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 ('06GCYT8JBRCX13ZD51SY4SX1WW', 'status', 'backlog', 'done', NULL, '2026-09-23 17:59:58', '2026-09-23 17:59:58.641', '2026-09-23 17:59:58.641', NULL, '410fcc34e85dc80deb099cf6cb59a31f', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/ticket_idmap/2026-09.sql b/.pql/changelog/ticket_idmap/2026-09.sql index a11d205b1..e6118cc4c 100644 --- a/.pql/changelog/ticket_idmap/2026-09.sql +++ b/.pql/changelog/ticket_idmap/2026-09.sql @@ -7,3 +7,5 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', 'T-1290', '2026-09-02 16:17:45.770', '2026-09-02 16:17:45.770', NULL, '748d7ed02f4821a7ee5059a76d2fd3fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCX60QRMD7KJ1TVWTR7FWK28', 'T-1291', '2026-09-23 14:07:49.190', '2026-09-23 14:07:49.190', NULL, '20abfe457c23ea4e00d3c8109f2e39f2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCX9TTBJW4NPECEN6JM0V4HM', 'T-1292', '2026-09-23 14:24:29.282', '2026-09-23 14:24:29.282', NULL, '45174fb519d2deed098a244229d543e1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCYT8JBRCX13ZD51SY4SX1WW', 'T-1293', '2026-09-23 17:56:04.830', '2026-09-23 17:56:04.830', NULL, '5d8ca375541decd04d46a9932f80e2c8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCYTXYFNTZ828SM7KEREGHK0', 'T-1294', '2026-09-23 17:58:59.966', '2026-09-23 17:58:59.966', NULL, 'bcabb81af3c53e6d6f879da79ce6e627', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; diff --git a/.pql/changelog/tickets/2026-09.sql b/.pql/changelog/tickets/2026-09.sql index a4bf3f5ce..906ff4de7 100644 --- a/.pql/changelog/tickets/2026-09.sql +++ b/.pql/changelog/tickets/2026-09.sql @@ -671,3 +671,20 @@ 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; +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 ('06GCYT8JBRCX13ZD51SY4SX1WW', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port atlas map — the star-map generator family', 'The last unported family, never ticketed (T-1285 named ''reach atlas map'' as a separate ticket; none was filed). Five scripts + a seed: generate-star-map.py, generate-star-map-svg.py, generate-star-map-data.py, sculpt-star-map.py, tune-star-map-topology.py, star-map-seed.json. Per .claude/rules/diagrams.md, generate-star-map.py WRITES docs/diagrams/design/star-map-*.d2 (x7) + docs/design/star-map.json, and generate-star-map-svg.py writes the concentric/realcoords/topology SVGs; the Makefile has star-map-data / check-star-map targets (client/data/star_map_data.json). Decide per file live verb vs provenance (sculpt/tune may be one-shot topology authoring now that star-map.json is canonical and GJ-keyed). Standard port acceptance; generated outputs must come back byte-identical.', 'backlog', 'medium', NULL, 'tooling', 'D-263', '2026-09-23 17:56:04.830', '2026-09-23 17:56:04.830', NULL, 'c8ca6ea28c74f204db2ca8874a5d2ecc', 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 ('06GCYTXYFNTZ828SM7KEREGHK0', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Star-map d2 diagrams are frozen S-keyed output of an archived generator — replace with a renderer over star-map.json', 'Found in T-1293. docs/diagrams/design/star-map-*.d2 (x7) were written 2026-03-13 by tooling/generate-star-map.py (now tooling/archive/map-bootstrap/). They still carry S-NNN ids from before the GJ migration, so they no longer match docs/design/star-map.json (GJ-keyed, hand-maintained since). They cannot be regenerated: that generator rewrites star-map.json unconditionally from an S-keyed seed. Also: the committed star-map-concentric.svg no longer matches a fresh {"svg": "/var/mnt/data/projects/settled-reach/docs/diagrams/design/star-map-concentric.svg", "png": "/var/mnt/data/projects/settled-reach/docs/diagrams/design/star-map-concentric.png", "nodes": 301, "edges": 335} (data drifted since it was last rendered) — a one-command refresh, but it rewrites a committed PNG, so do it deliberately. Fix: a small verb that renders the seven sector diagrams FROM star-map.json + systems.db (the sector/topology logic is in the archived generator''s d2 emit functions), then regenerate. .claude/rules/diagrams.md now documents the frozen state.', 'backlog', 'low', NULL, 'tooling', NULL, '2026-09-23 17:58:59.965', '2026-09-23 17:58:59.965', NULL, '87ad08cf609c3ea01aa4176c9574b4b0', 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 ('06GCYTXYFNTZ828SM7KEREGHK0', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'Star-map d2 diagrams are frozen S-keyed output of an archived generator — replace with a renderer over star-map.json', 'Found in T-1293. docs/diagrams/design/star-map-*.d2 (x7) were written 2026-03-13 by tooling/generate-star-map.py (now tooling/archive/map-bootstrap/). They still carry S-NNN ids from before the GJ migration, so they no longer match docs/design/star-map.json (GJ-keyed, hand-maintained since). They cannot be regenerated: that generator rewrites star-map.json unconditionally from an S-keyed seed. + +Also: the committed star-map-concentric.svg no longer matches a fresh render by the atlas map svg verb (the data drifted since it was last rendered). Refreshing it is one command, but it rewrites a committed PNG as well, so do it deliberately. + +Fix: a small atlas map d2 verb that renders the seven sector diagrams FROM star-map.json + systems.db (the sector and topology logic is in the archived generator''s d2 emit functions), then regenerate. .claude/rules/diagrams.md now documents the frozen state.', 'backlog', 'low', NULL, 'tooling', NULL, '2026-09-23 17:58:59.965', '2026-09-23 17:59:13.666', NULL, '4b6231d4ea712465ac090135482c63db', 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 ('06GCYT8JBRCX13ZD51SY4SX1WW', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port atlas map — the star-map generator family', 'The last unported family, never ticketed (T-1285 named ''reach atlas map'' as a separate ticket; none was filed). Five scripts + a seed: generate-star-map.py, generate-star-map-svg.py, generate-star-map-data.py, sculpt-star-map.py, tune-star-map-topology.py, star-map-seed.json. Per .claude/rules/diagrams.md, generate-star-map.py WRITES docs/diagrams/design/star-map-*.d2 (x7) + docs/design/star-map.json, and generate-star-map-svg.py writes the concentric/realcoords/topology SVGs; the Makefile has star-map-data / check-star-map targets (client/data/star_map_data.json). Decide per file live verb vs provenance (sculpt/tune may be one-shot topology authoring now that star-map.json is canonical and GJ-keyed). Standard port acceptance; generated outputs must come back byte-identical. + +DONE 2026-09-23. reach atlas map data [--check] + reach atlas map svg, nested under atlas like planet. Parity: star_map_data.json regenerates with a ONE-LINE diff, the _meta.note that named the old script path; the SVG is byte-identical to the old script''s output on the same data (the committed SVG itself is stale vs current data, T-1294). make check-star-map / star-map-data kept as one-line delegates because pre-pr-client and pre-pr-validate depend on check-star-map. + +ARCHIVED, NOT PORTED: generate-star-map.py + star-map-seed.json + sculpt + tune (tooling/archive/map-bootstrap/). The generator unconditionally rewrites docs/design/star-map.json from an S-NNN-keyed seed, so re-running it would erase the GJ migration and every hand edit since. sculpt and tune operate only on S-NNN edges. .claude/rules/diagrams.md used to tell agents to "edit the generator and re-run it"; it now documents three cases: concentric SVG is a live render, the 7 sector d2 files are frozen S-keyed output (T-1294), and realcoords/topology SVGs have no generator at all.', 'backlog', 'medium', NULL, 'tooling', 'D-263', '2026-09-23 17:56:04.830', '2026-09-23 17:59:58.221', NULL, '1492a8466ccc6a7af5bd79028b0d055b', 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 ('06GCYT8JBRCX13ZD51SY4SX1WW', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port atlas map — the star-map generator family', 'The last unported family, never ticketed (T-1285 named ''reach atlas map'' as a separate ticket; none was filed). Five scripts + a seed: generate-star-map.py, generate-star-map-svg.py, generate-star-map-data.py, sculpt-star-map.py, tune-star-map-topology.py, star-map-seed.json. Per .claude/rules/diagrams.md, generate-star-map.py WRITES docs/diagrams/design/star-map-*.d2 (x7) + docs/design/star-map.json, and generate-star-map-svg.py writes the concentric/realcoords/topology SVGs; the Makefile has star-map-data / check-star-map targets (client/data/star_map_data.json). Decide per file live verb vs provenance (sculpt/tune may be one-shot topology authoring now that star-map.json is canonical and GJ-keyed). Standard port acceptance; generated outputs must come back byte-identical. + +DONE 2026-09-23. reach atlas map data [--check] + reach atlas map svg, nested under atlas like planet. Parity: star_map_data.json regenerates with a ONE-LINE diff, the _meta.note that named the old script path; the SVG is byte-identical to the old script''s output on the same data (the committed SVG itself is stale vs current data, T-1294). make check-star-map / star-map-data kept as one-line delegates because pre-pr-client and pre-pr-validate depend on check-star-map. + +ARCHIVED, NOT PORTED: generate-star-map.py + star-map-seed.json + sculpt + tune (tooling/archive/map-bootstrap/). The generator unconditionally rewrites docs/design/star-map.json from an S-NNN-keyed seed, so re-running it would erase the GJ migration and every hand edit since. sculpt and tune operate only on S-NNN edges. .claude/rules/diagrams.md used to tell agents to "edit the generator and re-run it"; it now documents three cases: concentric SVG is a live render, the 7 sector d2 files are frozen S-keyed output (T-1294), and realcoords/topology SVGs have no generator at all.', 'done', 'medium', NULL, 'tooling', 'D-263', '2026-09-23 17:56:04.830', '2026-09-23 17:59:58.640', NULL, '030333d5dbd367b41dd9429c17ca3ac2', 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 3a7249e16..800a0b5bf 100644 --- a/Makefile +++ b/Makefile @@ -517,16 +517,16 @@ audit: deny: cd server && cargo deny check -check-star-map: - @python3 tooling/generate-star-map-data.py --check +# Both delegate to `reach atlas map data` in one line and say so (D-263): +# check-star-map is a dependency of pre-pr-client / pre-pr-validate, which is +# orchestration, and star-map-data is named in the help. Regenerate after any +# systems.db change (e.g. terrain_reference, #839) so the atlas viewer picks up +# the new fields; the pre-pr targets fail on a stale file. +check-star-map: ## = reach atlas map data --check + @reach atlas map data --check -# Regenerate client/data/star_map_data.json from systems.db + wiki. Depends on -# nothing — call this after any systems.db change (e.g. the server atlas -# pipeline populating terrain_reference in #839) so the atlas viewer picks up -# the new fields. pre-pr-client / pre-pr-validate assert staleness via -# check-star-map and will fail if this step is skipped. -star-map-data: - @python3 tooling/generate-star-map-data.py +star-map-data: ## = reach atlas map data + @reach atlas map data perf-baseline: @reach dev perf diff --git a/client/data/star_map_data.json b/client/data/star_map_data.json index 58436e0a7..2c28d6b9c 100644 --- a/client/data/star_map_data.json +++ b/client/data/star_map_data.json @@ -3,7 +3,7 @@ "generated_from": "star-map.json + systems.db + wiki/star-systems", "system_count": 301, "edge_count": 335, - "note": "Client-side star map + atlas orbital data. Regenerate with: tooling/generate-star-map-data.py" + "note": "Client-side star map + atlas orbital data. Regenerate with: reach atlas map data" }, "nodes": [ { diff --git a/tooling/DOMAINS.md b/tooling/DOMAINS.md index 614146ff9..417b56062 100644 --- a/tooling/DOMAINS.md +++ b/tooling/DOMAINS.md @@ -86,7 +86,7 @@ invented. |---|---|---| | `check` | repo consistency gates the push hook runs | `check-client-version` ✅, `check-canvas-version`, `check-systems-db-stamp`, `check-fact-ids`, `check-dataflow-graph.py` | | `validate` | content and schema validation | `validate-content`, `validate-checklist`, `validate-ron` | -| `atlas` | **the whole spatial ladder** (D-191). Flat verbs for authoring and inspection; nested groups per rung for generation | flat: `atlas` (Rust binary), `atlas-check`, `atlas-names`, `atlas-commit-and-sync`, `atlas-systems-done`, `atlas-update-field`, `atlas-verify`, `atlas-flatness` · `atlas map`: the 5 star-map files · `atlas planet`: ✅ ported (T-1288) from `planet-gen/` (30) — ten verbs, each restating its module's options for real `--help`; `test_planet_router.py` fails if a router option drifts from the module parser | +| `atlas` | **the whole spatial ladder** (D-191). Flat verbs for authoring and inspection; nested groups per rung for generation | flat: `atlas` (Rust binary), `atlas-check`, `atlas-names`, `atlas-commit-and-sync`, `atlas-systems-done`, `atlas-update-field`, `atlas-verify`, `atlas-flatness` · `atlas map`: ✅ ported (T-1293) — `reach atlas map data [--check]` (client JSON; diff was only the self-naming note line) and `reach atlas map svg` (byte-identical to the old script). The generator + seed + sculpt + tune were S-keyed bootstrap and would overwrite `star-map.json`: archived, not ported · `atlas planet`: ✅ ported (T-1288) from `planet-gen/` (30) — ten verbs, each restating its module's options for real `--help`; `test_planet_router.py` fails if a router option drifts from the module parser | | ~~`starmap`~~ | **folded into `atlas map`** — the top rung of the same ladder | — | | ~~`planet`~~ | **folded into `atlas planet`** — the third rung of the same ladder | — | | `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 | diff --git a/tooling/archive/README.md b/tooling/archive/README.md index 56ed7dc27..98ccd423a 100644 --- a/tooling/archive/README.md +++ b/tooling/archive/README.md @@ -21,4 +21,7 @@ them would lose the provenance; keeping them live would imply they still work | `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). +| `map-bootstrap/generate-star-map.py` + `star-map-seed.json` | the 6-phase algorithm that produced `docs/design/star-map.json` and the 7 sector `.d2` files | **destructive today** — it rewrites `star-map.json` unconditionally from an `S-NNN`-keyed seed, which would erase the GJ migration and every hand edit since (T-1293, T-1294) | +| `map-bootstrap/sculpt-star-map.py`, `tune-star-map-topology.py` | reshaped the generated graph toward a target topology distribution | operate on `S-NNN` edges; today's map has none | + +Archived in T-1250 / T-1290 / T-1293 (2026-09-23). diff --git a/tooling/generate-star-map.py b/tooling/archive/map-bootstrap/generate-star-map.py similarity index 100% rename from tooling/generate-star-map.py rename to tooling/archive/map-bootstrap/generate-star-map.py diff --git a/tooling/sculpt-star-map.py b/tooling/archive/map-bootstrap/sculpt-star-map.py similarity index 100% rename from tooling/sculpt-star-map.py rename to tooling/archive/map-bootstrap/sculpt-star-map.py diff --git a/tooling/star-map-seed.json b/tooling/archive/map-bootstrap/star-map-seed.json similarity index 100% rename from tooling/star-map-seed.json rename to tooling/archive/map-bootstrap/star-map-seed.json diff --git a/tooling/tune-star-map-topology.py b/tooling/archive/map-bootstrap/tune-star-map-topology.py similarity index 100% rename from tooling/tune-star-map-topology.py rename to tooling/archive/map-bootstrap/tune-star-map-topology.py diff --git a/tooling/domains/atlas/map/__init__.py b/tooling/domains/atlas/map/__init__.py new file mode 100644 index 000000000..fa6de3a80 --- /dev/null +++ b/tooling/domains/atlas/map/__init__.py @@ -0,0 +1,6 @@ +"""`atlas map` — the top rung of the spatial ladder: the Reach map (D-191). + +Renders of docs/design/star-map.json, which is canonical and GJ-keyed. The +tools that generated and sculpted it are provenance in +tooling/archive/map-bootstrap/ — see router.py for why they must not run. +""" diff --git a/tooling/generate-star-map-data.py b/tooling/domains/atlas/map/data.py similarity index 83% rename from tooling/generate-star-map-data.py rename to tooling/domains/atlas/map/data.py index 7e63fe1b4..ea4b5c608 100755 --- a/tooling/generate-star-map-data.py +++ b/tooling/domains/atlas/map/data.py @@ -1,9 +1,10 @@ -#!/usr/bin/env python3 """Generate client/data/star_map_data.json from star-map.json + systems.db + wiki. -Run from any directory — paths are resolved relative to this script's location: - python3 tooling/generate-star-map-data.py - python3 tooling/generate-star-map-data.py --check # exit 1 if committed JSON is stale + reach atlas map data # regenerate + reach atlas map data --check # exit 1 if the committed JSON is stale + +Formerly tooling/generate-star-map-data.py (T-1293). The output is unchanged +except `_meta.note`, which named the old script path and now names the verb. Sources: docs/design/star-map.json — graph topology (nodes + edges) @@ -18,13 +19,13 @@ import json import os import re import sqlite3 -import sys -import tempfile -# Resolve project root from this script's location. -# Works regardless of cwd — no fragile relative path guessing. -_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -_PROJECT_ROOT = os.path.dirname(_SCRIPT_DIR) +from tooling.core import config, console +from tooling.core.errors import ReachError + +# The repo root, asked of git. The original derived it from this file's own +# location, which is right only while the file sits one level under the root. +_PROJECT_ROOT = str(config.repo_root()) STAR_MAP_PATH = os.path.join(_PROJECT_ROOT, "docs", "design", "star-map.json") SYSTEMS_DB_PATH = os.path.join(_PROJECT_ROOT, "server", "data", "systems.db") @@ -149,11 +150,9 @@ def build_adjacency(edges: list) -> dict: def generate() -> dict: """Generate the enriched star map data dict.""" if not os.path.exists(STAR_MAP_PATH): - print(f"ERROR: star-map.json not found at {STAR_MAP_PATH}", file=sys.stderr) - sys.exit(1) + raise ReachError(f"star-map.json not found at {STAR_MAP_PATH}", fix="git restore docs/design/star-map.json") if not os.path.exists(SYSTEMS_DB_PATH): - print(f"ERROR: systems.db not found at {SYSTEMS_DB_PATH}", file=sys.stderr) - sys.exit(1) + raise ReachError(f"systems.db not found at {SYSTEMS_DB_PATH}", fix="make regen-db") with open(STAR_MAP_PATH) as f: star_map = json.load(f) @@ -238,8 +237,7 @@ def generate() -> dict: "economic_role": row["economic_role"], }) except sqlite3.Error as e: - print(f"ERROR: systems.db query failed: {e}", file=sys.stderr) - sys.exit(1) + raise ReachError(f"systems.db query failed: {e}", fix="make regen-db — the schema may be behind") from e finally: conn.close() @@ -304,56 +302,44 @@ def generate() -> dict: nodes.sort(key=lambda x: (x["hop_distance"], x["system_id"])) - print(f" Wiki index data: {wiki_hits}/{len(nodes)} systems") - print(f" GTTR excerpts: {gttr_hits}/{len(nodes)} systems") + console.event(f"Wiki index data: {wiki_hits}/{len(nodes)} systems") + console.event(f"GTTR excerpts: {gttr_hits}/{len(nodes)} systems") return { "_meta": { "generated_from": "star-map.json + systems.db + wiki/star-systems", "system_count": len(nodes), "edge_count": len(star_map["edges"]), - "note": "Client-side star map + atlas orbital data. Regenerate with: tooling/generate-star-map-data.py", + "note": "Client-side star map + atlas orbital data. Regenerate with: reach atlas map data", }, "nodes": nodes, "edges": star_map["edges"], } +def render(data: dict) -> str: + """The exact bytes written — shared by write and check so they cannot disagree.""" + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + def write_output(data: dict, path: str) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write("\n") + f.write(render(data)) -def main() -> None: - check_mode = "--check" in sys.argv - +def run(check: bool = False) -> dict: + """Regenerate the client star-map data, or (check) report whether it is stale.""" data = generate() - - if check_mode: - # Generate to temp file and compare against committed JSON + if check: if not os.path.exists(OUTPUT_PATH): - print(f"STALE: {OUTPUT_PATH} does not exist — run without --check to generate") - sys.exit(1) - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: - json.dump(data, tmp, indent=2, ensure_ascii=False) - tmp.write("\n") - tmp_path = tmp.name - try: - with open(tmp_path) as a, open(OUTPUT_PATH) as b: - if a.read() != b.read(): - print(f"STALE: {OUTPUT_PATH} differs from generated output") - print("Run: python3 tooling/generate-star-map-data.py") - sys.exit(1) - print(f"OK: {OUTPUT_PATH} is up to date") - finally: - os.unlink(tmp_path) - else: - write_output(data, OUTPUT_PATH) - print(f"Generated {OUTPUT_PATH}") - print(f" Nodes: {data['_meta']['system_count']}, Edges: {data['_meta']['edge_count']}") - - -if __name__ == "__main__": - main() + raise ReachError(f"{OUTPUT_PATH} does not exist", fix="reach atlas map data") + with open(OUTPUT_PATH) as f: + if f.read() != render(data): + raise ReachError( + f"STALE: {OUTPUT_PATH} differs from generated output", + fix="reach atlas map data, then stage client/data/star_map_data.json", + ) + return {"path": OUTPUT_PATH, "stale": False} + write_output(data, OUTPUT_PATH) + return {"path": OUTPUT_PATH, "nodes": data["_meta"]["system_count"], "edges": data["_meta"]["edge_count"]} diff --git a/tooling/domains/atlas/map/router.py b/tooling/domains/atlas/map/router.py new file mode 100644 index 000000000..9bd0d0a2e --- /dev/null +++ b/tooling/domains/atlas/map/router.py @@ -0,0 +1,54 @@ +"""Transport for `atlas map` — the Reach map, the ladder's top rung (D-191, D-243). + +Two verbs, both RENDERS of the committed star-map.json. What does not appear +here is deliberate: the generator that produced star-map.json in the first +place, and the sculpt/tune passes that shaped its topology, are archived +(tooling/archive/map-bootstrap/). They are keyed on the retired S-NNN ids, and +the generator overwrites docs/design/star-map.json unconditionally from its +seed — re-running it would replace the GJ-keyed, hand-maintained map (T-1293). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import typer + +from tooling.core import cli, console +from tooling.core.command import command + +app = cli.domain("map", "The Reach map — client star-map data and the concentric SVG.") + + +@app.callback() +def _rung() -> None: + """Keeps `map` a group (Typer collapses a single-command app).""" + + +@app.command("data") +@command +def data( + check: bool = typer.Option(False, "--check", help="Fail if the committed JSON is stale; write nothing."), +) -> None: + """Regenerate client/data/star_map_data.json from star-map.json + systems.db + wiki.""" + from tooling.domains.atlas.map import data as service + + result = service.run(check=check) + if check: + console.verdict(f"star-map data: {result['path']} is up to date") + else: + console.out(json.dumps(result)) + console.verdict(f"star-map data: wrote {result['nodes']} nodes, {result['edges']} edges") + + +@app.command("svg") +@command +def svg( + output: Path = typer.Argument(None, help="SVG path (default: docs/diagrams/design/star-map-concentric.svg)."), +) -> None: + """Render the concentric-ring Reach map SVG (and a PNG beside it).""" + from tooling.domains.atlas.map import svg as service + + result = service.render(str(output) if output else service.DEFAULT_OUTPUT) + console.out(json.dumps(result)) diff --git a/tooling/domains/atlas/map/svg.py b/tooling/domains/atlas/map/svg.py new file mode 100644 index 000000000..1e9d1d488 --- /dev/null +++ b/tooling/domains/atlas/map/svg.py @@ -0,0 +1,381 @@ +"""Render the concentric-ring star map SVG from star-map.json and systems.db. + + reach atlas map svg [output.svg] + default output: docs/diagrams/design/star-map-concentric.svg (+ .png beside it) + +Rings are hop distance from the Gateway; wedges are the four cardinal reaches. +This never passes through d2 (.claude/rules/diagrams.md), and the PNG it writes +beside the default SVG is the one PNG that rule keeps on purpose. + +Formerly tooling/generate-star-map-svg.py, a module-level script (T-1293). The +drawing code is unchanged — only wrapped in render(), with its prints routed +through console and the PNG step's converter choice made a pure function. +""" + +import json +import math +import os +import shutil +import sqlite3 + +from tooling.core import config, console, process + +ROOT = str(config.repo_root()) + +STARMAP_PATH = os.path.join(ROOT, 'docs/design/star-map.json') +DB_PATH = os.path.join(ROOT, 'server/data/systems.db') +DEFAULT_OUTPUT = os.path.join(ROOT, 'docs/diagrams/design/star-map-concentric.svg') + + +def render(output_path: str = DEFAULT_OUTPUT) -> dict: + """Write the SVG (and a PNG beside it when a converter exists).""" + # --- Load data --- + with open(STARMAP_PATH) as f: + starmap = json.load(f) + + db = sqlite3.connect(DB_PATH) + sectors = dict(db.execute('SELECT system_id, geographic_sector FROM star_systems').fetchall()) + names_raw = dict(db.execute('SELECT system_id, system_name FROM star_systems').fetchall()) + waves = dict(db.execute('SELECT s.system_id, h.settlement_wave FROM star_systems s JOIN system_history h ON s.system_id = h.system_id').fetchall()) + hops_db = dict(db.execute('SELECT system_id, hop_distance_from_gateway FROM system_gates').fetchall()) + + names = {} + for sid, n in names_raw.items(): + names[sid] = n if n and 'PLACEHOLDER' not in n else sid + + adj = {} + for n in starmap['nodes']: + adj[n['system_id']] = [] + for e in starmap['edges']: + adj.setdefault(e[0], []).append(e[1]) + adj.setdefault(e[1], []).append(e[0]) + + hop_groups = {} + for n in starmap['nodes']: + sid = n['system_id'] + h = hops_db.get(sid, 99) + hop_groups.setdefault(h, []).append(sid) + + apertures = {} + for n in starmap['nodes']: + apertures[n['system_id']] = n.get('aperture_count', 1) + + max_hop = max(hop_groups.keys()) + + # Collapse hops 15+ into a single ring + FOLD_HOP = 15 + folded_nodes = [] + for h in list(hop_groups.keys()): + if h >= FOLD_HOP: + folded_nodes.extend(hop_groups.pop(h)) + if folded_nodes: + hop_groups[FOLD_HOP] = list(dict.fromkeys( + hop_groups.get(FOLD_HOP, []) + folded_nodes + )) + max_display_hop = FOLD_HOP + + # --- Layout parameters --- + RING_STEP = 55 + RING_BASE = 50 + ring_radius = {} + for h in range(max_display_hop + 1): + if h == 0: + ring_radius[h] = 0 + else: + ring_radius[h] = RING_STEP * h + RING_BASE + + # Band half-widths (how thick each ring band is) + NORMAL_BAND = 27 + FOLD_BAND = NORMAL_BAND # same width as normal rings + FADE_BAND = NORMAL_BAND * 4 # empty fade zone beyond the last ring + + # Canvas: 16:9, circle fills the height + _max_r = RING_STEP * FOLD_HOP + RING_BASE + NORMAL_BAND + FADE_BAND + 60 + height = int(_max_r * 2 + 120) + width = int(height * 16 / 9) + cx, cy = width / 2, height / 2 + + # 90-degree wedges, no gaps, centered on cardinals + sector_wedge_center = { + 'north_reach': -math.pi / 2, + 'east_reach': 0, + 'south_reach': math.pi / 2, + 'west_reach': math.pi, + } + wedge_half = math.pi / 4 # 45 degrees each side = 90 total + + # Sol override: force to due west (angle = pi) + SOL_ID = 'GJ 0' + + # --- Sector colors --- + node_colors = { + 'core': '#FFD700', + 'north_reach': '#4488FF', + 'south_reach': '#FF4444', + 'east_reach': '#44DD44', + 'west_reach': '#FF8800', + 'deep_frontier': '#AA44FF' + } + + # Ring band colors per sector (two alternating shades) + band_colors = { + 'north_reach': ('#1a2244', '#1a2a55'), + 'east_reach': ('#1a2a1a', '#1a331a'), + 'south_reach': ('#2a1a1a', '#331a1a'), + 'west_reach': ('#2a2010', '#332810'), + } + + wave_opacity = { + 'origin': 1.0, 'wave_1': 1.0, 'wave_2': 0.85, 'wave_3': 0.7, + 'wave_4': 0.55, 'wave_5': 0.4, 'unsettled': 0.25 + } + + # --- Placement --- + positions = {} + for sid in hop_groups.get(0, []): + positions[sid] = (cx, cy) + + def parent_angle(sid, h): + # For folded nodes, parents can be at any hop < FOLD_HOP + threshold = min(h, FOLD_HOP) + parents = [nb for nb in adj.get(sid, []) if hops_db.get(nb, 99) < threshold and nb in positions] + if parents: + avg_x = sum(positions[p][0] for p in parents) / len(parents) + avg_y = sum(positions[p][1] for p in parents) / len(parents) + return math.atan2(avg_y - cy, avg_x - cx) + return 0 + + for h in range(1, max_display_hop + 1): + nodes = hop_groups.get(h, []) + if not nodes: + continue + r = ring_radius[h] + + # For the fold ring, spiral nodes counter-clockwise by their actual hop + # Nodes at higher real hops get placed slightly further out + is_fold = (h == FOLD_HOP) + + # Bucket by sector + buckets = {} + for sid in nodes: + s = sectors.get(sid, 'deep_frontier') + buckets.setdefault(s, []).append(sid) + + placed = [] + + # Cardinal sectors: strict wedge placement + for sector_name, center_angle in sector_wedge_center.items(): + bucket = buckets.get(sector_name, []) + if not bucket: + continue + if is_fold: + # Sort by actual hop distance (higher = further in spiral), then parent angle + bucket.sort(key=lambda s: (hops_db.get(s, 99), parent_angle(s, h))) + else: + bucket.sort(key=lambda s: parent_angle(s, h)) + n = len(bucket) + lo = center_angle - wedge_half + hi = center_angle + wedge_half + for i, sid in enumerate(bucket): + angle = lo + (hi - lo) * (i + 0.5) / n + placed.append((angle, sid)) + + # Core: place near parents + for sid in buckets.get('core', []): + pa = parent_angle(sid, h) + placed.append((pa, sid)) + + # Deep frontier: assign to nearest cardinal wedge + for sid in buckets.get('deep_frontier', []): + pa = parent_angle(sid, h) + best_sector = min(sector_wedge_center.items(), + key=lambda sc: min(abs(pa - sc[1]), + abs(pa - sc[1] + 2*math.pi), + abs(pa - sc[1] - 2*math.pi))) + center = best_sector[1] + lo = center - wedge_half + hi = center + wedge_half + clamped = max(lo + 0.02, min(hi - 0.02, pa)) + placed.append((clamped, sid)) + + # Sol override + for i, (angle, sid) in enumerate(placed): + if sid == SOL_ID: + placed[i] = (math.pi, sid) + break + + # Sort and resolve overlaps + placed.sort() + min_gap = 2 * math.pi / (len(placed) + 1) * 0.7 + for _ in range(30): + moved = False + for i in range(len(placed)): + j = (i + 1) % len(placed) + a1 = placed[i][0] + a2 = placed[j][0] + diff = a2 - a1 + if j == 0: + diff += 2 * math.pi + if diff < min_gap: + nudge = (min_gap - diff) / 2 + placed[i] = (a1 - nudge, placed[i][1]) + placed[j] = (a2 + nudge, placed[j][1]) + moved = True + if not moved: + break + + for angle, sid in placed: + if is_fold: + # Subtle counter-clockwise spiral by real hop distance + real_hop = hops_db.get(sid, FOLD_HOP) + hop_frac = (real_hop - FOLD_HOP) / max(max_hop - FOLD_HOP, 1) + spiral_offset = -hop_frac * 0.12 + x = cx + r * math.cos(angle + spiral_offset) + y = cy + r * math.sin(angle + spiral_offset) + else: + x = cx + r * math.cos(angle) + y = cy + r * math.sin(angle) + positions[sid] = (x, y) + + # --- SVG rendering --- + svg = [] + svg.append(f'') + + # Defs for the fade gradient beyond the last ring + # Use userSpaceOnUse so the gradient is a true circle regardless of canvas aspect ratio + svg.append('') + fade_start = ring_radius[FOLD_HOP] + FOLD_BAND + fade_end = fade_start + FADE_BAND + # Gradient radius must cover corners of canvas + grad_r = max(width, height) + start_pct = fade_start / grad_r * 100 + end_pct = fade_end / grad_r * 100 + svg.append(f'') + svg.append(' ') + svg.append(f' ') + svg.append(f' ') + svg.append(' ') + svg.append('') + svg.append('') + + svg.append(f'') + + # Alternating sector band rings + for sector_name, center_angle in sector_wedge_center.items(): + lo = center_angle - wedge_half + hi = center_angle + wedge_half + c1, c2 = band_colors[sector_name] + + for h in range(1, max_display_hop + 1): + band = NORMAL_BAND + r_inner = ring_radius[h] - band + r_outer = ring_radius[h] + band + if r_inner < 0: + r_inner = 0 + color = c1 if h % 2 == 0 else c2 + opacity = 0.35 + + x1_out = cx + r_outer * math.cos(lo) + y1_out = cy + r_outer * math.sin(lo) + x2_out = cx + r_outer * math.cos(hi) + y2_out = cy + r_outer * math.sin(hi) + x1_in = cx + r_inner * math.cos(hi) + y1_in = cy + r_inner * math.sin(hi) + x2_in = cx + r_inner * math.cos(lo) + y2_in = cy + r_inner * math.sin(lo) + + svg.append(f'') + + # Fade overlay on outer edge + svg.append(f'') + + # Cardinal labels — placed in the fade zone + label_r = ring_radius[FOLD_HOP] + FOLD_BAND + FADE_BAND * 0.4 + svg.append(f'NORTH REACH') + svg.append(f'SOUTH REACH') + svg.append(f'EAST REACH') + svg.append(f'WEST REACH') + + # Edges + for e in starmap['edges']: + if e[0] in positions and e[1] in positions: + x1, y1 = positions[e[0]] + x2, y2 = positions[e[1]] + svg.append(f'') + + # Nodes + for n in starmap['nodes']: + sid = n['system_id'] + if sid not in positions: + continue + x, y = positions[sid] + sector = sectors.get(sid, 'deep_frontier') + color = node_colors.get(sector, '#AA44FF') + wave = waves.get(sid, 'wave_3') + hop = hops_db.get(sid, 99) + opacity = wave_opacity.get(wave, 0.5) + + nr = 8 if sid == 'GJ 71' else (5 if sector == 'core' else (3 if hop <= 5 else 2)) + svg.append(f'') + + name = names.get(sid, sid) + ap = apertures.get(sid, 1) + if sector == 'core' or ap >= 5 or hop <= 3: + fs = 9 if sector == 'core' else 7 + svg.append(f'{name}') + + # Legend + lx, ly = 30, 40 + svg.append(f'THE SETTLED REACH') + ly += 8 + for sector, color in node_colors.items(): + svg.append(f'') + svg.append(f'{sector}') + ly += 20 + ly += 10 + svg.append(f'rings = hop distance from Gateway (center)') + ly += 14 + svg.append(f'outer ring = hop 15+ (deep frontier fade)') + ly += 14 + svg.append(f'opacity = settlement wave (bright=ancient, dim=recent)') + + svg.append('') + + with open(output_path, 'w') as f: + f.write('\n'.join(svg)) + console.event(f'Written to {output_path}') + console.event(f'{len(positions)} nodes, {len(starmap["edges"])} edges') + console.event(f'Hops 0-{FOLD_HOP-1} individual, {FOLD_HOP}+ folded ({len(hop_groups.get(FOLD_HOP, []))} nodes)') + + png = render_png(output_path) + return {"svg": output_path, "png": png, "nodes": len(positions), "edges": len(starmap["edges"])} + + +def png_command(svg_path: str, png_path: str, which=shutil.which) -> list[str] | None: + """The SVG→PNG converter argv, in the original preference order — pure.""" + if which('magick'): + return ['magick', '-density', '150', '-background', '#0a0a1a', svg_path, png_path] + if which('convert'): + return ['convert', '-density', '150', '-background', '#0a0a1a', svg_path, png_path] + if which('rsvg-convert'): + return ['rsvg-convert', '-d', '150', '-p', '150', '-b', '#0a0a1a', '-o', png_path, svg_path] + return None + + +def render_png(svg_path: str) -> str | None: + """Rasterise beside the SVG. A missing or failing converter warns, as before.""" + png_path = svg_path.replace('.svg', '.png') + cmd = png_command(svg_path, png_path) + if cmd is None: + console.event('No SVG-to-PNG converter found (tried magick, convert, rsvg-convert). PNG not generated.', + level='warn') + return None + result = process.run(cmd, check=False) + if result.returncode != 0: + console.event(f'PNG conversion failed: {result.stderr.strip()}', level='warn') + return None + console.event(f'PNG written to {png_path}') + return png_path diff --git a/tooling/domains/atlas/router.py b/tooling/domains/atlas/router.py index 923190f4c..db688d6d3 100644 --- a/tooling/domains/atlas/router.py +++ b/tooling/domains/atlas/router.py @@ -156,13 +156,16 @@ def flatness_report( ) -# --- the rungs below the body surface ------------------------------------- +# --- the rungs, as nested groups ----------------------------------------- # -# `planet` is a nested GROUP, not a domain of its own: the ladder is one -# subject (D-243), and a body is a rung of it rather than a peer. It is added -# at import time but its own module tree loads only when a `planet` verb runs -# — same lazy contract as the domains themselves. +# `map` (the Reach, the top rung) and `planet` (a body, the third) are nested +# GROUPS, not domains of their own: the ladder is one subject (D-243), and each +# rung is part of it rather than a peer. They are added at import time, but +# their module trees load only when one of their verbs runs — the same lazy +# contract as the domains themselves. +from tooling.domains.atlas.map.router import app as _map_app # noqa: E402 from tooling.domains.atlas.planet.router import app as _planet_app # noqa: E402 +app.add_typer(_map_app, name="map") app.add_typer(_planet_app, name="planet") diff --git a/tooling/generate-star-map-svg.py b/tooling/generate-star-map-svg.py deleted file mode 100644 index 2ca1f699e..000000000 --- a/tooling/generate-star-map-svg.py +++ /dev/null @@ -1,363 +0,0 @@ -#!/usr/bin/env python3 -"""Generate concentric-ring star map SVG from star-map.json and systems.db. - -Usage: python3 tooling/generate-star-map-svg.py [output.svg] -Default output: docs/diagrams/design/star-map-concentric.svg -""" - -import json -import sqlite3 -import math -import sys -import os - -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.dirname(SCRIPT_DIR) - -STARMAP_PATH = os.path.join(ROOT, 'docs/design/star-map.json') -DB_PATH = os.path.join(ROOT, 'server/data/systems.db') -DEFAULT_OUTPUT = os.path.join(ROOT, 'docs/diagrams/design/star-map-concentric.svg') - -output_path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_OUTPUT - -# --- Load data --- -with open(STARMAP_PATH) as f: - starmap = json.load(f) - -db = sqlite3.connect(DB_PATH) -sectors = dict(db.execute('SELECT system_id, geographic_sector FROM star_systems').fetchall()) -names_raw = dict(db.execute('SELECT system_id, system_name FROM star_systems').fetchall()) -waves = dict(db.execute('SELECT s.system_id, h.settlement_wave FROM star_systems s JOIN system_history h ON s.system_id = h.system_id').fetchall()) -hops_db = dict(db.execute('SELECT system_id, hop_distance_from_gateway FROM system_gates').fetchall()) - -names = {} -for sid, n in names_raw.items(): - names[sid] = n if n and 'PLACEHOLDER' not in n else sid - -adj = {} -for n in starmap['nodes']: - adj[n['system_id']] = [] -for e in starmap['edges']: - adj.setdefault(e[0], []).append(e[1]) - adj.setdefault(e[1], []).append(e[0]) - -hop_groups = {} -for n in starmap['nodes']: - sid = n['system_id'] - h = hops_db.get(sid, 99) - hop_groups.setdefault(h, []).append(sid) - -apertures = {} -for n in starmap['nodes']: - apertures[n['system_id']] = n.get('aperture_count', 1) - -max_hop = max(hop_groups.keys()) - -# Collapse hops 15+ into a single ring -FOLD_HOP = 15 -folded_nodes = [] -for h in list(hop_groups.keys()): - if h >= FOLD_HOP: - folded_nodes.extend(hop_groups.pop(h)) -if folded_nodes: - hop_groups[FOLD_HOP] = list(dict.fromkeys( - hop_groups.get(FOLD_HOP, []) + folded_nodes - )) -max_display_hop = FOLD_HOP - -# --- Layout parameters --- -RING_STEP = 55 -RING_BASE = 50 -ring_radius = {} -for h in range(max_display_hop + 1): - if h == 0: - ring_radius[h] = 0 - else: - ring_radius[h] = RING_STEP * h + RING_BASE - -# Band half-widths (how thick each ring band is) -NORMAL_BAND = 27 -FOLD_BAND = NORMAL_BAND # same width as normal rings -FADE_BAND = NORMAL_BAND * 4 # empty fade zone beyond the last ring - -# Canvas: 16:9, circle fills the height -_max_r = RING_STEP * FOLD_HOP + RING_BASE + NORMAL_BAND + FADE_BAND + 60 -height = int(_max_r * 2 + 120) -width = int(height * 16 / 9) -cx, cy = width / 2, height / 2 - -# 90-degree wedges, no gaps, centered on cardinals -sector_wedge_center = { - 'north_reach': -math.pi / 2, - 'east_reach': 0, - 'south_reach': math.pi / 2, - 'west_reach': math.pi, -} -wedge_half = math.pi / 4 # 45 degrees each side = 90 total - -# Sol override: force to due west (angle = pi) -SOL_ID = 'GJ 0' - -# --- Sector colors --- -node_colors = { - 'core': '#FFD700', - 'north_reach': '#4488FF', - 'south_reach': '#FF4444', - 'east_reach': '#44DD44', - 'west_reach': '#FF8800', - 'deep_frontier': '#AA44FF' -} - -# Ring band colors per sector (two alternating shades) -band_colors = { - 'north_reach': ('#1a2244', '#1a2a55'), - 'east_reach': ('#1a2a1a', '#1a331a'), - 'south_reach': ('#2a1a1a', '#331a1a'), - 'west_reach': ('#2a2010', '#332810'), -} - -wave_opacity = { - 'origin': 1.0, 'wave_1': 1.0, 'wave_2': 0.85, 'wave_3': 0.7, - 'wave_4': 0.55, 'wave_5': 0.4, 'unsettled': 0.25 -} - -# --- Placement --- -positions = {} -for sid in hop_groups.get(0, []): - positions[sid] = (cx, cy) - -def parent_angle(sid, h): - # For folded nodes, parents can be at any hop < FOLD_HOP - threshold = min(h, FOLD_HOP) - parents = [nb for nb in adj.get(sid, []) if hops_db.get(nb, 99) < threshold and nb in positions] - if parents: - avg_x = sum(positions[p][0] for p in parents) / len(parents) - avg_y = sum(positions[p][1] for p in parents) / len(parents) - return math.atan2(avg_y - cy, avg_x - cx) - return 0 - -for h in range(1, max_display_hop + 1): - nodes = hop_groups.get(h, []) - if not nodes: - continue - r = ring_radius[h] - - # For the fold ring, spiral nodes counter-clockwise by their actual hop - # Nodes at higher real hops get placed slightly further out - is_fold = (h == FOLD_HOP) - - # Bucket by sector - buckets = {} - for sid in nodes: - s = sectors.get(sid, 'deep_frontier') - buckets.setdefault(s, []).append(sid) - - placed = [] - - # Cardinal sectors: strict wedge placement - for sector_name, center_angle in sector_wedge_center.items(): - bucket = buckets.get(sector_name, []) - if not bucket: - continue - if is_fold: - # Sort by actual hop distance (higher = further in spiral), then parent angle - bucket.sort(key=lambda s: (hops_db.get(s, 99), parent_angle(s, h))) - else: - bucket.sort(key=lambda s: parent_angle(s, h)) - n = len(bucket) - lo = center_angle - wedge_half - hi = center_angle + wedge_half - for i, sid in enumerate(bucket): - angle = lo + (hi - lo) * (i + 0.5) / n - placed.append((angle, sid)) - - # Core: place near parents - for sid in buckets.get('core', []): - pa = parent_angle(sid, h) - placed.append((pa, sid)) - - # Deep frontier: assign to nearest cardinal wedge - for sid in buckets.get('deep_frontier', []): - pa = parent_angle(sid, h) - best_sector = min(sector_wedge_center.items(), - key=lambda sc: min(abs(pa - sc[1]), - abs(pa - sc[1] + 2*math.pi), - abs(pa - sc[1] - 2*math.pi))) - center = best_sector[1] - lo = center - wedge_half - hi = center + wedge_half - clamped = max(lo + 0.02, min(hi - 0.02, pa)) - placed.append((clamped, sid)) - - # Sol override - for i, (angle, sid) in enumerate(placed): - if sid == SOL_ID: - placed[i] = (math.pi, sid) - break - - # Sort and resolve overlaps - placed.sort() - min_gap = 2 * math.pi / (len(placed) + 1) * 0.7 - for _ in range(30): - moved = False - for i in range(len(placed)): - j = (i + 1) % len(placed) - a1 = placed[i][0] - a2 = placed[j][0] - diff = a2 - a1 - if j == 0: - diff += 2 * math.pi - if diff < min_gap: - nudge = (min_gap - diff) / 2 - placed[i] = (a1 - nudge, placed[i][1]) - placed[j] = (a2 + nudge, placed[j][1]) - moved = True - if not moved: - break - - for angle, sid in placed: - if is_fold: - # Subtle counter-clockwise spiral by real hop distance - real_hop = hops_db.get(sid, FOLD_HOP) - hop_frac = (real_hop - FOLD_HOP) / max(max_hop - FOLD_HOP, 1) - spiral_offset = -hop_frac * 0.12 - x = cx + r * math.cos(angle + spiral_offset) - y = cy + r * math.sin(angle + spiral_offset) - else: - x = cx + r * math.cos(angle) - y = cy + r * math.sin(angle) - positions[sid] = (x, y) - -# --- SVG rendering --- -svg = [] -svg.append(f'') - -# Defs for the fade gradient beyond the last ring -# Use userSpaceOnUse so the gradient is a true circle regardless of canvas aspect ratio -svg.append('') -fade_start = ring_radius[FOLD_HOP] + FOLD_BAND -fade_end = fade_start + FADE_BAND -# Gradient radius must cover corners of canvas -grad_r = max(width, height) -start_pct = fade_start / grad_r * 100 -end_pct = fade_end / grad_r * 100 -svg.append(f'') -svg.append(' ') -svg.append(f' ') -svg.append(f' ') -svg.append(' ') -svg.append('') -svg.append('') - -svg.append(f'') - -# Alternating sector band rings -for sector_name, center_angle in sector_wedge_center.items(): - lo = center_angle - wedge_half - hi = center_angle + wedge_half - c1, c2 = band_colors[sector_name] - - for h in range(1, max_display_hop + 1): - band = NORMAL_BAND - r_inner = ring_radius[h] - band - r_outer = ring_radius[h] + band - if r_inner < 0: - r_inner = 0 - color = c1 if h % 2 == 0 else c2 - opacity = 0.35 - - x1_out = cx + r_outer * math.cos(lo) - y1_out = cy + r_outer * math.sin(lo) - x2_out = cx + r_outer * math.cos(hi) - y2_out = cy + r_outer * math.sin(hi) - x1_in = cx + r_inner * math.cos(hi) - y1_in = cy + r_inner * math.sin(hi) - x2_in = cx + r_inner * math.cos(lo) - y2_in = cy + r_inner * math.sin(lo) - - svg.append(f'') - -# Fade overlay on outer edge -svg.append(f'') - -# Cardinal labels — placed in the fade zone -label_r = ring_radius[FOLD_HOP] + FOLD_BAND + FADE_BAND * 0.4 -svg.append(f'NORTH REACH') -svg.append(f'SOUTH REACH') -svg.append(f'EAST REACH') -svg.append(f'WEST REACH') - -# Edges -for e in starmap['edges']: - if e[0] in positions and e[1] in positions: - x1, y1 = positions[e[0]] - x2, y2 = positions[e[1]] - svg.append(f'') - -# Nodes -for n in starmap['nodes']: - sid = n['system_id'] - if sid not in positions: - continue - x, y = positions[sid] - sector = sectors.get(sid, 'deep_frontier') - color = node_colors.get(sector, '#AA44FF') - wave = waves.get(sid, 'wave_3') - hop = hops_db.get(sid, 99) - opacity = wave_opacity.get(wave, 0.5) - - nr = 8 if sid == 'GJ 71' else (5 if sector == 'core' else (3 if hop <= 5 else 2)) - svg.append(f'') - - name = names.get(sid, sid) - ap = apertures.get(sid, 1) - if sector == 'core' or ap >= 5 or hop <= 3: - fs = 9 if sector == 'core' else 7 - svg.append(f'{name}') - -# Legend -lx, ly = 30, 40 -svg.append(f'THE SETTLED REACH') -ly += 8 -for sector, color in node_colors.items(): - svg.append(f'') - svg.append(f'{sector}') - ly += 20 -ly += 10 -svg.append(f'rings = hop distance from Gateway (center)') -ly += 14 -svg.append(f'outer ring = hop 15+ (deep frontier fade)') -ly += 14 -svg.append(f'opacity = settlement wave (bright=ancient, dim=recent)') - -svg.append('') - -with open(output_path, 'w') as f: - f.write('\n'.join(svg)) -print(f'Written to {output_path}') -print(f'{len(positions)} nodes, {len(starmap["edges"])} edges') -print(f'Hops 0-{FOLD_HOP-1} individual, {FOLD_HOP}+ folded ({len(hop_groups.get(FOLD_HOP, []))} nodes)') - -# Also render PNG -import shutil -import subprocess -png_path = output_path.replace('.svg', '.png') -if shutil.which('magick'): - cmd = ['magick', '-density', '150', '-background', '#0a0a1a', output_path, png_path] -elif shutil.which('convert'): - cmd = ['convert', '-density', '150', '-background', '#0a0a1a', output_path, png_path] -elif shutil.which('rsvg-convert'): - cmd = ['rsvg-convert', '-d', '150', '-p', '150', '-b', '#0a0a1a', '-o', png_path, output_path] -else: - cmd = None - print('No SVG-to-PNG converter found (tried magick, convert, rsvg-convert). PNG not generated.') - -if cmd: - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode == 0: - print(f'PNG written to {png_path}') - else: - print(f'PNG conversion failed: {result.stderr.strip()}')