Files
settled-reach/tooling/planet-gen/body_definition_parser.py
T
jpmschweitzerandClaude Opus 4.6 467c21a0f3 fix(assets): address PR #119 review — 10 issues across planet-gen pipeline
1. Fix pclass.title() underscore bug in scaffold headings (216+ files)
2. Add 5 extended classes to batch.py valid_classes set
3. Add atmosphere rim colors for cold_arid/hot_arid/tropical/boreal/temperate_terminator
4. Add cloud/tilt ranges for extended classes (boreal 55-75%, tropical 60-80%)
5. Fix legend overflow at 1024px + deduplicate rainforest labels
6. Move _check_habitability() inside loop (was only checking last body)
7. Preserve gas_giant_ringed distinction in profile table
8. Drop meaningless terrain fields from gas giant frontmatter
9. Update stale docstrings/comments for 1024x512 default
10. Add infernal ring color

All lookup tables (CLASS_TILT, CLASS_CLOUD, CLASS_POLAR_ICE, CLASS_GEOTHERMAL,
CLASS_OBLATENESS, atmo_colors, tectonic_map, substrate_map) now include the
5 extended planet classes. Body content requires full regeneration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 19:18:08 +02:00

859 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
body_definition_parser.py
-------------------------
Parses a system index.md file and produces one body_definition.json
per renderable celestial body.
Input: index.md (system wiki page, bodies table + system profile)
Output: {body_id}_def.json per planet / moon / gas_giant
Design principles:
- "rand" sentinel means: derive from seed + planet class constraints
- Explicit values in the bodies table or override dict always win
- Every derivation is documented so the logic is auditable
- No field is silently dropped — unknowns get a logged warning
Field resolution order (highest wins):
1. override dict (per-body, hand-authored for special cases like Sol)
2. direct read (field exists verbatim in bodies table)
3. derived (computed from other fields — documented formula)
4. inferred (implied by combination of fields)
5. randomised (seeded, within planet-class constraints)
Usage:
python3 body_definition_parser.py path/to/index.md [--out-dir ./defs]
# With overrides (e.g. Sol)
python3 body_definition_parser.py sol/index.md --overrides sol_overrides.json
Override file format:
{
"GJ0g": { "rings": true, "ring_color": [0.88, 0.78, 0.55] },
"GJ0f": { "rings": false },
"GJ0d": { "orbit": { "axial_tilt_deg": 23.4 } }
}
"""
import argparse
import hashlib
import json
import logging
import math
import os
import re
from pathlib import Path
from typing import Optional
import numpy as np
logging.basicConfig(level=logging.INFO, format=" %(levelname)s %(message)s")
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants / lookup tables
# ---------------------------------------------------------------------------
# Spectral type → solar luminosity (approximate)
STAR_LUMINOSITY = {
"O": 100000.0, "B": 1000.0, "A": 10.0,
"F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04,
}
# Spectral type → colour temperature K (approximate midpoint)
STAR_COLOUR_TEMP = {
"O": 40000, "B": 20000, "A": 9000,
"F": 7000, "G": 5800, "K": 4500, "M": 3200,
}
# Star type → UV index category
STAR_UV = {
"O": "extreme", "B": "extreme", "A": "high",
"F": "high", "G": "moderate","K": "low", "M": "low",
}
# atmosphere field → density string
# Wiki atmosphere value → pipeline atmosphere density.
# Both legacy ("breathable") and current ("standard") wiki values mapped.
ATMO_MAP = {
"none": "none",
"trace": "none", # too thin for weather/clouds
"thin": "thin",
"breathable": "standard", # legacy wiki value
"standard": "standard", # current wiki value
"dense": "thick",
"thick": "thick",
"toxic": "thick", # Venus-style reducing atmosphere
"reducing": "thick", # same as toxic
"hydrogen-helium": "thick", # gas giant
"gas_giant": "thick", # gas giant atmosphere field
}
# hydrosphere → approximate land_fraction range [min, max]
HYDRO_LAND = {
"ocean": (0.28, 0.50),
"liquid_water":(0.35, 0.65),
"rivers": (0.50, 0.75), # Titan-style — surface liquid but mostly land
"ice": (0.70, 0.90), # mostly frozen land
"subsurface": (0.90, 0.99), # surface appears dry
"none": (0.97, 1.00),
}
# biome → planet_class
# Wiki planet class → pipeline planet class.
# All 26 known wiki values mapped explicitly.
# Unknown values default to "temperate" (safe for modders adding new classes).
PLANET_CLASS_MAP = {
# Direct matches
"temperate": "temperate",
"arid": "arid",
"frozen": "frozen",
"volcanic": "volcanic",
"barren": "barren",
"oceanic": "oceanic",
# Temperate variants
"temperate_terminator": "temperate_terminator",
"temperate-cool": "temperate",
"temperate_highland": "temperate",
"temperate_maritime": "temperate",
"temperate_savanna": "temperate",
"temperate_coastal": "temperate",
"temperate_riverine": "temperate",
"temperate_ocean": "oceanic",
"cold_temperate": "temperate",
"floodplain_temperate": "temperate",
# Arid variants
"cold_arid": "cold_arid",
"hot_arid": "hot_arid",
# Tropical
"tropical": "tropical",
"tropical_ocean": "oceanic",
"subtropical": "tropical",
# Cold
"ice": "frozen",
"boreal": "boreal",
# Warm ocean
"warm_ocean": "oceanic",
# Geothermal
"geothermal": "volcanic",
# Forest (from prototype)
"forest": "forest",
}
# planet_class → axial tilt range [min, max] degrees
# Tidal locking check overrides this for short-period bodies
CLASS_TILT = {
"temperate": (10, 35),
"temperate_terminator": (0, 5), # tidally locked by definition
"oceanic": (5, 25),
"forest": (10, 40),
"arid": (5, 30),
"cold_arid": (10, 40),
"hot_arid": (2, 15),
"tropical": (5, 15),
"boreal": (20, 50),
"frozen": (15, 60), # high tilt → seasonal extremes → frozen
"volcanic": (2, 20),
"barren": (0, 45),
}
# planet_class → geothermal flux
CLASS_GEOTHERMAL = {
"volcanic": "extreme",
"temperate": "low",
"temperate_terminator": "low",
"oceanic": "low",
"forest": "low",
"arid": "low",
"cold_arid": "low",
"hot_arid": "low",
"tropical": "low",
"boreal": "low",
"frozen": "low",
"barren": "low",
}
# planet_class → polar ice latitude (fraction of 01, where 1 = poles)
# Lower = ice caps extend further toward equator
CLASS_POLAR_ICE = {
"temperate": (0.72, 0.85),
"temperate_terminator": (0.75, 0.90),
"oceanic": (0.80, 0.92),
"forest": (0.75, 0.88),
"arid": (0.90, 0.99),
"cold_arid": (0.50, 0.70),
"hot_arid": (0.95, 1.00),
"tropical": (0.88, 0.96),
"boreal": (0.45, 0.65),
"frozen": (0.10, 0.40),
"volcanic": (0.95, 1.00),
"barren": (0.92, 1.00),
}
# planet_class → oblateness range
CLASS_OBLATENESS = {
"temperate": (0.001, 0.005),
"temperate_terminator": (0.001, 0.004),
"oceanic": (0.001, 0.004),
"forest": (0.001, 0.005),
"arid": (0.001, 0.004),
"cold_arid": (0.001, 0.004),
"hot_arid": (0.001, 0.004),
"tropical": (0.001, 0.005),
"boreal": (0.001, 0.004),
"frozen": (0.001, 0.003),
"volcanic": (0.002, 0.008),
"barren": (0.000, 0.003),
}
# Gas giant band palettes available
from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
# planet_class → cloud coverage base range
CLASS_CLOUD = {
"temperate": (0.35, 0.55),
"temperate_terminator": (0.30, 0.50),
"oceanic": (0.55, 0.75),
"forest": (0.40, 0.60),
"arid": (0.05, 0.20),
"cold_arid": (0.10, 0.25),
"hot_arid": (0.02, 0.10),
"tropical": (0.60, 0.80),
"boreal": (0.55, 0.75),
"frozen": (0.20, 0.45),
"volcanic": (0.60, 0.85),
"barren": (0.00, 0.05),
}
# Atmosphere classes that allow clouds
CLOUD_CAPABLE = {"standard", "thick", "thin"}
# Render defaults
RENDER_DEFAULTS = {
"globe_light_angle_deg": 125,
"specular_ocean": True,
"night_side_ambient": 0.025,
}
# Ring probability for gas giants (if not overridden)
RING_PROBABILITY = 0.40 # 40% chance of rings — Saturn is special
# Ring colour palettes paired to band palettes
RING_COLOURS = {
"jovian": [0.55, 0.48, 0.35], # faint dark rings
"neptunian": [0.72, 0.82, 0.95], # blue-tinted
"saturnian": [0.88, 0.78, 0.55], # warm golden
"icy": [0.85, 0.90, 0.95], # pale ice
"sulfuric": [0.75, 0.70, 0.30], # sulphur-tinted
"infernal": [0.60, 0.25, 0.15], # dark ember
}
# ---------------------------------------------------------------------------
# Seeded RNG helpers
# ---------------------------------------------------------------------------
def _seed_from_id(body_id: str) -> int:
"""Deterministic integer seed from body ID string."""
h = hashlib.md5(body_id.encode()).digest()
return int.from_bytes(h[:4], "little")
def _rng(body_id: str, salt: str = "") -> np.random.Generator:
"""Seeded RNG for a specific body + context. Always reproducible."""
seed = _seed_from_id(body_id + salt)
return np.random.default_rng(seed)
def _rand_range(body_id: str, lo: float, hi: float, salt: str = "") -> float:
"""Uniform float in [lo, hi], seeded from body_id."""
return float(_rng(body_id, salt).uniform(lo, hi))
def _rand_choice(body_id: str, choices: list, salt: str = "") -> object:
"""Random choice from list, seeded from body_id."""
idx = int(_rng(body_id, salt).integers(0, len(choices)))
return choices[idx]
def _rand_bool(body_id: str, probability: float, salt: str = "") -> bool:
"""True with given probability, seeded from body_id."""
return float(_rng(body_id, salt).uniform(0, 1)) < probability
# ---------------------------------------------------------------------------
# Orbital mechanics
# ---------------------------------------------------------------------------
def _derive_distance_au(period_days: float, star_type: str) -> float:
"""
Kepler's third law: a³ = P² × M_star
Returns orbital distance in AU.
M_star approximated from spectral type luminosity (L ∝ M^4 for main seq).
"""
if period_days <= 0:
return 1.0
lum = STAR_LUMINOSITY.get(star_type, 1.0)
m_star = lum ** 0.25 # rough mass from luminosity
p_years = period_days / 365.25
return (p_years ** 2 * m_star) ** (1.0 / 3.0)
def _check_habitability(body_def: dict) -> None:
"""
Warn if a temperate/oceanic/forest world has a physically implausible
equilibrium temperature. Helps catch orbital distance errors early.
"""
pclass = body_def.get("planet_class", "")
if pclass not in ("temperate", "oceanic", "forest"):
return
lum = body_def["star"].get("luminosity_solar", 1.0)
dist = body_def["orbit"].get("distance_au", 1.0)
atmo = body_def["physical"].get("atmosphere", "standard")
gh = {"none": 0, "thin": 8, "standard": 33, "thick": 80}.get(atmo, 33)
t_eq = 278.5 * (lum ** 0.25) / math.sqrt(max(dist, 0.01)) + gh
if t_eq > 340:
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
f"too hot for {pclass}. Check distance_au ({dist:.2f} AU). "
f"Habitable zone ≈ {(278.5*(lum**0.25)/(290-gh))**2:.2f} AU")
elif t_eq < 220:
log.warning(f" {body_def['id']}: T_eq={t_eq:.0f}K ({t_eq-273:.0f}°C) — "
f"too cold for {pclass}. Check distance_au ({dist:.2f} AU).")
def _is_tidally_locked(period_days: float, star_type: str) -> bool:
"""
Bodies with very short periods around dim stars are likely tidally locked.
Rough threshold: period < 20 days for M-stars, < 10 for K-stars.
"""
thresholds = {"M": 20, "K": 10, "F": 4, "G": 4, "A": 2, "B": 1, "O": 1}
return period_days < thresholds.get(star_type, 5)
def _tidal_heating(period_days: float, mass_class: str, parent_is_giant: bool) -> str:
"""
Estimate geothermal flux modifier from tidal heating.
Short-period moons around gas giants get significant heating (Io/Europa).
"""
if not parent_is_giant:
return "low"
if period_days < 3:
return "extreme" # Io-like
if period_days < 10:
return "moderate" # Europa-like
return "low"
# ---------------------------------------------------------------------------
# Markdown parser — bodies table
# ---------------------------------------------------------------------------
def _parse_star(system_profile_text: str) -> dict:
"""
Extract star type and luminosity from system profile section.
Looks for lines like: | **Star** | G2V · 0.0 ly |
"""
match = re.search(r'\*\*Star\*\*.*?([OBAFGKM])\d*[Vab]*', system_profile_text)
star_type = match.group(1) if match else "G"
return {
"type": star_type,
"luminosity_solar": STAR_LUMINOSITY.get(star_type, 1.0),
"color_temp_K": STAR_COLOUR_TEMP.get(star_type, 5800),
}
def _parse_bodies_table(md_text: str) -> list[dict]:
"""
Parse the Celestial Bodies table from the markdown.
Returns list of raw row dicts.
"""
# Find the table section
table_match = re.search(
r'\| Orbit \| ID.*?\n(\|[-| ]+\|\n)(.*?)(?=\n##|\Z)',
md_text, re.DOTALL
)
if not table_match:
log.warning("No bodies table found in markdown")
return []
table_body = table_match.group(2)
rows = []
for line in table_body.strip().splitlines():
if not line.strip().startswith('|'):
continue
cells = [c.strip() for c in line.split('|')[1:-1]]
if len(cells) < 10:
continue
# Extract body ID from backtick notation
id_match = re.search(r'`([^`]+)`', cells[1])
if not id_match:
continue
body_id = id_match.group(1)
# Skip non-body rows
body_type = cells[3].strip().lower()
if body_type in ('asteroid_belt', 'oort_cloud', ''):
continue
if body_type not in ('planet', 'moon', 'gas_giant'):
continue
def cell(i, default="—"):
v = cells[i].strip() if i < len(cells) else default
return v if v not in ('—', '', '-') else default
# Gravity: strip 'g' suffix
grav_str = cell(7)
try:
gravity = float(re.sub(r'[^\d.]', '', grav_str))
except (ValueError, TypeError):
gravity = None
# Orbit period
try:
period = float(cell(8))
except (ValueError, TypeError):
period = 0.0
# Day length
try:
day_h = float(cell(9))
except (ValueError, TypeError):
day_h = None
# Parent body — detect from ↳ prefix
is_moon_row = '↳' in cells[0]
rows.append({
"orbit_label": cells[0].strip(),
"body_id": body_id,
"name": cell(2) if cell(2) != '—' else None,
"body_type": body_type,
"inhabited": cell(4).lower() == 'yes',
"population": cell(5),
"mass_class": cell(6).lower(), # terrestrial / dwarf / gas_giant / ice_giant
"gravity_g": gravity,
"period_days": period,
"day_h": day_h,
"atmosphere": cell(10).lower(),
"biome": cell(11).lower(),
"hydrosphere": cell(12).lower(),
"economy": cell(13),
"settlement": cell(14),
"industrial": cell(15),
"is_moon_row": is_moon_row,
})
return rows
# ---------------------------------------------------------------------------
# Body definition builder
# ---------------------------------------------------------------------------
def _build_body_def(
row: dict,
star: dict,
system_id: str,
overrides: dict,
parent_is_giant: bool = False,
) -> Optional[dict]:
"""
Convert one bodies table row into a body_definition dict.
overrides: per-body override dict (keyed by body_id).
Returns None for bodies that don't need a render (asteroid belts etc).
"""
bid = row["body_id"]
btype = row["body_type"]
mass = row["mass_class"]
biome = row["biome"]
hydro = row["hydrosphere"]
atmo = row["atmosphere"]
period = row["period_days"]
gravity = row["gravity_g"]
star_type = star["type"]
ov = overrides.get(bid, {}) # per-body override dict
# ── Planet class ──────────────────────────────────────────────────────
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
planet_class = "gas_giant"
else:
planet_class = PLANET_CLASS_MAP.get(biome, "temperate")
if biome and biome not in PLANET_CLASS_MAP and biome != "—":
log.warning(f" {bid}: unknown planet class '{biome}' — defaulting to temperate")
planet_class = ov.get("planet_class", planet_class)
# ── Body scale ────────────────────────────────────────────────────────
body_scale = "moon" if row["is_moon_row"] or mass == "dwarf" else "planet"
body_scale = ov.get("body_scale", body_scale)
# ── Seed — deterministic from body ID ─────────────────────────────────
seed = _seed_from_id(bid)
seed = ov.get("seed", seed)
# ── Orbital distance ──────────────────────────────────────────────────
distance_au = _derive_distance_au(period, star_type)
# ── Axial tilt ────────────────────────────────────────────────────────
tilt_ov = (ov.get("orbit", {}) or {}).get("axial_tilt_deg", "rand")
if tilt_ov != "rand":
axial_tilt = float(tilt_ov)
elif _is_tidally_locked(period, star_type) and not parent_is_giant:
axial_tilt = _rand_range(bid, 0, 5, "tilt")
elif planet_class in CLASS_TILT:
lo, hi = CLASS_TILT[planet_class]
axial_tilt = _rand_range(bid, lo, hi, "tilt")
else:
axial_tilt = _rand_range(bid, 5, 35, "tilt")
# ── Atmosphere density ────────────────────────────────────────────────
atmo_density = ATMO_MAP.get(atmo, "none")
atmo_density = ov.get("atmosphere_density", atmo_density)
# ── Atmosphere colour — from star type + planet class ─────────────────
atmo_colors = {
"temperate": [0.45, 0.65, 1.00],
"temperate_terminator": [0.45, 0.65, 1.00],
"oceanic": [0.40, 0.60, 1.00],
"forest": [0.42, 0.68, 0.80],
"arid": [0.90, 0.72, 0.50],
"cold_arid": [0.82, 0.58, 0.40],
"hot_arid": [0.90, 0.72, 0.50],
"tropical": [0.42, 0.68, 0.80],
"boreal": [0.45, 0.65, 1.00],
"frozen": [0.75, 0.88, 1.00],
"volcanic": [0.55, 0.40, 0.30],
"barren": None,
}
atmo_color = atmo_colors.get(planet_class)
if atmo_density == "none":
atmo_color = None # no atmosphere = no rim glow
atmo_color = ov.get("atmosphere_color", atmo_color)
# ── Land fraction ─────────────────────────────────────────────────────
land_ov = (ov.get("terrain", {}) or {}).get("land_fraction", "rand")
if land_ov != "rand":
land_fraction = float(land_ov)
else:
lo, hi = HYDRO_LAND.get(hydro, (0.90, 0.99))
land_fraction = _rand_range(bid, lo, hi, "land")
# ── Polar ice latitude ────────────────────────────────────────────────
ice_ov = (ov.get("terrain", {}) or {}).get("polar_ice_lat", "rand")
if ice_ov != "rand":
polar_ice_lat = float(ice_ov)
else:
lo, hi = CLASS_POLAR_ICE.get(planet_class, (0.80, 0.95))
# High axial tilt → ice caps extend further toward equator
tilt_factor = (axial_tilt / 90.0) * 0.3
lo = max(0.05, lo - tilt_factor)
hi = max(0.10, hi - tilt_factor)
polar_ice_lat = _rand_range(bid, lo, hi, "ice")
# ── Tectonics ─────────────────────────────────────────────────────────
tectonic_map = {
"volcanic": "extreme", "temperate": "active",
"temperate_terminator": "active",
"oceanic": "active", "forest": "active",
"arid": "low", "cold_arid": "low",
"hot_arid": "low", "tropical": "active",
"boreal": "active", "frozen": "low", "barren": "none",
}
tectonics = tectonic_map.get(planet_class, "low")
tectonics = ov.get("tectonics", tectonics)
# ── Geothermal flux ───────────────────────────────────────────────────
geothermal = CLASS_GEOTHERMAL.get(planet_class, "low")
# Tidal heating for moons of gas giants
if parent_is_giant:
tidal = _tidal_heating(period, mass, parent_is_giant)
if tidal != "low":
geothermal = tidal
geothermal = ov.get("geothermal_flux", geothermal)
# ── UV index ──────────────────────────────────────────────────────────
uv_index = STAR_UV.get(star_type, "moderate")
# Thin/no atmosphere → UV reaches surface directly
if atmo_density in ("none", "thin"):
uv_map = {"low": "moderate", "moderate": "high", "high": "extreme"}
uv_index = uv_map.get(uv_index, uv_index)
uv_index = ov.get("uv_index", uv_index)
# ── Substrate ─────────────────────────────────────────────────────────
substrate_map = {
"volcanic": "sulfuric",
"arid": "silicate",
"cold_arid": "silicate",
"hot_arid": "silicate",
"tropical": "silicate",
"boreal": "silicate",
"frozen": "ice",
"barren": "silicate",
"temperate": "silicate",
"temperate_terminator": "silicate",
"oceanic": "silicate",
"forest": "silicate",
}
substrate = substrate_map.get(planet_class, "silicate")
if hydro == "subsurface" and planet_class == "frozen":
substrate = "ice"
substrate = ov.get("substrate", substrate)
# ── Chemosynthetic modifier ───────────────────────────────────────────
# Europa case: frozen + subsurface + tidal heating → chemosynthetic
chemosynthetic = False
if hydro == "subsurface" and geothermal in ("moderate", "high", "extreme"):
chemosynthetic = True
chemosynthetic = ov.get("chemosynthetic", chemosynthetic)
# ── Oblateness ────────────────────────────────────────────────────────
oblat_lo, oblat_hi = CLASS_OBLATENESS.get(planet_class, (0.001, 0.005))
oblateness = _rand_range(bid, oblat_lo, oblat_hi, "oblat")
if btype == "gas_giant" or mass in ("gas_giant", "ice_giant"):
oblateness = _rand_range(bid, 0.050, 0.090, "oblat")
oblateness = ov.get("oblateness", oblateness)
# ── Clouds ────────────────────────────────────────────────────────────
clouds_enabled = atmo_density in CLOUD_CAPABLE and planet_class != "barren"
if planet_class == "barren":
clouds_enabled = False
cld_ov = ov.get("clouds", {}) or {}
clouds_enabled = cld_ov.get("enabled", clouds_enabled)
coverage_ov = cld_ov.get("coverage_base", "rand")
if coverage_ov != "rand":
coverage = float(coverage_ov)
else:
lo, hi = CLASS_CLOUD.get(planet_class, (0.10, 0.40))
coverage = _rand_range(bid, lo, hi, "cloud")
# ── Gas giant specific ────────────────────────────────────────────────
gas_giant_cfg = None
rings_cfg = None
if planet_class == "gas_giant":
palette_ov = (ov.get("gas_giant", {}) or {}).get("band_palette", "rand")
if palette_ov == "rand":
palette = _rand_choice(bid, GAS_PALETTES, "palette")
else:
palette = palette_ov
storm_count = int(_rand_range(bid, 1, 5, "storms"))
storm_count = (ov.get("gas_giant", {}) or {}).get("storm_count", storm_count)
storm_size = _rand_range(bid, 0.06, 0.14, "storm_sz")
storm_size = (ov.get("gas_giant", {}) or {}).get("storm_max_size", storm_size)
gas_giant_cfg = {
"band_palette": palette,
"storm_count": storm_count,
"storm_max_size": round(float(storm_size), 3),
}
# Rings
rings_ov = ov.get("rings", "rand")
if rings_ov == "rand":
has_rings = _rand_bool(bid, RING_PROBABILITY, "rings")
elif isinstance(rings_ov, dict):
has_rings = rings_ov.get("enabled", True)
else:
has_rings = bool(rings_ov)
if has_rings:
planet_class = "gas_giant_ringed"
r_inner = round(_rand_range(bid, 1.08, 1.25, "r_inner"), 2)
r_outer = round(_rand_range(bid, 2.20, 2.80, "r_outer"), 2)
opacity = round(_rand_range(bid, 0.45, 0.72, "r_opa"), 2)
rcolor = RING_COLOURS.get(palette, [0.75, 0.70, 0.60])
# Merge any explicit ring overrides
if isinstance(rings_ov, dict):
r_inner = rings_ov.get("inner_radius_factor", r_inner)
r_outer = rings_ov.get("outer_radius_factor", r_outer)
opacity = rings_ov.get("opacity_base", opacity)
rcolor = rings_ov.get("ring_color", rcolor)
rings_cfg = {
"enabled": True,
"inner_radius_factor": r_inner,
"outer_radius_factor": r_outer,
"opacity_base": opacity,
"ring_color": rcolor,
}
# ── Render config ─────────────────────────────────────────────────────
render_cfg = dict(RENDER_DEFAULTS)
render_cfg["specular_ocean"] = hydro in ("ocean", "liquid_water", "rivers")
if planet_class in ("barren", "arid", "volcanic"):
render_cfg["specular_ocean"] = False
render_cfg.update(ov.get("render", {}))
# ── Assemble ──────────────────────────────────────────────────────────
body_def = {
"id": bid,
"name": row["name"],
"body_type": btype,
"planet_class": planet_class,
"body_scale": body_scale,
"seed": seed,
"star": star,
"orbit": {
"distance_au": round(distance_au, 3),
"period_days": period,
"axial_tilt_deg": round(axial_tilt, 1),
},
"physical": {
"gravity_g": gravity,
"oblateness": round(oblateness, 4),
"atmosphere": atmo_density,
"atmosphere_color": atmo_color,
},
"terrain": {
"land_fraction": round(land_fraction, 3),
"polar_ice_lat": round(polar_ice_lat, 3),
"tectonics": tectonics,
},
"environment": {
"geothermal_flux": geothermal,
"uv_index": uv_index,
"substrate": substrate,
"chemosynthetic": chemosynthetic,
"hydrosphere": hydro,
},
"clouds": {
"enabled": bool(clouds_enabled),
"coverage_base": round(coverage, 3),
},
"render": render_cfg,
}
# Gas giants: drop meaningless terrain fields
if gas_giant_cfg:
body_def.pop("terrain", None)
body_def["gas_giant"] = gas_giant_cfg
if rings_cfg:
body_def["rings"] = rings_cfg
# Wiki cultural data — not used by the generator, carried for the
# body index.md template and downstream pipelines.
pop_raw = row.get("population", "—")
body_def["wiki"] = {
"inhabited": row.get("inhabited", False),
"population": pop_raw if pop_raw not in ("—", "", None) else None,
"economy": row.get("economy") if row.get("economy") not in ("—", "", None) else None,
"settlement": row.get("settlement") if row.get("settlement") not in ("—", "", None) else None,
"industrial": row.get("industrial") if row.get("industrial") not in ("—", "", None) else None,
}
return body_def
# ---------------------------------------------------------------------------
# System parser — top-level entry
# ---------------------------------------------------------------------------
def parse_system(
md_path: str,
overrides: dict = None,
out_dir: str = None,
) -> list[dict]:
"""
Parse a system index.md and return list of body_definition dicts.
Optionally write one JSON file per body into out_dir.
overrides: { body_id: { field: value, ... } }
"""
overrides = overrides or {}
md_text = Path(md_path).read_text(encoding="utf-8")
# Extract system ID from first header
sys_match = re.search(r'\*\*([A-Z0-9 ]+)\*\*', md_text)
system_id = sys_match.group(1).replace(" ", "_") if sys_match else "UNKNOWN"
# Parse star
star = _parse_star(md_text)
log.info(f"System: {system_id} Star: {star['type']}-type "
f"L={star['luminosity_solar']:.3g} Lsun")
# Parse bodies table
rows = _parse_bodies_table(md_text)
log.info(f"Found {len(rows)} renderable bodies")
# Track which bodies are moons of gas giants (for tidal heating)
# Simple heuristic: if the previous non-moon row was a gas_giant, this is its moon
last_giant = False
body_defs = []
for row in rows:
bid = row["body_id"]
btype = row["body_type"]
mass = row["mass_class"]
is_giant = btype == "gas_giant" or mass in ("gas_giant", "ice_giant")
# Determine if this moon orbits a gas giant
parent_is_giant = row["is_moon_row"] and last_giant
if not row["is_moon_row"]:
last_giant = is_giant
# Build definition
body_def = _build_body_def(
row, star, system_id, overrides,
parent_is_giant=parent_is_giant,
)
if body_def is None:
continue
body_defs.append(body_def)
log.info(f" {bid:20s} {body_def['planet_class']:20s} "
f"scale={body_def['body_scale']:6s} "
f"seed={body_def['seed']}")
_check_habitability(body_def)
# Write output files
if out_dir:
os.makedirs(out_dir, exist_ok=True)
for bd in body_defs:
out_path = os.path.join(out_dir, f"{bd['id']}_def.json")
with open(out_path, "w") as f:
json.dump(bd, f, indent=2)
log.info(f"Wrote {len(body_defs)} body definitions → {out_dir}/")
return body_defs
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Parse system index.md → body_definition.json files"
)
parser.add_argument("md_file", help="Path to system index.md")
parser.add_argument("--out-dir", default="./body_defs",
help="Output directory for JSON files (default: ./body_defs)")
parser.add_argument("--overrides", default=None,
help="Path to JSON overrides file (optional)")
parser.add_argument("--print", action="store_true",
help="Print all body definitions to stdout")
args = parser.parse_args()
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
if args.print:
print(json.dumps(defs, indent=2))