Files
settled-reach/tooling/domains/atlas/planet/body_definition_parser.py
T
jpmschweitzerandClaude Opus 5.5 6fb0ba0e3d chore(tooling): T-1272 + T-1274 — close E3: no hyphens left, and the lint ignores come off
T-1272 (a verification, as rescoped). No directory Python imports carries a
hyphen any more. The hyphenated script trees were emptied by the per-domain
moves, not renamed. What still has a hyphen is never imported: the three Rust
crates, and the provenance under tooling/archive/, which has no __init__.py.
CLAUDE.md and DEVOPS still pointed at tooling/db/, and pyproject still
predicted the rename; all three fixed.

T-1274. E402, E702 and F841 were ignored for the whole tree from T-1066 on
(43 / 41 / 21 violations). All three are back on:

- E402: the planet modules' imports only sat below their path constants
  because they used to follow a sys.path insert, gone since T-1288. Hoisted.
  The Blender payloads keep a per-file exception, because they extend
  sys.path under Blender's own Python.
- E702: the paired component assignments in three planet maths files are
  deliberate, so they get a per-file exception scoped to those files.
- F841: 10 dead locals removed from live code, each checked for side effects
  first; logo_uv keeps its call, which creates the UV layer.
- tooling/archive/ is excluded: it is provenance, and "fixing" a one-shot
  falsifies the record of what actually ran.

Evidence the lint is real: violations fed through stdin fire in a domain
module, and E402 stays quiet only on a payload path. Evidence nothing moved:
globe renders are pixel-identical before and after for an oceanic, a frozen
and a gas-giant body, and the ledger edit was regenerated (stamp fresh,
generated_brands.toml unchanged).

One finding, noted in the code rather than fixed: planet_renderer computed an
oblate-spheroid ray scale and never used it, so `oblateness` shapes no globe.
Wiring it in would change every globe render; that is a decision to make
deliberately, not a lint fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:05:07 +02:00

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