feat(assets): heightmap pipeline spike — prototype + test inputs
PO-built prototype (FBM terrain, Whittaker biomes, procedural globe) with test body definitions for all planet types. Replaces pyplatec approach. Spike validates the pipeline architecture for batch #817. Includes handover doc, 9 test body definitions, and updated spike pipeline documentation. Stale pyplatec outputs removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,23 +4,26 @@
|
||||
**Author:** Araminta
|
||||
**Date:** 2026-04-05
|
||||
**Status:** Spike complete — awaiting review before batch (#794, Sprint 33)
|
||||
**Output:** `heightmaps/GJ144d_kallast.png` (4096×2048px)
|
||||
**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 pipeline from wiki data through to a
|
||||
deliverable PNG. Every stage ran successfully on Kallast (GJ144d, Ran system):
|
||||
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.
|
||||
|
||||
- pyplatec tectonic simulation → elevation grid
|
||||
- Erosion pass → softer ridges, valley hints
|
||||
- Dynamic sea level → correct 40% land coverage from wiki spec
|
||||
- Terrain classification → 13 biome classes
|
||||
- D8 flow accumulation → river network
|
||||
- Settlement placement snapped to appropriate terrain class
|
||||
- Road network connecting all cities
|
||||
- Annotated render with title/legend in Settled Reach visual grammar
|
||||
- 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).**
|
||||
|
||||
@@ -28,224 +31,253 @@ deliverable PNG. Every stage ran successfully on Kallast (GJ144d, Ran system):
|
||||
|
||||
## Planet: Kallast (GJ144d)
|
||||
|
||||
Selected because it showcases all annotation types:
|
||||
Selected because it showcases temperate terrain variety and the wiki narrative
|
||||
provides a direct visual brief.
|
||||
|
||||
| Property | Value | Source |
|
||||
|----------|-------|--------|
|
||||
| Planet ID | `GJ144d` | systems.db |
|
||||
| System | Ran (GJ 144) | systems.db |
|
||||
| Biome | temperate | systems.db |
|
||||
| Hydrosphere | ocean | systems.db |
|
||||
| Land coverage | 40% | wiki: "amber continental shelves" |
|
||||
| Population | 2,000,000,000 | systems.db |
|
||||
| Settlement wave | 1 (580y) | systems.db |
|
||||
| Settlement pattern | urban_concentrated | systems.db |
|
||||
| Industrial | Agricultural_Syndic | systems.db |
|
||||
| Terrain character | Extensive temperate plains, amber-toned grassland | wiki narrative |
|
||||
|
||||
Kallast was chosen over higher-population worlds (Haodu, etc.) because the wiki narrative
|
||||
explicitly describes the terrain features we need to annotate: "amber continental shelves
|
||||
broken by irrigation channels wide enough to see from low orbit." That text is a direct
|
||||
visual brief. The pipeline output should feel consistent with it.
|
||||
| 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: Planet profile (wiki + systems.db)
|
||||
↓ planet_type, land_fraction, settlement data
|
||||
Input: wiki/star-systems/GJ-144/index.md
|
||||
↓ body_definition_parser.parse_system()
|
||||
|
||||
Stage 1: Tectonic simulation (pyplatec)
|
||||
platec.create(seed, W, H, sea_level=land_fraction, …)
|
||||
platec.step() × 200 [200 steps for mature, well-eroded world]
|
||||
platec.get_heightmap() → float list → reshape → normalize [0, 1]
|
||||
Runtime: ~1s at 512×256 (scales linearly with grid × steps)
|
||||
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: Erosion (scipy gaussian_filter)
|
||||
Slope-weighted smoothing: steep cells erode more
|
||||
4 passes on mature world (reduce to 2 for young volcanic)
|
||||
Runtime: <0.5s at 512×256
|
||||
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: Dynamic sea level
|
||||
sea_level = np.percentile(terrain, (1 - land_fraction) * 100)
|
||||
CRITICAL: pyplatec output is heavily right-skewed (most cells at low
|
||||
elevation). A fixed sea_level fraction (e.g. 0.40) does NOT produce
|
||||
40% land — you get ~0.2% land. Always compute from actual distribution.
|
||||
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: Terrain classification (13 classes)
|
||||
Thresholds as fractions of the land elevation range [sea_level, max]
|
||||
so classification scales correctly across different pyplatec outputs.
|
||||
Classes: ocean_deep → ocean_mid → ocean_shallow → coast → lowland →
|
||||
plains → grassland → hills → forest → highland → mountain →
|
||||
peak → snow
|
||||
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: D8 flow accumulation → river network
|
||||
Sort land cells by elevation descending
|
||||
Each cell drains to steepest downslope neighbour (8-directional)
|
||||
Flow threshold: 30 (calibrated for 512×256 grid with 40% land)
|
||||
Note: threshold scales with grid size and terrain relief — calibrate
|
||||
per planet type. Very flat worlds (like Kallast) need lower threshold.
|
||||
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: Settlement placement
|
||||
For each city from wiki data: snap to nearest plains/grain_belt cell
|
||||
within expanding search radius (20 → 40 → 60 → 80 cells)
|
||||
Preference order: plains (class 5) > grain_belt (6) > lowland (4) >
|
||||
coast (3) > hills (7)
|
||||
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: Road network
|
||||
Tier-1 and tier-2 cities connected by major roads (all-pairs from capital)
|
||||
Tier-3 nodes connected to nearest tier-1/2 by minor roads
|
||||
Rendered as polylines on the annotated layer
|
||||
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: Geographic render (PIL)
|
||||
1. Base terrain color layer (RGB from class colors)
|
||||
2. Elevation shading on land (ambient occlusion proxy)
|
||||
3. Dilate river mask at source grid resolution (2 iterations, preserves topology)
|
||||
4. Scale up terrain to output resolution (4096×2048) via LANCZOS
|
||||
5. Paint rivers AFTER upscale via NEAREST-neighbor upscaled mask
|
||||
CRITICAL: painting before LANCZOS blurs rivers into invisibility.
|
||||
Post-upscale NEAREST gives each source cell a 4×4px block — clearly legible.
|
||||
6. Lat/lon grid lines (every 30°), scaled width
|
||||
7. Title panel + legend — natural geographic features only
|
||||
(ocean, coast, plains, grassland, mountain, river)
|
||||
Text/panel sizes scale with UI_SCALE = OUTPUT_W / 1024
|
||||
NOTE: settlements, roads, freight elevators are NOT rendered here.
|
||||
They live in the JSON sidecar and are overlaid by the atlas app.
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration per Planet Type
|
||||
|
||||
For the batch run (#794), per-planet config differs in:
|
||||
|
||||
| Parameter | Kallast | Young volcanic | Ice world | Desert | Ocean world |
|
||||
|-----------|---------|----------------|-----------|--------|-------------|
|
||||
| `plate_count` | 10 | 4 | 7 | 6 | 8 |
|
||||
| `sim_steps` | 200 | 100 | 150 | 150 | 180 |
|
||||
| `erosion_passes` | 4 | 1 | 3 (glacial) | 2 (aeolian) | 3 |
|
||||
| `land_fraction` | 0.40 | 0.55 | 0.30 | 0.60 | 0.15 |
|
||||
| `river_threshold` | 60 | 320 | 80 | 60 | 200 |
|
||||
|
||||
The `land_fraction` comes directly from the wiki's hydrosphere field:
|
||||
- `ocean` → 0.30–0.45
|
||||
- `liquid_water` → 0.40–0.60
|
||||
- `ice` → 0.20–0.35
|
||||
- `none` → 0.90–0.99
|
||||
|
||||
---
|
||||
|
||||
## Two-Layer Model
|
||||
|
||||
Heightmaps are **geographic only**. Human data lives in JSON sidecars.
|
||||
|
||||
```
|
||||
kallast_heightmap.png ← geographic render: terrain, rivers, biomes, grid
|
||||
kallast_heightmap_settlements.json ← human layer: city names + grid coordinates
|
||||
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, dilated river
|
||||
network, lat/lon grid, title panel.
|
||||
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
|
||||
separately by the atlas app (Phase 3) when the map is interactive.
|
||||
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 (cities grow, shrink, change character). Keeping them separate
|
||||
means the PNG can be regenerated from terrain data without recomputing settlement
|
||||
placement, and vice versa.
|
||||
**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.
|
||||
|
||||
## Output Files
|
||||
---
|
||||
|
||||
| File | Size | Description |
|
||||
|------|------|-------------|
|
||||
| `kallast_heightmap.png` | 4096×2048px | Geographic world map (deliverable) |
|
||||
| `kallast_terrain.npy` | ~2MB | Raw normalised elevation grid (numpy float32, 1024×512) |
|
||||
| `kallast_heightmap_settlements.json` | <1KB | City positions for atlas DB import (human layer sidecar) |
|
||||
## Configuration
|
||||
|
||||
For batch production, the `.npy` and `.json` files are inputs to the Phase 3
|
||||
atlas pipeline — they pre-seed the city layer rather than requiring re-computation.
|
||||
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. **River painting order is critical.** Painting river pixels into the source-resolution
|
||||
array before LANCZOS upscaling blurs them into invisibility. Always dilate at source
|
||||
resolution, then upscale with NEAREST neighbor and paint AFTER. Enforced in `render()`.
|
||||
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. **Flat worlds produce sparse rivers.** Kallast has low terrain relief. Threshold=60
|
||||
at 1024×512 gives 113 pre-dilation cells (1164 post). Scale threshold with grid area:
|
||||
`threshold_1024 ≈ threshold_512 * 4`. For this flat world, halve the baseline to get
|
||||
denser coverage.
|
||||
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. **City placement uses wiki narrative coordinates, not astrophysical simulation.**
|
||||
Relative positions (e.g. "Kallast Prime at 45% longitude, 48% latitude") are editorial
|
||||
decisions. The snap algorithm finds nearest suitable terrain class within search radius.
|
||||
This is intentional — settlement locations should reflect the world's narrative.
|
||||
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. **Agricultural layer.** The wiki describes irrigation channels wide enough to see
|
||||
from orbit. These are human infrastructure — they belong in the JSON sidecar, not the
|
||||
geographic heightmap. Phase 3 atlas work should render irrigation channels as a
|
||||
separate overlay from hydrology + settlement data.
|
||||
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 Run Estimate (#794)
|
||||
## Batch Estimate (#794)
|
||||
|
||||
Grid size: 1024×512. Output: 4096×2048.
|
||||
Grid size: 512×256 (prototype default). Output: 4096×2048 heightmap + 2048 globe.
|
||||
|
||||
| Phase | Step | Time per planet | 301 planets |
|
||||
|-------|------|-----------------|-------------|
|
||||
| Tectonic (200 steps, 1024×512) | ~3.5s | 1054s |
|
||||
| Erosion (4 passes) | ~1.0s | 301s |
|
||||
| Hydrology | ~0.5s | 151s |
|
||||
| Placement + roads | ~0.5s | 151s |
|
||||
| Render + export | ~0.8s | 241s |
|
||||
| **Total** | | **~6.3s/planet** | **~32 minutes** |
|
||||
Timing per planet (single-threaded, approximate):
|
||||
|
||||
Full batch of 301 systems runs in ~32 minutes single-threaded. Parallelisable across all
|
||||
CPU cores (no shared state) — realistically ~8 minutes on 4 cores.
|
||||
| Stage | Time |
|
||||
|-------|------|
|
||||
| Parse wiki | <0.1s |
|
||||
| Simulation (elev+temp+moist+hs+rivers+biome) | ~3–6s |
|
||||
| Heightmap render (4096×2048) | ~2–4s |
|
||||
| Globe render (2048×2048) | ~3–6s |
|
||||
| **Total per planet** | **~8–16s** |
|
||||
|
||||
Note: if batch time is a concern, `sim_steps=100` halves tectonic time with acceptable
|
||||
terrain quality for most planet types. Only mature worlds (Kallast, old ocean worlds)
|
||||
benefit meaningfully from 200 steps.
|
||||
Full batch of 301 planets: ~40–80 minutes single-threaded.
|
||||
Parallelisable across all CPU cores (no shared state) — ~10–20 min on 4 cores.
|
||||
|
||||
---
|
||||
|
||||
## Running the Spike
|
||||
|
||||
```bash
|
||||
# Standard (200 tectonic steps, ~1.5s)
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py
|
||||
|
||||
# Fast mode (50 steps — good for testing annotation, poor terrain)
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py --fast
|
||||
|
||||
# Different seed (changes continent layout)
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py --seed 42
|
||||
|
||||
# Custom output path
|
||||
python3 spikes/heightmap-pipeline/generate_kallast.py --output heightmaps/GJ144d_kallast_v2.png
|
||||
```
|
||||
|
||||
Dependencies: `pyplatec`, `scipy`, `numpy`, `Pillow` (all installable via pip)
|
||||
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), Jeroen should confirm:
|
||||
Before starting batch (#794):
|
||||
|
||||
1. **Visual style.** Does the terrain color palette work? The grassland amber
|
||||
(`#a59b4b`) reads as temperate plains — is this the right mood for Kallast?
|
||||
2. **Annotation density.** 12 settlements for a 2B-population world — too sparse? too
|
||||
many? For the batch, settlement count would be derived from wiki city data (if any)
|
||||
or a formula from population + settlement_pattern.
|
||||
3. **Output resolution.** 1024×512 adequate for wiki use? Or do we need 2048×1024
|
||||
for the implant atlas app (Phase 3)?
|
||||
4. **River threshold calibration.** The flat terrain of Kallast needed threshold=30.
|
||||
Should we auto-calibrate per planet by targeting N river-mouth cells, rather than
|
||||
a fixed threshold?
|
||||
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?
|
||||
|
||||
@@ -1,567 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Heightmap Spike: Kallast (GJ144d)
|
||||
Kallast (GJ144d) heightmap + globe generator — Settled Reach
|
||||
|
||||
Produces one annotated heightmap for Kallast — Ran system's inner habitable
|
||||
world. Wave 1 agricultural planet, breathable atmosphere, 0.95g, temperate.
|
||||
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:
|
||||
1. Tectonic simulation (pyplatec) → elevation grid
|
||||
2. Hydraulic erosion (numpy/scipy) → soften ridges, carve valleys
|
||||
3. Climate pass → moisture/temperature from latitude + elevation
|
||||
4. Terrain classification → biome zones
|
||||
5. Hydrology → flow accumulation → river network
|
||||
6. Settlement placement → cities along rivers + fertile plains
|
||||
7. Road network → minimum spanning connections between major cities
|
||||
8. Annotated render → PNG export
|
||||
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 [--output path] [--seed N] [--fast]
|
||||
python3 generate_kallast.py [options]
|
||||
|
||||
Outputs:
|
||||
kallast_heightmap.png — annotated world map
|
||||
kallast_terrain.npy — raw terrain grid (numpy, for batch reuse)
|
||||
kallast_rivers.npy — river network mask
|
||||
kallast_settlements.json — city coordinates and names
|
||||
--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 json
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
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"
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Planet parameters (from wiki/systems.db: GJ144d Kallast)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
PLANET = {
|
||||
"name": "Kallast",
|
||||
"system": "Ran (GJ 144)",
|
||||
"body_id": "GJ144d",
|
||||
"gravity": 0.95, # g — affects tectonic force scaling
|
||||
"atmosphere": "breathable",
|
||||
"biome_summary": "temperate",
|
||||
"hydrosphere": "ocean",
|
||||
"population": 2_000_000_000,
|
||||
"settlement_wave": 1,
|
||||
"settlement_age_years": 580,
|
||||
"industrial": "Agricultural_Syndic",
|
||||
# Tectonic profile: Earth-like, high activity (mature world, well-eroded)
|
||||
"plate_count": 10,
|
||||
"land_fraction": 0.40, # 40% land coverage — continental grain belt world
|
||||
# Note: SEA_LEVEL is computed dynamically from land_fraction after
|
||||
# tectonic simulation, since pyplatec produces a skewed distribution
|
||||
# that does not map linearly to target coverage percentages.
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Grid settings
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
GRID_W = 1024
|
||||
GRID_H = 512
|
||||
OUTPUT_W = 4096
|
||||
OUTPUT_H = 2048
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Color palette (Settled Reach visual grammar: muted, earthy, legible)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
PALETTE = {
|
||||
"ocean_deep": (18, 32, 58),
|
||||
"ocean_mid": (28, 52, 90),
|
||||
"ocean_shallow": (42, 80, 110),
|
||||
"coast": (80, 105, 75),
|
||||
"lowland": (95, 115, 65),
|
||||
"plains": (130, 145, 80),
|
||||
"grassland": (165, 155, 75), # temperate plains / savanna (amber tone)
|
||||
"hills": (120, 110, 80),
|
||||
"forest": (60, 90, 55),
|
||||
"highland": (110, 100, 90),
|
||||
"mountain": (140, 130, 120),
|
||||
"peak": (195, 190, 185),
|
||||
"snow": (230, 228, 225),
|
||||
# Annotation colors
|
||||
"river": (80, 140, 200),
|
||||
"road_major": (180, 155, 90),
|
||||
"road_minor": (160, 140, 85),
|
||||
"city_major": (220, 60, 50),
|
||||
"city_minor": (200, 110, 60),
|
||||
"city_label": (240, 235, 220),
|
||||
"freight_elev": (200, 180, 100), # freight elevator pads
|
||||
"grid_line": (255, 255, 255),
|
||||
"title_bg": (15, 18, 25),
|
||||
"title_text": (200, 208, 224), # insert chrome: #c8d0e0
|
||||
}
|
||||
|
||||
|
||||
def step(msg: str):
|
||||
print(f" [{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1. Tectonic simulation
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_tectonics(seed: int, fast: bool = False) -> np.ndarray:
|
||||
"""Run pyplatec tectonic simulation. Returns normalised float32 grid."""
|
||||
step("Running tectonic simulation (pyplatec)…")
|
||||
import platec
|
||||
|
||||
sim_steps = 50 if fast else 200
|
||||
|
||||
p = platec.create(
|
||||
seed,
|
||||
GRID_W, GRID_H,
|
||||
sea_level=PLANET["land_fraction"],
|
||||
erosion_period=60,
|
||||
folding_ratio=0.02,
|
||||
aggr_overlap_abs=1_000_000,
|
||||
aggr_overlap_rel=0.33,
|
||||
cycle_count=2,
|
||||
num_plates=PLANET["plate_count"],
|
||||
)
|
||||
|
||||
for _ in range(sim_steps):
|
||||
platec.step(p)
|
||||
|
||||
hmap_raw = platec.get_heightmap(p)
|
||||
platec.destroy(p)
|
||||
|
||||
arr = np.array(hmap_raw, dtype=np.float32).reshape(GRID_H, GRID_W)
|
||||
# Normalise to [0, 1]
|
||||
lo, hi = arr.min(), arr.max()
|
||||
arr = (arr - lo) / (hi - lo + 1e-9)
|
||||
step(f"Tectonic done. Elevation range: [{lo:.1f}, {hi:.1f}]")
|
||||
return arr
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2. Hydraulic erosion (simplified — scipy gaussian smoothing on steep slopes)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def erode(terrain: np.ndarray, passes: int = 3) -> np.ndarray:
|
||||
"""Simplified erosion: smooth with slope-weighted kernel."""
|
||||
step(f"Applying erosion ({passes} passes)…")
|
||||
from scipy.ndimage import gaussian_filter, uniform_filter
|
||||
|
||||
result = terrain.copy()
|
||||
for i in range(passes):
|
||||
# Identify steep slopes
|
||||
gy, gx = np.gradient(result)
|
||||
slope = np.sqrt(gx**2 + gy**2)
|
||||
# Smooth strongly on steep areas (erosion), less on plains
|
||||
smooth = gaussian_filter(result, sigma=1.5)
|
||||
weight = np.clip(slope * 8, 0, 1)
|
||||
result = result * (1 - weight * 0.4) + smooth * (weight * 0.4)
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3. Terrain classification
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SEA_LEVEL = None # set dynamically after tectonic simulation
|
||||
|
||||
def compute_sea_level(terrain: np.ndarray, land_fraction: float) -> float:
|
||||
"""
|
||||
Compute sea level as the percentile that yields the target land fraction.
|
||||
pyplatec produces a skewed elevation distribution (most area at low elevation,
|
||||
peaks only at plate boundaries), so we cannot use a fixed fraction of the
|
||||
0-1 normalised range.
|
||||
"""
|
||||
ocean_fraction = 1.0 - land_fraction
|
||||
sl = float(np.percentile(terrain, ocean_fraction * 100))
|
||||
step(f"Sea level computed: {sl:.4f} (target land={land_fraction*100:.0f}%, "
|
||||
f"actual≈{(terrain >= sl).sum() / terrain.size * 100:.1f}%)")
|
||||
return sl
|
||||
|
||||
|
||||
def classify(terrain: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Returns integer class array relative to the dynamic sea level.
|
||||
Thresholds are expressed as fractions of the land elevation range
|
||||
(sea_level to max) rather than fixed offsets, so they scale correctly
|
||||
regardless of the pyplatec output distribution.
|
||||
|
||||
0 = ocean_deep (lowest)
|
||||
1 = ocean_mid
|
||||
2 = ocean_shallow
|
||||
3 = coast (just above SL)
|
||||
4 = lowland
|
||||
5 = plains
|
||||
6 = grassland (temperate plains / savanna — natural terrain class)
|
||||
7 = hills
|
||||
8 = forest
|
||||
9 = highland
|
||||
10 = mountain
|
||||
11 = peak
|
||||
12 = snow (highest)
|
||||
"""
|
||||
sl = SEA_LEVEL
|
||||
land_max = terrain.max()
|
||||
land_range = max(land_max - sl, 1e-6)
|
||||
|
||||
c = np.zeros_like(terrain, dtype=np.int8)
|
||||
# Ocean bands (below sea level)
|
||||
ocean_range = max(sl - terrain.min(), 1e-6)
|
||||
c[terrain >= terrain.min()] = 0 # ocean_deep (baseline)
|
||||
c[terrain >= sl - ocean_range * 0.5] = 1 # ocean_mid
|
||||
c[terrain >= sl - ocean_range * 0.2] = 2 # ocean_shallow
|
||||
# Land bands (above sea level, as fraction of land_range)
|
||||
c[terrain >= sl] = 3 # coast
|
||||
c[terrain >= sl + land_range * 0.05] = 4 # lowland
|
||||
c[terrain >= sl + land_range * 0.15] = 5 # plains
|
||||
c[terrain >= sl + land_range * 0.28] = 6 # grassland
|
||||
c[terrain >= sl + land_range * 0.42] = 7 # hills
|
||||
c[terrain >= sl + land_range * 0.53] = 8 # forest
|
||||
c[terrain >= sl + land_range * 0.63] = 9 # highland
|
||||
c[terrain >= sl + land_range * 0.74] = 10 # mountain
|
||||
c[terrain >= sl + land_range * 0.85] = 11 # peak
|
||||
c[terrain >= sl + land_range * 0.93] = 12 # snow
|
||||
return c
|
||||
|
||||
|
||||
CLASS_COLORS = [
|
||||
PALETTE["ocean_deep"],
|
||||
PALETTE["ocean_mid"],
|
||||
PALETTE["ocean_shallow"],
|
||||
PALETTE["coast"],
|
||||
PALETTE["lowland"],
|
||||
PALETTE["plains"],
|
||||
PALETTE["grassland"],
|
||||
PALETTE["hills"],
|
||||
PALETTE["forest"],
|
||||
PALETTE["highland"],
|
||||
PALETTE["mountain"],
|
||||
PALETTE["peak"],
|
||||
PALETTE["snow"],
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 4. Hydrology — flow accumulation → river network
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def compute_rivers(terrain: np.ndarray, threshold: int = 30) -> np.ndarray:
|
||||
"""
|
||||
Simple D8 flow accumulation. Returns boolean mask of river cells.
|
||||
Not physically accurate but produces plausible branching networks.
|
||||
"""
|
||||
step("Computing river network…")
|
||||
H, W = terrain.shape
|
||||
# D8 direction offsets
|
||||
dirs = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
|
||||
|
||||
# For each land cell, find steepest descent
|
||||
flow_acc = np.zeros((H, W), dtype=np.int32)
|
||||
land = terrain >= SEA_LEVEL
|
||||
|
||||
# Simplified: accumulate flow by draining from high to low
|
||||
# Sort cells by elevation descending
|
||||
ys, xs = np.where(land)
|
||||
order = np.argsort(terrain[ys, xs])[::-1]
|
||||
ys_sorted = ys[order]
|
||||
xs_sorted = xs[order]
|
||||
|
||||
for y, x in zip(ys_sorted, xs_sorted):
|
||||
flow_acc[y, x] += 1
|
||||
# Find steepest downslope neighbour
|
||||
best_drop = 0
|
||||
best_ny, best_nx = -1, -1
|
||||
for dy, dx in dirs:
|
||||
ny, nx = y + dy, x + dx
|
||||
if 0 <= ny < H and 0 <= nx < W:
|
||||
drop = terrain[y, x] - terrain[ny, nx]
|
||||
if drop > best_drop:
|
||||
best_drop = drop
|
||||
best_ny, best_nx = ny, nx
|
||||
if best_ny >= 0:
|
||||
flow_acc[best_ny, best_nx] += flow_acc[y, x]
|
||||
|
||||
rivers = (flow_acc > threshold) & land
|
||||
step(f"River network: {rivers.sum()} cells above threshold {threshold}")
|
||||
return rivers
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5. Settlement placement
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
KALLAST_CITIES = [
|
||||
# Name, (relative grid position), tier (1=capital, 2=regional, 3=node)
|
||||
# Placed on fertile plains near river confluences.
|
||||
# Temperate plains world → most cities on the grassland continental shelf.
|
||||
("Kallast Prime", (0.45, 0.48), 1), # capital, central continent
|
||||
("Ardenvall", (0.30, 0.40), 2), # western grain province
|
||||
("Thessmark", (0.60, 0.52), 2), # eastern province
|
||||
("Coldwater", (0.25, 0.62), 2), # southern coast, fishing + export
|
||||
("Brightfield", (0.50, 0.36), 2), # northern plains
|
||||
("Vorn's Crossing", (0.38, 0.55), 3), # river crossing, freight node
|
||||
("Saltmere", (0.68, 0.42), 3), # coast + processing node
|
||||
("Kaspel", (0.20, 0.50), 3), # western interior node
|
||||
("Drenmark", (0.72, 0.58), 3), # southeastern node
|
||||
("New Farrow", (0.55, 0.64), 3), # southern freight hub
|
||||
("Ossenfield", (0.42, 0.30), 3), # northern highland approach
|
||||
("Tyne Station", (0.33, 0.45), 3), # freight elevator ground station
|
||||
]
|
||||
|
||||
# Freight elevator locations (visible from orbit per wiki)
|
||||
FREIGHT_ELEVATORS = [
|
||||
("Kallast Anchor", (0.45, 0.46)),
|
||||
("Ardenvall Lift", (0.29, 0.38)),
|
||||
("Thessmark Riser", (0.61, 0.50)),
|
||||
]
|
||||
|
||||
|
||||
def place_settlements(terrain: np.ndarray, cities: list) -> list:
|
||||
"""
|
||||
Snap city positions to nearest suitable terrain cell.
|
||||
Suitable = plains or grassland class, preferably near river.
|
||||
Returns list of (name, grid_y, grid_x, tier) tuples.
|
||||
"""
|
||||
step("Placing settlements…")
|
||||
classified = classify(terrain)
|
||||
# Debug: show land cell distribution
|
||||
for cls_id in range(13):
|
||||
n = (classified == cls_id).sum()
|
||||
if n > 0:
|
||||
step(f" class {cls_id}: {n} cells")
|
||||
H, W = terrain.shape
|
||||
placed = []
|
||||
|
||||
for name, (rx, ry), tier in cities:
|
||||
cx = int(rx * W)
|
||||
cy = int(ry * H)
|
||||
# Search in expanding radius for valid terrain (up to 60 cells)
|
||||
best_y, best_x = cy, cx
|
||||
best_score = -1
|
||||
for radius in [20, 40, 60, 80]:
|
||||
for dy in range(-radius, radius + 1):
|
||||
for dx in range(-radius, radius + 1):
|
||||
ty, tx = cy + dy, cx + dx
|
||||
if 0 <= ty < H and 0 <= tx < W:
|
||||
c = classified[ty, tx]
|
||||
# Score: higher for plains/grassland, acceptable for coast/lowland
|
||||
score = 0
|
||||
if c in (5, 6): # plains/grassland (ideal settlement terrain)
|
||||
score = 20 - (abs(dy) + abs(dx)) * 0.2
|
||||
elif c == 4: # lowland
|
||||
score = 12 - (abs(dy) + abs(dx)) * 0.2
|
||||
elif c == 3: # coast (ports, export hubs)
|
||||
score = 8 - (abs(dy) + abs(dx)) * 0.2
|
||||
elif c == 7: # hills (defensible / highland cities)
|
||||
score = 5 - (abs(dy) + abs(dx)) * 0.2
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_y, best_x = ty, tx
|
||||
if best_score > 0:
|
||||
break # found valid terrain at this radius, stop expanding
|
||||
placed.append((name, best_y, best_x, tier))
|
||||
step(f" {name} → ({best_x}, {best_y}) terrain={classified[best_y, best_x]}")
|
||||
|
||||
return placed
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 6. Road network (simple greedy connections)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_roads(settlements: list) -> list:
|
||||
"""
|
||||
Connect tier-1 and tier-2 cities with major roads.
|
||||
Connect tier-3 nodes to nearest tier-1 or tier-2.
|
||||
Returns list of (y1, x1, y2, x2, road_type) tuples.
|
||||
"""
|
||||
roads = []
|
||||
tier12 = [(n, y, x) for (n, y, x, t) in settlements if t <= 2]
|
||||
tier3 = [(n, y, x) for (n, y, x, t) in settlements if t == 3]
|
||||
|
||||
# Connect all tier-1/2 cities in order (simple chain + cross-links)
|
||||
for i in range(len(tier12) - 1):
|
||||
_, y1, x1 = tier12[i]
|
||||
_, y2, x2 = tier12[i + 1]
|
||||
roads.append((y1, x1, y2, x2, "major"))
|
||||
# Capital spurs to each tier-2
|
||||
cap = tier12[0]
|
||||
for city in tier12[1:]:
|
||||
roads.append((cap[1], cap[2], city[1], city[2], "major"))
|
||||
|
||||
# Tier-3 nodes to nearest tier-1/2
|
||||
for (n3, y3, x3) in tier3:
|
||||
best_d = 1e9
|
||||
best = tier12[0]
|
||||
for city in tier12:
|
||||
d = (city[1]-y3)**2 + (city[2]-x3)**2
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = city
|
||||
roads.append((y3, x3, best[1], best[2], "minor"))
|
||||
|
||||
return roads
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 7. Render
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def render(
|
||||
terrain: np.ndarray,
|
||||
rivers: np.ndarray,
|
||||
output_path: str,
|
||||
):
|
||||
"""
|
||||
Renders a geographic heightmap: terrain colors, rivers, coastlines,
|
||||
biome zones, lat/lon grid, title panel.
|
||||
Human layer (settlements, roads, irrigation) is stored in the JSON sidecar only.
|
||||
|
||||
River rendering order matters: dilate at source resolution to preserve flow
|
||||
topology, then paint AFTER upscaling via NEAREST neighbor to avoid LANCZOS
|
||||
blurring the river network into invisibility.
|
||||
"""
|
||||
step("Rendering geographic heightmap…")
|
||||
from scipy.ndimage import binary_dilation
|
||||
from PIL import ImageFont
|
||||
H, W = terrain.shape
|
||||
classified = classify(terrain)
|
||||
|
||||
# UI scale relative to 1024×512 reference resolution
|
||||
UI_SCALE = OUTPUT_W / 1024
|
||||
|
||||
# Base terrain color layer
|
||||
rgb = np.zeros((H, W, 3), dtype=np.uint8)
|
||||
for cls_id, color in enumerate(CLASS_COLORS):
|
||||
mask = classified == cls_id
|
||||
rgb[mask] = color
|
||||
|
||||
# Slight elevation shading on land (ambient occlusion approximation)
|
||||
land_mask = terrain >= SEA_LEVEL
|
||||
elev_norm = np.clip((terrain - SEA_LEVEL) / (1.0 - SEA_LEVEL + 1e-9), 0, 1)
|
||||
shade = (0.85 + 0.15 * elev_norm)[..., np.newaxis]
|
||||
rgb = np.where(land_mask[..., np.newaxis], (rgb * shade).astype(np.uint8), rgb)
|
||||
|
||||
# Dilate river mask at source resolution (preserves branching topology)
|
||||
rivers_drawn = binary_dilation(rivers, iterations=2)
|
||||
step(f"River cells after dilation: {rivers_drawn.sum()}")
|
||||
|
||||
# Upscale terrain with LANCZOS (smooth gradients) — WITHOUT rivers painted in yet
|
||||
img = Image.fromarray(rgb).resize((OUTPUT_W, OUTPUT_H), Image.LANCZOS)
|
||||
|
||||
# Paint rivers AFTER upscale using NEAREST neighbor — no blur, sharp edges
|
||||
rivers_up = Image.fromarray((rivers_drawn.astype(np.uint8) * 255)).resize(
|
||||
(OUTPUT_W, OUTPUT_H), Image.NEAREST
|
||||
)
|
||||
img_arr = np.array(img)
|
||||
img_arr[np.array(rivers_up) > 0] = PALETTE["river"]
|
||||
img = Image.fromarray(img_arr)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Lat/lon grid lines (every 30°)
|
||||
grid_w = max(1, int(UI_SCALE))
|
||||
for lat_pct in [1/6, 2/6, 3/6, 4/6, 5/6]:
|
||||
y = int(lat_pct * OUTPUT_H)
|
||||
draw.line([(0, y), (OUTPUT_W, y)], fill=(80, 90, 100), width=grid_w)
|
||||
for lon_pct in [1/6, 2/6, 3/6, 4/6, 5/6]:
|
||||
x = int(lon_pct * OUTPUT_W)
|
||||
draw.line([(x, 0), (x, OUTPUT_H)], fill=(80, 90, 100), width=grid_w)
|
||||
|
||||
# Fonts — load_default(size=N) requires Pillow 10+
|
||||
try:
|
||||
font_title = ImageFont.load_default(size=int(14 * UI_SCALE))
|
||||
font_sub = ImageFont.load_default(size=int(12 * UI_SCALE))
|
||||
font_small = ImageFont.load_default(size=int(11 * UI_SCALE))
|
||||
except TypeError:
|
||||
font_title = font_sub = font_small = ImageFont.load_default()
|
||||
|
||||
# Title / metadata panel (insert chrome aesthetic)
|
||||
panel_h = int(56 * UI_SCALE)
|
||||
panel = Image.new('RGBA', (OUTPUT_W, panel_h), (15, 18, 25, 200))
|
||||
img = img.convert('RGBA')
|
||||
img.paste(panel, (0, 0), panel)
|
||||
img = img.convert('RGB')
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
px = int(12 * UI_SCALE)
|
||||
py = int(8 * UI_SCALE)
|
||||
lh = int(18 * UI_SCALE)
|
||||
draw.text((px, py), f"KALLAST · GJ 144d · Ran System", fill=PALETTE["title_text"], font=font_title)
|
||||
draw.text((px, py + lh), f"temperate / ocean / breathable / 0.95g / {PLANET['population']//1_000_000_000:.1f}B pop / Wave {PLANET['settlement_wave']} / {PLANET['settlement_age_years']}y settled", fill=(130, 140, 160), font=font_sub)
|
||||
draw.text((px, py + lh*2), "HEIGHTMAP SPIKE v0.1 — Settled Reach Phase 1", fill=(80, 90, 110), font=font_small)
|
||||
|
||||
# Legend (bottom strip) — natural geographic features only
|
||||
sw = int(16 * UI_SCALE)
|
||||
legend_y = OUTPUT_H - int(40 * UI_SCALE)
|
||||
legend_items = [
|
||||
("ocean", PALETTE["ocean_deep"]),
|
||||
("coast", PALETTE["coast"]),
|
||||
("plains", PALETTE["plains"]),
|
||||
("grassland", PALETTE["grassland"]),
|
||||
("mountain", PALETTE["mountain"]),
|
||||
("river", PALETTE["river"]),
|
||||
]
|
||||
lx = px
|
||||
for label, color in legend_items:
|
||||
draw.rectangle([(lx, legend_y + int(4*UI_SCALE)), (lx+sw, legend_y + sw + int(4*UI_SCALE))], fill=color)
|
||||
draw.text((lx + sw + int(4*UI_SCALE), legend_y + int(3*UI_SCALE)), label, fill=(180, 185, 200), font=font_small)
|
||||
lx += int(110 * UI_SCALE)
|
||||
|
||||
img.save(output_path, format="PNG", optimize=False)
|
||||
step(f"Saved: {output_path}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate Kallast heightmap spike")
|
||||
parser.add_argument("--output", default="spikes/heightmap-pipeline/kallast_heightmap.png")
|
||||
parser.add_argument("--terrain-out", default="spikes/heightmap-pipeline/kallast_terrain.npy")
|
||||
parser.add_argument("--seed", type=int, default=144042) # GJ144d seed
|
||||
parser.add_argument("--fast", action="store_true", help="Fewer tectonic steps (quicker, less detail)")
|
||||
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()
|
||||
|
||||
print(f"\nKallast Heightmap Spike")
|
||||
print(f" Planet: {PLANET['name']} ({PLANET['body_id']})")
|
||||
print(f" Grid: {GRID_W}×{GRID_H}")
|
||||
print(f" Seed: {args.seed}")
|
||||
print(f" Mode: {'fast' if args.fast else 'standard'}\n")
|
||||
if args.small:
|
||||
args.out_w = 1024
|
||||
args.out_h = 512
|
||||
args.globe_size = 512
|
||||
|
||||
t0 = time.time()
|
||||
t_start = time.time()
|
||||
|
||||
terrain = run_tectonics(args.seed, fast=args.fast)
|
||||
terrain = erode(terrain, passes=4)
|
||||
# ── 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.")
|
||||
|
||||
# Set dynamic sea level based on target land coverage
|
||||
global SEA_LEVEL
|
||||
SEA_LEVEL = compute_sea_level(terrain, PLANET["land_fraction"])
|
||||
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.")
|
||||
|
||||
rivers = compute_rivers(terrain, threshold=60) # calibrated for 1024×512 grid; flat world needs lower relative threshold
|
||||
pc = body_def.get("planet_class", "?")
|
||||
seed = body_def.get("seed", 0)
|
||||
print(f" {body_def['id']} class={pc} seed={seed}")
|
||||
|
||||
settlements = place_settlements(terrain, KALLAST_CITIES)
|
||||
# ── 2. Simulate terrain ──────────────────────────────────────────────────
|
||||
print("Simulating terrain…")
|
||||
t0 = time.time()
|
||||
terrain = simulate(body_def)
|
||||
sim_t = time.time() - t0
|
||||
|
||||
render(terrain, rivers, args.output)
|
||||
if not terrain:
|
||||
raise SystemExit(f"{args.body_id} is a gas giant — no terrain to render.")
|
||||
|
||||
np.save(args.terrain_out, terrain)
|
||||
step(f"Terrain grid saved: {args.terrain_out}")
|
||||
sea = terrain["sea_level"]
|
||||
nriv = len(terrain["rivers"])
|
||||
print(f" Done in {sim_t:.1f}s "
|
||||
f"sea_level={sea:.3f} rivers={nriv}")
|
||||
|
||||
# Write settlements JSON
|
||||
sj_path = args.output.replace(".png", "_settlements.json")
|
||||
with open(sj_path, "w") as f:
|
||||
json.dump([
|
||||
{"name": n, "grid_y": int(gy), "grid_x": int(gx), "tier": t}
|
||||
for (n, gy, gx, t) in settlements
|
||||
], f, indent=2)
|
||||
step(f"Settlement data saved: {sj_path}")
|
||||
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}")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f"\nDone in {elapsed:.1f}s")
|
||||
print(f"Output: {args.output}")
|
||||
# ── 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__":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 MiB |
@@ -1,74 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "Kallast Prime",
|
||||
"grid_y": 297,
|
||||
"grid_x": 460,
|
||||
"tier": 1
|
||||
},
|
||||
{
|
||||
"name": "Ardenvall",
|
||||
"grid_y": 182,
|
||||
"grid_x": 246,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Thessmark",
|
||||
"grid_y": 266,
|
||||
"grid_x": 613,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Coldwater",
|
||||
"grid_y": 316,
|
||||
"grid_x": 272,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Brightfield",
|
||||
"grid_y": 172,
|
||||
"grid_x": 512,
|
||||
"tier": 2
|
||||
},
|
||||
{
|
||||
"name": "Vorn's Crossing",
|
||||
"grid_y": 293,
|
||||
"grid_x": 399,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Saltmere",
|
||||
"grid_y": 195,
|
||||
"grid_x": 707,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Kaspel",
|
||||
"grid_y": 214,
|
||||
"grid_x": 214,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Drenmark",
|
||||
"grid_y": 296,
|
||||
"grid_x": 737,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "New Farrow",
|
||||
"grid_y": 327,
|
||||
"grid_x": 563,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Ossenfield",
|
||||
"grid_y": 155,
|
||||
"grid_x": 433,
|
||||
"tier": 3
|
||||
},
|
||||
{
|
||||
"name": "Tyne Station",
|
||||
"grid_y": 300,
|
||||
"grid_x": 322,
|
||||
"tier": 3
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,458 @@
|
||||
# Settled Reach — Planet Generator Handover
|
||||
**Date:** 2026-04-06
|
||||
**Status:** Spike complete — heightmap pipeline validated, globe renderer disconnected pending integration
|
||||
**For:** Claude Code spike import
|
||||
|
||||
---
|
||||
|
||||
## What Was Built
|
||||
|
||||
A procedural planet generator that reads system wiki markdown files and produces annotated equirectangular heightmap PNGs (4096×2048) as the primary output. A globe renderer exists as a separate module and will be integrated later.
|
||||
|
||||
### Four files
|
||||
|
||||
| File | Role | Status |
|
||||
|------|------|--------|
|
||||
| `body_definition_parser.py` | `index.md` → `body_def.json` | Complete |
|
||||
| `planet_simulation.py` | `body_def` → terrain grids | Complete |
|
||||
| `render_heightmap.py` | terrain grids → 4096×2048 PNG | Complete |
|
||||
| `planet_renderer.py` | terrain grids → globe PNG | Disconnected — integrate later |
|
||||
|
||||
### Pipeline
|
||||
|
||||
```
|
||||
index.md
|
||||
└─► body_definition_parser.py → body_def.json (one per body)
|
||||
└─► planet_simulation.py → terrain dict (float32 grids)
|
||||
└─► render_heightmap.py → heightmap.png (PRIMARY OUTPUT)
|
||||
└─► planet_renderer.py → globe.png (SECONDARY — disconnected)
|
||||
```
|
||||
|
||||
### Output hierarchy
|
||||
|
||||
- **Primary:** `{body_id}_heightmap.png` — 4096×2048 equirectangular cartographic map. Geographic only, no cultural data. Feeds tile generator.
|
||||
- **Secondary:** `{body_id}_globe.png` — sphere render from same terrain. Wiki mugshot. Not yet wired to simulation output.
|
||||
- **Sidecar (pending):** `{body_id}_geo_data.json` — serialised float grids + river polylines for tile generator. Not yet written.
|
||||
|
||||
---
|
||||
|
||||
## Input Format: `index.md`
|
||||
|
||||
System wiki pages in markdown. The parser reads the **Celestial Bodies** table and the **System Profile** section.
|
||||
|
||||
### System Profile (used for star type)
|
||||
|
||||
```markdown
|
||||
| **Star** | K2V · 0.38 ly |
|
||||
```
|
||||
|
||||
Extracts spectral type from the first letter: `K2V → K`.
|
||||
|
||||
### Celestial Bodies table
|
||||
|
||||
```markdown
|
||||
| Orbit | ID | Name | Type | Inhabited | Pop | Mass | Gravity | Year (d) | Day (h) | Atmo | Biome | Hydro | Economy | Settlement | Industrial |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 3 | `GJ0d` | Earth | planet | yes | 8.5B | terrestrial | 1.00g | 365 | 24.0 | breathable | temperate | ocean | manufacturing | urban_concentrated | — |
|
||||
| ↳ 3.1 | `GJ0d-1` | Luna | moon | yes | 350M | dwarf | 0.17g | 27 | 655.7 | none | barren | none | manufacturing | domed | — |
|
||||
```
|
||||
|
||||
**Used fields:**
|
||||
|
||||
| Column | Used for | Notes |
|
||||
|--------|----------|-------|
|
||||
| `ID` | `body_id`, `seed` | Seed is hash of ID string — deterministic |
|
||||
| `Name` | display name | Optional, `—` is fine |
|
||||
| `Type` | `body_type` | `planet`, `moon`, `gas_giant` |
|
||||
| `Mass` | `body_scale`, `oblateness` | `dwarf` → moon scale |
|
||||
| `Gravity` | `physical.gravity_g` | Strip `g` suffix |
|
||||
| `Year (d)` | `orbit.distance_au` | Derived via Kepler + star luminosity |
|
||||
| `Atmo` | `physical.atmosphere` | See mapping below |
|
||||
| `Biome` | `planet_class` | Direct mapping |
|
||||
| `Hydro` | `terrain.land_fraction` | See mapping below |
|
||||
| `↳` prefix | `is_moon_row` | Determines parent-is-giant for tidal heating |
|
||||
|
||||
**Not used:** `Day (h)`, `Economy`, `Settlement`, `Industrial`, `Inhabited`, `Pop`. These are cultural data — the renderer is geographic only.
|
||||
|
||||
### Field mappings
|
||||
|
||||
**Atmosphere → density:**
|
||||
```
|
||||
none → none
|
||||
thin → thin
|
||||
breathable → standard
|
||||
dense → thick
|
||||
toxic → thick (Venus-style reducing atmosphere)
|
||||
```
|
||||
|
||||
**Biome → planet_class:**
|
||||
```
|
||||
temperate → temperate
|
||||
arid → arid
|
||||
frozen → frozen
|
||||
volcanic → volcanic
|
||||
barren → barren
|
||||
forest → forest
|
||||
oceanic → oceanic
|
||||
```
|
||||
|
||||
**Hydrosphere → land_fraction range (randomised within):**
|
||||
```
|
||||
ocean → [0.28, 0.50]
|
||||
liquid_water → [0.35, 0.65]
|
||||
rivers → [0.50, 0.75] (Titan-style)
|
||||
ice → [0.70, 0.90]
|
||||
subsurface → [0.90, 0.99]
|
||||
none → [0.97, 1.00]
|
||||
```
|
||||
|
||||
### Override file format
|
||||
|
||||
Per-body overrides in a separate JSON file. Any explicit value wins over derived/randomised values. Use `"rand"` as sentinel for normal behaviour.
|
||||
|
||||
```json
|
||||
{
|
||||
"GJ0d": {
|
||||
"orbit": { "axial_tilt_deg": 23.4 },
|
||||
"terrain": { "land_fraction": 0.29, "polar_ice_lat": 0.78 }
|
||||
},
|
||||
"GJ0g": {
|
||||
"rings": {
|
||||
"enabled": true,
|
||||
"inner_radius_factor": 1.12,
|
||||
"outer_radius_factor": 2.65,
|
||||
"opacity_base": 0.62,
|
||||
"ring_color": [0.88, 0.78, 0.55]
|
||||
},
|
||||
"gas_giant": { "band_palette": "saturnian" }
|
||||
},
|
||||
"GJ0f": { "rings": false },
|
||||
"GJ0c": { "planet_class": "volcanic" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Body Definition Schema (`body_def.json`)
|
||||
|
||||
Output of `body_definition_parser.py`. Input to `planet_simulation.py` and `planet_renderer.py`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "GJ144d",
|
||||
"name": "Kallast",
|
||||
"body_type": "planet",
|
||||
"planet_class": "temperate",
|
||||
"body_scale": "planet",
|
||||
"seed": 144042,
|
||||
|
||||
"star": {
|
||||
"type": "K",
|
||||
"luminosity_solar": 0.40,
|
||||
"color_temp_K": 4500
|
||||
},
|
||||
|
||||
"orbit": {
|
||||
"distance_au": 0.38,
|
||||
"period_days": 312,
|
||||
"axial_tilt_deg": 18.0
|
||||
},
|
||||
|
||||
"physical": {
|
||||
"gravity_g": 0.95,
|
||||
"oblateness": 0.003,
|
||||
"atmosphere": "standard",
|
||||
"atmosphere_color": [0.45, 0.65, 1.0]
|
||||
},
|
||||
|
||||
"terrain": {
|
||||
"land_fraction": 0.40,
|
||||
"polar_ice_lat": 0.78,
|
||||
"tectonics": "active",
|
||||
"max_elevation_km": 10.0
|
||||
},
|
||||
|
||||
"environment": {
|
||||
"geothermal_flux": "low",
|
||||
"uv_index": "low",
|
||||
"substrate": "silicate",
|
||||
"chemosynthetic": false,
|
||||
"hydrosphere": "ocean"
|
||||
},
|
||||
|
||||
"clouds": {
|
||||
"enabled": true,
|
||||
"coverage_base": 0.45
|
||||
},
|
||||
|
||||
"render": {
|
||||
"globe_light_angle_deg": 125,
|
||||
"specular_ocean": true,
|
||||
"night_side_ambient": 0.025
|
||||
},
|
||||
|
||||
"gas_giant": {
|
||||
"band_palette": "jovian",
|
||||
"storm_count": 3,
|
||||
"storm_max_size": 0.10
|
||||
},
|
||||
|
||||
"rings": {
|
||||
"enabled": true,
|
||||
"inner_radius_factor": 1.12,
|
||||
"outer_radius_factor": 2.65,
|
||||
"opacity_base": 0.62,
|
||||
"ring_color": [0.88, 0.78, 0.55]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`gas_giant` and `rings` blocks only present when applicable.
|
||||
|
||||
---
|
||||
|
||||
## Terrain Dict Schema
|
||||
|
||||
Output of `planet_simulation.simulate()`. All grids are `(GRID_H=256, GRID_W=512)`.
|
||||
|
||||
```python
|
||||
{
|
||||
# Float grids — float32 [0,1] unless noted
|
||||
"elevation": np.ndarray (256, 512), # [0,1] normalised
|
||||
"temperature": np.ndarray (256, 512), # [0,1] normalised FOR RENDERER
|
||||
# biome lookup uses absolute K internally
|
||||
"moisture": np.ndarray (256, 512), # [0,1]
|
||||
"hillshade": np.ndarray (256, 512), # [0,1]
|
||||
|
||||
# Classification grids
|
||||
"biome": np.ndarray (256, 512), # int8, class IDs (see table below)
|
||||
"surface_water": np.ndarray (256, 512), # bool, True = ocean/lake
|
||||
|
||||
# River data
|
||||
"river_grid": np.ndarray (256, 512), # bool, True = river cell
|
||||
"rivers": list of [(row, col), ...], # polylines in grid coords
|
||||
|
||||
# Scalar
|
||||
"sea_level": float, # elevation threshold [0,1]
|
||||
|
||||
# Audit trail
|
||||
"temperature_clamped": bool, # True if T_raw fell outside CLASS_T_BAND
|
||||
"temperature_raw_K": float, # physical equilibrium temp before clamping
|
||||
"temperature_band_K": [float, float], # [lo, hi] band applied
|
||||
|
||||
# Grid metadata
|
||||
"_grid_w": 512,
|
||||
"_grid_h": 256,
|
||||
}
|
||||
```
|
||||
|
||||
**Grid coordinate convention:**
|
||||
- Row 0 = north pole, row 255 = south pole
|
||||
- Col 0 = 180°W, col 511 = 180°E
|
||||
- Longitude wraps: col 0 and col 511 are adjacent
|
||||
- Equirectangular — maps directly to heightmap PNG with same aspect ratio
|
||||
|
||||
---
|
||||
|
||||
## Biome Class Table
|
||||
|
||||
Temperature axis uses **absolute Kelvin**. This is intentional and important — see Design Decisions below.
|
||||
|
||||
### Base Whittaker classes
|
||||
|
||||
| ID | Name | Temp range (K) | Moisture [0,1] | Cartographic colour |
|
||||
|----|------|----------------|----------------|---------------------|
|
||||
| 0 | ocean deep | — | — | (80, 155, 190) |
|
||||
| 1 | ocean mid | — | — | (110, 185, 215) |
|
||||
| 2 | ocean shallow | — | — | (150, 210, 230) |
|
||||
| 3 | coast | — | — | (155, 185, 130) |
|
||||
| 4 | lowland | — | — | (120, 165, 100) |
|
||||
| 5 | tropical rainforest | 303–999 | 0.65–1.00 | (50, 140, 65) |
|
||||
| 6 | tropical seasonal | 303–999 | 0.35–0.65 | (90, 170, 75) |
|
||||
| 7 | savanna | 293–999 | 0.18–0.35 | (175, 210, 105) |
|
||||
| 8 | temperate grassland | 278–303 | 0.30–0.55 | (190, 210, 110) |
|
||||
| 9 | temperate deciduous | 273–308 | 0.30–1.00 | (70, 148, 70) |
|
||||
| 10 | temperate rainforest | 278–303 | 0.60–1.00 | (45, 125, 65) |
|
||||
| 11 | boreal / taiga | 253–278 | 0.15–1.00 | (28, 88, 55) |
|
||||
| 12 | shrubland | 278–303 | 0.10–0.30 | (168, 168, 95) |
|
||||
| 13 | temperate desert | 273–293 | 0.00–0.30 | (215, 200, 155) |
|
||||
| 14 | subtropical desert | 283–308 | 0.00–0.18 | (210, 165, 85) |
|
||||
| 15 | hot desert | 303–999 | 0.00–0.18 | (215, 138, 55) |
|
||||
| 16 | tundra | 233–263 | 0.00–1.00 | (198, 185, 145) |
|
||||
| 17 | ice / snow | 200–273 | 0.00–1.00 | (235, 238, 242) |
|
||||
| 18 | mountain rock | — | — | (148, 135, 120) |
|
||||
|
||||
### Modifier-applied classes
|
||||
|
||||
Applied after Whittaker lookup based on `environment` fields.
|
||||
|
||||
| ID | Name | Trigger condition | Cartographic colour |
|
||||
|----|------|-------------------|---------------------|
|
||||
| 19 | lava field | `volcanic` class + high elevation + geothermal extreme/high | (55, 32, 22) |
|
||||
| 20 | chemosynthetic mat | `chemosynthetic=True` + moderate temperature zone | (45, 88, 52) |
|
||||
| 21 | thermophilic field | geothermal extreme/high + high temperature + low elevation | (118, 72, 40) |
|
||||
| 22 | sulfuric scrub | substrate=sulfuric + mid elevation + warm | (148, 130, 58) |
|
||||
| 23 | cryptobiotic crust | uv_index extreme/high + thin/no atmosphere + exposed mid elevation | (130, 118, 100) |
|
||||
| 25 | ash field | `volcanic` class + mid elevation + geothermal extreme/high | (68, 58, 52) |
|
||||
| 26 | ice shelf | surface_water=True + temperature < 271K | (245, 246, 248) |
|
||||
|
||||
### Dual colour palette
|
||||
|
||||
Each biome has two colour sets in `render_heightmap.BIOME_PALETTE`:
|
||||
|
||||
```python
|
||||
BIOME_PALETTE = {
|
||||
5: {
|
||||
"cartographic": (50, 140, 65), # NG map style — readable, saturated
|
||||
"photographic": (12, 38, 18), # orbital appearance — dark, muted
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Select mode via `render_heightmap(bd, terrain, render_mode="cartographic")` or `"photographic"`. Per-planet alien colour overrides can patch `BIOME_PALETTE[class_id]["photographic"]` before rendering — e.g. `(120, 20, 80)` for purple alien forest.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Fiction wins over physics — descriptor-anchored temperature
|
||||
|
||||
The source data (`index.md`) was authored with "close enough" orbital parameters. A world described as `temperate` might be at 0.38 AU from a K-dwarf which physically gives 392K (119°C) — uninhabitable.
|
||||
|
||||
**Decision:** Compute raw equilibrium temperature from stellar physics (`T = 278.5 * L^0.25 / sqrt(a)`), then clamp to a band defined by `planet_class`. The fiction wins; physics sets the gradient within the band.
|
||||
|
||||
```python
|
||||
CLASS_T_BAND = {
|
||||
"temperate": (275, 305),
|
||||
"oceanic": (278, 300),
|
||||
"forest": (275, 308),
|
||||
"arid": (295, 340),
|
||||
"frozen": (210, 265),
|
||||
"volcanic": (290, 380),
|
||||
"barren": (180, 380),
|
||||
}
|
||||
```
|
||||
|
||||
A close-in temperate world sits at the warm end (305K). A far-out one sits at the cool end (275K). The audit trail is preserved in the terrain dict (`temperature_clamped`, `temperature_raw_K`, `temperature_band_K`).
|
||||
|
||||
### Whittaker table uses absolute Kelvin — not normalised temperature
|
||||
|
||||
Early implementation normalised world temperature to [0,1] before the Whittaker lookup. A frozen planet at -60°C to -10°C would normalise its warmest cells to 1.0 and classify them as tropical rainforest.
|
||||
|
||||
**Decision:** Biome lookup operates in absolute Kelvin throughout. Temperature is only normalised to [0,1] for the renderer display, *after* biome classification is complete.
|
||||
|
||||
### Ice shelf is a separate biome class (26)
|
||||
|
||||
Frozen ocean surface is distinct from land ice. Class 17 (land ice) has hillshade texture because it's terrain. Class 26 (ice shelf) renders near-pure white to appear flat — the ocean beneath has no surface relief.
|
||||
|
||||
**Trigger:** `surface_water=True AND temperature < 271K` (salinity-adjusted freezing point).
|
||||
|
||||
### `"rand"` sentinel for body definition parameters
|
||||
|
||||
Any field in `body_def.json` can be `"rand"` to indicate seeded randomisation within planet-class-appropriate bounds. An explicit value always overrides. This allows:
|
||||
- Minimal authoring for batch production (all 301 systems)
|
||||
- Full override control for narratively significant bodies (Saturn's rings, Earth's axial tilt)
|
||||
|
||||
**Pattern:** Sol override file sets `rings: true` on Saturn, `rings: false` on Jupiter, `axial_tilt_deg: 23.4` on Earth. Everything else randomises from the body ID hash seed — deterministic across runs.
|
||||
|
||||
### Seed is derived from body ID
|
||||
|
||||
`seed = MD5(body_id)[:4]` as integer. Same body ID always produces the same terrain. Seeds 1–9 are valid for testing but production bodies use their actual ID-derived seed.
|
||||
|
||||
### Continent separation uses multiplicative gating with S-curve secondary
|
||||
|
||||
The continent mask composites three independently-normalised noise layers:
|
||||
1. **Primary** (large, slow): main continental shapes
|
||||
2. **Secondary** (medium, independent seed): S-curve contrasted, then multiplied against primary — where secondary is low, it collapses the primary to ocean, creating channels and separation
|
||||
3. **Rift** (anisotropic, stretched V axis): thin elongated features — island chains, isthmuses
|
||||
|
||||
**Key:** Secondary uses S-curve contrast (`sigmoid(k*(x-0.5))`) not power curve. Power curves are seed-dependent in effect — some seeds produce mostly-low secondary fields that fail to separate continents. S-curve reliably pushes highs high and lows low regardless of field distribution.
|
||||
|
||||
```python
|
||||
separated = primary * (0.4 + secondary * 0.6)
|
||||
combined = separated * 0.82 + (rift - 0.5) * 0.18
|
||||
```
|
||||
|
||||
### Tectonic ridges use coordinate-space domain warping
|
||||
|
||||
Ridge positions (not just amplitude) are warped by two noise passes before Voronoi distance computation. This produces curved arcing mountain ranges. The critical detail: warp the *coordinates fed to Voronoi*, not the output — warping the output only bends the height variation, not the ridge line geometry.
|
||||
|
||||
### Longitude noise is seamless via 3D circle projection
|
||||
|
||||
FBM is sampled on `(cos(u·2π)·r, sin(u·2π)·r, v)` where `r = freq/(2π)`. The radius compensation ensures one full longitude revolution spans the same spatial distance as `freq` units on the latitude axis — preserving aspect ratio. Without radius compensation, features appear ~6× smaller in longitude than latitude.
|
||||
|
||||
V (latitude) is intentionally non-periodic — poles are endpoints, not a loop.
|
||||
|
||||
### Heightmap is geographic only — cultural data is a sidecar
|
||||
|
||||
The heightmap PNG renders: terrain classification, hillshade, rivers, coastlines, lat/lon grid, title panel.
|
||||
|
||||
The heightmap does NOT render: settlements, roads, freight elevators, irrigation channels.
|
||||
|
||||
**Rationale:** The geographic base layer is stable. Cultural overlays change as the simulation runs. Keeping them separate means the PNG can be regenerated without recomputing settlement placement, and the atlas app overlays them dynamically.
|
||||
|
||||
---
|
||||
|
||||
## Pending Items
|
||||
|
||||
Before batch production:
|
||||
|
||||
1. **`geo_data.json` exporter** — serialise terrain grids + river polylines for tile generator input. Alongside each heightmap PNG.
|
||||
|
||||
2. **Pipeline runner** — single script `index.md [--overrides overrides.json] → output_dir/`. Runs parser → simulate → heightmap for every renderable body in the system.
|
||||
|
||||
3. **Globe integration** — wire `planet_renderer.py` to use the photographic colour mode from `render_heightmap.BIOME_PALETTE` instead of its internal `BIOME_COLORS`. The renderer is disconnected from the simulation pipeline — it currently uses a procedural surface fallback when `terrain=None`. Integration pass: pass terrain dict through.
|
||||
|
||||
4. **Edge cases to watch:**
|
||||
- Fully frozen worlds (GJ406e): ice shelf distinction is invisible against land ice — correct behaviour, not a bug. Only meaningful on partially-frozen worlds where shelf meets open ocean.
|
||||
- Barren worlds with very low land fraction: ocean biome dominates, crater detail disappears. Consider raising land_fraction floor for barren class.
|
||||
- River routing on flat worlds: D8 downhill walk can get trapped in flat areas — `best_nr < 0` terminates early, producing short rivers. Calibrate moisture threshold per planet class.
|
||||
|
||||
5. **Quality gate:** Run Sol system end-to-end — it has every body type (temperate, barren, frozen moons, volcanic moon, gas giants with and without rings, dwarf moons) and will stress-test the full pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
numpy
|
||||
scipy
|
||||
Pillow
|
||||
```
|
||||
|
||||
No pyplatec — the spike's tectonic simulation was replaced with procedural FBM + Voronoi ridges which is faster, more controllable, and produces comparable results for the heightmap use case.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
from body_definition_parser import parse_system
|
||||
from planet_simulation import simulate
|
||||
from render_heightmap import render_heightmap
|
||||
|
||||
# Parse system — optionally with overrides
|
||||
defs = parse_system("sol/index.md", overrides={"GJ0g": {"rings": True}})
|
||||
|
||||
for bd in defs:
|
||||
terrain = simulate(bd)
|
||||
if not terrain:
|
||||
continue # gas giant — terrain dict is empty, globe renderer handles it
|
||||
img = render_heightmap(bd, terrain)
|
||||
img.save(f"{bd['id']}_heightmap.png")
|
||||
|
||||
# Fast iteration at 1024x512
|
||||
img = render_heightmap(bd, terrain, out_w=1024, out_h=512)
|
||||
|
||||
# Photographic mode for globe surface texture
|
||||
img = render_heightmap(bd, terrain, render_mode="photographic")
|
||||
```
|
||||
|
||||
### Timing (512×256 simulation grid)
|
||||
- Simulate: ~3s per terrestrial body
|
||||
- Render 1024×512: ~0.2s
|
||||
- Render 4096×2048: ~4s
|
||||
- Gas giant (no simulation): ~0.3s
|
||||
|
||||
Full batch of 301 systems × ~8 bodies ≈ 40 minutes single-threaded at full resolution. Parallelisable — no shared state between bodies.
|
||||
@@ -0,0 +1,766 @@
|
||||
"""
|
||||
body_definition_parser.py
|
||||
-------------------------
|
||||
Parses a system index.md file and produces one body_definition.json
|
||||
per renderable celestial body.
|
||||
|
||||
Input: index.md (system wiki page, bodies table + system profile)
|
||||
Output: {body_id}_def.json per planet / moon / gas_giant
|
||||
|
||||
Design principles:
|
||||
- "rand" sentinel means: derive from seed + planet class constraints
|
||||
- Explicit values in the bodies table or override dict always win
|
||||
- Every derivation is documented so the logic is auditable
|
||||
- No field is silently dropped — unknowns get a logged warning
|
||||
|
||||
Field resolution order (highest wins):
|
||||
1. override dict (per-body, hand-authored for special cases like Sol)
|
||||
2. direct read (field exists verbatim in bodies table)
|
||||
3. derived (computed from other fields — documented formula)
|
||||
4. inferred (implied by combination of fields)
|
||||
5. randomised (seeded, within planet-class constraints)
|
||||
|
||||
Usage:
|
||||
python3 body_definition_parser.py path/to/index.md [--out-dir ./defs]
|
||||
|
||||
# With overrides (e.g. Sol)
|
||||
python3 body_definition_parser.py sol/index.md --overrides sol_overrides.json
|
||||
|
||||
Override file format:
|
||||
{
|
||||
"GJ0g": { "rings": true, "ring_color": [0.88, 0.78, 0.55] },
|
||||
"GJ0f": { "rings": false },
|
||||
"GJ0d": { "orbit": { "axial_tilt_deg": 23.4 } }
|
||||
}
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format=" %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants / lookup tables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Spectral type → solar luminosity (approximate)
|
||||
STAR_LUMINOSITY = {
|
||||
"O": 100000.0, "B": 1000.0, "A": 10.0,
|
||||
"F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04,
|
||||
}
|
||||
|
||||
# Spectral type → colour temperature K (approximate midpoint)
|
||||
STAR_COLOUR_TEMP = {
|
||||
"O": 40000, "B": 20000, "A": 9000,
|
||||
"F": 7000, "G": 5800, "K": 4500, "M": 3200,
|
||||
}
|
||||
|
||||
# Star type → UV index category
|
||||
STAR_UV = {
|
||||
"O": "extreme", "B": "extreme", "A": "high",
|
||||
"F": "high", "G": "moderate","K": "low", "M": "low",
|
||||
}
|
||||
|
||||
# atmosphere field → density string
|
||||
ATMO_MAP = {
|
||||
"none": "none",
|
||||
"thin": "thin",
|
||||
"breathable": "standard",
|
||||
"dense": "thick",
|
||||
"toxic": "thick", # Venus-style reducing atmosphere
|
||||
}
|
||||
|
||||
# hydrosphere → approximate land_fraction range [min, max]
|
||||
HYDRO_LAND = {
|
||||
"ocean": (0.28, 0.50),
|
||||
"liquid_water":(0.35, 0.65),
|
||||
"rivers": (0.50, 0.75), # Titan-style — surface liquid but mostly land
|
||||
"ice": (0.70, 0.90), # mostly frozen land
|
||||
"subsurface": (0.90, 0.99), # surface appears dry
|
||||
"none": (0.97, 1.00),
|
||||
}
|
||||
|
||||
# biome → planet_class
|
||||
BIOME_CLASS = {
|
||||
"temperate": "temperate",
|
||||
"arid": "arid",
|
||||
"frozen": "frozen",
|
||||
"volcanic": "volcanic",
|
||||
"barren": "barren",
|
||||
"forest": "forest",
|
||||
"oceanic": "oceanic",
|
||||
}
|
||||
|
||||
# planet_class → axial tilt range [min, max] degrees
|
||||
# Tidal locking check overrides this for short-period bodies
|
||||
CLASS_TILT = {
|
||||
"temperate": (10, 35),
|
||||
"oceanic": (5, 25),
|
||||
"forest": (10, 40),
|
||||
"arid": (5, 30),
|
||||
"frozen": (15, 60), # high tilt → seasonal extremes → frozen
|
||||
"volcanic": (2, 20),
|
||||
"barren": (0, 45),
|
||||
}
|
||||
|
||||
# planet_class → geothermal flux
|
||||
CLASS_GEOTHERMAL = {
|
||||
"volcanic": "extreme",
|
||||
"temperate": "low",
|
||||
"oceanic": "low",
|
||||
"forest": "low",
|
||||
"arid": "low",
|
||||
"frozen": "low",
|
||||
"barren": "low",
|
||||
}
|
||||
|
||||
# planet_class → polar ice latitude (fraction of 0–1, where 1 = poles)
|
||||
# Lower = ice caps extend further toward equator
|
||||
CLASS_POLAR_ICE = {
|
||||
"temperate": (0.72, 0.85),
|
||||
"oceanic": (0.80, 0.92),
|
||||
"forest": (0.75, 0.88),
|
||||
"arid": (0.90, 0.99),
|
||||
"frozen": (0.10, 0.40),
|
||||
"volcanic": (0.95, 1.00),
|
||||
"barren": (0.92, 1.00),
|
||||
}
|
||||
|
||||
# planet_class → oblateness range
|
||||
CLASS_OBLATENESS = {
|
||||
"temperate": (0.001, 0.005),
|
||||
"oceanic": (0.001, 0.004),
|
||||
"forest": (0.001, 0.005),
|
||||
"arid": (0.001, 0.004),
|
||||
"frozen": (0.001, 0.003),
|
||||
"volcanic": (0.002, 0.008),
|
||||
"barren": (0.000, 0.003),
|
||||
}
|
||||
|
||||
# Gas giant band palettes available
|
||||
GAS_PALETTES = ["jovian", "neptunian", "saturnian", "icy", "sulfuric"]
|
||||
|
||||
# planet_class → cloud coverage base range
|
||||
CLASS_CLOUD = {
|
||||
"temperate": (0.35, 0.55),
|
||||
"oceanic": (0.55, 0.75),
|
||||
"forest": (0.40, 0.60),
|
||||
"arid": (0.05, 0.20),
|
||||
"frozen": (0.20, 0.45),
|
||||
"volcanic": (0.60, 0.85),
|
||||
"barren": (0.00, 0.05),
|
||||
}
|
||||
|
||||
# Atmosphere classes that allow clouds
|
||||
CLOUD_CAPABLE = {"standard", "thick", "thin"}
|
||||
|
||||
# Render defaults
|
||||
RENDER_DEFAULTS = {
|
||||
"globe_light_angle_deg": 125,
|
||||
"specular_ocean": True,
|
||||
"night_side_ambient": 0.025,
|
||||
}
|
||||
|
||||
# Ring probability for gas giants (if not overridden)
|
||||
RING_PROBABILITY = 0.40 # 40% chance of rings — Saturn is special
|
||||
|
||||
# Ring colour palettes paired to band palettes
|
||||
RING_COLOURS = {
|
||||
"jovian": [0.55, 0.48, 0.35], # faint dark rings
|
||||
"neptunian": [0.72, 0.82, 0.95], # blue-tinted
|
||||
"saturnian": [0.88, 0.78, 0.55], # warm golden
|
||||
"icy": [0.85, 0.90, 0.95], # pale ice
|
||||
"sulfuric": [0.75, 0.70, 0.30], # sulphur-tinted
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeded RNG helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_from_id(body_id: str) -> int:
|
||||
"""Deterministic integer seed from body ID string."""
|
||||
h = hashlib.md5(body_id.encode()).digest()
|
||||
return int.from_bytes(h[:4], "little")
|
||||
|
||||
|
||||
def _rng(body_id: str, salt: str = "") -> np.random.Generator:
|
||||
"""Seeded RNG for a specific body + context. Always reproducible."""
|
||||
seed = _seed_from_id(body_id + salt)
|
||||
return np.random.default_rng(seed)
|
||||
|
||||
|
||||
def _rand_range(body_id: str, lo: float, hi: float, salt: str = "") -> float:
|
||||
"""Uniform float in [lo, hi], seeded from body_id."""
|
||||
return float(_rng(body_id, salt).uniform(lo, hi))
|
||||
|
||||
|
||||
def _rand_choice(body_id: str, choices: list, salt: str = "") -> object:
|
||||
"""Random choice from list, seeded from body_id."""
|
||||
idx = int(_rng(body_id, salt).integers(0, len(choices)))
|
||||
return choices[idx]
|
||||
|
||||
|
||||
def _rand_bool(body_id: str, probability: float, salt: str = "") -> bool:
|
||||
"""True with given probability, seeded from body_id."""
|
||||
return float(_rng(body_id, salt).uniform(0, 1)) < probability
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orbital mechanics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _derive_distance_au(period_days: float, star_type: str) -> float:
|
||||
"""
|
||||
Kepler's third law: a³ = P² × M_star
|
||||
Returns orbital distance in AU.
|
||||
M_star approximated from spectral type luminosity (L ∝ M^4 for main seq).
|
||||
"""
|
||||
if period_days <= 0:
|
||||
return 1.0
|
||||
lum = STAR_LUMINOSITY.get(star_type, 1.0)
|
||||
m_star = lum ** 0.25 # rough mass from luminosity
|
||||
p_years = period_days / 365.25
|
||||
return (p_years ** 2 * m_star) ** (1.0 / 3.0)
|
||||
|
||||
|
||||
def _check_habitability(body_def: dict) -> None:
|
||||
"""
|
||||
Warn if a temperate/oceanic/forest world has a physically implausible
|
||||
equilibrium temperature. Helps catch orbital distance errors early.
|
||||
"""
|
||||
pclass = body_def.get("planet_class", "")
|
||||
if pclass not in ("temperate", "oceanic", "forest"):
|
||||
return
|
||||
lum = body_def["star"].get("luminosity_solar", 1.0)
|
||||
dist = body_def["orbit"].get("distance_au", 1.0)
|
||||
atmo = body_def["physical"].get("atmosphere", "standard")
|
||||
gh = {"none": 0, "thin": 8, "standard": 33, "thick": 80}.get(atmo, 33)
|
||||
t_eq = 278.5 * (lum ** 0.25) / math.sqrt(max(dist, 0.01)) + gh
|
||||
if t_eq > 340:
|
||||
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
|
||||
f"too hot for {pclass}. Check distance_au ({dist:.2f} AU). "
|
||||
f"Habitable zone ≈ {(278.5*(lum**0.25)/(290-gh))**2:.2f} AU")
|
||||
elif t_eq < 220:
|
||||
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
|
||||
f"too cold for {pclass}. Check distance_au ({dist:.2f} AU).")
|
||||
|
||||
|
||||
def _is_tidally_locked(period_days: float, star_type: str) -> bool:
|
||||
"""
|
||||
Bodies with very short periods around dim stars are likely tidally locked.
|
||||
Rough threshold: period < 20 days for M-stars, < 10 for K-stars.
|
||||
"""
|
||||
thresholds = {"M": 20, "K": 10, "F": 4, "G": 4, "A": 2, "B": 1, "O": 1}
|
||||
return period_days < thresholds.get(star_type, 5)
|
||||
|
||||
|
||||
def _tidal_heating(period_days: float, mass_class: str, parent_is_giant: bool) -> str:
|
||||
"""
|
||||
Estimate geothermal flux modifier from tidal heating.
|
||||
Short-period moons around gas giants get significant heating (Io/Europa).
|
||||
"""
|
||||
if not parent_is_giant:
|
||||
return "low"
|
||||
if period_days < 3:
|
||||
return "extreme" # Io-like
|
||||
if period_days < 10:
|
||||
return "moderate" # Europa-like
|
||||
return "low"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown parser — bodies table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_star(system_profile_text: str) -> dict:
|
||||
"""
|
||||
Extract star type and luminosity from system profile section.
|
||||
Looks for lines like: | **Star** | G2V · 0.0 ly |
|
||||
"""
|
||||
match = re.search(r'\*\*Star\*\*.*?([OBAFGKM])\d*[Vab]*', system_profile_text)
|
||||
star_type = match.group(1) if match else "G"
|
||||
return {
|
||||
"type": star_type,
|
||||
"luminosity_solar": STAR_LUMINOSITY.get(star_type, 1.0),
|
||||
"color_temp_K": STAR_COLOUR_TEMP.get(star_type, 5800),
|
||||
}
|
||||
|
||||
|
||||
def _parse_bodies_table(md_text: str) -> list[dict]:
|
||||
"""
|
||||
Parse the Celestial Bodies table from the markdown.
|
||||
Returns list of raw row dicts.
|
||||
"""
|
||||
# Find the table section
|
||||
table_match = re.search(
|
||||
r'\| Orbit \| ID.*?\n(\|[-| ]+\|\n)(.*?)(?=\n##|\Z)',
|
||||
md_text, re.DOTALL
|
||||
)
|
||||
if not table_match:
|
||||
log.warning("No bodies table found in markdown")
|
||||
return []
|
||||
|
||||
table_body = table_match.group(2)
|
||||
rows = []
|
||||
|
||||
for line in table_body.strip().splitlines():
|
||||
if not line.strip().startswith('|'):
|
||||
continue
|
||||
cells = [c.strip() for c in line.split('|')[1:-1]]
|
||||
if len(cells) < 10:
|
||||
continue
|
||||
|
||||
# Extract body ID from backtick notation
|
||||
id_match = re.search(r'`([^`]+)`', cells[1])
|
||||
if not id_match:
|
||||
continue
|
||||
body_id = id_match.group(1)
|
||||
|
||||
# Skip non-body rows
|
||||
body_type = cells[3].strip().lower()
|
||||
if body_type in ('asteroid_belt', 'oort_cloud', ''):
|
||||
continue
|
||||
if body_type not in ('planet', 'moon', 'gas_giant'):
|
||||
continue
|
||||
|
||||
def cell(i, default="—"):
|
||||
v = cells[i].strip() if i < len(cells) else default
|
||||
return v if v not in ('—', '', '-') else default
|
||||
|
||||
# Gravity: strip 'g' suffix
|
||||
grav_str = cell(7)
|
||||
try:
|
||||
gravity = float(re.sub(r'[^\d.]', '', grav_str))
|
||||
except (ValueError, TypeError):
|
||||
gravity = None
|
||||
|
||||
# Orbit period
|
||||
try:
|
||||
period = float(cell(8))
|
||||
except (ValueError, TypeError):
|
||||
period = 0.0
|
||||
|
||||
# Day length
|
||||
try:
|
||||
day_h = float(cell(9))
|
||||
except (ValueError, TypeError):
|
||||
day_h = None
|
||||
|
||||
# Parent body — detect from ↳ prefix
|
||||
is_moon_row = '↳' in cells[0]
|
||||
|
||||
rows.append({
|
||||
"orbit_label": cells[0].strip(),
|
||||
"body_id": body_id,
|
||||
"name": cell(2) if cell(2) != '—' else None,
|
||||
"body_type": body_type,
|
||||
"inhabited": cell(4).lower() == 'yes',
|
||||
"population": cell(5),
|
||||
"mass_class": cell(6).lower(), # terrestrial / dwarf / gas_giant / ice_giant
|
||||
"gravity_g": gravity,
|
||||
"period_days": period,
|
||||
"day_h": day_h,
|
||||
"atmosphere": cell(10).lower(),
|
||||
"biome": cell(11).lower(),
|
||||
"hydrosphere": cell(12).lower(),
|
||||
"economy": cell(13),
|
||||
"settlement": cell(14),
|
||||
"industrial": cell(15),
|
||||
"is_moon_row": is_moon_row,
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body definition builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_body_def(
|
||||
row: dict,
|
||||
star: dict,
|
||||
system_id: str,
|
||||
overrides: dict,
|
||||
parent_is_giant: bool = False,
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Convert one bodies table row into a body_definition dict.
|
||||
overrides: per-body override dict (keyed by body_id).
|
||||
Returns None for bodies that don't need a render (asteroid belts etc).
|
||||
"""
|
||||
bid = row["body_id"]
|
||||
btype = row["body_type"]
|
||||
mass = row["mass_class"]
|
||||
biome = row["biome"]
|
||||
hydro = row["hydrosphere"]
|
||||
atmo = row["atmosphere"]
|
||||
period = row["period_days"]
|
||||
gravity = row["gravity_g"]
|
||||
star_type = star["type"]
|
||||
|
||||
ov = overrides.get(bid, {}) # per-body override dict
|
||||
|
||||
# ── Planet class ──────────────────────────────────────────────────────
|
||||
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
|
||||
planet_class = "gas_giant"
|
||||
else:
|
||||
planet_class = BIOME_CLASS.get(biome, "barren")
|
||||
|
||||
planet_class = ov.get("planet_class", planet_class)
|
||||
|
||||
# ── Body scale ────────────────────────────────────────────────────────
|
||||
body_scale = "moon" if row["is_moon_row"] or mass == "dwarf" else "planet"
|
||||
body_scale = ov.get("body_scale", body_scale)
|
||||
|
||||
# ── Seed — deterministic from body ID ─────────────────────────────────
|
||||
seed = _seed_from_id(bid)
|
||||
seed = ov.get("seed", seed)
|
||||
|
||||
# ── Orbital distance ──────────────────────────────────────────────────
|
||||
distance_au = _derive_distance_au(period, star_type)
|
||||
|
||||
# ── Axial tilt ────────────────────────────────────────────────────────
|
||||
tilt_ov = (ov.get("orbit", {}) or {}).get("axial_tilt_deg", "rand")
|
||||
if tilt_ov != "rand":
|
||||
axial_tilt = float(tilt_ov)
|
||||
elif _is_tidally_locked(period, star_type) and not parent_is_giant:
|
||||
axial_tilt = _rand_range(bid, 0, 5, "tilt")
|
||||
elif planet_class in CLASS_TILT:
|
||||
lo, hi = CLASS_TILT[planet_class]
|
||||
axial_tilt = _rand_range(bid, lo, hi, "tilt")
|
||||
else:
|
||||
axial_tilt = _rand_range(bid, 5, 35, "tilt")
|
||||
|
||||
# ── Atmosphere density ────────────────────────────────────────────────
|
||||
atmo_density = ATMO_MAP.get(atmo, "none")
|
||||
atmo_density = ov.get("atmosphere_density", atmo_density)
|
||||
|
||||
# ── Atmosphere colour — from star type + planet class ─────────────────
|
||||
atmo_colors = {
|
||||
"temperate": [0.45, 0.65, 1.00],
|
||||
"oceanic": [0.40, 0.60, 1.00],
|
||||
"forest": [0.42, 0.68, 0.80],
|
||||
"arid": [0.90, 0.72, 0.50],
|
||||
"frozen": [0.75, 0.88, 1.00],
|
||||
"volcanic": [0.55, 0.40, 0.30],
|
||||
"barren": None,
|
||||
}
|
||||
atmo_color = atmo_colors.get(planet_class)
|
||||
atmo_color = ov.get("atmosphere_color", atmo_color)
|
||||
|
||||
# ── Land fraction ─────────────────────────────────────────────────────
|
||||
land_ov = (ov.get("terrain", {}) or {}).get("land_fraction", "rand")
|
||||
if land_ov != "rand":
|
||||
land_fraction = float(land_ov)
|
||||
else:
|
||||
lo, hi = HYDRO_LAND.get(hydro, (0.90, 0.99))
|
||||
land_fraction = _rand_range(bid, lo, hi, "land")
|
||||
|
||||
# ── Polar ice latitude ────────────────────────────────────────────────
|
||||
ice_ov = (ov.get("terrain", {}) or {}).get("polar_ice_lat", "rand")
|
||||
if ice_ov != "rand":
|
||||
polar_ice_lat = float(ice_ov)
|
||||
else:
|
||||
lo, hi = CLASS_POLAR_ICE.get(planet_class, (0.80, 0.95))
|
||||
# High axial tilt → ice caps extend further toward equator
|
||||
tilt_factor = (axial_tilt / 90.0) * 0.3
|
||||
lo = max(0.05, lo - tilt_factor)
|
||||
hi = max(0.10, hi - tilt_factor)
|
||||
polar_ice_lat = _rand_range(bid, lo, hi, "ice")
|
||||
|
||||
# ── Tectonics ─────────────────────────────────────────────────────────
|
||||
tectonic_map = {
|
||||
"volcanic": "extreme", "temperate": "active",
|
||||
"oceanic": "active", "forest": "active",
|
||||
"arid": "low", "frozen": "low", "barren": "none",
|
||||
}
|
||||
tectonics = tectonic_map.get(planet_class, "low")
|
||||
tectonics = ov.get("tectonics", tectonics)
|
||||
|
||||
# ── Geothermal flux ───────────────────────────────────────────────────
|
||||
geothermal = CLASS_GEOTHERMAL.get(planet_class, "low")
|
||||
# Tidal heating for moons of gas giants
|
||||
if parent_is_giant:
|
||||
tidal = _tidal_heating(period, mass, parent_is_giant)
|
||||
if tidal != "low":
|
||||
geothermal = tidal
|
||||
geothermal = ov.get("geothermal_flux", geothermal)
|
||||
|
||||
# ── UV index ──────────────────────────────────────────────────────────
|
||||
uv_index = STAR_UV.get(star_type, "moderate")
|
||||
# Thin/no atmosphere → UV reaches surface directly
|
||||
if atmo_density in ("none", "thin"):
|
||||
uv_map = {"low": "moderate", "moderate": "high", "high": "extreme"}
|
||||
uv_index = uv_map.get(uv_index, uv_index)
|
||||
uv_index = ov.get("uv_index", uv_index)
|
||||
|
||||
# ── Substrate ─────────────────────────────────────────────────────────
|
||||
substrate_map = {
|
||||
"volcanic": "sulfuric",
|
||||
"arid": "silicate",
|
||||
"frozen": "ice",
|
||||
"barren": "silicate",
|
||||
"temperate":"silicate",
|
||||
"oceanic": "silicate",
|
||||
"forest": "silicate",
|
||||
}
|
||||
substrate = substrate_map.get(planet_class, "silicate")
|
||||
if hydro == "subsurface" and planet_class == "frozen":
|
||||
substrate = "ice"
|
||||
substrate = ov.get("substrate", substrate)
|
||||
|
||||
# ── Chemosynthetic modifier ───────────────────────────────────────────
|
||||
# Europa case: frozen + subsurface + tidal heating → chemosynthetic
|
||||
chemosynthetic = False
|
||||
if hydro == "subsurface" and geothermal in ("moderate", "high", "extreme"):
|
||||
chemosynthetic = True
|
||||
chemosynthetic = ov.get("chemosynthetic", chemosynthetic)
|
||||
|
||||
# ── Oblateness ────────────────────────────────────────────────────────
|
||||
oblat_lo, oblat_hi = CLASS_OBLATENESS.get(planet_class, (0.001, 0.005))
|
||||
oblateness = _rand_range(bid, oblat_lo, oblat_hi, "oblat")
|
||||
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
|
||||
oblateness = _rand_range(bid, 0.050, 0.090, "oblat")
|
||||
oblateness = ov.get("oblateness", oblateness)
|
||||
|
||||
# ── Clouds ────────────────────────────────────────────────────────────
|
||||
clouds_enabled = atmo_density in CLOUD_CAPABLE and planet_class != "barren"
|
||||
if planet_class == "barren":
|
||||
clouds_enabled = False
|
||||
cld_ov = ov.get("clouds", {}) or {}
|
||||
clouds_enabled = cld_ov.get("enabled", clouds_enabled)
|
||||
|
||||
coverage_ov = cld_ov.get("coverage_base", "rand")
|
||||
if coverage_ov != "rand":
|
||||
coverage = float(coverage_ov)
|
||||
else:
|
||||
lo, hi = CLASS_CLOUD.get(planet_class, (0.10, 0.40))
|
||||
coverage = _rand_range(bid, lo, hi, "cloud")
|
||||
|
||||
# ── Gas giant specific ────────────────────────────────────────────────
|
||||
gas_giant_cfg = None
|
||||
rings_cfg = None
|
||||
|
||||
if planet_class == "gas_giant":
|
||||
palette_ov = (ov.get("gas_giant", {}) or {}).get("band_palette", "rand")
|
||||
if palette_ov == "rand":
|
||||
palette = _rand_choice(bid, GAS_PALETTES, "palette")
|
||||
else:
|
||||
palette = palette_ov
|
||||
|
||||
storm_count = int(_rand_range(bid, 1, 5, "storms"))
|
||||
storm_count = (ov.get("gas_giant", {}) or {}).get("storm_count", storm_count)
|
||||
storm_size = _rand_range(bid, 0.06, 0.14, "storm_sz")
|
||||
storm_size = (ov.get("gas_giant", {}) or {}).get("storm_max_size", storm_size)
|
||||
|
||||
gas_giant_cfg = {
|
||||
"band_palette": palette,
|
||||
"storm_count": storm_count,
|
||||
"storm_max_size": round(float(storm_size), 3),
|
||||
}
|
||||
|
||||
# Rings
|
||||
rings_ov = ov.get("rings", "rand")
|
||||
if rings_ov == "rand":
|
||||
has_rings = _rand_bool(bid, RING_PROBABILITY, "rings")
|
||||
elif isinstance(rings_ov, dict):
|
||||
has_rings = rings_ov.get("enabled", True)
|
||||
else:
|
||||
has_rings = bool(rings_ov)
|
||||
|
||||
if has_rings:
|
||||
planet_class = "gas_giant_ringed"
|
||||
r_inner = round(_rand_range(bid, 1.08, 1.25, "r_inner"), 2)
|
||||
r_outer = round(_rand_range(bid, 2.20, 2.80, "r_outer"), 2)
|
||||
opacity = round(_rand_range(bid, 0.45, 0.72, "r_opa"), 2)
|
||||
rcolor = RING_COLOURS.get(palette, [0.75, 0.70, 0.60])
|
||||
|
||||
# Merge any explicit ring overrides
|
||||
if isinstance(rings_ov, dict):
|
||||
r_inner = rings_ov.get("inner_radius_factor", r_inner)
|
||||
r_outer = rings_ov.get("outer_radius_factor", r_outer)
|
||||
opacity = rings_ov.get("opacity_base", opacity)
|
||||
rcolor = rings_ov.get("ring_color", rcolor)
|
||||
|
||||
rings_cfg = {
|
||||
"enabled": True,
|
||||
"inner_radius_factor": r_inner,
|
||||
"outer_radius_factor": r_outer,
|
||||
"opacity_base": opacity,
|
||||
"ring_color": rcolor,
|
||||
}
|
||||
|
||||
# ── Render config ─────────────────────────────────────────────────────
|
||||
render_cfg = dict(RENDER_DEFAULTS)
|
||||
render_cfg["specular_ocean"] = hydro in ("ocean", "liquid_water", "rivers")
|
||||
if planet_class in ("barren", "arid", "volcanic"):
|
||||
render_cfg["specular_ocean"] = False
|
||||
render_cfg.update(ov.get("render", {}))
|
||||
|
||||
# ── Assemble ──────────────────────────────────────────────────────────
|
||||
body_def = {
|
||||
"id": bid,
|
||||
"name": row["name"],
|
||||
"body_type": btype,
|
||||
"planet_class": planet_class,
|
||||
"body_scale": body_scale,
|
||||
"seed": seed,
|
||||
|
||||
"star": star,
|
||||
|
||||
"orbit": {
|
||||
"distance_au": round(distance_au, 3),
|
||||
"period_days": period,
|
||||
"axial_tilt_deg": round(axial_tilt, 1),
|
||||
},
|
||||
|
||||
"physical": {
|
||||
"gravity_g": gravity,
|
||||
"oblateness": round(oblateness, 4),
|
||||
"atmosphere": atmo_density,
|
||||
"atmosphere_color": atmo_color,
|
||||
},
|
||||
|
||||
"terrain": {
|
||||
"land_fraction": round(land_fraction, 3),
|
||||
"polar_ice_lat": round(polar_ice_lat, 3),
|
||||
"tectonics": tectonics,
|
||||
},
|
||||
|
||||
"environment": {
|
||||
"geothermal_flux": geothermal,
|
||||
"uv_index": uv_index,
|
||||
"substrate": substrate,
|
||||
"chemosynthetic": chemosynthetic,
|
||||
"hydrosphere": hydro,
|
||||
},
|
||||
|
||||
"clouds": {
|
||||
"enabled": bool(clouds_enabled),
|
||||
"coverage_base": round(coverage, 3),
|
||||
},
|
||||
|
||||
"render": render_cfg,
|
||||
}
|
||||
|
||||
# Gas giant extras
|
||||
if gas_giant_cfg:
|
||||
body_def["gas_giant"] = gas_giant_cfg
|
||||
if rings_cfg:
|
||||
body_def["rings"] = rings_cfg
|
||||
|
||||
return body_def
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System parser — top-level entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_system(
|
||||
md_path: str,
|
||||
overrides: dict = None,
|
||||
out_dir: str = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Parse a system index.md and return list of body_definition dicts.
|
||||
Optionally write one JSON file per body into out_dir.
|
||||
|
||||
overrides: { body_id: { field: value, ... } }
|
||||
"""
|
||||
overrides = overrides or {}
|
||||
md_text = Path(md_path).read_text(encoding="utf-8")
|
||||
|
||||
# Extract system ID from first header
|
||||
sys_match = re.search(r'\*\*([A-Z0-9 ]+)\*\*', md_text)
|
||||
system_id = sys_match.group(1).replace(" ", "_") if sys_match else "UNKNOWN"
|
||||
|
||||
# Parse star
|
||||
star = _parse_star(md_text)
|
||||
log.info(f"System: {system_id} Star: {star['type']}-type "
|
||||
f"L={star['luminosity_solar']:.3g} Lsun")
|
||||
|
||||
# Parse bodies table
|
||||
rows = _parse_bodies_table(md_text)
|
||||
log.info(f"Found {len(rows)} renderable bodies")
|
||||
|
||||
# Track which bodies are moons of gas giants (for tidal heating)
|
||||
# Simple heuristic: if the previous non-moon row was a gas_giant, this is its moon
|
||||
last_giant = False
|
||||
body_defs = []
|
||||
|
||||
for row in rows:
|
||||
bid = row["body_id"]
|
||||
btype = row["body_type"]
|
||||
mass = row["mass_class"]
|
||||
|
||||
is_giant = btype == "gas_giant" or mass in ("gas_giant", "ice_giant")
|
||||
|
||||
# Determine if this moon orbits a gas giant
|
||||
parent_is_giant = row["is_moon_row"] and last_giant
|
||||
|
||||
if not row["is_moon_row"]:
|
||||
last_giant = is_giant
|
||||
|
||||
# Build definition
|
||||
body_def = _build_body_def(
|
||||
row, star, system_id, overrides,
|
||||
parent_is_giant=parent_is_giant,
|
||||
)
|
||||
if body_def is None:
|
||||
continue
|
||||
|
||||
body_defs.append(body_def)
|
||||
log.info(f" {bid:20s} {body_def['planet_class']:20s} "
|
||||
f"scale={body_def['body_scale']:6s} "
|
||||
f"seed={body_def['seed']}")
|
||||
|
||||
# Write output files
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for bd in body_defs:
|
||||
out_path = os.path.join(out_dir, f"{bd['id']}_def.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(bd, f, indent=2)
|
||||
log.info(f"Wrote {len(body_defs)} body definitions → {out_dir}/")
|
||||
|
||||
_check_habitability(body_def)
|
||||
return body_defs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Parse system index.md → body_definition.json files"
|
||||
)
|
||||
parser.add_argument("md_file", help="Path to system index.md")
|
||||
parser.add_argument("--out-dir", default="./body_defs",
|
||||
help="Output directory for JSON files (default: ./body_defs)")
|
||||
parser.add_argument("--overrides", default=None,
|
||||
help="Path to JSON overrides file (optional)")
|
||||
parser.add_argument("--print", action="store_true",
|
||||
help="Print all body definitions to stdout")
|
||||
args = parser.parse_args()
|
||||
|
||||
overrides = {}
|
||||
if args.overrides:
|
||||
with open(args.overrides) as f:
|
||||
overrides = json.load(f)
|
||||
|
||||
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
|
||||
|
||||
if args.print:
|
||||
print(json.dumps(defs, indent=2))
|
||||
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 1–2px dark border at the sea level threshold."""
|
||||
surf_water = _upscale(terrain["surface_water"].astype(np.float32),
|
||||
order=0) > 0.5
|
||||
|
||||
# Dilate water mask by 1px, XOR with original → coastline ring
|
||||
dilated = binary_dilation(surf_water, iterations=2)
|
||||
coastline = dilated & ~surf_water
|
||||
|
||||
coast_col = np.array(COAST_RGB, dtype=np.float32) / 255.0
|
||||
out = rgb.copy()
|
||||
out[coastline] = coast_col
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 5: Rivers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_rivers(terrain: dict,
|
||||
rgb: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Draw rivers as anti-aliased polylines.
|
||||
River list is in simulation grid coords (row, col) at GRID_H×GRID_W.
|
||||
Scale to output pixels, draw with PIL.
|
||||
"""
|
||||
rivers = terrain.get("rivers", [])
|
||||
if not rivers:
|
||||
return rgb
|
||||
|
||||
GRID_H, GRID_W = terrain["_grid_h"], terrain["_grid_w"]
|
||||
scale_y = OUT_H / GRID_H
|
||||
scale_x = OUT_W / GRID_W
|
||||
|
||||
# Work on a PIL image for anti-aliased line drawing
|
||||
img = Image.fromarray((rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
river_col = RIVER_RGB
|
||||
|
||||
for path in rivers:
|
||||
if len(path) < 2:
|
||||
continue
|
||||
# Scale grid coords to output pixels
|
||||
pts = [(int(c * scale_x), int(r * scale_y)) for r, c in path]
|
||||
# Line width scales with path length — longer rivers are wider
|
||||
width = max(1, min(3, len(path) // 80))
|
||||
draw.line(pts, fill=river_col, width=width, joint="curve")
|
||||
|
||||
return np.array(img).astype(np.float32) / 255.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 6: Lat/lon grid
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_grid(rgb: np.ndarray) -> np.ndarray:
|
||||
"""Draw lat/lon lines every 30° as semi-transparent overlays."""
|
||||
out = rgb.copy()
|
||||
col = np.array([255, 255, 255], dtype=np.float32) / 255.0
|
||||
alpha = 0.12 # very subtle
|
||||
|
||||
# Latitude lines (horizontal) every 30°: at 1/6, 2/6, 3/6, 4/6, 5/6 of height
|
||||
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
|
||||
y = int(frac * OUT_H)
|
||||
y0 = max(0, y - 1); y1 = min(OUT_H - 1, y + 1)
|
||||
out[y0:y1, :] = out[y0:y1, :] * (1 - alpha) + col * alpha
|
||||
|
||||
# Longitude lines (vertical) every 30°
|
||||
for frac in [1/6, 2/6, 3/6, 4/6, 5/6]:
|
||||
x = int(frac * OUT_W)
|
||||
x0 = max(0, x - 1); x1 = min(OUT_W - 1, x + 1)
|
||||
out[:, x0:x1] = out[:, x0:x1] * (1 - alpha) + col * alpha
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 7: Title panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_font(size: int):
|
||||
try:
|
||||
return ImageFont.load_default(size=size)
|
||||
except TypeError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _render_title(img: Image.Image, body_def: dict) -> Image.Image:
|
||||
"""Draw metadata strip at top of image."""
|
||||
panel_h = int(52 * UI_SCALE)
|
||||
panel = Image.new("RGBA", (OUT_W, panel_h), (12, 15, 22, 210))
|
||||
|
||||
img_rgba = img.convert("RGBA")
|
||||
img_rgba.paste(panel, (0, 0), panel)
|
||||
img_out = img_rgba.convert("RGB")
|
||||
draw = ImageDraw.Draw(img_out)
|
||||
|
||||
name = body_def.get("name") or body_def.get("id", "Unknown")
|
||||
bid = body_def.get("id", "")
|
||||
pclass = body_def.get("planet_class", "").replace("_ringed", "")
|
||||
star = body_def.get("star", {})
|
||||
orbit = body_def.get("orbit", {})
|
||||
phys = body_def.get("physical", {})
|
||||
env = body_def.get("environment", {})
|
||||
|
||||
star_str = f"{star.get('type','?')}-type"
|
||||
dist_str = f"{orbit.get('distance_au', 0):.2f} AU"
|
||||
grav_str = f"{phys.get('gravity_g', '?')}g"
|
||||
atmo_str = phys.get("atmosphere", "?")
|
||||
hydro_str = env.get("hydrosphere", "?")
|
||||
|
||||
px = int(14 * UI_SCALE)
|
||||
py = int(7 * UI_SCALE)
|
||||
lh = int(17 * UI_SCALE)
|
||||
|
||||
title_col = (200, 210, 228)
|
||||
sub_col = (130, 145, 168)
|
||||
dim_col = (75, 88, 110)
|
||||
|
||||
line1 = f"{name.upper()} · {bid} · {pclass}"
|
||||
line2 = f"{star_str} · {dist_str} · {grav_str} · atmo: {atmo_str} · hydro: {hydro_str}"
|
||||
line3 = "HEIGHTMAP · Settled Reach"
|
||||
|
||||
draw.text((px, py), line1, fill=title_col, font=_load_font(int(14 * UI_SCALE)))
|
||||
draw.text((px, py + lh), line2, fill=sub_col, font=_load_font(int(12 * UI_SCALE)))
|
||||
draw.text((px, py + lh*2), line3, fill=dim_col, font=_load_font(int(11 * UI_SCALE)))
|
||||
|
||||
return img_out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 8: Legend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _biome_legend_items(terrain: dict) -> list:
|
||||
"""
|
||||
Return list of (label, RGB) for biome classes actually present
|
||||
in this terrain — no phantom legend entries.
|
||||
"""
|
||||
biome = terrain["biome"]
|
||||
present = set(np.unique(biome).tolist())
|
||||
|
||||
LABELS = {
|
||||
0: "ocean deep", 1: "ocean", 2: "coastal water",
|
||||
3: "coast", 5: "rainforest", 6: "trop. forest",
|
||||
7: "savanna", 8: "grassland", 9: "forest",
|
||||
10: "rainforest", 11: "boreal", 12: "shrubland",
|
||||
13: "temperate desert", 14: "desert", 15: "hot desert",
|
||||
16: "tundra", 17: "ice / snow", 18: "mountain rock",
|
||||
19: "lava field", 20: "chemosyn. mat", 21: "thermophilic",
|
||||
22: "sulfuric scrub", 23: "crypto. crust", 25: "ash field",
|
||||
}
|
||||
|
||||
items = []
|
||||
# Fixed display order — most common first, exotic last
|
||||
order = [0, 1, 2, 3, 7, 8, 5, 6, 9, 10, 11, 12, 13, 14, 15, 16, 17,
|
||||
18, 19, 20, 21, 22, 23, 25]
|
||||
for cls_id in order:
|
||||
if cls_id in present and cls_id in LABELS:
|
||||
rgb = BIOME_RGB.get(cls_id, (128, 128, 128))
|
||||
items.append((LABELS[cls_id], rgb))
|
||||
|
||||
# Always include river swatch if rivers exist
|
||||
if terrain.get("rivers"):
|
||||
items.append(("river", RIVER_RGB))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _render_legend(img: Image.Image, terrain: dict) -> Image.Image:
|
||||
"""Draw biome legend strip at bottom of image."""
|
||||
items = _biome_legend_items(terrain)
|
||||
if not items:
|
||||
return img
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
sw = int(14 * UI_SCALE) # swatch width
|
||||
sh = int(12 * UI_SCALE) # swatch height
|
||||
pad_x = int(14 * UI_SCALE)
|
||||
leg_y = OUT_H - int(34 * UI_SCALE)
|
||||
font = _load_font(int(10 * UI_SCALE))
|
||||
gap = int(6 * UI_SCALE)
|
||||
step = int(108 * UI_SCALE)
|
||||
|
||||
lx = pad_x
|
||||
for label, rgb in items:
|
||||
if lx + step > OUT_W - pad_x:
|
||||
break
|
||||
draw.rectangle([(lx, leg_y), (lx + sw, leg_y + sh)], fill=rgb)
|
||||
draw.text((lx + sw + gap, leg_y), label,
|
||||
fill=(185, 192, 205), font=font)
|
||||
lx += step
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def render_heightmap(body_def: dict,
|
||||
terrain: dict,
|
||||
out_w: int = OUT_W,
|
||||
out_h: int = OUT_H,
|
||||
render_mode: str = "cartographic") -> Image.Image:
|
||||
"""
|
||||
Render a 4096×2048 annotated equirectangular heightmap PNG.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
body_def : dict — body definition from body_definition_parser
|
||||
terrain : dict — terrain dict from planet_simulation.simulate()
|
||||
out_w, out_h — output resolution (default 4096×2048)
|
||||
|
||||
Returns
|
||||
-------
|
||||
PIL.Image.Image RGB
|
||||
"""
|
||||
global OUT_W, OUT_H, UI_SCALE
|
||||
OUT_W = out_w
|
||||
OUT_H = out_h
|
||||
UI_SCALE = out_w / 1024
|
||||
|
||||
# Set active colour mode for this render
|
||||
global BIOME_RGB, OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW, RENDER_MODE
|
||||
RENDER_MODE = render_mode
|
||||
BIOME_RGB = _build_biome_rgb(render_mode)
|
||||
OCEAN_DEEP, OCEAN_MID, OCEAN_SHALLOW = _ocean_arrays(render_mode)
|
||||
|
||||
# Guard: require simulation data
|
||||
required = ("elevation", "biome", "surface_water", "hillshade", "sea_level")
|
||||
missing = [k for k in required if k not in terrain]
|
||||
if missing:
|
||||
raise ValueError(f"terrain dict missing keys: {missing}")
|
||||
|
||||
# 1+2+3: surface colour with elevation shading and hillshade
|
||||
rgb = _render_surface(terrain)
|
||||
|
||||
# 4: coastline
|
||||
rgb = _render_coastline(terrain, rgb)
|
||||
|
||||
# 5: rivers
|
||||
rgb = _render_rivers(terrain, rgb)
|
||||
|
||||
# 6: lat/lon grid
|
||||
rgb = _render_grid(rgb)
|
||||
|
||||
# Convert to PIL for text rendering
|
||||
img = Image.fromarray(
|
||||
(rgb * 255).clip(0, 255).astype(np.uint8), mode="RGB")
|
||||
|
||||
# 7: title panel
|
||||
img = _render_title(img, body_def)
|
||||
|
||||
# 8: legend
|
||||
img = _render_legend(img, terrain)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys, json, time, os
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 render_heightmap.py body_def.json [--small]")
|
||||
sys.exit(1)
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
bd = json.load(f)
|
||||
|
||||
# --small flag renders at 1024×512 for fast iteration
|
||||
small = "--small" in sys.argv
|
||||
w, h = (1024, 512) if small else (OUT_W, OUT_H)
|
||||
|
||||
from planet_simulation import simulate
|
||||
|
||||
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
|
||||
t0 = time.time()
|
||||
terrain = simulate(bd)
|
||||
sim_t = time.time() - t0
|
||||
|
||||
if not terrain:
|
||||
print("Gas giant — no heightmap.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Rendering heightmap {w}×{h}…")
|
||||
t1 = time.time()
|
||||
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
|
||||
ren_t = time.time() - t1
|
||||
|
||||
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
|
||||
img.save(out, format="PNG")
|
||||
print(f"Saved: {out}")
|
||||
print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user