diff --git a/tooling/planet-gen/batch.py b/tooling/planet-gen/batch.py index 21360ac7e..cb7d60db6 100644 --- a/tooling/planet-gen/batch.py +++ b/tooling/planet-gen/batch.py @@ -96,7 +96,7 @@ def _read_frontmatter(md_path: Path) -> dict: # Validation (dry-run) # ───────────────────────────────────────────────────────────────────────────── -REQUIRED_FIELDS = ["id", "planet_class", "seed", "star", "orbit", "physical", "terrain"] +REQUIRED_FIELDS = ["id", "planet_class", "seed", "star", "orbit", "physical"] REQUIRED_STAR = ["type", "luminosity_solar"] REQUIRED_ORBIT = ["distance_au", "period_days", "axial_tilt_deg"] REQUIRED_PHYSICAL = ["gravity_g", "atmosphere"] @@ -130,25 +130,28 @@ def _validate_body_def(bd: dict, body_dir: Path) -> list: if f not in phys: errors.append(f"{bid}: missing physical.{f}") - terrain = bd.get("terrain", {}) - for f in REQUIRED_TERRAIN: - if f not in terrain: - errors.append(f"{bid}: missing terrain.{f}") - - lf = terrain.get("land_fraction") - if lf is not None and (lf < 0 or lf > 1): - errors.append(f"{bid}: terrain.land_fraction = {lf} — must be [0, 1]") - seed = bd.get("seed") if seed is None or not isinstance(seed, int): errors.append(f"{bid}: seed must be an integer, got {seed!r}") pclass = bd.get("planet_class", "") valid_classes = {"temperate", "oceanic", "forest", "arid", "frozen", - "volcanic", "barren", "gas_giant", "gas_giant_ringed"} + "volcanic", "barren", "gas_giant", "gas_giant_ringed", + "cold_arid", "hot_arid", "tropical", "boreal", + "temperate_terminator"} if pclass not in valid_classes: errors.append(f"{bid}: planet_class '{pclass}' not in {valid_classes}") + is_gas = pclass in ("gas_giant", "gas_giant_ringed") + terrain = bd.get("terrain", {}) + if not is_gas: + for f in REQUIRED_TERRAIN: + if f not in terrain: + errors.append(f"{bid}: missing terrain.{f}") + lf = terrain.get("land_fraction") + if lf is not None and (lf < 0 or lf > 1): + errors.append(f"{bid}: terrain.land_fraction = {lf} — must be [0, 1]") + return errors diff --git a/tooling/planet-gen/body_definition_parser.py b/tooling/planet-gen/body_definition_parser.py index 40dafc83f..bdee957f1 100644 --- a/tooling/planet-gen/body_definition_parser.py +++ b/tooling/planet-gen/body_definition_parser.py @@ -142,47 +142,67 @@ PLANET_CLASS_MAP = { # 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), + "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", - "oceanic": "low", - "forest": "low", - "arid": "low", - "frozen": "low", - "barren": "low", + "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), - "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), + "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), - "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), + "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 @@ -190,13 +210,18 @@ from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES # planet_class → cloud coverage base range CLASS_CLOUD = { - "temperate": (0.35, 0.55), - "oceanic": (0.55, 0.75), - "forest": (0.40, 0.60), - "arid": (0.05, 0.20), - "frozen": (0.20, 0.45), - "volcanic": (0.60, 0.85), - "barren": (0.00, 0.05), + "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 @@ -219,6 +244,7 @@ RING_COLOURS = { "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 } # --------------------------------------------------------------------------- @@ -487,13 +513,18 @@ def _build_body_def( # ── 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, + "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": @@ -523,8 +554,11 @@ def _build_body_def( # ── Tectonics ───────────────────────────────────────────────────────── tectonic_map = { "volcanic": "extreme", "temperate": "active", + "temperate_terminator": "active", "oceanic": "active", "forest": "active", - "arid": "low", "frozen": "low", "barren": "none", + "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) @@ -548,13 +582,18 @@ def _build_body_def( # ── Substrate ───────────────────────────────────────────────────────── substrate_map = { - "volcanic": "sulfuric", - "arid": "silicate", - "frozen": "ice", - "barren": "silicate", - "temperate":"silicate", - "oceanic": "silicate", - "forest": "silicate", + "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": @@ -695,8 +734,9 @@ def _build_body_def( "render": render_cfg, } - # Gas giant extras + # 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 @@ -776,6 +816,7 @@ def parse_system( 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: @@ -786,7 +827,6 @@ def parse_system( json.dump(bd, f, indent=2) log.info(f"Wrote {len(body_defs)} body definitions → {out_dir}/") - _check_habitability(body_def) return body_defs diff --git a/tooling/planet-gen/render_heightmap.py b/tooling/planet-gen/render_heightmap.py index 05de78101..0e71eb7b1 100644 --- a/tooling/planet-gen/render_heightmap.py +++ b/tooling/planet-gen/render_heightmap.py @@ -1,7 +1,7 @@ """ render_heightmap.py ------------------- -Renders a 4096×2048 annotated equirectangular heightmap PNG from a terrain dict. +Renders an 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. @@ -54,7 +54,7 @@ from biome_config import ( OUT_W = 1024 OUT_H = 512 -UI_SCALE = OUT_W / 1024 # 4.0 — all pixel sizes scale with this +UI_SCALE = OUT_W / 1024 # 1.0 at default 1024 — all pixel sizes scale with this # --------------------------------------------------------------------------- # Biome colour palette @@ -327,9 +327,9 @@ def _biome_legend_items(terrain: dict) -> list: LABELS = { 0: "ocean deep", 1: "ocean", 2: "coastal water", - 3: "coast", 5: "rainforest", 6: "trop. forest", + 3: "coast", 5: "trop. rainforest", 6: "trop. forest", 7: "savanna", 8: "grassland", 9: "forest", - 10: "rainforest", 11: "boreal", 12: "shrubland", + 10: "temp. 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", @@ -365,12 +365,13 @@ def _render_legend(img: Image.Image, terrain: dict) -> Image.Image: 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) + # Scale step to fit all items within image width + max_items = len(items) + max_step = (OUT_W - 2 * pad_x) // max(max_items, 1) + step = min(int(108 * UI_SCALE), max_step) 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) @@ -452,15 +453,15 @@ if __name__ == "__main__": import sys, json, time if len(sys.argv) < 2: - print("Usage: python3 render_heightmap.py body_def.json [--small]") + print("Usage: python3 render_heightmap.py body_def.json [--large]") 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) + # --large flag renders at 4096×2048 for high-res review + large = "--large" in sys.argv + w, h = (4096, 2048) if large else (OUT_W, OUT_H) from planet_simulation import simulate diff --git a/tooling/planet-gen/scaffold_bodies.py b/tooling/planet-gen/scaffold_bodies.py index a2ed15434..479ebc279 100644 --- a/tooling/planet-gen/scaffold_bodies.py +++ b/tooling/planet-gen/scaffold_bodies.py @@ -61,7 +61,7 @@ def _body_prose(bd: dict, system_dir: Path) -> str: """Generate markdown content below the frontmatter.""" name = bd.get("name") or bd.get("id") bid = bd["id"] - pclass = bd.get("planet_class", "unknown").replace("_ringed", "") + pclass = bd.get("planet_class", "unknown") btype = bd.get("body_type", "planet") wiki = bd.get("wiki", {}) phys = bd.get("physical", {}) @@ -76,11 +76,11 @@ def _body_prose(bd: dict, system_dir: Path) -> str: # Type line if btype == "moon": - lines.append(f"{pclass.title()} moon.") + lines.append(f"{pclass.replace('_', ' ').title()} moon.") elif pclass in ("gas_giant", "gas_giant_ringed"): lines.append(f"Gas giant.") else: - lines.append(f"{pclass.title()} {btype}.") + lines.append(f"{pclass.replace('_', ' ').title()} {btype}.") lines.append("") # System link @@ -92,7 +92,7 @@ def _body_prose(bd: dict, system_dir: Path) -> str: lines.append("") lines.append(f"![Globe](globe.png)") lines.append("") - if pclass not in ("gas_giant",): + if pclass not in ("gas_giant", "gas_giant_ringed"): lines.append(f"![Heightmap](heightmap.png)") lines.append("")