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'')
+
+ 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'')
-
-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()}')