diff --git a/.gitignore b/.gitignore
index 8dedd0fda..acde0fba4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,6 +34,15 @@ renderer/output/*.png
spikes/**/.godot/
spikes/**/*.import
spikes/**/*.uid
+spikes/**/*.npy
+spikes/**/*.npz
+
+# Planet generator intermediates
+tooling/planet-gen/__pycache__/
+*.tmp.npz
+
+# Generated terrain grids (large, regenerated from pipeline)
+wiki/**/bodies/**/terrain.npz
# Asset downloads (vendor archives, not game content)
docs/assets/downloads/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb6022de7..f192c95a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
+- Planet generator pipeline (`tooling/planet-gen/`) — procedural terrain simulation, Whittaker biome classification, equirectangular heightmap + 512px globe rendering from wiki data
+- Externalized biome/color configuration (`tooling/planet-gen/biomes.toml`) — single source for all classification tables, palettes, and rendering parameters
+- Batch runner with error handling, resume support, determinism verification, and 50% error rate circuit breaker
+- Per-body wiki pages with YAML frontmatter (`wiki/star-systems/*/bodies/*/index.md`) — body definition + profile + visual embeds
+- Body scaffolding tool reads system index.md and generates per-body index.md with frontmatter
+- Ran system (GJ-144) generated: 10 bodies with heightmaps, globe renders, and geographic markers
+- Implant HUD icon set: 13 SVGs (stance, status, interaction, inventory) per D-086
+- 7 procedural planet type renders for wiki/GTTR (`client/assets/planets/`)
+- Ticket #817: batch planet generation for all ~3000 bodies
+- Ticket #816: remove stale Qdrant semantic search infrastructure
- `PROJECT_STATE.md` at repo root — shared project state referenced by all 18 agent briefings
- Economics decision domain (`decisions/economics.md`): D-171–D-187 covering currency system, commodity taxonomy, shadow economy, corporation tiers, productivity seeding, simulation architecture, data pipeline, gate transmission levels
- Commodity catalog: 36 commodity types in `wiki/economics/commodities.toml` with 21 production chains in `wiki/economics/production_chains.toml`
diff --git a/client/assets/icons/icon_action_examine.svg b/client/assets/icons/icon_action_examine.svg
new file mode 100644
index 000000000..2e2890886
--- /dev/null
+++ b/client/assets/icons/icon_action_examine.svg
@@ -0,0 +1,14 @@
+
diff --git a/client/assets/icons/icon_action_follow.svg b/client/assets/icons/icon_action_follow.svg
new file mode 100644
index 000000000..4680d47ca
--- /dev/null
+++ b/client/assets/icons/icon_action_follow.svg
@@ -0,0 +1,14 @@
+
diff --git a/client/assets/icons/icon_action_observe.svg b/client/assets/icons/icon_action_observe.svg
new file mode 100644
index 000000000..802f84661
--- /dev/null
+++ b/client/assets/icons/icon_action_observe.svg
@@ -0,0 +1,14 @@
+
diff --git a/client/assets/icons/icon_action_talk.svg b/client/assets/icons/icon_action_talk.svg
new file mode 100644
index 000000000..afb6b742e
--- /dev/null
+++ b/client/assets/icons/icon_action_talk.svg
@@ -0,0 +1,18 @@
+
diff --git a/client/assets/icons/icon_item_access_token.svg b/client/assets/icons/icon_item_access_token.svg
new file mode 100644
index 000000000..e4872dbfe
--- /dev/null
+++ b/client/assets/icons/icon_item_access_token.svg
@@ -0,0 +1,17 @@
+
diff --git a/client/assets/icons/icon_item_comm_log.svg b/client/assets/icons/icon_item_comm_log.svg
new file mode 100644
index 000000000..29e2718bc
--- /dev/null
+++ b/client/assets/icons/icon_item_comm_log.svg
@@ -0,0 +1,21 @@
+
diff --git a/client/assets/icons/icon_item_manifest.svg b/client/assets/icons/icon_item_manifest.svg
new file mode 100644
index 000000000..67f9f017e
--- /dev/null
+++ b/client/assets/icons/icon_item_manifest.svg
@@ -0,0 +1,18 @@
+
diff --git a/client/assets/icons/icon_stance_careful.svg b/client/assets/icons/icon_stance_careful.svg
new file mode 100644
index 000000000..70316dfe8
--- /dev/null
+++ b/client/assets/icons/icon_stance_careful.svg
@@ -0,0 +1,24 @@
+
diff --git a/client/assets/icons/icon_stance_crouch.svg b/client/assets/icons/icon_stance_crouch.svg
new file mode 100644
index 000000000..404a9d7e0
--- /dev/null
+++ b/client/assets/icons/icon_stance_crouch.svg
@@ -0,0 +1,22 @@
+
diff --git a/client/assets/icons/icon_stance_sprint.svg b/client/assets/icons/icon_stance_sprint.svg
new file mode 100644
index 000000000..72139111f
--- /dev/null
+++ b/client/assets/icons/icon_stance_sprint.svg
@@ -0,0 +1,23 @@
+
diff --git a/client/assets/icons/icon_stance_walk.svg b/client/assets/icons/icon_stance_walk.svg
new file mode 100644
index 000000000..d3f5f2491
--- /dev/null
+++ b/client/assets/icons/icon_stance_walk.svg
@@ -0,0 +1,22 @@
+
diff --git a/client/assets/icons/icon_status_health.svg b/client/assets/icons/icon_status_health.svg
new file mode 100644
index 000000000..edca318d2
--- /dev/null
+++ b/client/assets/icons/icon_status_health.svg
@@ -0,0 +1,13 @@
+
diff --git a/client/assets/icons/icon_status_perception.svg b/client/assets/icons/icon_status_perception.svg
new file mode 100644
index 000000000..9cfc9c71b
--- /dev/null
+++ b/client/assets/icons/icon_status_perception.svg
@@ -0,0 +1,16 @@
+
diff --git a/client/assets/planets/planet_arid.png b/client/assets/planets/planet_arid.png
new file mode 100644
index 000000000..e6fc40aa8
Binary files /dev/null and b/client/assets/planets/planet_arid.png differ
diff --git a/client/assets/planets/planet_barren.png b/client/assets/planets/planet_barren.png
new file mode 100644
index 000000000..eb44fc1ed
Binary files /dev/null and b/client/assets/planets/planet_barren.png differ
diff --git a/client/assets/planets/planet_frozen.png b/client/assets/planets/planet_frozen.png
new file mode 100644
index 000000000..1fb1db6a5
Binary files /dev/null and b/client/assets/planets/planet_frozen.png differ
diff --git a/client/assets/planets/planet_oceanic.png b/client/assets/planets/planet_oceanic.png
new file mode 100644
index 000000000..611a80851
Binary files /dev/null and b/client/assets/planets/planet_oceanic.png differ
diff --git a/client/assets/planets/planet_temperate.png b/client/assets/planets/planet_temperate.png
new file mode 100644
index 000000000..2e302ae08
Binary files /dev/null and b/client/assets/planets/planet_temperate.png differ
diff --git a/client/assets/planets/planet_temperate_terminator.png b/client/assets/planets/planet_temperate_terminator.png
new file mode 100644
index 000000000..2a96313be
Binary files /dev/null and b/client/assets/planets/planet_temperate_terminator.png differ
diff --git a/client/assets/planets/planet_volcanic.png b/client/assets/planets/planet_volcanic.png
new file mode 100644
index 000000000..b19c06d69
Binary files /dev/null and b/client/assets/planets/planet_volcanic.png differ
diff --git a/decisions/perception.md b/decisions/perception.md
index fe60b0d88..ea810b364 100644
--- a/decisions/perception.md
+++ b/decisions/perception.md
@@ -464,7 +464,8 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
- **Icon vocabulary (v0.1):**
- Inventory item silhouettes: 3–4 items at 40×40px (manifest, access token, comm log)
- Perception mode indicators: 2–3 icons at 20×20px (deferred until #315 specs the modes)
- - Stance indicators: text labels (`WALK`, `CAREFUL`, `SPRINT`, `CROUCH`) at Michroma 11px — icons deferred to v0.2
+ - Stance indicators: 4 icons at 20×20px (WALK, CAREFUL, SPRINT, CROUCH) — delivered Sprint 32 alongside text labels. Icons supplement labels; text remains primary affordance.
+ - Interaction prompt icons: 4 icons at 16×16px (talk, observe, follow, examine) — delivered Sprint 32. Supplement interaction verb text per insert HUD wireframe §9.
- Border arrows and geometric markers (diamonds, dots, squares): drawn as primitives, not icons
- **Authoring constraints:** 1px stroke weight base (scaled to 2px for `lattice_augmented` via parameter), `#c8d0e0` as design reference color (runtime-replaceable), minimal anchor points, no decorative curves.
- **Calibration reference:** Phosphor Light (MIT) — not for use, but for evaluating small-size readability of custom shapes during authoring. If a custom icon doesn't read as cleanly as a Phosphor Light equivalent at 16px, simplify it.
diff --git a/decisions/questions-architecture.md b/decisions/questions-architecture.md
index 3abdaf68c..da77d76fd 100644
--- a/decisions/questions-architecture.md
+++ b/decisions/questions-architecture.md
@@ -109,8 +109,9 @@ Technical foundation questions: engine, protocols, data structures, performance,
---
### Q-064: 3D planet generator for wiki system screenshots
-- **Status:** Open
+- **Status:** Resolved — answered by #779 (Sprint 32)
- **Question:** Evaluate the Godot 3D Planet Generator (https://github.com/remijean/godot-3d-planet-generator) for generating unique planet visuals per star system in the wiki. Each of the 301 systems could get a procedurally generated planet rendered as a screenshot for its wiki page. Key questions: can we get enough visual variety across 301 systems (different biomes, atmospheres, colors, ring configurations)? Can the generator run headlessly for batch rendering? What's the parameter space — how many distinct-looking planets can it produce? Could the planet configs be seeded from system properties (star class, habitable zone, etc.) for consistency across regenerations?
+- **Resolution:** Pure Python ray-sphere renderer (`spikes/planet-renders/generate_planets.py`) replaces the Godot plugin approach. Answers all evaluation criteria: (1) visual variety via biome_summary type × body_id seed = 301 distinct renders, (2) fully headless — no Godot required, ~2s for all 7 types, (3) seeded from system properties for reproducibility. Avoids headless Godot rendering complexity. See `docs/design/planetary-screenshots-spec.md`.
- **Cross-reference:** Wiki system pages (docs/wiki/), world generation pipeline
---
diff --git a/docs/design/icon-set-v01.md b/docs/design/icon-set-v01.md
new file mode 100644
index 000000000..056a4480d
--- /dev/null
+++ b/docs/design/icon-set-v01.md
@@ -0,0 +1,154 @@
+---
+title: "Implant HUD Icon Set — v0.1"
+description: "Design specification for the implant HUD icon vocabulary: stance indicators, status badges, interaction prompts, and inventory item silhouettes"
+type: design
+status: active
+ticket: "#795"
+decision_refs: [D-086]
+author: "Araminta"
+created: 2026-04-05
+updated: 2026-04-05
+---
+
+# Implant HUD Icon Set — v0.1
+
+**Ticket:** #795
+**Author:** Araminta (Visual Designer)
+**Date:** 2026-04-05
+**Status:** Active — unblocks #787 (minimap + stance label implant conversion)
+**Decision reference:** D-086 (insert icon system)
+
+---
+
+## 1. Authoring Constraints (D-086)
+
+All icons in this set follow D-086 authoring constraints without exception:
+
+| Constraint | Value |
+|------------|-------|
+| Format | Custom SVG (no icon font) |
+| Stroke weight | `1px` base (scaled to `2px` for `lattice_augmented` via shader parameter) |
+| Chrome color | `#c8d0e0` (design reference — runtime-replaceable via ShaderMaterial) |
+| Anchor points | Minimal — prefer `L` (lines), `polyline`, `polygon`, `rect`, `circle`. Avoid `Q`/`C` curves. |
+| Fill | `none` on all paths except explicit solid elements (battery terminal caps, etc.) |
+| Terminations | `stroke-linecap="round"`, `stroke-linejoin="round"` — clean joins, not decorative |
+| Calibration | Icons must read cleanly at their target display size. Reference: Phosphor Light (MIT) — not for use, as legibility benchmark only |
+| Godot import | SVGs import via AtlasTexture. Runtime color swap via ShaderMaterial — do not bake color into shape variants |
+
+---
+
+## 2. Icon Inventory
+
+### 2.1 Stance Indicators
+
+**Location:** `client/assets/icons/`
+**Size:** 20×20px viewBox
+**Used in:** Insert HUD lower-left stance indicator, implant component library
+
+These icons appear alongside the text label (`WALK`, `CAREFUL`, etc.) per the insert HUD wireframe. They reinforce the label visually and support conversion to icon-first display in #787.
+
+| File | Stance | Design differentiator |
+|------|--------|-----------------------|
+| `icon_stance_walk.svg` | WALK | Upright figure, bilateral stride. Arms in natural walk swing (forward/back asymmetry). |
+| `icon_stance_careful.svg` | CAREFUL | Upright figure, arms spread laterally (environmental check posture). Legs closer together (deliberate foot placement). |
+| `icon_stance_sprint.svg` | SPRINT | Head and torso shifted forward (lean). Arms in pump position. Wide asymmetric leg stride. |
+| `icon_stance_crouch.svg` | CROUCH | Head lowered. Torso bent at waist. Knees bent via polyline angles. Compressed vertical range. |
+
+**Recognition at 20px:** Stance icons are always displayed with the text label in v0.1. The icons reinforce the text; they do not replace it. Pattern recognition builds over ~10 minutes of play (same threshold as the radial menu, D-058).
+
+### 2.2 Status Badges
+
+**Location:** `client/assets/icons/`
+**Size:** 20×20px viewBox
+**Used in:** Insert HUD chrome band (top row), perception mode indicator
+
+| File | Badge | Design |
+|------|-------|--------|
+| `icon_status_health.svg` | HEALTH | Medical cross — two overlapping rects forming a plus symbol. Geometric, no curves. |
+| `icon_status_perception.svg` | PERCEPTION MODE | Diamond eye — four-sided polygon outline with centered circle pupil. Diamond shape reinforces insert lattice aesthetic. |
+
+**Perception mode variants:** Mode-specific icons (thermal, camera feed, etc.) are deferred until #315 fully specs the perception modes. The `icon_status_perception.svg` is a generic "perception layer active" indicator only.
+
+### 2.3 Interaction Prompt Icons
+
+**Location:** `client/assets/icons/`
+**Size:** 16×16px viewBox
+**Used in:** Entity hover interaction verb display (insert layer, z-6)
+
+These appear when the cursor enters an entity's interaction radius. They supplement the verb text (e.g., `Talk`). Per the HUD wireframe, the verb text is primary — icons are secondary affordance.
+
+| File | Action | Design |
+|------|--------|--------|
+| `icon_action_talk.svg` | TALK | Speech bubble — rectangular body, triangle callout at bottom-left, two content lines. |
+| `icon_action_observe.svg` | OBSERVE | Diamond eye (shares vocabulary with `icon_status_perception.svg`). |
+| `icon_action_follow.svg` | FOLLOW | Arrow right — shaft and arrowhead. Directional / movement. |
+| `icon_action_examine.svg` | EXAMINE | Magnifying glass — lens circle with diagonal handle. The circle is a geometric primitive. |
+
+**Extending verb coverage:** Additional verbs (`Confront`, `Wait`, etc.) will need icons as the verb list expands. Follow the diamond-eye vocabulary for perception verbs; arrow vocabulary for movement; speech bubble vocabulary for social verbs.
+
+### 2.4 Inventory Item Silhouettes
+
+**Location:** `client/assets/icons/`
+**Size:** 40×40px viewBox
+**Used in:** Insert HUD inventory band (lower-right, per insert-hud-wireframe-v01.md §7)
+
+D-086 amendment (2026-04-05): inventory icons are no longer character-specific. [D-117] eliminated the smuggler/detective frame; [D-122] removed named characters. These icons apply to any character who can carry physical items.
+
+| File | Item | Design |
+|------|------|--------|
+| `icon_item_manifest.svg` | Manifest (document) | Pentagon-body document with folded top-right corner. Three text-content lines inside. |
+| `icon_item_access_token.svg` | Access token (hex tag) | Flat-top hexagon with punch-hole circle near top. Two interior encoding lines. |
+| `icon_item_comm_log.svg` | Personal comm log (device) | Rectangular handheld body with screen inset, antenna stub, signal lines (polylines), control button. |
+
+---
+
+## 3. Visual Grammar Integration
+
+### 3.1 Color behavior
+
+The `#c8d0e0` chrome color is the design reference only. At runtime:
+- Default: `#c8d0e0` (insert chrome — unchanged)
+- `lattice_augmented` profile: stroke scales to 2px; color may shift to a brighter white
+- `insert_active == false`: icons reduce to 30–50% opacity (per element, per insert HUD wireframe §9.2)
+- Entity icons (if using D-033 relationship colors): color swapped via ShaderMaterial parameter
+
+Saturation rule (D-044): insert chrome must never compete with entity D-033 colors. Icons in `#c8d0e0` are intentionally de-saturated. Do not use warmer or more saturated colors in any icon variant.
+
+### 3.2 Godot import setup
+
+```gdscript
+# Load SVG as ImageTexture (Godot 4 native SVG import)
+# In .import settings: set scale to match display target size
+# Runtime color replacement:
+var icon_material = ShaderMaterial.new()
+icon_material.shader = preload("res://shaders/icon_tint.gdshader")
+icon_material.set_shader_parameter("tint_color", Color("#c8d0e0"))
+icon_node.material = icon_material
+```
+
+The `icon_tint.gdshader` (to be authored by client team, ticket #818) multiplies the SVG's existing color by the `tint_color` parameter. Must handle both stroke-based icons (most) and fill-based icons (health cross). Since all SVGs use `#c8d0e0` as their base color, the default parameter leaves them unchanged; swapping the parameter recolors them at runtime.
+
+### 3.3 Sizing at non-native resolutions
+
+All icons have explicit `width` and `height` attributes matching their viewBox. Godot's SVG importer respects these. For 2x displays, import at 2x scale — do not stretch from 1x. The 1px stroke renders correctly at integer multiples.
+
+---
+
+## 4. What This Document Does Not Cover
+
+- **Perception mode variant icons** (thermal, camera, etc.) — awaiting #315 full spec
+- **Minimap POI icons** — the insert HUD uses geometric primitives (diamonds, dots, squares) rendered as primitives, not SVG imports (per insert-hud-wireframe-v01.md §4)
+- **Cursor state icons** — specified in D-056, cursor states are handled by the cursor rendering system
+- **World radial menu icons** — the four spokes (Observe, Insert, Comms, Wait) in D-058 need a follow-on ticket
+
+---
+
+## 5. Calibration Notes
+
+Test all icons at their native display size (20×20px or 16×16px) at 100% scale before approving implementation. The Phosphor Light benchmark:
+
+> *If a custom icon doesn't read as cleanly as a Phosphor Light equivalent at 16px, simplify it.*
+
+The stance icons are the most complex. If any stance icon reads poorly at 20px on a real display, simplify to fewer line segments. The text label remains available as the primary affordance.
+
+Inventory icons at 40×40 have more room and should read clearly without simplification.
diff --git a/docs/design/planetary-screenshots-spec.md b/docs/design/planetary-screenshots-spec.md
new file mode 100644
index 000000000..b1545b192
--- /dev/null
+++ b/docs/design/planetary-screenshots-spec.md
@@ -0,0 +1,157 @@
+---
+title: "Planetary Screenshot Spec — v0.1"
+description: "Procedural planet sphere renders for the GTTR body-info-panel and wiki pages. One image per biome_summary type."
+type: design
+status: active
+ticket: "#779"
+decision_refs: []
+author: "Araminta"
+created: 2026-04-05
+updated: 2026-04-05
+---
+
+# Planetary Screenshot Spec — v0.1
+
+**Ticket:** #779
+**Author:** Araminta
+**Date:** 2026-04-05
+**Status:** Active — assets delivered, awaiting client integration
+
+---
+
+## Display Context
+
+Planet screenshots appear in the **body-info-panel** navigator (wireframe:
+`docs/design/wireframes/navigator/body-info-panel.json`):
+
+| Property | Value |
+|----------|-------|
+| Container | `planet-screenshot` — 360×360px, `#111820` background |
+| Sphere display | 240×240px ellipse within the container |
+| Z-context | Implant fullscreen (z=20), not gameplay layer |
+| Background | `#0d1117` (navigator panel) |
+
+In the **GTTR arrival window** (diegetic implant display during transit), the same
+asset appears with the same proportions — one image per destination planet.
+
+---
+
+## Asset Inventory
+
+**Location:** `client/assets/planets/`
+**Format:** PNG, 512×512px, RGBA
+**Display:** Scaled to 240×240 within a 360×360 container
+
+| File | `biome_summary` value | Character |
+|------|-----------------------|-----------|
+| `planet_temperate.png` | `temperate` | Blue ocean, amber-green continents, cloud cover, polar caps |
+| `planet_temperate_terminator.png` | `temperate_terminator` | Half gold-scorched day face, half frozen dark face, sharp terminator |
+| `planet_oceanic.png` | `oceanic` | Deep blue water world, heavy cloud, scattered archipelago |
+| `planet_arid.png` | `arid` | Warm reddish-brown, dusty, sparse thin polar caps |
+| `planet_frozen.png` | `frozen` | White-blue ice, exposed grey rock at mid-latitudes |
+| `planet_volcanic.png` | `volcanic` | Dark basalt, orange lava cloud patterns |
+| `planet_barren.png` | `barren` | Cratered grey-brown, no atmosphere glow |
+
+---
+
+## Generation Pipeline
+
+**Script:** `spikes/planet-renders/generate_planets.py`
+
+```bash
+# Regenerate all types
+python3 spikes/planet-renders/generate_planets.py
+
+# Single type
+python3 spikes/planet-renders/generate_planets.py --type temperate
+
+# Custom output location
+python3 spikes/planet-renders/generate_planets.py --output-dir path/to/dir
+```
+
+**Dependencies:** `numpy`, `Pillow` (already in requirements)
+
+**Technique:** Numpy ray-sphere intersection + Lambertian diffuse + specular +
+procedural octave-sine texture. Runs without GPU. ~2s for all 7 types.
+
+### Lighting rig
+
+| Property | Value |
+|----------|-------|
+| Star direction (from surface) | (-0.55, 0.45, 0.70) normalized |
+| Ambient | 0.22 (dark side is dim, not black) |
+| Diffuse | 0.78 |
+| Specular | 0.30 (modest glint) |
+| Atmosphere rim glow | Blue-white, (1-rim)^5 × 0.7 |
+
+### Texture approach
+
+Procedural octave sine noise — no external noise library required. UV coordinates
+from spherical mapping (lon, lat). Each planet type uses different:
+- `seed` (shifts continent/feature pattern)
+- `octaves` (detail level)
+- `threshold` (land/ocean boundary)
+- Color palette (per type)
+
+---
+
+## Binding to Atlas Data
+
+Each planet in `systems.db` has a `biome_summary` field. The client selects the
+matching planet image:
+
+```gdscript
+func planet_image_for_biome(biome: String) -> Texture2D:
+ var path = "res://assets/planets/planet_%s.png" % biome
+ if ResourceLoader.exists(path):
+ return load(path)
+ return load("res://assets/planets/planet_barren.png") # fallback
+```
+
+For `biome_summary = "temperate_terminator"`, the image maps directly to
+`planet_temperate_terminator.png`.
+
+---
+
+## Coverage
+
+| `biome_summary` value | Image | Inhabited planets using this type |
+|-----------------------|-------|------------------------------------|
+| `temperate` | ✓ | Majority of wave_1/wave_2 inhabited worlds |
+| `temperate_terminator` | ✓ | Close-orbit M-dwarf worlds (Feldmark, Caparica) |
+| `oceanic` | ✓ | Aquaculture worlds |
+| `arid` | ✓ | Mars-analog, dry inner worlds |
+| `frozen` | ✓ | Outer habitable zone, cold worlds |
+| `volcanic` | ✓ | Young volcanic, active worlds |
+| `barren` | ✓ | Airless rocky bodies, uninhabited |
+| `gas_giant` | — | Not needed (gas giants not inhabited, no panel) |
+
+---
+
+## Visual Grammar Notes
+
+All planet renders are consistent with the implant UI aesthetic:
+
+- **Background:** `#04060a` space (near-black, matches `#0d1117` panel)
+- **Star field:** Sparse white dots, random but seeded (reproducible)
+- **Atmosphere rim:** Blue-white glow on all worlds with atmosphere — signals
+ habitability/breathability at a glance
+- **Terminator worlds:** Gold/dark split communicates the concept without labels
+- **Barren worlds:** No rim glow — instantly reads as airless
+
+The renders are deliberately non-photorealistic. They're what the player's implant
+processes during transit — a data-layer visualization, not a photograph.
+
+---
+
+## Future Additions
+
+When new `biome_summary` types are added to the atlas schema, add a new renderer
+function to `generate_planets.py` following the existing pattern. The functions
+are self-contained — no cross-dependencies.
+
+Potential additions as the Reach fills out:
+- `jungle` — dense green, high cloud, equatorial band
+- `desert` (distinct from `arid` — hotter, brighter orange)
+- `swamp` — dark green-brown
+- `gas_giant_banded` — for the implant wiki page of orbital gas giants
diff --git a/pyproject.toml b/pyproject.toml
index 5bfab990d..605305b31 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,6 +6,11 @@ dependencies = [
"PyYAML",
"jsonschema",
"numpy",
+ # scipy 1.17.1 — checked clean against NVD + OSV, no CVEs on record (2026-04-06)
+ "scipy==1.17.1",
+ # Pillow 12.2.0 — checked clean against NVD + OSV (2026-04-06)
+ # CVE-2026-25990 fixed in 12.1.1, CVE-2025-48379 fixed in 11.3.0
+ "Pillow==12.2.0",
]
[project.optional-dependencies]
diff --git a/spikes/heightmap-pipeline/PIPELINE.md b/spikes/heightmap-pipeline/PIPELINE.md
new file mode 100644
index 000000000..7b9e9fda3
--- /dev/null
+++ b/spikes/heightmap-pipeline/PIPELINE.md
@@ -0,0 +1,283 @@
+# Heightmap Pipeline — Spike Documentation
+
+**Ticket:** #778
+**Author:** Araminta
+**Date:** 2026-04-05
+**Status:** Spike complete — awaiting review before batch (#794, Sprint 33)
+**Outputs:**
+- `GJ144d_heightmap.png` (4096×2048px, equirectangular, cartographic)
+- `GJ144d_globe.png` (2048×2048px, ray-traced sphere)
+
+---
+
+## What This Spike Validates
+
+This spike validates the full annotated heightmap + globe pipeline from wiki data
+through to deliverable PNGs. Both outputs are produced from a single simulation run.
+
+- Wiki parsing → body_def dict (body_definition_parser)
+- FBM+Voronoi tectonic simulation → elevation grid (no external dependencies)
+- Temperature model (stellar physics + class clamping)
+- Moisture model (Hadley cells + ocean proximity + rain shadow)
+- Hillshade (gradient-based)
+- River network (D8 steepest descent, polyline output)
+- Extended Whittaker biome classification (absolute Kelvin — no frozen-world tropics)
+- Annotated equirectangular heightmap render
+- Ray-traced globe render with terrain-driven surface, PBR lighting, atmosphere
+
+**The pipeline is confirmed viable for batch production (#794).**
+
+---
+
+## Planet: Kallast (GJ144d)
+
+Selected because it showcases temperate terrain variety and the wiki narrative
+provides a direct visual brief.
+
+| Property | Value | Source |
+|----------|-------|--------|
+| Planet ID | `GJ144d` | wiki bodies table |
+| System | Ran (GJ 144) | wiki |
+| Star | K2V | wiki |
+| Planet class | temperate | wiki |
+| Hydrosphere | ocean | wiki |
+| Atmosphere | breathable | wiki |
+| Gravity | 0.95g | wiki |
+| Population | 2,000,000,000 | wiki |
+| Terrain character | Amber continental shelves, irrigation channels | wiki narrative |
+
+---
+
+## Pipeline Architecture
+
+```
+Input: wiki/star-systems/GJ-144/index.md
+ ↓ body_definition_parser.parse_system()
+
+Stage 1: Body definition
+ Parses bodies table → body_def dict per planet
+ Derives: seed (MD5 of body_id), distance_au (Kepler),
+ land_fraction (hydrosphere→ HYDRO_LAND), polar_ice_lat,
+ axial_tilt (CLASS_TILT), tectonics, atmosphere
+ "rand" sentinels → seeded randomization within class bounds
+ Runtime: <0.1s
+
+Stage 2: Elevation (planet_simulation.compute_elevation)
+ Primary continent mask: FBM with domain warp (3 independent fields)
+ Tectonic ridges: Voronoi plate boundaries + domain warp (curves)
+ Detail noise: FBM high-frequency layer
+ Dynamic sea level: np.percentile(elev, ocean_pct)
+ CRITICAL: right-skewed FBM output requires percentile-derived threshold.
+ Fixed fraction does not produce the target land coverage.
+ Erosion: slope-weighted gaussian smoothing, N passes (varies by tectonics)
+ Polar ice flattening at high latitudes
+ All longitude noise uses 3D circle projection for seamless wrapping.
+ Runtime: ~3-5s (512×256 grid)
+
+Stage 3: Temperature (planet_simulation.compute_temperature)
+ Stellar equilibrium temp (Stefan-Boltzmann) → greenhouse offset →
+ CLASS_T_BAND clamp → latitude gradient → elevation lapse rate →
+ class offset → geothermal boost
+ CRITICAL: output is absolute Kelvin, not normalised.
+ Biome classification uses raw K values to avoid frozen-world misclassification.
+ Normalisation happens AFTER biome classification for renderer display.
+
+Stage 4: Moisture (planet_simulation.compute_moisture)
+ Hadley cell bands (ITCZ + subtropical high + polar) + ocean proximity +
+ temperature contribution × rain shadow factor
+ Class/hydrosphere scale factors applied per planet type
+
+Stage 5: Hillshade (planet_simulation.compute_hillshade)
+ Gradient-based normal → dot product with sun direction (315°az, 45°alt)
+ Used for elevation shading in both heightmap and globe renders
+
+Stage 6: Rivers (planet_simulation.compute_rivers)
+ D8 steepest-descent flow from high-moisture local maxima
+ Output: list of (row, col) polylines in simulation grid coordinates
+ Max rivers capped per planet class (arid=3, frozen=2, default=12)
+ River list stored in terrain dict; renderer scales coords to output resolution
+
+Stage 7: Biome classification (planet_simulation.compute_biome)
+ Extended Whittaker table lookup in absolute Kelvin × moisture [0,1]
+ Ocean depth bands (0=deep, 1=mid, 2=shallow)
+ Frozen ocean override (class 26 = ice shelf, distinct from land ice)
+ Modifier stack: geothermal, chemosynthetic, UV, substrate overrides
+ 26 biome classes + 1 unused slot (0=deep ocean … 26=ice shelf)
+
+Stage 8: Heightmap render (render_heightmap.render_heightmap)
+ Layer compositing order:
+ 1. Biome base colour (cartographic or photographic palette)
+ 2. Ocean depth gradient (3-stop blend: shallow → mid → deep)
+ 3. Elevation shading on land [0.88, 1.06] factor
+ 4. Hillshade blend (0.55 hs + 0.45 flat) — land only
+ 5. Coastline ring (binary_dilation XOR, 2px dark border)
+ 6. Rivers: PIL polylines scaled from grid coords to output pixels
+ Width 1-3px scaled by path length (longer = wider)
+ 7. Lat/lon grid every 30° (12% white overlay, 2px)
+ 8. Title panel (name, class, star, orbit, atmo, hydro)
+ 9. Biome legend (present-only swatches, natural features only)
+ Output resolution: configurable, default 4096×2048. UI_SCALE = W/1024.
+
+Stage 9: Globe render (planet_renderer.render_globe)
+ Ray-traced sphere (camera at z=3, looking at origin)
+ Terrain-driven surface: biome→ photographic palette (27 classes, 0-26)
+ Extended colour array (_EXTENDED_BIOME_COLORS) covers full class range
+ including exotic classes 20-26. BIOME_COLORS (0-19) used only for
+ procedural fallback when terrain=None.
+ Elevation shading on land cells
+ Full lighting: smoothstep diffuse, terminator warm scatter, ocean specular,
+ atmospheric rim glow, night-side ambient
+ Optional cloud layer: moisture-driven coverage + gaussian blur
+ Star field background, atmosphere halo
+ Output: RGBA PNG (alpha=255 on sphere+ring pixels, 0 on background)
+ Default size: 2048×2048
+```
+
+---
+
+## Two-Layer Model
+
+Heightmaps are **geographic only**. Human data lives in JSON sidecars.
+
+```
+GJ144d_heightmap.png ← geographic render: terrain, rivers, biomes, grid
+GJ144d_settlements.json ← human layer: city names + grid coordinates (Phase 3)
+```
+
+The PNG renders: terrain classification colors, elevation shading, river network,
+lat/lon grid, title panel, biome legend.
+
+The PNG does NOT render: settlements, roads, freight elevators, city labels,
+irrigation channels, or any human-activity markers. Those exist in the JSON
+sidecar and are overlaid by the atlas app (Phase 3) when the map is interactive.
+
+**Rationale:** A geographic heightmap is a stable base layer. The human overlay
+changes as the simulation runs. Keeping them separate means the PNG can be
+regenerated from terrain data without recomputing settlement placement.
+
+---
+
+## Configuration
+
+The body_def drives all simulation parameters. Key fields parsed from wiki:
+
+| Field | Source | Effect |
+|-------|--------|--------|
+| `planet_class` | wiki type column | CLASS_T_BAND, erosion passes, river cap |
+| `hydrosphere` | wiki hydro column | land_fraction, moisture scale |
+| `atmosphere` | wiki atmo column | greenhouse offset, moisture computation |
+| `gravity_g` | wiki gravity column | informs max_elevation_km |
+| `star.type` | system profile | luminosity, star tint on globe |
+| `orbit.distance_au` | derived (Kepler) | equilibrium temperature |
+
+No manual per-planet configuration required for batch. All parameters derive
+from the wiki's bodies table.
+
+---
+
+## Running
+
+```bash
+# Standard (4096×2048 heightmap + 2048×2048 globe)
+python3 spikes/heightmap-pipeline/generate_kallast.py
+
+# Fast iteration (1024×512 + 512×512)
+python3 spikes/heightmap-pipeline/generate_kallast.py --small
+
+# Photographic colour mode (orbital appearance, dark/muted)
+python3 spikes/heightmap-pipeline/generate_kallast.py --render-mode photographic
+
+# High resolution (8192×4096 heightmap)
+python3 spikes/heightmap-pipeline/generate_kallast.py --out-w 8192 --out-h 4096
+
+# Custom output paths
+python3 spikes/heightmap-pipeline/generate_kallast.py \
+ --output /tmp/kallast_hm.png \
+ --globe-output /tmp/kallast_globe.png
+```
+
+Dependencies: `scipy`, `numpy`, `Pillow` (no external simulation engine required)
+
+Individual prototype modules also have standalone CLIs:
+
+```bash
+# Parse body defs from wiki (inspect what the parser produces)
+python3 prototype/body_definition_parser.py wiki/star-systems/GJ-144/index.md --out-dir /tmp/defs/
+
+# Simulate only (inspect terrain grids)
+python3 prototype/planet_simulation.py /tmp/defs/GJ144d_def.json --save-grids
+
+# Render heightmap from body_def JSON
+python3 prototype/render_heightmap.py /tmp/defs/GJ144d_def.json [--small]
+```
+
+---
+
+## Known Issues / Calibration Notes for Batch
+
+1. **Sea level uses percentile, not fixed fraction.**
+ FBM output is non-uniformly distributed — `np.percentile(elev, ocean_pct)` gives
+ correct land coverage. Fixed fractions do NOT work reliably.
+
+2. **Temperature is absolute Kelvin throughout.**
+ The Whittaker biome table uses K, not normalised [0,1]. This is intentional:
+ prevents a frozen world's "warm" pole from classifying as tropical.
+ Normalisation happens after biome classification for renderer display only.
+
+3. **Class clamping (CLASS_T_BAND).**
+ If stellar physics puts a temperate world outside its expected band (e.g. wiki
+ says "temperate" but distance_au makes it hotter), temperature is clamped.
+ The script logs a warning when clamping occurs. Check these during batch review.
+
+4. **Rivers are polylines, not pixel masks.**
+ The simulation outputs `(row, col)` paths. The renderer scales to output resolution
+ and draws with PIL's anti-aliased line tool. Width 1-3px scales with path length.
+ No pre-upscale painting / post-LANCZOS issues (old pyplatec pipeline concern).
+
+5. **Globe uses photographic palette.**
+ When terrain data is provided, `_EXTENDED_BIOME_COLORS` (27 entries, photographic
+ values) is used instead of `BIOME_COLORS` (19 entries, procedural-style values).
+ The extended array covers exotic classes 20-26 (lava fields, chemosynthetic mats,
+ ash fields, ice shelves) which the old array silently clipped to index 19.
+
+---
+
+## Batch Estimate (#794)
+
+Grid size: 512×256 (prototype default). Output: 4096×2048 heightmap + 2048 globe.
+
+Timing per planet (single-threaded, approximate):
+
+| Stage | Time |
+|-------|------|
+| Parse wiki | <0.1s |
+| Simulation (elev+temp+moist+hs+rivers+biome) | ~3–6s |
+| Heightmap render (4096×2048) | ~2–4s |
+| Globe render (2048×2048) | ~3–6s |
+| **Total per planet** | **~8–16s** |
+
+Full batch of 301 planets: ~40–80 minutes single-threaded.
+Parallelisable across all CPU cores (no shared state) — ~10–20 min on 4 cores.
+
+For faster batch: `--globe-size 1024` halves globe render time with acceptable
+fidelity for thumbnail/wiki use. Full 2048 globe recommended for atlas app.
+
+---
+
+## Open Questions for Review
+
+Before starting batch (#794):
+
+1. **Output resolution.** Default 4096×2048 for heightmap and 2048 for globe.
+ Is this sufficient for the Phase 3 atlas app, or do we need 8192×4096?
+ 8192×4096 is available via `--out-w 8192 --out-h 4096` — adds ~4× render time.
+
+2. **Render mode for batch.** Cartographic (NG map style) or photographic (orbital)?
+ Cartographic reads more clearly as a map; photographic looks more realistic as a
+ wiki thumbnail. Could produce both.
+
+3. **River calibration per class.** max_rivers=12 is the default. Arid worlds get 3,
+ frozen get 2. Is this density appropriate? Can compare against wiki narrative.
+
+4. **Cloud layer.** Globe render supports moisture-driven clouds (enabled via body_def
+ `clouds.enabled: true`). Should this be enabled for inhabited temperate worlds?
diff --git a/spikes/heightmap-pipeline/generate_kallast.py b/spikes/heightmap-pipeline/generate_kallast.py
new file mode 100644
index 000000000..d2ed87455
--- /dev/null
+++ b/spikes/heightmap-pipeline/generate_kallast.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+"""
+Kallast (GJ144d) heightmap + globe generator — Settled Reach
+
+Reads planet parameters from the wiki (wiki/star-systems/GJ-144/index.md),
+runs the FBM+Voronoi simulation pipeline, and produces:
+
+ 1. Annotated equirectangular heightmap PNG (geographic features only)
+ 2. Globe render PNG (ray-traced sphere with terrain data wrapped onto it)
+
+Both outputs share the same simulation run — terrain is computed once.
+
+Pipeline:
+ body_definition_parser → parse wiki → body_def dict
+ planet_simulation → FBM+Voronoi elevation, temperature, moisture,
+ hillshade, rivers, biome classification
+ render_heightmap → annotated equirectangular PNG
+ planet_renderer → ray-traced globe PNG (terrain-driven surface)
+
+Usage:
+ python3 generate_kallast.py [options]
+
+ --small Fast iteration: 1024×512 heightmap, 512×512 globe
+ --render-mode cartographic (NG map style) | photographic (orbital)
+
+Default outputs:
+ /mnt/user-data/outputs/GJ144d_heightmap.png (4096×2048)
+ /mnt/user-data/outputs/GJ144d_globe.png (2048×2048)
+"""
+
+import argparse
+import os
+import sys
+import time
+
+# ── Prototype pipeline on path ────────────────────────────────────────────────
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_PROTO = os.path.join(_HERE, "prototype")
+if _PROTO not in sys.path:
+ sys.path.insert(0, _PROTO)
+
+from body_definition_parser import parse_system
+from planet_simulation import simulate
+from render_heightmap import render_heightmap
+from planet_renderer import render_globe
+
+# ── Default paths ─────────────────────────────────────────────────────────────
+# Sprint worktree root is two levels above the spike dir.
+_SPRINT_ROOT = os.path.normpath(os.path.join(_HERE, "..", ".."))
+DEFAULT_WIKI = os.path.join(_SPRINT_ROOT, "wiki", "star-systems", "GJ-144", "index.md")
+DEFAULT_OUT_HM = "/mnt/user-data/outputs/GJ144d_heightmap.png"
+DEFAULT_OUT_GL = "/mnt/user-data/outputs/GJ144d_globe.png"
+DEFAULT_BODY = "GJ144d"
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate Kallast (GJ144d) heightmap + globe",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__,
+ )
+ parser.add_argument(
+ "--output", default=DEFAULT_OUT_HM, metavar="PATH",
+ help="Heightmap PNG output path")
+ parser.add_argument(
+ "--globe-output", dest="globe_output",
+ default=DEFAULT_OUT_GL, metavar="PATH",
+ help="Globe PNG output path")
+ parser.add_argument(
+ "--body-id", default=DEFAULT_BODY, metavar="ID",
+ help="Body ID to render (default: GJ144d)")
+ parser.add_argument(
+ "--wiki", default=DEFAULT_WIKI, metavar="PATH",
+ help="Path to GJ-144 wiki index.md")
+ parser.add_argument(
+ "--out-w", type=int, default=4096, metavar="N",
+ help="Heightmap output width (default: 4096)")
+ parser.add_argument(
+ "--out-h", type=int, default=2048, metavar="N",
+ help="Heightmap output height (default: 2048)")
+ parser.add_argument(
+ "--globe-size", type=int, default=2048, metavar="N",
+ help="Globe output size in px (default: 2048)")
+ parser.add_argument(
+ "--render-mode",
+ choices=["cartographic", "photographic"],
+ default="cartographic",
+ help="Heightmap colour mode (default: cartographic)")
+ parser.add_argument(
+ "--small", action="store_true",
+ help="Fast iteration: 1024×512 heightmap, 512×512 globe")
+ args = parser.parse_args()
+
+ if args.small:
+ args.out_w = 1024
+ args.out_h = 512
+ args.globe_size = 512
+
+ t_start = time.time()
+
+ # ── 1. Parse body definition from wiki ───────────────────────────────────
+ print(f"Parsing wiki: {args.wiki}")
+ if not os.path.exists(args.wiki):
+ raise SystemExit(
+ f"Wiki not found: {args.wiki}\n"
+ "Pass --wiki if running from a non-standard location.")
+
+ bodies = parse_system(args.wiki)
+ body_def = next((b for b in bodies if b["id"] == args.body_id), None)
+ if body_def is None:
+ available = [b["id"] for b in bodies]
+ raise SystemExit(
+ f"Body {args.body_id!r} not found in wiki.\n"
+ f"Available: {available}\n"
+ "Use --body-id to specify a different body.")
+
+ pc = body_def.get("planet_class", "?")
+ seed = body_def.get("seed", 0)
+ print(f" {body_def['id']} class={pc} seed={seed}")
+
+ # ── 2. Simulate terrain ──────────────────────────────────────────────────
+ print("Simulating terrain…")
+ t0 = time.time()
+ terrain = simulate(body_def)
+ sim_t = time.time() - t0
+
+ if not terrain:
+ raise SystemExit(f"{args.body_id} is a gas giant — no terrain to render.")
+
+ sea = terrain["sea_level"]
+ nriv = len(terrain["rivers"])
+ print(f" Done in {sim_t:.1f}s "
+ f"sea_level={sea:.3f} rivers={nriv}")
+
+ if terrain.get("temperature_clamped"):
+ raw_K = terrain.get("temperature_raw_K", "?")
+ band = terrain.get("temperature_band_K", [])
+ print(f" T_raw={raw_K}K clamped to {band} for {pc}")
+
+ # ── 3. Render heightmap ──────────────────────────────────────────────────
+ size_str = f"{args.out_w}×{args.out_h}"
+ print(f"Rendering heightmap {size_str} ({args.render_mode})…")
+ t1 = time.time()
+ hm = render_heightmap(
+ body_def, terrain,
+ out_w=args.out_w, out_h=args.out_h,
+ render_mode=args.render_mode)
+ hm_t = time.time() - t1
+
+ os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
+ hm.save(args.output, format="PNG")
+ print(f" Saved: {args.output} ({hm_t:.1f}s)")
+
+ # ── 4. Render globe ──────────────────────────────────────────────────────
+ print(f"Rendering globe {args.globe_size}×{args.globe_size}…")
+ t2 = time.time()
+ glob = render_globe(body_def, terrain=terrain, size=args.globe_size)
+ gl_t = time.time() - t2
+
+ os.makedirs(os.path.dirname(os.path.abspath(args.globe_output)), exist_ok=True)
+ glob.save(args.globe_output, format="PNG")
+ print(f" Saved: {args.globe_output} ({gl_t:.1f}s)")
+
+ total = time.time() - t_start
+ print(f"\nTotal: {total:.1f}s "
+ f"(sim={sim_t:.1f}s hm={hm_t:.1f}s glob={gl_t:.1f}s)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/spikes/heightmap-pipeline/kallast.json b/spikes/heightmap-pipeline/kallast.json
new file mode 100644
index 000000000..ef0438aad
--- /dev/null
+++ b/spikes/heightmap-pipeline/kallast.json
@@ -0,0 +1,53 @@
+{
+ "id": "GJ144d",
+ "name": "Kallast",
+ "body_type": "planet",
+ "planet_class": "temperate",
+ "body_scale": "planet",
+ "seed": 122361710,
+
+ "star": {
+ "type": "K",
+ "luminosity_solar": 0.40,
+ "color_temp_K": 4500
+ },
+
+ "orbit": {
+ "distance_au": 0.617,
+ "period_days": 280,
+ "axial_tilt_deg": 18.0
+ },
+
+ "physical": {
+ "gravity_g": 0.95,
+ "oblateness": 0.003,
+ "atmosphere": "standard",
+ "atmosphere_color": [0.45, 0.65, 1.0]
+ },
+
+ "terrain": {
+ "land_fraction": 0.40,
+ "polar_ice_lat": 0.78,
+ "tectonics": "active",
+ "max_elevation_km": 10.0
+ },
+
+ "environment": {
+ "geothermal_flux": "low",
+ "uv_index": "low",
+ "substrate": "silicate",
+ "chemosynthetic": false,
+ "hydrosphere": "ocean"
+ },
+
+ "clouds": {
+ "enabled": true,
+ "coverage_base": 0.45
+ },
+
+ "render": {
+ "globe_light_angle_deg": 125,
+ "specular_ocean": true,
+ "night_side_ambient": 0.025
+ }
+}
diff --git a/spikes/heightmap-pipeline/prototype/HANDOVER.md b/spikes/heightmap-pipeline/prototype/HANDOVER.md
new file mode 100644
index 000000000..58a9059bd
--- /dev/null
+++ b/spikes/heightmap-pipeline/prototype/HANDOVER.md
@@ -0,0 +1,458 @@
+# Settled Reach — Planet Generator Handover
+**Date:** 2026-04-06
+**Status:** Spike complete — heightmap pipeline validated, globe renderer disconnected pending integration
+**For:** Claude Code spike import
+
+---
+
+## What Was Built
+
+A procedural planet generator that reads system wiki markdown files and produces annotated equirectangular heightmap PNGs (4096×2048) as the primary output. A globe renderer exists as a separate module and will be integrated later.
+
+### Four files
+
+| File | Role | Status |
+|------|------|--------|
+| `body_definition_parser.py` | `index.md` → `body_def.json` | Complete |
+| `planet_simulation.py` | `body_def` → terrain grids | Complete |
+| `render_heightmap.py` | terrain grids → 4096×2048 PNG | Complete |
+| `planet_renderer.py` | terrain grids → globe PNG | Disconnected — integrate later |
+
+### Pipeline
+
+```
+index.md
+ └─► body_definition_parser.py → body_def.json (one per body)
+ └─► planet_simulation.py → terrain dict (float32 grids)
+ └─► render_heightmap.py → heightmap.png (PRIMARY OUTPUT)
+ └─► planet_renderer.py → globe.png (SECONDARY — disconnected)
+```
+
+### Output hierarchy
+
+- **Primary:** `{body_id}_heightmap.png` — 4096×2048 equirectangular cartographic map. Geographic only, no cultural data. Feeds tile generator.
+- **Secondary:** `{body_id}_globe.png` — sphere render from same terrain. Wiki mugshot. Not yet wired to simulation output.
+- **Sidecar (pending):** `{body_id}_geo_data.json` — serialised float grids + river polylines for tile generator. Not yet written.
+
+---
+
+## Input Format: `index.md`
+
+System wiki pages in markdown. The parser reads the **Celestial Bodies** table and the **System Profile** section.
+
+### System Profile (used for star type)
+
+```markdown
+| **Star** | K2V · 0.38 ly |
+```
+
+Extracts spectral type from the first letter: `K2V → K`.
+
+### Celestial Bodies table
+
+```markdown
+| Orbit | ID | Name | Type | Inhabited | Pop | Mass | Gravity | Year (d) | Day (h) | Atmo | Biome | Hydro | Economy | Settlement | Industrial |
+|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
+| 3 | `GJ0d` | Earth | planet | yes | 8.5B | terrestrial | 1.00g | 365 | 24.0 | breathable | temperate | ocean | manufacturing | urban_concentrated | — |
+| ↳ 3.1 | `GJ0d-1` | Luna | moon | yes | 350M | dwarf | 0.17g | 27 | 655.7 | none | barren | none | manufacturing | domed | — |
+```
+
+**Used fields:**
+
+| Column | Used for | Notes |
+|--------|----------|-------|
+| `ID` | `body_id`, `seed` | Seed is hash of ID string — deterministic |
+| `Name` | display name | Optional, `—` is fine |
+| `Type` | `body_type` | `planet`, `moon`, `gas_giant` |
+| `Mass` | `body_scale`, `oblateness` | `dwarf` → moon scale |
+| `Gravity` | `physical.gravity_g` | Strip `g` suffix |
+| `Year (d)` | `orbit.distance_au` | Derived via Kepler + star luminosity |
+| `Atmo` | `physical.atmosphere` | See mapping below |
+| `Biome` | `planet_class` | Direct mapping |
+| `Hydro` | `terrain.land_fraction` | See mapping below |
+| `↳` prefix | `is_moon_row` | Determines parent-is-giant for tidal heating |
+
+**Not used:** `Day (h)`, `Economy`, `Settlement`, `Industrial`, `Inhabited`, `Pop`. These are cultural data — the renderer is geographic only.
+
+### Field mappings
+
+**Atmosphere → density:**
+```
+none → none
+thin → thin
+breathable → standard
+dense → thick
+toxic → thick (Venus-style reducing atmosphere)
+```
+
+**Biome → planet_class:**
+```
+temperate → temperate
+arid → arid
+frozen → frozen
+volcanic → volcanic
+barren → barren
+forest → forest
+oceanic → oceanic
+```
+
+**Hydrosphere → land_fraction range (randomised within):**
+```
+ocean → [0.28, 0.50]
+liquid_water → [0.35, 0.65]
+rivers → [0.50, 0.75] (Titan-style)
+ice → [0.70, 0.90]
+subsurface → [0.90, 0.99]
+none → [0.97, 1.00]
+```
+
+### Override file format
+
+Per-body overrides in a separate JSON file. Any explicit value wins over derived/randomised values. Use `"rand"` as sentinel for normal behaviour.
+
+```json
+{
+ "GJ0d": {
+ "orbit": { "axial_tilt_deg": 23.4 },
+ "terrain": { "land_fraction": 0.29, "polar_ice_lat": 0.78 }
+ },
+ "GJ0g": {
+ "rings": {
+ "enabled": true,
+ "inner_radius_factor": 1.12,
+ "outer_radius_factor": 2.65,
+ "opacity_base": 0.62,
+ "ring_color": [0.88, 0.78, 0.55]
+ },
+ "gas_giant": { "band_palette": "saturnian" }
+ },
+ "GJ0f": { "rings": false },
+ "GJ0c": { "planet_class": "volcanic" }
+}
+```
+
+---
+
+## Body Definition Schema (`body_def.json`)
+
+Output of `body_definition_parser.py`. Input to `planet_simulation.py` and `planet_renderer.py`.
+
+```json
+{
+ "id": "GJ144d",
+ "name": "Kallast",
+ "body_type": "planet",
+ "planet_class": "temperate",
+ "body_scale": "planet",
+ "seed": 144042,
+
+ "star": {
+ "type": "K",
+ "luminosity_solar": 0.40,
+ "color_temp_K": 4500
+ },
+
+ "orbit": {
+ "distance_au": 0.38,
+ "period_days": 312,
+ "axial_tilt_deg": 18.0
+ },
+
+ "physical": {
+ "gravity_g": 0.95,
+ "oblateness": 0.003,
+ "atmosphere": "standard",
+ "atmosphere_color": [0.45, 0.65, 1.0]
+ },
+
+ "terrain": {
+ "land_fraction": 0.40,
+ "polar_ice_lat": 0.78,
+ "tectonics": "active",
+ "max_elevation_km": 10.0
+ },
+
+ "environment": {
+ "geothermal_flux": "low",
+ "uv_index": "low",
+ "substrate": "silicate",
+ "chemosynthetic": false,
+ "hydrosphere": "ocean"
+ },
+
+ "clouds": {
+ "enabled": true,
+ "coverage_base": 0.45
+ },
+
+ "render": {
+ "globe_light_angle_deg": 125,
+ "specular_ocean": true,
+ "night_side_ambient": 0.025
+ },
+
+ "gas_giant": {
+ "band_palette": "jovian",
+ "storm_count": 3,
+ "storm_max_size": 0.10
+ },
+
+ "rings": {
+ "enabled": true,
+ "inner_radius_factor": 1.12,
+ "outer_radius_factor": 2.65,
+ "opacity_base": 0.62,
+ "ring_color": [0.88, 0.78, 0.55]
+ }
+}
+```
+
+`gas_giant` and `rings` blocks only present when applicable.
+
+---
+
+## Terrain Dict Schema
+
+Output of `planet_simulation.simulate()`. All grids are `(GRID_H=256, GRID_W=512)`.
+
+```python
+{
+ # Float grids — float32 [0,1] unless noted
+ "elevation": np.ndarray (256, 512), # [0,1] normalised
+ "temperature": np.ndarray (256, 512), # [0,1] normalised FOR RENDERER
+ # biome lookup uses absolute K internally
+ "moisture": np.ndarray (256, 512), # [0,1]
+ "hillshade": np.ndarray (256, 512), # [0,1]
+
+ # Classification grids
+ "biome": np.ndarray (256, 512), # int8, class IDs (see table below)
+ "surface_water": np.ndarray (256, 512), # bool, True = ocean/lake
+
+ # River data
+ "river_grid": np.ndarray (256, 512), # bool, True = river cell
+ "rivers": list of [(row, col), ...], # polylines in grid coords
+
+ # Scalar
+ "sea_level": float, # elevation threshold [0,1]
+
+ # Audit trail
+ "temperature_clamped": bool, # True if T_raw fell outside CLASS_T_BAND
+ "temperature_raw_K": float, # physical equilibrium temp before clamping
+ "temperature_band_K": [float, float], # [lo, hi] band applied
+
+ # Grid metadata
+ "_grid_w": 512,
+ "_grid_h": 256,
+}
+```
+
+**Grid coordinate convention:**
+- Row 0 = north pole, row 255 = south pole
+- Col 0 = 180°W, col 511 = 180°E
+- Longitude wraps: col 0 and col 511 are adjacent
+- Equirectangular — maps directly to heightmap PNG with same aspect ratio
+
+---
+
+## Biome Class Table
+
+Temperature axis uses **absolute Kelvin**. This is intentional and important — see Design Decisions below.
+
+### Base Whittaker classes
+
+| ID | Name | Temp range (K) | Moisture [0,1] | Cartographic colour |
+|----|------|----------------|----------------|---------------------|
+| 0 | ocean deep | — | — | (80, 155, 190) |
+| 1 | ocean mid | — | — | (110, 185, 215) |
+| 2 | ocean shallow | — | — | (150, 210, 230) |
+| 3 | coast | — | — | (155, 185, 130) |
+| 4 | lowland | — | — | (120, 165, 100) |
+| 5 | tropical rainforest | 303–999 | 0.65–1.00 | (50, 140, 65) |
+| 6 | tropical seasonal | 303–999 | 0.35–0.65 | (90, 170, 75) |
+| 7 | savanna | 293–999 | 0.18–0.35 | (175, 210, 105) |
+| 8 | temperate grassland | 278–303 | 0.30–0.55 | (190, 210, 110) |
+| 9 | temperate deciduous | 273–308 | 0.30–1.00 | (70, 148, 70) |
+| 10 | temperate rainforest | 278–303 | 0.60–1.00 | (45, 125, 65) |
+| 11 | boreal / taiga | 253–278 | 0.15–1.00 | (28, 88, 55) |
+| 12 | shrubland | 278–303 | 0.10–0.30 | (168, 168, 95) |
+| 13 | temperate desert | 273–293 | 0.00–0.30 | (215, 200, 155) |
+| 14 | subtropical desert | 283–308 | 0.00–0.18 | (210, 165, 85) |
+| 15 | hot desert | 303–999 | 0.00–0.18 | (215, 138, 55) |
+| 16 | tundra | 233–263 | 0.00–1.00 | (198, 185, 145) |
+| 17 | ice / snow | 200–273 | 0.00–1.00 | (235, 238, 242) |
+| 18 | mountain rock | — | — | (148, 135, 120) |
+
+### Modifier-applied classes
+
+Applied after Whittaker lookup based on `environment` fields.
+
+| ID | Name | Trigger condition | Cartographic colour |
+|----|------|-------------------|---------------------|
+| 19 | lava field | `volcanic` class + high elevation + geothermal extreme/high | (55, 32, 22) |
+| 20 | chemosynthetic mat | `chemosynthetic=True` + moderate temperature zone | (45, 88, 52) |
+| 21 | thermophilic field | geothermal extreme/high + high temperature + low elevation | (118, 72, 40) |
+| 22 | sulfuric scrub | substrate=sulfuric + mid elevation + warm | (148, 130, 58) |
+| 23 | cryptobiotic crust | uv_index extreme/high + thin/no atmosphere + exposed mid elevation | (130, 118, 100) |
+| 25 | ash field | `volcanic` class + mid elevation + geothermal extreme/high | (68, 58, 52) |
+| 26 | ice shelf | surface_water=True + temperature < 271K | (245, 246, 248) |
+
+### Dual colour palette
+
+Each biome has two colour sets in `render_heightmap.BIOME_PALETTE`:
+
+```python
+BIOME_PALETTE = {
+ 5: {
+ "cartographic": (50, 140, 65), # NG map style — readable, saturated
+ "photographic": (12, 38, 18), # orbital appearance — dark, muted
+ },
+ ...
+}
+```
+
+Select mode via `render_heightmap(bd, terrain, render_mode="cartographic")` or `"photographic"`. Per-planet alien colour overrides can patch `BIOME_PALETTE[class_id]["photographic"]` before rendering — e.g. `(120, 20, 80)` for purple alien forest.
+
+---
+
+## Design Decisions
+
+### Fiction wins over physics — descriptor-anchored temperature
+
+The source data (`index.md`) was authored with "close enough" orbital parameters. A world described as `temperate` might be at 0.38 AU from a K-dwarf which physically gives 392K (119°C) — uninhabitable.
+
+**Decision:** Compute raw equilibrium temperature from stellar physics (`T = 278.5 * L^0.25 / sqrt(a)`), then clamp to a band defined by `planet_class`. The fiction wins; physics sets the gradient within the band.
+
+```python
+CLASS_T_BAND = {
+ "temperate": (275, 305),
+ "oceanic": (278, 300),
+ "forest": (275, 308),
+ "arid": (295, 340),
+ "frozen": (210, 265),
+ "volcanic": (290, 380),
+ "barren": (180, 380),
+}
+```
+
+A close-in temperate world sits at the warm end (305K). A far-out one sits at the cool end (275K). The audit trail is preserved in the terrain dict (`temperature_clamped`, `temperature_raw_K`, `temperature_band_K`).
+
+### Whittaker table uses absolute Kelvin — not normalised temperature
+
+Early implementation normalised world temperature to [0,1] before the Whittaker lookup. A frozen planet at -60°C to -10°C would normalise its warmest cells to 1.0 and classify them as tropical rainforest.
+
+**Decision:** Biome lookup operates in absolute Kelvin throughout. Temperature is only normalised to [0,1] for the renderer display, *after* biome classification is complete.
+
+### Ice shelf is a separate biome class (26)
+
+Frozen ocean surface is distinct from land ice. Class 17 (land ice) has hillshade texture because it's terrain. Class 26 (ice shelf) renders near-pure white to appear flat — the ocean beneath has no surface relief.
+
+**Trigger:** `surface_water=True AND temperature < 271K` (salinity-adjusted freezing point).
+
+### `"rand"` sentinel for body definition parameters
+
+Any field in `body_def.json` can be `"rand"` to indicate seeded randomisation within planet-class-appropriate bounds. An explicit value always overrides. This allows:
+- Minimal authoring for batch production (all 301 systems)
+- Full override control for narratively significant bodies (Saturn's rings, Earth's axial tilt)
+
+**Pattern:** Sol override file sets `rings: true` on Saturn, `rings: false` on Jupiter, `axial_tilt_deg: 23.4` on Earth. Everything else randomises from the body ID hash seed — deterministic across runs.
+
+### Seed is derived from body ID
+
+`seed = MD5(body_id)[:4]` as integer. Same body ID always produces the same terrain. Seeds 1–9 are valid for testing but production bodies use their actual ID-derived seed.
+
+### Continent separation uses multiplicative gating with S-curve secondary
+
+The continent mask composites three independently-normalised noise layers:
+1. **Primary** (large, slow): main continental shapes
+2. **Secondary** (medium, independent seed): S-curve contrasted, then multiplied against primary — where secondary is low, it collapses the primary to ocean, creating channels and separation
+3. **Rift** (anisotropic, stretched V axis): thin elongated features — island chains, isthmuses
+
+**Key:** Secondary uses S-curve contrast (`sigmoid(k*(x-0.5))`) not power curve. Power curves are seed-dependent in effect — some seeds produce mostly-low secondary fields that fail to separate continents. S-curve reliably pushes highs high and lows low regardless of field distribution.
+
+```python
+separated = primary * (0.4 + secondary * 0.6)
+combined = separated * 0.82 + (rift - 0.5) * 0.18
+```
+
+### Tectonic ridges use coordinate-space domain warping
+
+Ridge positions (not just amplitude) are warped by two noise passes before Voronoi distance computation. This produces curved arcing mountain ranges. The critical detail: warp the *coordinates fed to Voronoi*, not the output — warping the output only bends the height variation, not the ridge line geometry.
+
+### Longitude noise is seamless via 3D circle projection
+
+FBM is sampled on `(cos(u·2π)·r, sin(u·2π)·r, v)` where `r = freq/(2π)`. The radius compensation ensures one full longitude revolution spans the same spatial distance as `freq` units on the latitude axis — preserving aspect ratio. Without radius compensation, features appear ~6× smaller in longitude than latitude.
+
+V (latitude) is intentionally non-periodic — poles are endpoints, not a loop.
+
+### Heightmap is geographic only — cultural data is a sidecar
+
+The heightmap PNG renders: terrain classification, hillshade, rivers, coastlines, lat/lon grid, title panel.
+
+The heightmap does NOT render: settlements, roads, freight elevators, irrigation channels.
+
+**Rationale:** The geographic base layer is stable. Cultural overlays change as the simulation runs. Keeping them separate means the PNG can be regenerated without recomputing settlement placement, and the atlas app overlays them dynamically.
+
+---
+
+## Pending Items
+
+Before batch production:
+
+1. **`geo_data.json` exporter** — serialise terrain grids + river polylines for tile generator input. Alongside each heightmap PNG.
+
+2. **Pipeline runner** — single script `index.md [--overrides overrides.json] → output_dir/`. Runs parser → simulate → heightmap for every renderable body in the system.
+
+3. **Globe integration** — wire `planet_renderer.py` to use the photographic colour mode from `render_heightmap.BIOME_PALETTE` instead of its internal `BIOME_COLORS`. The renderer is disconnected from the simulation pipeline — it currently uses a procedural surface fallback when `terrain=None`. Integration pass: pass terrain dict through.
+
+4. **Edge cases to watch:**
+ - Fully frozen worlds (GJ406e): ice shelf distinction is invisible against land ice — correct behaviour, not a bug. Only meaningful on partially-frozen worlds where shelf meets open ocean.
+ - Barren worlds with very low land fraction: ocean biome dominates, crater detail disappears. Consider raising land_fraction floor for barren class.
+ - River routing on flat worlds: D8 downhill walk can get trapped in flat areas — `best_nr < 0` terminates early, producing short rivers. Calibrate moisture threshold per planet class.
+
+5. **Quality gate:** Run Sol system end-to-end — it has every body type (temperate, barren, frozen moons, volcanic moon, gas giants with and without rings, dwarf moons) and will stress-test the full pipeline.
+
+---
+
+## Dependencies
+
+```
+numpy
+scipy
+Pillow
+```
+
+No pyplatec — the spike's tectonic simulation was replaced with procedural FBM + Voronoi ridges which is faster, more controllable, and produces comparable results for the heightmap use case.
+
+---
+
+## Quick Start
+
+```python
+from body_definition_parser import parse_system
+from planet_simulation import simulate
+from render_heightmap import render_heightmap
+
+# Parse system — optionally with overrides
+defs = parse_system("sol/index.md", overrides={"GJ0g": {"rings": True}})
+
+for bd in defs:
+ terrain = simulate(bd)
+ if not terrain:
+ continue # gas giant — terrain dict is empty, globe renderer handles it
+ img = render_heightmap(bd, terrain)
+ img.save(f"{bd['id']}_heightmap.png")
+
+# Fast iteration at 1024x512
+img = render_heightmap(bd, terrain, out_w=1024, out_h=512)
+
+# Photographic mode for globe surface texture
+img = render_heightmap(bd, terrain, render_mode="photographic")
+```
+
+### Timing (512×256 simulation grid)
+- Simulate: ~3s per terrestrial body
+- Render 1024×512: ~0.2s
+- Render 4096×2048: ~4s
+- Gas giant (no simulation): ~0.3s
+
+Full batch of 301 systems × ~8 bodies ≈ 40 minutes single-threaded at full resolution. Parallelisable — no shared state between bodies.
diff --git a/spikes/heightmap-pipeline/prototype/body_definition_parser.py b/spikes/heightmap-pipeline/prototype/body_definition_parser.py
new file mode 100644
index 000000000..962c5cae4
--- /dev/null
+++ b/spikes/heightmap-pipeline/prototype/body_definition_parser.py
@@ -0,0 +1,766 @@
+"""
+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:
+ python3 body_definition_parser.py path/to/index.md [--out-dir ./defs]
+
+ # With overrides (e.g. Sol)
+ python3 body_definition_parser.py 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
+import sys
+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
+ATMO_MAP = {
+ "none": "none",
+ "thin": "thin",
+ "breathable": "standard",
+ "dense": "thick",
+ "toxic": "thick", # Venus-style reducing atmosphere
+}
+
+# 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
+BIOME_CLASS = {
+ "temperate": "temperate",
+ "arid": "arid",
+ "frozen": "frozen",
+ "volcanic": "volcanic",
+ "barren": "barren",
+ "forest": "forest",
+ "oceanic": "oceanic",
+}
+
+# planet_class → axial tilt range [min, max] degrees
+# Tidal locking check overrides this for short-period bodies
+CLASS_TILT = {
+ "temperate": (10, 35),
+ "oceanic": (5, 25),
+ "forest": (10, 40),
+ "arid": (5, 30),
+ "frozen": (15, 60), # high tilt → seasonal extremes → frozen
+ "volcanic": (2, 20),
+ "barren": (0, 45),
+}
+
+# planet_class → geothermal flux
+CLASS_GEOTHERMAL = {
+ "volcanic": "extreme",
+ "temperate": "low",
+ "oceanic": "low",
+ "forest": "low",
+ "arid": "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),
+ "oceanic": (0.80, 0.92),
+ "forest": (0.75, 0.88),
+ "arid": (0.90, 0.99),
+ "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),
+ "oceanic": (0.001, 0.004),
+ "forest": (0.001, 0.005),
+ "arid": (0.001, 0.004),
+ "frozen": (0.001, 0.003),
+ "volcanic": (0.002, 0.008),
+ "barren": (0.000, 0.003),
+}
+
+# Gas giant band palettes available
+GAS_PALETTES = ["jovian", "neptunian", "saturnian", "icy", "sulfuric"]
+
+# planet_class → cloud coverage base range
+CLASS_CLOUD = {
+ "temperate": (0.35, 0.55),
+ "oceanic": (0.55, 0.75),
+ "forest": (0.40, 0.60),
+ "arid": (0.05, 0.20),
+ "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
+}
+
+# ---------------------------------------------------------------------------
+# 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 = BIOME_CLASS.get(biome, "barren")
+
+ 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],
+ "oceanic": [0.40, 0.60, 1.00],
+ "forest": [0.42, 0.68, 0.80],
+ "arid": [0.90, 0.72, 0.50],
+ "frozen": [0.75, 0.88, 1.00],
+ "volcanic": [0.55, 0.40, 0.30],
+ "barren": None,
+ }
+ atmo_color = atmo_colors.get(planet_class)
+ 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",
+ "oceanic": "active", "forest": "active",
+ "arid": "low", "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",
+ "frozen": "ice",
+ "barren": "silicate",
+ "temperate":"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 giant extras
+ if gas_giant_cfg:
+ body_def["gas_giant"] = gas_giant_cfg
+ if rings_cfg:
+ body_def["rings"] = rings_cfg
+
+ 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']}")
+
+ # 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}/")
+
+ _check_habitability(body_def)
+ 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:
+ print(json.dumps(defs, indent=2))
diff --git a/spikes/heightmap-pipeline/prototype/planet_renderer.py b/spikes/heightmap-pipeline/prototype/planet_renderer.py
new file mode 100644
index 000000000..30ff5209f
--- /dev/null
+++ b/spikes/heightmap-pipeline/prototype/planet_renderer.py
@@ -0,0 +1,1007 @@
+"""
+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 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
+
+# ---------------------------------------------------------------------------
+# 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 = {
+ "O": (0.80, 0.85, 1.00),
+ "B": (0.88, 0.92, 1.00),
+ "A": (0.95, 0.97, 1.00),
+ "F": (1.00, 0.98, 0.95),
+ "G": (1.00, 0.95, 0.88),
+ "K": (1.00, 0.88, 0.72),
+ "M": (1.00, 0.78, 0.60),
+}
+
+# ---------------------------------------------------------------------------
+# 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)
+
+# Extended photographic biome palette — covers all 27 class IDs (0-26).
+# Mirrors render_heightmap.BIOME_PALETTE["photographic"] exactly.
+# Used in _surface_color_terrestrial when real terrain data is available.
+_PHOT_U8 = {
+ 0: ( 8, 22, 48), # deep ocean
+ 1: ( 15, 38, 78), # ocean
+ 2: ( 28, 65, 110), # coastal water
+ 3: ( 52, 72, 52), # coast land
+ 5: ( 12, 38, 18), # tropical rainforest
+ 6: ( 28, 65, 28), # tropical seasonal
+ 7: (108, 118, 55), # savanna
+ 8: (118, 128, 62), # temperate grassland
+ 9: ( 22, 55, 28), # temperate deciduous
+ 10: ( 15, 45, 22), # temperate rainforest
+ 11: ( 8, 30, 18), # boreal / taiga
+ 12: ( 98, 88, 55), # shrubland
+ 13: (155, 138, 98), # temperate desert
+ 14: (148, 108, 62), # subtropical desert
+ 15: (162, 98, 42), # hot desert
+ 16: ( 95, 88, 72), # tundra
+ 17: (218, 228, 238), # ice / snow
+ 18: ( 88, 80, 72), # mountain rock
+ 19: ( 38, 22, 15), # lava field
+ 20: ( 18, 38, 22), # chemosynthetic mat
+ 21: ( 78, 45, 22), # thermophilic field
+ 22: ( 98, 85, 35), # sulfuric scrub
+ 23: ( 78, 70, 58), # cryptobiotic crust
+ 24: ( 82, 75, 65), # lithic pioneer
+ 25: ( 42, 35, 30), # ash field
+ 26: (235, 238, 242), # ice shelf
+}
+_EXTENDED_BIOME_COLORS = np.zeros((27, 3), dtype=np.float32)
+for _cid, _rgb in _PHOT_U8.items():
+ _EXTENDED_BIOME_COLORS[_cid] = np.array(_rgb, dtype=np.float32) / 255.0
+del _cid, _rgb, _PHOT_U8
+
+# Gas giant band palettes
+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),
+ ],
+}
+
+# ---------------------------------------------------------------------------
+# Atmosphere colors per planet class
+# ---------------------------------------------------------------------------
+
+ATMO_COLORS = {
+ "temperate": (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),
+ "martian": (0.82, 0.58, 0.40),
+ "frozen": (0.75, 0.88, 1.00),
+ "barren": None,
+ "volcanic": (0.55, 0.40, 0.30),
+ "moon": None,
+ "gas_giant": None,
+ "gas_giant_ringed":None,
+}
+
+# ---------------------------------------------------------------------------
+# 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
+ u = (np.arctan2(hz, hx) / (2.0 * math.pi)) % 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:
+ biome = terrain["biome"]
+ # Use photographic palette (covers exotic classes 20-26); BIOME_COLORS
+ # only covers 0-19 and is for procedural-only rendering.
+ col = _EXTENDED_BIOME_COLORS[np.clip(biome, 0, len(_EXTENDED_BIOME_COLORS)-1)]
+ water = terrain.get("surface_water",
+ biome <= 2)
+ # Elevation shading
+ if "elevation" in terrain:
+ elev = terrain["elevation"]
+ shade = 0.82 + 0.18 * elev
+ col = np.clip(col * shade[..., np.newaxis], 0, 1)
+ 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 — from moisture grid or procedural
+ if terrain is not None and "moisture" in terrain:
+ moist = terrain["moisture"]
+ raw_cld = 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[..., np.newaxis]
+
+
+# ---------------------------------------------------------------------------
+# 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]
+ # Oval: wider than tall
+ dist = np.sqrt((du / (storm_sizes[i] * 2.0))**2 +
+ (dv / storm_sizes[i])**2)
+ storm_alpha = np.clip(1.0 - dist / 0.8, 0, 1) ** 2
+ rgb = rgb * (1 - storm_alpha[..., np.newaxis]) + \
+ storm_cols[i] * 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 == "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)
+
+ # -- Resample terrain grids to match UV grid dimensions ------------------
+ # Simulation grids are 256x512; renderer UV grid is size x size.
+ # Bicubic upscale each grid to (size, size) before use.
+ if terrain:
+ from scipy.ndimage import zoom
+ tH = terrain["elevation"].shape[0] if "elevation" in terrain else None
+ tW = terrain["elevation"].shape[1] if "elevation" in terrain else None
+ if tH and (tH != size or tW != size):
+ zy = size / tH; zx = size / tW
+ resampled = {}
+ for k, v in terrain.items():
+ if isinstance(v, np.ndarray) and v.ndim == 2:
+ resampled[k] = zoom(v.astype(np.float32), (zy, zx),
+ order=1).astype(v.dtype)
+ else:
+ resampled[k] = v
+ terrain = resampled
+
+ # -- 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 = _cloud_layer(u, v, terrain, body_def, seed,
+ nx, ny, nz, NdotL, star_tint, atmo_color)
+ lit_rgb = np.clip(lit_rgb + cld, 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, os, 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
+ print(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
+ paths.append(out)
+
+ print(f"\nDone. {len(paths)} planets rendered at {qa_size}px.")
diff --git a/spikes/heightmap-pipeline/prototype/planet_simulation.py b/spikes/heightmap-pipeline/prototype/planet_simulation.py
new file mode 100644
index 000000000..7c20fbb52
--- /dev/null
+++ b/spikes/heightmap-pipeline/prototype/planet_simulation.py
@@ -0,0 +1,819 @@
+"""
+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
+ "river_grid": bool (H, W) river cell mask
+ "rivers": list of [(row,col), ...] polylines in grid coords
+ "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
+
+log = logging.getLogger(__name__)
+GRID_W = 512
+GRID_H = 256
+
+
+# ---------------------------------------------------------------------------
+# 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()
+ for _ in range(passes):
+ gy, gx = np.gradient(result)
+ slope = np.sqrt(gx**2 + gy**2)
+ smooth = gaussian_filter(result, sigma=1.2)
+ weight = np.clip(slope * 6.0, 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)
+ 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)
+
+ cont = _continent_mask(u, v, seed, land_frac)
+ ridges = _tectonic_ridges(u, v, seed, n_plates=n_plates)
+ detail = _fbm(u, v, seed + 300, octaves=5, gain=0.45, base_freq=4.0)
+
+ ocean_pct = (1.0 - land_frac) * 100.0
+ sea_level = float(np.percentile(cont, ocean_pct))
+ land_mask = cont >= sea_level
+
+ elev = (cont * 0.55
+ + ridges * 0.25 * land_mask
+ + detail * 0.20)
+
+ if planet_class in ("barren", "moon"):
+ rng = _rng(seed, 77)
+ n_craters = int(rng.integers(40, 120))
+ cy_c = rng.uniform(0, GRID_H, n_craters).astype(np.float32)
+ cx_c = rng.uniform(0, GRID_W, n_craters).astype(np.float32)
+ sizes = rng.uniform(3, 18, n_craters).astype(np.float32)
+ depths = rng.uniform(0.02, 0.10, n_craters).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')
+ craters = np.zeros_like(elev)
+ for i in range(n_craters):
+ d2 = (rr - cy_c[i])**2 + (cc - cx_c[i])**2
+ craters -= depths[i] * np.exp(-d2 / (2 * sizes[i]**2))
+ elev = elev + craters * 0.4
+ elif planet_class == "frozen":
+ elev = gaussian_filter(elev, sigma=1.5).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 flattening
+ 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)
+ elev = elev * (1.0 - ice_blend * 0.6) + (sea_level + 0.05) * (ice_blend * 0.6)
+ elev = np.clip(elev, 0.0, 1.0).astype(np.float32)
+
+ sea_level = float(np.percentile(elev, ocean_pct))
+ 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,
+}
+
+
+# Descriptor temperature bands (Kelvin, equatorial baseline).
+# Used to clamp physically-derived temperature to match wiki descriptors.
+CLASS_T_BAND = {
+ "temperate": (275, 305), # cool temperate to warm temperate
+ "oceanic": (278, 300), # narrow band — ocean moderates extremes
+ "forest": (275, 308), # slightly wider — high moisture worlds
+ "arid": (295, 340), # hot and dry
+ "frozen": (210, 265), # well below freezing
+ "volcanic": (290, 380), # hot, geothermal contribution added later
+ "barren": (180, 380), # no constraint — airless bodies vary wildly
+ }
+
+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
+ lat_gradient = 60.0 * tilt_factor
+ 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)
+
+ # 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)
+ m_min, m_max = moisture.min(), moisture.max()
+ return ((moisture - m_min) / (m_max - m_min + 1e-9)).astype(np.float32)
+
+
+# ---------------------------------------------------------------------------
+# 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)
+
+
+# ---------------------------------------------------------------------------
+# 5. Rivers
+# ---------------------------------------------------------------------------
+
+def compute_rivers(body_def, elevation, sea_level, moisture,
+ max_rivers=12):
+ atmo = body_def["physical"]["atmosphere"]
+ planet_class = body_def["planet_class"].replace("_ringed", "")
+ hydro = body_def.get("environment", {}).get("hydrosphere", "none")
+
+ if atmo == "none" or hydro in ("none", "subsurface", "ice"):
+ return []
+
+ river_cap = {"barren": 2, "volcanic": 3, "arid": 3, "frozen": 2}
+ max_rivers = river_cap.get(planet_class, max_rivers)
+
+ H, W = elevation.shape
+ land_mask = elevation >= sea_level
+ seed = body_def["seed"]
+ rng = _rng(seed, 500)
+
+ from scipy.ndimage import maximum_filter
+ local_max = (elevation == maximum_filter(elevation, size=8)) & land_mask
+ moist_ok = moisture > 0.35
+ candidates = np.argwhere(local_max & moist_ok)
+ if len(candidates) == 0:
+ candidates = np.argwhere(land_mask)
+
+ np.random.default_rng(seed).shuffle(candidates)
+ sources = candidates[:min(max_rivers, len(candidates))]
+
+ D8 = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
+ rivers = []
+
+ for src in sources:
+ r, c = int(src[0]), int(src[1])
+ path = [(r, c)]
+ visited = {(r, c)}
+
+ for _ in range(GRID_W * 2):
+ if elevation[r, c] < sea_level:
+ break
+ best_drop = 0.0; best_nr = -1; best_nc = -1
+ for dr, dc in D8:
+ nr = r + dr; nc = (c + dc) % W
+ if nr < 0 or nr >= H or (nr, nc) in visited:
+ continue
+ drop = elevation[r, c] - elevation[nr, nc]
+ drop += float(rng.uniform(-0.005, 0.005))
+ if drop > best_drop:
+ best_drop = drop; best_nr = nr; best_nc = nc
+ if best_nr < 0:
+ break
+ r, c = best_nr, best_nc
+ visited.add((r, c))
+ path.append((r, c))
+
+ if len(path) > 5:
+ rivers.append(path)
+
+ return rivers
+
+
+def _rivers_to_grid(rivers, H, W):
+ grid = np.zeros((H, W), dtype=bool)
+ for path in rivers:
+ for r, c in path:
+ if 0 <= r < H and 0 <= c < W:
+ grid[r, c] = True
+ return grid
+
+
+# ---------------------------------------------------------------------------
+# 6. Biome
+# ---------------------------------------------------------------------------
+
+# Extended Whittaker table.
+# Temperature axis is ABSOLUTE KELVIN — anchored to real physics, not per-world range.
+# This ensures a frozen world's "warm" cells don't get classified as tropical.
+# Moisture axis stays [0,1].
+#
+# Reference points:
+# 200K = hard frozen (CO2 sublimation territory)
+# 233K = -40C, absolute limit for Earth-like life
+# 253K = -20C, cold tolerance limit for most vegetation
+# 273K = 0C, water freezing point
+# 283K = 10C, temperate cool
+# 293K = 20C, temperate warm
+# 303K = 30C, subtropical
+# 313K = 40C, hot desert
+#
+# (temp_lo_K, temp_hi_K, moist_lo, moist_hi, class_id)
+WHITTAKER_TABLE = [
+ (303, 999, 0.65, 1.00, 5), # tropical rainforest
+ (303, 999, 0.35, 0.65, 6), # tropical seasonal forest
+ (293, 999, 0.18, 0.35, 7), # savanna
+ (303, 999, 0.00, 0.18, 15), # hot desert
+ (283, 308, 0.55, 1.00, 9), # temperate deciduous forest
+ (278, 303, 0.30, 0.55, 8), # temperate grassland
+ (278, 303, 0.60, 1.00, 10), # temperate rainforest
+ (273, 293, 0.30, 0.60, 9), # temperate deciduous (cool)
+ (278, 303, 0.10, 0.30, 12), # shrubland
+ (283, 308, 0.00, 0.18, 14), # subtropical desert
+ (273, 293, 0.00, 0.30, 13), # temperate desert
+ (253, 278, 0.40, 1.00, 11), # boreal / taiga
+ (253, 278, 0.15, 0.40, 11), # boreal dry
+ (243, 263, 0.00, 1.00, 16), # tundra
+ (233, 253, 0.20, 1.00, 16), # cold tundra
+ (200, 243, 0.00, 1.00, 17), # ice / snow (anything below -30C)
+ (243, 273, 0.00, 0.15, 17), # ice (cold + very dry)
+]
+
+# Exotic class IDs — append colours to renderer's BIOME_COLORS list
+EXOTIC_CLASSES = {
+ "chemosynthetic_mat": 20,
+ "thermophilic_field": 21,
+ "sulfuric_scrub": 22,
+ "cryptobiotic_crust": 23,
+ "ash_field": 25,
+ "lava_field": 19,
+ "ice_shelf": 26, # frozen ocean surface
+}
+
+# RGB colours for exotic classes — feed into renderer palette extension
+EXTENDED_BIOME_COLORS = {
+ 20: (0.22, 0.30, 0.20), # chemosynthetic_mat
+ 21: (0.42, 0.28, 0.18), # thermophilic_field
+ 22: (0.52, 0.45, 0.22), # sulfuric_scrub
+ 23: (0.45, 0.42, 0.38), # cryptobiotic_crust
+ 25: (0.25, 0.22, 0.20), # ash_field
+}
+
+
+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
+
+ # Base Whittaker lookup on land cells
+ 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.
+ frozen_ocean = surface_water & (temperature < 271.0)
+ biome[frozen_ocean] = 26
+
+ # Very cold override
+ biome[(temperature < 243.0) & land] = 17 # below -30C → ice regardless
+
+ # Elevation overrides — mountain rock and permanent snow
+ elev_norm = np.where(land,
+ (elevation - sea_level) / (1.0 - sea_level + 1e-9),
+ 0.0)
+ 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"]
+
+ 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)
+
+ rivers = compute_rivers(body_def, elevation, sea_level, moisture)
+ river_grid = _rivers_to_grid(rivers, GRID_H, GRID_W)
+
+ 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,
+ "river_grid": river_grid,
+ "rivers": rivers,
+ "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, json, time, os
+ from PIL import Image
+
+ if len(sys.argv) < 2:
+ print("Usage: python3 planet_simulation.py 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
+
+ print(f"Simulating: {bd['id']} ({bd['planet_class']})")
+ t0 = time.time()
+ terrain = simulate(bd)
+
+ if not terrain:
+ print("Gas giant — no terrain simulation.")
+ sys.exit(0)
+
+ dt = time.time() - t0
+ print(f"Done in {dt:.1f}s")
+ print(f" sea_level: {terrain['sea_level']:.3f}")
+ print(f" land cells: {(~terrain['surface_water']).sum()}")
+ print(f" rivers: {len(terrain['rivers'])} polylines")
+
+ ids, counts = np.unique(terrain['biome'], return_counts=True)
+ print(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")
+ print(f"Grids saved → {out}/")
diff --git a/spikes/heightmap-pipeline/prototype/render_heightmap.py b/spikes/heightmap-pipeline/prototype/render_heightmap.py
new file mode 100644
index 000000000..55d5e4676
--- /dev/null
+++ b/spikes/heightmap-pipeline/prototype/render_heightmap.py
@@ -0,0 +1,530 @@
+"""
+render_heightmap.py
+-------------------
+Renders a 4096×2048 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 render_heightmap import render_heightmap
+ from planet_simulation import simulate
+ from 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 math
+import numpy as np
+from PIL import Image, ImageDraw, ImageFilter, ImageFont
+from scipy.ndimage import gaussian_filter, binary_dilation
+
+# ---------------------------------------------------------------------------
+# Output resolution
+# ---------------------------------------------------------------------------
+
+OUT_W = 4096
+OUT_H = 2048
+UI_SCALE = OUT_W / 1024 # 4.0 — all pixel sizes scale with this
+
+# ---------------------------------------------------------------------------
+# Biome colour palette
+# Indices match planet_simulation.WHITTAKER_TABLE class IDs.
+# Extended exotic classes appended at end.
+# ---------------------------------------------------------------------------
+
+# Whittaker biome palette — two colour sets per class.
+#
+# "cartographic" — map colours (National Geographic style).
+# Designed to be read and distinguished at a glance.
+# "photographic" — orbital appearance colours.
+# What the surface actually looks like from space:
+# dark, muted, texture-driven. Hillshade does the work.
+#
+# Per-planet overrides can replace either set for alien biome appearances
+# (pink forests, purple tundra, etc) — just patch the dict before rendering.
+#
+# Class IDs match planet_simulation.WHITTAKER_TABLE.
+
+BIOME_PALETTE = {
+ # ── Ocean ────────────────────────────────────────────────────────────
+ 0: {"cartographic": ( 80, 155, 190), "photographic": ( 18, 45, 80)}, # ocean deep
+ 1: {"cartographic": (110, 185, 215), "photographic": ( 28, 72, 115)}, # ocean mid
+ 2: {"cartographic": (150, 210, 230), "photographic": ( 42, 105, 145)}, # ocean shallow
+ # ── Coast / lowland ──────────────────────────────────────────────────
+ 3: {"cartographic": (155, 185, 130), "photographic": ( 90, 108, 75)}, # coast
+ 4: {"cartographic": (120, 165, 100), "photographic": ( 72, 98, 58)}, # lowland
+ # ── Tropical ─────────────────────────────────────────────────────────
+ 5: {"cartographic": ( 50, 140, 65), "photographic": ( 12, 38, 18)}, # tropical rainforest
+ 6: {"cartographic": ( 90, 170, 75), "photographic": ( 28, 65, 28)}, # tropical seasonal
+ 7: {"cartographic": (175, 210, 105), "photographic": (108, 118, 55)}, # savanna
+ # ── Temperate ────────────────────────────────────────────────────────
+ 8: {"cartographic": (190, 210, 110), "photographic": (118, 128, 62)}, # temperate grassland
+ 9: {"cartographic": ( 70, 148, 70), "photographic": ( 22, 55, 28)}, # temperate deciduous
+ 10: {"cartographic": ( 45, 125, 65), "photographic": ( 15, 45, 22)}, # temperate rainforest
+ 11: {"cartographic": ( 28, 88, 55), "photographic": ( 8, 30, 18)}, # boreal / taiga
+ 12: {"cartographic": (168, 168, 95), "photographic": ( 98, 88, 55)}, # shrubland
+ # ── Desert ───────────────────────────────────────────────────────────
+ 13: {"cartographic": (215, 200, 155), "photographic": (155, 138, 98)}, # temperate desert
+ 14: {"cartographic": (210, 165, 85), "photographic": (148, 108, 62)}, # subtropical desert
+ 15: {"cartographic": (215, 138, 55), "photographic": (162, 98, 42)}, # hot desert
+ # ── Cold ─────────────────────────────────────────────────────────────
+ 16: {"cartographic": (198, 185, 145), "photographic": ( 95, 88, 72)}, # tundra
+ 17: {"cartographic": (235, 238, 242), "photographic": (218, 228, 238)}, # ice / snow
+ 18: {"cartographic": (148, 135, 120), "photographic": ( 88, 80, 72)}, # mountain rock
+ # ── Volcanic ─────────────────────────────────────────────────────────
+ 19: {"cartographic": ( 55, 32, 22), "photographic": ( 38, 22, 15)}, # lava field
+ # ── Exotic / extremophile ─────────────────────────────────────────────
+ 20: {"cartographic": ( 45, 88, 52), "photographic": ( 18, 38, 22)}, # chemosynthetic mat
+ 21: {"cartographic": (118, 72, 40), "photographic": ( 78, 45, 22)}, # thermophilic field
+ 22: {"cartographic": (148, 130, 58), "photographic": ( 98, 85, 35)}, # sulfuric scrub
+ 23: {"cartographic": (130, 118, 100), "photographic": ( 78, 70, 58)}, # cryptobiotic crust
+ 24: {"cartographic": (138, 125, 110), "photographic": ( 82, 75, 65)}, # lithic pioneer
+ 25: {"cartographic": ( 68, 58, 52), "photographic": ( 42, 35, 30)}, # ash field
+ 26: {"cartographic": (245, 246, 248), "photographic": (235, 238, 242)}, # ice shelf — pure white, reads flat
+}
+
+# Legacy flat lookup — built from palette at import time, keyed by render_mode.
+# Call _build_biome_rgb(mode) to get a simple {class_id: (R,G,B)} dict.
+def _build_biome_rgb(mode: str = "cartographic") -> dict:
+ return {k: v[mode] for k, v in BIOME_PALETTE.items() if mode in v}
+
+# Active colour set — set before rendering, defaults to cartographic
+RENDER_MODE = "cartographic"
+BIOME_RGB = _build_biome_rgb(RENDER_MODE)
+
+# Ocean palette for depth gradient
+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 colour
+RIVER_RGB = (80, 140, 200)
+
+# Coastline colour
+COAST_RGB = (30, 45, 35)
+
+# ---------------------------------------------------------------------------
+# 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: Rivers
+# ---------------------------------------------------------------------------
+
+def _render_rivers(terrain: dict,
+ rgb: np.ndarray) -> np.ndarray:
+ """
+ Draw rivers as anti-aliased polylines.
+ River list is in simulation grid coords (row, col) at GRID_H×GRID_W.
+ Scale to output pixels, draw with PIL.
+ """
+ rivers = terrain.get("rivers", [])
+ if not rivers:
+ return rgb
+
+ GRID_H, GRID_W = terrain["_grid_h"], terrain["_grid_w"]
+ scale_y = OUT_H / GRID_H
+ scale_x = OUT_W / GRID_W
+
+ # Work on a PIL image for anti-aliased line drawing
+ img = Image.fromarray((rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
+ draw = ImageDraw.Draw(img)
+
+ river_col = RIVER_RGB
+
+ for path in rivers:
+ if len(path) < 2:
+ continue
+ # Scale grid coords to output pixels
+ pts = [(int(c * scale_x), int(r * scale_y)) for r, c in path]
+ # Line width scales with path length — longer rivers are wider
+ width = max(1, min(3, len(path) // 80))
+ draw.line(pts, fill=river_col, width=width, joint="curve")
+
+ return np.array(img).astype(np.float32) / 255.0
+
+
+# ---------------------------------------------------------------------------
+# Layer 6: 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("_ringed", "")
+ 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: "rainforest", 6: "trop. forest",
+ 7: "savanna", 8: "grassland", 9: "forest",
+ 10: "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)
+ step = int(108 * UI_SCALE)
+
+ lx = pad_x
+ for label, rgb in items:
+ if lx + step > OUT_W - pad_x:
+ break
+ draw.rectangle([(lx, leg_y), (lx + sw, leg_y + sh)], fill=rgb)
+ draw.text((lx + sw + gap, leg_y), label,
+ 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") -> Image.Image:
+ """
+ Render a 4096×2048 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 4096×2048)
+
+ 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: rivers
+ rgb = _render_rivers(terrain, rgb)
+
+ # 6: 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")
+
+ # 7: title panel
+ img = _render_title(img, body_def)
+
+ # 8: legend
+ img = _render_legend(img, terrain)
+
+ return img
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+if __name__ == "__main__":
+ import sys, json, time, os
+
+ if len(sys.argv) < 2:
+ print("Usage: python3 render_heightmap.py body_def.json [--small]")
+ sys.exit(1)
+
+ with open(sys.argv[1]) as f:
+ bd = json.load(f)
+
+ # --small flag renders at 1024×512 for fast iteration
+ small = "--small" in sys.argv
+ w, h = (1024, 512) if small else (OUT_W, OUT_H)
+
+ from planet_simulation import simulate
+
+ print(f"Simulating: {bd['id']} ({bd['planet_class']})")
+ t0 = time.time()
+ terrain = simulate(bd)
+ sim_t = time.time() - t0
+
+ if not terrain:
+ print("Gas giant — no heightmap.")
+ sys.exit(0)
+
+ print(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")
+ print(f"Saved: {out}")
+ print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
diff --git a/spikes/heightmap-pipeline/test_arid.json b/spikes/heightmap-pipeline/test_arid.json
new file mode 100644
index 000000000..10b0bd45b
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_arid.json
@@ -0,0 +1,52 @@
+{
+ "id": "TEST_ARID",
+ "name": "Dust Bowl",
+ "body_type": "planet",
+ "planet_class": "arid",
+ "body_scale": "planet",
+ "seed": 2875992775,
+
+ "star": {
+ "type": "G",
+ "luminosity_solar": 1.0,
+ "color_temp_K": 5800
+ },
+
+ "orbit": {
+ "distance_au": 1.2,
+ "period_days": 480,
+ "axial_tilt_deg": 12.0
+ },
+
+ "physical": {
+ "gravity_g": 0.42,
+ "oblateness": 0.002,
+ "atmosphere": "thin",
+ "atmosphere_color": [0.85, 0.55, 0.35]
+ },
+
+ "terrain": {
+ "land_fraction": 0.92,
+ "polar_ice_lat": 0.85,
+ "tectonics": "low",
+ "max_elevation_km": 22.0
+ },
+
+ "environment": {
+ "geothermal_flux": "low",
+ "uv_index": "high",
+ "substrate": "silicate",
+ "chemosynthetic": false,
+ "hydrosphere": "none"
+ },
+
+ "clouds": {
+ "enabled": false
+ },
+
+ "render": {
+ "globe_light_angle_deg": 135,
+ "specular_ocean": false,
+ "night_side_ambient": 0.015
+ }
+}
diff --git a/spikes/heightmap-pipeline/test_arid_wet.json b/spikes/heightmap-pipeline/test_arid_wet.json
new file mode 100644
index 000000000..60f446181
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_arid_wet.json
@@ -0,0 +1,11 @@
+{
+ "id": "TEST_ARID_WET", "name": "Rust Basin", "body_type": "planet",
+ "planet_class": "arid", "body_scale": "planet", "seed": 44123,
+ "star": {"type": "G", "luminosity_solar": 1.0, "color_temp_K": 5800},
+ "orbit": {"distance_au": 1.1, "period_days": 420, "axial_tilt_deg": 10.0},
+ "physical": {"gravity_g": 0.80, "oblateness": 0.002, "atmosphere": "standard", "atmosphere_color": [0.85, 0.60, 0.40]},
+ "terrain": {"land_fraction": 0.82, "polar_ice_lat": 0.88, "tectonics": "low", "max_elevation_km": 14.0},
+ "environment": {"geothermal_flux": "low", "uv_index": "moderate", "substrate": "silicate", "chemosynthetic": false, "hydrosphere": "liquid_water"},
+ "clouds": {"enabled": true, "coverage_base": 0.15},
+ "render": {"globe_light_angle_deg": 130, "specular_ocean": true, "night_side_ambient": 0.020}
+}
diff --git a/spikes/heightmap-pipeline/test_barren.json b/spikes/heightmap-pipeline/test_barren.json
new file mode 100644
index 000000000..f9f1f4603
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_barren.json
@@ -0,0 +1,11 @@
+{
+ "id": "TEST_BARREN", "name": "Scoria", "body_type": "planet",
+ "planet_class": "barren", "body_scale": "planet", "seed": 77723,
+ "star": {"type": "K", "luminosity_solar": 0.4, "color_temp_K": 4500},
+ "orbit": {"distance_au": 0.2, "period_days": 45, "axial_tilt_deg": 2.0},
+ "physical": {"gravity_g": 0.35, "oblateness": 0.001, "atmosphere": "none", "atmosphere_color": [0.5, 0.5, 0.5]},
+ "terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.95, "tectonics": "none", "max_elevation_km": 8.0},
+ "environment": {"geothermal_flux": "low", "uv_index": "high", "substrate": "silicate", "chemosynthetic": false, "hydrosphere": "none"},
+ "clouds": {"enabled": false},
+ "render": {"globe_light_angle_deg": 130, "specular_ocean": false, "night_side_ambient": 0.008}
+}
diff --git a/spikes/heightmap-pipeline/test_frozen.json b/spikes/heightmap-pipeline/test_frozen.json
new file mode 100644
index 000000000..7c13672c5
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_frozen.json
@@ -0,0 +1,11 @@
+{
+ "id": "TEST_FROZEN", "name": "Frostheim", "body_type": "planet",
+ "planet_class": "frozen", "body_scale": "planet", "seed": 33345,
+ "star": {"type": "K", "luminosity_solar": 0.4, "color_temp_K": 4500},
+ "orbit": {"distance_au": 1.8, "period_days": 1100, "axial_tilt_deg": 8.0},
+ "physical": {"gravity_g": 0.78, "oblateness": 0.002, "atmosphere": "standard", "atmosphere_color": [0.6, 0.7, 1.0]},
+ "terrain": {"land_fraction": 0.55, "polar_ice_lat": 0.40, "tectonics": "low", "max_elevation_km": 5.0},
+ "environment": {"geothermal_flux": "low", "uv_index": "low", "substrate": "silicate", "chemosynthetic": false, "hydrosphere": "ice"},
+ "clouds": {"enabled": true, "coverage_base": 0.30},
+ "render": {"globe_light_angle_deg": 140, "specular_ocean": true, "night_side_ambient": 0.018}
+}
diff --git a/spikes/heightmap-pipeline/test_gas_giant.json b/spikes/heightmap-pipeline/test_gas_giant.json
new file mode 100644
index 000000000..f2cbf264d
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_gas_giant.json
@@ -0,0 +1,12 @@
+{
+ "id": "TEST_GAS", "name": "Typhon", "body_type": "planet",
+ "planet_class": "gas_giant", "body_scale": "planet", "seed": 44456,
+ "star": {"type": "G", "luminosity_solar": 1.0, "color_temp_K": 5800},
+ "orbit": {"distance_au": 5.2, "period_days": 4333, "axial_tilt_deg": 3.0},
+ "physical": {"gravity_g": 2.5, "oblateness": 0.065, "atmosphere": "dense", "atmosphere_color": [0.7, 0.6, 0.4]},
+ "terrain": {"land_fraction": 0.0, "polar_ice_lat": 0.99, "tectonics": "none", "max_elevation_km": 0},
+ "environment": {"geothermal_flux": "moderate", "uv_index": "moderate", "substrate": "gas", "chemosynthetic": false, "hydrosphere": "none"},
+ "clouds": {"enabled": false},
+ "gas_giant": {"band_palette": "jovian", "storm_count": 3, "storm_max_size": 0.10},
+ "render": {"globe_light_angle_deg": 130, "specular_ocean": false, "night_side_ambient": 0.015}
+}
diff --git a/spikes/heightmap-pipeline/test_gas_ringed.json b/spikes/heightmap-pipeline/test_gas_ringed.json
new file mode 100644
index 000000000..52ce24829
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_gas_ringed.json
@@ -0,0 +1,13 @@
+{
+ "id": "TEST_GAS_RING", "name": "Aurelius", "body_type": "planet",
+ "planet_class": "gas_giant_ringed", "body_scale": "planet", "seed": 88834,
+ "star": {"type": "G", "luminosity_solar": 1.0, "color_temp_K": 5800},
+ "orbit": {"distance_au": 9.5, "period_days": 10759, "axial_tilt_deg": 27.0},
+ "physical": {"gravity_g": 1.1, "oblateness": 0.098, "atmosphere": "dense", "atmosphere_color": [0.85, 0.78, 0.55]},
+ "terrain": {"land_fraction": 0.0, "polar_ice_lat": 0.99, "tectonics": "none", "max_elevation_km": 0},
+ "environment": {"geothermal_flux": "low", "uv_index": "low", "substrate": "gas", "chemosynthetic": false, "hydrosphere": "none"},
+ "clouds": {"enabled": false},
+ "gas_giant": {"band_palette": "saturnian", "storm_count": 1, "storm_max_size": 0.05},
+ "rings": {"enabled": true, "inner_radius_factor": 1.12, "outer_radius_factor": 2.65, "opacity_base": 0.62, "ring_color": [0.88, 0.78, 0.55]},
+ "render": {"globe_light_angle_deg": 135, "specular_ocean": false, "night_side_ambient": 0.010}
+}
diff --git a/spikes/heightmap-pipeline/test_moon.json b/spikes/heightmap-pipeline/test_moon.json
new file mode 100644
index 000000000..5a384af07
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_moon.json
@@ -0,0 +1,11 @@
+{
+ "id": "TEST_MOON", "name": "Shale", "body_type": "moon",
+ "planet_class": "barren", "body_scale": "dwarf", "seed": 11198,
+ "star": {"type": "G", "luminosity_solar": 1.0, "color_temp_K": 5800},
+ "orbit": {"distance_au": 1.0, "period_days": 28, "axial_tilt_deg": 1.5},
+ "physical": {"gravity_g": 0.16, "oblateness": 0.001, "atmosphere": "none", "atmosphere_color": [0.5, 0.5, 0.5]},
+ "terrain": {"land_fraction": 0.99, "polar_ice_lat": 0.98, "tectonics": "none", "max_elevation_km": 4.0},
+ "environment": {"geothermal_flux": "low", "uv_index": "moderate", "substrate": "silicate", "chemosynthetic": false, "hydrosphere": "none"},
+ "clouds": {"enabled": false},
+ "render": {"globe_light_angle_deg": 125, "specular_ocean": false, "night_side_ambient": 0.005}
+}
diff --git a/spikes/heightmap-pipeline/test_oceanic.json b/spikes/heightmap-pipeline/test_oceanic.json
new file mode 100644
index 000000000..51f952a59
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_oceanic.json
@@ -0,0 +1,11 @@
+{
+ "id": "TEST_OCEANIC", "name": "Deep Blue", "body_type": "planet",
+ "planet_class": "oceanic", "body_scale": "planet", "seed": 55512,
+ "star": {"type": "G", "luminosity_solar": 1.0, "color_temp_K": 5800},
+ "orbit": {"distance_au": 0.95, "period_days": 338, "axial_tilt_deg": 20.0},
+ "physical": {"gravity_g": 0.88, "oblateness": 0.003, "atmosphere": "standard", "atmosphere_color": [0.4, 0.6, 1.0]},
+ "terrain": {"land_fraction": 0.12, "polar_ice_lat": 0.75, "tectonics": "active", "max_elevation_km": 6.0},
+ "environment": {"geothermal_flux": "moderate", "uv_index": "moderate", "substrate": "silicate", "chemosynthetic": false, "hydrosphere": "ocean"},
+ "clouds": {"enabled": true, "coverage_base": 0.60},
+ "render": {"globe_light_angle_deg": 125, "specular_ocean": true, "night_side_ambient": 0.025}
+}
diff --git a/spikes/heightmap-pipeline/test_volcanic.json b/spikes/heightmap-pipeline/test_volcanic.json
new file mode 100644
index 000000000..1dc3d14cc
--- /dev/null
+++ b/spikes/heightmap-pipeline/test_volcanic.json
@@ -0,0 +1,11 @@
+{
+ "id": "TEST_VOLCANIC", "name": "Caldera", "body_type": "planet",
+ "planet_class": "volcanic", "body_scale": "planet", "seed": 99967,
+ "star": {"type": "M", "luminosity_solar": 0.04, "color_temp_K": 3200},
+ "orbit": {"distance_au": 0.12, "period_days": 18, "axial_tilt_deg": 3.0},
+ "physical": {"gravity_g": 1.1, "oblateness": 0.004, "atmosphere": "toxic", "atmosphere_color": [0.7, 0.4, 0.2]},
+ "terrain": {"land_fraction": 0.85, "polar_ice_lat": 0.99, "tectonics": "extreme", "max_elevation_km": 18.0},
+ "environment": {"geothermal_flux": "extreme", "uv_index": "low", "substrate": "silicate", "chemosynthetic": false, "hydrosphere": "none"},
+ "clouds": {"enabled": true, "coverage_base": 0.35},
+ "render": {"globe_light_angle_deg": 120, "specular_ocean": false, "night_side_ambient": 0.040}
+}
diff --git a/spikes/planet-renders/generate_planets.py b/spikes/planet-renders/generate_planets.py
new file mode 100644
index 000000000..3e0f1e09b
--- /dev/null
+++ b/spikes/planet-renders/generate_planets.py
@@ -0,0 +1,511 @@
+#!/usr/bin/env python3
+"""
+Planetary Screenshot Generator
+
+Produces procedurally-rendered sphere images for each planet type
+in the Settled Reach. Output used in:
+ - wiki body-info-panel (360×360 container, sphere ~240px)
+ - GTTR arrival window (diegetic implant UI, same asset)
+
+Approach: numpy ray-sphere intersection + Lambertian shading + specular
++ procedural texture (octave-summed sine waves approximating noise).
+
+No GPU required. Runs offline. Output is PNG at 512×512 (downscaled
+to 360×360 for the panel; kept large for quality).
+
+Usage:
+ python3 generate_planets.py [--output-dir path]
+
+Output:
+ planet_temperate.png — Earth-like: continents, ocean, clouds
+ planet_temperate_terminator.png — Tidally locked: bright stripe, dark back
+ planet_oceanic.png — Water world: blue, archipelago dots
+ planet_arid.png — Desert/Mars: reddish-orange, dust storms
+ planet_frozen.png — Ice world: white, blue cracks
+ planet_volcanic.png — Volcanic: dark basalt, orange lava
+ planet_barren.png — Airless rocky: cratered grey
+"""
+
+import argparse
+import math
+import os
+import numpy as np
+from PIL import Image
+
+
+SIZE = 512 # output pixel dimensions (square)
+SPHERE_R = 0.92 # sphere radius in [-1, 1] space (slightly smaller than 1 = padding)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Procedural noise — octave sum of sine waves
+# (approximates value noise without a noise library)
+# ─────────────────────────────────────────────────────────────────────────────
+
+def proc_noise(u: np.ndarray, v: np.ndarray, seed: float, octaves: int = 5) -> np.ndarray:
+ """
+ Returns values roughly in [-1, 1].
+ u, v are 2D arrays of coordinates (e.g. lon/lat on sphere surface).
+ seed shifts the pattern.
+ """
+ result = np.zeros_like(u, dtype=np.float32)
+ amplitude = 1.0
+ frequency = 1.0
+ total_amp = 0.0
+ for i in range(octaves):
+ ph = seed + i * 3.7
+ result += amplitude * (
+ np.sin(frequency * u * 13.7 + ph) * np.cos(frequency * v * 8.1 + ph * 0.7) +
+ np.cos(frequency * u * 7.3 - ph * 0.4) * np.sin(frequency * v * 11.3 + ph * 1.3)
+ )
+ total_amp += 2 * amplitude
+ amplitude *= 0.5
+ frequency *= 2.0
+ return result / total_amp
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Ray-sphere intersection
+# ─────────────────────────────────────────────────────────────────────────────
+
+def raytrace_sphere(size: int, r: float = 1.0):
+ """
+ Returns (hit_mask, nx, ny, nz, u, v) arrays of shape (size, size).
+ - hit_mask: bool, True where the ray hits the sphere
+ - nx/ny/nz: surface normals at hit points (unit length)
+ - u/v: spherical UV coordinates in [0, 1]
+
+ Camera at (0, 0, 3), looking toward origin. Sphere at origin.
+ """
+ # Pixel coordinates mapped to [-1, 1] square
+ lin = np.linspace(-1, 1, size, dtype=np.float32)
+ px, py = np.meshgrid(lin, -lin) # y flipped so top = 1
+
+ # Ray direction from camera
+ oz = 3.0
+ rdx, rdy, rdz = px, py, -oz * np.ones((size, size), dtype=np.float32)
+ mag = np.sqrt(rdx**2 + rdy**2 + rdz**2)
+ rdx /= mag; rdy /= mag; rdz /= mag
+
+ # Ray-sphere: t^2 + 2t(o·d) + |o|^2 - r^2 = 0
+ # o = (0, 0, oz), sphere center = (0, 0, 0)
+ b = 2 * (oz * rdz) # ox=oy=0
+ c = oz**2 - r**2
+ disc = b**2 - 4 * c
+ hit = disc >= 0.0
+
+ t_arr = np.where(hit, (-b - np.sqrt(np.maximum(disc, 0.0))) / 2.0, np.inf)
+
+ # Hit position
+ hx = rdx * t_arr
+ hy = rdy * t_arr
+ hz = oz + rdz * t_arr
+
+ # Normals (outward) — normalise hit position since sphere at origin radius r
+ norm = np.sqrt(hx**2 + hy**2 + hz**2)
+ norm = np.where(hit, norm, 1.0) # avoid /0 in miss pixels
+ nx, ny, nz = hx / norm, hy / norm, hz / norm
+
+ # Spherical UV: u = lon / 2π, v = lat / π + 0.5
+ u_coord = (np.arctan2(nz, nx) / (2 * math.pi)) % 1.0
+ v_coord = np.arcsin(np.clip(ny, -1, 1)) / math.pi + 0.5
+
+ return hit, nx, ny, nz, u_coord, v_coord
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Lighting
+# ─────────────────────────────────────────────────────────────────────────────
+
+def light(nx, ny, nz, lx=-0.6, ly=0.5, lz=-0.4, shininess=20.0):
+ """
+ Diffuse + specular from a fixed star direction.
+ Returns diffuse (float array) and specular (float array).
+ """
+ lmag = math.sqrt(lx**2 + ly**2 + lz**2)
+ lx /= lmag; ly /= lmag; lz /= lmag
+
+ diffuse = np.clip(nx * lx + ny * ly + nz * lz, 0.0, 1.0)
+
+ # Reflect: r = 2(n·l)n - l
+ ndotl = nx * lx + ny * ly + nz * lz
+ rx = 2 * ndotl * nx - lx
+ ry = 2 * ndotl * ny - ly
+ rz = 2 * ndotl * nz - lz
+
+ # View direction: toward camera at (0,0,3) — for normalized normals ~(0,0,1) approx
+ vz = 1.0
+ spec = np.clip(rx * 0 + ry * 0 + rz * vz, 0.0, 1.0) ** shininess
+ return diffuse, spec
+
+
+def lerp_color(a, b, t):
+ """Blend between two RGB tuples by t (0-1 scalar or array)."""
+ t = np.clip(t, 0, 1)
+ if hasattr(t, '__len__'):
+ t = t[..., np.newaxis]
+ return np.array(a) * (1 - t) + np.array(b) * t
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Planet type renderers
+# ─────────────────────────────────────────────────────────────────────────────
+
+def render_planet(hit, nx, ny, nz, u, v, surface_fn, cloud_fn=None,
+ bg=(4, 6, 10), star_lx=-0.55, star_ly=0.45, star_lz=0.70,
+ has_atmosphere=True):
+ """
+ Core render loop. surface_fn(u, v, nx, ny, nz) → RGB float [0..1].
+ cloud_fn(u, v) → alpha float [0..1] or None.
+ Returns PIL Image (RGBA).
+ """
+ H, W = hit.shape
+ rgb = np.zeros((H, W, 4), dtype=np.float32)
+
+ # Background (space)
+ rgb[..., 0] = bg[0] / 255.0
+ rgb[..., 1] = bg[1] / 255.0
+ rgb[..., 2] = bg[2] / 255.0
+ rgb[..., 3] = 1.0
+
+ # Star field
+ rng = np.random.default_rng(42)
+ star_mask = rng.random((H, W)) < 0.002
+ star_bright = rng.uniform(0.4, 1.0, (H, W))
+ rgb[~hit & star_mask, 0] = star_bright[~hit & star_mask]
+ rgb[~hit & star_mask, 1] = star_bright[~hit & star_mask]
+ rgb[~hit & star_mask, 2] = star_bright[~hit & star_mask]
+
+ if not hit.any():
+ arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
+ return Image.fromarray(arr)
+
+ # Work only on hit pixels to avoid NaN propagation from miss areas
+ h_idx = np.where(hit)
+ nx_h = nx[h_idx]; ny_h = ny[h_idx]; nz_h = nz[h_idx]
+ u_h = u[h_idx]; v_h = v[h_idx]
+
+ # Surface color (compute on hit pixels)
+ surf_full = surface_fn(u, v, nx, ny, nz) # full grid for simplicity
+ surf_h = surf_full[h_idx] # (N, 3)
+
+ # Lighting on hit pixels only
+ lmag = math.sqrt(star_lx**2 + star_ly**2 + star_lz**2)
+ lx = star_lx / lmag; ly = star_ly / lmag; lz = star_lz / lmag
+
+ diff_h = np.clip(nx_h * lx + ny_h * ly + nz_h * lz, 0.0, 1.0)
+
+ ndotl = nx_h * lx + ny_h * ly + nz_h * lz
+ rx_h = 2 * ndotl * nx_h - lx
+ ry_h = 2 * ndotl * ny_h - ly
+ rz_h = 2 * ndotl * nz_h - lz
+ spec_h = np.clip(rz_h, 0.0, 1.0) ** 25 # view dir = +z approx
+
+ # Ambient + diffuse + spec — generous ambient for readability (dark side not black)
+ ambient = 0.22
+ lit_h = surf_h * (ambient + 0.78 * diff_h[:, np.newaxis]) + spec_h[:, np.newaxis] * 0.30
+
+ # Cloud layer
+ if cloud_fn is not None:
+ cloud_full = cloud_fn(u, v)
+ cloud_h = cloud_full[h_idx]
+ cloud_alpha_h = np.clip(cloud_h * 0.9, 0, 1)
+ cloud_rgb_h = np.ones((len(h_idx[0]), 3)) * 0.96
+ cloud_lit_h = cloud_rgb_h * (ambient + 0.85 * diff_h[:, np.newaxis])
+ lit_h = lit_h * (1 - cloud_alpha_h[:, np.newaxis]) + cloud_lit_h * cloud_alpha_h[:, np.newaxis]
+
+ # Atmosphere rim glow (blue edge)
+ if has_atmosphere:
+ rim_h = np.abs(nz_h) # dot with view +z
+ rim_glow_h = (1 - rim_h) ** 5 * 0.7
+ atmo_c = np.array([0.35, 0.60, 1.0])
+ lit_h = lit_h + rim_glow_h[:, np.newaxis] * atmo_c * 0.35
+
+ lit_h = np.clip(lit_h, 0, 1)
+
+ out = np.zeros((H * W, 3), dtype=np.float32)
+ out[np.ravel_multi_index(h_idx, (H, W))] = lit_h
+ out = out.reshape(H, W, 3)
+
+ rgb[hit, 0] = out[hit, 0]
+ rgb[hit, 1] = out[hit, 1]
+ rgb[hit, 2] = out[hit, 2]
+ rgb[hit, 3] = 1.0
+
+ arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
+ return Image.fromarray(arr)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Planet type definitions
+# ─────────────────────────────────────────────────────────────────────────────
+
+def planet_temperate(out_dir):
+ """
+ Temperate / Earth-like. Continents (amber-brown), ocean (blue), polar caps.
+ Character: "amber continental shelves" per Kallast wiki.
+ """
+ hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
+ n = proc_noise(u * 2, v * 3, seed=1.1)
+ n2 = proc_noise(u * 5, v * 7, seed=2.3, octaves=3)
+
+ def surface(u, v, nx, ny, nz):
+ h = n * 0.7 + n2 * 0.3
+ lat = (v - 0.5) * 2 # [-1, 1]
+ polar = np.abs(lat) ** 3
+ land = h > 0.08
+ # Ocean: rich blue, deepens with depth
+ ocean_t = np.clip((h + 0.6) * 0.9, 0, 1)
+ ocean_c = lerp_color((8, 28, 80), (35, 100, 175), ocean_t) / 255.0
+ # Land: amber grain belt → hillside → highland
+ land_t = np.clip((h - 0.08) / 0.55, 0, 1)
+ land_c = lerp_color((155, 165, 75), (105, 90, 65), land_t) / 255.0
+ # Forest band at mid elevation
+ forest_band = np.clip((land_t - 0.2) * 5, 0, 1) * np.clip((0.6 - land_t) * 5, 0, 1)
+ forest_c = np.array([55, 100, 50]) / 255.0
+ land_c = land_c * (1 - forest_band[..., np.newaxis]) + forest_c * forest_band[..., np.newaxis]
+ base = np.where(land[..., np.newaxis], land_c, ocean_c)
+ # Polar ice caps
+ ice_c = np.array([0.88, 0.91, 0.97])
+ polar_blend = np.clip((polar - 0.55) * 5, 0, 1)
+ base = base * (1 - polar_blend[..., np.newaxis]) + ice_c * polar_blend[..., np.newaxis]
+ return base
+
+ def clouds(u, v):
+ c1 = proc_noise(u * 3, v * 2, seed=9.1, octaves=4)
+ return np.clip((c1 + 0.2) * 1.5, 0, 1) * 0.5
+
+ img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
+ img.save(os.path.join(out_dir, "planet_temperate.png"))
+ print(f" planet_temperate.png")
+
+
+def planet_temperate_terminator(out_dir):
+ """
+ Tidally locked. One face scorched, one face frozen, habitable terminator band.
+ Character: "terminator-band settlement" per Feldmark, Caparica wikis.
+ """
+ hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
+ n = proc_noise(u * 2, v * 2, seed=3.3)
+
+ def surface(u, v, nx, ny, nz):
+ # Longitude position: 0=day face, 0.5=night face
+ lon = (u - 0.25) % 1.0 # shift so day face is center
+ day = np.clip(1 - lon * 2, 0, 1)
+ night = np.clip(lon * 2 - 1, 0, 1)
+ term = 1 - day - night
+ h = n * 0.5
+
+ day_c = np.array([0.80, 0.55, 0.25]) # scorched orange-gold
+ night_c = np.array([0.15, 0.20, 0.30]) # frozen dark blue
+ term_c = lerp_color((85, 130, 90), (130, 160, 100), np.clip(h, 0, 1)) / 255.0
+
+ base = (day_c * day[..., np.newaxis] +
+ night_c * night[..., np.newaxis] +
+ term_c * term[..., np.newaxis])
+ return np.clip(base, 0, 1)
+
+ def clouds(u, v):
+ c = proc_noise(u * 2.5, v * 4, seed=11.1, octaves=3)
+ lon = (u - 0.25) % 1.0
+ term_weight = np.clip(1 - np.abs(lon - 0.5) * 4, 0, 1)
+ return np.clip((c + 0.3) * 0.6, 0, 1) * term_weight
+
+ img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
+ img.save(os.path.join(out_dir, "planet_temperate_terminator.png"))
+ print(f" planet_temperate_terminator.png")
+
+
+def planet_oceanic(out_dir):
+ """
+ Ocean world. Mostly water, scattered archipelagos.
+ Character: Caparica — aquaculture domes visible, terminator farming.
+ """
+ hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
+ n = proc_noise(u * 4, v * 5, seed=5.5, octaves=4)
+
+ def surface(u, v, nx, ny, nz):
+ h = n
+ land = h > 0.55 # very little land — archipelago only
+ ocean_deep_c = np.array([10, 30, 70]) / 255.0
+ ocean_shallow_c = np.array([30, 80, 130]) / 255.0
+ ocean_t = np.clip((h + 0.5) * 0.8, 0, 1)
+ ocean_c = ocean_deep_c * (1 - ocean_t[..., np.newaxis]) + ocean_shallow_c * ocean_t[..., np.newaxis]
+ land_c = np.array([80, 120, 70]) / 255.0
+ base = np.where(land[..., np.newaxis], land_c, ocean_c)
+ # Polar ice
+ lat = np.abs(v - 0.5) * 2
+ ice = np.clip((lat - 0.7) * 5, 0, 1)
+ base = base * (1 - ice[..., np.newaxis]) + np.array([0.85, 0.90, 0.95]) * ice[..., np.newaxis]
+ return base
+
+ def clouds(u, v):
+ c = proc_noise(u * 2, v * 3, seed=22.2, octaves=4)
+ return np.clip((c + 0.3) * 0.7, 0, 1) * 0.7
+
+ img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
+ img.save(os.path.join(out_dir, "planet_oceanic.png"))
+ print(f" planet_oceanic.png")
+
+
+def planet_arid(out_dir):
+ """
+ Arid / desert / Mars-analog. Reddish-orange dust, no permanent surface water.
+ """
+ hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
+ n = proc_noise(u * 2, v * 3, seed=7.7, octaves=4)
+ n2 = proc_noise(u * 8, v * 10, seed=8.2, octaves=2)
+
+ def surface(u, v, nx, ny, nz):
+ h = n * 0.6 + n2 * 0.4
+ base_t = np.clip((h + 0.5) * 0.9, 0, 1)
+ dark_c = np.array([100, 50, 35]) / 255.0
+ light_c = np.array([190, 130, 80]) / 255.0
+ base = dark_c * (1 - base_t[..., np.newaxis]) + light_c * base_t[..., np.newaxis]
+ # Dust storm wisps
+ storm = proc_noise(u * 6, v * 2, seed=13.1, octaves=3)
+ storm_alpha = np.clip((storm + 0.6) * 0.3, 0, 1)
+ dust_c = np.array([0.78, 0.60, 0.45])
+ base = base * (1 - storm_alpha[..., np.newaxis]) + dust_c * storm_alpha[..., np.newaxis]
+ # Thin polar cap
+ lat = np.abs(v - 0.5) * 2
+ ice = np.clip((lat - 0.85) * 8, 0, 1)
+ base = base * (1 - ice[..., np.newaxis]) + np.array([0.90, 0.88, 0.88]) * ice[..., np.newaxis]
+ return np.clip(base, 0, 1)
+
+ # No cloud layer (thin atmosphere)
+ img = render_planet(hit, nx, ny, nz, u, v, surface, cloud_fn=None)
+ img.save(os.path.join(out_dir, "planet_arid.png"))
+ print(f" planet_arid.png")
+
+
+def planet_frozen(out_dir):
+ """
+ Ice world. White/blue, glacial features, ice caps extend to equator.
+ """
+ hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
+ n = proc_noise(u * 3, v * 4, seed=11.1, octaves=5)
+ n2 = proc_noise(u * 7, v * 9, seed=12.3, octaves=3)
+
+ def surface(u, v, nx, ny, nz):
+ h = n * 0.6 + n2 * 0.4
+ # Ice everywhere — variation between white and blue-grey
+ base_t = np.clip((h + 0.3) * 0.9, 0, 1)
+ deep_ice = np.array([0.55, 0.65, 0.80])
+ snow_c = np.array([0.90, 0.92, 0.96])
+ base = deep_ice * (1 - base_t[..., np.newaxis]) + snow_c * base_t[..., np.newaxis]
+ # Exposed rock patches at mid-latitude
+ lat = np.abs(v - 0.5) * 2
+ rock_zone = np.clip((0.4 - lat) * 3, 0, 1) * np.clip(n2 + 0.1, 0, 1)
+ rock_c = np.array([0.35, 0.32, 0.30])
+ base = base * (1 - rock_zone[..., np.newaxis]) + rock_c * rock_zone[..., np.newaxis]
+ return base
+
+ def clouds(u, v):
+ c = proc_noise(u * 2, v * 2, seed=30.0, octaves=3)
+ return np.clip((c + 0.4) * 0.4, 0, 1) * 0.3
+
+ img = render_planet(hit, nx, ny, nz, u, v, surface, clouds)
+ img.save(os.path.join(out_dir, "planet_frozen.png"))
+ print(f" planet_frozen.png")
+
+
+def planet_volcanic(out_dir):
+ """
+ Active volcanic world. Dark basalt surface with orange/red lava flows.
+ """
+ hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
+ n = proc_noise(u * 3, v * 4, seed=15.5, octaves=4)
+ n2 = proc_noise(u * 10, v * 12, seed=16.7, octaves=3)
+
+ def surface(u, v, nx, ny, nz):
+ h = n * 0.7 + n2 * 0.3
+ # Dark basalt base
+ basalt_t = np.clip((h + 0.5) * 0.6, 0, 1)
+ dark_c = np.array([25, 22, 20]) / 255.0
+ mid_c = np.array([65, 55, 50]) / 255.0
+ base = dark_c * (1 - basalt_t[..., np.newaxis]) + mid_c * basalt_t[..., np.newaxis]
+ # Lava flows: bright orange channels in low-elevation areas
+ lava_t = np.clip((-h - 0.1) * 3, 0, 1)
+ lava_c = np.array([0.95, 0.45, 0.10])
+ base = base + lava_c * lava_t[..., np.newaxis]
+ # Volcano glow spots
+ g = proc_noise(u * 15, v * 15, seed=17.3, octaves=2)
+ glow = np.clip((g - 0.7) * 5, 0, 1)
+ glow_c = np.array([1.0, 0.6, 0.2])
+ base = base + glow_c * glow[..., np.newaxis] * 0.5
+ return np.clip(base, 0, 1)
+
+ def clouds(u, v):
+ # Volcanic haze — yellowish-brown sulfur clouds
+ c = proc_noise(u * 4, v * 3, seed=55.0, octaves=3)
+ return np.clip((c + 0.5) * 0.6, 0, 1) * 0.4
+
+ # Warmer star (volcanic world might be inner orbit — stronger illumination)
+ img = render_planet(hit, nx, ny, nz, u, v, surface, clouds,
+ star_lx=-0.55, star_ly=0.40, star_lz=0.73)
+ img.save(os.path.join(out_dir, "planet_volcanic.png"))
+ print(f" planet_volcanic.png")
+
+
+def planet_barren(out_dir):
+ """
+ Airless barren world. Cratered grey-brown, no atmosphere glow.
+ """
+ hit, nx, ny, nz, u, v = raytrace_sphere(SIZE, SPHERE_R)
+ n = proc_noise(u * 3, v * 4, seed=20.0, octaves=4)
+ n2 = proc_noise(u * 12, v * 14, seed=21.3, octaves=2)
+
+ def surface(u, v, nx, ny, nz):
+ h = n * 0.6 + n2 * 0.4
+ t = np.clip((h + 0.5) * 0.8, 0, 1)
+ dark_c = np.array([50, 45, 42]) / 255.0
+ light_c = np.array([140, 130, 120]) / 255.0
+ base = dark_c * (1 - t[..., np.newaxis]) + light_c * t[..., np.newaxis]
+ return base
+
+ # No clouds, no atmosphere glow — hard terminator
+ img = render_planet(hit, nx, ny, nz, u, v, surface, cloud_fn=None,
+ has_atmosphere=False)
+ img.save(os.path.join(out_dir, "planet_barren.png"))
+ print(f" planet_barren.png")
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Main
+# ─────────────────────────────────────────────────────────────────────────────
+
+PLANET_TYPES = [
+ ("temperate", planet_temperate),
+ ("temperate_terminator", planet_temperate_terminator),
+ ("oceanic", planet_oceanic),
+ ("arid", planet_arid),
+ ("frozen", planet_frozen),
+ ("volcanic", planet_volcanic),
+ ("barren", planet_barren),
+]
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Generate procedural planet screenshots")
+ parser.add_argument("--output-dir", default="client/assets/planets",
+ help="Directory for output PNG files")
+ parser.add_argument("--type", choices=[p[0] for p in PLANET_TYPES],
+ help="Render only one planet type")
+ args = parser.parse_args()
+
+ os.makedirs(args.output_dir, exist_ok=True)
+ print(f"Output dir: {args.output_dir}")
+ print(f"Rendering {SIZE}×{SIZE}px spheres…\n")
+
+ targets = PLANET_TYPES if not args.type else [(t, fn) for t, fn in PLANET_TYPES if t == args.type]
+
+ for ptype, fn in targets:
+ fn(args.output_dir)
+
+ print(f"\nDone. {len(targets)} planet type(s) written to {args.output_dir}/")
+ print("For wiki/GTTR display: scale to 240×240 within the 360×360 panel container.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/planet-gen/batch b/tooling/planet-gen/batch
new file mode 100755
index 000000000..0136945b6
--- /dev/null
+++ b/tooling/planet-gen/batch
@@ -0,0 +1,5 @@
+#!/bin/bash
+# Batch planet generation — process all systems unattended.
+# Usage: tooling/planet-gen/batch [--scaffold-only] [--generate-only] [--system GJ-144]
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+exec python3 "$SCRIPT_DIR/batch.py" "$@"
diff --git a/tooling/planet-gen/batch.py b/tooling/planet-gen/batch.py
new file mode 100644
index 000000000..9d7ebd3c7
--- /dev/null
+++ b/tooling/planet-gen/batch.py
@@ -0,0 +1,564 @@
+#!/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:
+ python3 batch.py # full run: scaffold + generate
+ python3 batch.py --scaffold-only # just create body index.md files
+ python3 batch.py --generate-only # just render (bodies must exist)
+ python3 batch.py --system GJ-144 # single system
+ python3 batch.py --system GJ-144 --body GJ144d # single body
+ python3 batch.py --overrides sol.json # per-body overrides
+ python3 batch.py --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 os
+import sys
+import time
+import traceback
+from datetime import datetime
+from pathlib import Path
+
+# Venv bootstrap
+TOOLING_DIR = Path(__file__).resolve().parent
+WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
+_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
+if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
+ os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
+
+import yaml
+import numpy as np
+
+from body_definition_parser import parse_system
+from planet_simulation import simulate
+from 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", "terrain"]
+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}")
+
+ terrain = bd.get("terrain", {})
+ 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]")
+
+ 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"}
+ if pclass not in valid_classes:
+ errors.append(f"{bid}: planet_class '{pclass}' not in {valid_classes}")
+
+ 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 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 _atomic_save_img(img, target: Path):
+ """Write image to .tmp, then rename. Prevents corrupt files on crash."""
+ tmp = target.with_name(target.name + ".tmp")
+ img.save(str(tmp), format="PNG")
+ tmp.rename(target)
+
+
+def _atomic_save(data, target: Path, save_fn):
+ """Write data to .tmp, then rename. Prevents corrupt files on crash."""
+ tmp = target.with_name(target.name + ".tmp")
+ save_fn(data, str(tmp))
+ tmp.rename(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:
+ import render_heightmap as rh
+ rh.OUT_W = hmap_w
+ rh.OUT_H = hmap_h
+ rh.UI_SCALE = hmap_w / 1024
+ rh.RENDER_MODE = "cartographic"
+ rh.BIOME_RGB = rh._build_biome_rgb("cartographic")
+ rh.OCEAN_DEEP, rh.OCEAN_MID, rh.OCEAN_SHALLOW = rh._ocean_arrays("cartographic")
+
+ hmap_img = render_heightmap(bd, terrain, chrome=False)
+ _atomic_save_img(hmap_img, body_dir / "heightmap.png")
+
+ # Globe — atomic write
+ from planet_renderer import render_globe
+ globe_img = render_globe(bd, terrain, size=globe_size)
+ _atomic_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"]])
+ # npz: numpy appends .npz to the path, so write directly
+ # (atomic rename doesn't work cleanly with numpy's extension handling)
+ npz_path = body_dir / "terrain.npz"
+ npz_tmp = body_dir / "terrain_tmp"
+ np.savez_compressed(str(npz_tmp), **save_dict)
+ Path(str(npz_tmp) + ".npz").rename(npz_path)
+
+ # Markers — atomic write
+ from generate import _build_markers
+ markers = _build_markers(bd, terrain)
+ _atomic_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())}"
+ print(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
+ return "generated"
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Main
+# ─────────────────────────────────────────────────────────────────────────────
+
+def main():
+ 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="4096x2048")
+ parser.add_argument("--globe-size", type=int, default=512)
+ args = parser.parse_args()
+
+ 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():
+ print(f"error: {wiki_systems} not found", file=sys.stderr)
+ sys.exit(1)
+
+ 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():
+ print(f"error: system {args.system} not found", file=sys.stderr)
+ sys.exit(1)
+ 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
+
+ print(f"\n Planet Generator — Batch Mode")
+ print(f" Systems: {len(system_dirs)}")
+ print(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
+ if args.dry_run:
+ print(f" Mode: DRY RUN (validation only)")
+ print()
+
+ 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:
+ print(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
+
+ print(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:
+ print(f" scaffolded {bid}")
+ except Exception as e:
+ tb = traceback.format_exc()
+ print(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
+ print(f" {body_id:20s} ({body_name:20s}) INVALID")
+ for err in errors:
+ print(f" - {err}")
+ else:
+ total_valid += 1
+ print(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()
+ print(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:
+ print(f"\n ABORT: error rate {total_errors}/{total_attempted} "
+ f"({total_errors/total_attempted*100:.0f}%) exceeds "
+ f"{error_rate_threshold*100:.0f}% threshold")
+ print(f" Check {LOG_PATH} for details")
+ sys.exit(1)
+
+ elapsed = time.time() - t_total
+
+ print(f"\n Batch complete: {elapsed:.0f}s")
+ if args.dry_run:
+ print(f" valid: {total_valid}")
+ print(f" invalid: {total_invalid}")
+ else:
+ print(f" scaffolded: {total_scaffolded}")
+ print(f" generated: {total_generated}")
+ print(f" skipped: {total_skipped}")
+ print(f" errors: {total_errors}")
+ if total_errors > 0:
+ print(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
+
+ print(f"\n 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:
+ print(" 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:
+ print(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():
+ print(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:
+ print(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
+ all_match = False
+
+ if all_match:
+ passed += 1
+ else:
+ failed += 1
+
+ shutil.rmtree(tmp_dir)
+
+ print(f" passed: {passed} failed: {failed}")
+ if failed > 0:
+ print(f" WARNING: non-deterministic output detected!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/planet-gen/biome_config.py b/tooling/planet-gen/biome_config.py
new file mode 100644
index 000000000..646ff85c9
--- /dev/null
+++ b/tooling/planet-gen/biome_config.py
@@ -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 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"]),
+}
diff --git a/tooling/planet-gen/biomes.toml b/tooling/planet-gen/biomes.toml
new file mode 100644
index 000000000..9b085a08d
--- /dev/null
+++ b/tooling/planet-gen/biomes.toml
@@ -0,0 +1,326 @@
+# 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]
+temperate = [275, 305]
+oceanic = [278, 300]
+forest = [275, 308]
+arid = [295, 340]
+frozen = [210, 265]
+volcanic = [290, 380]
+barren = [180, 380]
+
+# ─────────────────────────────────────────────────────────────────────
+# 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] }
+
+# 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]
+oceanic = [0.40, 0.60, 1.00]
+forest = [0.42, 0.68, 0.80]
+arid = [0.90, 0.72, 0.50]
+martian = [0.82, 0.58, 0.40]
+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
diff --git a/tooling/planet-gen/body_definition_parser.py b/tooling/planet-gen/body_definition_parser.py
new file mode 100644
index 000000000..5cc6ca161
--- /dev/null
+++ b/tooling/planet-gen/body_definition_parser.py
@@ -0,0 +1,778 @@
+"""
+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:
+ python3 body_definition_parser.py path/to/index.md [--out-dir ./defs]
+
+ # With overrides (e.g. Sol)
+ python3 body_definition_parser.py 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
+ATMO_MAP = {
+ "none": "none",
+ "thin": "thin",
+ "breathable": "standard",
+ "dense": "thick",
+ "toxic": "thick", # Venus-style reducing atmosphere
+}
+
+# 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
+BIOME_CLASS = {
+ "temperate": "temperate",
+ "arid": "arid",
+ "frozen": "frozen",
+ "volcanic": "volcanic",
+ "barren": "barren",
+ "forest": "forest",
+ "oceanic": "oceanic",
+}
+
+# planet_class → axial tilt range [min, max] degrees
+# Tidal locking check overrides this for short-period bodies
+CLASS_TILT = {
+ "temperate": (10, 35),
+ "oceanic": (5, 25),
+ "forest": (10, 40),
+ "arid": (5, 30),
+ "frozen": (15, 60), # high tilt → seasonal extremes → frozen
+ "volcanic": (2, 20),
+ "barren": (0, 45),
+}
+
+# planet_class → geothermal flux
+CLASS_GEOTHERMAL = {
+ "volcanic": "extreme",
+ "temperate": "low",
+ "oceanic": "low",
+ "forest": "low",
+ "arid": "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),
+ "oceanic": (0.80, 0.92),
+ "forest": (0.75, 0.88),
+ "arid": (0.90, 0.99),
+ "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),
+ "oceanic": (0.001, 0.004),
+ "forest": (0.001, 0.005),
+ "arid": (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 biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
+
+# planet_class → cloud coverage base range
+CLASS_CLOUD = {
+ "temperate": (0.35, 0.55),
+ "oceanic": (0.55, 0.75),
+ "forest": (0.40, 0.60),
+ "arid": (0.05, 0.20),
+ "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
+}
+
+# ---------------------------------------------------------------------------
+# 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 = BIOME_CLASS.get(biome, "barren")
+
+ 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],
+ "oceanic": [0.40, 0.60, 1.00],
+ "forest": [0.42, 0.68, 0.80],
+ "arid": [0.90, 0.72, 0.50],
+ "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",
+ "oceanic": "active", "forest": "active",
+ "arid": "low", "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",
+ "frozen": "ice",
+ "barren": "silicate",
+ "temperate":"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 giant extras
+ if gas_giant_cfg:
+ 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']}")
+
+ # 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}/")
+
+ _check_habitability(body_def)
+ 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:
+ print(json.dumps(defs, indent=2))
diff --git a/tooling/planet-gen/generate b/tooling/planet-gen/generate
new file mode 100755
index 000000000..f183b9098
--- /dev/null
+++ b/tooling/planet-gen/generate
@@ -0,0 +1,5 @@
+#!/bin/bash
+# Planet generator CLI wrapper.
+# Usage: tooling/planet-gen/generate body_def.json --output-dir ./output
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+exec python3 "$SCRIPT_DIR/generate.py" "$@"
diff --git a/tooling/planet-gen/generate.py b/tooling/planet-gen/generate.py
new file mode 100644
index 000000000..a618143ef
--- /dev/null
+++ b/tooling/planet-gen/generate.py
@@ -0,0 +1,300 @@
+#!/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 sys
+import time
+
+# Venv bootstrap — re-exec into .venv/bin/python if not already there.
+from pathlib import Path
+TOOLING_DIR = Path(__file__).resolve().parent
+WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
+_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
+if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
+ os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
+
+import numpy as np
+
+from planet_simulation import simulate
+from 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
+
+ print(f"\n {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:
+ print(f" simulate: gas giant ({t_sim - t0:.1f}s)")
+ else:
+ print(f" simulate: {t_sim - t0:.1f}s "
+ f"sea={terrain['sea_level']:.3f} "
+ f"land={int((~terrain['surface_water']).sum())} "
+ f"rivers={len(terrain['rivers'])}")
+
+ # ── 2. Render heightmap ──────────────────────────────────────────────
+ t_hmap = t_sim
+ if not is_gas:
+ import render_heightmap as rh
+ rh.OUT_W = hmap_w
+ rh.OUT_H = hmap_h
+ rh.UI_SCALE = hmap_w / 1024
+ rh.RENDER_MODE = render_mode
+ rh.BIOME_RGB = rh._build_biome_rgb(render_mode)
+ rh.OCEAN_DEEP, rh.OCEAN_MID, rh.OCEAN_SHALLOW = rh._ocean_arrays(render_mode)
+
+ # Clean heightmap (no title/legend)
+ hmap_img = render_heightmap(body_def, terrain, 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, chrome=True)
+ chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
+ hmap_chrome.save(chrome_path)
+ print(f" chrome: {chrome_path}")
+
+ t_hmap = time.time()
+ print(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
+
+ # ── 3. Render globe ──────────────────────────────────────────────────
+ try:
+ from 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()
+ print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
+ except Exception as e:
+ print(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
+ print(f" total: {elapsed:.1f}s → {body_dir}/")
+
+
+def main():
+ 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="4096x2048",
+ 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()
+
+ # Parse heightmap size
+ try:
+ hw, hh = args.heightmap_size.lower().split("x")
+ hmap_w, hmap_h = int(hw), int(hh)
+ except ValueError:
+ print(f"error: invalid heightmap size '{args.heightmap_size}'", file=sys.stderr)
+ sys.exit(1)
+
+ # ── Collect body definitions ─────────────────────────────────────────
+ body_defs = []
+
+ if args.system:
+ # Read from system index.md → parse bodies table
+ from 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)
+ print(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:
+ print(f"error: {input_path} frontmatter missing 'id' or 'planet_class'",
+ file=sys.stderr)
+ sys.exit(1)
+ else:
+ print(f"error: {input_path} has no YAML frontmatter", file=sys.stderr)
+ sys.exit(1)
+ else:
+ print(f"error: unrecognized input format: {input_path}", file=sys.stderr)
+ sys.exit(1)
+ 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
+ print(f"\n All done: {len(body_defs)} bodies in {elapsed:.1f}s")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tooling/planet-gen/planet_renderer.py b/tooling/planet-gen/planet_renderer.py
new file mode 100644
index 000000000..6a20c8504
--- /dev/null
+++ b/tooling/planet-gen/planet_renderer.py
@@ -0,0 +1,970 @@
+"""
+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 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 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,
+)
+
+# ---------------------------------------------------------------------------
+# 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
+ u = (np.arctan2(hz, hx) / (2.0 * math.pi)) % 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, os, 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
+ print(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
+ paths.append(out)
+
+ print(f"\nDone. {len(paths)} planets rendered at {qa_size}px.")
diff --git a/tooling/planet-gen/planet_simulation.py b/tooling/planet-gen/planet_simulation.py
new file mode 100644
index 000000000..0862c23ef
--- /dev/null
+++ b/tooling/planet-gen/planet_simulation.py
@@ -0,0 +1,956 @@
+"""
+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
+ "river_grid": bool (H, W) river cell mask
+ "rivers": list of [(row,col), ...] polylines in grid coords
+ "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 biome_config import (
+ WHITTAKER_TABLE, CLASS_T_BAND, EXOTIC_CLASSES, CRATER_SCALING,
+)
+
+log = logging.getLogger(__name__)
+GRID_W = 512
+GRID_H = 256
+
+
+# ---------------------------------------------------------------------------
+# 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()
+ for _ in range(passes):
+ gy, gx = np.gradient(result)
+ slope = np.sqrt(gx**2 + gy**2)
+ smooth = gaussian_filter(result, sigma=1.2)
+ weight = np.clip(slope * 6.0, 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)
+ 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 (2-5 cells), a few are large (15-30)
+ raw_sizes = rng.power(0.4, n_craters) # skewed toward 0
+ sizes = (2 + raw_sizes * 28).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).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)
+ # 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)
+
+
+# ---------------------------------------------------------------------------
+# 5. Rivers
+# ---------------------------------------------------------------------------
+
+def compute_rivers(body_def, elevation, sea_level, moisture,
+ max_rivers=12):
+ atmo = body_def["physical"]["atmosphere"]
+ planet_class = body_def["planet_class"].replace("_ringed", "")
+ hydro = body_def.get("environment", {}).get("hydrosphere", "none")
+
+ if atmo == "none" or hydro in ("none", "subsurface", "ice"):
+ return []
+
+ river_cap = {"barren": 2, "volcanic": 3, "arid": 3, "frozen": 2}
+ max_rivers = river_cap.get(planet_class, max_rivers)
+
+ H, W = elevation.shape
+ land_mask = elevation >= sea_level
+ seed = body_def["seed"]
+ rng = _rng(seed, 500)
+
+ from scipy.ndimage import maximum_filter
+ local_max = (elevation == maximum_filter(elevation, size=8)) & land_mask
+ moist_ok = moisture > 0.35
+ candidates = np.argwhere(local_max & moist_ok)
+ if len(candidates) == 0:
+ candidates = np.argwhere(land_mask)
+
+ np.random.default_rng(seed).shuffle(candidates)
+ sources = candidates[:min(max_rivers, len(candidates))]
+
+ D8 = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
+ rivers = []
+
+ for src in sources:
+ r, c = int(src[0]), int(src[1])
+ path = [(r, c)]
+ visited = {(r, c)}
+
+ for _ in range(GRID_W * 2):
+ if elevation[r, c] < sea_level:
+ break
+ best_drop = 0.0; best_nr = -1; best_nc = -1
+ for dr, dc in D8:
+ nr = r + dr; nc = (c + dc) % W
+ if nr < 0 or nr >= H or (nr, nc) in visited:
+ continue
+ drop = elevation[r, c] - elevation[nr, nc]
+ drop += float(rng.uniform(-0.005, 0.005))
+ if drop > best_drop:
+ best_drop = drop; best_nr = nr; best_nc = nc
+ if best_nr < 0:
+ break
+ r, c = best_nr, best_nc
+ visited.add((r, c))
+ path.append((r, c))
+
+ if len(path) > 5:
+ rivers.append(path)
+
+ return rivers
+
+
+def _rivers_to_grid(rivers, H, W):
+ grid = np.zeros((H, W), dtype=bool)
+ for path in rivers:
+ for r, c in path:
+ if 0 <= r < H and 0 <= c < W:
+ grid[r, c] = True
+ return grid
+
+
+# ---------------------------------------------------------------------------
+# 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
+ ring1 = binary_dilation(oasis_water, iterations=2) & ~oasis_water & land
+ ring2 = binary_dilation(oasis_water, iterations=4) & ~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)
+
+ rivers = compute_rivers(body_def, elevation, sea_level, moisture)
+ river_grid = _rivers_to_grid(rivers, GRID_H, GRID_W)
+
+ 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,
+ "river_grid": river_grid,
+ "rivers": rivers,
+ "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, json, time, os
+ from PIL import Image
+
+ if len(sys.argv) < 2:
+ print("Usage: python3 planet_simulation.py 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
+
+ print(f"Simulating: {bd['id']} ({bd['planet_class']})")
+ t0 = time.time()
+ terrain = simulate(bd)
+
+ if not terrain:
+ print("Gas giant — no terrain simulation.")
+ sys.exit(0)
+
+ dt = time.time() - t0
+ print(f"Done in {dt:.1f}s")
+ print(f" sea_level: {terrain['sea_level']:.3f}")
+ print(f" land cells: {(~terrain['surface_water']).sum()}")
+ print(f" rivers: {len(terrain['rivers'])} polylines")
+
+ ids, counts = np.unique(terrain['biome'], return_counts=True)
+ print(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")
+ print(f"Grids saved → {out}/")
diff --git a/tooling/planet-gen/render_heightmap.py b/tooling/planet-gen/render_heightmap.py
new file mode 100644
index 000000000..8b60933b2
--- /dev/null
+++ b/tooling/planet-gen/render_heightmap.py
@@ -0,0 +1,484 @@
+"""
+render_heightmap.py
+-------------------
+Renders a 4096×2048 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 render_heightmap import render_heightmap
+ from planet_simulation import simulate
+ from 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 biome_config import (
+ BIOME_PALETTE as _BIOME_PALETTE_CFG,
+ RIVER_RGB as _RIVER_RGB_CFG,
+ COAST_RGB as _COAST_RGB_CFG,
+ build_biome_rgb,
+)
+
+# ---------------------------------------------------------------------------
+# Output resolution
+# ---------------------------------------------------------------------------
+
+OUT_W = 4096
+OUT_H = 2048
+UI_SCALE = OUT_W / 1024 # 4.0 — 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: Rivers
+# ---------------------------------------------------------------------------
+
+def _render_rivers(terrain: dict,
+ rgb: np.ndarray) -> np.ndarray:
+ """
+ Draw rivers as anti-aliased polylines.
+ River list is in simulation grid coords (row, col) at GRID_H×GRID_W.
+ Scale to output pixels, draw with PIL.
+ """
+ rivers = terrain.get("rivers", [])
+ if not rivers:
+ return rgb
+
+ GRID_H, GRID_W = terrain["_grid_h"], terrain["_grid_w"]
+ scale_y = OUT_H / GRID_H
+ scale_x = OUT_W / GRID_W
+
+ # Work on a PIL image for anti-aliased line drawing
+ img = Image.fromarray((rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
+ draw = ImageDraw.Draw(img)
+
+ river_col = RIVER_RGB
+
+ for path in rivers:
+ if len(path) < 2:
+ continue
+ # Scale grid coords to output pixels
+ pts = [(int(c * scale_x), int(r * scale_y)) for r, c in path]
+ # Line width scales with path length — longer rivers are wider.
+ # Base width doubled for readability at high output resolutions.
+ width = max(2, min(6, len(path) // 40))
+ draw.line(pts, fill=river_col, width=width, joint="curve")
+
+ return np.array(img).astype(np.float32) / 255.0
+
+
+# ---------------------------------------------------------------------------
+# Layer 6: 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("_ringed", "")
+ 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: "rainforest", 6: "trop. forest",
+ 7: "savanna", 8: "grassland", 9: "forest",
+ 10: "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)
+ step = int(108 * UI_SCALE)
+
+ lx = pad_x
+ for label, rgb in items:
+ if lx + step > OUT_W - pad_x:
+ break
+ draw.rectangle([(lx, leg_y), (lx + sw, leg_y + sh)], fill=rgb)
+ draw.text((lx + sw + gap, leg_y), label,
+ 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 a 4096×2048 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 4096×2048)
+
+ 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: rivers
+ rgb = _render_rivers(terrain, rgb)
+
+ # 6: 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, json, time
+
+ if len(sys.argv) < 2:
+ print("Usage: python3 render_heightmap.py body_def.json [--small]")
+ sys.exit(1)
+
+ with open(sys.argv[1]) as f:
+ bd = json.load(f)
+
+ # --small flag renders at 1024×512 for fast iteration
+ small = "--small" in sys.argv
+ w, h = (1024, 512) if small else (OUT_W, OUT_H)
+
+ from planet_simulation import simulate
+
+ print(f"Simulating: {bd['id']} ({bd['planet_class']})")
+ t0 = time.time()
+ terrain = simulate(bd)
+ sim_t = time.time() - t0
+
+ if not terrain:
+ print("Gas giant — no heightmap.")
+ sys.exit(0)
+
+ print(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")
+ print(f"Saved: {out}")
+ print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
diff --git a/tooling/planet-gen/scaffold_bodies.py b/tooling/planet-gen/scaffold_bodies.py
new file mode 100644
index 000000000..a2ed15434
--- /dev/null
+++ b/tooling/planet-gen/scaffold_bodies.py
@@ -0,0 +1,194 @@
+#!/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:
+ python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md
+ python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json
+ python3 scaffold_bodies.py 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
+import os
+import sys
+from pathlib import Path
+
+# Venv bootstrap
+TOOLING_DIR = Path(__file__).resolve().parent
+WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
+_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
+if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
+ os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
+
+try:
+ import yaml
+except ImportError:
+ # PyYAML is in pyproject.toml deps
+ print("error: PyYAML not installed — run `make setup-venv`", file=sys.stderr)
+ sys.exit(1)
+
+from 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").replace("_ringed", "")
+ 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.title()} moon.")
+ elif pclass in ("gas_giant", "gas_giant_ringed"):
+ lines.append(f"Gas giant.")
+ else:
+ lines.append(f"{pclass.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(f"")
+ lines.append("")
+ if pclass not in ("gas_giant",):
+ lines.append(f"")
+ 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} |")
+ 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(f"| **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("")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def main():
+ 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()
+
+ 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)
+ print(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():
+ print(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:
+ print(f" would create {index_path}")
+ print(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)
+ print(f" created {index_path}")
+
+ created += 1
+
+ action = "would create" if args.dry_run else "created"
+ print(f"\n {action} {created}, skipped {skipped}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144b/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144b/globe.png
new file mode 100644
index 000000000..9a4d65aa3
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144b/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144b/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144b/heightmap.png
new file mode 100644
index 000000000..a88a3e44e
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144b/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144b/index.md b/wiki/star-systems/GJ-144/bodies/GJ144b/index.md
new file mode 100644
index 000000000..9d7efb864
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144b/index.md
@@ -0,0 +1,64 @@
+---
+id: GJ144b
+name: null
+body_type: planet
+planet_class: barren
+body_scale: planet
+seed: 859224927
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 0.175
+ period_days: 30.0
+ axial_tilt_deg: 24.1
+physical:
+ gravity_g: 0.32
+ oblateness: 0.0026
+ atmosphere: none
+ atmosphere_color: null
+terrain:
+ land_fraction: 0.979
+ polar_ice_lat: 0.85
+ tectonics: none
+environment:
+ geothermal_flux: low
+ uv_index: moderate
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: none
+clouds:
+ enabled: false
+ coverage_base: 0.043
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+---
+
+# GJ144b
+
+Barren planet.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | planet |
+| **Class** | barren |
+| **Gravity** | 0.32g |
+| **Land** | 98% |
+| **Year** | 30 days |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144b/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144b/markers.json
new file mode 100644
index 000000000..79e7caf8f
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144b/markers.json
@@ -0,0 +1,404 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [],
+ "oceans": [],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 40,
+ 224
+ ],
+ "peak": [
+ 3,
+ 193
+ ],
+ "area_cells": 34937
+ },
+ {
+ "id": "range_2",
+ "name": null,
+ "center": [
+ 63,
+ 428
+ ],
+ "peak": [
+ 60,
+ 428
+ ],
+ "area_cells": 79
+ },
+ {
+ "id": "range_6",
+ "name": null,
+ "center": [
+ 77,
+ 250
+ ],
+ "peak": [
+ 78,
+ 251
+ ],
+ "area_cells": 23
+ },
+ {
+ "id": "range_9",
+ "name": null,
+ "center": [
+ 90,
+ 267
+ ],
+ "peak": [
+ 88,
+ 265
+ ],
+ "area_cells": 74
+ },
+ {
+ "id": "range_11",
+ "name": null,
+ "center": [
+ 95,
+ 391
+ ],
+ "peak": [
+ 100,
+ 389
+ ],
+ "area_cells": 74
+ },
+ {
+ "id": "range_16",
+ "name": null,
+ "center": [
+ 95,
+ 186
+ ],
+ "peak": [
+ 96,
+ 187
+ ],
+ "area_cells": 47
+ },
+ {
+ "id": "range_17",
+ "name": null,
+ "center": [
+ 96,
+ 57
+ ],
+ "peak": [
+ 97,
+ 57
+ ],
+ "area_cells": 28
+ },
+ {
+ "id": "range_18",
+ "name": null,
+ "center": [
+ 97,
+ 407
+ ],
+ "peak": [
+ 98,
+ 407
+ ],
+ "area_cells": 29
+ },
+ {
+ "id": "range_23",
+ "name": null,
+ "center": [
+ 103,
+ 376
+ ],
+ "peak": [
+ 104,
+ 376
+ ],
+ "area_cells": 44
+ },
+ {
+ "id": "range_24",
+ "name": null,
+ "center": [
+ 103,
+ 255
+ ],
+ "peak": [
+ 103,
+ 256
+ ],
+ "area_cells": 22
+ },
+ {
+ "id": "range_27",
+ "name": null,
+ "center": [
+ 106,
+ 365
+ ],
+ "peak": [
+ 105,
+ 364
+ ],
+ "area_cells": 35
+ },
+ {
+ "id": "range_28",
+ "name": null,
+ "center": [
+ 120,
+ 489
+ ],
+ "peak": [
+ 125,
+ 482
+ ],
+ "area_cells": 271
+ },
+ {
+ "id": "range_29",
+ "name": null,
+ "center": [
+ 132,
+ 191
+ ],
+ "peak": [
+ 128,
+ 191
+ ],
+ "area_cells": 430
+ },
+ {
+ "id": "range_30",
+ "name": null,
+ "center": [
+ 126,
+ 248
+ ],
+ "peak": [
+ 127,
+ 249
+ ],
+ "area_cells": 68
+ },
+ {
+ "id": "range_31",
+ "name": null,
+ "center": [
+ 123,
+ 300
+ ],
+ "peak": [
+ 124,
+ 300
+ ],
+ "area_cells": 20
+ },
+ {
+ "id": "range_33",
+ "name": null,
+ "center": [
+ 141,
+ 290
+ ],
+ "peak": [
+ 142,
+ 291
+ ],
+ "area_cells": 74
+ },
+ {
+ "id": "range_34",
+ "name": null,
+ "center": [
+ 144,
+ 397
+ ],
+ "peak": [
+ 144,
+ 397
+ ],
+ "area_cells": 76
+ },
+ {
+ "id": "range_35",
+ "name": null,
+ "center": [
+ 147,
+ 90
+ ],
+ "peak": [
+ 147,
+ 90
+ ],
+ "area_cells": 156
+ },
+ {
+ "id": "range_36",
+ "name": null,
+ "center": [
+ 148,
+ 268
+ ],
+ "peak": [
+ 147,
+ 273
+ ],
+ "area_cells": 97
+ },
+ {
+ "id": "range_37",
+ "name": null,
+ "center": [
+ 163,
+ 127
+ ],
+ "peak": [
+ 158,
+ 129
+ ],
+ "area_cells": 353
+ },
+ {
+ "id": "range_38",
+ "name": null,
+ "center": [
+ 155,
+ 18
+ ],
+ "peak": [
+ 154,
+ 18
+ ],
+ "area_cells": 204
+ },
+ {
+ "id": "range_39",
+ "name": null,
+ "center": [
+ 154,
+ 353
+ ],
+ "peak": [
+ 157,
+ 354
+ ],
+ "area_cells": 75
+ },
+ {
+ "id": "range_40",
+ "name": null,
+ "center": [
+ 172,
+ 383
+ ],
+ "peak": [
+ 173,
+ 382
+ ],
+ "area_cells": 306
+ },
+ {
+ "id": "range_43",
+ "name": null,
+ "center": [
+ 164,
+ 481
+ ],
+ "peak": [
+ 164,
+ 481
+ ],
+ "area_cells": 80
+ },
+ {
+ "id": "range_44",
+ "name": null,
+ "center": [
+ 164,
+ 229
+ ],
+ "peak": [
+ 164,
+ 230
+ ],
+ "area_cells": 35
+ },
+ {
+ "id": "range_45",
+ "name": null,
+ "center": [
+ 172,
+ 492
+ ],
+ "peak": [
+ 169,
+ 490
+ ],
+ "area_cells": 40
+ },
+ {
+ "id": "range_50",
+ "name": null,
+ "center": [
+ 194,
+ 424
+ ],
+ "peak": [
+ 188,
+ 428
+ ],
+ "area_cells": 511
+ },
+ {
+ "id": "range_51",
+ "name": null,
+ "center": [
+ 184,
+ 90
+ ],
+ "peak": [
+ 184,
+ 89
+ ],
+ "area_cells": 30
+ },
+ {
+ "id": "range_52",
+ "name": null,
+ "center": [
+ 192,
+ 465
+ ],
+ "peak": [
+ 192,
+ 467
+ ],
+ "area_cells": 68
+ },
+ {
+ "id": "range_53",
+ "name": null,
+ "center": [
+ 197,
+ 27
+ ],
+ "peak": [
+ 198,
+ 33
+ ],
+ "area_cells": 138
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144c/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144c/globe.png
new file mode 100644
index 000000000..a3387be47
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144c/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144c/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144c/heightmap.png
new file mode 100644
index 000000000..0d20c433e
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144c/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144c/index.md b/wiki/star-systems/GJ-144/bodies/GJ144c/index.md
new file mode 100644
index 000000000..c47e6358e
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144c/index.md
@@ -0,0 +1,65 @@
+---
+id: GJ144c
+name: null
+body_type: planet
+planet_class: barren
+body_scale: planet
+seed: 1847535954
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 0.416
+ period_days: 110.0
+ axial_tilt_deg: 13.1
+physical:
+ gravity_g: 0.55
+ oblateness: 0.0007
+ atmosphere: thick
+ atmosphere_color: null
+terrain:
+ land_fraction: 0.993
+ polar_ice_lat: 0.906
+ tectonics: none
+environment:
+ geothermal_flux: low
+ uv_index: low
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: none
+clouds:
+ enabled: false
+ coverage_base: 0.002
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+---
+
+# GJ144c
+
+Barren planet.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | planet |
+| **Class** | barren |
+| **Gravity** | 0.55g |
+| **Atmosphere** | thick |
+| **Land** | 99% |
+| **Year** | 110 days |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144c/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144c/markers.json
new file mode 100644
index 000000000..9e7b6335f
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144c/markers.json
@@ -0,0 +1,53 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [],
+ "oceans": [],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 72,
+ 220
+ ],
+ "peak": [
+ 70,
+ 222
+ ],
+ "area_cells": 652
+ },
+ {
+ "id": "range_2",
+ "name": null,
+ "center": [
+ 94,
+ 299
+ ],
+ "peak": [
+ 93,
+ 297
+ ],
+ "area_cells": 506
+ },
+ {
+ "id": "range_3",
+ "name": null,
+ "center": [
+ 196,
+ 234
+ ],
+ "peak": [
+ 181,
+ 91
+ ],
+ "area_cells": 57902
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d-1/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144d-1/globe.png
new file mode 100644
index 000000000..5c16e1f89
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144d-1/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d-1/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144d-1/heightmap.png
new file mode 100644
index 000000000..92470fa01
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144d-1/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d-1/index.md b/wiki/star-systems/GJ-144/bodies/GJ144d-1/index.md
new file mode 100644
index 000000000..2dbf76191
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144d-1/index.md
@@ -0,0 +1,64 @@
+---
+id: GJ144d-1
+name: null
+body_type: moon
+planet_class: barren
+body_scale: moon
+seed: 3555695222
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 0.125
+ period_days: 18.0
+ axial_tilt_deg: 42.3
+physical:
+ gravity_g: 0.08
+ oblateness: 0.0025
+ atmosphere: none
+ atmosphere_color: null
+terrain:
+ land_fraction: 0.99
+ polar_ice_lat: 0.845
+ tectonics: none
+environment:
+ geothermal_flux: low
+ uv_index: moderate
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: none
+clouds:
+ enabled: false
+ coverage_base: 0.008
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+---
+
+# GJ144d-1
+
+Barren moon.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | moon |
+| **Class** | barren |
+| **Gravity** | 0.08g |
+| **Land** | 99% |
+| **Orbit** | 18 days |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d-1/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144d-1/markers.json
new file mode 100644
index 000000000..4247a601d
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144d-1/markers.json
@@ -0,0 +1,209 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [],
+ "oceans": [],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 38,
+ 167
+ ],
+ "peak": [
+ 19,
+ 259
+ ],
+ "area_cells": 18621
+ },
+ {
+ "id": "range_3",
+ "name": null,
+ "center": [
+ 6,
+ 466
+ ],
+ "peak": [
+ 12,
+ 478
+ ],
+ "area_cells": 340
+ },
+ {
+ "id": "range_4",
+ "name": null,
+ "center": [
+ 18,
+ 437
+ ],
+ "peak": [
+ 12,
+ 439
+ ],
+ "area_cells": 47
+ },
+ {
+ "id": "range_5",
+ "name": null,
+ "center": [
+ 21,
+ 390
+ ],
+ "peak": [
+ 21,
+ 390
+ ],
+ "area_cells": 22
+ },
+ {
+ "id": "range_8",
+ "name": null,
+ "center": [
+ 23,
+ 378
+ ],
+ "peak": [
+ 24,
+ 376
+ ],
+ "area_cells": 105
+ },
+ {
+ "id": "range_13",
+ "name": null,
+ "center": [
+ 85,
+ 426
+ ],
+ "peak": [
+ 79,
+ 446
+ ],
+ "area_cells": 7072
+ },
+ {
+ "id": "range_22",
+ "name": null,
+ "center": [
+ 44,
+ 506
+ ],
+ "peak": [
+ 48,
+ 505
+ ],
+ "area_cells": 62
+ },
+ {
+ "id": "range_29",
+ "name": null,
+ "center": [
+ 91,
+ 302
+ ],
+ "peak": [
+ 94,
+ 305
+ ],
+ "area_cells": 82
+ },
+ {
+ "id": "range_30",
+ "name": null,
+ "center": [
+ 101,
+ 329
+ ],
+ "peak": [
+ 100,
+ 329
+ ],
+ "area_cells": 60
+ },
+ {
+ "id": "range_34",
+ "name": null,
+ "center": [
+ 121,
+ 182
+ ],
+ "peak": [
+ 120,
+ 186
+ ],
+ "area_cells": 203
+ },
+ {
+ "id": "range_36",
+ "name": null,
+ "center": [
+ 157,
+ 400
+ ],
+ "peak": [
+ 157,
+ 401
+ ],
+ "area_cells": 115
+ },
+ {
+ "id": "range_37",
+ "name": null,
+ "center": [
+ 162,
+ 375
+ ],
+ "peak": [
+ 161,
+ 372
+ ],
+ "area_cells": 212
+ },
+ {
+ "id": "range_40",
+ "name": null,
+ "center": [
+ 238,
+ 266
+ ],
+ "peak": [
+ 253,
+ 114
+ ],
+ "area_cells": 13961
+ },
+ {
+ "id": "range_41",
+ "name": null,
+ "center": [
+ 174,
+ 6
+ ],
+ "peak": [
+ 175,
+ 6
+ ],
+ "area_cells": 70
+ },
+ {
+ "id": "range_46",
+ "name": null,
+ "center": [
+ 216,
+ 402
+ ],
+ "peak": [
+ 217,
+ 403
+ ],
+ "area_cells": 20
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144d/globe.png
new file mode 100644
index 000000000..4dfedd984
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144d/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144d/heightmap.png
new file mode 100644
index 000000000..f45c767ab
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144d/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d/index.md b/wiki/star-systems/GJ-144/bodies/GJ144d/index.md
new file mode 100644
index 000000000..2ede8e610
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144d/index.md
@@ -0,0 +1,74 @@
+---
+id: GJ144d
+name: Kallast
+body_type: planet
+planet_class: temperate
+body_scale: planet
+seed: 1847020295
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 0.776
+ period_days: 280.0
+ axial_tilt_deg: 30.2
+physical:
+ gravity_g: 0.95
+ oblateness: 0.0022
+ atmosphere: standard
+ atmosphere_color:
+ - 0.45
+ - 0.65
+ - 1.0
+terrain:
+ land_fraction: 0.314
+ polar_ice_lat: 0.642
+ tectonics: active
+environment:
+ geothermal_flux: low
+ uv_index: low
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: ocean
+clouds:
+ enabled: true
+ coverage_base: 0.511
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: true
+ night_side_ambient: 0.025
+---
+
+# Kallast
+
+Temperate planet.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | planet |
+| **Class** | temperate |
+| **Gravity** | 0.95g |
+| **Atmosphere** | standard |
+| **Hydrosphere** | ocean |
+| **Land** | 31% |
+| **Year** | 280 days |
+| **Inhabited** | yes |
+| **Population** | 2.0B |
+| **Economy** | agricultural |
+| **Settlement** | urban_concentrated |
+| **Industry** | Agricultural_Syndic |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144d/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144d/markers.json
new file mode 100644
index 000000000..8273ba7b5
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144d/markers.json
@@ -0,0 +1,595 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [
+ {
+ "id": "river_0",
+ "name": null,
+ "path": [
+ [
+ 229,
+ 119
+ ],
+ [
+ 229,
+ 118
+ ],
+ [
+ 228,
+ 119
+ ],
+ [
+ 227,
+ 119
+ ],
+ [
+ 226,
+ 120
+ ],
+ [
+ 225,
+ 121
+ ],
+ [
+ 224,
+ 122
+ ],
+ [
+ 223,
+ 121
+ ],
+ [
+ 222,
+ 120
+ ]
+ ]
+ },
+ {
+ "id": "river_1",
+ "name": null,
+ "path": [
+ [
+ 129,
+ 207
+ ],
+ [
+ 130,
+ 206
+ ],
+ [
+ 131,
+ 205
+ ],
+ [
+ 132,
+ 204
+ ],
+ [
+ 133,
+ 204
+ ],
+ [
+ 134,
+ 203
+ ]
+ ]
+ },
+ {
+ "id": "river_2",
+ "name": null,
+ "path": [
+ [
+ 130,
+ 329
+ ],
+ [
+ 131,
+ 330
+ ],
+ [
+ 130,
+ 330
+ ],
+ [
+ 129,
+ 330
+ ],
+ [
+ 128,
+ 330
+ ],
+ [
+ 128,
+ 329
+ ],
+ [
+ 128,
+ 328
+ ],
+ [
+ 127,
+ 328
+ ]
+ ]
+ },
+ {
+ "id": "river_3",
+ "name": null,
+ "path": [
+ [
+ 106,
+ 364
+ ],
+ [
+ 107,
+ 365
+ ],
+ [
+ 108,
+ 366
+ ],
+ [
+ 108,
+ 367
+ ],
+ [
+ 107,
+ 368
+ ],
+ [
+ 106,
+ 369
+ ],
+ [
+ 106,
+ 370
+ ],
+ [
+ 106,
+ 371
+ ],
+ [
+ 106,
+ 372
+ ],
+ [
+ 106,
+ 373
+ ],
+ [
+ 106,
+ 374
+ ]
+ ]
+ },
+ {
+ "id": "river_4",
+ "name": null,
+ "path": [
+ [
+ 132,
+ 5
+ ],
+ [
+ 133,
+ 6
+ ],
+ [
+ 134,
+ 7
+ ],
+ [
+ 135,
+ 8
+ ],
+ [
+ 136,
+ 7
+ ],
+ [
+ 137,
+ 6
+ ],
+ [
+ 138,
+ 5
+ ],
+ [
+ 139,
+ 4
+ ],
+ [
+ 140,
+ 4
+ ],
+ [
+ 141,
+ 3
+ ],
+ [
+ 142,
+ 2
+ ],
+ [
+ 143,
+ 1
+ ],
+ [
+ 144,
+ 0
+ ],
+ [
+ 145,
+ 511
+ ],
+ [
+ 146,
+ 510
+ ],
+ [
+ 147,
+ 509
+ ],
+ [
+ 148,
+ 508
+ ],
+ [
+ 149,
+ 507
+ ],
+ [
+ 150,
+ 506
+ ],
+ [
+ 151,
+ 505
+ ],
+ [
+ 152,
+ 505
+ ],
+ [
+ 153,
+ 504
+ ]
+ ]
+ },
+ {
+ "id": "river_5",
+ "name": null,
+ "path": [
+ [
+ 142,
+ 511
+ ],
+ [
+ 141,
+ 510
+ ],
+ [
+ 140,
+ 510
+ ],
+ [
+ 139,
+ 509
+ ],
+ [
+ 138,
+ 508
+ ],
+ [
+ 137,
+ 507
+ ],
+ [
+ 137,
+ 506
+ ],
+ [
+ 138,
+ 505
+ ],
+ [
+ 137,
+ 505
+ ],
+ [
+ 136,
+ 504
+ ],
+ [
+ 136,
+ 503
+ ],
+ [
+ 136,
+ 502
+ ],
+ [
+ 137,
+ 502
+ ],
+ [
+ 138,
+ 501
+ ],
+ [
+ 139,
+ 501
+ ],
+ [
+ 139,
+ 500
+ ],
+ [
+ 140,
+ 499
+ ],
+ [
+ 141,
+ 498
+ ],
+ [
+ 142,
+ 498
+ ],
+ [
+ 143,
+ 497
+ ],
+ [
+ 144,
+ 496
+ ],
+ [
+ 145,
+ 495
+ ],
+ [
+ 146,
+ 494
+ ],
+ [
+ 146,
+ 493
+ ]
+ ]
+ },
+ {
+ "id": "river_6",
+ "name": null,
+ "path": [
+ [
+ 22,
+ 302
+ ],
+ [
+ 21,
+ 303
+ ],
+ [
+ 20,
+ 304
+ ],
+ [
+ 19,
+ 305
+ ],
+ [
+ 18,
+ 306
+ ],
+ [
+ 17,
+ 307
+ ],
+ [
+ 16,
+ 308
+ ],
+ [
+ 15,
+ 309
+ ],
+ [
+ 14,
+ 310
+ ],
+ [
+ 13,
+ 311
+ ]
+ ]
+ },
+ {
+ "id": "river_7",
+ "name": null,
+ "path": [
+ [
+ 58,
+ 295
+ ],
+ [
+ 57,
+ 294
+ ],
+ [
+ 56,
+ 293
+ ],
+ [
+ 55,
+ 292
+ ],
+ [
+ 54,
+ 291
+ ],
+ [
+ 53,
+ 290
+ ],
+ [
+ 52,
+ 289
+ ]
+ ]
+ },
+ {
+ "id": "river_8",
+ "name": null,
+ "path": [
+ [
+ 255,
+ 312
+ ],
+ [
+ 255,
+ 311
+ ],
+ [
+ 255,
+ 310
+ ],
+ [
+ 254,
+ 309
+ ],
+ [
+ 254,
+ 310
+ ],
+ [
+ 253,
+ 309
+ ],
+ [
+ 253,
+ 310
+ ],
+ [
+ 252,
+ 309
+ ],
+ [
+ 252,
+ 310
+ ],
+ [
+ 252,
+ 311
+ ],
+ [
+ 251,
+ 310
+ ],
+ [
+ 250,
+ 309
+ ],
+ [
+ 250,
+ 310
+ ],
+ [
+ 250,
+ 311
+ ],
+ [
+ 249,
+ 310
+ ],
+ [
+ 248,
+ 310
+ ],
+ [
+ 247,
+ 311
+ ]
+ ]
+ }
+ ],
+ "oceans": [
+ {
+ "id": "water_1",
+ "name": null,
+ "kind": "sea",
+ "center": [
+ 22,
+ 52
+ ],
+ "area_fraction": 0.0384
+ },
+ {
+ "id": "water_2",
+ "name": null,
+ "kind": "ocean",
+ "center": [
+ 38,
+ 376
+ ],
+ "area_fraction": 0.1581
+ },
+ {
+ "id": "water_7",
+ "name": null,
+ "kind": "ocean",
+ "center": [
+ 185,
+ 255
+ ],
+ "area_fraction": 0.4843
+ }
+ ],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 75,
+ 163
+ ],
+ "peak": [
+ 78,
+ 160
+ ],
+ "area_cells": 1894
+ },
+ {
+ "id": "range_2",
+ "name": null,
+ "center": [
+ 84,
+ 38
+ ],
+ "peak": [
+ 84,
+ 39
+ ],
+ "area_cells": 41
+ },
+ {
+ "id": "range_6",
+ "name": null,
+ "center": [
+ 136,
+ 23
+ ],
+ "peak": [
+ 137,
+ 19
+ ],
+ "area_cells": 154
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e-1/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144e-1/globe.png
new file mode 100644
index 000000000..f09131738
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144e-1/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e-1/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144e-1/heightmap.png
new file mode 100644
index 000000000..3d9db2e42
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144e-1/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e-1/index.md b/wiki/star-systems/GJ-144/bodies/GJ144e-1/index.md
new file mode 100644
index 000000000..c342c9e31
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144e-1/index.md
@@ -0,0 +1,64 @@
+---
+id: GJ144e-1
+name: null
+body_type: moon
+planet_class: barren
+body_scale: moon
+seed: 1567932962
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 0.095
+ period_days: 12.0
+ axial_tilt_deg: 6.3
+physical:
+ gravity_g: 0.06
+ oblateness: 0.0029
+ atmosphere: none
+ atmosphere_color: null
+terrain:
+ land_fraction: 0.988
+ polar_ice_lat: 0.973
+ tectonics: none
+environment:
+ geothermal_flux: low
+ uv_index: moderate
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: none
+clouds:
+ enabled: false
+ coverage_base: 0.005
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+---
+
+# GJ144e-1
+
+Barren moon.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | moon |
+| **Class** | barren |
+| **Gravity** | 0.06g |
+| **Land** | 99% |
+| **Orbit** | 12 days |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e-1/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144e-1/markers.json
new file mode 100644
index 000000000..32eef8198
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144e-1/markers.json
@@ -0,0 +1,300 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [],
+ "oceans": [],
+ "mountain_ranges": [
+ {
+ "id": "range_2",
+ "name": null,
+ "center": [
+ 24,
+ 143
+ ],
+ "peak": [
+ 23,
+ 141
+ ],
+ "area_cells": 108
+ },
+ {
+ "id": "range_3",
+ "name": null,
+ "center": [
+ 34,
+ 112
+ ],
+ "peak": [
+ 27,
+ 101
+ ],
+ "area_cells": 1180
+ },
+ {
+ "id": "range_6",
+ "name": null,
+ "center": [
+ 103,
+ 308
+ ],
+ "peak": [
+ 98,
+ 265
+ ],
+ "area_cells": 21621
+ },
+ {
+ "id": "range_9",
+ "name": null,
+ "center": [
+ 55,
+ 17
+ ],
+ "peak": [
+ 54,
+ 12
+ ],
+ "area_cells": 225
+ },
+ {
+ "id": "range_21",
+ "name": null,
+ "center": [
+ 100,
+ 488
+ ],
+ "peak": [
+ 115,
+ 480
+ ],
+ "area_cells": 1117
+ },
+ {
+ "id": "range_22",
+ "name": null,
+ "center": [
+ 72,
+ 31
+ ],
+ "peak": [
+ 73,
+ 31
+ ],
+ "area_cells": 20
+ },
+ {
+ "id": "range_25",
+ "name": null,
+ "center": [
+ 98,
+ 19
+ ],
+ "peak": [
+ 93,
+ 17
+ ],
+ "area_cells": 197
+ },
+ {
+ "id": "range_28",
+ "name": null,
+ "center": [
+ 132,
+ 179
+ ],
+ "peak": [
+ 142,
+ 170
+ ],
+ "area_cells": 277
+ },
+ {
+ "id": "range_29",
+ "name": null,
+ "center": [
+ 148,
+ 443
+ ],
+ "peak": [
+ 153,
+ 441
+ ],
+ "area_cells": 615
+ },
+ {
+ "id": "range_31",
+ "name": null,
+ "center": [
+ 152,
+ 34
+ ],
+ "peak": [
+ 155,
+ 34
+ ],
+ "area_cells": 285
+ },
+ {
+ "id": "range_36",
+ "name": null,
+ "center": [
+ 188,
+ 127
+ ],
+ "peak": [
+ 199,
+ 120
+ ],
+ "area_cells": 939
+ },
+ {
+ "id": "range_37",
+ "name": null,
+ "center": [
+ 164,
+ 173
+ ],
+ "peak": [
+ 162,
+ 176
+ ],
+ "area_cells": 29
+ },
+ {
+ "id": "range_38",
+ "name": null,
+ "center": [
+ 169,
+ 193
+ ],
+ "peak": [
+ 169,
+ 197
+ ],
+ "area_cells": 148
+ },
+ {
+ "id": "range_41",
+ "name": null,
+ "center": [
+ 183,
+ 317
+ ],
+ "peak": [
+ 183,
+ 317
+ ],
+ "area_cells": 232
+ },
+ {
+ "id": "range_42",
+ "name": null,
+ "center": [
+ 176,
+ 92
+ ],
+ "peak": [
+ 176,
+ 92
+ ],
+ "area_cells": 27
+ },
+ {
+ "id": "range_43",
+ "name": null,
+ "center": [
+ 188,
+ 63
+ ],
+ "peak": [
+ 187,
+ 67
+ ],
+ "area_cells": 317
+ },
+ {
+ "id": "range_45",
+ "name": null,
+ "center": [
+ 193,
+ 438
+ ],
+ "peak": [
+ 191,
+ 434
+ ],
+ "area_cells": 324
+ },
+ {
+ "id": "range_48",
+ "name": null,
+ "center": [
+ 196,
+ 338
+ ],
+ "peak": [
+ 196,
+ 338
+ ],
+ "area_cells": 69
+ },
+ {
+ "id": "range_50",
+ "name": null,
+ "center": [
+ 207,
+ 80
+ ],
+ "peak": [
+ 210,
+ 81
+ ],
+ "area_cells": 20
+ },
+ {
+ "id": "range_52",
+ "name": null,
+ "center": [
+ 209,
+ 450
+ ],
+ "peak": [
+ 212,
+ 451
+ ],
+ "area_cells": 24
+ },
+ {
+ "id": "range_53",
+ "name": null,
+ "center": [
+ 213,
+ 150
+ ],
+ "peak": [
+ 210,
+ 152
+ ],
+ "area_cells": 36
+ },
+ {
+ "id": "range_56",
+ "name": null,
+ "center": [
+ 220,
+ 434
+ ],
+ "peak": [
+ 219,
+ 437
+ ],
+ "area_cells": 20
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144e/globe.png
new file mode 100644
index 000000000..69b842fb2
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144e/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144e/heightmap.png
new file mode 100644
index 000000000..e13164e49
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144e/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e/index.md b/wiki/star-systems/GJ-144/bodies/GJ144e/index.md
new file mode 100644
index 000000000..dcf5487ee
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144e/index.md
@@ -0,0 +1,74 @@
+---
+id: GJ144e
+name: Vethis
+body_type: planet
+planet_class: temperate
+body_scale: planet
+seed: 2794611636
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 1.112
+ period_days: 480.0
+ axial_tilt_deg: 28.9
+physical:
+ gravity_g: 0.82
+ oblateness: 0.0041
+ atmosphere: standard
+ atmosphere_color:
+ - 0.45
+ - 0.65
+ - 1.0
+terrain:
+ land_fraction: 0.476
+ polar_ice_lat: 0.675
+ tectonics: active
+environment:
+ geothermal_flux: low
+ uv_index: low
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: ocean
+clouds:
+ enabled: true
+ coverage_base: 0.358
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: true
+ night_side_ambient: 0.025
+---
+
+# Vethis
+
+Temperate planet.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | planet |
+| **Class** | temperate |
+| **Gravity** | 0.82g |
+| **Atmosphere** | standard |
+| **Hydrosphere** | ocean |
+| **Land** | 48% |
+| **Year** | 480 days |
+| **Inhabited** | yes |
+| **Population** | 1.2B |
+| **Economy** | agricultural |
+| **Settlement** | dispersed_rural |
+| **Industry** | Agricultural_Syndic |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144e/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144e/markers.json
new file mode 100644
index 000000000..7e7ac6199
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144e/markers.json
@@ -0,0 +1,782 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [
+ {
+ "id": "river_0",
+ "name": null,
+ "path": [
+ [
+ 183,
+ 442
+ ],
+ [
+ 182,
+ 443
+ ],
+ [
+ 181,
+ 444
+ ],
+ [
+ 180,
+ 445
+ ],
+ [
+ 179,
+ 446
+ ],
+ [
+ 178,
+ 447
+ ],
+ [
+ 177,
+ 447
+ ],
+ [
+ 176,
+ 448
+ ],
+ [
+ 175,
+ 447
+ ],
+ [
+ 174,
+ 448
+ ],
+ [
+ 173,
+ 447
+ ],
+ [
+ 173,
+ 448
+ ],
+ [
+ 172,
+ 447
+ ],
+ [
+ 172,
+ 448
+ ]
+ ]
+ },
+ {
+ "id": "river_1",
+ "name": null,
+ "path": [
+ [
+ 209,
+ 423
+ ],
+ [
+ 210,
+ 424
+ ],
+ [
+ 211,
+ 425
+ ],
+ [
+ 212,
+ 426
+ ],
+ [
+ 213,
+ 427
+ ],
+ [
+ 214,
+ 428
+ ]
+ ]
+ },
+ {
+ "id": "river_2",
+ "name": null,
+ "path": [
+ [
+ 136,
+ 98
+ ],
+ [
+ 137,
+ 99
+ ],
+ [
+ 138,
+ 100
+ ],
+ [
+ 139,
+ 101
+ ],
+ [
+ 139,
+ 102
+ ],
+ [
+ 139,
+ 103
+ ],
+ [
+ 139,
+ 104
+ ],
+ [
+ 140,
+ 103
+ ]
+ ]
+ },
+ {
+ "id": "river_3",
+ "name": null,
+ "path": [
+ [
+ 232,
+ 340
+ ],
+ [
+ 231,
+ 339
+ ],
+ [
+ 230,
+ 339
+ ],
+ [
+ 229,
+ 338
+ ],
+ [
+ 228,
+ 338
+ ],
+ [
+ 227,
+ 337
+ ],
+ [
+ 226,
+ 338
+ ],
+ [
+ 226,
+ 337
+ ],
+ [
+ 227,
+ 338
+ ],
+ [
+ 227,
+ 339
+ ]
+ ]
+ },
+ {
+ "id": "river_4",
+ "name": null,
+ "path": [
+ [
+ 147,
+ 430
+ ],
+ [
+ 147,
+ 431
+ ],
+ [
+ 148,
+ 430
+ ],
+ [
+ 149,
+ 429
+ ],
+ [
+ 150,
+ 429
+ ],
+ [
+ 151,
+ 428
+ ],
+ [
+ 152,
+ 429
+ ],
+ [
+ 153,
+ 430
+ ],
+ [
+ 153,
+ 429
+ ],
+ [
+ 154,
+ 430
+ ],
+ [
+ 155,
+ 430
+ ],
+ [
+ 155,
+ 431
+ ],
+ [
+ 156,
+ 432
+ ],
+ [
+ 156,
+ 431
+ ],
+ [
+ 156,
+ 430
+ ],
+ [
+ 157,
+ 430
+ ],
+ [
+ 158,
+ 431
+ ],
+ [
+ 158,
+ 432
+ ],
+ [
+ 157,
+ 433
+ ],
+ [
+ 157,
+ 434
+ ],
+ [
+ 157,
+ 435
+ ],
+ [
+ 157,
+ 436
+ ],
+ [
+ 158,
+ 437
+ ],
+ [
+ 159,
+ 438
+ ],
+ [
+ 158,
+ 438
+ ],
+ [
+ 157,
+ 439
+ ],
+ [
+ 156,
+ 440
+ ],
+ [
+ 156,
+ 441
+ ],
+ [
+ 155,
+ 442
+ ],
+ [
+ 156,
+ 443
+ ],
+ [
+ 155,
+ 444
+ ],
+ [
+ 154,
+ 445
+ ],
+ [
+ 155,
+ 446
+ ],
+ [
+ 154,
+ 446
+ ],
+ [
+ 155,
+ 447
+ ],
+ [
+ 155,
+ 448
+ ],
+ [
+ 156,
+ 449
+ ],
+ [
+ 156,
+ 450
+ ],
+ [
+ 156,
+ 451
+ ],
+ [
+ 155,
+ 452
+ ],
+ [
+ 156,
+ 452
+ ],
+ [
+ 156,
+ 453
+ ],
+ [
+ 157,
+ 454
+ ]
+ ]
+ },
+ {
+ "id": "river_5",
+ "name": null,
+ "path": [
+ [
+ 44,
+ 143
+ ],
+ [
+ 43,
+ 144
+ ],
+ [
+ 42,
+ 145
+ ],
+ [
+ 41,
+ 145
+ ],
+ [
+ 40,
+ 144
+ ],
+ [
+ 39,
+ 143
+ ],
+ [
+ 38,
+ 142
+ ],
+ [
+ 37,
+ 141
+ ],
+ [
+ 36,
+ 140
+ ]
+ ]
+ },
+ {
+ "id": "river_6",
+ "name": null,
+ "path": [
+ [
+ 248,
+ 332
+ ],
+ [
+ 249,
+ 331
+ ],
+ [
+ 249,
+ 330
+ ],
+ [
+ 250,
+ 330
+ ],
+ [
+ 251,
+ 331
+ ],
+ [
+ 252,
+ 331
+ ],
+ [
+ 253,
+ 332
+ ],
+ [
+ 254,
+ 333
+ ],
+ [
+ 255,
+ 334
+ ],
+ [
+ 255,
+ 335
+ ],
+ [
+ 255,
+ 336
+ ],
+ [
+ 255,
+ 337
+ ]
+ ]
+ },
+ {
+ "id": "river_7",
+ "name": null,
+ "path": [
+ [
+ 7,
+ 376
+ ],
+ [
+ 7,
+ 375
+ ],
+ [
+ 7,
+ 374
+ ],
+ [
+ 8,
+ 374
+ ],
+ [
+ 9,
+ 373
+ ],
+ [
+ 10,
+ 372
+ ],
+ [
+ 11,
+ 372
+ ],
+ [
+ 12,
+ 371
+ ],
+ [
+ 13,
+ 370
+ ],
+ [
+ 14,
+ 369
+ ]
+ ]
+ },
+ {
+ "id": "river_8",
+ "name": null,
+ "path": [
+ [
+ 154,
+ 474
+ ],
+ [
+ 155,
+ 474
+ ],
+ [
+ 156,
+ 473
+ ],
+ [
+ 157,
+ 472
+ ],
+ [
+ 157,
+ 471
+ ],
+ [
+ 158,
+ 471
+ ],
+ [
+ 159,
+ 471
+ ],
+ [
+ 160,
+ 471
+ ],
+ [
+ 159,
+ 470
+ ],
+ [
+ 158,
+ 470
+ ],
+ [
+ 158,
+ 469
+ ],
+ [
+ 159,
+ 468
+ ],
+ [
+ 159,
+ 467
+ ],
+ [
+ 158,
+ 467
+ ],
+ [
+ 159,
+ 466
+ ],
+ [
+ 158,
+ 466
+ ],
+ [
+ 157,
+ 465
+ ],
+ [
+ 157,
+ 464
+ ],
+ [
+ 158,
+ 463
+ ],
+ [
+ 158,
+ 462
+ ],
+ [
+ 158,
+ 461
+ ],
+ [
+ 159,
+ 460
+ ],
+ [
+ 159,
+ 459
+ ],
+ [
+ 158,
+ 460
+ ],
+ [
+ 158,
+ 459
+ ],
+ [
+ 159,
+ 458
+ ],
+ [
+ 158,
+ 458
+ ],
+ [
+ 158,
+ 457
+ ],
+ [
+ 157,
+ 456
+ ],
+ [
+ 156,
+ 456
+ ],
+ [
+ 157,
+ 455
+ ],
+ [
+ 158,
+ 455
+ ],
+ [
+ 158,
+ 456
+ ],
+ [
+ 157,
+ 457
+ ],
+ [
+ 156,
+ 458
+ ],
+ [
+ 157,
+ 458
+ ],
+ [
+ 157,
+ 459
+ ],
+ [
+ 157,
+ 460
+ ],
+ [
+ 156,
+ 459
+ ]
+ ]
+ }
+ ],
+ "oceans": [
+ {
+ "id": "water_1",
+ "name": null,
+ "kind": "ocean",
+ "center": [
+ 64,
+ 248
+ ],
+ "area_fraction": 0.4969
+ },
+ {
+ "id": "water_5",
+ "name": null,
+ "kind": "lake",
+ "center": [
+ 216,
+ 443
+ ],
+ "area_fraction": 0.0137
+ },
+ {
+ "id": "water_7",
+ "name": null,
+ "kind": "lake",
+ "center": [
+ 219,
+ 288
+ ],
+ "area_fraction": 0.0056
+ }
+ ],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 177,
+ 183
+ ],
+ "peak": [
+ 160,
+ 140
+ ],
+ "area_cells": 12657
+ },
+ {
+ "id": "range_3",
+ "name": null,
+ "center": [
+ 180,
+ 4
+ ],
+ "peak": [
+ 178,
+ 0
+ ],
+ "area_cells": 148
+ },
+ {
+ "id": "range_4",
+ "name": null,
+ "center": [
+ 200,
+ 17
+ ],
+ "peak": [
+ 204,
+ 17
+ ],
+ "area_cells": 521
+ },
+ {
+ "id": "range_5",
+ "name": null,
+ "center": [
+ 193,
+ 395
+ ],
+ "peak": [
+ 194,
+ 394
+ ],
+ "area_cells": 121
+ },
+ {
+ "id": "range_7",
+ "name": null,
+ "center": [
+ 240,
+ 245
+ ],
+ "peak": [
+ 240,
+ 237
+ ],
+ "area_cells": 136
+ },
+ {
+ "id": "range_8",
+ "name": null,
+ "center": [
+ 243,
+ 501
+ ],
+ "peak": [
+ 243,
+ 501
+ ],
+ "area_cells": 52
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144f/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144f/globe.png
new file mode 100644
index 000000000..85baaf160
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144f/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144f/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144f/heightmap.png
new file mode 100644
index 000000000..38005f8bf
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144f/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144f/index.md b/wiki/star-systems/GJ-144/bodies/GJ144f/index.md
new file mode 100644
index 000000000..2ba306220
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144f/index.md
@@ -0,0 +1,69 @@
+---
+id: GJ144f
+name: null
+body_type: planet
+planet_class: frozen
+body_scale: planet
+seed: 1160599450
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 2.048
+ period_days: 1200.0
+ axial_tilt_deg: 22.4
+physical:
+ gravity_g: 0.4
+ oblateness: 0.0013
+ atmosphere: thin
+ atmosphere_color:
+ - 0.75
+ - 0.88
+ - 1.0
+terrain:
+ land_fraction: 0.701
+ polar_ice_lat: 0.215
+ tectonics: low
+environment:
+ geothermal_flux: low
+ uv_index: moderate
+ substrate: ice
+ chemosynthetic: false
+ hydrosphere: ice
+clouds:
+ enabled: true
+ coverage_base: 0.414
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+---
+
+# GJ144f
+
+Frozen planet.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | planet |
+| **Class** | frozen |
+| **Gravity** | 0.4g |
+| **Atmosphere** | thin |
+| **Hydrosphere** | ice |
+| **Land** | 70% |
+| **Year** | 1200 days |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144f/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144f/markers.json
new file mode 100644
index 000000000..1616f4e69
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144f/markers.json
@@ -0,0 +1,71 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [],
+ "oceans": [
+ {
+ "id": "water_3",
+ "name": null,
+ "kind": "ocean",
+ "center": [
+ 67,
+ 215
+ ],
+ "area_fraction": 0.2537
+ },
+ {
+ "id": "water_4",
+ "name": null,
+ "kind": "lake",
+ "center": [
+ 35,
+ 487
+ ],
+ "area_fraction": 0.0124
+ },
+ {
+ "id": "water_14",
+ "name": null,
+ "kind": "lake",
+ "center": [
+ 134,
+ 170
+ ],
+ "area_fraction": 0.006
+ }
+ ],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 174,
+ 90
+ ],
+ "peak": [
+ 178,
+ 101
+ ],
+ "area_cells": 689
+ },
+ {
+ "id": "range_2",
+ "name": null,
+ "center": [
+ 176,
+ 300
+ ],
+ "peak": [
+ 175,
+ 306
+ ],
+ "area_cells": 254
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-1/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144g-1/globe.png
new file mode 100644
index 000000000..ae29640f0
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144g-1/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-1/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144g-1/heightmap.png
new file mode 100644
index 000000000..7306a6cd0
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144g-1/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-1/index.md b/wiki/star-systems/GJ-144/bodies/GJ144g-1/index.md
new file mode 100644
index 000000000..77a923c96
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144g-1/index.md
@@ -0,0 +1,64 @@
+---
+id: GJ144g-1
+name: null
+body_type: moon
+planet_class: barren
+body_scale: moon
+seed: 3316716208
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 0.046
+ period_days: 4.0
+ axial_tilt_deg: 12.2
+physical:
+ gravity_g: 0.12
+ oblateness: 0.0005
+ atmosphere: none
+ atmosphere_color: null
+terrain:
+ land_fraction: 0.983
+ polar_ice_lat: 0.899
+ tectonics: none
+environment:
+ geothermal_flux: moderate
+ uv_index: moderate
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: none
+clouds:
+ enabled: false
+ coverage_base: 0.003
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+---
+
+# GJ144g-1
+
+Barren moon.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | moon |
+| **Class** | barren |
+| **Gravity** | 0.12g |
+| **Land** | 98% |
+| **Orbit** | 4 days |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-1/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144g-1/markers.json
new file mode 100644
index 000000000..49e200bcc
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144g-1/markers.json
@@ -0,0 +1,313 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [],
+ "oceans": [],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 43,
+ 364
+ ],
+ "peak": [
+ 43,
+ 365
+ ],
+ "area_cells": 27
+ },
+ {
+ "id": "range_3",
+ "name": null,
+ "center": [
+ 53,
+ 352
+ ],
+ "peak": [
+ 53,
+ 352
+ ],
+ "area_cells": 43
+ },
+ {
+ "id": "range_4",
+ "name": null,
+ "center": [
+ 78,
+ 386
+ ],
+ "peak": [
+ 78,
+ 385
+ ],
+ "area_cells": 182
+ },
+ {
+ "id": "range_5",
+ "name": null,
+ "center": [
+ 124,
+ 352
+ ],
+ "peak": [
+ 117,
+ 351
+ ],
+ "area_cells": 1354
+ },
+ {
+ "id": "range_7",
+ "name": null,
+ "center": [
+ 129,
+ 296
+ ],
+ "peak": [
+ 101,
+ 303
+ ],
+ "area_cells": 761
+ },
+ {
+ "id": "range_8",
+ "name": null,
+ "center": [
+ 105,
+ 259
+ ],
+ "peak": [
+ 105,
+ 260
+ ],
+ "area_cells": 171
+ },
+ {
+ "id": "range_11",
+ "name": null,
+ "center": [
+ 124,
+ 410
+ ],
+ "peak": [
+ 124,
+ 410
+ ],
+ "area_cells": 231
+ },
+ {
+ "id": "range_12",
+ "name": null,
+ "center": [
+ 143,
+ 250
+ ],
+ "peak": [
+ 152,
+ 252
+ ],
+ "area_cells": 623
+ },
+ {
+ "id": "range_13",
+ "name": null,
+ "center": [
+ 123,
+ 106
+ ],
+ "peak": [
+ 123,
+ 106
+ ],
+ "area_cells": 43
+ },
+ {
+ "id": "range_14",
+ "name": null,
+ "center": [
+ 129,
+ 88
+ ],
+ "peak": [
+ 128,
+ 90
+ ],
+ "area_cells": 60
+ },
+ {
+ "id": "range_15",
+ "name": null,
+ "center": [
+ 137,
+ 126
+ ],
+ "peak": [
+ 145,
+ 131
+ ],
+ "area_cells": 280
+ },
+ {
+ "id": "range_19",
+ "name": null,
+ "center": [
+ 177,
+ 449
+ ],
+ "peak": [
+ 163,
+ 449
+ ],
+ "area_cells": 3578
+ },
+ {
+ "id": "range_23",
+ "name": null,
+ "center": [
+ 185,
+ 109
+ ],
+ "peak": [
+ 180,
+ 116
+ ],
+ "area_cells": 5529
+ },
+ {
+ "id": "range_25",
+ "name": null,
+ "center": [
+ 176,
+ 290
+ ],
+ "peak": [
+ 176,
+ 290
+ ],
+ "area_cells": 132
+ },
+ {
+ "id": "range_27",
+ "name": null,
+ "center": [
+ 169,
+ 250
+ ],
+ "peak": [
+ 171,
+ 251
+ ],
+ "area_cells": 48
+ },
+ {
+ "id": "range_29",
+ "name": null,
+ "center": [
+ 173,
+ 360
+ ],
+ "peak": [
+ 175,
+ 360
+ ],
+ "area_cells": 53
+ },
+ {
+ "id": "range_31",
+ "name": null,
+ "center": [
+ 188,
+ 334
+ ],
+ "peak": [
+ 189,
+ 335
+ ],
+ "area_cells": 23
+ },
+ {
+ "id": "range_32",
+ "name": null,
+ "center": [
+ 191,
+ 321
+ ],
+ "peak": [
+ 192,
+ 321
+ ],
+ "area_cells": 37
+ },
+ {
+ "id": "range_35",
+ "name": null,
+ "center": [
+ 203,
+ 297
+ ],
+ "peak": [
+ 204,
+ 298
+ ],
+ "area_cells": 40
+ },
+ {
+ "id": "range_36",
+ "name": null,
+ "center": [
+ 205,
+ 277
+ ],
+ "peak": [
+ 205,
+ 278
+ ],
+ "area_cells": 35
+ },
+ {
+ "id": "range_37",
+ "name": null,
+ "center": [
+ 209,
+ 509
+ ],
+ "peak": [
+ 210,
+ 511
+ ],
+ "area_cells": 27
+ },
+ {
+ "id": "range_38",
+ "name": null,
+ "center": [
+ 217,
+ 31
+ ],
+ "peak": [
+ 217,
+ 32
+ ],
+ "area_cells": 38
+ },
+ {
+ "id": "range_39",
+ "name": null,
+ "center": [
+ 217,
+ 130
+ ],
+ "peak": [
+ 218,
+ 128
+ ],
+ "area_cells": 74
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-2/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144g-2/globe.png
new file mode 100644
index 000000000..62cd701af
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144g-2/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-2/heightmap.png b/wiki/star-systems/GJ-144/bodies/GJ144g-2/heightmap.png
new file mode 100644
index 000000000..5ad7945f2
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144g-2/heightmap.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-2/index.md b/wiki/star-systems/GJ-144/bodies/GJ144g-2/index.md
new file mode 100644
index 000000000..a2c15a1cd
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144g-2/index.md
@@ -0,0 +1,65 @@
+---
+id: GJ144g-2
+name: null
+body_type: moon
+planet_class: frozen
+body_scale: moon
+seed: 1569719108
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 0.084
+ period_days: 10.0
+ axial_tilt_deg: 38.6
+physical:
+ gravity_g: 0.08
+ oblateness: 0.0029
+ atmosphere: none
+ atmosphere_color: null
+terrain:
+ land_fraction: 0.837
+ polar_ice_lat: 0.177
+ tectonics: low
+environment:
+ geothermal_flux: low
+ uv_index: moderate
+ substrate: ice
+ chemosynthetic: false
+ hydrosphere: ice
+clouds:
+ enabled: false
+ coverage_base: 0.313
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+---
+
+# GJ144g-2
+
+Frozen moon.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | moon |
+| **Class** | frozen |
+| **Gravity** | 0.08g |
+| **Hydrosphere** | ice |
+| **Land** | 84% |
+| **Orbit** | 10 days |
+
+## Description
+
+
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g-2/markers.json b/wiki/star-systems/GJ-144/bodies/GJ144g-2/markers.json
new file mode 100644
index 000000000..00acd2c26
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144g-2/markers.json
@@ -0,0 +1,48 @@
+{
+ "grid": {
+ "w": 512,
+ "h": 256
+ },
+ "rivers": [],
+ "oceans": [
+ {
+ "id": "water_2",
+ "name": null,
+ "kind": "lake",
+ "center": [
+ 10,
+ 110
+ ],
+ "area_fraction": 0.0099
+ },
+ {
+ "id": "water_3",
+ "name": null,
+ "kind": "ocean",
+ "center": [
+ 36,
+ 223
+ ],
+ "area_fraction": 0.1218
+ }
+ ],
+ "mountain_ranges": [
+ {
+ "id": "range_1",
+ "name": null,
+ "center": [
+ 220,
+ 282
+ ],
+ "peak": [
+ 194,
+ 504
+ ],
+ "area_cells": 32986
+ }
+ ],
+ "roads": [],
+ "cities": [],
+ "railroads": [],
+ "pois": []
+}
\ No newline at end of file
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g/globe.png b/wiki/star-systems/GJ-144/bodies/GJ144g/globe.png
new file mode 100644
index 000000000..6fb814029
Binary files /dev/null and b/wiki/star-systems/GJ-144/bodies/GJ144g/globe.png differ
diff --git a/wiki/star-systems/GJ-144/bodies/GJ144g/index.md b/wiki/star-systems/GJ-144/bodies/GJ144g/index.md
new file mode 100644
index 000000000..ae908a71b
--- /dev/null
+++ b/wiki/star-systems/GJ-144/bodies/GJ144g/index.md
@@ -0,0 +1,74 @@
+---
+id: GJ144g
+name: null
+body_type: gas_giant
+planet_class: gas_giant_ringed
+body_scale: planet
+seed: 4159171102
+star:
+ type: K
+ luminosity_solar: 0.4
+ color_temp_K: 4500
+orbit:
+ distance_au: 3.34
+ period_days: 2500.0
+ axial_tilt_deg: 11.6
+physical:
+ gravity_g: null
+ oblateness: 0.0892
+ atmosphere: thick
+ atmosphere_color: null
+terrain:
+ land_fraction: 0.981
+ polar_ice_lat: 0.837
+ tectonics: low
+environment:
+ geothermal_flux: low
+ uv_index: low
+ substrate: silicate
+ chemosynthetic: false
+ hydrosphere: —
+clouds:
+ enabled: true
+ coverage_base: 0.333
+render:
+ globe_light_angle_deg: 125
+ specular_ocean: false
+ night_side_ambient: 0.025
+gas_giant:
+ band_palette: infernal
+ storm_count: 1
+ storm_max_size: 0.096
+rings:
+ enabled: true
+ inner_radius_factor: 1.19
+ outer_radius_factor: 2.43
+ opacity_base: 0.65
+ ring_color:
+ - 0.75
+ - 0.7
+ - 0.6
+---
+
+# GJ144g
+
+Gas giant.
+
+**System:** [GJ-144](../../index.md)
+
+## Visual
+
+
+
+## Profile
+
+| | |
+|---|---|
+| **Type** | gas_giant |
+| **Class** | gas_giant |
+| **Atmosphere** | thick |
+| **Year** | 2500 days |
+
+## Description
+
+