refactor(tooling): T-1288 — planet-gen becomes reach atlas planet

The 30-file tree moves under atlas as its third rung (D-243), ten verbs
fronting it. Each verb restates its module's options so `--help` describes
something; tooling/test_planet_router.py hands every declared option to the
module's own argparse and fails on drift, and now runs in make test-tooling.

The 2026-09-02 half of this move had converted the top-level imports and the
repo roots. Finishing it found what the half-move left:

- Lazy in-function imports, and all of sol_data/, still named siblings bare.
  They resolved only through sys.path.insert hacks, so under reach the first
  globe render in generate, batch or sol-import would have raised
  ModuleNotFoundError. Qualified; the hacks are gone.
- 247 print() calls and a stdout progress writer that fired once per 8 KB
  block. Report verbs (audit, quality) write through console.out, progress
  through console.event, and download progress is throttled to 10% steps
  so a job log is not tens of thousands of lines.
- Every error exit raises ReachError with a fix.

Two checks that could not fail:

- batch --verify-determinism printed a warning and exited 0 on a mismatch.
- import-provinces exited 0 with errors > 0.

Both now raise. The 271-body bake is only safe to re-run because the first
one holds.

sol-import --body is action="append" in the module but the router took one
value, so --body GJ0d --body GJ0e kept one. Now repeatable, and _flags repeats
list options.

test_conformance walked one level, so a nested group was reported as a verb
missing @command and its ten verbs were never checked. It recurses now;
proven by stripping @command from `planet quality` and watching it fail.

Stray PNGs from the 2026-09-03 runaway router-test run are parked in
.cache/t1288-stray-pngs/, not committed. Their reliefmaps differ from HEAD
while the heightmap regenerated byte-identical — filed as T-1291.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:08:02 +02:00
co-authored by Claude Opus 5.5
parent 201dabd19b
commit 668772075c
56 changed files with 1130 additions and 509 deletions
+22
View File
@@ -0,0 +1,22 @@
"""`atlas planet` — the third rung of the spatial ladder (D-243).
Nested under `atlas` rather than standing alone because atlas coordinates
world-location generation: the ladder is one subject, and `planet` is a rung of
it, not a peer. Same reason `atlas map` holds the star-map verbs.
Formerly `tooling/planet-gen/`, 30 files reachable only by path. The move fixed
what a hyphenated directory made impossible — these modules import each other,
and until now did it by mutating `sys.path` at import time.
**Every repo-root computation here was wrong on arrival.** The originals
computed `Path(__file__).parent / ".." / ".."`, correct while the files sat two
levels deep and silently wrong at four. That is the fifth-through-ninth
instance of this trap in the port, and it fails quietly: a gate resolves its
inputs to a directory that does not exist, finds nothing to check, and exits 0.
They now call `config.repo_root()`, which asks git.
`PLANET_DIR` (was `TOOLING_DIR`) still means "beside this code" and still
resolves correctly — the data files it addresses, `biomes.toml`,
`sol_overrides.json`, `sol_markers/` and `earth_blocklist.txt`, moved with it.
The rename is so the name stops claiming to be `tooling/`.
"""
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""
atlas_cohesion_audit.py — Quality analysis script for atlas atlas_* tables.
Runs a set of SQL queries against server/data/systems.db atlas_* tables to
identify names that need hand-refining:
1. Stem duplicates within a system (same root token, different bodies)
2. Cross-feature same-body near-duplicates (e.g. Aureus River + Aureus Range)
3. Cardinal direction density per body (Western X / Northern X patterns)
4. Generic / lazy outputs (Great %, Hilly %, Sector %, The %)
5. Empty or single-character names
6. Earth-echo concentration in a given system
Usage:
reach atlas planet audit
reach atlas planet audit --system GJ 144
reach atlas planet audit --body GJ144d
Decisions: D-191 (atlas pipeline, corridor palettes, markers.json format)
"""
import argparse
import sqlite3
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
CARDINALS = ("North", "South", "East", "West", "Northern", "Southern",
"Eastern", "Western", "Upper", "Lower", "Far", "Kita", "Minami",
"Higashi", "Nishi")
LAZY_PATTERNS = (
"Great %", "Hilly %", "Sector %", "Flat %", "High %",
"Big %", "Little %", "Low %", "Deep %", "Old %",
"% Bluff", "% View", "% Gulch",
"Flatland %", "Prairie %", "Desert %", "Canyon %",
"% Crossing", "% Bend", "% Fork", "% Run", "% Creek",
"% Point", "% Pass", "% Peak", "% Ridge", "% Stream",
"% Ground", "% Slope", "% Mesa",
)
EARTH_ECHOES = (
"Manchester", "London", "Paris", "Berlin", "Madrid",
"Sierra Nevada", "Blue Ridge", "Appalachian",
"Route %", "Highway %", "Interstate %",
"New York", "Los Angeles", "Chicago",
"Timberline", "Riverbend", "Greenfield",
"Stony Creek", "Iron Creek", "Pine Gulch", "Valley View",
)
def get_conn(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
return conn
def section(title: str) -> None:
console.out(f"\n{'='*70}")
console.out(f" {title}")
console.out('='*70)
def run_all_features_for_body(conn, body_id: str) -> list[tuple[str, str]]:
"""Return (table_label, name) for all named features on a body."""
features = []
for table, label in [
("atlas_cities", "city"),
("atlas_rivers", "river"),
("atlas_oceans", "ocean"),
("atlas_mountain_ranges", "mountain"),
("atlas_pois", "poi"),
]:
rows = conn.execute(
f"SELECT name FROM {table} WHERE body_id=? AND name != ''", (body_id,)
).fetchall()
for r in rows:
features.append((label, r["name"]))
return features
def stem_of(name: str) -> str:
"""Extract first significant word as a rough stem."""
parts = name.replace("The ", "").replace("the ", "").strip().split()
return parts[0].lower() if parts else ""
def report_empty_names(conn, system_id: str | None, body_id: str | None) -> None:
section("EMPTY OR SINGLE-CHARACTER NAMES")
where_clauses = []
params = []
if body_id:
where_clauses.append("body_id = ?")
params.append(body_id)
elif system_id:
where_clauses.append("body_id IN (SELECT body_id FROM bodies WHERE system_id = ?)")
params.append(system_id)
w = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
found = 0
for table in ("atlas_cities", "atlas_rivers", "atlas_oceans",
"atlas_mountain_ranges", "atlas_pois"):
rows = conn.execute(
f"SELECT body_id, local_id, name FROM {table} {w} "
f"AND (name = '' OR LENGTH(name) < 2)", params
).fetchall()
for r in rows:
console.out(f" [{table}] {r['body_id']}/{r['local_id']}: '{r['name']}'")
found += 1
if not found:
console.out(" None found.")
def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> None:
section("GENERIC / LAZY OUTPUTS")
where_body = ""
params_base = []
if body_id:
where_body = "AND body_id = ?"
params_base = [body_id]
elif system_id:
where_body = "AND body_id IN (SELECT body_id FROM bodies WHERE system_id = ?)"
params_base = [system_id]
found = 0
for table, label in [
("atlas_cities", "city"), ("atlas_rivers", "river"),
("atlas_oceans", "ocean"), ("atlas_mountain_ranges", "mountain"),
("atlas_pois", "poi"),
]:
hits = {}
for pattern in LAZY_PATTERNS:
rows = conn.execute(
f"SELECT body_id, local_id, name FROM {table} "
f"WHERE name LIKE ? {where_body}",
[pattern] + params_base,
).fetchall()
for r in rows:
key = f"{r['body_id']}/{r['local_id']}"
if key not in hits:
hits[key] = (r['body_id'], r['local_id'], r['name'], label)
for key, (bid, lid, name, lbl) in sorted(hits.items()):
console.out(f" [{lbl}] {bid}/{lid}: '{name}'")
found += 1
if not found:
console.out(" None found.")
def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
section("CARDINAL DIRECTION NAMES (check if map-orientation correlates)")
where_body = ""
params_base = []
if body_id:
where_body = "AND body_id = ?"
params_base = [body_id]
elif system_id:
where_body = "AND body_id IN (SELECT body_id FROM bodies WHERE system_id = ?)"
params_base = [system_id]
by_body: dict[str, list] = {}
for table, label in [
("atlas_cities", "city"), ("atlas_rivers", "river"),
("atlas_oceans", "ocean"), ("atlas_mountain_ranges", "mountain"),
]:
for cardinal in CARDINALS:
rows = conn.execute(
f"SELECT body_id, local_id, name FROM {table} "
f"WHERE (name LIKE ? OR name LIKE ?) {where_body}",
[f"{cardinal} %", f"% {cardinal} %"] + params_base,
).fetchall()
for r in rows:
by_body.setdefault(r["body_id"], []).append(
(label, r["local_id"], r["name"])
)
if not by_body:
console.out(" None found.")
return
for bid, entries in sorted(by_body.items()):
console.out(f" {bid}: {len(entries)} cardinal name(s)")
for lbl, lid, name in entries:
console.out(f" [{lbl}] {lid}: '{name}'")
def report_earth_echoes(conn, system_id: str | None, body_id: str | None) -> None:
section("EARTH-ECHO CONCENTRATION (allowed but check intentionality)")
where_body = ""
params_base = []
if body_id:
where_body = "AND body_id = ?"
params_base = [body_id]
elif system_id:
where_body = "AND body_id IN (SELECT body_id FROM bodies WHERE system_id = ?)"
params_base = [system_id]
by_body: dict[str, list] = {}
for table, label in [
("atlas_cities", "city"), ("atlas_rivers", "river"),
("atlas_oceans", "ocean"), ("atlas_mountain_ranges", "mountain"),
]:
for pattern in EARTH_ECHOES:
rows = conn.execute(
f"SELECT body_id, local_id, name FROM {table} "
f"WHERE name LIKE ? {where_body}",
[f"%{pattern.replace('%', '')}%"] + params_base,
).fetchall()
for r in rows:
key = (r["body_id"], label, r["local_id"])
by_body.setdefault(r["body_id"], []).append(
(label, r["local_id"], r["name"])
)
if not by_body:
console.out(" None found.")
return
for bid, entries in sorted(by_body.items()):
console.out(f" {bid}: {len(entries)} earth-echo(s)")
for lbl, lid, name in entries:
console.out(f" [{lbl}] {lid}: '{name}'")
def report_same_body_stem_dupes(conn, system_id: str | None, body_id: str | None) -> None:
section("SAME-BODY CROSS-FEATURE STEM DUPLICATES")
where = ""
params = []
if body_id:
where = "WHERE body_id = ?"
params = [body_id]
elif system_id:
where = "WHERE system_id = ?"
params = [system_id]
bodies_q = conn.execute(
f"SELECT body_id FROM bodies {where}", params
).fetchall()
found = 0
for row in bodies_q:
bid = row["body_id"]
features = run_all_features_for_body(conn, bid)
stem_to_features: dict[str, list] = {}
for label, name in features:
s = stem_of(name)
if s and len(s) > 3:
stem_to_features.setdefault(s, []).append((label, name))
for stem, items in sorted(stem_to_features.items()):
if len(items) > 1:
types = set(i[0] for i in items)
if len(types) > 1: # only flag cross-feature (different types)
console.out(f" {bid} stem='{stem}':")
for lbl, nm in items:
console.out(f" [{lbl}] '{nm}'")
found += 1
if not found:
console.out(" None found.")
def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
section("CROSS-BODY STEM DUPLICATES WITHIN SYSTEM (same corridor)")
if not system_id:
console.out(" (requires --system; skipped)")
return
bodies_q = conn.execute(
"SELECT body_id FROM bodies WHERE system_id = ?", (system_id,)
).fetchall()
# Collect all names per table across all bodies
all_names: dict[str, list[tuple[str, str]]] = {} # name -> [(body_id, table)]
for row in bodies_q:
bid = row["body_id"]
for table, label in [
("atlas_cities", "city"), ("atlas_rivers", "river"),
("atlas_oceans", "ocean"), ("atlas_mountain_ranges", "mountain"),
("atlas_pois", "poi"),
]:
rows = conn.execute(
f"SELECT name FROM {table} WHERE body_id=? AND name != ''", (bid,)
).fetchall()
for r in rows:
name = r["name"]
s = stem_of(name)
if s and len(s) > 3:
all_names.setdefault(s, []).append((bid, label, name))
found = 0
for stem, hits in sorted(all_names.items()):
bodies_hit = set(h[0] for h in hits)
if len(bodies_hit) > 1:
console.out(f" stem='{stem}' appears in {len(bodies_hit)} bodies:")
for bid, label, name in hits:
console.out(f" {bid} [{label}] '{name}'")
found += 1
if not found:
console.out(" None found.")
def report_summary_score(conn, system_id: str | None, body_id: str | None) -> None:
section("BODY SUMMARY SCORES")
where = ""
params = []
if body_id:
where = "WHERE body_id = ?"
params = [body_id]
elif system_id:
where = "WHERE system_id = ?"
params = [system_id]
else:
where = "WHERE inhabited = 1"
bodies_q = conn.execute(
f"SELECT body_id, proper_name, population FROM bodies {where}", params
).fetchall()
console.out(f" {'body_id':<20} {'name':<20} {'pop':<12} cities rivers oceans mounts pois")
for row in bodies_q:
bid = row["body_id"]
name = row["proper_name"] or ""
pop = row["population"] or 0
counts = {}
for table, key in [
("atlas_cities", "c"), ("atlas_rivers", "r"),
("atlas_oceans", "o"), ("atlas_mountain_ranges", "m"), ("atlas_pois", "p"),
]:
n = conn.execute(
f"SELECT COUNT(*) FROM {table} WHERE body_id=?", (bid,)
).fetchone()[0]
counts[key] = n
console.out(f" {bid:<20} {name:<20} {pop:<12,} "
f"{counts['c']:>5} {counts['r']:>6} {counts['o']:>6} "
f"{counts['m']:>6} {counts['p']:>4}")
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--system", help="GJ catalog ID (e.g. 'GJ 144')")
parser.add_argument("--body", help="Body ID (e.g. 'GJ144d')")
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
args = parser.parse_args(argv)
db = Path(args.db)
if not db.exists():
raise ReachError(f"database not found at {db}", fix="make regen-db, or point --db at an existing systems.db")
conn = get_conn(db)
system_id = args.system
body_id = args.body
if body_id and not system_id:
row = conn.execute(
"SELECT system_id FROM bodies WHERE body_id=?", (body_id,)
).fetchone()
if row:
system_id = row["system_id"]
console.out("\nAtlas Cohesion Audit")
console.out(f" DB: {db}")
if system_id:
console.out(f" System: {system_id}")
if body_id:
console.out(f" Body: {body_id}")
report_summary_score(conn, system_id, body_id)
report_empty_names(conn, system_id, body_id)
report_generic_lazy(conn, system_id, body_id)
report_cardinals(conn, system_id, body_id)
report_earth_echoes(conn, system_id, body_id)
report_same_body_stem_dupes(conn, system_id, body_id)
if system_id and not body_id:
report_cross_body_stem_dupes(conn, system_id)
conn.close()
console.out("\nDone.\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
atlas_common.py — shared atlas-DB utilities for the atlas planet importers.
Extracted from the retired generate_atlas.py (D-223, #951). The atlas city/road/
river geometry *generator* was retired when authored geometry was dropped in
favour of the deterministic server-side cascade (Phase 4); markers.json is now a
flavoured name pool only. What remained were the schema/DB helpers shared by the
surviving build-time importers (heightmaps, province boundaries, city names) and
the naming pipeline. Those live here so there is a single home for them with no
dependency on the deleted geometry-production code.
This is a library module — it does NOT re-exec the venv. The importing scripts
(import_heightmaps.py, import_province_boundaries.py, import_city_names.py,
gemma_naming.py) own the venv bootstrap before importing this module.
Decisions: D-223 (authored content as flavoured name pool), D-191 (atlas index).
"""
import sqlite3
from pathlib import Path
from tooling.core import config
import yaml
# ---------------------------------------------------------------------------
# Paths and grid constants
# ---------------------------------------------------------------------------
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
SYSTEMS_SCHEMA_PATH = REPO_ROOT / "server" / "data" / "systems-schema.sql"
# Heightmap pixel-space grid (D-202). Geometry derives from this resolution.
GRID_W = 512
GRID_H = 256
# Delimiters for the single canonical atlas_* DDL block in
# server/data/systems-schema.sql. `_load_atlas_schema()` extracts everything
# between these markers at runtime so this file does not duplicate the schema
# (and drift from it).
_ATLAS_SCHEMA_BEGIN_MARKER = "-- BEGIN ATLAS INDEX"
_ATLAS_SCHEMA_END_MARKER = "-- END ATLAS INDEX"
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
def _load_atlas_schema() -> str:
"""Return the atlas_* DDL block from systems-schema.sql.
systems-schema.sql is the single source of truth for the atlas index
tables (see the BEGIN ATLAS INDEX / END ATLAS INDEX markers). We extract
just that block and run it through `executescript` so callers work against
any DB state (fresh or partially-migrated) without maintaining a second
copy of the DDL here.
"""
if not SYSTEMS_SCHEMA_PATH.exists():
raise RuntimeError(
f"systems-schema.sql not found at {SYSTEMS_SCHEMA_PATH} — "
"atlas importers cannot proceed without the canonical schema."
)
text = SYSTEMS_SCHEMA_PATH.read_text()
try:
start = text.index(_ATLAS_SCHEMA_BEGIN_MARKER)
end = text.index(_ATLAS_SCHEMA_END_MARKER, start)
except ValueError as e:
raise RuntimeError(
f"systems-schema.sql is missing the {_ATLAS_SCHEMA_BEGIN_MARKER}/"
f"{_ATLAS_SCHEMA_END_MARKER} block — has the schema been "
"restructured?"
) from e
return text[start:end]
def ensure_atlas_schema(conn: sqlite3.Connection) -> None:
"""Apply the canonical atlas_* DDL from systems-schema.sql.
Idempotent: all statements inside the block use CREATE TABLE / INDEX
IF NOT EXISTS, so running this on an already-migrated DB is a no-op.
Also ensures the meta stamp table exists (#855, #856).
"""
conn.executescript(_load_atlas_schema())
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS meta (
generator_name TEXT PRIMARY KEY,
schema_version TEXT NOT NULL,
schema_sha TEXT,
generator_sha TEXT NOT NULL,
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
)
# Add schema_sha column to existing DBs that pre-date #888 (#888 migration).
try:
conn.execute("ALTER TABLE meta ADD COLUMN schema_sha TEXT")
except sqlite3.OperationalError as e:
if "duplicate column" not in str(e).lower():
raise
# ---------------------------------------------------------------------------
# Body definition loading
# ---------------------------------------------------------------------------
def load_body_def(body_dir: Path) -> dict | None:
"""Load body definition from body index.md frontmatter."""
index_md = body_dir / "index.md"
if not index_md.exists():
return None
content = index_md.read_text()
if not content.startswith("---"):
return None
try:
end = content.index("---", 3)
bd = yaml.safe_load(content[3:end])
except (ValueError, yaml.YAMLError):
return None
if not bd or "id" not in bd or "planet_class" not in bd:
return None
return bd
# ---------------------------------------------------------------------------
# DB queries
# ---------------------------------------------------------------------------
def query_inhabited_bodies(conn: sqlite3.Connection) -> list[dict]:
"""Query systems.db for all inhabited bodies with terrain_reference set."""
rows = conn.execute("""
SELECT
b.body_id,
b.system_id,
b.terrain_reference,
b.population,
b.settlement_pattern,
b.planet_class,
b.economic_role,
COALESCE(b.cultural_corridor, s.cultural_corridor) AS cultural_corridor
FROM bodies b
JOIN star_systems s ON b.system_id = s.system_id
WHERE b.inhabited = 1
AND b.terrain_reference IS NOT NULL
AND b.population > 0
ORDER BY b.system_id, b.body_id
""").fetchall()
return [
{
"body_id": row[0],
"system_id": row[1],
"terrain_reference": row[2],
"population": row[3] or 0,
"settlement_pattern": row[4],
"planet_class": row[5],
"economic_role": row[6],
"cultural_corridor": row[7],
}
for row in rows
]
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""
atlas_quality_analysis.py — Atlas content quality audit for Sprint 36 (#849/#838).
Queries atlas_* tables in systems.db and reports on:
1. Cross-body name collisions (same name, same feature type, different bodies)
2. Cardinal/directional name density per body
3. Generic/lazy name patterns
4. Earth-echo concentration in high-visibility systems
5. Top-stem frequency across all named features
Usage:
reach atlas planet quality [--db server/data/systems.db]
reach atlas planet quality --system GJ380
reach atlas planet quality --top-collisions 20
reach atlas planet quality --body GJ71c
D-191 §8: markers.json is pixel-space [row, col] against 512×256.
Re-run after any hand-refine pass to verify improvements.
"""
import argparse
import re
import sqlite3
from collections import Counter, defaultdict
from tooling.core import config, console
REPO_ROOT = config.repo_root()
DEFAULT_DB = REPO_ROOT / "server" / "data" / "systems.db"
CARDINAL_RE = re.compile(
r"\b(north|south|east|west|eastern|western|northern|southern|"
r"upper|lower|new|great|old|central|inner|outer|kita|minami|higashi|nishi)\b",
re.I,
)
GENERIC_RE = re.compile(
r"\b(hilly|sector|zone|district)\b"
r"|^(great|the great|hilly)\b"
r"|^(valley floor|ridge line|ridge crest|flat ground|riverbend)$",
re.I,
)
EARTH_ECHO_RE = re.compile(
r"\b(manchester|london|paris|berlin|tokyo|beijing|new york|sydney|dubai|"
r"route \d+|sector \d+|block \d+)\b",
re.I,
)
FEATURE_TABLES = [
("atlas_cities", "city"),
("atlas_rivers", "river"),
("atlas_oceans", "ocean"),
("atlas_mountain_ranges", "mountain"),
]
def open_db(path: str) -> sqlite3.Connection:
return sqlite3.connect(path)
def build_body_index(conn: sqlite3.Connection) -> dict:
c = conn.cursor()
c.execute(
"SELECT body_id, system_id, proper_name, cultural_corridor, population "
"FROM bodies WHERE inhabited=1"
)
return {
r[0]: {"system_id": r[1], "name": r[2], "corridor": r[3], "pop": r[4]}
for r in c.fetchall()
}
def gather_all_names(conn: sqlite3.Connection) -> dict[str, list[tuple[str, str, str]]]:
"""body_id → [(feature_type, name, local_id), ...]"""
c = conn.cursor()
result = defaultdict(list)
for tbl, feat_type in FEATURE_TABLES:
try:
c.execute(f"SELECT body_id, name, local_id FROM {tbl} WHERE name IS NOT NULL AND name != ''")
for body_id, name, local_id in c.fetchall():
result[body_id].append((feat_type, name, local_id))
except sqlite3.OperationalError:
pass
return result
def cross_body_collisions(conn: sqlite3.Connection, limit: int = 20) -> dict:
c = conn.cursor()
collisions = {}
for tbl, feat_type in FEATURE_TABLES:
try:
c.execute(
f"SELECT name, COUNT(DISTINCT body_id) as cnt, GROUP_CONCAT(DISTINCT body_id) "
f"FROM {tbl} WHERE name IS NOT NULL AND name != '' "
f"GROUP BY name HAVING cnt > 1 ORDER BY cnt DESC LIMIT ?",
(limit,),
)
collisions[feat_type] = [(r[0], r[1], r[2]) for r in c.fetchall()]
except sqlite3.OperationalError:
collisions[feat_type] = []
return collisions
def stem_frequency(names: list[str], top_n: int = 30) -> list[tuple[str, int]]:
stems = Counter()
for name in names:
words = name.split()
if words:
stems[words[0].lower()] += 1
return stems.most_common(top_n)
def body_quality_report(body_id: str, names: list[tuple], conn: sqlite3.Connection) -> dict:
total = len(names)
if total == 0:
return {}
cardinal = sum(1 for _, n, _ in names if CARDINAL_RE.search(n))
generic = sum(1 for _, n, _ in names if GENERIC_RE.search(n))
earth = sum(1 for _, n, _ in names if EARTH_ECHO_RE.search(n))
c = conn.cursor()
# collision count: how many of this body's names appear on other bodies (same type)
colliding = 0
for feat_type, name, _ in names:
tbl = [t for t, f in FEATURE_TABLES if f == feat_type][0]
try:
c.execute(
f"SELECT COUNT(DISTINCT body_id) FROM {tbl} WHERE name=? AND body_id!=?",
(name, body_id),
)
others = c.fetchone()[0]
if others > 0:
colliding += 1
except sqlite3.OperationalError:
pass
return {
"total": total,
"cardinal": cardinal,
"cardinal_pct": cardinal / total,
"generic": generic,
"earth_echo": earth,
"colliding": colliding,
"colliding_pct": colliding / total,
}
def run_analysis(args):
conn = open_db(args.db)
body_index = build_body_index(conn)
all_names_by_body = gather_all_names(conn)
# Filter by system or body if requested
if args.system:
body_index = {k: v for k, v in body_index.items() if v["system_id"] == args.system}
if args.body:
body_index = {k: v for k, v in body_index.items() if k == args.body}
console.out("=" * 70)
console.out("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
console.out(f"DB: {args.db}")
if args.system:
console.out(f"Filter: system={args.system}")
if args.body:
console.out(f"Filter: body={args.body}")
console.out("=" * 70)
# --- 1. Cross-body collisions ---
console.out("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
collisions = cross_body_collisions(conn, limit=args.top_collisions)
for feat_type, rows in collisions.items():
if rows:
console.out(f"\n {feat_type}:")
for name, cnt, bodies in rows:
console.out(f" '{name}' — {cnt} bodies: {bodies[:80]}")
# --- 2. Per-body quality scores ---
console.out("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
reports = []
for bid, info in body_index.items():
names = all_names_by_body.get(bid, [])
if not names:
continue
report = body_quality_report(bid, names, conn)
if not report:
continue
reports.append((bid, info, report))
reports.sort(key=lambda x: -x[2]["colliding_pct"])
console.out(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
f"{'Coll%':>6} {'Card%':>6} {'Gen':>4} {'Echo':>4}")
for bid, info, rep in reports[:30]:
console.out(f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
f"{rep['colliding_pct']:>6.0%} {rep['cardinal_pct']:>6.0%} "
f"{rep['generic']:>4} {rep['earth_echo']:>4}")
# --- 3. Stem frequency ---
console.out("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
all_names_flat = [n for names in all_names_by_body.values() for _, n, _ in names]
for stem, cnt in stem_frequency(all_names_flat, top_n=20):
console.out(f" {stem:<20} {cnt}")
# --- 4. Detailed body report (if --body specified) ---
if args.body and args.body in all_names_by_body:
bid = args.body
info = body_index.get(bid, {})
names = all_names_by_body[bid]
console.out(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
c = conn.cursor()
for feat_type, name, local_id in sorted(names, key=lambda x: x[0]):
tbl = [t for t, f in FEATURE_TABLES if f == feat_type][0]
c.execute(
f"SELECT COUNT(DISTINCT body_id) FROM {tbl} WHERE name=? AND body_id!=?",
(name, bid),
)
others = c.fetchone()[0]
flag = f" *** COLLISION ×{others}" if others > 0 else ""
cardinal = " [cardinal]" if CARDINAL_RE.search(name) else ""
generic = " [generic]" if GENERIC_RE.search(name) else ""
console.out(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
# --- 5. Sol gap check ---
console.out("\n[ 5. SOL SYSTEM GAP CHECK ]")
c = conn.cursor()
c.execute("SELECT body_id, proper_name, population FROM bodies WHERE system_id='GJ 0' AND inhabited=1")
sol_bodies = c.fetchall()
for bid, bname, pop in sol_bodies:
has_cities = bid in all_names_by_body and any(f == "city" for f, _, _ in all_names_by_body[bid])
status = "HAS DATA" if has_cities else "*** EMPTY — needs authoring"
console.out(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
conn.close()
console.out("\nDone.")
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to systems.db")
parser.add_argument("--system", help="Filter to one system (e.g. GJ380)")
parser.add_argument("--body", help="Filter to one body (e.g. GJ71c)")
parser.add_argument("--top-collisions", type=int, default=15, help="Collision list limit")
args = parser.parse_args(argv)
run_analysis(args)
if __name__ == "__main__":
main()
+559
View File
@@ -0,0 +1,559 @@
#!/usr/bin/env python3
"""
Batch planet generation — process all systems unattended.
Walks wiki/star-systems/, scaffolds body index.md files where missing,
then generates heightmap + globe + terrain data for every body.
Usage:
reach atlas planet batch # full run: scaffold + generate
reach atlas planet batch --scaffold-only # just create body index.md files
reach atlas planet batch --generate-only # just render (bodies must exist)
reach atlas planet batch --system GJ-144 # single system
reach atlas planet batch --system GJ-144 --body GJ144d # single body
reach atlas planet batch --overrides sol.json # per-body overrides
reach atlas planet batch --dry-run # validate data, don't generate
Skips:
- GJ-0 (Sol) — manual overrides required, use --system GJ-0 explicitly
- asteroid_belt, oort_cloud (no renderable surface)
- Bodies that already have globe.png (use --force to regenerate)
Error handling:
- Errors are printed to console and logged to /tmp/planet-gen-errors.log
- Batch aborts if error rate exceeds 50% of attempted bodies
"""
import argparse
import json
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
# Venv bootstrap
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import yaml
import numpy as np
from tooling.domains.atlas.planet.body_definition_parser import parse_system
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
# Systems to skip in batch mode (require manual handling)
SKIP_SYSTEMS = {"GJ-0"}
LOG_PATH = Path("/tmp/planet-gen-errors.log")
# ─────────────────────────────────────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────────────────────────────────────
def _log_error(system_id: str, body_id: str, error: str, tb: str):
"""Append error to log file with context."""
with open(LOG_PATH, "a") as f:
f.write(f"\n{'='*72}\n")
f.write(f" time: {datetime.now().isoformat()}\n")
f.write(f" system: {system_id}\n")
f.write(f" body: {body_id}\n")
f.write(f" error: {error}\n")
f.write(f" traceback:\n{tb}\n")
# ─────────────────────────────────────────────────────────────────────────────
# System helpers
# ─────────────────────────────────────────────────────────────────────────────
def _read_system_name(index_md: Path) -> str:
"""Extract the system name from the # heading in index.md."""
for line in index_md.read_text().splitlines()[:5]:
if line.startswith("# "):
return line[2:].strip()
return index_md.parent.name
def _read_frontmatter(md_path: Path) -> dict:
"""Read YAML frontmatter from a body index.md."""
content = md_path.read_text()
if not content.startswith("---"):
return {}
try:
end = content.index("---", 3)
return yaml.safe_load(content[3:end]) or {}
except (ValueError, yaml.YAMLError):
return {}
# ─────────────────────────────────────────────────────────────────────────────
# Validation (dry-run)
# ─────────────────────────────────────────────────────────────────────────────
REQUIRED_FIELDS = ["id", "planet_class", "seed", "star", "orbit", "physical"]
REQUIRED_STAR = ["type", "luminosity_solar"]
REQUIRED_ORBIT = ["distance_au", "period_days", "axial_tilt_deg"]
REQUIRED_PHYSICAL = ["gravity_g", "atmosphere"]
REQUIRED_TERRAIN = ["land_fraction", "tectonics"]
def _validate_body_def(bd: dict, body_dir: Path) -> list:
"""Validate a body definition. Returns list of error strings."""
errors = []
bid = bd.get("id", "?")
for f in REQUIRED_FIELDS:
if f not in bd:
errors.append(f"{bid}: missing top-level field '{f}'")
star = bd.get("star", {})
for f in REQUIRED_STAR:
if f not in star:
errors.append(f"{bid}: missing star.{f}")
orbit = bd.get("orbit", {})
for f in REQUIRED_ORBIT:
v = orbit.get(f)
if v is None or v == "rand":
errors.append(f"{bid}: orbit.{f} is {v!r} — must be resolved (not 'rand')")
elif isinstance(v, (int, float)) and v <= 0:
errors.append(f"{bid}: orbit.{f} = {v} — must be positive")
phys = bd.get("physical", {})
for f in REQUIRED_PHYSICAL:
if f not in phys:
errors.append(f"{bid}: missing physical.{f}")
seed = bd.get("seed")
if seed is None or not isinstance(seed, int):
errors.append(f"{bid}: seed must be an integer, got {seed!r}")
pclass = bd.get("planet_class", "")
valid_classes = {"temperate", "oceanic", "forest", "arid", "frozen",
"volcanic", "barren", "gas_giant", "gas_giant_ringed",
"cold_arid", "hot_arid", "tropical", "boreal",
"temperate_terminator"}
if pclass not in valid_classes:
errors.append(f"{bid}: planet_class '{pclass}' not in {valid_classes}")
is_gas = pclass in ("gas_giant", "gas_giant_ringed")
terrain = bd.get("terrain", {})
if not is_gas:
for f in REQUIRED_TERRAIN:
if f not in terrain:
errors.append(f"{bid}: missing terrain.{f}")
lf = terrain.get("land_fraction")
if lf is not None and (lf < 0 or lf > 1):
errors.append(f"{bid}: terrain.land_fraction = {lf} — must be [0, 1]")
return errors
# ─────────────────────────────────────────────────────────────────────────────
# Scaffold
# ─────────────────────────────────────────────────────────────────────────────
def _scaffold_system(system_dir: Path, overrides: dict) -> list:
"""Scaffold body index.md files for one system. Returns body IDs created."""
index_md = system_dir / "index.md"
if not index_md.exists():
return []
from tooling.domains.atlas.planet.scaffold_bodies import _body_to_frontmatter, _body_prose
body_defs = parse_system(str(index_md), overrides=overrides)
bodies_dir = system_dir / "bodies"
created = []
for bd in body_defs:
body_dir = bodies_dir / bd["id"]
body_index = body_dir / "index.md"
if body_index.exists():
continue
body_dir.mkdir(parents=True, exist_ok=True)
fm = _body_to_frontmatter(bd)
prose = _body_prose(bd, system_dir)
body_index.write_text(f"---\n{fm}\n---\n\n{prose}")
created.append(bd["id"])
return created
# ─────────────────────────────────────────────────────────────────────────────
# Generate
# ─────────────────────────────────────────────────────────────────────────────
def _save_img(img, target: Path):
"""Write image directly — resume logic handles incomplete outputs."""
img.save(str(target), format="PNG")
def _save(data, target: Path, save_fn):
"""Write data directly — resume logic handles incomplete outputs."""
save_fn(data, str(target))
def _is_complete(body_dir: Path, is_gas: bool) -> bool:
"""Check if all expected outputs exist (for resume support)."""
if not (body_dir / "globe.png").exists():
return False
if not is_gas:
for f in ("heightmap.png", "terrain.npz", "markers.json"):
if not (body_dir / f).exists():
return False
return True
def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
globe_size: int, force: bool) -> str:
"""
Generate assets for one body.
Returns: "generated", "skipped", or "error"
"""
index_md = body_dir / "index.md"
if not index_md.exists():
return "skipped"
bd = _read_frontmatter(index_md)
if not bd or "id" not in bd or "planet_class" not in bd:
return "skipped"
body_id = bd["id"]
name = bd.get("name") or body_id
planet_class = bd.get("planet_class", "")
is_gas = planet_class in ("gas_giant", "gas_giant_ringed")
# Resume: skip if all outputs exist (unless --force)
if not force and _is_complete(body_dir, is_gas):
return "skipped"
t0 = time.time()
# Simulate
terrain = simulate(bd)
is_gas = not terrain # re-check from actual simulation result
# Heightmap — atomic write
if not is_gas:
hmap_img = render_heightmap(bd, terrain,
out_w=hmap_w, out_h=hmap_h,
chrome=False)
_save_img(hmap_img, body_dir / "heightmap.png")
# Globe — atomic write
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(bd, terrain, size=globe_size)
_save_img(globe_img, body_dir / "globe.png")
# Terrain data — atomic write
if not is_gas:
save_dict = {}
for key in ("elevation", "temperature", "moisture", "hillshade",
"biome", "surface_water", "river_grid"):
if key in terrain:
save_dict[key] = terrain[key]
save_dict["sea_level"] = np.array([terrain["sea_level"]])
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
# Markers
from tooling.domains.atlas.planet.generate import _build_markers
markers = _build_markers(bd, terrain)
_save(markers, body_dir / "markers.json",
lambda m, p: Path(p).write_text(json.dumps(m, indent=2)))
elapsed = time.time() - t0
kind = "gas" if is_gas else f"land={int((~terrain['surface_water']).sum())}"
console.event(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
return "generated"
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Batch planet generation — all systems unattended")
parser.add_argument("--system", help="Process only this system (dir name, e.g. GJ-144)")
parser.add_argument("--body", help="Process only this body (requires --system)")
parser.add_argument("--scaffold-only", action="store_true")
parser.add_argument("--generate-only", action="store_true")
parser.add_argument("--overrides", help="Per-body overrides JSON")
parser.add_argument("--force", action="store_true",
help="Regenerate even if globe.png exists")
parser.add_argument("--dry-run", action="store_true",
help="Validate body definitions without generating")
parser.add_argument("--verify-determinism", type=int, metavar="N", default=0,
help="Run N random bodies twice and verify identical output")
parser.add_argument("--heightmap-size", default="1024x512")
parser.add_argument("--globe-size", type=int, default=512)
args = parser.parse_args(argv)
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
wiki_systems = WORKTREE_ROOT / "wiki" / "star-systems"
if not wiki_systems.exists():
raise ReachError(f"{wiki_systems} not found", fix="run from a settled-reach checkout — make reach-repoint")
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
# Collect system directories
if args.system:
system_dirs = [wiki_systems / args.system]
if not system_dirs[0].exists():
raise ReachError(f"system {args.system} not found", fix="--system takes the wiki directory name, e.g. GJ-1002 (see wiki/star-systems/)")
else:
system_dirs = sorted([
d for d in wiki_systems.iterdir()
if d.is_dir() and (d / "index.md").exists()
])
# Clear error log
if LOG_PATH.exists():
LOG_PATH.unlink()
t_total = time.time()
total_scaffolded = 0
total_generated = 0
total_skipped = 0
total_errors = 0
total_attempted = 0
total_valid = 0
total_invalid = 0
error_rate_threshold = 0.50
console.event(" Planet Generator — Batch Mode")
console.event(f" Systems: {len(system_dirs)}")
console.event(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
if args.dry_run:
console.event(" Mode: DRY RUN (validation only)")
for system_dir in system_dirs:
system_id = system_dir.name
# Skip Sol in batch mode (needs manual overrides)
if system_id in SKIP_SYSTEMS and not args.system:
console.event(f" {system_id} — skipped (manual)")
continue
system_name = _read_system_name(system_dir / "index.md")
# Count bodies before starting
bodies_dir_check = system_dir / "bodies"
if bodies_dir_check.exists():
n_bodies = sum(1 for d in bodies_dir_check.iterdir()
if d.is_dir() and (d / "index.md").exists())
else:
# Peek at the system table to estimate body count
try:
defs = parse_system(str(system_dir / "index.md"), overrides=overrides)
n_bodies = len(defs)
except Exception:
n_bodies = 0
console.event(f" {system_id} — {system_name} ({n_bodies} bodies)")
# ── Scaffold ─────────────────────────────────────────────────────
if not args.generate_only and not args.dry_run:
try:
created = _scaffold_system(system_dir, overrides)
if created:
total_scaffolded += len(created)
for bid in created:
console.event(f" scaffolded {bid}")
except Exception as e:
tb = traceback.format_exc()
console.event(f" SCAFFOLD ERROR: {e}")
_log_error(system_id, "*", str(e), tb)
# ── Validate / Generate ──────────────────────────────────────────
bodies_dir = system_dir / "bodies"
if not bodies_dir.exists():
if not args.scaffold_only and not args.dry_run:
# Try scaffold first if bodies dir doesn't exist
try:
_scaffold_system(system_dir, overrides)
except Exception:
pass
if not bodies_dir.exists():
continue
body_dirs = sorted(d for d in bodies_dir.iterdir() if d.is_dir())
if args.body:
body_dirs = [d for d in body_dirs if d.name == args.body]
for body_dir in body_dirs:
index_md = body_dir / "index.md"
if not index_md.exists():
continue
bd = _read_frontmatter(index_md)
if not bd or "id" not in bd:
continue
body_id = bd.get("id", "?")
body_name = bd.get("name") or body_id
if args.dry_run:
# Validate only
errors = _validate_body_def(bd, body_dir)
if errors:
total_invalid += 1
console.event(f" {body_id:20s} ({body_name:20s}) INVALID")
for err in errors:
console.event(f" - {err}")
else:
total_valid += 1
console.event(f" {body_id:20s} ({body_name:20s}) ok")
continue
if args.scaffold_only:
continue
# Generate with error handling
total_attempted += 1
try:
result = _generate_body_from_dir(
body_dir, hmap_w, hmap_h, args.globe_size, args.force)
if result == "generated":
total_generated += 1
elif result == "skipped":
total_skipped += 1
elif result == "error":
total_errors += 1
except Exception as e:
total_errors += 1
tb = traceback.format_exc()
console.event(f" {body_id:20s} ({body_name:20s}) ERROR: {e}")
_log_error(system_id, body_id, str(e), tb)
# Circuit breaker: abort if error rate is too high
if total_attempted >= 10 and total_errors / total_attempted > error_rate_threshold:
console.event(f" ABORT: error rate {total_errors}/{total_attempted} "
f"({total_errors/total_attempted*100:.0f}%) exceeds "
f"{error_rate_threshold*100:.0f}% threshold")
console.event(f" Check {LOG_PATH} for details")
sys.exit(1)
elapsed = time.time() - t_total
console.event(f" Batch complete: {elapsed:.0f}s")
if args.dry_run:
console.event(f" valid: {total_valid}")
console.event(f" invalid: {total_invalid}")
else:
console.event(f" scaffolded: {total_scaffolded}")
console.event(f" generated: {total_generated}")
console.event(f" skipped: {total_skipped}")
console.event(f" errors: {total_errors}")
if total_errors > 0:
console.event(f" error log: {LOG_PATH}")
# ── Determinism verification ─────────────────────────────────────────
if args.verify_determinism > 0:
_verify_determinism(wiki_systems, args.verify_determinism,
hmap_w, hmap_h, args.globe_size)
def _verify_determinism(wiki_systems: Path, n_samples: int,
hmap_w: int, hmap_h: int, globe_size: int):
"""Run N bodies twice, verify outputs are bit-identical."""
import hashlib
import tempfile
import shutil
console.event(f" Determinism verification — {n_samples} samples")
# Collect all body dirs that have been generated
all_body_dirs = []
for system_dir in wiki_systems.iterdir():
bodies = system_dir / "bodies"
if bodies.exists():
for bd in bodies.iterdir():
if bd.is_dir() and (bd / "globe.png").exists():
all_body_dirs.append(bd)
if not all_body_dirs:
console.event(" no generated bodies to verify")
return
rng = np.random.default_rng(42)
samples = rng.choice(len(all_body_dirs), min(n_samples, len(all_body_dirs)),
replace=False)
passed = 0
failed = 0
for idx in samples:
body_dir = all_body_dirs[idx]
bd = _read_frontmatter(body_dir / "index.md")
if not bd:
continue
body_id = bd["id"]
# Generate into a temp dir
tmp_dir = Path(tempfile.mkdtemp(prefix=f"detcheck_{body_id}_"))
tmp_body = tmp_dir / body_id
tmp_body.mkdir()
# Copy index.md so the generator can read it
shutil.copy2(body_dir / "index.md", tmp_body / "index.md")
try:
_generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True)
except Exception as e:
console.event(f" {body_id}: generation failed — {e}")
shutil.rmtree(tmp_dir)
failed += 1
continue
# Compare file hashes
all_match = True
for fname in ("globe.png", "heightmap.png", "terrain.npz", "markers.json"):
orig = body_dir / fname
rerun = tmp_body / fname
if not orig.exists() and not rerun.exists():
continue
if not orig.exists() or not rerun.exists():
console.event(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}")
all_match = False
continue
h1 = hashlib.sha256(orig.read_bytes()).hexdigest()[:16]
h2 = hashlib.sha256(rerun.read_bytes()).hexdigest()[:16]
if h1 != h2:
console.event(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
all_match = False
if all_match:
passed += 1
else:
failed += 1
shutil.rmtree(tmp_dir)
console.event(f" passed: {passed} failed: {failed}")
if failed > 0:
# Was a printed warning with exit 0 — a determinism check that could not
# fail (T-1288). The 271-body bake is only re-runnable because this holds.
raise ReachError(
f"non-deterministic output: {failed} of {passed + failed} bodies differ on re-run",
fix="reach atlas planet batch --verify-determinism 1, then diff the MISMATCH files above",
)
if __name__ == "__main__":
main()
@@ -0,0 +1,98 @@
"""
biome_config.py — loads biomes.toml and provides lookup structures.
Single source of truth for biome classification, colors, and rendering
parameters. All three pipeline modules import from here instead of
maintaining their own hardcoded tables.
Usage:
from tooling.domains.atlas.planet.biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, BIOME_PALETTE,
STAR_TINTS, ATMO_COLORS, GAS_PALETTES,
EXOTIC_CLASSES, CRATER_SCALING, RIVER_RGB, COAST_RGB,
)
"""
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib # Python < 3.11 fallback
_CONFIG_PATH = Path(__file__).resolve().parent / "biomes.toml"
def _load():
with open(_CONFIG_PATH, "rb") as f:
return tomllib.load(f)
_CFG = _load()
# ── Whittaker table ──────────────────────────────────────────────────
# List of (temp_lo, temp_hi, moist_lo, moist_hi, class_id) tuples.
WHITTAKER_TABLE = [
(w["temp_lo"], w["temp_hi"], w["moist_lo"], w["moist_hi"], w["id"])
for w in _CFG["whittaker"]
]
# ── Temperature bands per planet class ───────────────────────────────
CLASS_T_BAND = {
k: tuple(v) for k, v in _CFG["temperature_bands"].items()
}
# ── Biome color palette ──────────────────────────────────────────────
# {class_id: {"cartographic": (R,G,B), "photographic": (R,G,B), "name": str}}
BIOME_PALETTE = {}
for key, val in _CFG["biome_colors"].items():
cid = int(key)
BIOME_PALETTE[cid] = {
"cartographic": tuple(val["cartographic"]),
"photographic": tuple(val["photographic"]),
"name": val.get("name", f"class_{cid}"),
}
# Max class ID for array sizing
MAX_BIOME_ID = max(BIOME_PALETTE.keys())
def build_biome_rgb(mode: str = "cartographic") -> dict:
"""Returns {class_id: (R, G, B)} for the given render mode."""
return {k: v[mode] for k, v in BIOME_PALETTE.items()}
# ── Render colors ────────────────────────────────────────────────────
RIVER_RGB = tuple(_CFG["render_colors"]["river"])
COAST_RGB = tuple(_CFG["render_colors"]["coastline"])
# ── Star tints ───────────────────────────────────────────────────────
STAR_TINTS = {k: tuple(v) for k, v in _CFG["star_tints"].items()}
# ── Atmosphere colors ────────────────────────────────────────────────
ATMO_COLORS = {k: tuple(v) for k, v in _CFG["atmosphere_colors"].items()}
# Planet classes without atmosphere glow
for _no_atmo in ("barren", "moon", "gas_giant", "gas_giant_ringed"):
ATMO_COLORS.setdefault(_no_atmo, None)
# ── Gas giant palettes ───────────────────────────────────────────────
GAS_PALETTES = {
k: [tuple(band) for band in v]
for k, v in _CFG["gas_palettes"].items()
if k != "selection_order"
}
# Locked selection list — order determines which body gets which palette.
# Only append, never reorder or remove.
GAS_PALETTE_SELECTION = list(_CFG["gas_palettes"]["selection_order"])
# ── Exotic biome class IDs ───────────────────────────────────────────
EXOTIC_CLASSES = dict(_CFG["exotic_classes"])
# ── Crater scaling ───────────────────────────────────────────────────
CRATER_SCALING = {
"base_count": _CFG["crater_scaling"]["base_count"],
"atmosphere": dict(_CFG["crater_scaling"]["atmosphere"]),
"tectonics": dict(_CFG["crater_scaling"]["tectonics"]),
}
+342
View File
@@ -0,0 +1,342 @@
# Planet Generator — Biome & Color Configuration
#
# Single source of truth for biome classification, color palettes,
# and rendering parameters. Loaded by planet_simulation.py,
# render_heightmap.py, and planet_renderer.py.
#
# Edit this file to adjust colors without touching Python code.
# ─────────────────────────────────────────────────────────────────────
# Whittaker biome classification table
#
# Temperature axis is ABSOLUTE KELVIN — not normalized per-world.
# Moisture axis is [0, 1].
# Order matters: first match wins.
# ─────────────────────────────────────────────────────────────────────
[[whittaker]]
id = 5
name = "tropical_rainforest"
temp_lo = 303
temp_hi = 999
moist_lo = 0.65
moist_hi = 1.00
[[whittaker]]
id = 6
name = "tropical_seasonal"
temp_lo = 303
temp_hi = 999
moist_lo = 0.35
moist_hi = 0.65
[[whittaker]]
id = 7
name = "savanna"
temp_lo = 293
temp_hi = 999
moist_lo = 0.18
moist_hi = 0.35
[[whittaker]]
id = 15
name = "hot_desert"
temp_lo = 303
temp_hi = 999
moist_lo = 0.00
moist_hi = 0.18
[[whittaker]]
id = 9
name = "temperate_deciduous"
temp_lo = 283
temp_hi = 308
moist_lo = 0.55
moist_hi = 1.00
[[whittaker]]
id = 8
name = "temperate_grassland"
temp_lo = 278
temp_hi = 303
moist_lo = 0.30
moist_hi = 0.55
[[whittaker]]
id = 10
name = "temperate_rainforest"
temp_lo = 278
temp_hi = 303
moist_lo = 0.60
moist_hi = 1.00
[[whittaker]]
id = 9
name = "temperate_deciduous_cool"
temp_lo = 273
temp_hi = 293
moist_lo = 0.30
moist_hi = 0.60
[[whittaker]]
id = 12
name = "shrubland"
temp_lo = 278
temp_hi = 303
moist_lo = 0.10
moist_hi = 0.30
[[whittaker]]
id = 14
name = "subtropical_desert"
temp_lo = 283
temp_hi = 308
moist_lo = 0.00
moist_hi = 0.18
[[whittaker]]
id = 13
name = "temperate_desert"
temp_lo = 273
temp_hi = 293
moist_lo = 0.00
moist_hi = 0.30
[[whittaker]]
id = 11
name = "boreal_taiga"
temp_lo = 253
temp_hi = 278
moist_lo = 0.40
moist_hi = 1.00
[[whittaker]]
id = 11
name = "boreal_dry"
temp_lo = 253
temp_hi = 278
moist_lo = 0.15
moist_hi = 0.40
[[whittaker]]
id = 16
name = "tundra"
temp_lo = 243
temp_hi = 263
moist_lo = 0.00
moist_hi = 1.00
[[whittaker]]
id = 16
name = "cold_tundra"
temp_lo = 233
temp_hi = 253
moist_lo = 0.20
moist_hi = 1.00
[[whittaker]]
id = 17
name = "ice_snow"
temp_lo = 200
temp_hi = 243
moist_lo = 0.00
moist_hi = 1.00
[[whittaker]]
id = 17
name = "ice_cold_dry"
temp_lo = 243
temp_hi = 273
moist_lo = 0.00
moist_hi = 0.15
# ─────────────────────────────────────────────────────────────────────
# Temperature bands per planet class (Kelvin)
# Fiction wins over physics — temperature is clamped to these bands.
# ─────────────────────────────────────────────────────────────────────
[temperature_bands]
# Core classes
temperate = [275, 305]
oceanic = [278, 300]
forest = [275, 308]
arid = [295, 340]
frozen = [210, 265]
volcanic = [290, 380]
barren = [180, 380]
# Extended classes
temperate_terminator = [260, 310] # sharp day/night gradient, wide band
cold_arid = [240, 290] # cold desert (Mars-analog)
hot_arid = [310, 360] # scorching desert
tropical = [295, 315] # hot and wet
boreal = [245, 278] # cold forest / taiga
# ─────────────────────────────────────────────────────────────────────
# Biome color palette
#
# Each biome class has two color modes:
# cartographic — map colors (National Geographic style, readable)
# photographic — orbital appearance (dark, muted, realistic)
#
# RGB values 0-255.
# ─────────────────────────────────────────────────────────────────────
[biome_colors]
# Ocean
0 = { name = "ocean_deep", cartographic = [ 80, 155, 190], photographic = [ 18, 45, 80] }
1 = { name = "ocean_mid", cartographic = [110, 185, 215], photographic = [ 28, 72, 115] }
2 = { name = "ocean_shallow", cartographic = [150, 210, 230], photographic = [ 42, 105, 145] }
# Coast / lowland
3 = { name = "coast", cartographic = [155, 185, 130], photographic = [ 90, 108, 75] }
4 = { name = "lowland", cartographic = [120, 165, 100], photographic = [ 72, 98, 58] }
# Tropical
5 = { name = "tropical_rainforest", cartographic = [ 50, 140, 65], photographic = [ 12, 38, 18] }
6 = { name = "tropical_seasonal", cartographic = [ 90, 170, 75], photographic = [ 28, 65, 28] }
7 = { name = "savanna", cartographic = [175, 210, 105], photographic = [108, 118, 55] }
# Temperate
8 = { name = "temperate_grassland", cartographic = [190, 210, 110], photographic = [118, 128, 62] }
9 = { name = "temperate_deciduous", cartographic = [ 70, 148, 70], photographic = [ 22, 55, 28] }
10 = { name = "temperate_rainforest", cartographic = [ 45, 125, 65], photographic = [ 15, 45, 22] }
11 = { name = "boreal_taiga", cartographic = [ 28, 88, 55], photographic = [ 8, 30, 18] }
12 = { name = "shrubland", cartographic = [168, 168, 95], photographic = [ 98, 88, 55] }
# Desert
13 = { name = "temperate_desert", cartographic = [215, 200, 155], photographic = [155, 138, 98] }
14 = { name = "subtropical_desert", cartographic = [210, 165, 85], photographic = [148, 108, 62] }
15 = { name = "hot_desert", cartographic = [215, 138, 55], photographic = [162, 98, 42] }
# Cold
16 = { name = "tundra", cartographic = [198, 185, 145], photographic = [ 95, 88, 72] }
17 = { name = "ice_snow", cartographic = [235, 238, 242], photographic = [218, 228, 238] }
18 = { name = "mountain_rock", cartographic = [148, 135, 120], photographic = [ 88, 80, 72] }
# Volcanic
19 = { name = "lava_field", cartographic = [ 55, 32, 22], photographic = [ 38, 22, 15] }
# Exotic / extremophile
20 = { name = "chemosynthetic_mat", cartographic = [ 45, 88, 52], photographic = [ 18, 38, 22] }
21 = { name = "thermophilic_field", cartographic = [118, 72, 40], photographic = [ 78, 45, 22] }
22 = { name = "sulfuric_scrub", cartographic = [148, 130, 58], photographic = [ 98, 85, 35] }
23 = { name = "cryptobiotic_crust", cartographic = [130, 118, 100], photographic = [ 78, 70, 58] }
24 = { name = "lithic_pioneer", cartographic = [138, 125, 110], photographic = [ 82, 75, 65] }
25 = { name = "ash_field", cartographic = [ 68, 58, 52], photographic = [ 42, 35, 30] }
26 = { name = "ice_shelf", cartographic = [245, 246, 248], photographic = [235, 238, 242] }
# Dry terrain (atmosphere gate — non-vegetated)
27 = { name = "dust_plain", cartographic = [195, 165, 115], photographic = [175, 142, 95] }
28 = { name = "rocky_highland", cartographic = [175, 145, 105], photographic = [152, 122, 85] }
29 = { name = "warm_dust", cartographic = [210, 175, 120], photographic = [188, 155, 105] }
30 = { name = "cold_rock", cartographic = [160, 140, 115], photographic = [135, 118, 95] }
# Ferric terrain (iron oxide — Mars, arid iron-rich worlds)
34 = { name = "ferric_dust", cartographic = [185, 110, 65], photographic = [158, 88, 48] }
35 = { name = "ferric_highland", cartographic = [165, 100, 60], photographic = [138, 78, 42] }
36 = { name = "ferric_lowland", cartographic = [200, 130, 75], photographic = [172, 105, 58] }
# Lunar terrain (grey rock)
31 = { name = "lunar_highland", cartographic = [165, 165, 162], photographic = [138, 138, 135] }
32 = { name = "lunar_mare", cartographic = [120, 120, 118], photographic = [100, 100, 98] }
33 = { name = "lunar_midland", cartographic = [145, 145, 142], photographic = [118, 118, 115] }
# ─────────────────────────────────────────────────────────────────────
# Rendering colors (not biome-specific)
# ─────────────────────────────────────────────────────────────────────
[render_colors]
river = [ 80, 140, 200]
coastline = [ 30, 45, 35]
# ─────────────────────────────────────────────────────────────────────
# Star tints — subtle color shift from stellar type
# Applied multiplicatively to surface lighting.
# ─────────────────────────────────────────────────────────────────────
[star_tints]
O = [0.90, 0.92, 1.00]
B = [0.94, 0.96, 1.00]
A = [0.97, 0.98, 1.00]
F = [1.00, 0.99, 0.97]
G = [1.00, 0.97, 0.93]
K = [1.00, 0.93, 0.85]
M = [1.00, 0.88, 0.78]
# ─────────────────────────────────────────────────────────────────────
# Atmosphere rim colors per planet class (globe renderer)
# null = no atmosphere glow
# ─────────────────────────────────────────────────────────────────────
[atmosphere_colors]
temperate = [0.45, 0.65, 1.00]
temperate_terminator = [0.48, 0.62, 0.95]
oceanic = [0.40, 0.60, 1.00]
forest = [0.42, 0.68, 0.80]
arid = [0.90, 0.72, 0.50]
cold_arid = [0.82, 0.58, 0.40]
hot_arid = [0.92, 0.68, 0.42]
tropical = [0.48, 0.72, 0.85]
boreal = [0.50, 0.68, 0.95]
frozen = [0.75, 0.88, 1.00]
volcanic = [0.55, 0.40, 0.30]
# ─────────────────────────────────────────────────────────────────────
# Gas giant band palettes (globe renderer)
# Each palette is a list of RGB band colors.
# ─────────────────────────────────────────────────────────────────────
[gas_palettes]
jovian = [[0.78, 0.62, 0.44], [0.60, 0.44, 0.30], [0.88, 0.76, 0.60],
[0.72, 0.55, 0.38], [0.92, 0.84, 0.70], [0.55, 0.40, 0.28]]
neptunian = [[0.25, 0.45, 0.75], [0.35, 0.58, 0.85], [0.18, 0.35, 0.65],
[0.42, 0.65, 0.90], [0.20, 0.40, 0.70], [0.50, 0.70, 0.92]]
saturnian = [[0.82, 0.74, 0.55], [0.75, 0.66, 0.48], [0.88, 0.80, 0.62],
[0.70, 0.62, 0.44], [0.92, 0.86, 0.68], [0.65, 0.58, 0.42]]
icy = [[0.78, 0.85, 0.92], [0.70, 0.80, 0.90], [0.85, 0.90, 0.95],
[0.65, 0.75, 0.88], [0.90, 0.93, 0.97], [0.60, 0.70, 0.85]]
sulfuric = [[0.88, 0.80, 0.22], [0.80, 0.65, 0.18], [0.92, 0.86, 0.35],
[0.75, 0.60, 0.15], [0.85, 0.75, 0.28], [0.70, 0.55, 0.12]]
infernal = [[0.65, 0.18, 0.12], [0.45, 0.12, 0.08], [0.80, 0.25, 0.15],
[0.55, 0.15, 0.10], [0.72, 0.20, 0.12], [0.38, 0.10, 0.06]]
# Locked selection list for deterministic random palette assignment.
# Order matters — changing this changes which body gets which palette.
# Only add to the end. Never reorder or remove.
selection_order = ["jovian", "neptunian", "saturnian", "icy", "sulfuric", "infernal"]
# ─────────────────────────────────────────────────────────────────────
# Crater scaling factors
# atmosphere: fraction of impactors that survive entry
# tectonics: fraction of craters preserved (not resurfaced)
# Final crater count = base_count × atmo_factor × tect_factor
# ─────────────────────────────────────────────────────────────────────
[crater_scaling]
base_count = 300
[crater_scaling.atmosphere]
none = 1.0
thin = 0.6
standard = 0.25
thick = 0.08
[crater_scaling.tectonics]
none = 1.0
low = 0.7
active = 0.3
extreme = 0.1
# ─────────────────────────────────────────────────────────────────────
# Exotic biome class IDs (modifier stack in compute_biome)
# ─────────────────────────────────────────────────────────────────────
[exotic_classes]
chemosynthetic_mat = 20
thermophilic_field = 21
sulfuric_scrub = 22
cryptobiotic_crust = 23
ash_field = 25
lava_field = 19
ice_shelf = 26
@@ -0,0 +1,859 @@
"""
body_definition_parser.py
-------------------------
Parses a system index.md file and produces one body_definition.json
per renderable celestial body.
Input: index.md (system wiki page, bodies table + system profile)
Output: {body_id}_def.json per planet / moon / gas_giant
Design principles:
- "rand" sentinel means: derive from seed + planet class constraints
- Explicit values in the bodies table or override dict always win
- Every derivation is documented so the logic is auditable
- No field is silently dropped — unknowns get a logged warning
Field resolution order (highest wins):
1. override dict (per-body, hand-authored for special cases like Sol)
2. direct read (field exists verbatim in bodies table)
3. derived (computed from other fields — documented formula)
4. inferred (implied by combination of fields)
5. randomised (seeded, within planet-class constraints)
Usage:
python -m tooling.domains.atlas.planet.body_definition_parser path/to/index.md [--out-dir ./defs]
# With overrides (e.g. Sol)
python -m tooling.domains.atlas.planet.body_definition_parser sol/index.md --overrides sol_overrides.json
Override file format:
{
"GJ0g": { "rings": true, "ring_color": [0.88, 0.78, 0.55] },
"GJ0f": { "rings": false },
"GJ0d": { "orbit": { "axial_tilt_deg": 23.4 } }
}
"""
import argparse
import hashlib
import json
import logging
import math
import os
import re
from pathlib import Path
from typing import Optional
import numpy as np
logging.basicConfig(level=logging.INFO, format=" %(levelname)s %(message)s")
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants / lookup tables
# ---------------------------------------------------------------------------
# Spectral type → solar luminosity (approximate)
STAR_LUMINOSITY = {
"O": 100000.0, "B": 1000.0, "A": 10.0,
"F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04,
}
# Spectral type → colour temperature K (approximate midpoint)
STAR_COLOUR_TEMP = {
"O": 40000, "B": 20000, "A": 9000,
"F": 7000, "G": 5800, "K": 4500, "M": 3200,
}
# Star type → UV index category
STAR_UV = {
"O": "extreme", "B": "extreme", "A": "high",
"F": "high", "G": "moderate","K": "low", "M": "low",
}
# atmosphere field → density string
# Wiki atmosphere value → pipeline atmosphere density.
# Both legacy ("breathable") and current ("standard") wiki values mapped.
ATMO_MAP = {
"none": "none",
"trace": "none", # too thin for weather/clouds
"thin": "thin",
"breathable": "standard", # legacy wiki value
"standard": "standard", # current wiki value
"dense": "thick",
"thick": "thick",
"toxic": "thick", # Venus-style reducing atmosphere
"reducing": "thick", # same as toxic
"hydrogen-helium": "thick", # gas giant
"gas_giant": "thick", # gas giant atmosphere field
}
# hydrosphere → approximate land_fraction range [min, max]
HYDRO_LAND = {
"ocean": (0.28, 0.50),
"liquid_water":(0.35, 0.65),
"rivers": (0.50, 0.75), # Titan-style — surface liquid but mostly land
"ice": (0.70, 0.90), # mostly frozen land
"subsurface": (0.90, 0.99), # surface appears dry
"none": (0.97, 1.00),
}
# biome → planet_class
# Wiki planet class → pipeline planet class.
# All 26 known wiki values mapped explicitly.
# Unknown values default to "temperate" (safe for modders adding new classes).
PLANET_CLASS_MAP = {
# Direct matches
"temperate": "temperate",
"arid": "arid",
"frozen": "frozen",
"volcanic": "volcanic",
"barren": "barren",
"oceanic": "oceanic",
# Temperate variants
"temperate_terminator": "temperate_terminator",
"temperate-cool": "temperate",
"temperate_highland": "temperate",
"temperate_maritime": "temperate",
"temperate_savanna": "temperate",
"temperate_coastal": "temperate",
"temperate_riverine": "temperate",
"temperate_ocean": "oceanic",
"cold_temperate": "temperate",
"floodplain_temperate": "temperate",
# Arid variants
"cold_arid": "cold_arid",
"hot_arid": "hot_arid",
# Tropical
"tropical": "tropical",
"tropical_ocean": "oceanic",
"subtropical": "tropical",
# Cold
"ice": "frozen",
"boreal": "boreal",
# Warm ocean
"warm_ocean": "oceanic",
# Geothermal
"geothermal": "volcanic",
# Forest (from prototype)
"forest": "forest",
}
# planet_class → axial tilt range [min, max] degrees
# Tidal locking check overrides this for short-period bodies
CLASS_TILT = {
"temperate": (10, 35),
"temperate_terminator": (0, 5), # tidally locked by definition
"oceanic": (5, 25),
"forest": (10, 40),
"arid": (5, 30),
"cold_arid": (10, 40),
"hot_arid": (2, 15),
"tropical": (5, 15),
"boreal": (20, 50),
"frozen": (15, 60), # high tilt → seasonal extremes → frozen
"volcanic": (2, 20),
"barren": (0, 45),
}
# planet_class → geothermal flux
CLASS_GEOTHERMAL = {
"volcanic": "extreme",
"temperate": "low",
"temperate_terminator": "low",
"oceanic": "low",
"forest": "low",
"arid": "low",
"cold_arid": "low",
"hot_arid": "low",
"tropical": "low",
"boreal": "low",
"frozen": "low",
"barren": "low",
}
# planet_class → polar ice latitude (fraction of 0–1, where 1 = poles)
# Lower = ice caps extend further toward equator
CLASS_POLAR_ICE = {
"temperate": (0.72, 0.85),
"temperate_terminator": (0.75, 0.90),
"oceanic": (0.80, 0.92),
"forest": (0.75, 0.88),
"arid": (0.90, 0.99),
"cold_arid": (0.50, 0.70),
"hot_arid": (0.95, 1.00),
"tropical": (0.88, 0.96),
"boreal": (0.45, 0.65),
"frozen": (0.10, 0.40),
"volcanic": (0.95, 1.00),
"barren": (0.92, 1.00),
}
# planet_class → oblateness range
CLASS_OBLATENESS = {
"temperate": (0.001, 0.005),
"temperate_terminator": (0.001, 0.004),
"oceanic": (0.001, 0.004),
"forest": (0.001, 0.005),
"arid": (0.001, 0.004),
"cold_arid": (0.001, 0.004),
"hot_arid": (0.001, 0.004),
"tropical": (0.001, 0.005),
"boreal": (0.001, 0.004),
"frozen": (0.001, 0.003),
"volcanic": (0.002, 0.008),
"barren": (0.000, 0.003),
}
# Gas giant band palettes available
from tooling.domains.atlas.planet.biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
from tooling.core import console
# planet_class → cloud coverage base range
CLASS_CLOUD = {
"temperate": (0.35, 0.55),
"temperate_terminator": (0.30, 0.50),
"oceanic": (0.55, 0.75),
"forest": (0.40, 0.60),
"arid": (0.05, 0.20),
"cold_arid": (0.10, 0.25),
"hot_arid": (0.02, 0.10),
"tropical": (0.60, 0.80),
"boreal": (0.55, 0.75),
"frozen": (0.20, 0.45),
"volcanic": (0.60, 0.85),
"barren": (0.00, 0.05),
}
# Atmosphere classes that allow clouds
CLOUD_CAPABLE = {"standard", "thick", "thin"}
# Render defaults
RENDER_DEFAULTS = {
"globe_light_angle_deg": 125,
"specular_ocean": True,
"night_side_ambient": 0.025,
}
# Ring probability for gas giants (if not overridden)
RING_PROBABILITY = 0.40 # 40% chance of rings — Saturn is special
# Ring colour palettes paired to band palettes
RING_COLOURS = {
"jovian": [0.55, 0.48, 0.35], # faint dark rings
"neptunian": [0.72, 0.82, 0.95], # blue-tinted
"saturnian": [0.88, 0.78, 0.55], # warm golden
"icy": [0.85, 0.90, 0.95], # pale ice
"sulfuric": [0.75, 0.70, 0.30], # sulphur-tinted
"infernal": [0.60, 0.25, 0.15], # dark ember
}
# ---------------------------------------------------------------------------
# Seeded RNG helpers
# ---------------------------------------------------------------------------
def _seed_from_id(body_id: str) -> int:
"""Deterministic integer seed from body ID string."""
h = hashlib.md5(body_id.encode()).digest()
return int.from_bytes(h[:4], "little")
def _rng(body_id: str, salt: str = "") -> np.random.Generator:
"""Seeded RNG for a specific body + context. Always reproducible."""
seed = _seed_from_id(body_id + salt)
return np.random.default_rng(seed)
def _rand_range(body_id: str, lo: float, hi: float, salt: str = "") -> float:
"""Uniform float in [lo, hi], seeded from body_id."""
return float(_rng(body_id, salt).uniform(lo, hi))
def _rand_choice(body_id: str, choices: list, salt: str = "") -> object:
"""Random choice from list, seeded from body_id."""
idx = int(_rng(body_id, salt).integers(0, len(choices)))
return choices[idx]
def _rand_bool(body_id: str, probability: float, salt: str = "") -> bool:
"""True with given probability, seeded from body_id."""
return float(_rng(body_id, salt).uniform(0, 1)) < probability
# ---------------------------------------------------------------------------
# Orbital mechanics
# ---------------------------------------------------------------------------
def _derive_distance_au(period_days: float, star_type: str) -> float:
"""
Kepler's third law: a³ = P² × M_star
Returns orbital distance in AU.
M_star approximated from spectral type luminosity (L ∝ M^4 for main seq).
"""
if period_days <= 0:
return 1.0
lum = STAR_LUMINOSITY.get(star_type, 1.0)
m_star = lum ** 0.25 # rough mass from luminosity
p_years = period_days / 365.25
return (p_years ** 2 * m_star) ** (1.0 / 3.0)
def _check_habitability(body_def: dict) -> None:
"""
Warn if a temperate/oceanic/forest world has a physically implausible
equilibrium temperature. Helps catch orbital distance errors early.
"""
pclass = body_def.get("planet_class", "")
if pclass not in ("temperate", "oceanic", "forest"):
return
lum = body_def["star"].get("luminosity_solar", 1.0)
dist = body_def["orbit"].get("distance_au", 1.0)
atmo = body_def["physical"].get("atmosphere", "standard")
gh = {"none": 0, "thin": 8, "standard": 33, "thick": 80}.get(atmo, 33)
t_eq = 278.5 * (lum ** 0.25) / math.sqrt(max(dist, 0.01)) + gh
if t_eq > 340:
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
f"too hot for {pclass}. Check distance_au ({dist:.2f} AU). "
f"Habitable zone ≈ {(278.5*(lum**0.25)/(290-gh))**2:.2f} AU")
elif t_eq < 220:
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
f"too cold for {pclass}. Check distance_au ({dist:.2f} AU).")
def _is_tidally_locked(period_days: float, star_type: str) -> bool:
"""
Bodies with very short periods around dim stars are likely tidally locked.
Rough threshold: period < 20 days for M-stars, < 10 for K-stars.
"""
thresholds = {"M": 20, "K": 10, "F": 4, "G": 4, "A": 2, "B": 1, "O": 1}
return period_days < thresholds.get(star_type, 5)
def _tidal_heating(period_days: float, mass_class: str, parent_is_giant: bool) -> str:
"""
Estimate geothermal flux modifier from tidal heating.
Short-period moons around gas giants get significant heating (Io/Europa).
"""
if not parent_is_giant:
return "low"
if period_days < 3:
return "extreme" # Io-like
if period_days < 10:
return "moderate" # Europa-like
return "low"
# ---------------------------------------------------------------------------
# Markdown parser — bodies table
# ---------------------------------------------------------------------------
def _parse_star(system_profile_text: str) -> dict:
"""
Extract star type and luminosity from system profile section.
Looks for lines like: | **Star** | G2V · 0.0 ly |
"""
match = re.search(r'\*\*Star\*\*.*?([OBAFGKM])\d*[Vab]*', system_profile_text)
star_type = match.group(1) if match else "G"
return {
"type": star_type,
"luminosity_solar": STAR_LUMINOSITY.get(star_type, 1.0),
"color_temp_K": STAR_COLOUR_TEMP.get(star_type, 5800),
}
def _parse_bodies_table(md_text: str) -> list[dict]:
"""
Parse the Celestial Bodies table from the markdown.
Returns list of raw row dicts.
"""
# Find the table section
table_match = re.search(
r'\| Orbit \| ID.*?\n(\|[-| ]+\|\n)(.*?)(?=\n##|\Z)',
md_text, re.DOTALL
)
if not table_match:
log.warning("No bodies table found in markdown")
return []
table_body = table_match.group(2)
rows = []
for line in table_body.strip().splitlines():
if not line.strip().startswith('|'):
continue
cells = [c.strip() for c in line.split('|')[1:-1]]
if len(cells) < 10:
continue
# Extract body ID from backtick notation
id_match = re.search(r'`([^`]+)`', cells[1])
if not id_match:
continue
body_id = id_match.group(1)
# Skip non-body rows
body_type = cells[3].strip().lower()
if body_type in ('asteroid_belt', 'oort_cloud', ''):
continue
if body_type not in ('planet', 'moon', 'gas_giant'):
continue
def cell(i, default="—"):
v = cells[i].strip() if i < len(cells) else default
return v if v not in ('—', '', '-') else default
# Gravity: strip 'g' suffix
grav_str = cell(7)
try:
gravity = float(re.sub(r'[^\d.]', '', grav_str))
except (ValueError, TypeError):
gravity = None
# Orbit period
try:
period = float(cell(8))
except (ValueError, TypeError):
period = 0.0
# Day length
try:
day_h = float(cell(9))
except (ValueError, TypeError):
day_h = None
# Parent body — detect from ↳ prefix
is_moon_row = '↳' in cells[0]
rows.append({
"orbit_label": cells[0].strip(),
"body_id": body_id,
"name": cell(2) if cell(2) != '—' else None,
"body_type": body_type,
"inhabited": cell(4).lower() == 'yes',
"population": cell(5),
"mass_class": cell(6).lower(), # terrestrial / dwarf / gas_giant / ice_giant
"gravity_g": gravity,
"period_days": period,
"day_h": day_h,
"atmosphere": cell(10).lower(),
"biome": cell(11).lower(),
"hydrosphere": cell(12).lower(),
"economy": cell(13),
"settlement": cell(14),
"industrial": cell(15),
"is_moon_row": is_moon_row,
})
return rows
# ---------------------------------------------------------------------------
# Body definition builder
# ---------------------------------------------------------------------------
def _build_body_def(
row: dict,
star: dict,
system_id: str,
overrides: dict,
parent_is_giant: bool = False,
) -> Optional[dict]:
"""
Convert one bodies table row into a body_definition dict.
overrides: per-body override dict (keyed by body_id).
Returns None for bodies that don't need a render (asteroid belts etc).
"""
bid = row["body_id"]
btype = row["body_type"]
mass = row["mass_class"]
biome = row["biome"]
hydro = row["hydrosphere"]
atmo = row["atmosphere"]
period = row["period_days"]
gravity = row["gravity_g"]
star_type = star["type"]
ov = overrides.get(bid, {}) # per-body override dict
# ── Planet class ──────────────────────────────────────────────────────
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
planet_class = "gas_giant"
else:
planet_class = PLANET_CLASS_MAP.get(biome, "temperate")
if biome and biome not in PLANET_CLASS_MAP and biome != "—":
log.warning(f" {bid}: unknown planet class '{biome}' — defaulting to temperate")
planet_class = ov.get("planet_class", planet_class)
# ── Body scale ────────────────────────────────────────────────────────
body_scale = "moon" if row["is_moon_row"] or mass == "dwarf" else "planet"
body_scale = ov.get("body_scale", body_scale)
# ── Seed — deterministic from body ID ─────────────────────────────────
seed = _seed_from_id(bid)
seed = ov.get("seed", seed)
# ── Orbital distance ──────────────────────────────────────────────────
distance_au = _derive_distance_au(period, star_type)
# ── Axial tilt ────────────────────────────────────────────────────────
tilt_ov = (ov.get("orbit", {}) or {}).get("axial_tilt_deg", "rand")
if tilt_ov != "rand":
axial_tilt = float(tilt_ov)
elif _is_tidally_locked(period, star_type) and not parent_is_giant:
axial_tilt = _rand_range(bid, 0, 5, "tilt")
elif planet_class in CLASS_TILT:
lo, hi = CLASS_TILT[planet_class]
axial_tilt = _rand_range(bid, lo, hi, "tilt")
else:
axial_tilt = _rand_range(bid, 5, 35, "tilt")
# ── Atmosphere density ────────────────────────────────────────────────
atmo_density = ATMO_MAP.get(atmo, "none")
atmo_density = ov.get("atmosphere_density", atmo_density)
# ── Atmosphere colour — from star type + planet class ─────────────────
atmo_colors = {
"temperate": [0.45, 0.65, 1.00],
"temperate_terminator": [0.45, 0.65, 1.00],
"oceanic": [0.40, 0.60, 1.00],
"forest": [0.42, 0.68, 0.80],
"arid": [0.90, 0.72, 0.50],
"cold_arid": [0.82, 0.58, 0.40],
"hot_arid": [0.90, 0.72, 0.50],
"tropical": [0.42, 0.68, 0.80],
"boreal": [0.45, 0.65, 1.00],
"frozen": [0.75, 0.88, 1.00],
"volcanic": [0.55, 0.40, 0.30],
"barren": None,
}
atmo_color = atmo_colors.get(planet_class)
if atmo_density == "none":
atmo_color = None # no atmosphere = no rim glow
atmo_color = ov.get("atmosphere_color", atmo_color)
# ── Land fraction ─────────────────────────────────────────────────────
land_ov = (ov.get("terrain", {}) or {}).get("land_fraction", "rand")
if land_ov != "rand":
land_fraction = float(land_ov)
else:
lo, hi = HYDRO_LAND.get(hydro, (0.90, 0.99))
land_fraction = _rand_range(bid, lo, hi, "land")
# ── Polar ice latitude ────────────────────────────────────────────────
ice_ov = (ov.get("terrain", {}) or {}).get("polar_ice_lat", "rand")
if ice_ov != "rand":
polar_ice_lat = float(ice_ov)
else:
lo, hi = CLASS_POLAR_ICE.get(planet_class, (0.80, 0.95))
# High axial tilt → ice caps extend further toward equator
tilt_factor = (axial_tilt / 90.0) * 0.3
lo = max(0.05, lo - tilt_factor)
hi = max(0.10, hi - tilt_factor)
polar_ice_lat = _rand_range(bid, lo, hi, "ice")
# ── Tectonics ─────────────────────────────────────────────────────────
tectonic_map = {
"volcanic": "extreme", "temperate": "active",
"temperate_terminator": "active",
"oceanic": "active", "forest": "active",
"arid": "low", "cold_arid": "low",
"hot_arid": "low", "tropical": "active",
"boreal": "active", "frozen": "low", "barren": "none",
}
tectonics = tectonic_map.get(planet_class, "low")
tectonics = ov.get("tectonics", tectonics)
# ── Geothermal flux ───────────────────────────────────────────────────
geothermal = CLASS_GEOTHERMAL.get(planet_class, "low")
# Tidal heating for moons of gas giants
if parent_is_giant:
tidal = _tidal_heating(period, mass, parent_is_giant)
if tidal != "low":
geothermal = tidal
geothermal = ov.get("geothermal_flux", geothermal)
# ── UV index ──────────────────────────────────────────────────────────
uv_index = STAR_UV.get(star_type, "moderate")
# Thin/no atmosphere → UV reaches surface directly
if atmo_density in ("none", "thin"):
uv_map = {"low": "moderate", "moderate": "high", "high": "extreme"}
uv_index = uv_map.get(uv_index, uv_index)
uv_index = ov.get("uv_index", uv_index)
# ── Substrate ─────────────────────────────────────────────────────────
substrate_map = {
"volcanic": "sulfuric",
"arid": "silicate",
"cold_arid": "silicate",
"hot_arid": "silicate",
"tropical": "silicate",
"boreal": "silicate",
"frozen": "ice",
"barren": "silicate",
"temperate": "silicate",
"temperate_terminator": "silicate",
"oceanic": "silicate",
"forest": "silicate",
}
substrate = substrate_map.get(planet_class, "silicate")
if hydro == "subsurface" and planet_class == "frozen":
substrate = "ice"
substrate = ov.get("substrate", substrate)
# ── Chemosynthetic modifier ───────────────────────────────────────────
# Europa case: frozen + subsurface + tidal heating → chemosynthetic
chemosynthetic = False
if hydro == "subsurface" and geothermal in ("moderate", "high", "extreme"):
chemosynthetic = True
chemosynthetic = ov.get("chemosynthetic", chemosynthetic)
# ── Oblateness ────────────────────────────────────────────────────────
oblat_lo, oblat_hi = CLASS_OBLATENESS.get(planet_class, (0.001, 0.005))
oblateness = _rand_range(bid, oblat_lo, oblat_hi, "oblat")
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
oblateness = _rand_range(bid, 0.050, 0.090, "oblat")
oblateness = ov.get("oblateness", oblateness)
# ── Clouds ────────────────────────────────────────────────────────────
clouds_enabled = atmo_density in CLOUD_CAPABLE and planet_class != "barren"
if planet_class == "barren":
clouds_enabled = False
cld_ov = ov.get("clouds", {}) or {}
clouds_enabled = cld_ov.get("enabled", clouds_enabled)
coverage_ov = cld_ov.get("coverage_base", "rand")
if coverage_ov != "rand":
coverage = float(coverage_ov)
else:
lo, hi = CLASS_CLOUD.get(planet_class, (0.10, 0.40))
coverage = _rand_range(bid, lo, hi, "cloud")
# ── Gas giant specific ────────────────────────────────────────────────
gas_giant_cfg = None
rings_cfg = None
if planet_class == "gas_giant":
palette_ov = (ov.get("gas_giant", {}) or {}).get("band_palette", "rand")
if palette_ov == "rand":
palette = _rand_choice(bid, GAS_PALETTES, "palette")
else:
palette = palette_ov
storm_count = int(_rand_range(bid, 1, 5, "storms"))
storm_count = (ov.get("gas_giant", {}) or {}).get("storm_count", storm_count)
storm_size = _rand_range(bid, 0.06, 0.14, "storm_sz")
storm_size = (ov.get("gas_giant", {}) or {}).get("storm_max_size", storm_size)
gas_giant_cfg = {
"band_palette": palette,
"storm_count": storm_count,
"storm_max_size": round(float(storm_size), 3),
}
# Rings
rings_ov = ov.get("rings", "rand")
if rings_ov == "rand":
has_rings = _rand_bool(bid, RING_PROBABILITY, "rings")
elif isinstance(rings_ov, dict):
has_rings = rings_ov.get("enabled", True)
else:
has_rings = bool(rings_ov)
if has_rings:
planet_class = "gas_giant_ringed"
r_inner = round(_rand_range(bid, 1.08, 1.25, "r_inner"), 2)
r_outer = round(_rand_range(bid, 2.20, 2.80, "r_outer"), 2)
opacity = round(_rand_range(bid, 0.45, 0.72, "r_opa"), 2)
rcolor = RING_COLOURS.get(palette, [0.75, 0.70, 0.60])
# Merge any explicit ring overrides
if isinstance(rings_ov, dict):
r_inner = rings_ov.get("inner_radius_factor", r_inner)
r_outer = rings_ov.get("outer_radius_factor", r_outer)
opacity = rings_ov.get("opacity_base", opacity)
rcolor = rings_ov.get("ring_color", rcolor)
rings_cfg = {
"enabled": True,
"inner_radius_factor": r_inner,
"outer_radius_factor": r_outer,
"opacity_base": opacity,
"ring_color": rcolor,
}
# ── Render config ─────────────────────────────────────────────────────
render_cfg = dict(RENDER_DEFAULTS)
render_cfg["specular_ocean"] = hydro in ("ocean", "liquid_water", "rivers")
if planet_class in ("barren", "arid", "volcanic"):
render_cfg["specular_ocean"] = False
render_cfg.update(ov.get("render", {}))
# ── Assemble ──────────────────────────────────────────────────────────
body_def = {
"id": bid,
"name": row["name"],
"body_type": btype,
"planet_class": planet_class,
"body_scale": body_scale,
"seed": seed,
"star": star,
"orbit": {
"distance_au": round(distance_au, 3),
"period_days": period,
"axial_tilt_deg": round(axial_tilt, 1),
},
"physical": {
"gravity_g": gravity,
"oblateness": round(oblateness, 4),
"atmosphere": atmo_density,
"atmosphere_color": atmo_color,
},
"terrain": {
"land_fraction": round(land_fraction, 3),
"polar_ice_lat": round(polar_ice_lat, 3),
"tectonics": tectonics,
},
"environment": {
"geothermal_flux": geothermal,
"uv_index": uv_index,
"substrate": substrate,
"chemosynthetic": chemosynthetic,
"hydrosphere": hydro,
},
"clouds": {
"enabled": bool(clouds_enabled),
"coverage_base": round(coverage, 3),
},
"render": render_cfg,
}
# Gas giants: drop meaningless terrain fields
if gas_giant_cfg:
body_def.pop("terrain", None)
body_def["gas_giant"] = gas_giant_cfg
if rings_cfg:
body_def["rings"] = rings_cfg
# Wiki cultural data — not used by the generator, carried for the
# body index.md template and downstream pipelines.
pop_raw = row.get("population", "—")
body_def["wiki"] = {
"inhabited": row.get("inhabited", False),
"population": pop_raw if pop_raw not in ("—", "", None) else None,
"economy": row.get("economy") if row.get("economy") not in ("—", "", None) else None,
"settlement": row.get("settlement") if row.get("settlement") not in ("—", "", None) else None,
"industrial": row.get("industrial") if row.get("industrial") not in ("—", "", None) else None,
}
return body_def
# ---------------------------------------------------------------------------
# System parser — top-level entry
# ---------------------------------------------------------------------------
def parse_system(
md_path: str,
overrides: dict = None,
out_dir: str = None,
) -> list[dict]:
"""
Parse a system index.md and return list of body_definition dicts.
Optionally write one JSON file per body into out_dir.
overrides: { body_id: { field: value, ... } }
"""
overrides = overrides or {}
md_text = Path(md_path).read_text(encoding="utf-8")
# Extract system ID from first header
sys_match = re.search(r'\*\*([A-Z0-9 ]+)\*\*', md_text)
system_id = sys_match.group(1).replace(" ", "_") if sys_match else "UNKNOWN"
# Parse star
star = _parse_star(md_text)
log.info(f"System: {system_id} Star: {star['type']}-type "
f"L={star['luminosity_solar']:.3g} Lsun")
# Parse bodies table
rows = _parse_bodies_table(md_text)
log.info(f"Found {len(rows)} renderable bodies")
# Track which bodies are moons of gas giants (for tidal heating)
# Simple heuristic: if the previous non-moon row was a gas_giant, this is its moon
last_giant = False
body_defs = []
for row in rows:
bid = row["body_id"]
btype = row["body_type"]
mass = row["mass_class"]
is_giant = btype == "gas_giant" or mass in ("gas_giant", "ice_giant")
# Determine if this moon orbits a gas giant
parent_is_giant = row["is_moon_row"] and last_giant
if not row["is_moon_row"]:
last_giant = is_giant
# Build definition
body_def = _build_body_def(
row, star, system_id, overrides,
parent_is_giant=parent_is_giant,
)
if body_def is None:
continue
body_defs.append(body_def)
log.info(f" {bid:20s} {body_def['planet_class']:20s} "
f"scale={body_def['body_scale']:6s} "
f"seed={body_def['seed']}")
_check_habitability(body_def)
# Write output files
if out_dir:
os.makedirs(out_dir, exist_ok=True)
for bd in body_defs:
out_path = os.path.join(out_dir, f"{bd['id']}_def.json")
with open(out_path, "w") as f:
json.dump(bd, f, indent=2)
log.info(f"Wrote {len(body_defs)} body definitions → {out_dir}/")
return body_defs
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Parse system index.md → body_definition.json files"
)
parser.add_argument("md_file", help="Path to system index.md")
parser.add_argument("--out-dir", default="./body_defs",
help="Output directory for JSON files (default: ./body_defs)")
parser.add_argument("--overrides", default=None,
help="Path to JSON overrides file (optional)")
parser.add_argument("--print", action="store_true",
help="Print all body definitions to stdout")
args = parser.parse_args()
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
if args.print:
console.out(json.dumps(defs, indent=2))
@@ -0,0 +1,270 @@
# Earth-name blocklist (#833, D-191 §4). Retained as curated reference data
# after the Gemma naming pipeline was retired (#951, D-223) — see
# docs/gemma-naming-methodology.md.
#
# Per memory feedback_earth_echo_names: Earth-sounding names are OK and
# expected — the setting is a blended reality. This file is a small,
# curated list of Earth *majors* whose raw names should not appear as
# settled-reach feature names because they are too on-the-nose. Prefixed
# variants like "Nouveau Paris", "Neu Berlin", or "New Tokyo" are allowed
# (they pass because the exact-match check does not strip prefixes).
#
# Match rule: case-insensitive whole-name equality between a generated
# feature name and any entry below. One entry per line, `#` comments are
# allowed, blank lines ignored.
#
# Expand this list if the generator starts producing raw-Earth-major
# names in frequent runs. Err on the side of short — we do NOT want to
# block every vaguely Earthy token.
# Capitals & megacities
Paris
London
Tokyo
Beijing
Moscow
Berlin
Madrid
Rome
Cairo
Mumbai
Delhi
Jakarta
Seoul
Bangkok
Istanbul
Lagos
Nairobi
Johannesburg
Sydney
Washington
Ottawa
Mexico City
Rio de Janeiro
Rio
Buenos Aires
Lima
Bogota
Dhaka
Karachi
Lahore
Tehran
Baghdad
Riyadh
Manila
Hanoi
Kuala Lumpur
Singapore
Vienna
Prague
Warsaw
Amsterdam
# GJ0d (Earth body) is exempt — this blocklist applies to alien worlds only, not to Earth itself.
Brussels
Copenhagen
Stockholm
Oslo
Helsinki
Reykjavik
Lisbon
Athens
Budapest
Dublin
Edinburgh
Glasgow
# Iconic cities / regional majors
New York
Los Angeles
Chicago
Houston
Miami
Boston
Seattle
Toronto
Vancouver
Montreal
Barcelona
Munich
Hamburg
Frankfurt
Milan
Venice
Florence
Naples
Marseille
Lyon
Geneva
Zurich
Shanghai
Hong Kong
Taipei
Osaka
Kyoto
Yokohama
Busan
Ho Chi Minh
Kolkata
Chennai
Bangalore
Hyderabad
Addis Ababa
Casablanca
Accra
Kinshasa
Cape Town
Tel Aviv
Dubai
Doha
Damascus
Beirut
Jerusalem
Auckland
Wellington
Melbourne
Brisbane
Perth
# Mountain / natural majors that read as Earth
Everest
Kilimanjaro
Fuji
Matterhorn
Olympus
Etna
Vesuvius
Denali
Aconcagua
Ararat
K2
Annapurna
Elbrus
Kosciuszko
# Rivers
Amazon
Nile
Danube
Mississippi
Mekong
Ganges
Yangtze
Rhine
Volga
Thames
Seine
Tigris
Euphrates
Congo
Niger
Zambezi
# Oceans / seas
Atlantic
Pacific
Indian
Arctic
Mediterranean
Baltic
Caspian
Aegean
Adriatic
Caribbean
# Historical / colonial / alternate spellings of Earth cities — Gemma
# knows these from training data and emits them as if they were
# neutral names.
Calcutta
Bombay
Madras
Bangalore
Poona
Benares
Peking
Canton
Nanking
Chungking
Saigon
Rangoon
Ceylon
Batavia
Angora
Smyrna
Constantinople
Formosa
Tasmania
Rhodesia
Persia
Mesopotamia
# Earth natural-phenomenon names Gemma likes to reach for
Aurora Borealis
Aurora Australis
Great Divide
Great Lakes
Great Basin
Grand Canyon
Death Valley
Sahara
Gobi
Patagonia
Serengeti
Outback
Tundra
Siberia
Amazon Basin
# Greek/Roman mythology that reads too literally as Earth classical
Olympus Mons
Mount Olympus
# European rivers the model keeps reaching for
Rhine
Weser
Elbe
Oder
Vistula
Loire
Rhône
Douro
Tagus
Ebro
Po
Arno
Tiber
Sava
Drava
Vlatava
Vltava
Dnieper
Don
Volga
Dniester
# Nordic / Eastern European cities
Reykjavik
Oslo
Bergen
Tromsø
Gothenburg
Gdansk
Krakow
Warsaw
Prague
Brno
Bratislava
Budapest
Debrecen
Bucharest
Sofia
Belgrade
Zagreb
Ljubljana
Tallinn
Riga
Vilnius
Kiev
Kyiv
Minsk
Odessa
Lviv
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""
Planet Generator — CLI entry point.
Two input modes:
1. Body definition JSON: generate body_def.json --output-dir ./output
2. System index.md: generate --system wiki/star-systems/GJ-144/index.md
Outputs per body into {output-dir}/{body_id}/:
heightmap.png — clean equirectangular cartographic map (no chrome)
globe.png — 512×512 sphere render
body.json — body descriptor + rendering metadata
terrain.npz — compressed terrain grids for downstream generators
rivers.json — river polylines in grid coords
Optional (spike/review only):
heightmap_chrome.png — heightmap with title bar + legend overlay
"""
import argparse
import json
import os
import time
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
def _build_markers(body_def: dict, terrain: dict) -> dict:
"""Extract geographic markers from terrain data."""
from scipy.ndimage import label, center_of_mass
grid_h = terrain["_grid_h"]
grid_w = terrain["_grid_w"]
sea_level = terrain["sea_level"]
elevation = terrain["elevation"]
surface_water = terrain["surface_water"]
biome = terrain["biome"]
markers = {
"grid": {"w": grid_w, "h": grid_h},
"rivers": [],
"oceans": [],
"mountain_ranges": [],
"roads": [],
"cities": [],
"railroads": [],
"pois": [],
}
# ── Rivers ───────────────────────────────────────────────────────────
for i, path in enumerate(terrain.get("rivers", [])):
markers["rivers"].append({
"id": f"river_{i}",
"name": None, # named by copy team or procedural namer
"path": path,
})
# ── Oceans / seas ────────────────────────────────────────────────────
# Label connected water bodies and record their center + area
if surface_water.any():
water_labels, n_bodies = label(surface_water)
total_cells = grid_h * grid_w
for lbl in range(1, n_bodies + 1):
mask = water_labels == lbl
area_cells = int(mask.sum())
area_frac = area_cells / total_cells
if area_frac < 0.005:
continue # skip tiny puddles
cy, cx = center_of_mass(mask)
kind = "ocean" if area_frac > 0.10 else "sea" if area_frac > 0.02 else "lake"
markers["oceans"].append({
"id": f"water_{lbl}",
"name": None,
"kind": kind,
"center": [int(cy), int(cx)],
"area_fraction": round(area_frac, 4),
})
# ── Mountain ranges ──────────────────────────────────────────────────
# High-elevation connected regions on land
land = ~surface_water
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
mountains = land & (elev_norm > 0.55)
if mountains.any():
mtn_labels, n_ranges = label(mountains)
for lbl in range(1, n_ranges + 1):
mask = mtn_labels == lbl
area = int(mask.sum())
if area < 20:
continue # skip tiny peaks
cy, cx = center_of_mass(mask)
# Find the ridge line: cells with highest elevation in the range
ys, xs = np.where(mask)
peak_idx = np.argmax(elevation[ys, xs])
markers["mountain_ranges"].append({
"id": f"range_{lbl}",
"name": None,
"center": [int(cy), int(cx)],
"peak": [int(ys[peak_idx]), int(xs[peak_idx])],
"area_cells": area,
})
return markers
def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
globe_size: int, render_mode: str, output_dir: str,
chrome: bool = False):
"""Generate all outputs for a single body definition."""
body_id = body_def["id"]
if body_def.pop("_flat_output", False):
body_dir = output_dir # output directly, no body_id subdir
else:
body_dir = os.path.join(output_dir, body_id)
os.makedirs(body_dir, exist_ok=True)
planet_class = body_def.get("planet_class", "unknown")
name = body_def.get("name") or body_id
console.event(f" {body_id} ({name}) — {planet_class}")
t0 = time.time()
# ── 1. Simulate ──────────────────────────────────────────────────────
terrain = simulate(body_def)
is_gas = not terrain
t_sim = time.time()
if is_gas:
console.event(f" simulate: gas giant ({t_sim - t0:.1f}s)")
else:
console.event(f" simulate: {t_sim - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())}")
# ── 2. Render heightmap ──────────────────────────────────────────────
t_hmap = t_sim
if not is_gas:
# Clean heightmap (no title/legend)
hmap_img = render_heightmap(body_def, terrain,
out_w=hmap_w, out_h=hmap_h,
render_mode=render_mode, chrome=False)
hmap_img.save(os.path.join(body_dir, "heightmap.png"))
# Chrome version for review (optional — saved to /tmp, not shipped)
if chrome:
hmap_chrome = render_heightmap(body_def, terrain,
out_w=hmap_w, out_h=hmap_h,
render_mode=render_mode, chrome=True)
chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
hmap_chrome.save(chrome_path)
console.event(f" chrome: {chrome_path}")
t_hmap = time.time()
console.event(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
# ── 3. Render globe ──────────────────────────────────────────────────
try:
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(body_def, terrain, size=globe_size)
globe_img.save(os.path.join(body_dir, "globe.png"))
t_globe = time.time()
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
except Exception as e:
console.event(f" globe: FAILED — {e}")
t_globe = time.time()
# ── 4. Write data files ──────────────────────────────────────────────
# Body definition lives in the index.md frontmatter — no body.json needed.
if not is_gas:
# terrain.npz — grids for downstream generators
save_dict = {}
for key in ("elevation", "temperature", "moisture", "hillshade",
"biome", "surface_water", "river_grid"):
if key in terrain:
save_dict[key] = terrain[key]
save_dict["sea_level"] = np.array([terrain["sea_level"]])
np.savez_compressed(os.path.join(body_dir, "terrain.npz"), **save_dict)
# markers.json — named geographic and cultural features.
markers = _build_markers(body_def, terrain)
with open(os.path.join(body_dir, "markers.json"), "w") as f:
json.dump(markers, f, indent=2)
elapsed = time.time() - t0
console.event(f" total: {elapsed:.1f}s → {body_dir}/")
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Planet generator — heightmap + globe from body definitions")
# Input modes
parser.add_argument("body_def", nargs="?",
help="Path to body definition JSON file")
parser.add_argument("--system",
help="Path to system index.md — generates all bodies")
parser.add_argument("--overrides",
help="Path to per-body overrides JSON (used with --system)")
# Output
parser.add_argument("--output-dir", default=".",
help="Root output directory (bodies get subdirs)")
# Rendering
parser.add_argument("--heightmap-size", default="1024x512",
help="Heightmap output resolution (WxH)")
parser.add_argument("--globe-size", type=int, default=512,
help="Globe output resolution (square, locked at 512)")
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
default="cartographic")
parser.add_argument("--chrome", action="store_true",
help="Also render heightmap with title/legend (review only, not shipped)")
args = parser.parse_args(argv)
# Parse heightmap size
try:
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
except ValueError:
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
# ── Collect body definitions ─────────────────────────────────────────
body_defs = []
if args.system:
# Read from system index.md → parse bodies table
from tooling.domains.atlas.planet.body_definition_parser import parse_system
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
body_defs = parse_system(args.system, overrides=overrides)
console.event(f"System: {args.system} — {len(body_defs)} bodies")
elif args.body_def:
input_path = args.body_def
if input_path.endswith(".json"):
with open(input_path) as f:
body_defs = [json.load(f)]
elif input_path.endswith(".md"):
# Read body definition from frontmatter
import yaml
with open(input_path) as f:
content = f.read()
if content.startswith("---"):
fm_end = content.index("---", 3)
fm = yaml.safe_load(content[3:fm_end])
if "id" in fm and "planet_class" in fm:
body_defs = [fm]
fm["_flat_output"] = True # no body_id subdir
# Output alongside the body index.md if no --output-dir
if args.output_dir == ".":
args.output_dir = str(Path(input_path).parent)
else:
raise ReachError(f"{input_path} frontmatter missing 'id' or 'planet_class'", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
raise ReachError(f"{input_path} has no YAML frontmatter", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
raise ReachError(f"unrecognized input format: {input_path}", fix="pass a body definition .json or a body index.md")
else:
parser.error("Provide a body_def (.json or .md) or --system index.md")
# ── Generate ─────────────────────────────────────────────────────────
t_total = time.time()
for bd in body_defs:
_generate_body(bd, hmap_w, hmap_h, args.globe_size,
args.render_mode, args.output_dir, chrome=args.chrome)
elapsed = time.time() - t_total
console.event(f" All done: {len(body_defs)} bodies in {elapsed:.1f}s")
if __name__ == "__main__":
main()
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
import_heightmaps.py — bake per-body canonical elevation assets (D-202 amended, #963).
This is the build-time elevation producer. For each NON-Sol inhabited body it
runs planet_simulation.simulate() at the canonical 1024×512 grid and writes two
files into the body's wiki directory:
reliefmap.png — clean color hypsometric render (display / Atlas), 1024×512
heightmap.png — 16-bit GRAYSCALE elevation, the canonical cascade input,
with sea_level stored in a PNG tEXt chunk
It also performs the one-time rename of the legacy color `heightmap.png`
(which was a relief render) → `reliefmap.png` across ALL bodies, including Sol
(GJ-0) — Sol's terrain is real-geography (sol_import) and is NOT re-simulated,
only its display file is renamed.
No systems.db writes: the PNG is the store (the atlas_body_heightmaps BLOB
table is dropped). Run with uv (numpy/scipy/Pillow):
reach atlas planet import-heightmaps # full bake
reach atlas planet import-heightmaps --limit 3 # smoke test
reach atlas planet import-heightmaps --dry-run
Exit codes: 0 = completed (possibly with per-body errors), 1 = fatal.
"""
import argparse
import glob
import sqlite3
import sys
import time
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import simulate # noqa: E402
from tooling.domains.atlas.planet.render_heightmap import render_heightmap # noqa: E402
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
def rename_legacy_heightmaps(dry_run: bool) -> int:
"""One-time: legacy color `heightmap.png` (a relief render) → `reliefmap.png`,
for every body. Bodies the bake re-simulates get their `reliefmap.png`
overwritten with a fresh 1024 render afterwards. Idempotent: skips bodies
that already have `reliefmap.png`."""
n = 0
for hm in sorted(glob.glob(str(WIKI_SYSTEMS / "*" / "bodies" / "*" / "heightmap.png"))):
relief = Path(hm).with_name("reliefmap.png")
if relief.exists():
continue # already migrated (or freshly baked)
if not dry_run:
Path(hm).rename(relief)
n += 1
return n
def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
"""Simulate one body at 1024×512 and write reliefmap.png + 16-bit heightmap.png."""
try:
terrain = simulate(bd)
except Exception as exc: # noqa: BLE001
return {"status": "error", "message": f"simulate failed: {exc}"}
if not terrain or "elevation" not in terrain:
return {"status": "gas_giant"}
elevation = np.clip(np.asarray(terrain["elevation"], dtype=np.float32), 0.0, 1.0)
sea_level = float(terrain.get("sea_level", 0.0))
if not dry_run:
# Clean color relief (no painted features — cascade/Atlas overlay those).
relief = render_heightmap(bd, terrain, render_mode="cartographic", chrome=False)
relief.save(body_dir / "reliefmap.png")
# Canonical 16-bit grayscale elevation; sea_level rides in a tEXt chunk.
u16 = (elevation * 65535.0).astype(np.uint16)
meta = PngInfo()
meta.add_text("sea_level", f"{sea_level:.6f}")
Image.fromarray(u16).save(body_dir / "heightmap.png", pnginfo=meta)
return {"status": "baked", "shape": elevation.shape, "sea_level": sea_level}
def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(description="Bake canonical heightmap/reliefmap assets (#963)")
ap.add_argument("--db", default=str(DB_PATH))
ap.add_argument("--body", help="Bake only this body_id")
ap.add_argument("--limit", type=int, help="Bake at most N bodies (smoke test)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--skip-rename", action="store_true", help="Skip the legacy rename pass")
args = ap.parse_args()
db_path = Path(args.db)
if not db_path.exists():
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
console.event(" Heightmap bake (#963, D-202 amended)")
if args.dry_run:
console.event(" Mode: DRY RUN (no files written)")
# 1. Universal rename of the legacy color heightmap.png → reliefmap.png.
if not args.skip_rename:
n_renamed = rename_legacy_heightmaps(args.dry_run)
console.event(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
# 2. Bake non-Sol inhabited bodies.
conn = sqlite3.connect(str(db_path))
rows = conn.execute(
"SELECT body_id, terrain_reference FROM bodies "
"WHERE inhabited=1 AND terrain_reference IS NOT NULL AND population>0 "
"AND system_id != 'GJ 0' ORDER BY body_id"
).fetchall()
conn.close()
if args.body:
rows = [r for r in rows if r[0] == args.body]
if args.limit:
rows = rows[: args.limit]
console.event(f" Baking {len(rows)} non-Sol inhabited bodies\n")
parsed: dict[str, list] = {}
t0 = time.time()
n_baked = n_gas = n_err = 0
for i, (body_id, tref) in enumerate(rows):
body_dir = REPO_ROOT / Path(tref).parent
sys_index = str(REPO_ROOT / Path(tref.split("/bodies/")[0]) / "index.md")
try:
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
except Exception as exc: # noqa: BLE001
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
n_err += 1
continue
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
n_err += 1
continue
ts = time.time()
res = bake_body(bd, body_dir, args.dry_run)
dt = time.time() - ts
if res["status"] == "baked":
n_baked += 1
if (i + 1) % 25 == 0 or args.limit:
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
f"sea={res['sea_level']:.3f} ({dt:.1f}s)")
elif res["status"] == "gas_giant":
n_gas += 1
else:
n_err += 1
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
console.event(f" Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
console.event(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
if n_err:
sys.exit(0) # per-body errors are non-fatal; reported above
if __name__ == "__main__":
main()
@@ -0,0 +1,545 @@
#!/usr/bin/env python3
"""
import_province_boundaries.py — Pre-compute province boundaries from watershed analysis.
For each inhabited body with a heightmap row in atlas_body_heightmaps, runs D8
drainage analysis to derive drainage basin boundaries and stores them as pixel-space
polylines in atlas_province_boundaries (D-205, D-208, #907).
Algorithm:
1. Load float32 elevation BLOB from atlas_body_heightmaps.
2. Depression-fill: raise sinks to the lowest-outlet neighbor (iterative).
3. D8 flow direction: assign each cell to its steepest-descent neighbor.
4. Flow accumulation: upstream cell count per cell (topological sort).
5. Basin labeling: seed a basin per pour-point (flow-accumulation > threshold);
flood-fill remaining cells following flow direction.
6. Merge small basins (< 2% area) into the largest adjacent basin.
7. Clamp basin count to [4, 12] by iterative merging of smallest basins.
8. Trace boundary polylines between adjacent basins.
9. Upsert rows into atlas_province_boundaries.
Province count target: 4–12 per body (D-205). Bodies with low relief get fewer,
larger provinces; high-relief worlds get more.
Performance: ~3–5s per body on a single CPU core at canonical 512×256 resolution.
The bottleneck is the pure-Python depression-fill + flow-direction scan (O(H×W) each,
~131k cells). For a full run of ~270 inhabited bodies expect ~15–20 minutes.
Hot loops (_depression_fill, _flow_direction) are candidates for NumPy vectorization
if build time becomes a bottleneck; the current scalar implementation is correct
and deterministic, which takes priority at this stage.
Incremental: bodies that already have rows in atlas_province_boundaries are skipped
unless --force is passed.
Usage:
reach atlas planet import-provinces
reach atlas planet import-provinces --body GJ380c
reach atlas planet import-provinces --force
reach atlas planet import-provinces --dry-run
Exit codes:
0 completed
1 fatal error (missing DB, schema error)
"""
import argparse
import json
import time
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
import sqlite3
from tooling.domains.atlas.planet.atlas_common import (
DB_PATH,
ensure_atlas_schema,
query_inhabited_bodies,
)
# D8 neighbor offsets: (dr, dc)
_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
# River threshold from D-208: cells with flow_accumulation > 200 are river cells.
# Province seeds are local flow-accumulation maxima (watershed pour points).
_FLOW_THRESHOLD = 200
# Minimum basin area as fraction of total cells before merging into neighbor.
_MIN_BASIN_FRAC = 0.02
_PROVINCE_MIN = 4
_PROVINCE_MAX = 12
def _load_elevation(body_id: str, conn: sqlite3.Connection) -> np.ndarray | None:
"""Load float32 LE elevation BLOB from atlas_body_heightmaps."""
row = conn.execute(
"SELECT data, width, height FROM atlas_body_heightmaps WHERE body_id = ?",
(body_id,),
).fetchone()
if not row:
return None
data, width, height = row
arr = np.frombuffer(data, dtype="<f4").reshape((height, width))
return arr.astype(np.float32)
def _depression_fill(elev: np.ndarray) -> np.ndarray:
"""Simple iterative depression fill: raise sinks to their lowest outlet.
Uses a shallow iterative pass — good enough for province-scale basins on
512×256 grids. Not full priority-flood (which is O(N log N)); this O(N·k)
approach converges in ≤10 passes on real heightmaps.
"""
H, W = elev.shape
filled = elev.copy()
for _ in range(10):
changed = False
for r in range(1, H - 1):
for c in range(W):
nbr_min = float("inf")
for dr, dc in _D8:
nr = r + dr
nc = (c + dc) % W
if 0 <= nr < H:
nbr_min = min(nbr_min, filled[nr, nc])
if filled[r, c] < nbr_min:
filled[r, c] = nbr_min + 1e-6
changed = True
if not changed:
break
return filled
def _flow_direction(filled: np.ndarray) -> np.ndarray:
"""D8 flow direction: index into _D8 (0–7), or -1 for no outflow (edge/flat)."""
H, W = filled.shape
fdir = np.full((H, W), -1, dtype=np.int8)
for r in range(H):
for c in range(W):
best_drop = 0.0
best_k = -1
for k, (dr, dc) in enumerate(_D8):
nr = r + dr
nc = (c + dc) % W
if nr < 0 or nr >= H:
continue
drop = filled[r, c] - filled[nr, nc]
if drop > best_drop:
best_drop = drop
best_k = k
fdir[r, c] = best_k
return fdir
def _flow_accumulation(fdir: np.ndarray) -> np.ndarray:
"""Flow accumulation via topological sort of the D8 DAG."""
H, W = fdir.shape
in_degree = np.zeros((H, W), dtype=np.int32)
for r in range(H):
for c in range(W):
k = int(fdir[r, c])
if k < 0:
continue
dr, dc = _D8[k]
nr = r + dr
nc = (c + dc) % W
if 0 <= nr < H:
in_degree[nr, nc] += 1
from collections import deque
queue = deque()
for r in range(H):
for c in range(W):
if in_degree[r, c] == 0:
queue.append((r, c))
accum = np.ones((H, W), dtype=np.int32)
while queue:
r, c = queue.popleft()
k = int(fdir[r, c])
if k < 0:
continue
dr, dc = _D8[k]
nr = r + dr
nc = (c + dc) % W
if 0 <= nr < H:
accum[nr, nc] += accum[r, c]
in_degree[nr, nc] -= 1
if in_degree[nr, nc] == 0:
queue.append((nr, nc))
return accum
def _label_basins(fdir: np.ndarray, accum: np.ndarray) -> np.ndarray:
"""Label each cell with a basin ID via pour-point flood fill.
Pour points are local flow-accumulation maxima above the river threshold.
Each pour point seeds a basin; remaining cells are labeled by tracing
flow direction back to their pour-point seed.
"""
H, W = fdir.shape
labels = np.full((H, W), -1, dtype=np.int32)
# Seed one label per local accum maximum above threshold.
# Use a simple scan: a cell is a local maximum if no neighbor has higher accum.
pour_pts: list[tuple[int, int]] = []
for r in range(H):
for c in range(W):
if accum[r, c] <= _FLOW_THRESHOLD:
continue
is_max = True
for dr, dc in _D8:
nr = r + dr
nc = (c + dc) % W
if 0 <= nr < H and accum[nr, nc] > accum[r, c]:
is_max = False
break
if is_max:
pour_pts.append((r, c))
# If no pour points (e.g. flat/ocean world), create a single basin.
if not pour_pts:
labels[:] = 0
return labels
for basin_id, (r, c) in enumerate(pour_pts):
labels[r, c] = basin_id
# BFS flood: for each unlabeled cell, follow flow direction until a labeled
# cell is reached; assign that label back along the path.
def _trace(r0: int, c0: int) -> int:
path: list[tuple[int, int]] = []
r, c = r0, c0
for _ in range(H * W):
if labels[r, c] >= 0:
lbl = labels[r, c]
for pr, pc in path:
labels[pr, pc] = lbl
return lbl
path.append((r, c))
k = int(fdir[r, c])
if k < 0:
# No outflow — assign basin 0
lbl = 0
for pr, pc in path:
labels[pr, pc] = lbl
return lbl
dr, dc = _D8[k]
nr = r + dr
nc = (c + dc) % W
if nr < 0 or nr >= H:
lbl = 0
for pr, pc in path:
labels[pr, pc] = lbl
return lbl
r, c = nr, nc
# Cycle guard
lbl = 0
for pr, pc in path:
labels[pr, pc] = lbl
return lbl
for r in range(H):
for c in range(W):
if labels[r, c] < 0:
_trace(r, c)
return labels
def _merge_small_basins(
labels: np.ndarray, target_min: int, target_max: int
) -> np.ndarray:
"""Merge tiny basins into their largest neighbor until count is in [target_min, target_max]."""
H, W = labels.shape
labels = labels.copy()
def _basin_sizes() -> dict[int, int]:
ids, counts = np.unique(labels, return_counts=True)
return dict(zip(ids.tolist(), counts.tolist()))
def _neighbors(basin_id: int) -> set[int]:
mask = labels == basin_id
# Dilate mask by 1 pixel in each direction, find adjacent basin IDs.
nbrs: set[int] = set()
rs, cs = np.where(mask)
for r, c in zip(rs.tolist(), cs.tolist()):
for dr, dc in _D8:
nr = r + dr
nc = (c + dc) % W
if 0 <= nr < H:
nbr_id = int(labels[nr, nc])
if nbr_id != basin_id:
nbrs.add(nbr_id)
return nbrs
total = H * W
for _ in range(200):
sizes = _basin_sizes()
n_basins = len(sizes)
if n_basins <= target_max and all(
v / total >= _MIN_BASIN_FRAC for v in sizes.values()
):
break
if n_basins <= target_min:
break
# Find the smallest basin
smallest_id = min(sizes, key=lambda b: sizes[b])
smallest_frac = sizes[smallest_id] / total
if n_basins <= target_max and smallest_frac >= _MIN_BASIN_FRAC:
break
# Merge into its largest neighbor
nbrs = _neighbors(smallest_id)
if not nbrs:
break
merge_into = max(nbrs, key=lambda b: sizes.get(b, 0))
labels[labels == smallest_id] = merge_into
# Re-number contiguously from 0
unique_ids = sorted(np.unique(labels).tolist())
remap = {old: new for new, old in enumerate(unique_ids)}
new_labels = np.zeros_like(labels)
for old, new in remap.items():
new_labels[labels == old] = new
return new_labels
def _trace_boundary(labels: np.ndarray, basin_id: int) -> list[list[int]]:
"""Trace the outer boundary of a basin as a pixel-space polyline.
Returns a list of [row, col] points forming the boundary polygon.
Uses a simple contour walk: find all boundary cells (cells adjacent to a
different basin), then sort them by angle from centroid to approximate a
closed polygon.
"""
H, W = labels.shape
mask = labels == basin_id
# Boundary cells: in this basin AND adjacent to a different basin
boundary: list[tuple[int, int]] = []
rs, cs = np.where(mask)
for r, c in zip(rs.tolist(), cs.tolist()):
on_boundary = False
for dr, dc in _D8:
nr = r + dr
nc = (c + dc) % W
if nr < 0 or nr >= H:
on_boundary = True
break
if labels[nr, nc] != basin_id:
on_boundary = True
break
if on_boundary:
boundary.append((r, c))
if not boundary:
return []
# Sort by angle from centroid — produces a rough polygon outline.
arr = np.array(boundary, dtype=np.float32)
centroid_r = float(np.mean(arr[:, 0]))
centroid_c = float(np.mean(arr[:, 1]))
angles = np.arctan2(arr[:, 0] - centroid_r, arr[:, 1] - centroid_c)
order = np.argsort(angles)
# Subsample if very large — keep at most 500 points for storage efficiency.
pts = [boundary[i] for i in order.tolist()]
if len(pts) > 500:
step = len(pts) // 500
pts = pts[::step]
return [[r, c] for r, c in pts]
def compute_province_boundaries(
body_id: str, elevation: np.ndarray
) -> list[dict]:
"""Run full watershed analysis; return list of basin dicts.
Each dict:
basin_id: int
path: JSON-serialisable [[row, col], ...]
area_pct: float
"""
H, W = elevation.shape
total_cells = H * W
filled = _depression_fill(elevation)
fdir = _flow_direction(filled)
accum = _flow_accumulation(fdir)
labels = _label_basins(fdir, accum)
labels = _merge_small_basins(labels, _PROVINCE_MIN, _PROVINCE_MAX)
unique_ids = sorted(np.unique(labels).tolist())
basins = []
for basin_id in unique_ids:
count = int(np.sum(labels == basin_id))
area_pct = count / total_cells
path = _trace_boundary(labels, basin_id)
if not path:
continue
basins.append({
"basin_id": basin_id,
"path": path,
"area_pct": area_pct,
})
return basins
def import_body_provinces(
body_id: str,
conn: sqlite3.Connection,
force: bool,
dry_run: bool,
verbose: bool,
) -> dict:
"""Import province boundary rows for one body.
Returns dict:
status: 'imported' | 'skipped' | 'no_heightmap' | 'error'
imported: count of basins written
message: detail on error/skip
"""
if not force:
existing = conn.execute(
"SELECT COUNT(*) FROM atlas_province_boundaries WHERE body_id = ?",
(body_id,),
).fetchone()[0]
if existing > 0:
return {"status": "skipped", "imported": 0,
"message": f"already has {existing} rows"}
elevation = _load_elevation(body_id, conn)
if elevation is None:
return {"status": "no_heightmap", "imported": 0,
"message": "no row in atlas_body_heightmaps"}
try:
basins = compute_province_boundaries(body_id, elevation)
except Exception as exc:
return {"status": "error", "imported": 0, "message": str(exc)}
if not basins:
return {"status": "error", "imported": 0,
"message": "no basins produced from watershed analysis"}
if verbose:
areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins]
console.event(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
if not dry_run:
with conn: # per-body transaction (BEGIN/COMMIT): rolls back this body on exception, keeps prior commits
conn.execute(
"DELETE FROM atlas_province_boundaries WHERE body_id = ?",
(body_id,),
)
for b in basins:
conn.execute(
"""INSERT INTO atlas_province_boundaries
(body_id, basin_id, path, area_pct)
VALUES (?, ?, ?, ?)""",
(body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]),
)
return {"status": "imported", "imported": len(basins)}
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
)
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
parser.add_argument("--body", help="Process only this body_id")
parser.add_argument("--force", action="store_true",
help="Re-import even if rows already exist")
parser.add_argument("--dry-run", action="store_true",
help="Analyse without writing to DB")
parser.add_argument("--verbose", action="store_true",
help="Print per-body detail")
args = parser.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
console.event(" Province Boundary Import (#907)")
console.event(f" DB: {db_path}")
if args.dry_run:
console.event(" Mode: DRY RUN (no DB writes)")
if args.force:
console.event(" Force: enabled (will overwrite existing rows)")
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON")
ensure_atlas_schema(conn)
bodies = query_inhabited_bodies(conn)
if args.body:
bodies = [b for b in bodies if b["body_id"] == args.body]
if not bodies:
conn.close()
raise ReachError(f"body '{args.body}' not found or has no terrain_reference", fix="reach atlas planet terrain-reference, then retry")
console.event(f" {len(bodies)} inhabited bodies with terrain_reference\n")
t_total = time.time()
n_imported = 0
n_skipped = 0
n_no_hmap = 0
n_errors = 0
for i, body_info in enumerate(bodies):
body_id = body_info["body_id"]
t0 = time.time()
result = import_body_provinces(body_id, conn, args.force, args.dry_run, args.verbose)
elapsed = time.time() - t0
status = result["status"]
if status == "imported":
n_imported += 1
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
elif status == "skipped":
n_skipped += 1
if args.verbose:
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
elif status == "no_heightmap":
n_no_hmap += 1
if args.verbose:
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
elif status == "error":
n_errors += 1
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
conn.close()
elapsed_total = time.time() - t_total
console.event(f" Done in {elapsed_total:.1f}s")
console.event(f" imported={n_imported} skipped={n_skipped} "
f"no_heightmap={n_no_hmap} errors={n_errors}")
if n_errors > 0:
raise ReachError(f"{n_errors} error(s) — check output above", fix="re-run the failing body alone: reach atlas planet import-provinces --body <id> --verbose")
if __name__ == "__main__":
main()
@@ -0,0 +1,976 @@
"""
planet_renderer.py
------------------
Renders a 2048×2048 planet globe PNG from a body definition dict.
Supported planet_class values:
temperate, oceanic, forest — terrestrial, biome-colored surface
arid, martian — dry terrestrial, dust haze
frozen — ice world, cold-tinted
barren — rocky, no atmosphere
volcanic — dark rock, lava highlight pass
moon — barren + crater density from 'age'
gas_giant — band renderer, no UV wrap
gas_giant_ringed — gas_giant + ring plane composite
Lighting model (terrestrial):
diffuse — Lambert with sharpened terminator
specular — Phong, ocean cells only (masked by surface_water grid)
terminator — warm scatter band at dot(N,L) ≈ 0
rim glow — atmosphere color at grazing angle, lit + dark side
night side — faint ambient scatter, no city lights
clouds — moisture-driven opacity, rendered above surface
Outputs:
PIL Image (RGBA, 2048×2048) — caller saves as PNG
Usage:
from tooling.domains.atlas.planet.planet_renderer import render_globe
img = render_globe(body_def, terrain=None)
img.save("myplanet.png")
# With terrain data:
img = render_globe(body_def, terrain={
"elevation": np.ndarray (H, W) float32 [0,1],
"temperature": np.ndarray (H, W) float32 [0,1],
"moisture": np.ndarray (H, W) float32 [0,1],
"biome": np.ndarray (H, W) int8 [0..N],
"surface_water":np.ndarray (H, W) bool,
})
"""
import math
import numpy as np
from PIL import Image
from tooling.domains.atlas.planet.biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
STAR_TINTS as _STAR_TINTS_CFG,
ATMO_COLORS as _ATMO_COLORS_CFG,
GAS_PALETTES as _GAS_PALETTES_CFG,
MAX_BIOME_ID,
)
from tooling.core import console
# ---------------------------------------------------------------------------
# Output resolution
# ---------------------------------------------------------------------------
GLOBE_SIZE = 2048
SPHERE_R = 0.90 # sphere radius in [-1,1] NDC — leaves margin for ring/glow
# ---------------------------------------------------------------------------
# Star color temperature → RGB tint for lighting
# ---------------------------------------------------------------------------
# Star tints loaded from biomes.toml
STAR_TINTS = _STAR_TINTS_CFG
# ---------------------------------------------------------------------------
# Biome palette (index matches Whittaker classification order)
# Colours are float RGB [0,1]
# ---------------------------------------------------------------------------
BIOME_COLORS = np.array([
[0.12, 0.20, 0.38], # 0 ocean deep
[0.16, 0.30, 0.52], # 1 ocean mid
[0.22, 0.42, 0.58], # 2 ocean shallow
[0.50, 0.62, 0.45], # 3 coast / beach
[0.38, 0.52, 0.30], # 4 subtropical dry forest
[0.25, 0.48, 0.22], # 5 tropical rainforest
[0.42, 0.56, 0.28], # 6 tropical seasonal forest
[0.55, 0.60, 0.32], # 7 savanna / grassland
[0.62, 0.58, 0.38], # 8 temperate grassland
[0.30, 0.50, 0.28], # 9 temperate deciduous forest
[0.22, 0.40, 0.25], # 10 temperate rainforest
[0.20, 0.35, 0.22], # 11 boreal / taiga
[0.72, 0.68, 0.58], # 12 shrubland / chaparral
[0.78, 0.70, 0.50], # 13 temperate desert
[0.82, 0.72, 0.52], # 14 subtropical desert
[0.85, 0.78, 0.62], # 15 hot desert
[0.88, 0.88, 0.92], # 16 tundra
[0.92, 0.94, 0.97], # 17 ice / snow
[0.55, 0.50, 0.45], # 18 mountain rock
[0.38, 0.32, 0.28], # 19 volcanic / lava field
], dtype=np.float32)
# Photographic biome colors loaded from biomes.toml via biome_config.
_EXTENDED_BIOME_COLORS = np.zeros((MAX_BIOME_ID + 1, 3), dtype=np.float32)
for _cid, _val in _BIOME_PALETTE_CFG.items():
_EXTENDED_BIOME_COLORS[_cid] = np.array(_val["photographic"], dtype=np.float32) / 255.0
del _cid, _val
# Gas giant palettes and atmosphere colors loaded from biomes.toml
GAS_PALETTES = _GAS_PALETTES_CFG
ATMO_COLORS = _ATMO_COLORS_CFG
# ---------------------------------------------------------------------------
# Noise helpers — pure numpy, no external deps
# ---------------------------------------------------------------------------
def _hash2(x: np.ndarray, y: np.ndarray, seed: int) -> np.ndarray:
"""Deterministic pseudo-random float in [0,1] from integer x,y coords."""
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _value_noise_octave(u, v, freq, seed):
"""Single octave value noise via bilinear grid interpolation. No sine waves."""
uf = u * freq; vf = v * freq
x0 = np.floor(uf).astype(np.int32); y0 = np.floor(vf).astype(np.int32)
x1 = x0 + 1; y1 = y0 + 1
tx = uf - x0; ty = vf - y0
tx = tx * tx * (3.0 - 2.0 * tx) # smoothstep
ty = ty * ty * (3.0 - 2.0 * ty)
v00 = _hash2(x0, y0, seed); v10 = _hash2(x1, y0, seed)
v01 = _hash2(x0, y1, seed); v11 = _hash2(x1, y1, seed)
return (v00*(1-tx)*(1-ty) + v10*tx*(1-ty) +
v01*(1-tx)*ty + v11*tx*ty).astype(np.float32)
def fbm(u: np.ndarray, v: np.ndarray, seed: int,
octaves: int = 7, lacunarity: float = 2.0,
gain: float = 0.50) -> np.ndarray:
"""FBM using value noise (bilinear grid). Returns [0,1] float32. No hatching."""
result = np.zeros_like(u, dtype=np.float32)
amplitude = 1.0; frequency = 2.0; total = 0.0
rng = np.random.default_rng(seed)
for i in range(octaves):
oct_seed = int(rng.integers(0, 0x7FFFFFFF))
result += amplitude * _value_noise_octave(u, v, frequency, oct_seed)
total += amplitude
amplitude *= gain; frequency *= lacunarity
return result / (total + 1e-9)
# ---------------------------------------------------------------------------
# Ray-sphere intersection — vectorised over full image
# ---------------------------------------------------------------------------
def _raytrace(size: int, r: float = 1.0, oblateness: float = 0.0):
"""
Camera at (0, 0, 3) looking at origin.
oblateness flattens the sphere along Y (gas giants).
Returns: hit(bool), nx, ny, nz, u, v — all (size, size) float32
"""
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px, py = np.meshgrid(lin, -lin) # y flipped: top = +y
oz = 3.0
rdx = px.copy()
rdy = py.copy()
rdz = np.full((size, size), -oz, dtype=np.float32)
mag = np.sqrt(rdx**2 + rdy**2 + rdz**2)
rdx /= mag; rdy /= mag; rdz /= mag
# Scale Y for oblate spheroid
rdy_s = rdy / (1.0 - oblateness + 1e-9)
b = 2.0 * oz * rdz
c = oz**2 - r**2
disc = b**2 - 4.0 * c
hit = disc >= 0.0
safe = np.maximum(disc, 0.0)
t = np.where(hit, (-b - np.sqrt(safe)) / 2.0, np.inf)
hx = rdx * t
hy = rdy * t
hz = oz + rdz * t
# Surface normal — account for oblate scaling
nx = hx
ny = hy / (1.0 - oblateness + 1e-9)**2
nz = hz
nm = np.where(hit, np.sqrt(nx**2 + ny**2 + nz**2), 1.0)
nx /= nm; ny /= nm; nz /= nm
# UV from undistorted hit point
# arctan2(hx, hz) so longitude increases eastward (right on screen).
# +0.5 offset centers the view on 0° longitude (Greenwich) instead of
# 180° (dateline), keeping the seam on the back of the sphere.
u = (np.arctan2(hx, hz) / (2.0 * math.pi) + 0.5) % 1.0
v = np.arcsin(np.clip(hy / np.where(hit, np.sqrt(hx**2 + hy**2 + hz**2), 1.0), -1.0, 1.0)) / math.pi + 0.5
return hit, nx.astype(np.float32), ny.astype(np.float32), nz.astype(np.float32), u.astype(np.float32), v.astype(np.float32)
# ---------------------------------------------------------------------------
# Lighting helpers
# ---------------------------------------------------------------------------
def _star_light_dir(angle_deg: float):
"""
Light direction vector from star.
angle_deg: 90 = directly to the right (classic terminator).
~110 gives dramatic 3/4 lit look.
"""
a = math.radians(angle_deg)
lx = math.cos(a)
ly = math.sin(a) * 0.25 # slight vertical offset
lz = 0.55
m = math.sqrt(lx**2 + ly**2 + lz**2)
return lx/m, ly/m, lz/m
def _apply_lighting(
rgb: np.ndarray, # (H,W,3) float32 surface color [0,1]
hit: np.ndarray, # (H,W) bool
nx, ny, nz: np.ndarray, # surface normals
surface_water: np.ndarray, # (H,W) bool — specular mask
atmo_color, # (3,) float or None
body_def: dict,
) -> np.ndarray:
"""
Full lighting pass. Returns (H,W,3) float32 lit RGB.
"""
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
night_amb= render.get("night_side_ambient", 0.02)
do_spec = render.get("specular_ocean", True)
lx, ly, lz = _star_light_dir(langle)
star_type = body_def.get("star", {}).get("type", "G")
star_tint = np.array(STAR_TINTS.get(star_type, (1,1,1)), dtype=np.float32)
# View direction (camera at 0,0,3, looking at origin)
vz = -1.0 # simplified: view dir is ~(0,0,-1) at pixel center
# Dot products
NdotL = nx * lx + ny * ly + nz * lz # (H,W)
NdotV = np.abs(nz) # grazing = 0, face-on = 1
# --- Diffuse (sharpened Lambert) ---
# Smoothstep-stretched terminator: spreads the lit→dark transition
# across a wider band than physical Lambert. More cinematic, less harsh.
t_raw = np.clip(NdotL * 1.4 + 0.15, 0.0, 1.0) # shift+scale to widen zone
diff = t_raw * t_raw * (3.0 - 2.0 * t_raw) # smoothstep
ambient = 0.06
lit_rgb = rgb * (ambient + (1.0 - ambient) * diff[..., np.newaxis] * star_tint)
# --- Night side ambient scatter ---
dark_mask = (NdotL < 0.0)
night_rgb = rgb * (night_amb * star_tint)
lit_rgb = np.where(dark_mask[..., np.newaxis], night_rgb, lit_rgb)
# --- Terminator warm scatter band ---
term = np.abs(NdotL)
term_band = np.clip(1.0 - term / 0.10, 0.0, 1.0) ** 2 # 0-10° around terminator
term_color = np.array([1.0, 0.62, 0.28], dtype=np.float32) * star_tint
lit_rgb = lit_rgb + term_band[..., np.newaxis] * term_color * 0.35 * np.clip(NdotL + 0.10, 0, 1)[..., np.newaxis]
# --- Ocean specular ---
if do_spec and surface_water is not None:
rx = -lx + 2.0 * NdotL * nx
ry = -ly + 2.0 * NdotL * ny
rz = -lz + 2.0 * NdotL * nz
spec = np.clip(-rz, 0.0, 1.0) ** 70 # tight highlight
spec *= surface_water.astype(np.float32)
spec *= (NdotL > 0.0).astype(np.float32)
lit_rgb += spec[..., np.newaxis] * star_tint * 0.80
# --- Atmospheric rim glow ---
if atmo_color is not None:
ac = np.array(atmo_color, dtype=np.float32)
rim = (1.0 - NdotV) ** 5
# Lit side: bright rim
rim_lit = rim * np.clip(NdotL + 0.30, 0.0, 1.0)
# Dark side: fainter rim (scatter from beyond terminator)
rim_dark = rim * np.clip(-NdotL + 0.15, 0.0, 1.0) * 0.35
lit_rgb += (rim_lit + rim_dark)[..., np.newaxis] * ac * 0.60
return np.clip(lit_rgb, 0.0, 1.0)
# ---------------------------------------------------------------------------
# Star field background
# ---------------------------------------------------------------------------
def _make_starfield(size: int, seed: int) -> np.ndarray:
"""Returns (size, size, 3) float32 star field background."""
rng = np.random.default_rng(seed + 9999)
field = np.zeros((size, size, 3), dtype=np.float32)
n_stars = int(size * size * 0.0018)
ys = rng.integers(0, size, n_stars)
xs = rng.integers(0, size, n_stars)
bri = rng.uniform(0.25, 1.0, n_stars).astype(np.float32)
# Slight color variation
cr = rng.uniform(0.85, 1.00, n_stars).astype(np.float32)
cg = rng.uniform(0.88, 1.00, n_stars).astype(np.float32)
cb = rng.uniform(0.90, 1.00, n_stars).astype(np.float32)
field[ys, xs, 0] = bri * cr
field[ys, xs, 1] = bri * cg
field[ys, xs, 2] = bri * cb
return field
# ---------------------------------------------------------------------------
# Surface color from terrain data OR procedural fallback
# ---------------------------------------------------------------------------
def _surface_color_terrestrial(
u: np.ndarray, v: np.ndarray,
terrain, body_def: dict, seed: int
) -> tuple:
"""
Returns (rgb (H,W,3) float32, surface_water (H,W) bool).
If terrain is None, generates a plausible procedural surface.
"""
planet_class = body_def.get("planet_class", "temperate")
H, W = u.shape
if terrain is not None and "biome" in terrain:
# Sample terrain grids by UV coordinates (equirectangular projection).
# u = longitude [0,1], v = latitude [0,1] where 0=south pole, 1=north pole.
# Terrain grid: row 0 = north pole, row H-1 = south pole.
tH, tW = terrain["biome"].shape
# Map UV to terrain grid indices
col_idx = np.clip((u * tW).astype(np.int32), 0, tW - 1)
row_idx = np.clip(((1.0 - v) * tH).astype(np.int32), 0, tH - 1)
biome = terrain["biome"][row_idx, col_idx]
col = _EXTENDED_BIOME_COLORS[np.clip(biome, 0, len(_EXTENDED_BIOME_COLORS)-1)]
water_grid = terrain.get("surface_water", terrain["biome"] <= 2)
water = water_grid[row_idx, col_idx]
# Elevation shading — skip for ice/snow classes (17, 26) which
# should stay bright. The hillshade in the lighting pass provides
# enough depth cue on ice surfaces.
if "elevation" in terrain:
elev = terrain["elevation"][row_idx, col_idx]
shade = 0.82 + 0.18 * elev
is_ice = (biome == 17) | (biome == 26)
shade = np.where(is_ice, 1.0, shade)
col = np.clip(col * shade[..., np.newaxis], 0, 1)
# Terrain relief on rocky/dry worlds: hillshade drives surface
# contrast since biome color is uniform. Stronger on cratered
# bodies where rims catching light is the primary visual feature.
if "hillshade" in terrain:
hs = terrain["hillshade"][row_idx, col_idx]
is_rock = ((biome == 18) | (biome == 27) | (biome == 28) | (biome == 29)
| (biome == 30) | (biome == 31) | (biome == 32) | (biome == 33))
rock_variation = 0.55 + 0.45 * hs
col = np.where(is_rock[..., np.newaxis],
np.clip(col * rock_variation[..., np.newaxis], 0, 1),
col)
return col.astype(np.float32), water
# --- Procedural fallback ---
rng = np.random.default_rng(seed)
# Continent mask — low-freq noise, threshold to land_fraction
lf = body_def.get("terrain", {}).get("land_fraction", 0.35)
cont_noise = fbm(u * 3, v * 2, seed, octaves=5, gain=0.55)
# Normalise to [0,1]
cont = (cont_noise - cont_noise.min()) / (cont_noise.max() - cont_noise.min() + 1e-9)
land = cont > (1.0 - lf)
# Detail texture
detail = fbm(u * 8, v * 6, seed + 1, octaves=4, gain=0.5)
detail = (detail - detail.min()) / (detail.max() - detail.min() + 1e-9)
# Base colors by planet class
water_col = np.array([0.12, 0.25, 0.50], np.float32)
shore_col = np.array([0.45, 0.55, 0.35], np.float32)
class_land = {
"temperate": (np.array([0.28, 0.50, 0.22], np.float32),
np.array([0.50, 0.62, 0.32], np.float32)),
"forest": (np.array([0.18, 0.40, 0.18], np.float32),
np.array([0.30, 0.52, 0.24], np.float32)),
"oceanic": (np.array([0.22, 0.45, 0.20], np.float32),
np.array([0.08, 0.18, 0.42], np.float32)),
"arid": (np.array([0.70, 0.60, 0.40], np.float32),
np.array([0.82, 0.72, 0.52], np.float32)),
"martian": (np.array([0.62, 0.38, 0.25], np.float32),
np.array([0.72, 0.48, 0.32], np.float32)),
"frozen": (np.array([0.82, 0.88, 0.95], np.float32),
np.array([0.90, 0.94, 0.98], np.float32)),
"barren": (np.array([0.38, 0.35, 0.32], np.float32),
np.array([0.52, 0.48, 0.44], np.float32)),
"volcanic": (np.array([0.22, 0.18, 0.16], np.float32),
np.array([0.70, 0.30, 0.10], np.float32)),
}
dark_l, light_l = class_land.get(planet_class,
class_land["temperate"])
land_col = dark_l[np.newaxis, np.newaxis, :] * (1 - detail[..., np.newaxis]) + \
light_l[np.newaxis, np.newaxis, :] * detail[..., np.newaxis]
# Polar ice caps
lat_abs = np.abs(v - 0.5) * 2.0
ice_thresh = body_def.get("terrain", {}).get("polar_ice_lat", 0.80)
ice_blend = np.clip((lat_abs - ice_thresh) / (1.0 - ice_thresh + 0.05), 0, 1)
ice_color = np.array([0.92, 0.95, 0.98], np.float32)
land_col = land_col * (1 - ice_blend[..., np.newaxis]) + \
ice_color * ice_blend[..., np.newaxis]
# Ocean depth shading
ocean_depth = 1.0 - cont
oc = water_col[np.newaxis, np.newaxis, :] * (0.6 + 0.4 * ocean_depth[..., np.newaxis])
# Shallow coast transition
coast_blend = np.clip((cont - (1 - lf)) / 0.06, 0, 1)
land_col_c = land_col * (1 - coast_blend[..., np.newaxis]) * 0.0 + \
shore_col * (1 - coast_blend[..., np.newaxis]) + \
land_col * coast_blend[..., np.newaxis]
rgb = np.where(land[..., np.newaxis], land_col_c, oc)
# Volcanic lava cracks
if planet_class == "volcanic":
lava_noise = fbm(u * 15, v * 12, seed + 7, octaves=3)
lava = np.clip((lava_noise + 0.15) * 8.0, 0, 1)
lava_col = np.array([0.92, 0.40, 0.05], np.float32)
lava_mask = land & (lava > 0.85)
rgb = np.where(lava_mask[..., np.newaxis], lava_col, rgb)
water_mask = ~land
return rgb.astype(np.float32), water_mask
# ---------------------------------------------------------------------------
# Cloud layer
# ---------------------------------------------------------------------------
def _cloud_layer(
u: np.ndarray, v: np.ndarray,
terrain, body_def: dict, seed: int,
nx, ny, nz: np.ndarray,
NdotL: np.ndarray,
star_tint: np.ndarray,
atmo_color,
) -> np.ndarray:
"""
Returns (H,W,3) float32 additive cloud RGB.
Moisture-driven if terrain provided, else procedural.
"""
cloud_cfg = body_def.get("clouds", {})
coverage = cloud_cfg.get("coverage_base", 0.40)
planet_class= body_def.get("planet_class", "temperate")
if planet_class in ("barren", "moon", "gas_giant", "gas_giant_ringed"):
return np.zeros((*u.shape, 3), dtype=np.float32)
# Cloud opacity — procedural shapes weighted by moisture.
# Moisture influences density, not shape — otherwise clouds just
# blanket the oceans where moisture is highest.
cloud_shape = fbm(u * 4, v * 3, seed + 42, octaves=5, gain=0.58)
cloud_shape = (cloud_shape - cloud_shape.min()) / (cloud_shape.max() - cloud_shape.min() + 1e-9)
if terrain is not None and "moisture" in terrain:
tH, tW = terrain["moisture"].shape
col_idx = np.clip((u * tW).astype(np.int32), 0, tW - 1)
row_idx = np.clip(((1.0 - v) * tH).astype(np.int32), 0, tH - 1)
moist = terrain["moisture"][row_idx, col_idx]
# Moisture boosts cloud density where it's wet, but the shape
# comes from the noise field — clouds can exist over land too.
raw_cld = cloud_shape * (0.5 + 0.5 * moist)
else:
raw_cld = fbm(u * 4, v * 3, seed + 42, octaves=5, gain=0.58)
raw_cld = (raw_cld - raw_cld.min()) / (raw_cld.max() - raw_cld.min() + 1e-9)
# Threshold to target coverage
thresh = np.percentile(raw_cld, (1.0 - coverage) * 100)
alpha = np.clip((raw_cld - thresh) / (raw_cld.max() - thresh + 1e-9), 0, 1)
alpha = alpha ** 0.70 # soften edges
# Gaussian blur on cloud alpha to eliminate any residual noise texture
from scipy.ndimage import gaussian_filter
alpha = gaussian_filter(alpha, sigma=2.5).astype(np.float32)
alpha = np.clip(alpha, 0, 1)
# Cloud color — lit side bright, dark side very dim
diff = np.clip(NdotL, 0.0, 1.0)
amb = 0.08
cld_bri = (amb + (1 - amb) * diff)[..., np.newaxis] * star_tint[np.newaxis, np.newaxis, :]
cld_rgb = cld_bri * 0.96 # slightly warm white
# Rim darkening on clouds at grazing angle
NdotV = np.abs(nz)
rim = (1.0 - NdotV) ** 3 * 0.25
cld_rgb = cld_rgb * (1.0 - rim[..., np.newaxis])
return cld_rgb, alpha
# ---------------------------------------------------------------------------
# Gas giant renderer
# ---------------------------------------------------------------------------
def _render_gas_giant(
hit: np.ndarray,
nx, ny, nz: np.ndarray,
u: np.ndarray, v: np.ndarray,
body_def: dict, seed: int,
) -> np.ndarray:
"""
Returns (H,W,3) float32 lit gas giant surface color.
No UV-wrap needed — surface is procedural bands.
"""
gg_cfg = body_def.get("gas_giant", {})
palette_name = gg_cfg.get("band_palette", "jovian")
storm_count = gg_cfg.get("storm_count", 2)
storm_size = gg_cfg.get("storm_max_size", 0.10)
palette = np.array(GAS_PALETTES.get(palette_name, GAS_PALETTES["jovian"]),
dtype=np.float32)
n_bands = len(palette)
rng = np.random.default_rng(seed)
# Latitude with domain warp for natural band wobble
warp = fbm(u * 2, v * 4, seed + 100, octaves=4, gain=0.50) * 0.08
lat_warped = np.clip(v + warp, 0.0, 1.0)
# Band index from warped latitude
band_raw = lat_warped * n_bands * 2.5
band_idx = np.floor(band_raw).astype(np.int32) % n_bands
# Detail noise within bands
detail = fbm(u * 6, v * 8, seed + 200, octaves=3, gain=0.45)
detail = (detail - detail.min()) / (detail.max() - detail.min() + 1e-9)
# Base band color
rgb = palette[band_idx]
# Subtle lightening/darkening from detail
rgb = rgb * (0.88 + 0.24 * detail[..., np.newaxis])
# Storm ovals
storm_lats = rng.uniform(0.20, 0.80, storm_count)
storm_lons = rng.uniform(0.05, 0.95, storm_count)
storm_sizes = rng.uniform(storm_size * 0.5, storm_size, storm_count)
storm_cols = palette[rng.integers(0, n_bands, storm_count)]
for i in range(storm_count):
du = (u - storm_lons[i] + 0.5) % 1.0 - 0.5
dv = v - storm_lats[i]
# Distance from storm center (oval: wider than tall)
sz = storm_sizes[i]
dist = np.sqrt((du / (sz * 2.0))**2 + (dv / sz)**2)
# Spiral swirl: rotate the band pattern around the storm center.
# Angle increases toward center → spiral arms.
angle = np.arctan2(dv, du)
swirl_strength = np.clip(1.0 - dist / 1.2, 0, 1) ** 1.5
swirl_angle = swirl_strength * 3.5 # ~1 full rotation at center
# Distort the band noise by rotating UV around storm
swirl_u = du * np.cos(swirl_angle) - dv * np.sin(swirl_angle)
swirl_detail = np.sin(swirl_u * 40.0 + angle * 2.0) * 0.08
# Storm color: base + swirl texture
storm_alpha = np.clip(1.0 - dist / 0.8, 0, 1) ** 2
storm_rgb = storm_cols[i] * (1.0 + swirl_detail[..., np.newaxis])
rgb = rgb * (1 - storm_alpha[..., np.newaxis]) + \
storm_rgb * storm_alpha[..., np.newaxis]
rgb = np.clip(rgb, 0.0, 1.0)
# Lighting — diffuse only (no specular, slight rim)
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
star_type= body_def.get("star", {}).get("type", "G")
star_tint= np.array(STAR_TINTS.get(star_type, (1,1,1)), np.float32)
NdotL = nx * lx + ny * ly + nz * lz
t_raw = np.clip(NdotL * 1.4 + 0.15, 0.0, 1.0)
diff = t_raw * t_raw * (3.0 - 2.0 * t_raw)
amb = 0.08
night_amb = render.get("night_side_ambient", 0.025)
dark = NdotL < 0
lit_rgb = rgb * (amb + (1 - amb) * diff[..., np.newaxis] * star_tint)
lit_rgb = np.where(dark[..., np.newaxis],
rgb * night_amb,
lit_rgb)
# Atmosphere/rim glow using band palette mid color
mid_col = palette[n_bands // 2] * 0.7 + np.array([0.5, 0.5, 0.6], np.float32) * 0.3
NdotV = np.abs(nz)
rim = (1.0 - NdotV) ** 5
rim_lit = rim * np.clip(NdotL + 0.30, 0, 1)
lit_rgb += rim_lit[..., np.newaxis] * mid_col * 0.50
return np.clip(lit_rgb, 0, 1)
# ---------------------------------------------------------------------------
# Ring plane compositor
# ---------------------------------------------------------------------------
def _composite_rings(
canvas: np.ndarray,
hit: np.ndarray,
body_def: dict, seed: int,
effective_r: float = SPHERE_R,
) -> np.ndarray:
"""
Equatorial ring plane viewed from 5° above.
The ring lies in the planet's equatorial plane (horizontal).
Viewed from 5° elevation, the projection is an ellipse where:
- X axis = full ring radius (unchanged by elevation angle)
- Y axis = ring_radius * sin(ELEV) — very flat, only 8.7% of X
- Centre = planet screen centre (cx, cy) — no offset
- Near side = bottom half of ellipse (ys_g > 0) — crosses in front
- Far side = top half of ellipse (ys_g <= 0) — behind planet
"""
ELEV = math.radians(5) # camera elevation above ring plane
sin_elev = math.sin(ELEV) # 0.0872 — Y compression factor
cos_elev = math.cos(ELEV) # 0.9962 — used for lighting normal
ring_cfg = body_def.get("rings", {})
r_inner = ring_cfg.get("inner_radius_factor", 1.12)
r_outer = ring_cfg.get("outer_radius_factor", 2.65)
base_opa = ring_cfg.get("opacity_base", 0.62)
palette_name = body_def.get("gas_giant", {}).get("band_palette", "jovian")
palette = np.array(GAS_PALETTES.get(palette_name, GAS_PALETTES["jovian"]),
dtype=np.float32)
if "ring_color" in ring_cfg:
ring_col = np.array(ring_cfg["ring_color"], dtype=np.float32)
else:
ring_base = palette[0]*0.4 + palette[2]*0.4 + palette[4]*0.2
ring_col = np.clip(ring_base * 1.15, 0, 1)
H, W = canvas.shape[:2]
cx, cy = W / 2.0, H / 2.0
planet_px = (effective_r / 2.0) * W # sphere radius in pixels
# Pixel offsets from planet centre — ellipse is centred here, no shift
ys_arr = np.arange(H, dtype=np.float32) - cy
xs_arr = np.arange(W, dtype=np.float32) - cx
xs_g, ys_g = np.meshgrid(xs_arr, ys_arr)
# Ellipse axes: X = full radius, Y = radius * sin(elevation)
rx_o = r_outer * planet_px
ry_o = r_outer * planet_px * sin_elev # very flat
rx_i = r_inner * planet_px
ry_i = r_inner * planet_px * sin_elev
# Annular ring mask
e_outer = (xs_g / rx_o)**2 + (ys_g / ry_o)**2
e_inner = (xs_g / rx_i)**2 + (ys_g / ry_i)**2
in_ring = (e_outer <= 1.0) & (e_inner >= 1.0)
# Radial opacity variation
t_ring = np.clip(
(np.sqrt(e_outer) - r_inner/r_outer) / (1.0 - r_inner/r_outer + 1e-9),
0, 1)
gap = np.clip(1.0 - np.abs(t_ring - 0.55) / 0.06, 0, 1) ** 2
r_px = np.sqrt((xs_g/rx_o)**2 + (ys_g/ry_o)**2)
density = np.sin(r_px * 55.0) * 0.10 + 0.90
opa = np.clip(base_opa * density * (1.0 - gap*0.75) * in_ring, 0, 1)
# Lighting — ring plane normal is (0, sin_elev, -cos_elev) for equatorial plane
# at 5° elevation. Ring faces mostly upward so boost ambient significantly.
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
ring_light = abs(sin_elev * ly + (-cos_elev) * lz) * 0.40 + 0.72
lit_ring = np.clip(ring_col * ring_light, 0, 1)
result = canvas.copy()
# Far side: top half of ellipse (ys_g <= 0) — draw behind planet only
far = in_ring & (ys_g <= 0) & ~hit
result[far] = (result[far] * (1 - opa[far, np.newaxis]) +
lit_ring * opa[far, np.newaxis])
# Near side: bottom half of ellipse (ys_g > 0) — draw in front of everything
near = in_ring & (ys_g > 0)
near_on = near & hit
near_off = near & ~hit
result[near_off] = (result[near_off] * (1 - opa[near_off, np.newaxis]) +
lit_ring * opa[near_off, np.newaxis])
shadow = 1.0 - opa[near_on, np.newaxis] * 0.30
result[near_on] = (result[near_on] * shadow * (1 - opa[near_on, np.newaxis]) +
lit_ring * opa[near_on, np.newaxis])
return np.clip(result, 0, 1)
def render_globe(
body_def: dict,
terrain: dict = None,
size: int = GLOBE_SIZE,
) -> Image.Image:
"""
Render a planet globe.
Parameters
----------
body_def : dict
Body definition (see module docstring for schema).
terrain : dict or None
Geographic data grids. If None, procedural surface is used.
size : int
Output image size (default 2048).
Returns
-------
PIL.Image.Image RGBA, size×size
"""
seed = body_def.get("seed", 42)
planet_class = body_def.get("planet_class", "temperate")
oblateness = body_def.get("physical", {}).get("oblateness", 0.0)
body_scale = body_def.get("body_scale", "planet") # "planet" or "moon"
# Inflate oblateness for gas giants
if planet_class in ("gas_giant", "gas_giant_ringed"):
oblateness = max(oblateness,
body_def.get("physical", {}).get("oblateness", 0.065))
# Effective sphere radius in NDC [-1,1]:
# - ringed bodies: shrink so outer ring fits within 0.84 NDC margin
# - moons: 2/3 scale of planet for visual distinction in grids
if planet_class == "gas_giant_ringed":
# Fit outer ring within 82% of half-image width.
# outer_ring_px = r_outer * (effective_r/2) * W = 0.82 * (W/2)
# => effective_r = 0.82 / r_outer
r_outer_fit = body_def.get("rings", {}).get("outer_radius_factor", 2.65)
effective_r = 0.82 / r_outer_fit
else:
effective_r = SPHERE_R # default 0.90
if body_scale in ("moon", "dwarf") or body_def.get("body_type") == "moon":
effective_r *= 0.50
# -- Ray trace --------------------------------------------------------
hit, nx, ny, nz, u, v = _raytrace(size, effective_r, oblateness)
# Zero out normals on miss pixels to avoid NaN propagation
nx = np.where(hit, nx, 0.0)
ny = np.where(hit, ny, 0.0)
nz = np.where(hit, nz, 1.0)
u = np.where(hit, u, 0.0)
v = np.where(hit, v, 0.5)
# -- Background -------------------------------------------------------
canvas = _make_starfield(size, seed)
# Terrain grids stay at their native resolution (256×512 equirectangular).
# Surface and cloud functions sample by UV coordinates, not pixel alignment.
# -- Surface color ----------------------------------------------------
is_gas = planet_class in ("gas_giant", "gas_giant_ringed")
if is_gas:
surface_rgb = _render_gas_giant(hit, nx, ny, nz, u, v, body_def, seed)
surface_water = None
else:
surface_rgb, surface_water = _surface_color_terrestrial(
u, v, terrain, body_def, seed)
# -- Lighting ---------------------------------------------------------
atmo_color = ATMO_COLORS.get(planet_class)
if is_gas:
lit_rgb = surface_rgb # gas giant handles own lighting internally
else:
render = body_def.get("render", {})
langle = render.get("globe_light_angle_deg", 125)
lx, ly, lz = _star_light_dir(langle)
star_type = body_def.get("star", {}).get("type", "G")
star_tint = np.array(STAR_TINTS.get(star_type, (1,1,1)), np.float32)
lit_rgb = _apply_lighting(
surface_rgb, hit, nx, ny, nz,
surface_water, atmo_color, body_def)
# -- Clouds -------------------------------------------------------
NdotL = nx * lx + ny * ly + nz * lz
cld_cfg = body_def.get("clouds", {})
if cld_cfg.get("enabled", False):
cld_rgb, cld_alpha = _cloud_layer(u, v, terrain, body_def, seed,
nx, ny, nz, NdotL, star_tint, atmo_color)
# Alpha-blend: clouds occlude surface, not just add brightness
a = cld_alpha[..., np.newaxis]
lit_rgb = lit_rgb * (1.0 - a) + cld_rgb * a
lit_rgb = np.clip(lit_rgb, 0, 1)
# -- Composite onto canvas --------------------------------------------
canvas[hit] = lit_rgb[hit]
# -- Ring plane -------------------------------------------------------
if planet_class == "gas_giant_ringed":
canvas = _composite_rings(canvas, hit, body_def, seed, effective_r)
# -- Atmosphere glow halo (outside sphere edge) ----------------------
if atmo_color is not None:
ac = np.array(atmo_color, dtype=np.float32)
# Distance from pixel to sphere center
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px2, py2 = np.meshgrid(lin, -lin)
dist_c = np.sqrt(px2**2 + py2**2)
halo = np.clip((effective_r + 0.045 - dist_c) / 0.045, 0, 1)
halo *= (~hit).astype(np.float32)
# Light-side bias
langle = body_def.get("render", {}).get("globe_light_angle_deg", 125)
la = math.radians(langle)
halo_bias = np.clip(px2 * math.cos(la) * 0.5 + 0.5, 0.2, 1.0)
halo *= halo_bias
canvas = canvas + halo[..., np.newaxis] * ac * 0.40
canvas = np.clip(canvas, 0, 1)
# -- Convert to PIL ---------------------------------------------------
canvas_uint8 = (canvas * 255.0).clip(0, 255).astype(np.uint8)
# Alpha: opaque on hit pixels; ring pixels get opacity from their blend weight
alpha = np.where(hit, 255, 0).astype(np.uint8)
# For ringed bodies, mark ring pixels as opaque too
if planet_class == "gas_giant_ringed":
# Alpha for ring pixels — same equatorial geometry as _composite_rings
ELEV_A = math.radians(5)
ring_cfg = body_def.get("rings", {})
r_inner_a = ring_cfg.get("inner_radius_factor", 1.12)
r_outer_a = ring_cfg.get("outer_radius_factor", 2.65)
base_opa_a = ring_cfg.get("opacity_base", 0.62)
planet_px_a = (effective_r / 2.0) * size
sin_elev_a = math.sin(ELEV_A)
ys_a = np.arange(size, dtype=np.float32) - size / 2.0
xs_a = np.arange(size, dtype=np.float32) - size / 2.0
xs_ga, ys_ga = np.meshgrid(xs_a, ys_a)
rx_oa = r_outer_a * planet_px_a
ry_oa = r_outer_a * planet_px_a * sin_elev_a
rx_ia = r_inner_a * planet_px_a
ry_ia = r_inner_a * planet_px_a * sin_elev_a
e_oa = (xs_ga/rx_oa)**2 + (ys_ga/ry_oa)**2
e_ia = (xs_ga/rx_ia)**2 + (ys_ga/ry_ia)**2
ring_px = (e_oa <= 1.0) & (e_ia >= 1.0)
r_na = np.sqrt(e_oa)
den_a = np.sin(r_na * 55.0) * 0.10 + 0.90
gt_a = np.clip((r_na - r_inner_a/r_outer_a)/(1.0 - r_inner_a/r_outer_a + 1e-9), 0, 1)
gap_a = np.clip(1.0 - np.abs(gt_a - 0.55)/0.06, 0, 1)**2
opa_a = np.clip(base_opa_a * den_a * (1-gap_a*0.75) * ring_px, 0, 1)
alpha = np.maximum(alpha, (opa_a * 255).astype(np.uint8))
# Partial alpha on halo
if atmo_color is not None:
lin = np.linspace(-1.0, 1.0, size, dtype=np.float32)
px2, py2 = np.meshgrid(lin, -lin)
dist_c = np.sqrt(px2**2 + py2**2)
halo_a = np.clip((effective_r + 0.045 - dist_c) / 0.045, 0, 1)
halo_a *= (~hit).astype(np.float32)
alpha = np.maximum(alpha, (halo_a * 200).astype(np.uint8))
rgba = np.dstack([canvas_uint8, alpha])
return Image.fromarray(rgba, mode="RGBA")
# ---------------------------------------------------------------------------
# CLI test — renders one body of each class for visual QA
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
import os
import time
TEST_BODIES = [
{
"id": "test_temperate", "name": "Test Temperate",
"planet_class": "temperate", "seed": 144042,
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 1.0, "axial_tilt_deg": 23},
"physical": {"gravity_g": 1.0, "oblateness": 0.003},
"terrain": {"land_fraction": 0.40, "polar_ice_lat": 0.78},
"clouds": {"enabled": True, "coverage_base": 0.45},
"render": {"globe_light_angle_deg": 125, "specular_ocean": True,
"night_side_ambient": 0.025},
},
{
"id": "test_arid", "name": "Test Arid",
"planet_class": "arid", "seed": 55001,
"star": {"type": "G", "luminosity_solar": 1.1},
"orbit": {"distance_au": 1.3, "axial_tilt_deg": 5},
"physical": {"gravity_g": 0.85, "oblateness": 0.002},
"terrain": {"land_fraction": 0.70, "polar_ice_lat": 0.92},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.015},
},
{
"id": "test_frozen", "name": "Test Frozen",
"planet_class": "frozen", "seed": 88800,
"star": {"type": "K", "luminosity_solar": 0.4},
"orbit": {"distance_au": 0.6, "axial_tilt_deg": 45},
"physical": {"gravity_g": 0.90, "oblateness": 0.002},
"terrain": {"land_fraction": 0.30, "polar_ice_lat": 0.30},
"clouds": {"enabled": True, "coverage_base": 0.30},
"render": {"globe_light_angle_deg": 125, "specular_ocean": True,
"night_side_ambient": 0.018},
},
{
"id": "test_barren", "name": "Test Barren",
"planet_class": "barren", "seed": 31415,
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 0.5, "axial_tilt_deg": 2},
"physical": {"gravity_g": 0.40, "oblateness": 0.001},
"terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.98},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.005},
},
{
"id": "test_volcanic", "name": "Test Volcanic",
"planet_class": "volcanic", "seed": 66666,
"star": {"type": "M", "luminosity_solar": 0.08},
"orbit": {"distance_au": 0.15, "axial_tilt_deg": 10},
"physical": {"gravity_g": 1.1, "oblateness": 0.004},
"terrain": {"land_fraction": 0.85, "polar_ice_lat": 0.99},
"clouds": {"enabled": True, "coverage_base": 0.70},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.040},
},
{
"id": "test_gas_giant", "name": "Test Gas Giant",
"planet_class": "gas_giant", "seed": 20001,
"star": {"type": "G", "luminosity_solar": 1.0},
"physical": {"oblateness": 0.065},
"gas_giant": {"band_palette": "jovian", "storm_count": 3,
"storm_max_size": 0.10},
"render": {"globe_light_angle_deg": 125, "night_side_ambient": 0.025},
},
{
"id": "test_moon", "name": "Test Moon",
"planet_class": "barren", "seed": 99001,
"body_scale": "moon",
"star": {"type": "G", "luminosity_solar": 1.0},
"orbit": {"distance_au": 1.0, "axial_tilt_deg": 5},
"physical": {"gravity_g": 0.16, "oblateness": 0.001},
"terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.99},
"clouds": {"enabled": False},
"render": {"globe_light_angle_deg": 125, "specular_ocean": False,
"night_side_ambient": 0.005},
},
{
"id": "test_gas_giant_ringed", "name": "Test Ringed Giant",
"planet_class": "gas_giant_ringed", "seed": 77777,
"star": {"type": "G", "luminosity_solar": 1.0},
"physical": {"oblateness": 0.070},
"gas_giant": {"band_palette": "neptunian", "storm_count": 2,
"storm_max_size": 0.08},
"rings": {"enabled": True, "inner_radius_factor": 1.12,
"outer_radius_factor": 2.65, "opacity_base": 0.62,
"ring_color": [0.72, 0.82, 0.95]},
"render": {"globe_light_angle_deg": 125, "night_side_ambient": 0.020},
},
]
out_dir = "/mnt/user-data/outputs"
os.makedirs(out_dir, exist_ok=True)
# Use 512 for fast QA render; change to 2048 for final
qa_size = int(sys.argv[1]) if len(sys.argv) > 1 else 512
paths = []
for bd in TEST_BODIES:
t0 = time.time()
img = render_globe(bd, terrain=None, size=qa_size)
out = os.path.join(out_dir, f"{bd['id']}.png")
img.save(out, format="PNG")
dt = time.time() - t0
console.event(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
paths.append(out)
console.event(f"Done. {len(paths)} planets rendered at {qa_size}px.")
@@ -0,0 +1,937 @@
"""
planet_simulation.py
--------------------
Terrain simulation stack for the Settled Reach planet generator.
Consumes a body_definition dict (output of body_definition_parser.py)
and produces a terrain dict consumed by planet_renderer.render_globe().
Output terrain dict:
{
"elevation": float32 (H, W) [0, 1] normalised elevation
"temperature": float32 (H, W) [0, 1] 0=coldest, 1=hottest
"moisture": float32 (H, W) [0, 1] 0=driest, 1=wettest
"biome": int8 (H, W) biome class index
"surface_water": bool (H, W) ocean/lake mask
"hillshade": float32 (H, W) [0, 1] lighting from slope+aspect
"sea_level": float elevation threshold
}
Pipeline:
1. Elevation - continent mask + domain-warped FBM + tectonic ridges + erosion
2. Temperature - analytical formula: star + latitude + altitude
3. Moisture - Hadley cells + ocean proximity + rain shadow
4. Hillshade - surface normals from elevation gradient
5. Rivers - downhill carving from moisture-seeded sources
6. Biome - extended Whittaker lookup + modifier stack
Grid: 512 x 256 (longitude x latitude), equirectangular.
Row 0 = north pole, row 255 = south pole.
Col 0 = 180W, col 511 = 180E.
"""
import logging
import math
import numpy as np
from scipy.ndimage import gaussian_filter
from tooling.domains.atlas.planet.biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, EXOTIC_CLASSES, CRATER_SCALING,
)
from tooling.core import console
log = logging.getLogger(__name__)
# Canonical heightmap grid (D-202 amended, #963): bumped to 1024×512 so the
# stored elevation has real mid-scale detail for the lower cascade layers. The
# elevation noise is resolution-independent (normalized coords + absolute
# frequencies), so a higher grid samples the SAME continuous terrain at finer
# density — features keep their physical size and generation stays deterministic.
# Pixel-unit operations (gaussian sigma, crater radii, filter windows) scale by
# `GRID_W / 512` so smoothing/morphology behave identically at any resolution.
GRID_W = 1024
GRID_H = 512
def oasis_ring_iterations(grid_w: int) -> tuple[int, int]:
"""Pixel-unit `binary_dilation` iteration counts for the oasis vegetation
ring (T-964, PR #210 review — hoshe), scaled by `grid_w / 512` per the
module's GRID_W pixel-unit convention (see the comment above). Base counts
(2, 4) are the values authored at the original 512-wide grid; `round(...)`
is used (not floor/ceil) so the scaling is symmetric around exact
doubling/halving points, and `max(1, ...)` guards against a degenerate
zero-iteration dilation at very small `grid_w`.
Parameterized on `grid_w` (not reading the module-global `GRID_W`
directly) so it is independently unit-testable at both the historical
512 baseline and the current production 1024 value without needing to
monkeypatch the module global.
"""
scale = grid_w / 512.0
ring1_iters = max(1, round(2 * scale))
ring2_iters = max(1, round(4 * scale))
return ring1_iters, ring2_iters
# ---------------------------------------------------------------------------
# Seeded RNG
# ---------------------------------------------------------------------------
def _rng(seed: int, salt: int = 0) -> np.random.Generator:
return np.random.default_rng(seed ^ (salt * 2654435761))
# ---------------------------------------------------------------------------
# Noise primitives
# ---------------------------------------------------------------------------
def _hash2(x: np.ndarray, y: np.ndarray, seed: int) -> np.ndarray:
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _vnoise(u, v, freq, seed):
"""Standard 2D value noise — NOT seamless. Use _vnoise_s for longitude axis."""
uf = u * freq; vf = v * freq
x0 = np.floor(uf).astype(np.int32); y0 = np.floor(vf).astype(np.int32)
x1 = x0 + 1; y1 = y0 + 1
tx = uf - x0; ty = vf - y0
tx = tx * tx * (3.0 - 2.0 * tx)
ty = ty * ty * (3.0 - 2.0 * ty)
v00 = _hash2(x0, y0, seed); v10 = _hash2(x1, y0, seed)
v01 = _hash2(x0, y1, seed); v11 = _hash2(x1, y1, seed)
return (v00*(1-tx)*(1-ty) + v10*tx*(1-ty) +
v01*(1-tx)*ty + v11*tx*ty).astype(np.float32)
def _hash3(x, y, z, seed):
"""Hash for 3D integer coords."""
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
z.astype(np.int64) * np.int64(49979) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _vnoise_seamless(u, v, freq, seed):
"""
Seamless value noise in the U (longitude) axis only.
Maps u -> (cos(u*2π), sin(u*2π)) before hashing, so the noise
field is periodic in U with period 1 — no seam at the date line.
V (latitude) is not periodic — poles are endpoints, not a loop.
"""
# Project U onto a circle: (cx, cy)
# Divide circle radius by 2π so one full revolution spans the same
# distance as freq units on the flat V axis — corrects aspect ratio.
angle = u * (2.0 * math.pi)
r = freq / (2.0 * math.pi)
cx = np.cos(angle) * r
cy = np.sin(angle) * r
vf = v * freq
# Integer lattice in 3D (cx, cy, vf)
x0 = np.floor(cx).astype(np.int32); x1 = x0 + 1
y0 = np.floor(cy).astype(np.int32); y1 = y0 + 1
z0 = np.floor(vf).astype(np.int32); z1 = z0 + 1
# Smoothstep weights
tx = cx - x0; tx = tx * tx * (3.0 - 2.0 * tx)
ty = cy - y0; ty = ty * ty * (3.0 - 2.0 * ty)
tz = vf - z0; tz = tz * tz * (3.0 - 2.0 * tz)
# Trilinear interpolation over 8 corners
v000 = _hash3(x0, y0, z0, seed); v100 = _hash3(x1, y0, z0, seed)
v010 = _hash3(x0, y1, z0, seed); v110 = _hash3(x1, y1, z0, seed)
v001 = _hash3(x0, y0, z1, seed); v101 = _hash3(x1, y0, z1, seed)
v011 = _hash3(x0, y1, z1, seed); v111 = _hash3(x1, y1, z1, seed)
return (v000*(1-tx)*(1-ty)*(1-tz) + v100*tx*(1-ty)*(1-tz) +
v010*(1-tx)*ty*(1-tz) + v110*tx*ty*(1-tz) +
v001*(1-tx)*(1-ty)*tz + v101*tx*(1-ty)*tz +
v011*(1-tx)*ty*tz + v111*tx*ty*tz).astype(np.float32)
def _fbm(u, v, seed, octaves=6, lacunarity=2.0, gain=0.50, base_freq=2.0):
"""FBM using seamless noise in U — no longitude seam."""
result = np.zeros_like(u, dtype=np.float32)
amp = 1.0; freq = base_freq; total = 0.0
rng = np.random.default_rng(seed)
for _ in range(octaves):
oct_seed = int(rng.integers(0, 0x7FFFFFFF))
result += amp * _vnoise_seamless(u, v, freq, oct_seed)
total += amp
amp *= gain; freq *= lacunarity
return result / (total + 1e-9)
def _domain_warp(u, v, seed, strength=0.35):
"""Domain warp using seamless FBM — preserves no-seam property."""
wu = _fbm(u + 1.7, v + 9.2, seed + 1, octaves=4) * 2.0 - 1.0
wv = _fbm(u + 8.3, v + 2.8, seed + 2, octaves=4) * 2.0 - 1.0
# Only warp u periodically — keep v warp non-periodic (poles stay poles)
return (u + wu * strength) % 1.0, np.clip(v + wv * strength * 0.5, 0.0, 1.0)
# ---------------------------------------------------------------------------
# Coordinate grids
# ---------------------------------------------------------------------------
def _make_grids():
u_1d = np.linspace(0, 1, GRID_W, dtype=np.float32)
v_1d = np.linspace(0, 1, GRID_H, dtype=np.float32)
u, v = np.meshgrid(u_1d, v_1d)
lat_frac = -(v - 0.5) * 2.0 # +1 = north, -1 = south
lon_frac = (u - 0.5) * 2.0
lat_rad = lat_frac * (math.pi / 2.0)
return u, v, lat_frac, lon_frac, lat_rad
# ---------------------------------------------------------------------------
# 1. Elevation
# ---------------------------------------------------------------------------
def _continent_mask(u, v, seed, land_fraction):
def _norm(a):
lo, hi = a.min(), a.max()
return (a - lo) / (hi - lo + 1e-9)
def _contrast(a, strength=3.0):
"""
S-curve contrast: pushes highs toward 1 and lows toward 0
regardless of the field mean. More reliable than power curves
which behave differently depending on the field's distribution.
strength controls steepness — higher = sharper separation.
"""
# Sigmoid centred at 0.5: f(x) = 1/(1+exp(-k*(x-0.5)))
k = strength * 8.0
return 1.0 / (1.0 + np.exp(-k * (a - 0.5)))
# Primary: large continental plates
wu1, wv1 = _domain_warp(u, v, seed, strength=0.45)
primary = _norm(_fbm(wu1, wv1, seed + 10, octaves=5, gain=0.58, base_freq=1.2))
# Secondary: independent medium-scale field.
# S-curve contrast gives reliable highs and lows regardless of seed.
wu2, wv2 = _domain_warp(u, v, seed + 11, strength=0.40)
sec_raw = _norm(_fbm(wu2, wv2, seed + 20, octaves=5, gain=0.55, base_freq=1.8))
secondary = _contrast(sec_raw, strength=2.5)
# Rift: anisotropic thin elongated features
wu3, wv3 = _domain_warp(u, v, seed + 17, strength=0.30)
rift = _norm(_fbm(wu3, wv3 * 0.35, seed + 30, octaves=4, gain=0.52, base_freq=3.5))
# Multiplicative gate: secondary zeroes kill primary → ocean channels
separated = primary * (0.4 + secondary * 0.6)
combined = separated * 0.82 + (rift - 0.5) * 0.18
return _norm(combined).astype(np.float32)
def _tectonic_ridges(u, v, seed, n_plates=8):
rng = _rng(seed, 99)
px = rng.uniform(0, 1, n_plates).astype(np.float32)
py = rng.uniform(0, 1, n_plates).astype(np.float32)
H, W = u.shape
# Domain-warp coords before Voronoi — bends ridge positions into curves
wu1 = _fbm(u * 1.5 + 3.1, v * 1.5 + 7.4, seed + 201, octaves=3,
gain=0.55, base_freq=1.8) * 2.0 - 1.0
wv1 = _fbm(u * 1.5 + 8.6, v * 1.5 + 2.2, seed + 202, octaves=3,
gain=0.55, base_freq=1.8) * 2.0 - 1.0
wu2 = _fbm(u * 4.0 + 1.3, v * 4.0 + 5.7, seed + 203, octaves=2,
gain=0.50, base_freq=3.5) * 2.0 - 1.0
wv2 = _fbm(u * 4.0 + 6.1, v * 4.0 + 0.9, seed + 204, octaves=2,
gain=0.50, base_freq=3.5) * 2.0 - 1.0
uw = (u + wu1 * 0.22 + wu2 * 0.08) % 1.0
vw = np.clip(v + wv1 * 0.18 + wv2 * 0.06, 0.0, 1.0)
dist1 = np.full((H, W), np.inf, dtype=np.float32)
dist2 = np.full((H, W), np.inf, dtype=np.float32)
for i in range(n_plates):
du = np.minimum(np.abs(uw - px[i]), 1.0 - np.abs(uw - px[i]))
dv = np.abs(vw - py[i])
d = np.sqrt(du**2 + dv**2)
mask = d < dist1
dist2 = np.where(mask, dist1, np.minimum(dist2, d))
dist1 = np.where(mask, d, dist1)
# Two ridge widths: broad ranges + sharp collision zones
broad = np.exp(-((dist2 - dist1) / 0.06) ** 2) * 0.5
sharp = np.exp(-((dist2 - dist1) / 0.025) ** 2) * 1.0
ridge_raw = np.clip(broad + sharp, 0, 1)
# Amplitude variation along ridge
ridge_noise = _fbm(u, v, seed + 50, octaves=4, gain=0.55, base_freq=4.0)
# Fracture zones — cross-cutting features (transform faults, rift valleys)
# Anisotropic: stretch u relative to v for elongated cross features
fracture = _fbm(u * 0.4, v, seed + 77, octaves=3, gain=0.6, base_freq=6.0)
fracture = np.clip(fracture - 0.55, 0, 1) * 2.0
return np.clip(ridge_raw * (0.35 + 0.65 * ridge_noise)
+ fracture * 0.20, 0, 1).astype(np.float32)
def _erode(terrain, passes, seed):
result = terrain.copy()
# Resolution scale: smoothing radii and the per-pixel slope (which halves as
# the grid doubles, since np.gradient is in pixel units) scale with width so
# erosion behaves identically at any GRID size.
scale = result.shape[1] / 512.0
for _ in range(passes):
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
smooth = gaussian_filter(result, sigma=1.2 * scale)
weight = np.clip(slope * 6.0 * scale, 0.0, 1.0)
result = result * (1.0 - weight * 0.35) + smooth * (weight * 0.35)
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
flow = gaussian_filter(slope, sigma=3.0 * scale)
flow = (flow - flow.min()) / (flow.max() - flow.min() + 1e-9)
result = result - flow * 0.06
return np.clip(result, 0.0, 1.0)
def compute_elevation(body_def, u, v, lat_frac):
seed = body_def["seed"]
planet_class = body_def["planet_class"].replace("_ringed", "")
land_frac = body_def["terrain"]["land_fraction"]
tectonics = body_def["terrain"].get("tectonics", "active")
plate_map = {"extreme": 12, "active": 8, "low": 5, "none": 3}
erosion_map = {"extreme": 1, "active": 3, "low": 4, "none": 2}
n_plates = plate_map.get(tectonics, 8)
erosion_p = erosion_map.get(tectonics, 3)
ocean_pct = (1.0 - land_frac) * 100.0
detail = _fbm(u, v, seed + 300, octaves=5, gain=0.45, base_freq=4.0)
if tectonics == "none":
# No tectonic activity: gentle base terrain, no ridges, no continents.
# Craters dominate on these worlds.
base = _fbm(u, v, seed + 100, octaves=4, gain=0.50, base_freq=1.5)
elev = base * 0.60 + detail * 0.40
else:
# Tectonic worlds: continent mask + ridges scaled by activity level.
cont = _continent_mask(u, v, seed, land_frac)
ridges = _tectonic_ridges(u, v, seed, n_plates=n_plates)
sea_level_est = float(np.percentile(cont, ocean_pct))
land_mask = cont >= sea_level_est
# Ridge prominence scales with tectonic activity
ridge_weight = {"low": 0.12, "active": 0.25, "extreme": 0.38}
rw = ridge_weight.get(tectonics, 0.25)
elev = (cont * (0.80 - rw)
+ ridges * rw * land_mask
+ detail * 0.20)
# Craters happen everywhere. Atmosphere controls how many impactors
# survive entry; tectonics controls how many craters get resurfaced.
# Both reduce density, neither toggles craters off entirely.
crater_factor = (CRATER_SCALING["atmosphere"].get(body_def["physical"]["atmosphere"], 0.25)
* CRATER_SCALING["tectonics"].get(tectonics, 0.3))
if crater_factor > 0.02:
rng = _rng(seed, 77)
base_count = CRATER_SCALING["base_count"]
n_craters = max(5, int(base_count * crater_factor))
cy_c = rng.uniform(0, GRID_H, n_craters).astype(np.float32)
cx_c = rng.uniform(0, GRID_W, n_craters).astype(np.float32)
# Power-law: most craters are small, a few large. Radii are in pixels,
# so scale with resolution to keep craters the same physical size.
raw_sizes = rng.power(0.4, n_craters) # skewed toward 0
sizes = ((2 + raw_sizes * 28) * (GRID_W / 512.0)).astype(np.float32)
# Depth scales with crater factor — eroded worlds have shallower craters
depth_scale = 0.5 + 0.5 * crater_factor
depths = ((0.05 + raw_sizes * 0.20) * depth_scale).astype(np.float32)
rows = np.arange(GRID_H, dtype=np.float32)
cols = np.arange(GRID_W, dtype=np.float32)
rr, cc = np.meshgrid(rows, cols, indexing='ij')
# Latitude correction: scale longitude distance by cos(lat) so
# craters are circular on the sphere, not stretched at the poles.
lat_rad = (0.5 - rr / GRID_H) * math.pi # +pi/2 at north, -pi/2 at south
cos_lat = np.cos(lat_rad)
cos_lat = np.clip(cos_lat, 0.1, 1.0) # avoid division issues at poles
craters = np.zeros_like(elev)
for i in range(n_craters):
dy = rr - cy_c[i]
dx = cc - cx_c[i]
# Wrap longitude for craters near the date line
dx = np.minimum(np.abs(dx), GRID_W - np.abs(dx))
# Scale dx by cos(lat) at the crater center
center_lat = (0.5 - cy_c[i] / GRID_H) * math.pi
dx_scaled = dx / max(math.cos(center_lat), 0.1)
d = np.sqrt(dy**2 + dx_scaled**2)
r = sizes[i]
dep = depths[i]
# Crater profile: flat floor inside 0.6r, raised rim at 0.9-1.1r,
# smooth falloff outside. More realistic than gaussian dimple.
floor = np.clip(1.0 - d / (r * 0.6), 0, 1)
rim = np.exp(-((d - r) / (r * 0.25))**2)
craters -= dep * floor * 0.8 # excavate floor
craters += dep * rim * 0.3 # raise rim
elev = elev + craters
elev = np.clip(elev, 0.0, None) # floor at 0
elif planet_class == "frozen":
elev = gaussian_filter(elev, sigma=1.5 * (GRID_W / 512.0)).astype(np.float32)
elif planet_class == "volcanic":
erosion_p = max(1, erosion_p - 1)
elev = _erode(elev, passes=erosion_p, seed=seed)
lo, hi = elev.min(), elev.max()
elev = (elev - lo) / (hi - lo + 1e-9)
sea_level = float(np.percentile(elev, ocean_pct))
# Polar ice — smooth land elevation toward a low plateau at high latitudes.
# Only applies when there's an atmosphere to deliver precipitation/ice.
# Airless bodies have no polar caps — cold rock stays rock.
hydro = body_def.get("environment", {}).get("hydrosphere", "ocean")
atmo = body_def["physical"]["atmosphere"]
has_polar_ice = atmo not in ("none",) and hydro not in ("none", "subsurface")
if has_polar_ice:
ice_lat = body_def["terrain"].get("polar_ice_lat", 0.80)
lat_abs = np.abs(lat_frac)
ice_blend = np.clip((lat_abs - ice_lat) / (1.0 - ice_lat + 0.01), 0, 1)
if planet_class == "frozen":
ice_blend = np.clip(ice_blend * 2.0, 0, 1)
land_mask = elev >= sea_level
ice_target = sea_level + 0.05
elev = np.where(
land_mask,
elev * (1.0 - ice_blend * 0.6) + ice_target * (ice_blend * 0.6),
elev)
elev = np.clip(elev, 0.0, 1.0).astype(np.float32)
sea_level = float(np.percentile(elev, ocean_pct))
# Dry worlds (no/subsurface hydrosphere): low elevation is dry basin, not ocean.
hydro = body_def.get("environment", {}).get("hydrosphere", "ocean")
if hydro in ("none", "subsurface"):
surf_water = np.zeros_like(elev, dtype=bool)
else:
surf_water = elev < sea_level
return elev, sea_level, surf_water
# ---------------------------------------------------------------------------
# 2. Temperature
# ---------------------------------------------------------------------------
# Stellar luminosity relative to Sol (approximate midpoint per spectral type)
STAR_LUMINOSITY = {
"O": 100000.0, "B": 1000.0, "A": 10.0,
"F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04,
}
# CLASS_T_BAND loaded from biomes.toml via biome_config
def compute_temperature(body_def, elevation, sea_level, lat_frac):
star_type = body_def["star"]["type"]
distance_au = body_def["orbit"]["distance_au"]
axial_tilt = body_def["orbit"]["axial_tilt_deg"]
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
geothermal = body_def.get("environment", {}).get("geothermal_flux", "low")
# Equilibrium temperature — descriptor-anchored.
#
# We compute the raw stellar physics (Stefan-Boltzmann) to get a
# physically grounded value, then clamp it to the temperature band
# appropriate for the planet_class. This ensures the wiki's descriptors
# (temperate, frozen, arid…) are always honoured even when orbital
# parameters were set with "close enough" precision.
#
# Within the clamped band, the raw value still drives relative warmth:
# a close-in temperate world sits at the warm end of the temperate band,
# a far-out one at the cool end. The fiction wins; physics sets the gradient.
lum = body_def.get("star", {}).get("luminosity_solar",
STAR_LUMINOSITY.get(star_type, 1.0))
t_raw = 278.5 * (lum ** 0.25) / math.sqrt(max(distance_au, 0.01))
greenhouse = {"none": 0, "thin": 8, "standard": 33, "thick": 80}
t_raw += greenhouse.get(atmo, 0)
temperature_clamped = False
temperature_raw_K = float(t_raw)
if planet_class in CLASS_T_BAND:
t_lo, t_hi = CLASS_T_BAND[planet_class]
t_base = float(np.clip(t_raw, t_lo, t_hi))
if t_raw < t_lo or t_raw > t_hi:
temperature_clamped = True
log.debug(f" T_raw={t_raw:.0f}K clamped to [{t_lo},{t_hi}] "
f"for {planet_class} ({body_def.get('id','')})")
else:
t_base = t_raw
tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5
# Atmosphere controls heat redistribution — thicker atmo = smaller
# equator-pole gradient. Thin/no atmo = extreme day/night but we
# still want the planet class to read correctly at the poles.
atmo_gradient_scale = {"none": 0.6, "thin": 0.7, "standard": 1.0, "thick": 1.2}
lat_gradient = 60.0 * tilt_factor * atmo_gradient_scale.get(atmo, 1.0)
t_lat = t_base - lat_gradient * np.abs(lat_frac)
max_relief_km = body_def.get("terrain", {}).get("max_elevation_km", 10.0)
elev_land = np.where(elevation >= sea_level,
(elevation - sea_level) / (1.0 - sea_level + 1e-9), 0.0)
elev_km = elev_land * max_relief_km
lapse = 6.5 if atmo != "none" else 2.0
t_final = t_lat - lapse * elev_km
class_offset = {"frozen": -30, "volcanic": 20, "arid": 10}
t_final += class_offset.get(planet_class, 0)
geo_boost = {"low": 0, "moderate": 5, "high": 15, "extreme": 35}
t_final += geo_boost.get(geothermal, 0)
# Soft floor: prevent planet class from being contradicted at the poles.
# An arid world shouldn't have ice caps; a volcanic world shouldn't freeze.
# Clamp the minimum temperature to the class band's lower bound.
if planet_class in CLASS_T_BAND:
t_floor = CLASS_T_BAND[planet_class][0]
t_final = np.maximum(t_final, t_floor)
# Return absolute Kelvin grid plus audit metadata.
# Biome lookup needs absolute values; renderer normalises for display.
return t_final.astype(np.float32), temperature_clamped, temperature_raw_K
# ---------------------------------------------------------------------------
# 3. Moisture
# ---------------------------------------------------------------------------
def compute_moisture(body_def, elevation, sea_level, temperature,
lat_frac, lon_frac):
# Normalise temperature locally for moisture computation
t_norm = np.clip((temperature - temperature.min()) /
(temperature.max() - temperature.min() + 1e-9), 0, 1)
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
if atmo == "none":
return np.zeros((GRID_H, GRID_W), dtype=np.float32)
lat_abs = np.abs(lat_frac)
# Hadley cell bands
itcz = np.clip(1.0 - (lat_abs / 0.33), 0, 1)
subtr = np.clip(1.0 - np.abs(lat_abs - 0.50) / 0.17, 0, 1)
polar = np.clip((lat_abs - 0.67) / 0.33, 0, 1)
hadley = np.clip(itcz * 0.85 + subtr * 0.10 + polar * 0.40, 0, 1)
# Ocean proximity
surf_water = elevation < sea_level
if surf_water.any():
from scipy.ndimage import distance_transform_edt
dist = distance_transform_edt(~surf_water).astype(np.float32)
ocean_prox = 1.0 - np.clip(dist / (dist.max() * 0.5 + 1e-9), 0, 1)
else:
ocean_prox = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# Rain shadow — westerly winds: windward (west face) is wet
shift = max(1, GRID_W // 80)
elev_above = np.clip(elevation - sea_level, 0, None)
elev_sh = np.clip(np.roll(elevation, shift, axis=1) - sea_level, 0, None)
shadow_raw = np.clip(elev_sh - elev_above * 0.5, 0, None)
shadow_raw = shadow_raw / (shadow_raw.max() + 1e-9)
rain_shadow = 1.0 - shadow_raw * 0.70
moisture = (hadley * 0.40
+ ocean_prox * 0.45
+ t_norm * 0.15) * rain_shadow
class_scale = {
"arid": 0.25, "oceanic": 1.30, "forest": 1.30,
"frozen": 0.55, "volcanic": 0.40, "barren": 0.05,
}
moisture *= class_scale.get(planet_class, 1.0)
hydro_scale = {
"ocean": 1.2, "liquid_water": 1.2,
"subsurface": 0.1, "none": 0.05,
}
moisture *= hydro_scale.get(hydro, 1.0)
moisture = gaussian_filter(moisture.astype(np.float32), sigma=2.0 * (GRID_W / 512.0))
# Only normalize if the raw range is substantial — otherwise the
# normalization re-inflates near-zero moisture on dry worlds back to [0,1].
m_min, m_max = moisture.min(), moisture.max()
if m_max > 0.05:
moisture = ((moisture - m_min) / (m_max - m_min + 1e-9)).astype(np.float32)
else:
# Effectively dry — clamp to near-zero
moisture = np.clip(moisture / 0.05, 0, 1).astype(np.float32)
return moisture
# ---------------------------------------------------------------------------
# 4. Hillshade
# ---------------------------------------------------------------------------
def compute_hillshade(elevation,
sun_azimuth_deg=315.0,
sun_altitude_deg=45.0):
scale = GRID_W / 8.0
gy, gx = np.gradient(elevation * scale)
mag = np.sqrt(gx**2 + gy**2 + 1.0)
nx = -gx / mag; ny = -gy / mag; nz = 1.0 / mag
az = math.radians(sun_azimuth_deg)
alt = math.radians(sun_altitude_deg)
lx = math.cos(alt) * math.cos(az)
ly = math.cos(alt) * math.sin(az)
lz = math.sin(alt)
diffuse = np.clip(nx * lx + ny * ly + nz * lz, 0.0, 1.0)
return (0.25 + 0.75 * diffuse).astype(np.float32)
# ---------------------------------------------------------------------------
# Rivers are NOT computed here (D-208, #963): river networks are derived by the
# Rust cascade's D8 drainage from the heightmap — the single source of river
# truth, with mouths that reach the sea by construction. The old heuristic
# `compute_rivers` was removed to avoid implying the Python sim owns rivers.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 6. Biome
# ---------------------------------------------------------------------------
# WHITTAKER_TABLE, EXOTIC_CLASSES loaded from biomes.toml via biome_config
def compute_biome(body_def, elevation, sea_level, surface_water,
temperature, moisture):
H, W = elevation.shape
biome = np.zeros((H, W), dtype=np.int8)
land = ~surface_water
atmo = body_def["physical"]["atmosphere"]
# --- Atmosphere gate ---
# Worlds with no or thin atmosphere can't support vegetation.
# Skip the Whittaker table entirely — classify by elevation and
# temperature only, using rock/dust/ice classes.
if atmo in ("none", "thin"):
# Dry terrain classes: 27=dust plain, 28=rocky highland,
# 29=warm dust, 30=cold rock. No vegetation possible.
# No ice on airless worlds — cold rock stays rock.
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
has_ice_source = hydro not in ("none", "subsurface") or atmo == "thin"
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
cf = np.full(land.sum(), 28, dtype=np.int8) # default: rocky highland
tf = temperature[land].ravel()
en = elev_norm[land].ravel()
# Moon vs planet: moons use grey lunar palette, planets use warm rock
is_lunar = body_def.get("body_type") == "moon"
if is_lunar:
# Lunar classes: 31=highland, 32=mare (dark basin), 33=midland
cf[:] = 33 # default: midland grey
cf[en > 0.50] = 31 # highland
cf[en < 0.20] = 32 # mare (dark basin floor)
if has_ice_source:
cf[tf < 200] = 17 # ice (only if water source)
else:
# Temperature-based classification using dry terrain classes
if has_ice_source:
cf[tf < 200] = 17 # ice/snow (only if water source)
else:
cf[tf < 200] = 30 # cold rock (no water = no ice)
cf[(tf >= 200) & (tf < 260)] = 30 # cold rock
cf[(tf >= 260) & (tf < 310)] = 28 # rocky highland
cf[(tf >= 310) & (tf < 340)] = 29 # warm dust
cf[tf >= 340] = 15 # hot desert (scorched)
# Elevation variation
if has_ice_source:
cf[(en > 0.70) & (tf < 273)] = 17 # high + cold = ice cap
cf[(en < 0.20) & (tf >= 260)] = 27 # low elevation = dust plain
biome[land] = cf
else:
# --- Standard Whittaker lookup for breathable/toxic atmospheres ---
tf = temperature[land].ravel()
mf = moisture[land].ravel()
cf = np.full(tf.shape, 17, dtype=np.int8) # default: ice
# Temperature fed to biome is absolute Kelvin — compare directly
for (tlo, thi, mlo, mhi, cls) in WHITTAKER_TABLE:
mask = (tf >= tlo) & (tf <= thi) & (mf >= mlo) & (mf <= mhi)
cf[mask] = cls
biome[land] = cf
# Ocean depth bands
if surface_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
biome[surface_water & (depth < 0.15)] = 2
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1
biome[surface_water & (depth >= 0.50)] = 0
# Frozen ocean — override ocean biome with ice shelf (class 26).
# Distinct from land ice (17) — slightly different appearance,
# blue tint suggests ocean beneath.
# Add noise to the freeze threshold so the boundary isn't a straight
# latitude line — ice edges are irregular in reality.
seed = body_def["seed"]
u_grid, v_grid, _, _, _ = _make_grids()
ice_noise = _fbm(u_grid, v_grid, seed + 900, octaves=4,
gain=0.5, base_freq=3.0) * 2.0 - 1.0
freeze_threshold = 271.0 + ice_noise * 8.0 # ±8K variation
frozen_ocean = surface_water & (temperature < freeze_threshold)
biome[frozen_ocean] = 26
# Very cold override — only on worlds with atmosphere (ice needs deposition)
if atmo not in ("none",):
biome[(temperature < 243.0) & land] = 17 # below -30C → ice
# Elevation overrides — mountain rock and permanent snow.
# Only apply snow on worlds with atmosphere (ice needs deposition).
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
hydro_here = body_def.get("environment", {}).get("hydrosphere", "none")
has_ice_deposition = atmo not in ("none",) and hydro_here not in ("none", "subsurface")
if has_ice_deposition:
biome[land & (elev_norm > 0.85)] = 17
biome[land & (elev_norm > 0.65) & (temperature < 0.35)] = 18
# ── Modifier stack ─────────────────────────────────────────────────────
env = body_def.get("environment", {})
geothermal = env.get("geothermal_flux", "low")
chemosyn = env.get("chemosynthetic", False)
uv_index = env.get("uv_index", "moderate")
substrate = env.get("substrate", "silicate")
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
# Geothermal: volcanic worlds get lava/ash at high elevations
if geothermal in ("extreme", "high") and planet_class == "volcanic":
biome[land & (elev_norm > 0.75)] = EXOTIC_CLASSES["lava_field"]
biome[land & (elev_norm > 0.45) & (elev_norm <= 0.75)] = EXOTIC_CLASSES["ash_field"]
# Thermophilic fields near heat vents on any high-geothermal world
if geothermal in ("extreme", "high") and not chemosyn:
hot = (temperature > 303.0) & land & (elev_norm < 0.45)
biome[hot] = EXOTIC_CLASSES["thermophilic_field"]
# Chemosynthetic worlds (Europa-type): cold surface, geothermal warmth
if chemosyn:
geo_warm = (temperature > 263.0) & (temperature < 293.0) & land
biome[geo_warm] = EXOTIC_CLASSES["chemosynthetic_mat"]
# UV radiation: cryptobiotic crust on exposed terrain with thin/no atmo
if uv_index in ("extreme", "high") and atmo in ("none", "thin"):
exposed = (land & (elev_norm > 0.15) & (elev_norm < 0.65)
& (moisture < 0.30)
& (biome != 17) & (biome != 18) & (biome != 19))
biome[exposed] = EXOTIC_CLASSES["cryptobiotic_crust"]
# Sulfuric substrate: scrub on volcanic mid-elevations
if substrate == "sulfuric":
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (temperature > 0.35)
biome[scrub & (biome == 18)] = EXOTIC_CLASSES["sulfuric_scrub"]
# ── Anomaly scatter ─────────────────────────────────────────────────
# Sparse micro-features that break biome uniformity and tell stories.
# A high-frequency noise field selects ~2-5% of cells for anomaly
# replacement. The anomaly type depends on the surrounding biome context.
if atmo not in ("none",):
seed = body_def["seed"]
u_grid, v_grid, _, _, _ = _make_grids()
scatter_noise = _fbm(u_grid, v_grid, seed + 800, octaves=3,
gain=0.6, base_freq=12.0)
# High threshold = sparse features (~3% of land)
scatter_mask = (scatter_noise > 0.72) & land
if scatter_mask.any():
b_local = biome[scatter_mask]
t_local = temperature[scatter_mask]
m_local = moisture[scatter_mask]
e_local = elev_norm[scatter_mask]
new_b = b_local.copy()
# Temperate/forest → volcanic vent (lava at high elevation)
veg_mask = np.isin(b_local, [5, 6, 7, 8, 9, 10, 11])
new_b[veg_mask & (e_local > 0.50)] = 19 # lava field
new_b[veg_mask & (e_local > 0.35) & (e_local <= 0.50)] = 25 # ash
# Desert/dry → oasis with vegetation ring (only if water exists)
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
has_water = hydro not in ("none", "subsurface")
dry_mask = np.isin(b_local, [13, 14, 15, 27, 28, 29])
if has_water:
# Very rare lake in desert lowlands
new_b[dry_mask & (e_local < 0.10) & (m_local > 0.20)] = 2 # shallow water
# Vegetation around moisture (oasis fringe — works even without
# standing water, represents subsurface moisture reaching roots)
new_b[dry_mask & (m_local > 0.15) & (e_local >= 0.10)] = 7 # savanna
# Frozen → geothermal hotspot with pioneer vegetation
cold_mask = np.isin(b_local, [16, 17])
new_b[cold_mask & (t_local > 260)] = 12 # shrubland (hardy plants)
# Volcanic → cooling zone with pioneer life
lava_mask = np.isin(b_local, [19, 25])
new_b[lava_mask & (t_local < 310) & (m_local > 0.30)] = 24 # lithic pioneer
biome[scatter_mask] = new_b
# Vegetation ring around oasis lakes: dilate water cells from the
# scatter pass and assign graduated vegetation to the ring.
# water → coast vegetation → savanna/shrub → original biome
oasis_water = (biome == 2) & land # scattered lake cells on land
if oasis_water.any():
from scipy.ndimage import binary_dilation
# Pixel-unit iteration counts scale by GRID_W/512 (module
# convention, see the GRID_W comment above) so the vegetation
# ring covers the same physical distance at any resolution — a
# fixed pixel count would shrink the ring (relative to the
# planet's real size) as GRID_W grows past the 512 baseline.
# LIVE BEHAVIOR CHANGE (T-964, PR #210 review — hoshe): GRID_W is
# ALREADY 1024 in production (not a future value), so this scales
# ring1/ring2 from the historical 2/4 iterations to 4/8 on EVERY
# planet generated today — see `oasis_ring_iterations`'s test
# coverage (`test_planet_simulation.py`) pinning both the 512
# (historical) and 1024 (current production) values explicitly.
ring1_iters, ring2_iters = oasis_ring_iterations(GRID_W)
ring1 = binary_dilation(oasis_water, iterations=ring1_iters) & ~oasis_water & land
ring2 = (
binary_dilation(oasis_water, iterations=ring2_iters)
& ~oasis_water
& ~ring1
& land
)
# Inner ring: lush vegetation (coast/lowland green)
biome[ring1] = 4 # lowland
# Outer ring: transitional (savanna/shrub)
biome[ring2] = 12 # shrubland
return biome
# ---------------------------------------------------------------------------
# Top-level simulate()
# ---------------------------------------------------------------------------
def simulate(body_def: dict) -> dict:
"""
Run the full simulation stack for one body.
Parameters
----------
body_def : dict — from body_definition_parser.parse_system()
Returns
-------
dict terrain dict consumed by planet_renderer.render_globe()
Empty dict for gas giants (renderer handles those procedurally).
"""
planet_class = body_def.get("planet_class", "barren").replace("_ringed", "")
if planet_class == "gas_giant":
return {}
u, v, lat_frac, lon_frac, lat_rad = _make_grids()
elevation, sea_level, surface_water = compute_elevation(
body_def, u, v, lat_frac)
temperature, temp_clamped, temp_raw_K = compute_temperature(
body_def, elevation, sea_level, lat_frac)
moisture = compute_moisture(
body_def, elevation, sea_level, temperature, lat_frac, lon_frac)
hillshade = compute_hillshade(elevation)
biome = compute_biome(
body_def, elevation, sea_level, surface_water, temperature, moisture)
# Normalise temperature to [0,1] for renderer display — biome already computed
t_min, t_max = temperature.min(), temperature.max()
temperature_norm = ((temperature - t_min) / (t_max - t_min + 1e-9)).astype(np.float32)
return {
"elevation": elevation,
"temperature": temperature_norm, # normalised [0,1] for renderer
"moisture": moisture,
"biome": biome,
"surface_water": surface_water,
"hillshade": hillshade,
"sea_level": sea_level,
"_grid_w": GRID_W,
"_grid_h": GRID_H,
# Audit trail
"temperature_clamped": temp_clamped,
"temperature_raw_K": round(temp_raw_K, 1),
"temperature_band_K": list(CLASS_T_BAND.get(
body_def.get("planet_class","").replace("_ringed",""), [None,None])),
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
import json
import time
import os
from PIL import Image
if len(sys.argv) < 2:
console.event("Usage: python -m tooling.domains.atlas.planet.planet_simulation body_def.json [--save-grids]")
sys.exit(1)
with open(sys.argv[1]) as f:
bd = json.load(f)
save_grids = "--save-grids" in sys.argv
console.event(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
if not terrain:
console.event("Gas giant — no terrain simulation.")
sys.exit(0)
dt = time.time() - t0
console.event(f"Done in {dt:.1f}s")
console.event(f" sea_level: {terrain['sea_level']:.3f}")
console.event(f" land cells: {(~terrain['surface_water']).sum()}")
ids, counts = np.unique(terrain['biome'], return_counts=True)
console.event(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
if save_grids:
out = f"/tmp/{bd['id']}_grids"
os.makedirs(out, exist_ok=True)
for name in ("elevation", "temperature", "moisture", "hillshade"):
arr = terrain[name]
Image.fromarray((arr * 255).astype("uint8"), "L").save(
f"{out}/{name}.png")
console.event(f"Grids saved → {out}/")
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
Batch populate terrain_reference column in systems.db bodies table.
For each inhabited body with NULL terrain_reference:
1. Construct expected wiki heightmap path:
wiki/star-systems/{system_slug}/bodies/{body_id}/heightmap.png
where system_slug = system_id with spaces replaced by hyphens.
2. Verify the file exists.
3. Update terrain_reference to the relative path.
Bodies with missing heightmaps are logged to stdout for remediation.
This is the prerequisite for the atlas importers (import_heightmaps.py, #901).
Usage:
reach atlas planet terrain-reference
reach atlas planet terrain-reference --dry-run
reach atlas planet terrain-reference --db path/to/systems.db
Decisions: D-191 (atlas pipeline prerequisites)
"""
import argparse
import sqlite3
from pathlib import Path
from tooling.core import config, console
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_DIR = REPO_ROOT / "wiki" / "star-systems"
def system_slug(system_id: str) -> str:
"""Convert a system_id to its wiki directory slug.
'GJ 71' → 'GJ-71'
'GJ 244A' → 'GJ-244A'
'GJ 559B' → 'GJ-559B'
"""
return system_id.replace(" ", "-")
def expected_heightmap_path(system_id: str, body_id: str) -> Path:
"""Return the expected absolute heightmap path for a body."""
return WIKI_DIR / system_slug(system_id) / "bodies" / body_id / "heightmap.png"
def relative_terrain_reference(system_id: str, body_id: str) -> str:
"""Return the terrain_reference value to store in the DB.
Stored as a repo-root-relative path so it is portable across checkouts.
"""
return f"wiki/star-systems/{system_slug(system_id)}/bodies/{body_id}/heightmap.png"
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Populate terrain_reference column in systems.db bodies table"
)
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
parser.add_argument(
"--dry-run",
action="store_true",
help="Report without writing changes",
)
args = parser.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
console.event(f"error: {db_path} not found")
raise SystemExit(1)
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON")
# Fetch all inhabited bodies with NULL terrain_reference.
rows = conn.execute("""
SELECT b.body_id, b.system_id
FROM bodies b
WHERE b.terrain_reference IS NULL
ORDER BY b.system_id, b.body_id
""").fetchall()
console.event(" terrain_reference population pass")
console.event(f" DB: {db_path}")
if args.dry_run:
console.event(" Mode: DRY RUN")
console.event(f" {len(rows)} bodies with NULL terrain_reference\n")
found = []
missing = []
for body_id, system_id in rows:
path = expected_heightmap_path(system_id, body_id)
if path.exists():
found.append((body_id, system_id))
else:
missing.append((body_id, system_id, str(path)))
# Report missing heightmaps before writing — helps flag gaps early.
if missing:
console.event(f" MISSING heightmaps ({len(missing)} bodies — no update for these):")
for body_id, system_id, path in missing:
console.event(f" {body_id} ({system_id}) → {path}")
if found:
console.event(f" Updating {len(found)} bodies with terrain_reference:")
for body_id, system_id in found:
ref = relative_terrain_reference(system_id, body_id)
console.event(f" {body_id} ({system_id}) → {ref}")
if not args.dry_run:
conn.execute(
"UPDATE bodies SET terrain_reference = ? WHERE body_id = ?",
(ref, body_id),
)
if not args.dry_run:
conn.commit()
console.event(f" Committed {len(found)} terrain_reference updates.")
else:
console.event(" Dry run — no changes written.")
conn.close()
# Summary
console.event(" Summary:")
console.event(f" Updated: {len(found)}")
console.event(f" Missing: {len(missing)}")
console.event(f" Total: {len(rows)}\n")
if missing:
console.event(f" Action required: generate heightmaps for {len(missing)} bodies "
f"before running the atlas importers (#901).")
console.event(" Use: make generate-terrain (or run generate.py per body)\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,456 @@
"""
render_heightmap.py
-------------------
Renders an annotated equirectangular heightmap PNG from a terrain dict.
This is the PRIMARY output of the planet generator pipeline.
The globe render is a separate downstream step that reads the same terrain dict.
Equirectangular projection:
X axis: longitude 0°→360° (left to right)
Y axis: latitude +90°→-90° (top to bottom, north pole at row 0)
Each terrain grid cell maps to a block of output pixels via bicubic upscale.
All rendering is in float32; final conversion to uint8 at save time.
Output layers (composited in order):
1. Biome colour — smooth-blended from Whittaker grid, not hard-snapped
2. Elevation shading — subtle darkening in valleys, lightening on peaks
3. Hillshade — surface normal lighting pass (makes terrain 3D-readable)
4. Coastline — 1px dark border at sea level threshold
5. Rivers — anti-aliased polylines from river list
6. Lat/lon grid — every 30°, semi-transparent
7. Title panel — body metadata strip at top
8. Legend — biome colour swatches at bottom
Geographic only. No settlements, roads, or cultural data.
Those live in a separate JSON sidecar and are overlaid by the atlas app.
Usage:
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.body_definition_parser import parse_system
defs = parse_system("index.md")
terrain = simulate(defs[0])
img = render_heightmap(defs[0], terrain)
img.save("GJ144d_heightmap.png")
"""
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy.ndimage import binary_dilation
from tooling.domains.atlas.planet.biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
RIVER_RGB as _RIVER_RGB_CFG,
COAST_RGB as _COAST_RGB_CFG,
build_biome_rgb,
)
from tooling.core import console
# ---------------------------------------------------------------------------
# Output resolution
# ---------------------------------------------------------------------------
OUT_W = 1024
OUT_H = 512
UI_SCALE = OUT_W / 1024 # 1.0 at default 1024 — all pixel sizes scale with this
# ---------------------------------------------------------------------------
# Biome colour palette
# Indices match planet_simulation.WHITTAKER_TABLE class IDs.
# Extended exotic classes appended at end.
# ---------------------------------------------------------------------------
# Biome palette loaded from biomes.toml via biome_config.
# Per-planet overrides can patch _BIOME_PALETTE_CFG before rendering.
BIOME_PALETTE = _BIOME_PALETTE_CFG
def _build_biome_rgb(mode: str = "cartographic") -> dict:
return build_biome_rgb(mode)
RENDER_MODE = "cartographic"
BIOME_RGB = _build_biome_rgb(RENDER_MODE)
def _ocean_arrays(mode: str = "cartographic"):
return (
np.array(BIOME_PALETTE[0][mode], dtype=np.float32),
np.array(BIOME_PALETTE[1][mode], dtype=np.float32),
np.array(BIOME_PALETTE[2][mode], dtype=np.float32),
)
OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(RENDER_MODE)
RIVER_RGB = _RIVER_RGB_CFG
COAST_RGB = _COAST_RGB_CFG
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _upscale(grid: np.ndarray, order: int = 1) -> np.ndarray:
"""
Upscale a (GRID_H, GRID_W) float32 grid to (OUT_H, OUT_W).
order=1 → bilinear (smooth, good for continuous fields)
order=0 → nearest (sharp, good for integer class grids)
"""
from scipy.ndimage import zoom
zy = OUT_H / grid.shape[0]
zx = OUT_W / grid.shape[1]
return zoom(grid.astype(np.float32), (zy, zx), order=order).astype(np.float32)
def _upscale_int(grid: np.ndarray) -> np.ndarray:
"""Nearest-neighbour upscale for integer class grids (biome, etc)."""
from scipy.ndimage import zoom
zy = OUT_H / grid.shape[0]
zx = OUT_W / grid.shape[1]
return zoom(grid.astype(np.int32), (zy, zx), order=0).astype(np.int8)
# ---------------------------------------------------------------------------
# Layer 1 + 2 + 3: Biome colour + elevation shading + hillshade
# ---------------------------------------------------------------------------
def _render_surface(terrain: dict) -> np.ndarray:
"""
Returns (OUT_H, OUT_W, 3) float32 RGB in [0, 1].
Compositing order:
biome_colour × elevation_shade × hillshade_factor
"""
elevation = _upscale(terrain["elevation"], order=1)
hillshade = _upscale(terrain["hillshade"], order=1)
biome_up = _upscale_int(terrain["biome"])
surf_water = _upscale(terrain["surface_water"].astype(np.float32),
order=0) > 0.5
sea_level = terrain["sea_level"]
H, W = elevation.shape
# ── Biome base colour ─────────────────────────────────────────────────
# Clamp biome index, look up palette
# Build lookup array from active BIOME_RGB dict for vectorised indexing
max_id = max(BIOME_RGB.keys())
pal_arr = np.zeros((max_id + 1, 3), dtype=np.float32)
for k, v in BIOME_RGB.items():
pal_arr[k] = v
biome_clamped = np.clip(biome_up, 0, max_id)
rgb = pal_arr[biome_clamped].astype(np.float32) / 255.0
# ── Ocean depth blending ───────────────────────────────────────────────
# Override flat ocean biome with smooth depth gradient
if surf_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
deep_col = OCEAN_DEEP / 255.0
mid_col = OCEAN_MID / 255.0
shallow_col = OCEAN_SHALLOW / 255.0
# Three-stop blend: 0=shallow, 0.5=mid, 1=deep
t1 = np.clip(depth * 2.0, 0, 1) # 0→0.5 depth: shallow→mid
t2 = np.clip((depth - 0.5) * 2.0, 0, 1) # 0.5→1 depth: mid→deep
ocean_rgb = (shallow_col * (1 - t1)[..., None]
+ mid_col * (t1 * (1 - t2))[..., None]
+ deep_col * t2[..., None])
rgb = np.where(surf_water[..., None], ocean_rgb, rgb)
# ── Elevation shading on land ──────────────────────────────────────────
# Slight darkening in lowlands, brightening on ridges
elev_norm = np.where(
~surf_water,
np.clip((elevation - sea_level) / (1.0 - sea_level + 1e-9), 0, 1),
0.0)
elev_shade = 0.88 + 0.18 * elev_norm # [0.88, 1.06] — clamp below
rgb = np.where(~surf_water[..., None],
np.clip(rgb * elev_shade[..., None], 0, 1),
rgb)
# ── Hillshade ──────────────────────────────────────────────────────────
# Apply only on land — ocean gets its own depth shading
# Blend factor: 0.55 hillshade + 0.45 flat (keeps colours readable)
hs_blend = 0.55 * hillshade + 0.45
rgb = np.where(~surf_water[..., None],
np.clip(rgb * hs_blend[..., None], 0, 1),
rgb)
return rgb.astype(np.float32)
# ---------------------------------------------------------------------------
# Layer 4: Coastline
# ---------------------------------------------------------------------------
def _render_coastline(terrain: dict,
rgb: np.ndarray) -> np.ndarray:
"""Draw a 1–2px dark border at the sea level threshold."""
surf_water = _upscale(terrain["surface_water"].astype(np.float32),
order=0) > 0.5
# Dilate water mask by 1px, XOR with original → coastline ring
dilated = binary_dilation(surf_water, iterations=2)
coastline = dilated & ~surf_water
coast_col = np.array(COAST_RGB, dtype=np.float32) / 255.0
out = rgb.copy()
out[coastline] = coast_col
return out
# ---------------------------------------------------------------------------
# Layer 5: Lat/lon grid
# ---------------------------------------------------------------------------
def _render_grid(rgb: np.ndarray) -> np.ndarray:
"""Draw lat/lon lines every 30° as semi-transparent overlays."""
out = rgb.copy()
col = np.array([255, 255, 255], dtype=np.float32) / 255.0
alpha = 0.12 # very subtle
# Latitude lines (horizontal) every 30°: at 1/6, 2/6, 3/6, 4/6, 5/6 of height
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
y = int(frac * OUT_H)
y0 = max(0, y - 1); y1 = min(OUT_H - 1, y + 1)
out[y0:y1, :] = out[y0:y1, :] * (1 - alpha) + col * alpha
# Longitude lines (vertical) every 30°
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
x = int(frac * OUT_W)
x0 = max(0, x - 1); x1 = min(OUT_W - 1, x + 1)
out[:, x0:x1] = out[:, x0:x1] * (1 - alpha) + col * alpha
return out
# ---------------------------------------------------------------------------
# Layer 7: Title panel
# ---------------------------------------------------------------------------
def _load_font(size: int):
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
def _render_title(img: Image.Image, body_def: dict) -> Image.Image:
"""Draw metadata strip at top of image."""
panel_h = int(52 * UI_SCALE)
panel = Image.new("RGBA", (OUT_W, panel_h), (12, 15, 22, 210))
img_rgba = img.convert("RGBA")
img_rgba.paste(panel, (0, 0), panel)
img_out = img_rgba.convert("RGB")
draw = ImageDraw.Draw(img_out)
name = body_def.get("name") or body_def.get("id", "Unknown")
bid = body_def.get("id", "")
pclass = body_def.get("planet_class", "").replace("_", " ")
star = body_def.get("star", {})
orbit = body_def.get("orbit", {})
phys = body_def.get("physical", {})
env = body_def.get("environment", {})
star_str = f"{star.get('type','?')}-type"
dist_str = f"{orbit.get('distance_au', 0):.2f} AU"
grav_str = f"{phys.get('gravity_g', '?')}g"
atmo_str = phys.get("atmosphere", "?")
hydro_str = env.get("hydrosphere", "?")
px = int(14 * UI_SCALE)
py = int(7 * UI_SCALE)
lh = int(17 * UI_SCALE)
title_col = (200, 210, 228)
sub_col = (130, 145, 168)
dim_col = (75, 88, 110)
line1 = f"{name.upper()} · {bid} · {pclass}"
line2 = f"{star_str} · {dist_str} · {grav_str} · atmo: {atmo_str} · hydro: {hydro_str}"
line3 = "HEIGHTMAP · Settled Reach"
draw.text((px, py), line1, fill=title_col, font=_load_font(int(14 * UI_SCALE)))
draw.text((px, py + lh), line2, fill=sub_col, font=_load_font(int(12 * UI_SCALE)))
draw.text((px, py + lh*2), line3, fill=dim_col, font=_load_font(int(11 * UI_SCALE)))
return img_out
# ---------------------------------------------------------------------------
# Layer 8: Legend
# ---------------------------------------------------------------------------
def _biome_legend_items(terrain: dict) -> list:
"""
Return list of (label, RGB) for biome classes actually present
in this terrain — no phantom legend entries.
"""
biome = terrain["biome"]
present = set(np.unique(biome).tolist())
LABELS = {
0: "ocean deep", 1: "ocean", 2: "coastal water",
3: "coast", 5: "trop. rainforest", 6: "trop. forest",
7: "savanna", 8: "grassland", 9: "forest",
10: "temp. rainforest", 11: "boreal", 12: "shrubland",
13: "temperate desert", 14: "desert", 15: "hot desert",
16: "tundra", 17: "ice / snow", 18: "mountain rock",
19: "lava field", 20: "chemosyn. mat", 21: "thermophilic",
22: "sulfuric scrub", 23: "crypto. crust", 25: "ash field",
}
items = []
# Fixed display order — most common first, exotic last
order = [0, 1, 2, 3, 7, 8, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17,
18, 19, 20, 21, 22, 23, 25]
for cls_id in order:
if cls_id in present and cls_id in LABELS:
rgb = BIOME_RGB.get(cls_id, (128, 128, 128))
items.append((LABELS[cls_id], rgb))
# Always include river swatch if rivers exist
if terrain.get("rivers"):
items.append(("river", RIVER_RGB))
return items
def _render_legend(img: Image.Image, terrain: dict) -> Image.Image:
"""Draw biome legend strip at bottom of image."""
items = _biome_legend_items(terrain)
if not items:
return img
draw = ImageDraw.Draw(img)
sw = int(14 * UI_SCALE) # swatch width
sh = int(12 * UI_SCALE) # swatch height
pad_x = int(14 * UI_SCALE)
leg_y = OUT_H - int(34 * UI_SCALE)
font = _load_font(int(10 * UI_SCALE))
gap = int(6 * UI_SCALE)
# Scale step to fit all items within image width
max_items = len(items)
max_step = (OUT_W - 2 * pad_x) // max(max_items, 1)
step = min(int(108 * UI_SCALE), max_step)
# Max label width = step minus swatch minus gaps
max_label_px = step - sw - gap - int(4 * UI_SCALE)
lx = pad_x
for label, rgb in items:
draw.rectangle([(lx, leg_y), (lx + sw, leg_y + sh)], fill=rgb)
# Truncate label to fit allocated space
display = label
while display and draw.textlength(display, font=font) > max_label_px:
display = display[:-1]
if display != label and display:
display = display[:-1] + "…"
draw.text((lx + sw + gap, leg_y), display,
fill=(185, 192, 205), font=font)
lx += step
return img
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def render_heightmap(body_def: dict,
terrain: dict,
out_w: int = OUT_W,
out_h: int = OUT_H,
render_mode: str = "cartographic",
chrome: bool = True) -> Image.Image:
"""
Render an annotated equirectangular heightmap PNG.
Parameters
----------
body_def : dict — body definition from body_definition_parser
terrain : dict — terrain dict from planet_simulation.simulate()
out_w, out_h — output resolution (default 1024×512)
Returns
-------
PIL.Image.Image RGB
"""
global OUT_W, OUT_H, UI_SCALE
OUT_W = out_w
OUT_H = out_h
UI_SCALE = out_w / 1024
# Set active colour mode for this render
global BIOME_RGB, OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW, RENDER_MODE
RENDER_MODE = render_mode
BIOME_RGB = _build_biome_rgb(render_mode)
OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(render_mode)
# Guard: require simulation data
required = ("elevation", "biome", "surface_water", "hillshade", "sea_level")
missing = [k for k in required if k not in terrain]
if missing:
raise ValueError(f"terrain dict missing keys: {missing}")
# 1+2+3: surface colour with elevation shading and hillshade
rgb = _render_surface(terrain)
# 4: coastline
rgb = _render_coastline(terrain, rgb)
# 5: lat/lon grid
rgb = _render_grid(rgb)
# Convert to PIL for text rendering
img = Image.fromarray(
(rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
if chrome:
# 7: title panel
img = _render_title(img, body_def)
# 8: legend
img = _render_legend(img, terrain)
return img
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
import json
import time
if len(sys.argv) < 2:
console.event("Usage: python -m tooling.domains.atlas.planet.render_heightmap body_def.json [--large]")
sys.exit(1)
with open(sys.argv[1]) as f:
bd = json.load(f)
# --large flag renders at 4096×2048 for high-res review
large = "--large" in sys.argv
w, h = (4096, 2048) if large else (OUT_W, OUT_H)
from tooling.domains.atlas.planet.planet_simulation import simulate
console.event(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
sim_t = time.time() - t0
if not terrain:
console.event("Gas giant — no heightmap.")
sys.exit(0)
console.event(f"Rendering heightmap {w}×{h}…")
t1 = time.time()
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
ren_t = time.time() - t1
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
img.save(out, format="PNG")
console.event(f"Saved: {out}")
console.event(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
+261
View File
@@ -0,0 +1,261 @@
"""Transport for `atlas planet` — args in, delegate, format out.
Every verb here declares its options explicitly rather than forwarding an
opaque argument list. That is deliberate and it is the more expensive option:
the underlying modules already parse their own arguments, so this restates ten
surfaces that exist elsewhere.
It is worth it because the primary user of reach is an agent (D-263), and an
agent discovers a command by reading its `--help`. A passthrough would make
`reach atlas planet generate --help` describe nothing, and the real surface
would only be findable by reading the module — which is the fragmentation the
whole CLI exists to end.
The cost is a second place that can drift. `tooling/test_planet_router.py`
closes that: it hands every declared option to the module's own parser and
fails if the parser does not recognise it.
"""
from __future__ import annotations
from pathlib import Path
import typer
from tooling.core import cli
from tooling.core.command import command
app = cli.domain("planet", "Bodies — scaffold, generate, import and audit.")
@app.callback()
def _rung() -> None:
"""Keeps `planet` a group (Typer collapses a single-command app)."""
def _flags(**pairs: object) -> list[str]:
"""Turn declared options into the argv the module's parser expects.
None means "not given" and is dropped, so the module's own defaults stay
authoritative — restating them here would be a second source of truth for
every default in the domain. A list repeats the flag, for the parsers that
declare `action="append"`.
"""
argv: list[str] = []
for name, value in pairs.items():
flag = "--" + name.replace("_", "-")
if value is None or value is False:
continue
if value is True:
argv.append(flag)
elif isinstance(value, (list, tuple)):
for item in value:
argv += [flag, str(item)]
else:
argv += [flag, str(value)]
return argv
# --- generation -----------------------------------------------------------
@app.command("generate")
@command
def generate(
body_def: Path = typer.Argument(..., help="Body definition JSON."),
system: str = typer.Option(None, "--system", help="System id to generate for."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
chrome: bool = typer.Option(False, "--chrome", help="Draw chrome on the render."),
) -> None:
"""Generate one body — simulate, render the heightmap, write the globe."""
from tooling.domains.atlas.planet import generate as impl
impl.main([
str(body_def),
*_flags(
system=system,
overrides=overrides,
output_dir=output_dir,
heightmap_size=heightmap_size,
globe_size=globe_size,
render_mode=render_mode,
chrome=chrome,
),
])
@app.command("batch")
@command
def batch(
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
scaffold_only: bool = typer.Option(False, "--scaffold-only", help="Scaffold, do not generate."),
generate_only: bool = typer.Option(False, "--generate-only", help="Generate, do not scaffold."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
force: bool = typer.Option(False, "--force", help="Regenerate what already exists."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
verify_determinism: int = typer.Option(
None, "--verify-determinism", metavar="N",
help="Generate N random bodies twice and verify the output is identical.",
),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
) -> None:
"""Generate every system unattended — the long one; consider --detach."""
from tooling.domains.atlas.planet import batch as impl
impl.main(_flags(
system=system,
body=body,
scaffold_only=scaffold_only,
generate_only=generate_only,
overrides=overrides,
force=force,
dry_run=dry_run,
verify_determinism=verify_determinism,
heightmap_size=heightmap_size,
globe_size=globe_size,
))
@app.command("scaffold")
@command
def scaffold(
system_index: Path = typer.Argument(..., help="System index JSON."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
) -> None:
"""Write body definitions for a system, ready for `generate`."""
from tooling.domains.atlas.planet import scaffold_bodies as impl
impl.main([str(system_index), *_flags(overrides=overrides, dry_run=dry_run)])
# --- imports into the atlas DB -------------------------------------------
@app.command("import-heightmaps")
@command
def import_heightmaps(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
skip_rename: bool = typer.Option(False, "--skip-rename", help="Do not rename source files."),
) -> None:
"""Load heightmap grids into atlas_body_heightmaps.
A one-time build import, deliberately NOT part of `make regen-db` and not
stamped (.claude/rules/asset-pipeline.md). Running it is a decision, not a
step in the pipeline.
"""
from tooling.domains.atlas.planet import import_heightmaps as impl
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, skip_rename=skip_rename))
@app.command("import-provinces")
@command
def import_provinces(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
force: bool = typer.Option(False, "--force", help="Recompute bodies that already have rows."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
verbose: bool = typer.Option(False, "--verbose", help="Per-body detail."),
) -> None:
"""Derive province boundaries from watershed analysis (D-205, D-208).
The other one-time build import — same standing as import-heightmaps.
Expect ~15–20 minutes for a full run; `--detach` and tail it.
"""
from tooling.domains.atlas.planet import import_province_boundaries as impl
impl.main(_flags(db=db, body=body, force=force, dry_run=dry_run, verbose=verbose))
@app.command("terrain-reference")
@command
def terrain_reference(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
) -> None:
"""Populate the terrain reference table."""
from tooling.domains.atlas.planet import populate_terrain_reference as impl
impl.main(_flags(db=db))
# --- Sol ------------------------------------------------------------------
@app.command("sol-import")
@command
def sol_import(
body: list[str] = typer.Option(None, "--body", help="Limit to these Sol bodies (repeatable)."),
download_only: bool = typer.Option(False, "--download-only", help="Fetch source data only."),
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
) -> None:
"""Import real Sol bodies from published elevation data."""
from tooling.domains.atlas.planet import sol_import as impl
impl.main(_flags(
body=body,
download_only=download_only,
output_dir=output_dir,
heightmap_size=heightmap_size,
globe_size=globe_size,
render_mode=render_mode,
))
@app.command("sol-name-fixes")
@command
def sol_name_fixes(
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
) -> None:
"""Name the auto-detected features on Sol bodies that were left unnamed."""
from tooling.domains.atlas.planet import sol_name_fixes as impl
impl.main(_flags(dry_run=dry_run))
# --- audits ---------------------------------------------------------------
@app.command("audit")
@command
def audit(
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
) -> None:
"""Cohesion audit — does the atlas hang together across bodies."""
from tooling.domains.atlas.planet import atlas_cohesion_audit as impl
impl.main(_flags(system=system, body=body, db=db))
@app.command("quality")
@command
def quality(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
top_collisions: int = typer.Option(None, "--top-collisions", help="How many to list."),
) -> None:
"""Quality analysis — name collisions and distribution."""
from tooling.domains.atlas.planet import atlas_quality_analysis as impl
impl.main(_flags(db=db, system=system, body=body, top_collisions=top_collisions))
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Scaffold per-body index.md files from a system index.md.
Reads the system's Celestial Bodies table, runs body_definition_parser
to produce full body definitions, and writes one index.md per body
under wiki/star-systems/{system}/bodies/{body_id}/index.md.
The frontmatter IS the body definition — the generator reads it directly.
Below the frontmatter is space for authored body content (narrative, notes).
Usage:
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md --dry-run
Only creates files that don't exist yet — never overwrites authored content.
Re-running is safe: existing body index.md files are skipped.
"""
import argparse
import json
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
# Venv bootstrap
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
try:
import yaml
except ImportError:
# PyYAML is in pyproject.toml deps
raise ReachError("PyYAML not installed — run `make setup-venv`", fix="make install-reach — reach declares PyYAML in its own environment")
from tooling.domains.atlas.planet.body_definition_parser import parse_system
def _body_to_frontmatter(bd: dict) -> str:
"""Convert a body definition dict to clean YAML frontmatter."""
# Order fields for readability
ordered = {}
for key in ("id", "name", "body_type", "planet_class", "body_scale", "seed"):
if key in bd:
ordered[key] = bd[key]
for section in ("star", "orbit", "physical", "terrain", "environment",
"clouds", "render", "gas_giant", "rings"):
if section in bd and bd[section] is not None:
ordered[section] = bd[section]
return yaml.dump(ordered, default_flow_style=False, sort_keys=False,
allow_unicode=True).rstrip()
def _body_prose(bd: dict, system_dir: Path) -> str:
"""Generate markdown content below the frontmatter."""
name = bd.get("name") or bd.get("id")
bid = bd["id"]
pclass = bd.get("planet_class", "unknown")
btype = bd.get("body_type", "planet")
wiki = bd.get("wiki", {})
phys = bd.get("physical", {})
orbit = bd.get("orbit", {})
terrain = bd.get("terrain", {})
env = bd.get("environment", {})
# System link (relative path from body dir to system index)
system_link = "../../index.md"
lines = [f"# {name}", ""]
# Type line
if btype == "moon":
lines.append(f"{pclass.replace('_', ' ').title()} moon.")
elif pclass in ("gas_giant", "gas_giant_ringed"):
lines.append("Gas giant.")
else:
lines.append(f"{pclass.replace('_', ' ').title()} {btype}.")
lines.append("")
# System link
lines.append(f"**System:** [{system_dir.name}]({system_link})")
lines.append("")
# Visual overview
lines.append("## Visual")
lines.append("")
lines.append("![Globe](globe.png)")
lines.append("")
# Heightmap link only when the file exists or will exist: import_heightmaps.py
# bakes heightmap.png solely for non-Sol, non-gas inhabited bodies with
# population > 0 (D-202 amended, #963) — linking it elsewhere produces a
# dead image.
if pclass not in ("gas_giant", "gas_giant_ringed"):
heightmap_path = system_dir / "bodies" / bid / "heightmap.png"
will_exist = (system_dir.name != "GJ-0"
and bool(wiki.get("inhabited"))
and bool(wiki.get("population")))
if heightmap_path.exists() or will_exist:
lines.append("![Heightmap](heightmap.png)")
lines.append("")
# Profile table
lines.append("## Profile")
lines.append("")
is_gas = pclass in ("gas_giant", "gas_giant_ringed")
lines.append("| | |")
lines.append("|---|---|")
lines.append(f"| **Type** | {btype} |")
lines.append(f"| **Class** | {pclass.replace('_', ' ')} |")
if phys.get("gravity_g") and not is_gas:
lines.append(f"| **Gravity** | {phys['gravity_g']}g |")
if phys.get("atmosphere") and phys["atmosphere"] != "none":
lines.append(f"| **Atmosphere** | {phys['atmosphere']} |")
hydro = env.get("hydrosphere")
if hydro and hydro not in ("none", "—", ""):
lines.append(f"| **Hydrosphere** | {hydro} |")
if not is_gas and terrain.get("land_fraction") is not None:
lines.append(f"| **Land** | {terrain['land_fraction']*100:.0f}% |")
if orbit.get("period_days"):
label = "Orbit" if btype == "moon" else "Year"
lines.append(f"| **{label}** | {orbit['period_days']:.0f} days |")
if wiki.get("inhabited"):
lines.append("| **Inhabited** | yes |")
if wiki.get("population"):
lines.append(f"| **Population** | {wiki['population']} |")
if wiki.get("economy"):
lines.append(f"| **Economy** | {wiki['economy']} |")
if wiki.get("settlement"):
lines.append(f"| **Settlement** | {wiki['settlement']} |")
if wiki.get("industrial"):
lines.append(f"| **Industry** | {wiki['industrial']} |")
lines.append("")
# Content section
lines.append("## Description")
lines.append("")
lines.append("<!-- Body content: narrative, history, notes -->")
lines.append("")
return "\n".join(lines)
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Scaffold per-body index.md files from a system index.md")
parser.add_argument("system_index", help="Path to system index.md")
parser.add_argument("--overrides", help="Per-body overrides JSON")
parser.add_argument("--dry-run", action="store_true",
help="Print what would be created without writing")
args = parser.parse_args(argv)
system_path = Path(args.system_index)
system_dir = system_path.parent
bodies_dir = system_dir / "bodies"
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
body_defs = parse_system(str(system_path), overrides=overrides)
console.event(f"System: {system_path} — {len(body_defs)} renderable bodies")
created = 0
skipped = 0
for bd in body_defs:
body_id = bd["id"]
body_dir = bodies_dir / body_id
index_path = body_dir / "index.md"
if index_path.exists():
console.event(f" skip {body_id} — index.md exists")
skipped += 1
continue
frontmatter = _body_to_frontmatter(bd)
prose = _body_prose(bd, system_dir)
content = f"---\n{frontmatter}\n---\n\n{prose}"
if args.dry_run:
console.event(f" would create {index_path}")
console.event(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}")
else:
body_dir.mkdir(parents=True, exist_ok=True)
with open(index_path, "w") as f:
f.write(content)
console.event(f" created {index_path}")
created += 1
action = "would create" if args.dry_run else "created"
console.event(f" {action} {created}, skipped {skipped}")
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
# Sol system real-world data importers
@@ -0,0 +1,104 @@
"""
Caching downloader for planetary science datasets.
Downloads are stored in sol_data/.cache/ and reused on subsequent runs.
Supports resume for large files and optional SHA-256 verification.
"""
import hashlib
import urllib.request
from pathlib import Path
from tooling.core import console
CACHE_DIR = Path(__file__).resolve().parent / ".cache"
# urlretrieve calls the hook once per 8 KB block. The old `\r` rewrite made that
# free on a terminal; as stream events it would be tens of thousands of lines in
# a job log, so progress is reported per 10% step (or per 50 MB when the server
# sends no length) instead.
_STEP_PCT = 10
_STEP_MB = 50
_last_step = -1
def _progress_hook(block_num, block_size, total_size):
"""Report download progress, one event per step."""
global _last_step
if block_num == 0:
_last_step = -1
downloaded = block_num * block_size
mb = downloaded / (1024 * 1024)
if total_size > 0:
fraction = min(1.0, downloaded / total_size)
step = int(fraction * 100) // _STEP_PCT
if step != _last_step:
_last_step = step
total_mb = total_size / (1024 * 1024)
console.event(f"downloading: {mb:.1f}/{total_mb:.1f} MB", progress=fraction)
else:
step = int(mb) // _STEP_MB
if step != _last_step:
_last_step = step
console.event(f"downloading: {mb:.1f} MB")
def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
"""
Download a file if not already cached. Returns path to cached file.
Parameters
----------
url : download URL
filename : local filename within the cache directory
sha256 : optional hex digest for verification
"""
CACHE_DIR.mkdir(parents=True, exist_ok=True)
local_path = CACHE_DIR / filename
if local_path.exists():
if sha256:
actual = _sha256(local_path)
if actual != sha256:
console.event(f"checksum mismatch for {filename}, re-downloading", level="warn")
local_path.unlink()
else:
return local_path
else:
return local_path
console.event(f" fetching {filename} from {url[:80]}...")
tmp_path = local_path.with_suffix(".tmp")
try:
# Many government data servers (USGS, NOAA) require a User-Agent
opener = urllib.request.build_opener()
opener.addheaders = [
("User-Agent", "SettledReach-PlanetGen/1.0 (terrain pipeline)"),
]
urllib.request.install_opener(opener)
urllib.request.urlretrieve(url, str(tmp_path), reporthook=_progress_hook)
except Exception as e:
if tmp_path.exists():
tmp_path.unlink()
raise RuntimeError(f"Download failed for {filename}: {e}") from e
if sha256:
actual = _sha256(tmp_path)
if actual != sha256:
tmp_path.unlink()
raise RuntimeError(
f"Checksum mismatch for {filename}: "
f"expected {sha256[:16]}..., got {actual[:16]}..."
)
tmp_path.rename(local_path)
return local_path
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()
@@ -0,0 +1,390 @@
"""
Earth (GJ0d) terrain builder.
Data sources:
- Elevation: ETOPO 2022 60 arc-second (NOAA) — GeoTIFF
- Temperature: WorldClim v2.1 annual mean (10 arc-min) — GeoTIFF
- Precipitation: WorldClim v2.1 annual total (10 arc-min) — GeoTIFF
- Rivers: Natural Earth 10m rivers — GeoJSON
All sources are equirectangular with col 0 = 180°W. ETOPO and WorldClim
use col 0 = 180°W natively. Natural Earth uses -180 to 180 longitude.
"""
import json
import zipfile
import numpy as np
from pathlib import Path
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array,
resample_to_grid, normalize_01, compute_sea_level,
compute_hillshade, assemble_terrain,
)
from tooling.core import console
# ─── Data source URLs ───────────────────────────────────────────────────────
# ETOPO 2022 60 arc-second — surface elevation (ice surface, not bedrock)
# ~130 MB GeoTIFF, 21600 x 10800, int16 metres
ETOPO_URL = "https://www.ngdc.noaa.gov/mgg/global/relief/ETOPO2022/data/60s/60s_surface_elev_gtif/ETOPO_2022_v1_60s_N90W180_surface.tif"
ETOPO_FILE = "ETOPO_2022_v1_60s_N90W180_surface.tif"
# WorldClim v2.1 — 10 arc-minute resolution (migrated to geodata.ucdavis.edu)
# Temperature: mean annual, °C × 10 (int16), in a zip
WCLIM_TEMP_URL = "https://geodata.ucdavis.edu/climate/worldclim/2_1/base/wc2.1_10m_tavg.zip"
WCLIM_TEMP_FILE = "wc2.1_10m_tavg.zip"
# Precipitation: annual total mm (int16), in a zip
WCLIM_PREC_URL = "https://geodata.ucdavis.edu/climate/worldclim/2_1/base/wc2.1_10m_prec.zip"
WCLIM_PREC_FILE = "wc2.1_10m_prec.zip"
# Natural Earth 10m rivers — GeoJSON from GitHub
RIVERS_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_rivers_lake_centerlines.geojson"
RIVERS_FILE = "ne_10m_rivers_lake_centerlines.geojson"
# Earth physical constants
EARTH_OCEAN_FRACTION = 0.71
EARTH_MIN_ELEV_M = -10994.0 # Mariana Trench
EARTH_MAX_ELEV_M = 8849.0 # Everest
# ─── River filtering ────────────────────────────────────────────────────────
# Rivers to include (smart scatter: 1-2 per continent + Rhine)
INCLUDED_RIVERS = {
# Europe
"Danube", "Volga", "Rhine",
# North America
"Mississippi", "St. Lawrence",
# South America
"Amazon", "Paraná",
# Africa
"Nile", "Congo",
# West Asia
"Tigris",
# East/South Asia
"Yangtze", "Ganges", "Mekong",
# Australia
"Murray",
}
# Fuzzy matching — some NE names differ slightly
RIVER_NAME_ALIASES = {
"Parana": "Paraná",
"Chang Jiang": "Yangtze",
"Huang He": "Yellow",
"Ganga": "Ganges",
"Nil": "Nile",
"Danau": "Danube",
"Donau": "Danube",
"Rhin": "Rhine",
"Rhein": "Rhine",
"Saint Lawrence": "St. Lawrence",
"St Lawrence": "St. Lawrence",
"Río Paraná": "Paraná",
"Rio Parana": "Paraná",
}
def _match_river_name(feature_name: str) -> str:
"""Check if a Natural Earth river name matches our included set."""
if not feature_name:
return None
name = feature_name.strip()
# Direct match
if name in INCLUDED_RIVERS:
return name
# Alias match
if name in RIVER_NAME_ALIASES:
alias = RIVER_NAME_ALIASES[name]
if alias in INCLUDED_RIVERS:
return alias
# Substring match (e.g. "Mississippi River" contains "Mississippi")
for included in INCLUDED_RIVERS:
if included.lower() in name.lower() or name.lower() in included.lower():
return included
return None
# ─── Data loaders ───────────────────────────────────────────────────────────
def _load_etopo() -> np.ndarray:
"""Load ETOPO 2022 elevation data, return raw metres array."""
path = ensure_cached(ETOPO_URL, ETOPO_FILE)
console.event(f" loading ETOPO: {path}")
try:
arr = load_tiff_as_array(str(path))
except Exception as e:
raise RuntimeError(
f"Failed to load ETOPO GeoTIFF: {e}\n"
f"If PIL can't read this TIFF, install Pillow with TIFF support "
f"or convert to raw binary."
) from e
console.event(f" ETOPO shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def _load_worldclim_temperature() -> np.ndarray:
"""
Load WorldClim v2.1 annual mean temperature.
Returns temperature in Kelvin at native resolution.
"""
zip_path = ensure_cached(WCLIM_TEMP_URL, WCLIM_TEMP_FILE)
console.event(f" loading WorldClim temperature: {zip_path}")
# The zip contains monthly TIFFs (tavg_01.tif to tavg_12.tif).
# Compute annual mean from all 12 months.
cache_dir = zip_path.parent
monthly_sum = None
count = 0
with zipfile.ZipFile(zip_path) as zf:
tif_names = sorted([n for n in zf.namelist() if n.endswith(".tif")])
for tif_name in tif_names:
extracted = cache_dir / Path(tif_name).name
if not extracted.exists():
zf.extract(tif_name, cache_dir)
# Handle nested paths in zip
nested = cache_dir / tif_name
if nested != extracted and nested.exists():
nested.rename(extracted)
try:
arr = load_tiff_as_array(str(extracted))
except Exception:
# Try the nested path
nested = cache_dir / tif_name
if nested.exists():
arr = load_tiff_as_array(str(nested))
else:
continue
# Replace nodata with NaN
arr[arr < -999] = np.nan
if monthly_sum is None:
monthly_sum = arr.copy()
else:
monthly_sum += arr
count += 1
if count == 0:
raise RuntimeError("No temperature TIFFs found in WorldClim archive")
# Annual mean (WorldClim tavg is °C × 10)
temp_C = (monthly_sum / count) / 10.0
# Convert to Kelvin
temp_K = temp_C + 273.15
# Replace NaN (ocean/nodata) with a reasonable ocean temperature
temp_K = np.nan_to_num(temp_K, nan=288.0)
console.event(f" WorldClim temp shape: {temp_K.shape}, "
f"range: [{np.nanmin(temp_K):.0f}, {np.nanmax(temp_K):.0f}] K")
return temp_K
def _load_worldclim_precipitation() -> np.ndarray:
"""
Load WorldClim v2.1 annual precipitation (sum of 12 months).
Returns precipitation in mm/year at native resolution.
"""
zip_path = ensure_cached(WCLIM_PREC_URL, WCLIM_PREC_FILE)
console.event(f" loading WorldClim precipitation: {zip_path}")
cache_dir = zip_path.parent
annual_sum = None
with zipfile.ZipFile(zip_path) as zf:
tif_names = sorted([n for n in zf.namelist() if n.endswith(".tif")])
for tif_name in tif_names:
extracted = cache_dir / Path(tif_name).name
if not extracted.exists():
zf.extract(tif_name, cache_dir)
nested = cache_dir / tif_name
if nested != extracted and nested.exists():
nested.rename(extracted)
try:
arr = load_tiff_as_array(str(extracted))
except Exception:
nested = cache_dir / tif_name
if nested.exists():
arr = load_tiff_as_array(str(nested))
else:
continue
arr[arr < -999] = 0.0
if annual_sum is None:
annual_sum = arr.copy()
else:
annual_sum += arr
if annual_sum is None:
raise RuntimeError("No precipitation TIFFs found in WorldClim archive")
console.event(f" WorldClim precip shape: {annual_sum.shape}, "
f"range: [{annual_sum.min():.0f}, {annual_sum.max():.0f}] mm/yr")
return annual_sum
def _load_rivers_geojson() -> list:
"""
Load Natural Earth rivers GeoJSON and extract polylines for included rivers.
Returns list of (name, [(row, col), ...]) in grid coordinates.
"""
path = ensure_cached(RIVERS_URL, RIVERS_FILE)
console.event(f" loading rivers: {path}")
with open(path) as f:
geojson = json.load(f)
rivers = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
fname = props.get("name") or props.get("name_en") or ""
matched = _match_river_name(fname)
if not matched:
continue
geom = feature.get("geometry", {})
geom_type = geom.get("type", "")
coords_list = []
if geom_type == "LineString":
coords_list = [geom["coordinates"]]
elif geom_type == "MultiLineString":
coords_list = geom["coordinates"]
else:
continue
for coords in coords_list:
path_grid = []
for lon, lat in coords:
# Convert lon/lat to grid coordinates
# Grid: row 0 = 90°N, row 255 = 90°S
# col 0 = 180°W, col 511 = 180°E
row = int((90.0 - lat) / 180.0 * GRID_H)
col = int((lon + 180.0) / 360.0 * GRID_W)
row = max(0, min(GRID_H - 1, row))
col = max(0, min(GRID_W - 1, col))
# Deduplicate: skip if same grid cell as previous point.
# Natural Earth has hundreds of lon/lat points per river,
# many of which land on the same 512x256 cell. Without
# dedup, the renderer sees len(path)=300 and draws width 6.
if path_grid and path_grid[-1] == (row, col):
continue
path_grid.append((row, col))
if len(path_grid) >= 2:
rivers.append((matched, path_grid))
# Deduplicate: keep longest segment per river name
by_name = {}
for name, path in rivers:
if name not in by_name or len(path) > len(by_name[name]):
by_name[name] = path
console.event(f" matched {len(by_name)} rivers: {', '.join(sorted(by_name.keys()))}")
return [(name, path) for name, path in by_name.items()]
# ─── Main builder ───────────────────────────────────────────────────────────
def build_terrain(body_def: dict) -> dict:
"""
Build Earth terrain dict from real-world data.
Returns the same dict format as planet_simulation.simulate().
"""
from tooling.domains.atlas.planet.planet_simulation import compute_biome
console.event(" Earth: loading real-world data...")
# ── 1. Elevation ────────────────────────────────────────────────────
etopo_raw = _load_etopo()
# ETOPO 2022 N90W180 is already col 0 = 180°W — no shift needed
# Resample to grid
elevation_m = resample_to_grid(etopo_raw, GRID_H, GRID_W, order=1)
# Normalise to [0, 1]
elevation = normalize_01(elevation_m, EARTH_MIN_ELEV_M, EARTH_MAX_ELEV_M)
# Sea level: Earth's ocean fraction is ~0.71
sea_level = compute_sea_level(elevation, EARTH_OCEAN_FRACTION)
surface_water = elevation < sea_level
console.event(f" elevation: sea_level={sea_level:.4f}, "
f"ocean={surface_water.sum()}/{GRID_H*GRID_W} cells")
# ── 2. Temperature ──────────────────────────────────────────────────
temp_raw_K = _load_worldclim_temperature()
# WorldClim uses col 0 = 180°W — no shift needed
temperature_K = resample_to_grid(temp_raw_K, GRID_H, GRID_W, order=1)
# Fill ocean areas with latitude-dependent ocean temperature
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
lat_abs = np.abs(v - 0.5) * 2.0 # 0 at equator, 1 at poles
ocean_temp = 301.0 - lat_abs[:, np.newaxis] * 30.0 # ~28°C equator, ~-2°C poles
temperature_K = np.where(surface_water, ocean_temp, temperature_K)
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
precip_raw = _load_worldclim_precipitation()
# WorldClim uses col 0 = 180°W — no shift needed
precip = resample_to_grid(precip_raw, GRID_H, GRID_W, order=1)
# Normalise to [0, 1] — global max is ~10000 mm/yr (tropical rainforest)
moisture = normalize_01(precip, 0.0, 6000.0)
# Ocean moisture = high (drives adjacent land humidity)
moisture = np.where(surface_water, 0.9, moisture)
console.event(f" moisture: [{moisture.min():.2f}, {moisture.max():.2f}]")
# ── 4. Biome classification ─────────────────────────────────────────
# Use the existing Whittaker table with real temperature and moisture
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
n_biomes = len(np.unique(biome))
console.event(f" biomes: {n_biomes} classes present")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
# ── 6. Rivers ───────────────────────────────────────────────────────
named_rivers = _load_rivers_geojson()
# Clip rivers: stop each path when it hits surface water.
# Rivers like the Amazon/Nile/Rhine otherwise draw through seas.
clipped = []
for name, path in named_rivers:
clipped_path = []
for r, c in path:
if surface_water[r, c]:
break
clipped_path.append((r, c))
if len(clipped_path) >= 2:
clipped.append((name, clipped_path))
n_orig = len(named_rivers)
n_kept = len(clipped)
console.event(f" rivers: {n_kept}/{n_orig} kept after water clipping")
named_rivers = clipped
rivers = [path for _, path in named_rivers]
# ── 7. Assemble ─────────────────────────────────────────────────────
terrain = assemble_terrain(
elevation=elevation,
temperature_K=temperature_K,
moisture=moisture,
biome=biome,
surface_water=surface_water,
hillshade=hillshade,
rivers=rivers,
sea_level=sea_level,
)
# Store river names for the marker overlay
terrain["_river_names"] = {i: name for i, (name, _) in enumerate(named_rivers)}
return terrain
@@ -0,0 +1,26 @@
"""
Gas giant body definition helpers for Jupiter, Saturn, Uranus, Neptune.
Gas giants have no solid surface — the existing planet_renderer._render_gas_giant()
handles band patterns procedurally. This module only provides configuration
validation and body_def enhancement. No terrain dict is produced.
The actual overrides are in sol_overrides.json and applied by the body
definition parser. This module exists for future enhancement (ring tuning,
storm placement, etc).
"""
from tooling.core import console
def validate_gas_giant_def(body_def: dict) -> bool:
"""Check that a gas giant body_def has required fields for rendering."""
pc = body_def.get("planet_class", "")
if "gas_giant" not in pc and pc not in ("gas_giant",):
return False
gg = body_def.get("gas_giant", {})
if not gg.get("band_palette"):
console.event(f"{body_def['id']} missing gas_giant.band_palette", level="warn")
return False
return True
@@ -0,0 +1,141 @@
"""
Ice moon terrain builder — Europa, Ganymede, Callisto, Enceladus.
These bodies lack high-quality global DEMs. We use available mosaics
(albedo/reflectance) to derive synthetic elevation:
- Bright = ice ridges/highlands (high)
- Dark = mare/chaos terrain/craters (low)
Each moon gets specific temperature and appearance tuning.
"""
import numpy as np
from scipy.ndimage import gaussian_filter
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# ─── Per-moon configuration ─────────────────────────────────────────────────
MOON_CONFIG = {
"GJ0f-2": { # Europa
"name": "Europa",
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/3c79b3867c0dc5ec2ea33e485a079e58_europa_voyager_galileo_ssi_global_mosaic_500m.jpg",
"mosaic_file": "europa_galileo_mosaic.jpg",
"base_temp_K": 102.0,
"lat_gradient_K": 10.0,
"sigma": 2.0, # smooth albedo → elevation
"invert_albedo": False, # bright = ridges (high)
},
"GJ0f-3": { # Ganymede
"name": "Ganymede",
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/f60b3c06c92f59834f2d4cf9b46cb8f7_ganymede_voyager_galileo_global_mosaic_1km.jpg",
"mosaic_file": "ganymede_galileo_mosaic.jpg",
"base_temp_K": 110.0,
"lat_gradient_K": 15.0,
"sigma": 3.0,
"invert_albedo": False,
},
"GJ0f-4": { # Callisto
"name": "Callisto",
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/26b4e80eeb35d46c53d56cded56deeef_callisto_voyager_galileo_global_mosaic_1km.jpg",
"mosaic_file": "callisto_galileo_mosaic.jpg",
"base_temp_K": 115.0,
"lat_gradient_K": 12.0,
"sigma": 4.0,
"invert_albedo": False,
},
"GJ0g-2": { # Enceladus
"name": "Enceladus",
"mosaic_url": "https://astrogeology.usgs.gov/cache/images/1e9fede316c8c47fdc0b96f4c09e4915_enceladus_cassini_iss_global_mosaic_100m.jpg",
"mosaic_file": "enceladus_cassini_mosaic.jpg",
"base_temp_K": 75.0,
"lat_gradient_K": 8.0,
"sigma": 2.0,
"invert_albedo": False,
},
}
def _load_mosaic_as_elevation(config: dict) -> np.ndarray:
"""Load a global mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(config["mosaic_url"], config["mosaic_file"])
console.event(f" loading {config['name']} mosaic: {path}")
albedo = load_image_as_elevation(str(path),
invert=config.get("invert_albedo", False))
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
except Exception as e:
console.event(f"{config['name']} mosaic unavailable ({e}), synthetic", level="warn")
albedo = _synthetic_ice_terrain(config["name"])
# Smooth albedo to create plausible topography
sigma = config.get("sigma", 3.0)
elevation = gaussian_filter(albedo, sigma=sigma)
return normalize_01(elevation)
def _synthetic_ice_terrain(name: str) -> np.ndarray:
"""Generate synthetic ice moon terrain if mosaic unavailable."""
seed = hash(name) & 0xFFFFFFFF
rng = np.random.default_rng(seed)
base = rng.random((GRID_H, GRID_W)).astype(np.float32)
base = gaussian_filter(base, sigma=6.0)
# Add craters
for _ in range(20):
cy, cx = rng.integers(0, GRID_H), rng.integers(0, GRID_W)
r = rng.integers(5, 20)
y, x = np.ogrid[-cy:GRID_H-cy, -cx:GRID_W-cx]
mask = x*x + y*y <= r*r
base[mask] *= 0.5
return normalize_01(base)
def build_terrain(body_def: dict) -> dict:
"""Build ice moon terrain dict from mosaic data."""
from tooling.domains.atlas.planet.planet_simulation import compute_biome
body_id = body_def["id"]
config = MOON_CONFIG.get(body_id)
if config is None:
raise ValueError(f"No ice moon config for {body_id}")
console.event(f" {config['name']}: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_mosaic_as_elevation(config)
sea_level = 0.0
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
# ── 2. Temperature ──────────────────────────────────────────────────
temperature_K = temperature_grid_analytical(
base_T_K=config["base_temp_K"],
elevation=elevation,
lapse_rate_K_per_unit=5.0,
lat_gradient_K=config["lat_gradient_K"],
)
temperature_K = np.maximum(temperature_K, 40.0)
# ── 3. Moisture ─────────────────────────────────────────────────────
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# ── 4. Biome ────────────────────────────────────────────────────────
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
return assemble_terrain(
elevation=elevation, temperature_K=temperature_K,
moisture=moisture, biome=biome,
surface_water=surface_water, hillshade=hillshade,
rivers=[], sea_level=sea_level,
)
@@ -0,0 +1,115 @@
"""
Io (GJ0f-1) terrain builder.
Io is the most volcanically active body in the solar system due to
tidal heating from Jupiter. Surface is covered in sulfur and volcanic
deposits. No published global DEM exists at useful resolution — we use
the Galileo/Voyager global mosaic (albedo) to derive synthetic elevation.
Data source:
- Surface: USGS Io Galileo/Voyager global mosaic
- Elevation: synthetic from albedo (dark = caldera/lava, bright = sulfur)
Properties:
- Surface temp: ~130K background, 400-1800K at volcanic hotspots
- planet_class: "volcanic", atmosphere: "none"
"""
import numpy as np
from scipy.ndimage import gaussian_filter
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# Io global mosaic (Galileo SSI + Voyager) — JPEG from USGS
# If direct download isn't available, fall back to procedural
IO_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/bf08a5b6fa0c2ed73117dc1b6c516fa8_io_galileo_voyager_global_mosaic_1km.jpg"
IO_MOSAIC_FILE = "io_galileo_mosaic.jpg"
IO_BACKGROUND_TEMP_K = 130.0
IO_HOTSPOT_TEMP_K = 600.0
def _load_io_mosaic() -> np.ndarray:
"""Load Io global mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(IO_MOSAIC_URL, IO_MOSAIC_FILE)
console.event(f" loading Io mosaic: {path}")
albedo = load_image_as_elevation(str(path), invert=False)
except Exception as e:
console.event(f"Io mosaic unavailable ({e}), generating synthetic", level="warn")
return _synthetic_io_terrain()
# Resample to grid
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
# Convert albedo to elevation:
# Dark regions (low albedo) = calderas/lava flows = low elevation
# Bright regions (high albedo) = sulfur deposits = high elevation
# Smooth to create plausible topography
elevation = gaussian_filter(albedo, sigma=3.0)
elevation = normalize_01(elevation)
return elevation
def _synthetic_io_terrain() -> np.ndarray:
"""Generate synthetic Io-like terrain if mosaic unavailable."""
rng = np.random.default_rng(42)
base = rng.random((GRID_H, GRID_W)).astype(np.float32)
base = gaussian_filter(base, sigma=8.0)
# Add volcanic calderas (circular depressions)
for _ in range(30):
cy, cx = rng.integers(0, GRID_H), rng.integers(0, GRID_W)
r = rng.integers(3, 15)
y, x = np.ogrid[-cy:GRID_H-cy, -cx:GRID_W-cx]
mask = x*x + y*y <= r*r
base[mask] *= 0.3
return normalize_01(base)
def build_terrain(body_def: dict) -> dict:
"""Build Io terrain dict."""
from tooling.domains.atlas.planet.planet_simulation import compute_biome
console.event(" Io: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_io_mosaic()
sea_level = 0.0
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
# ── 2. Temperature ──────────────────────────────────────────────────
# Background ~130K, volcanic hotspots much hotter
temperature_K = temperature_grid_analytical(
base_T_K=IO_BACKGROUND_TEMP_K,
elevation=elevation,
lapse_rate_K_per_unit=-200.0, # low elevation = hot (lava)
lat_gradient_K=10.0,
)
# Volcanic hotspots: low-elevation areas are hot
hotspot_mask = elevation < 0.25
temperature_K[hotspot_mask] += 300.0
# ── 3. Moisture ─────────────────────────────────────────────────────
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# ── 4. Biome ────────────────────────────────────────────────────────
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
return assemble_terrain(
elevation=elevation, temperature_K=temperature_K,
moisture=moisture, biome=biome,
surface_water=surface_water, hillshade=hillshade,
rivers=[], sea_level=sea_level,
)
@@ -0,0 +1,132 @@
"""
Luna (GJ0d-1) terrain builder.
Data source:
- Elevation: LOLA (Lunar Orbiter Laser Altimeter) DEM
Available at various resolutions from USGS Astrogeology.
We use the 4ppd (1440×720) or 16ppd version.
Luna properties:
- Min elevation: ~-9100 m (South Pole-Aitken basin)
- Max elevation: ~10786 m (near Engel'gardt crater rim)
- No atmosphere, no water
- body_type: "moon" → uses lunar biome palette (classes 31/32/33)
"""
import numpy as np
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_raw_binary,
resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# LOLA GDR — available as PDS IMG files
# 4ppd (1440 × 720) — compact version
LOLA_4PPD_URL = "https://pds-geosciences.wustl.edu/lro/lro-l-lola-3-rdr-v1/lrolol_1xxx/data/lola_gdr/cylindrical/img/ldem_4.img"
LOLA_4PPD_FILE = "lola_gdr_4ppd.img"
LOLA_4PPD_W = 1440
LOLA_4PPD_H = 720
# 16ppd (5760 × 2880) — higher quality
LOLA_16PPD_URL = "https://pds-geosciences.wustl.edu/lro/lro-l-lola-3-rdr-v1/lrolol_1xxx/data/lola_gdr/cylindrical/img/ldem_16.img"
LOLA_16PPD_FILE = "lola_gdr_16ppd.img"
LOLA_16PPD_W = 5760
LOLA_16PPD_H = 2880
# Luna physical constants
LUNA_MIN_ELEV_M = -9100.0
LUNA_MAX_ELEV_M = 10786.0
LUNA_EQUATORIAL_TEMP_K = 220.0 # mean dayside ~220K
LUNA_POLAR_TEMP_K = 100.0 # permanently shadowed craters ~40K, average ~100K
def _load_lola(use_16ppd: bool = False) -> np.ndarray:
"""Load LOLA DEM, return elevation in metres."""
if use_16ppd:
url, filename, w, h = LOLA_16PPD_URL, LOLA_16PPD_FILE, LOLA_16PPD_W, LOLA_16PPD_H
else:
url, filename, w, h = LOLA_4PPD_URL, LOLA_4PPD_FILE, LOLA_4PPD_W, LOLA_4PPD_H
path = ensure_cached(url, filename)
console.event(f" loading LOLA: {path} ({w}x{h})")
# LOLA GDR: little-endian int16 (LSB_INTEGER per PDS label)
# with a scaling factor of 0.5 metres.
try:
arr = load_raw_binary(str(path), w, h, dtype="<i2", offset=0)
# LOLA int16 values are in units of 0.5m (scale factor 0.5)
arr = arr * 0.5
except ValueError:
# If int16 doesn't work, try float32
arr = load_raw_binary(str(path), w, h, dtype="<f4", offset=0)
# Handle nodata
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
console.event(f" LOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def build_terrain(body_def: dict) -> dict:
"""Build Luna terrain dict from LOLA data."""
from tooling.domains.atlas.planet.planet_simulation import compute_biome
console.event(" Luna: loading LOLA data...")
# ── 1. Elevation ────────────────────────────────────────────────────
lola_raw = _load_lola(use_16ppd=False)
# LOLA cylindrical: col 0 = 0° longitude — shift to 180°W
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
lola_shifted = greenwich_to_dateline(lola_raw)
elevation_m = resample_to_grid(lola_shifted, GRID_H, GRID_W, order=1)
elevation = normalize_01(elevation_m, LUNA_MIN_ELEV_M, LUNA_MAX_ELEV_M)
# No liquid — sea level at 0
sea_level = 0.0
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
console.event(" elevation normalised")
# ── 2. Temperature ──────────────────────────────────────────────────
temperature_K = temperature_grid_analytical(
base_T_K=LUNA_EQUATORIAL_TEMP_K,
elevation=elevation,
lapse_rate_K_per_unit=10.0,
lat_gradient_K=120.0, # huge contrast equator to poles
)
# Clamp minimum
temperature_K = np.maximum(temperature_K, 40.0)
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# ── 4. Biome ────────────────────────────────────────────────────────
# body_type: "moon" + atmosphere: "none" → lunar palette (31/32/33)
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
console.event(f" biomes: {len(np.unique(biome))} classes")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
# ── 6. Assemble ─────────────────────────────────────────────────────
return assemble_terrain(
elevation=elevation,
temperature_K=temperature_K,
moisture=moisture,
biome=biome,
surface_water=surface_water,
hillshade=hillshade,
rivers=[],
sea_level=sea_level,
)
@@ -0,0 +1,190 @@
"""
Mars (GJ0e) terrain builder.
Data source:
- Elevation: MOLA MEGDR (Mars Orbiter Laser Altimeter)
PDS format, big-endian int16, metres relative to areoid.
Available at multiple resolutions. We use 4ppd (1440×720)
or 16ppd (5760×2880) — both small enough to download quickly.
Mars properties:
- Min elevation: ~-8200 m (Hellas Basin)
- Max elevation: ~21229 m (Olympus Mons)
- Polar ice caps: CO2 + water ice
- Thin atmosphere (6 mbar) — classified as "thin" in body_def
- Almost no liquid water (hydrosphere: "ice")
"""
import numpy as np
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_raw_binary, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# MOLA MEGDR — 4 pixels per degree (1440 × 720), big-endian int16
# Each pixel = metres relative to Mars areoid
# PDS binary with no header (data starts at byte 0 for .img files)
MOLA_4PPD_URL = "https://pds-geosciences.wustl.edu/mgs/mgs-m-mola-5-megdr-l3-v1/mgsl_300x/meg004/megt90n000cb.img"
MOLA_4PPD_FILE = "mola_megdr_4ppd.img"
MOLA_4PPD_W = 1440
MOLA_4PPD_H = 720
# Alternative: 16ppd (5760 × 2880) for higher quality
MOLA_16PPD_URL = "https://pds-geosciences.wustl.edu/mgs/mgs-m-mola-5-megdr-l3-v1/mgsl_300x/meg016/megt90n000eb.img"
MOLA_16PPD_FILE = "mola_megdr_16ppd.img"
MOLA_16PPD_W = 5760
MOLA_16PPD_H = 2880
# Mars physical constants
MARS_MIN_ELEV_M = -8200.0 # Hellas Basin
MARS_MAX_ELEV_M = 21229.0 # Olympus Mons summit
# Real Mars temperatures — we don't fudge these. Mars colour comes from
# ferric biome classes (34/35/36) applied based on iron oxide substrate.
MARS_EQUATORIAL_TEMP_K = 215.0 # daytime average near equator
MARS_POLAR_TEMP_K = 150.0
MARS_OCEAN_FRACTION = 0.0 # no liquid water (ice only)
# Ferric biome class IDs (from biomes.toml)
FERRIC_DUST = 34
FERRIC_HIGHLAND = 35
FERRIC_LOWLAND = 36
def _load_mola(use_16ppd: bool = False) -> np.ndarray:
"""Load MOLA DEM, return elevation in metres."""
if use_16ppd:
url, filename, w, h = MOLA_16PPD_URL, MOLA_16PPD_FILE, MOLA_16PPD_W, MOLA_16PPD_H
else:
url, filename, w, h = MOLA_4PPD_URL, MOLA_4PPD_FILE, MOLA_4PPD_W, MOLA_4PPD_H
path = ensure_cached(url, filename)
console.event(f" loading MOLA: {path} ({w}x{h})")
# MOLA MEGDR: big-endian int16, metres, no header
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
# MOLA nodata is typically 32767 or -32768
arr[arr > 30000] = 0.0
arr[arr < -30000] = 0.0
console.event(f" MOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def build_terrain(body_def: dict) -> dict:
"""Build Mars terrain dict from MOLA data."""
console.event(" Mars: loading MOLA data...")
# ── 1. Elevation ────────────────────────────────────────────────────
mola_raw = _load_mola(use_16ppd=False)
# MOLA is col 0 = 0° longitude — shift to col 0 = 180°W
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
mola_shifted = greenwich_to_dateline(mola_raw)
# Resample to grid
elevation_m = resample_to_grid(mola_shifted, GRID_H, GRID_W, order=1)
# Normalise to [0, 1]
elevation = normalize_01(elevation_m, MARS_MIN_ELEV_M, MARS_MAX_ELEV_M)
console.event(" elevation normalised")
# ── 2. Temperature ──────────────────────────────────────────────────
# Analytical: equatorial ~210K, polar ~150K, elevation lapse
temperature_K = temperature_grid_analytical(
base_T_K=MARS_EQUATORIAL_TEMP_K,
elevation=elevation,
lapse_rate_K_per_unit=30.0,
lat_gradient_K=60.0,
)
# Polar ice caps: very cold at high latitudes
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
lat_abs = np.abs(v - 0.5) * 2.0
polar_rows = lat_abs > 0.75
temperature_K[polar_rows, :] = np.minimum(temperature_K[polar_rows, :], 155.0)
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
# Mars has almost no moisture — thin atmosphere
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# Slight moisture near polar caps (water ice)
moisture[polar_rows, :] = 0.1
# ── 4. Terraformed water bodies ─────────────────────────────────────
# Lore: 800 years of partial terraforming. Water pools in the deepest
# basins (Hellas, Utopia, Isidis). ~2% of surface is now liquid water.
from tooling.domains.atlas.planet.sol_data.shared import compute_sea_level as _compute_sl
from scipy.ndimage import binary_dilation
TERRAFORM_OCEAN_FRAC = 0.02 # 2% water coverage
sea_level = _compute_sl(elevation, TERRAFORM_OCEAN_FRAC)
surface_water = elevation < sea_level
# Don't flood polar regions — those stay as ice caps, not lakes
surface_water[polar_rows, :] = False
n_water = int(surface_water.sum())
console.event(f" terraformed water: {n_water} cells "
f"(sea_level={sea_level:.4f})")
# ── 5. Biome classification ─────────────────────────────────────────
# Mars biome is built directly — compute_biome() would classify
# everything as ice at these temperatures.
biome = np.full((GRID_H, GRID_W), FERRIC_DUST, dtype=np.int8)
# Elevation-based ferric variation
biome[elevation > 0.55] = FERRIC_HIGHLAND # volcanic highlands
biome[elevation < 0.25] = FERRIC_LOWLAND # basin floors
# Polar ice caps
biome[polar_rows, :] = 17 # ice/snow
# Terraformed green fringe around water bodies — vegetation band
# where the thicker local atmosphere and water access allow plants.
# ~5 cell band around each water body.
veg_ring = binary_dilation(surface_water, iterations=5) & ~surface_water
# Don't put vegetation at poles
veg_ring[polar_rows, :] = False
biome[veg_ring] = 12 # shrubland (olive green — sparse terraformed vegetation)
# Inner vegetation ring (closer to water = lusher)
inner_ring = binary_dilation(surface_water, iterations=2) & ~surface_water
inner_ring[polar_rows, :] = False
biome[inner_ring] = 8 # temperate grassland (greener)
# Ocean depth bands for water bodies
if surface_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
biome[surface_water & (depth < 0.15)] = 2 # shallow
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1 # mid
biome[surface_water & (depth >= 0.50)] = 0 # deep
n_ice = int((biome == 17).sum())
n_ferric = int(((biome >= 34) & (biome <= 36)).sum())
n_veg = int(((biome == 8) | (biome == 12)).sum())
n_ocean = int(((biome >= 0) & (biome <= 2)).sum())
console.event(f" biomes: {len(np.unique(biome))} classes "
f"(ferric={n_ferric}, ice={n_ice}, veg={n_veg}, water={n_ocean})")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
# ── 6. Assemble ─────────────────────────────────────────────────────
return assemble_terrain(
elevation=elevation,
temperature_K=temperature_K,
moisture=moisture,
biome=biome,
surface_water=surface_water,
hillshade=hillshade,
rivers=[], # no rivers on Mars
sea_level=sea_level,
)
@@ -0,0 +1,116 @@
"""
Mercury (GJ0b) terrain builder.
Data source:
- Elevation: MESSENGER DEM from USGS Astrogeology
665m/px global DEM, GeoTIFF.
Mercury properties:
- Min elevation: ~-5380 m
- Max elevation: ~4480 m
- No atmosphere, no water
- Extreme temperature range: ~100K (night) to ~700K (day)
- body_type: "planet", planet_class: "barren", atmosphere: "none"
"""
import numpy as np
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array, load_raw_binary,
resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# MESSENGER DEM — try PDS binary first (compact), fall back to USGS GeoTIFF
MESSENGER_PDS_URL = "https://pds-geosciences.wustl.edu/messenger/mess-h-mdis_mla-6-dem-elevation-v1/messdmdem_1001/data/global_dem_16ppd.img"
MESSENGER_PDS_FILE = "messenger_dem_16ppd.img"
MESSENGER_PDS_W = 5760
MESSENGER_PDS_H = 2880
# USGS GeoTIFF fallback (~506 MB, but PIL-loadable)
MESSENGER_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Mercury_Messenger_USGS_DEM_Global_665m_v2.tif"
MESSENGER_TIFF_FILE = "Mercury_Messenger_USGS_DEM_Global_665m_v2.tif"
MERCURY_MIN_ELEV_M = -5380.0
MERCURY_MAX_ELEV_M = 4480.0
MERCURY_EQUATORIAL_TEMP_K = 440.0 # mean dayside
MERCURY_POLAR_TEMP_K = 200.0
def _load_messenger() -> np.ndarray:
"""Load MESSENGER DEM, return elevation in metres."""
# Try PDS binary first (compact ~33 MB)
try:
path = ensure_cached(MESSENGER_PDS_URL, MESSENGER_PDS_FILE)
console.event(f" loading MESSENGER PDS: {path}")
arr = load_raw_binary(str(path), MESSENGER_PDS_W, MESSENGER_PDS_H,
dtype=">i2", offset=0)
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
console.event(f" MESSENGER range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e:
console.event(f" PDS load failed ({e}), trying USGS GeoTIFF...")
# Fallback: USGS GeoTIFF (~506 MB)
try:
path = ensure_cached(MESSENGER_TIFF_URL, MESSENGER_TIFF_FILE)
console.event(f" loading MESSENGER GeoTIFF: {path}")
arr = load_tiff_as_array(str(path))
arr[arr < -20000] = 0.0
console.event(f" MESSENGER shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e2:
console.event(f" GeoTIFF also failed ({e2}), using procedural")
return None
def build_terrain(body_def: dict) -> dict:
"""Build Mercury terrain dict from MESSENGER data."""
from tooling.domains.atlas.planet.planet_simulation import compute_biome
console.event(" Mercury: loading MESSENGER data...")
# ── 1. Elevation ────────────────────────────────────────────────────
raw = _load_messenger()
if raw is None:
from tooling.domains.atlas.planet.planet_simulation import simulate
return simulate(body_def)
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
shifted = greenwich_to_dateline(raw)
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
elevation = normalize_01(elevation_m, MERCURY_MIN_ELEV_M, MERCURY_MAX_ELEV_M)
sea_level = 0.0
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
# ── 2. Temperature ──────────────────────────────────────────────────
temperature_K = temperature_grid_analytical(
base_T_K=MERCURY_EQUATORIAL_TEMP_K,
elevation=elevation,
lapse_rate_K_per_unit=20.0,
lat_gradient_K=240.0,
)
temperature_K = np.maximum(temperature_K, 100.0)
# ── 3. Moisture ─────────────────────────────────────────────────────
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# ── 4. Biome ────────────────────────────────────────────────────────
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
return assemble_terrain(
elevation=elevation, temperature_K=temperature_K,
moisture=moisture, biome=biome,
surface_water=surface_water, hillshade=hillshade,
rivers=[], sea_level=sea_level,
)
@@ -0,0 +1,248 @@
"""
Shared utilities for loading and processing real-world planetary data.
All loaders produce arrays compatible with the planet_simulation terrain dict:
- Grid size: GRID_H x GRID_W (256 x 512)
- Elevation: float32 [0, 1] normalised
- Temperature: float32 in absolute Kelvin (normalised to [0,1] later)
- Moisture: float32 [0, 1]
- Sea level: float elevation threshold
"""
import math
import numpy as np
from scipy.ndimage import zoom
from PIL import Image
# Planetary DEMs can exceed PIL's default decompression bomb limit
Image.MAX_IMAGE_PIXELS = None
# Match planet_simulation grid
GRID_W = 512
GRID_H = 256
# ─── Loading ────────────────────────────────────────────────────────────────
def load_tiff_as_array(path: str) -> np.ndarray:
"""
Load a GeoTIFF/TIFF as a numpy array via PIL.
PIL handles uncompressed and LZW-compressed TIFFs with 8/16/32-bit
integer or float samples. For multi-band, returns (H, W, bands).
For single-band, returns (H, W).
"""
img = Image.open(path)
arr = np.array(img, dtype=np.float32)
return arr
def load_raw_binary(path: str, width: int, height: int,
dtype: str = ">i2", offset: int = 0) -> np.ndarray:
"""
Load a raw binary raster (PDS IMG, .bin, etc).
Parameters
----------
path : file path
width : number of columns
height : number of rows
dtype : numpy dtype string (e.g. ">i2" for big-endian int16)
offset : byte offset to skip (header size)
"""
dt = np.dtype(dtype)
expected_bytes = width * height * dt.itemsize
with open(path, "rb") as f:
f.seek(offset)
raw = f.read(expected_bytes)
if len(raw) < expected_bytes:
raise ValueError(
f"Expected {expected_bytes} bytes, got {len(raw)}. "
f"Check width/height/dtype/offset."
)
arr = np.frombuffer(raw, dtype=dt).reshape(height, width).astype(np.float32)
return arr
def load_image_as_elevation(path: str, invert: bool = False) -> np.ndarray:
"""
Load a greyscale or RGB image and convert to float32 elevation.
For RGB, uses luminance. For greyscale, uses the single channel.
"""
img = Image.open(path).convert("L")
arr = np.array(img, dtype=np.float32) / 255.0
if invert:
arr = 1.0 - arr
return arr
# ─── Resampling ─────────────────────────────────────────────────────────────
def resample_to_grid(arr: np.ndarray, target_h: int = GRID_H,
target_w: int = GRID_W,
order: int = 1) -> np.ndarray:
"""
Resample a 2D array to target grid size.
order: 0=nearest, 1=bilinear, 3=cubic
"""
if arr.shape == (target_h, target_w):
return arr.astype(np.float32)
zoom_y = target_h / arr.shape[0]
zoom_x = target_w / arr.shape[1]
return zoom(arr, (zoom_y, zoom_x), order=order).astype(np.float32)
# ─── Normalisation ──────────────────────────────────────────────────────────
def normalize_01(arr: np.ndarray, lo: float = None, hi: float = None) -> np.ndarray:
"""Normalise array to [0, 1]."""
if lo is None:
lo = float(arr.min())
if hi is None:
hi = float(arr.max())
if hi - lo < 1e-9:
return np.zeros_like(arr, dtype=np.float32)
return np.clip((arr - lo) / (hi - lo), 0.0, 1.0).astype(np.float32)
def compute_sea_level(elevation: np.ndarray, ocean_fraction: float) -> float:
"""
Compute sea_level threshold such that ocean_fraction of cells are below it.
"""
if ocean_fraction <= 0.0:
return 0.0
if ocean_fraction >= 1.0:
return 1.0
return float(np.percentile(elevation, ocean_fraction * 100.0))
# ─── Longitude shift ────────────────────────────────────────────────────────
def shift_longitude(arr: np.ndarray, shift_cols: int) -> np.ndarray:
"""
Roll array along the longitude (column) axis.
The pipeline uses col 0 = 180°W. If source data uses col 0 = 0° (Greenwich),
shift by half the width to align.
"""
return np.roll(arr, shift_cols, axis=1)
def greenwich_to_dateline(arr: np.ndarray) -> np.ndarray:
"""
Shift from col 0 = 0° (Greenwich) to col 0 = 180°W (dateline).
Standard for most NASA/NOAA global datasets → pipeline convention.
"""
return shift_longitude(arr, arr.shape[1] // 2)
# ─── Hillshade ──────────────────────────────────────────────────────────────
def compute_hillshade(elevation: np.ndarray,
sun_azimuth_deg: float = 315.0,
sun_altitude_deg: float = 45.0) -> np.ndarray:
"""
Compute hillshade from elevation grid. Matches planet_simulation.compute_hillshade().
"""
scale = elevation.shape[1] / 8.0
gy, gx = np.gradient(elevation * scale)
mag = np.sqrt(gx**2 + gy**2 + 1.0)
nx = -gx / mag
ny = -gy / mag
nz = 1.0 / mag
az = math.radians(sun_azimuth_deg)
alt = math.radians(sun_altitude_deg)
lx = math.cos(alt) * math.sin(az)
ly = -math.cos(alt) * math.cos(az)
lz = math.sin(alt)
shade = np.clip(nx * lx + ny * ly + nz * lz, 0.0, 1.0)
return shade.astype(np.float32)
# ─── Analytical temperature models ─────────────────────────────────────────
def temperature_equilibrium_K(luminosity_solar: float, distance_au: float,
albedo: float = 0.3) -> float:
"""
Stefan-Boltzmann equilibrium temperature in Kelvin.
"""
L_sun = 3.828e26 # watts
sigma = 5.670e-8
d_m = distance_au * 1.496e11
T_eq = ((luminosity_solar * L_sun * (1 - albedo)) /
(16 * math.pi * sigma * d_m**2)) ** 0.25
return T_eq
def temperature_grid_analytical(
base_T_K: float,
grid_h: int = GRID_H,
grid_w: int = GRID_W,
elevation: np.ndarray = None,
lapse_rate_K_per_unit: float = 40.0,
lat_gradient_K: float = 60.0,
) -> np.ndarray:
"""
Analytical temperature grid: equator-to-pole gradient + elevation lapse.
Parameters
----------
base_T_K : equatorial temperature in Kelvin
elevation : normalised [0,1] elevation grid (optional)
lapse_rate_K_per_unit: temperature drop per unit elevation
lat_gradient_K : total temperature drop from equator to pole
"""
v = np.linspace(0, 1, grid_h, dtype=np.float32)
lat_frac = np.abs(v - 0.5) * 2.0 # 0 at equator, 1 at poles
lat_temp = lat_frac[:, np.newaxis] * lat_gradient_K # broadcast to (H, W)
temp = np.full((grid_h, grid_w), base_T_K, dtype=np.float32)
temp -= lat_temp
if elevation is not None:
temp -= elevation * lapse_rate_K_per_unit
return temp
# ─── Terrain dict assembly ──────────────────────────────────────────────────
def assemble_terrain(
elevation: np.ndarray,
temperature_K: np.ndarray,
moisture: np.ndarray,
biome: np.ndarray,
surface_water: np.ndarray,
hillshade: np.ndarray,
rivers: list,
sea_level: float,
) -> dict:
"""
Assemble the terrain dict in the format expected by render_heightmap
and render_globe. Temperature is normalised to [0,1] for the output
(matching planet_simulation.simulate() lines 891-893).
"""
H, W = elevation.shape
river_grid = np.zeros((H, W), dtype=bool)
for path in rivers:
for r, c in path:
if 0 <= r < H and 0 <= c < W:
river_grid[r, c] = True
# Normalise temperature to [0,1] for renderer display
t_min, t_max = temperature_K.min(), temperature_K.max()
temp_norm = ((temperature_K - t_min) / (t_max - t_min + 1e-9)).astype(np.float32)
return {
"elevation": elevation.astype(np.float32),
"temperature": temp_norm,
"moisture": moisture.astype(np.float32),
"biome": biome.astype(np.int8),
"surface_water": surface_water.astype(bool),
"hillshade": hillshade.astype(np.float32),
"river_grid": river_grid,
"rivers": rivers,
"sea_level": float(sea_level),
"_grid_w": W,
"_grid_h": H,
}
@@ -0,0 +1,172 @@
"""
Titan (GJ0g-1) terrain builder.
Titan is unique: dense nitrogen atmosphere, methane rain cycle,
methane/ethane lakes and rivers. Surface temperature ~94K uniform.
Data source:
- Surface: Cassini ISS global mosaic (4km resolution)
- Topography: very sparse Cassini radar altimetry (gap-filled)
Since Cassini topographic data is extremely sparse, we use the ISS
mosaic albedo to derive synthetic elevation (similar to ice moons)
with special handling for known methane lake regions.
Properties:
- planet_class: "frozen", atmosphere: "dense", hydrosphere: "rivers"
- Methane lakes primarily near the north pole (Kraken Mare, Ligeia Mare)
"""
import numpy as np
from scipy.ndimage import gaussian_filter
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
)
from tooling.core import console
# Cassini ISS global mosaic
TITAN_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/5e5ba96a58d3b38ee6e7b1e94b8c44e6_titan_iss_p19658_mosaic_global_4km.jpg"
TITAN_MOSAIC_FILE = "titan_cassini_iss_mosaic.jpg"
TITAN_SURFACE_TEMP_K = 94.0 # nearly uniform
TITAN_METHANE_LAKE_FRACTION = 0.02 # ~2% of surface is liquid methane
def _load_titan_mosaic() -> np.ndarray:
"""Load Titan mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(TITAN_MOSAIC_URL, TITAN_MOSAIC_FILE)
console.event(f" loading Titan mosaic: {path}")
albedo = load_image_as_elevation(str(path), invert=False)
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
except Exception as e:
console.event(f"Titan mosaic unavailable ({e}), synthetic", level="warn")
albedo = _synthetic_titan_terrain()
# Dark regions = low (lakes/flat), bright = dunes/highlands
elevation = gaussian_filter(albedo, sigma=3.0)
return normalize_01(elevation)
def _synthetic_titan_terrain() -> np.ndarray:
"""Generate synthetic Titan terrain."""
rng = np.random.default_rng(94)
base = rng.random((GRID_H, GRID_W)).astype(np.float32)
base = gaussian_filter(base, sigma=6.0)
# Titan has equatorial dune fields (higher terrain)
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
equatorial = np.exp(-((v - 0.5) ** 2) / 0.02)
base += equatorial[:, np.newaxis] * 0.3
return normalize_01(base)
def build_terrain(body_def: dict) -> dict:
"""Build Titan terrain dict."""
from tooling.domains.atlas.planet.planet_simulation import compute_biome
console.event(" Titan: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_titan_mosaic()
# Titan has methane lakes — set sea level to create them
# Lakes are concentrated at north polar regions
# Use a low sea level so that only the darkest (lowest) areas become liquid
from tooling.domains.atlas.planet.sol_data.shared import compute_sea_level
sea_level = compute_sea_level(elevation, TITAN_METHANE_LAKE_FRACTION)
surface_water = elevation < sea_level
# Concentrate lakes near north pole (real Titan has lakes mostly 60-90°N)
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
lat_abs = np.abs(v - 0.5) * 2.0 # 0=equator, 1=poles
north_mask = v < 0.2 # north polar region (top 20% of grid = 72-90°N)
# Allow lakes only in polar regions — mask out equatorial/southern "seas"
equatorial_mask = (lat_abs < 0.6)[:, np.newaxis] * np.ones(GRID_W, dtype=bool)
surface_water = surface_water & ~equatorial_mask
console.event(f" methane lakes: {surface_water.sum()} cells")
# ── 2. Temperature ──────────────────────────────────────────────────
# Titan has nearly uniform surface temp due to dense atmosphere + distance
temperature_K = np.full((GRID_H, GRID_W), TITAN_SURFACE_TEMP_K, dtype=np.float32)
# Very slight pole-equator gradient (~2K)
lat_temp = lat_abs[:, np.newaxis] * 2.0
temperature_K -= lat_temp
# ── 3. Moisture ─────────────────────────────────────────────────────
# Titan has a methane humidity cycle — higher moisture near poles
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# Polar moisture (methane humidity)
polar_humid = np.clip(lat_abs[:, np.newaxis] - 0.5, 0, 1) * 0.6
moisture += polar_humid
# Some equatorial humidity (methane drizzle)
equatorial_humid = np.exp(-((v[:, np.newaxis] - 0.5) ** 2) / 0.05) * 0.2
moisture += equatorial_humid
# ── 4. Biome ────────────────────────────────────────────────────────
# Titan at 94K with dense atmosphere goes through Whittaker table
# Everything will classify as ice/snow (class 17) — which is correct
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
# Override: methane lakes should be ocean classes, not ice
# (The biome function sets ocean depth bands for surface_water, which is
# what we want — methane lakes rendered like ocean)
console.event(f" biomes: {len(np.unique(biome))} classes")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
# ── 6. Rivers ───────────────────────────────────────────────────────
# Titan has methane drainage channels — add synthetic ones near poles
rivers = _titan_rivers(elevation, surface_water)
return assemble_terrain(
elevation=elevation, temperature_K=temperature_K,
moisture=moisture, biome=biome,
surface_water=surface_water, hillshade=hillshade,
rivers=rivers, sea_level=sea_level,
)
def _titan_rivers(elevation: np.ndarray, surface_water: np.ndarray) -> list:
"""
Generate synthetic methane drainage channels for Titan.
Simple downhill tracing from high-latitude sources to lakes.
"""
rivers = []
rng = np.random.default_rng(94)
# Start from a few points in the north polar region
for _ in range(5):
r = int(rng.integers(10, 50)) # north polar zone
c = int(rng.integers(0, GRID_W))
path = [(r, c)]
visited = {(r, c)}
for _ in range(200):
if surface_water[r, c]:
break
best_r, best_c = r, c
best_elev = elevation[r, c]
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1),
(-1, -1), (-1, 1), (1, -1), (1, 1)]:
nr, nc = r + dr, (c + dc) % GRID_W
if 0 <= nr < GRID_H and (nr, nc) not in visited:
if elevation[nr, nc] < best_elev:
best_elev = elevation[nr, nc]
best_r, best_c = nr, nc
if (best_r, best_c) == (r, c):
break
r, c = int(best_r), int(best_c)
path.append((r, c))
visited.add((r, c))
if len(path) >= 5:
rivers.append(path)
return rivers
@@ -0,0 +1,122 @@
"""
Venus (GJ0c) terrain builder.
Data source:
- Elevation: Magellan radar altimetry from USGS Astrogeology
Global topography at ~4.6 km/px, PDS format.
Venus properties:
- Surface: volcanic, extremely hot (~735K), dense CO2 atmosphere
- No liquid water, thick clouds
- Min elevation: ~-2000 m (lowlands)
- Max elevation: ~11000 m (Maxwell Montes on Ishtar Terra)
- planet_class: "volcanic", atmosphere: "toxic"
"""
import numpy as np
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array, load_raw_binary, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# Magellan topography — USGS GeoTIFF (reliable, PIL-loadable)
MAGELLAN_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Venus_Magellan_Topography_Global_4641m_v02.tif"
MAGELLAN_TIFF_FILE = "Venus_Magellan_Topography_Global_4641m_v02.tif"
# PDS fallback (raw binary, dimensions may vary)
MAGELLAN_PDS_URL = "https://pds-geosciences.wustl.edu/mgn/mgn-v-rdrs-5-dim-v1/mg_3002/gedr/gtdr/gtdr_shtplt.img"
MAGELLAN_PDS_FILE = "venus_magellan_gtdr.img"
VENUS_MIN_ELEV_M = -2000.0
VENUS_MAX_ELEV_M = 11000.0
VENUS_SURFACE_TEMP_K = 735.0 # nearly uniform due to dense atmosphere
def _load_magellan() -> np.ndarray:
"""Load Magellan topography data."""
# Try USGS GeoTIFF first (reliable, well-defined format)
try:
path = ensure_cached(MAGELLAN_TIFF_URL, MAGELLAN_TIFF_FILE)
console.event(f" loading Magellan GeoTIFF: {path}")
arr = load_tiff_as_array(str(path))
# Handle nodata
arr[arr < -20000] = 0.0
arr[arr > 20000] = 0.0
console.event(f" Magellan shape: {arr.shape}, "
f"range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e:
console.event(f" GeoTIFF failed ({e}), trying PDS binary...")
# PDS fallback — try common dimension/format combinations
try:
path = ensure_cached(MAGELLAN_PDS_URL, MAGELLAN_PDS_FILE)
console.event(f" loading Magellan PDS: {path}")
for w, h in [(4096, 2048), (2048, 1024), (8192, 4096)]:
try:
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
console.event(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
return arr
except ValueError:
continue
except Exception as e3:
console.event(f" PDS also failed ({e3})")
# All sources failed — fall through to procedural generation
console.event("all Magellan sources failed, using procedural", level="warn")
return None
def build_terrain(body_def: dict) -> dict:
"""Build Venus terrain dict from Magellan data."""
from tooling.domains.atlas.planet.planet_simulation import compute_biome
console.event(" Venus: loading Magellan data...")
# ── 1. Elevation ────────────────────────────────────────────────────
raw = _load_magellan()
if raw is None:
# Fall back to procedural simulation
from tooling.domains.atlas.planet.planet_simulation import simulate
return simulate(body_def)
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
shifted = greenwich_to_dateline(raw)
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
elevation = normalize_01(elevation_m, VENUS_MIN_ELEV_M, VENUS_MAX_ELEV_M)
sea_level = 0.0
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
# ── 2. Temperature ──────────────────────────────────────────────────
# Venus has nearly uniform surface temperature due to dense atmosphere
temperature_K = temperature_grid_analytical(
base_T_K=VENUS_SURFACE_TEMP_K,
elevation=elevation,
lapse_rate_K_per_unit=50.0, # slight cooling at altitude
lat_gradient_K=5.0, # almost no lat variation (thick atmo)
)
# ── 3. Moisture ─────────────────────────────────────────────────────
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# ── 4. Biome ────────────────────────────────────────────────────────
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
return assemble_terrain(
elevation=elevation, temperature_K=temperature_K,
moisture=moisture, biome=biome,
surface_water=surface_water, hillshade=hillshade,
rivers=[], sea_level=sea_level,
)
+371
View File
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
"""
sol_import.py — Import real-world data for the Sol system (GJ-0).
Produces the same output format as generate.py (heightmap.png, globe.png,
markers.json, terrain.npz) by constructing terrain dicts from real
planetary science data instead of procedural simulation.
Usage:
reach atlas planet sol-import # All Sol bodies
reach atlas planet sol-import --body GJ0d # Earth only
reach atlas planet sol-import --body GJ0d --body GJ0e # Earth + Mars
reach atlas planet sol-import --download-only # Fetch data, skip rendering
reach atlas planet sol-import --heightmap-size 2048x1024 --globe-size 1024
Data is cached in tooling/domains/atlas/planet/sol_data/.cache/ after first download.
"""
import argparse
import json
import time
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
from tooling.domains.atlas.planet.generate import _build_markers
# Per-body importers (lazy-loaded)
SOL_INDEX = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "index.md"
SOL_OVERRIDES = PLANET_DIR / "sol_overrides.json"
SOL_BODIES_DIR = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
SOL_MARKERS_DIR = PLANET_DIR / "sol_markers"
# Bodies that use real-world data (keyed by body_id → importer module)
REAL_DATA_BODIES = {
"GJ0b": "mercury",
"GJ0c": "venus",
"GJ0d": "earth",
"GJ0d-1": "luna",
"GJ0e": "mars",
"GJ0f-1": "io_moon",
"GJ0f-2": "ice_moons",
"GJ0f-3": "ice_moons",
"GJ0f-4": "ice_moons",
"GJ0g-1": "titan",
"GJ0g-2": "ice_moons",
}
# Bodies that fall through to procedural simulation
PROCEDURAL_BODIES = {"GJ0e-1", "GJ0e-2"}
# Non-renderable body types
SKIP_TYPES = {"asteroid_belt", "oort_cloud"}
def _load_importer(module_name: str):
"""Lazy-import a sol_data.* module."""
import importlib
return importlib.import_module(f"tooling.domains.atlas.planet.sol_data.{module_name}")
def _apply_named_features(markers: dict, body_id: str) -> dict:
"""Overlay named features from sol_markers/ onto auto-detected markers."""
features_map = {
"GJ0d": "earth_features.json",
"GJ0e": "mars_features.json",
"GJ0d-1": "luna_features.json",
}
outer_bodies = {"GJ0f-1", "GJ0f-2", "GJ0f-3", "GJ0f-4",
"GJ0g-1", "GJ0g-2"}
filename = features_map.get(body_id)
if not filename and body_id in outer_bodies:
filename = "outer_features.json"
if not filename:
return markers
features_path = SOL_MARKERS_DIR / filename
if not features_path.exists():
return markers
with open(features_path) as f:
features = json.load(f)
body_features = features.get(body_id, features)
# Name auto-detected oceans by matching center coordinates
if "oceans" in body_features:
for named_ocean in body_features["oceans"]:
best_match = None
best_dist = float("inf")
nc = named_ocean["center"]
for detected in markers["oceans"]:
dc = detected["center"]
dist = (dc[0] - nc[0])**2 + (dc[1] - nc[1])**2
if dist < best_dist:
best_dist = dist
best_match = detected
if best_match and best_dist < 2500: # within ~50 cells
best_match["name"] = named_ocean["name"]
# Name auto-detected mountain ranges by matching peak coordinates
if "mountain_ranges" in body_features:
for named_range in body_features["mountain_ranges"]:
best_match = None
best_dist = float("inf")
nc = named_range.get("peak", named_range.get("center", [0, 0]))
for detected in markers["mountain_ranges"]:
dp = detected.get("peak", detected.get("center", [0, 0]))
dist = (dp[0] - nc[0])**2 + (dp[1] - nc[1])**2
if dist < best_dist:
best_dist = dist
best_match = detected
if best_match and best_dist < 1600: # within ~40 cells
best_match["name"] = named_range["name"]
# Name rivers by matching start/end coordinates
if "rivers" in body_features:
for named_river in body_features["rivers"]:
best_match = None
best_dist = float("inf")
nc = named_river.get("mouth", named_river.get("center", [0, 0]))
for detected in markers["rivers"]:
if not detected["path"]:
continue
# Check last point (mouth) of river path
dp = detected["path"][-1]
dist = (dp[0] - nc[0])**2 + (dp[1] - nc[1])**2
if dist < best_dist:
best_dist = dist
best_match = detected
if best_match and best_dist < 900:
best_match["name"] = named_river["name"]
# Add cities as POIs
if "cities" in body_features:
for city in body_features["cities"]:
markers["cities"].append({
"id": f"city_{city['name'].lower().replace(' ', '_')}",
"name": city["name"],
"center": city["center"],
"population": city.get("population"),
})
# Add POIs
if "pois" in body_features:
for poi in body_features["pois"]:
markers["pois"].append(poi)
return markers
def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
globe_size: int, render_mode: str, output_dir: Path,
download_only: bool = False):
"""Generate all outputs for a single Sol body."""
body_id = body_def["id"]
body_type = body_def.get("body_type", "planet")
planet_class = body_def.get("planet_class", "unknown")
name = body_def.get("name") or body_id
# Skip non-renderable types
if body_type in SKIP_TYPES:
console.event(f" {body_id} ({name}) — skipped ({body_type})")
return
body_dir = output_dir / body_id
body_dir.mkdir(parents=True, exist_ok=True)
console.event(f" {body_id} ({name}) — {planet_class}")
t0 = time.time()
# ── 1. Build terrain ────────────────────────────────────────────────
terrain = {}
is_gas = planet_class in ("gas_giant",) or body_type == "gas_giant"
if is_gas:
# Gas giants: no terrain, renderer handles bands procedurally
terrain = {}
console.event(" terrain: gas giant (procedural bands)")
elif body_id in REAL_DATA_BODIES:
# Real-world data import
module_name = REAL_DATA_BODIES[body_id]
console.event(f" importing real data via sol_data.{module_name}...")
importer = _load_importer(module_name)
terrain = importer.build_terrain(body_def)
if download_only:
console.event(" download complete, skipping render")
return
elif body_id in PROCEDURAL_BODIES:
# Fall through to standard procedural simulation
console.event(" procedural simulation (irregular body)...")
terrain = simulate(body_def)
else:
console.event(f"no importer for {body_id}, using procedural", level="warn")
terrain = simulate(body_def)
t_terrain = time.time()
if terrain:
console.event(f" terrain: {t_terrain - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())} "
f"rivers={len(terrain['rivers'])}")
else:
console.event(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
# ── 2. Render heightmap ─────────────────────────────────────────────
t_hmap = t_terrain
if terrain:
hmap_img = render_heightmap(body_def, terrain,
out_w=hmap_w, out_h=hmap_h,
render_mode=render_mode, chrome=False)
hmap_img.save(str(body_dir / "heightmap.png"))
t_hmap = time.time()
console.event(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
# ── 3. Render globe ─────────────────────────────────────────────────
try:
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(body_def, terrain, size=globe_size)
globe_img.save(str(body_dir / "globe.png"))
t_globe = time.time()
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
except Exception as e:
console.event(f" globe: FAILED — {e}")
t_globe = time.time()
# ── 4. Write data files ─────────────────────────────────────────────
if terrain:
# terrain.npz
save_dict = {}
for key in ("elevation", "temperature", "moisture", "hillshade",
"biome", "surface_water", "river_grid"):
if key in terrain:
save_dict[key] = terrain[key]
save_dict["sea_level"] = np.array([terrain["sea_level"]])
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
# markers.json — auto-detected + named features overlay
markers = _build_markers(body_def, terrain)
markers = _apply_named_features(markers, body_id)
with open(body_dir / "markers.json", "w") as f:
json.dump(markers, f, indent=2)
# ── 5. Write index.md frontmatter ───────────────────────────────────
_write_index_md(body_def, body_dir)
elapsed = time.time() - t0
console.event(f" total: {elapsed:.1f}s -> {body_dir}/")
def _write_index_md(body_def: dict, body_dir: Path):
"""Write body index.md with YAML frontmatter."""
import yaml
# Strip internal fields
bd = {k: v for k, v in body_def.items()
if not k.startswith("_") and k != "wiki"}
fm = yaml.dump(bd, default_flow_style=False, sort_keys=False,
allow_unicode=True)
name = body_def.get("name") or body_def["id"]
planet_class = body_def.get("planet_class", "unknown")
system_link = "[GJ-0](../../index.md)"
md = f"""---
{fm.rstrip()}
---
# {name}
{planet_class.replace('_', ' ').title()} {'planet' if body_def.get('body_type') == 'planet' else body_def.get('body_type', 'body')}.
**System:** {system_link}
## Visual
![Heightmap](heightmap.png)
![Globe](globe.png)
"""
with open(body_dir / "index.md", "w") as f:
f.write(md)
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Sol system (GJ-0) real-world terrain importer")
parser.add_argument("--body", action="append", default=None,
help="Specific body ID(s) to generate (repeatable)")
parser.add_argument("--download-only", action="store_true",
help="Download source data without rendering")
parser.add_argument("--output-dir", default=None,
help="Override output directory")
parser.add_argument("--heightmap-size", default="1024x512",
help="Heightmap resolution (WxH)")
parser.add_argument("--globe-size", type=int, default=512,
help="Globe resolution (square)")
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
default="cartographic")
args = parser.parse_args(argv)
# Parse heightmap size
try:
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
except ValueError:
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
output_dir = Path(args.output_dir) if args.output_dir else SOL_BODIES_DIR
# Parse body definitions from GJ-0 index.md
from tooling.domains.atlas.planet.body_definition_parser import parse_system
overrides = {}
if SOL_OVERRIDES.exists():
with open(SOL_OVERRIDES) as f:
overrides = json.load(f)
body_defs = parse_system(str(SOL_INDEX), overrides=overrides)
console.event(f"Sol system: {len(body_defs)} bodies parsed")
# Filter to requested bodies
if args.body:
requested = set(args.body)
body_defs = [bd for bd in body_defs if bd["id"] in requested]
if not body_defs:
raise ReachError(f"no matching bodies for {args.body}", fix="pass a Sol body id from wiki/star-systems/GJ-0/bodies/ (e.g. GJ0c), or omit --body")
# Generate
t_total = time.time()
failed = []
for bd in body_defs:
try:
_generate_body(bd, hmap_w, hmap_h, args.globe_size,
args.render_mode, output_dir,
download_only=args.download_only)
except Exception as e:
console.event(f" FAILED: {bd['id']} — {e}")
failed.append(bd["id"])
elapsed = time.time() - t_total
n_ok = len(body_defs) - len(failed)
console.event(f" Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
if failed:
console.event(f" Failed: {', '.join(failed)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,55 @@
{
"GJ0d": {
"_city_selection_note": "11 cultural touchstones, one per major historical/cultural cluster. Criterion: would a player setting a bookmark to 'Earth' recognize this city? Full 46-city reference list is in git history (trimmed Sprint 36 #849). PR #133 review rebalance: Sydney → Lagos (African representation); London → Brussels (future EU capital fit). To adjust: edit this array and re-run sol_import.py, then generate_atlas.py --body GJ0d.",
"oceans": [
{"name": "Pacific Ocean", "center": [128, 440]},
{"name": "Atlantic Ocean", "center": [128, 170]},
{"name": "Indian Ocean", "center": [160, 330]},
{"name": "Arctic Ocean", "center": [15, 256]},
{"name": "Southern Ocean", "center": [230, 256]}
],
"mountain_ranges": [
{"name": "Himalayas", "peak": [93, 350], "center": [93, 348]},
{"name": "Andes", "peak": [118, 140], "center": [140, 140]},
{"name": "Rocky Mountains", "peak": [88, 105], "center": [85, 105]},
{"name": "Alps", "peak": [82, 264], "center": [82, 264]},
{"name": "Urals", "peak": [68, 300], "center": [72, 300]},
{"name": "Atlas Mountains", "peak": [93, 254], "center": [94, 254]},
{"name": "Great Dividing Range", "peak": [160, 420], "center": [162, 420]}
],
"rivers": [
{"name": "Danube", "mouth": [82, 278]},
{"name": "Volga", "mouth": [75, 298]},
{"name": "Rhine", "mouth": [79, 264]},
{"name": "Mississippi", "mouth": [96, 107]},
{"name": "St. Lawrence", "mouth": [80, 132]},
{"name": "Amazon", "mouth": [126, 165]},
{"name": "Paraná", "mouth": [148, 155]},
{"name": "Nile", "mouth": [98, 286]},
{"name": "Congo", "mouth": [125, 268]},
{"name": "Tigris", "mouth": [96, 303]},
{"name": "Yangtze", "mouth": [97, 387]},
{"name": "Ganges", "mouth": [102, 351]},
{"name": "Mekong", "mouth": [114, 374]},
{"name": "Murray", "mouth": [170, 417]}
],
"cities": [
{"name": "Brussels", "center": [80, 266], "population": 2100000, "region": "europe"},
{"name": "Istanbul", "center": [83, 279], "population": 15000000, "region": "europe"},
{"name": "Moscow", "center": [72, 294], "population": 12700000, "region": "europe"},
{"name": "New York", "center": [87, 130], "population": 20100000, "region": "north_america"},
{"name": "São Paulo", "center": [143, 164], "population": 22400000, "region": "south_america"},
{"name": "Cairo", "center": [97, 286], "population": 21300000, "region": "africa"},
{"name": "Tokyo", "center": [92, 400], "population": 37400000, "region": "east_asia"},
{"name": "Delhi", "center": [99, 339], "population": 32900000, "region": "east_asia"},
{"name": "Beijing", "center": [87, 383], "population": 21500000, "region": "east_asia"},
{"name": "Singapore", "center": [119, 372], "population": 5900000, "region": "southeast_asia"},
{"name": "Lagos", "center": [119, 263], "population": 15000000, "region": "africa"}
]
}
}
@@ -0,0 +1,21 @@
{
"GJ0d-1": {
"cities": [
{"name": "Artemis", "center": [215, 250], "population": 200000000, "kind": "capital"},
{"name": "Tranquility Station", "center": [119, 282], "population": 90000000, "kind": "city"},
{"name": "Selene", "center": [93, 247], "population": 60000000, "kind": "city"}
],
"pois": [
{"id": "poi_mare_tranquillitatis", "name": "Mare Tranquillitatis", "center": [119, 282], "kind": "mare"},
{"id": "poi_mare_imbrium", "name": "Mare Imbrium", "center": [93, 247], "kind": "mare"},
{"id": "poi_oceanus_procellarum", "name": "Oceanus Procellarum", "center": [107, 230], "kind": "mare"},
{"id": "poi_mare_serenitatis", "name": "Mare Serenitatis", "center": [104, 277], "kind": "mare"},
{"id": "poi_mare_crisium", "name": "Mare Crisium", "center": [108, 302], "kind": "mare"},
{"id": "poi_mare_nubium", "name": "Mare Nubium", "center": [134, 248], "kind": "mare"},
{"id": "poi_mare_fecunditatis", "name": "Mare Fecunditatis", "center": [124, 299], "kind": "mare"},
{"id": "poi_south_pole_aitken", "name": "South Pole-Aitken Basin","center": [213, 330], "kind": "basin"},
{"id": "poi_tycho", "name": "Tycho", "center": [163, 249], "kind": "crater"},
{"id": "poi_copernicus", "name": "Copernicus", "center": [118, 243], "kind": "crater"}
]
}
}
@@ -0,0 +1,29 @@
{
"GJ0e": {
"cities": [
{"name": "Olympia", "center": [107, 358], "population": 450000000, "kind": "capital"},
{"name": "Hellas Station", "center": [148, 329], "population": 350000000, "kind": "city"},
{"name": "Elysium City", "center": [103, 413], "population": 250000000, "kind": "city"},
{"name": "Chryse Landing", "center": [100, 392], "population": 150000000, "kind": "city"}
],
"mountain_ranges": [
{"name": "Olympus Mons", "peak": [107, 358], "center": [107, 358]},
{"name": "Tharsis Bulge", "peak": [115, 365], "center": [118, 362]},
{"name": "Elysium Mons", "peak": [103, 413], "center": [103, 413]},
{"name": "Ascraeus Mons", "peak": [108, 367], "center": [108, 367]},
{"name": "Arsia Mons", "peak": [118, 363], "center": [118, 363]}
],
"oceans": [
{"name": "Hellas Basin", "center": [148, 329]},
{"name": "Utopia Planitia", "center": [80, 385]},
{"name": "Isidis Planitia", "center": [112, 343]}
],
"pois": [
{"id": "poi_valles_marineris", "name": "Valles Marineris", "center": [118, 380], "kind": "canyon"},
{"id": "poi_north_polar_cap", "name": "North Polar Cap", "center": [10, 256], "kind": "ice_cap"},
{"id": "poi_south_polar_cap", "name": "South Polar Cap", "center": [245, 256], "kind": "ice_cap"},
{"id": "poi_chryse_planitia", "name": "Chryse Planitia", "center": [100, 392], "kind": "plain"},
{"id": "poi_acidalia_planitia","name": "Acidalia Planitia","center": [80, 395], "kind": "plain"}
]
}
}
@@ -0,0 +1,52 @@
{
"GJ0f-1": {
"pois": [
{"id": "poi_loki_patera", "name": "Loki Patera", "center": [115, 295], "kind": "volcano"},
{"id": "poi_pele", "name": "Pele", "center": [140, 358], "kind": "volcano"},
{"id": "poi_tvashtar", "name": "Tvashtar Paterae","center": [46, 354], "kind": "volcano"},
{"id": "poi_prometheus", "name": "Prometheus", "center": [128, 412], "kind": "volcano"},
{"id": "poi_masubi", "name": "Masubi", "center": [152, 370], "kind": "volcano"}
]
},
"GJ0f-2": {
"cities": [
{"name": "Conamara Station", "center": [118, 328], "population": 20000000, "kind": "capital"},
{"name": "Pwyll Base", "center": [155, 328], "population": 10000000, "kind": "city"}
],
"pois": [
{"id": "poi_conamara_chaos", "name": "Conamara Chaos", "center": [118, 328], "kind": "chaos"},
{"id": "poi_pwyll_crater", "name": "Pwyll Crater", "center": [155, 328], "kind": "crater"},
{"id": "poi_thera_macula", "name": "Thera Macula", "center": [145, 340], "kind": "macula"},
{"id": "poi_tyre", "name": "Tyre", "center": [93, 357], "kind": "multi_ring"}
]
},
"GJ0f-3": {
"pois": [
{"id": "poi_galileo_regio", "name": "Galileo Regio", "center": [90, 375], "kind": "dark_terrain"},
{"id": "poi_uruk_sulcus", "name": "Uruk Sulcus", "center": [108, 310], "kind": "grooved"},
{"id": "poi_gilgamesh", "name": "Gilgamesh", "center": [178, 370], "kind": "crater"}
]
},
"GJ0f-4": {
"pois": [
{"id": "poi_valhalla", "name": "Valhalla", "center": [108, 310], "kind": "multi_ring"},
{"id": "poi_asgard", "name": "Asgard", "center": [93, 370], "kind": "multi_ring"}
]
},
"GJ0g-1": {
"pois": [
{"id": "poi_kraken_mare", "name": "Kraken Mare", "center": [25, 340], "kind": "methane_sea"},
{"id": "poi_ligeia_mare", "name": "Ligeia Mare", "center": [20, 370], "kind": "methane_sea"},
{"id": "poi_punga_mare", "name": "Punga Mare", "center": [30, 350], "kind": "methane_sea"},
{"id": "poi_xanadu", "name": "Xanadu", "center": [117, 375], "kind": "bright_terrain"},
{"id": "poi_shangri_la", "name": "Shangri-La", "center": [130, 310], "kind": "dune_field"}
]
},
"GJ0g-2": {
"pois": [
{"id": "poi_tiger_stripes", "name": "Tiger Stripes", "center": [220, 256], "kind": "fracture"},
{"id": "poi_baghdad_sulcus", "name": "Baghdad Sulcus", "center": [218, 270], "kind": "fracture"},
{"id": "poi_samarkand_sulcus","name": "Samarkand Sulcus","center": [215, 240], "kind": "fracture"}
]
}
}
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
sol_name_fixes.py — Name previously-unnamed Sol body auto-detected features.
Targets features with null names: Earth oceans/rivers, Luna/Mars/Europa mountain ranges.
All names are real-world geographic names for Sol bodies.
Usage:
reach atlas planet sol-name-fixes # apply all fixes
reach atlas planet sol-name-fixes --dry-run # print planned changes without writing
"""
import argparse
import json
from tooling.core import config, console
REPO_ROOT = config.repo_root()
WIKI = REPO_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
# Keys are feature IDs; values are the names to assign.
FIXES = {
# Earth (GJ0d): 1 unnamed ocean + 3 unnamed rivers (auto-detected terrain artifacts)
"GJ0d": {
"oceans": {
"water_1": "The World Ocean", # 70% surface = all Earth's oceans unified
},
"rivers": {
"river_1": "Dnieper", # Eastern Europe ~50°N, 17°E
"river_4": "Tone River", # Japan, Kanto plain ~36°N, 140°E
"river_10": "Cagayan", # Northern Philippines ~18°N, 121°E
},
},
# Luna (GJ0d-1): 24 unnamed mountain ranges — all auto-detected; named after
# real lunar mountain systems, massifs, and scarps sorted by descending area.
"GJ0d-1": {
"mountain_ranges": {
"range_5": "Montes Apenninus", # 11221 cells — largest lunar mountain range
"range_34": "Montes Caucasus", # 3670 cells
"range_209": "Montes Jura", # 367 cells — on Mare Imbrium NW
"range_551": "Montes Alpes", # 289 cells — lunar Alps
"range_576": "Montes Rook", # 258 cells — south polar mountains
"range_599": "Montes Cordillera", # 164 cells — Orientale basin rim
"range_326": "Montes Carpatus", # 106 cells — south of Mare Imbrium
"range_555": "Montes Pyrenaeus", # 94 cells — east of Mare Nectaris
"range_586": "Montes Taurus", # 90 cells — NE of Mare Serenitatis
"range_589": "Montes Teneriffe", # 52 cells — isolated massif
"range_124": "Montes Harbinger", # 51 cells — west of Aristarchus
"range_587": "Montes Recti", # 48 cells — north Mare Imbrium
"range_522": "Pico Mons", # 35 cells — isolated peak
"range_579": "Haemus Montes", # 33 cells — south of Mare Serenitatis
"range_231": "Gruithuisen Domes", # 25 cells — volcanic domes
"range_580": "Mons La Hire", # 25 cells — volcanic dome, Mare Imbrium
"range_570": "Montes Riphaeus", # 24 cells — Oceanus Procellarum ridge
"range_596": "Montes Secchi", # 24 cells — east of Mare Fecunditatis
"range_604": "Montes Spitzbergen", # 24 cells — isolated massif
"range_493": "Malapert Mountain", # 23 cells — south pole region
"range_406": "Doerfel Mountains", # 26 cells — south pole far side
"range_432": "Altai Scarp", # 20 cells — Rupes Altai
"range_79": "Mons Rümker", # 21 cells — shield volcano
"range_348": "Leibnitz Mountains", # 21 cells — south polar range
},
},
# Mars (GJ0e): 4 unnamed mountain ranges in the anti-Tharsis hemisphere
"GJ0e": {
"mountain_ranges": {
"range_2": "Tyrrhena Mons", # ancient volcanic highland (area=67)
"range_3": "Hadriaca Mons", # old shield volcano (area=31)
"range_4": "Amphitrites Montes", # near south polar region (area=28)
"range_5": "Hellespontus Montes", # cross-refs Hellas Station (area=87)
},
},
# Europa (GJ0f-2): 1 unnamed mountain — Europa's ridged ice terrain (area=123557 cells)
"GJ0f-2": {
"mountain_ranges": {
"range_1": "Conamara Ridges", # cross-refs Conamara Station + Conamara Chaos
},
},
}
SECTION_MAP = {
"oceans": "oceans",
"rivers": "rivers",
"mountain_ranges": "mountain_ranges",
}
def _check_dedup(fixes: dict) -> None:
"""Raise if any name is assigned to more than one feature ID within a body."""
for body_id, sections in fixes.items():
for section, id_map in sections.items():
seen: dict[str, str] = {}
for fid, name in id_map.items():
if name in seen:
raise ValueError(
f"Duplicate name {name!r} in {body_id}/{section}: "
f"{seen[name]} and {fid} both assigned"
)
seen[name] = fid
def apply_sol_fixes(dry_run: bool = False) -> None:
_check_dedup(FIXES)
for body_id, body_fixes in FIXES.items():
path = WIKI / body_id / "markers.json"
if not path.exists():
console.event(f" SKIP {body_id}: markers.json not found")
continue
with open(path) as f:
markers = json.load(f)
changed = False
for section, id_map in body_fixes.items():
feature_list = markers.get(SECTION_MAP[section], [])
for feature in feature_list:
fid = feature.get("id", "")
if fid in id_map:
old = feature.get("name")
new = id_map[fid]
if old != new:
console.event(f" [{body_id}/{section}] {fid}: {old!r} → {new!r}")
if not dry_run:
feature["name"] = new
changed = True
if changed:
if dry_run:
console.event(f" (dry-run) Would write: {path}")
else:
with open(path, "w") as f:
json.dump(markers, f, indent=2)
console.event(f" Written: {path}")
else:
console.event(f" No changes for {body_id}")
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Name previously-unnamed Sol body auto-detected features."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="print planned changes without writing any files",
)
args = parser.parse_args(argv)
apply_sol_fixes(dry_run=args.dry_run)
if __name__ == "__main__":
main()
@@ -0,0 +1,108 @@
{
"GJ0b": {
"_comment": "Mercury — barren rock, tidally locked, extreme temps",
"orbit": { "axial_tilt_deg": 0.034 },
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
"environment": { "geothermal_flux": "low", "substrate": "silicate" }
},
"GJ0c": {
"_comment": "Venus — thick sulfuric acid clouds hide the surface completely",
"orbit": { "axial_tilt_deg": 177.4 },
"terrain": { "land_fraction": 1.0, "tectonics": "active" },
"physical": { "atmosphere_color": [0.92, 0.85, 0.55] },
"environment": { "geothermal_flux": "high", "substrate": "silicate" },
"clouds": { "enabled": true, "coverage_base": 0.95 }
},
"GJ0d": {
"_comment": "Earth — use real-world data pipeline",
"orbit": { "axial_tilt_deg": 23.44 },
"terrain": { "land_fraction": 0.29, "polar_ice_lat": 0.85, "tectonics": "active" },
"environment": { "hydrosphere": "ocean", "geothermal_flux": "low" },
"clouds": { "enabled": true, "coverage_base": 0.5 }
},
"GJ0d-1": {
"_comment": "Luna — barren, tidally locked",
"orbit": { "axial_tilt_deg": 6.68 },
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
"environment": { "geothermal_flux": "low", "substrate": "silicate" }
},
"GJ0e": {
"_comment": "Mars — thin atmo, partially terraformed in lore (800 years)",
"orbit": { "axial_tilt_deg": 25.19 },
"terrain": { "land_fraction": 0.98, "polar_ice_lat": 0.65, "tectonics": "none" },
"environment": { "hydrosphere": "ice", "geothermal_flux": "low", "substrate": "silicate" }
},
"GJ0f": {
"_comment": "Jupiter — gas giant, Great Red Spot",
"gas_giant": {
"band_palette": "jovian",
"storm_count": 2,
"storm_max_size": 0.12
},
"rings": false
},
"GJ0f-1": {
"_comment": "Io — volcanic moon of Jupiter, tidal heating",
"terrain": { "land_fraction": 1.0, "tectonics": "extreme" },
"environment": { "geothermal_flux": "high", "substrate": "silicate", "chemosynthetic": false }
},
"GJ0f-2": {
"_comment": "Europa — ice moon, subsurface ocean",
"terrain": { "land_fraction": 1.0, "tectonics": "low" },
"environment": { "geothermal_flux": "low", "substrate": "ice" }
},
"GJ0f-3": {
"_comment": "Ganymede — largest moon, ice/rock dichotomy",
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
"environment": { "geothermal_flux": "low", "substrate": "ice" }
},
"GJ0f-4": {
"_comment": "Callisto — heavily cratered ice moon",
"terrain": { "land_fraction": 1.0, "tectonics": "none" },
"environment": { "geothermal_flux": "low", "substrate": "ice" }
},
"GJ0g": {
"_comment": "Saturn — gas giant with prominent ring system",
"gas_giant": {
"band_palette": "saturnian",
"storm_count": 1,
"storm_max_size": 0.06
},
"rings": {
"enabled": true,
"inner_radius_factor": 1.12,
"outer_radius_factor": 2.65,
"opacity_base": 0.68,
"ring_color": [0.88, 0.78, 0.55]
}
},
"GJ0g-1": {
"_comment": "Titan — dense atmosphere, methane cycle",
"terrain": { "land_fraction": 0.60, "tectonics": "low" },
"environment": { "hydrosphere": "rivers", "geothermal_flux": "low", "substrate": "ice" }
},
"GJ0g-2": {
"_comment": "Enceladus — small ice moon, geysers",
"terrain": { "land_fraction": 1.0, "tectonics": "low" },
"environment": { "geothermal_flux": "moderate", "substrate": "ice" }
},
"GJ0h": {
"_comment": "Uranus — ice giant, extreme axial tilt",
"orbit": { "axial_tilt_deg": 97.8 },
"gas_giant": {
"band_palette": "icy",
"storm_count": 1,
"storm_max_size": 0.04
},
"rings": false
},
"GJ0i": {
"_comment": "Neptune — ice giant, active storms",
"gas_giant": {
"band_palette": "neptunian",
"storm_count": 3,
"storm_max_size": 0.08
},
"rings": false
}
}
+12
View File
@@ -154,3 +154,15 @@ def flatness_report(
fix="re-run the capture, or check .cache/screenshots/ for the ladder",
exit_code=code,
)
# --- the rungs below the body surface -------------------------------------
#
# `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.
from tooling.domains.atlas.planet.router import app as _planet_app # noqa: E402
app.add_typer(_planet_app, name="planet")
+2 -2
View File
@@ -15,8 +15,8 @@ REPO = "jpmschweitzer/settled-reach"
# set is read from the registry rather than restated, so this list cannot drift
# from tooling/generator_sources.py (T-1067).
EXTRA_WATCHED = (
"tooling/planet-gen/import_heightmaps.py",
"tooling/planet-gen/import_province_boundaries.py",
"tooling/domains/atlas/planet/import_heightmaps.py",
"tooling/domains/atlas/planet/import_province_boundaries.py",
"server/data/systems-schema.sql",
"wiki/star-systems/",
"wiki/economics/",