Merge branch 'sprint-32/visual'

This commit is contained in:
2026-04-06 16:23:07 +02:00
94 changed files with 13447 additions and 2 deletions
+9
View File
@@ -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/
+10
View File
@@ -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-171D-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`
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<!--
Interaction prompt: EXAMINE
Design: magnifying glass — lens circle (geometric primitive) with diagonal handle.
Universally understood. The circle is a geometric primitive, not a decorative curve.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Lens -->
<circle cx="7" cy="7" r="4"/>
<!-- Handle -->
<line x1="10.5" y1="10.5" x2="14" y2="14"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 602 B

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<!--
Interaction prompt: FOLLOW
Design: directional arrow right — clean shaft with arrowhead.
Communicates: move toward / track. Verb context makes the meaning clear.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Arrow shaft -->
<line x1="2" y1="8" x2="13" y2="8"/>
<!-- Arrowhead -->
<polyline points="10,5 13,8 10,11"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 578 B

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<!--
Interaction prompt: OBSERVE
Design: geometric eye — diamond outline with pupil circle.
Shares vocabulary with icon_status_perception.svg (reinforces visual system).
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Diamond eye -->
<polygon points="8,2 15,8 8,14 1,8"/>
<!-- Pupil -->
<circle cx="8" cy="8" r="2.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 573 B

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<!--
Interaction prompt: TALK
Design: speech bubble — rectangle body, triangle callout at bottom-left,
two content lines suggesting text. Rectangular (not rounded) to match
insert UI geometry aesthetic.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Bubble body -->
<rect x="2" y="2" width="12" height="8"/>
<!-- Callout triangle (bottom-left) -->
<polyline points="4,10 4,14 7,10"/>
<!-- Implied text content lines -->
<line x1="5" y1="5" x2="11" y2="5"/>
<line x1="5" y1="7.5" x2="9" y2="7.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 768 B

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
<!--
Inventory item: ACCESS TOKEN (hexagonal tag)
Design: flat-top hexagon body with a small punch-hole circle near top center
(physical tag/credential). Two interior lines suggest encoded markings.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points, 40×40px.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Hexagon (flat-top orientation) -->
<polygon points="10,10 20,5 30,10 30,30 20,35 10,30"/>
<!-- Punch hole (physical tag indicator) -->
<circle cx="20" cy="9" r="2"/>
<!-- Encoded marking lines -->
<line x1="14" y1="20" x2="26" y2="20"/>
<line x1="16" y1="24" x2="24" y2="24"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 798 B

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
<!--
Inventory item: PERSONAL COMM LOG (communication device)
Design: rectangular handheld device body, screen inset, antenna stub,
two diagonal signal-propagation lines (polylines, not curves).
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points, 40×40px.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Device body -->
<rect x="11" y="12" width="16" height="24"/>
<!-- Screen inset -->
<rect x="14" y="15" width="10" height="9"/>
<!-- Antenna stub -->
<line x1="21" y1="12" x2="23" y2="7"/>
<!-- Signal propagation lines (angled, not arcs) -->
<polyline points="25,9 27,10 28,12"/>
<polyline points="25,7 29,9 30,12"/>
<!-- Control button -->
<line x1="16" y1="31" x2="22" y2="31"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 913 B

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40">
<!--
Inventory item: MANIFEST (document)
Design: document body with top-right folded corner (classic document indicator),
three text-content lines inside suggesting information density.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points, 40×40px.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Document body with folded corner (pentagon path) -->
<path d="M8,4 L22,4 L28,10 L28,34 L8,34 Z"/>
<!-- Fold crease lines (complete the corner triangle) -->
<polyline points="22,4 22,10 28,10"/>
<!-- Content text lines -->
<line x1="12" y1="17" x2="24" y2="17"/>
<line x1="12" y1="21" x2="24" y2="21"/>
<line x1="12" y1="25" x2="19" y2="25"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 854 B

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
<!--
Stance: CAREFUL
Design: arms spread horizontally at full width (shoulder-height lateral awareness),
legs narrow and close together (deliberate, controlled foot placement).
Silhouette reads as a wide T with narrow base — unmistakably different from
WALK's diagonal arm-swing and wide stride at 20px.
Differentiator vs WALK: arms point sideways (not fore/aft), legs almost together
(not striding). The wide-arm / narrow-leg ratio inverts WALK's geometry.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Head -->
<circle cx="10" cy="4" r="2.5"/>
<!-- Torso (upright) -->
<line x1="10" y1="6.5" x2="10" y2="13"/>
<!-- Arms horizontal at shoulder height — full-width spread (T-shape) -->
<line x1="10" y1="9" x2="3" y2="9"/>
<line x1="10" y1="9" x2="17" y2="9"/>
<!-- Legs narrow — deliberate, almost no stride (controlled placement) -->
<line x1="10" y1="13" x2="8" y2="19"/>
<line x1="10" y1="13" x2="12" y2="19"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
<!--
Stance: CROUCH
Design: head lowered, torso bent forward, knees visibly bent via polyline angles.
Differentiator vs all others: compressed vertical range, bent-knee polylines.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Head (lowered, compressed stance) -->
<circle cx="9" cy="6" r="2.5"/>
<!-- Torso (bent at waist, angled forward) -->
<line x1="9" y1="8.5" x2="10" y2="13"/>
<!-- Left arm (braced forward) -->
<line x1="9.5" y1="10.5" x2="5" y2="12"/>
<!-- Right arm (rearward) -->
<line x1="9.5" y1="10.5" x2="13" y2="11"/>
<!-- Left leg (bent knee) -->
<polyline points="10,13 6,16 5,19"/>
<!-- Right leg (bent knee) -->
<polyline points="10,13 14,15 15,19"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 956 B

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
<!--
Stance: SPRINT
Design: head shifted forward, torso angled aggressively, wide asymmetric stride.
Differentiator vs WALK: clear forward lean of both head and torso,
arms in pump position (one high, one low), wider leg spread.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Head (shifted forward, momentum lean) -->
<circle cx="12" cy="4" r="2.5"/>
<!-- Torso (strongly angled forward) -->
<line x1="12" y1="6.5" x2="9" y2="13"/>
<!-- Left arm (drive forward and up) -->
<line x1="11" y1="9" x2="14" y2="13"/>
<!-- Right arm (pull back) -->
<line x1="11" y1="9" x2="6" y2="11"/>
<!-- Left leg (long lead stride) -->
<line x1="9" y1="13" x2="5" y2="19"/>
<!-- Right leg (push-off, trailing high) -->
<line x1="9" y1="13" x2="15" y2="17"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
<!--
Stance: WALK
Design: upright figure, bilateral stride, arms in natural walking swing.
Differentiator vs CAREFUL: arms follow natural walk arc, not spread wide.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Head -->
<circle cx="10" cy="4" r="2.5"/>
<!-- Torso -->
<line x1="10" y1="6.5" x2="10" y2="13"/>
<!-- Left arm (forward swing) -->
<line x1="10" y1="9" x2="6" y2="12"/>
<!-- Right arm (back swing) -->
<line x1="10" y1="9" x2="14" y2="11"/>
<!-- Left leg (forward step) -->
<line x1="10" y1="13" x2="6" y2="19"/>
<!-- Right leg (trailing step) -->
<line x1="10" y1="13" x2="14" y2="18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 885 B

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
<!--
Status badge: HEALTH
Design: medical cross — single polygon path tracing the outer edge of the plus shape.
Solid fill: no internal crossing lines, reads clearly at 20px.
D-086: fill=#c8d0e0 (explicit solid element), no stroke required.
Runtime recolor: shader replaces fill color via tint_color parameter.
-->
<path
d="M8.5,3 L11.5,3 L11.5,8.5 L17,8.5 L17,11.5 L11.5,11.5 L11.5,17 L8.5,17 L8.5,11.5 L3,11.5 L3,8.5 L8.5,8.5 Z"
fill="#c8d0e0"
stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20">
<!--
Status badge: PERCEPTION MODE
Design: geometric eye — diamond outline (4 straight lines) with centered circle pupil.
Diamond shape reinforces the insert/lattice aesthetic; no curves in the outer form.
Communicates: vision, awareness, active perception layer.
Deferred: mode-specific variants (thermal, camera feed) await #315 specs.
D-086: 1px stroke, #c8d0e0 chrome, minimal anchor points.
-->
<g stroke="#c8d0e0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" fill="none">
<!-- Diamond eye outline (4 straight edges) -->
<polygon points="10,3 18,10 10,17 2,10"/>
<!-- Pupil circle -->
<circle cx="10" cy="10" r="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+2 -1
View File
@@ -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: 34 items at 40×40px (manifest, access token, comm log)
- Perception mode indicators: 23 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.
+2 -1
View File
@@ -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
---
+154
View File
@@ -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 3050% 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.
+157
View File
@@ -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
+5
View File
@@ -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]
+283
View File
@@ -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) | ~36s |
| Heightmap render (4096×2048) | ~24s |
| Globe render (2048×2048) | ~36s |
| **Total per planet** | **~816s** |
Full batch of 301 planets: ~4080 minutes single-threaded.
Parallelisable across all CPU cores (no shared state) — ~1020 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?
@@ -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 <path/to/index.md> 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()
+53
View File
@@ -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
}
}
@@ -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 | 303999 | 0.651.00 | (50, 140, 65) |
| 6 | tropical seasonal | 303999 | 0.350.65 | (90, 170, 75) |
| 7 | savanna | 293999 | 0.180.35 | (175, 210, 105) |
| 8 | temperate grassland | 278303 | 0.300.55 | (190, 210, 110) |
| 9 | temperate deciduous | 273308 | 0.301.00 | (70, 148, 70) |
| 10 | temperate rainforest | 278303 | 0.601.00 | (45, 125, 65) |
| 11 | boreal / taiga | 253278 | 0.151.00 | (28, 88, 55) |
| 12 | shrubland | 278303 | 0.100.30 | (168, 168, 95) |
| 13 | temperate desert | 273293 | 0.000.30 | (215, 200, 155) |
| 14 | subtropical desert | 283308 | 0.000.18 | (210, 165, 85) |
| 15 | hot desert | 303999 | 0.000.18 | (215, 138, 55) |
| 16 | tundra | 233263 | 0.001.00 | (198, 185, 145) |
| 17 | ice / snow | 200273 | 0.001.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 19 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.
@@ -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 01, 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))
File diff suppressed because it is too large Load Diff
@@ -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}/")
@@ -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 12px 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")
+52
View File
@@ -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
}
}
@@ -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}
}
@@ -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}
}
@@ -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}
}
@@ -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}
}
@@ -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}
}
+11
View File
@@ -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}
}
@@ -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}
}
@@ -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}
}
+511
View File
@@ -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()
+5
View File
@@ -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" "$@"
+564
View File
@@ -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()
+98
View File
@@ -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"]),
}
+326
View File
@@ -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
@@ -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 01, 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))
+5
View File
@@ -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" "$@"
+300
View File
@@ -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()
+970
View File
@@ -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.")
+956
View File
@@ -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}/")
+484
View File
@@ -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 12px 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")
+194
View File
@@ -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"![Globe](globe.png)")
lines.append("")
if pclass not in ("gas_giant",):
lines.append(f"![Heightmap](heightmap.png)")
lines.append("")
# Profile table
lines.append("## Profile")
lines.append("")
is_gas = pclass in ("gas_giant", "gas_giant_ringed")
lines.append("| | |")
lines.append("|---|---|")
lines.append(f"| **Type** | {btype} |")
lines.append(f"| **Class** | {pclass} |")
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("<!-- Body content: narrative, history, notes -->")
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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## Profile
| | |
|---|---|
| **Type** | planet |
| **Class** | barren |
| **Gravity** | 0.32g |
| **Land** | 98% |
| **Year** | 30 days |
## Description
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## Profile
| | |
|---|---|
| **Type** | planet |
| **Class** | barren |
| **Gravity** | 0.55g |
| **Atmosphere** | thick |
| **Land** | 99% |
| **Year** | 110 days |
## Description
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## Profile
| | |
|---|---|
| **Type** | moon |
| **Class** | barren |
| **Gravity** | 0.08g |
| **Land** | 99% |
| **Orbit** | 18 days |
## Description
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## 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
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## Profile
| | |
|---|---|
| **Type** | moon |
| **Class** | barren |
| **Gravity** | 0.06g |
| **Land** | 99% |
| **Orbit** | 12 days |
## Description
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## 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
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## Profile
| | |
|---|---|
| **Type** | planet |
| **Class** | frozen |
| **Gravity** | 0.4g |
| **Atmosphere** | thin |
| **Hydrosphere** | ice |
| **Land** | 70% |
| **Year** | 1200 days |
## Description
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## Profile
| | |
|---|---|
| **Type** | moon |
| **Class** | barren |
| **Gravity** | 0.12g |
| **Land** | 98% |
| **Orbit** | 4 days |
## Description
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

@@ -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
![Globe](globe.png)
![Heightmap](heightmap.png)
## Profile
| | |
|---|---|
| **Type** | moon |
| **Class** | frozen |
| **Gravity** | 0.08g |
| **Hydrosphere** | ice |
| **Land** | 84% |
| **Orbit** | 10 days |
## Description
<!-- Body content: narrative, history, notes -->
@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -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
![Globe](globe.png)
## Profile
| | |
|---|---|
| **Type** | gas_giant |
| **Class** | gas_giant |
| **Atmosphere** | thick |
| **Year** | 2500 days |
## Description
<!-- Body content: narrative, history, notes -->