Files
settled-reach/tooling/domains/atlas/planet/generate.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

294 lines
13 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.
#!/usr/bin/env python3
"""
Planet Generator — CLI entry point.
Two input modes:
1. Body definition JSON: generate body_def.json --output-dir ./output
2. System index.md: generate --system wiki/star-systems/GJ-144/index.md
Outputs per body into {output-dir}/{body_id}/:
heightmap.png — clean equirectangular cartographic map (no chrome)
globe.png — 512×512 sphere render
body.json — body descriptor + rendering metadata
terrain.npz — compressed terrain grids for downstream generators
rivers.json — river polylines in grid coords
Optional (spike/review only):
heightmap_chrome.png — heightmap with title bar + legend overlay
"""
import argparse
import json
import os
import time
from pathlib import Path
import numpy as np
from tooling.core import config, console
from tooling.core.errors import ReachError
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
def _build_markers(body_def: dict, terrain: dict) -> dict:
"""Extract geographic markers from terrain data."""
from scipy.ndimage import label, center_of_mass
grid_h = terrain["_grid_h"]
grid_w = terrain["_grid_w"]
sea_level = terrain["sea_level"]
elevation = terrain["elevation"]
surface_water = terrain["surface_water"]
markers = {
"grid": {"w": grid_w, "h": grid_h},
"rivers": [],
"oceans": [],
"mountain_ranges": [],
"roads": [],
"cities": [],
"railroads": [],
"pois": [],
}
# ── Rivers ───────────────────────────────────────────────────────────
for i, path in enumerate(terrain.get("rivers", [])):
markers["rivers"].append({
"id": f"river_{i}",
"name": None, # named by copy team or procedural namer
"path": path,
})
# ── Oceans / seas ────────────────────────────────────────────────────
# Label connected water bodies and record their center + area
if surface_water.any():
water_labels, n_bodies = label(surface_water)
total_cells = grid_h * grid_w
for lbl in range(1, n_bodies + 1):
mask = water_labels == lbl
area_cells = int(mask.sum())
area_frac = area_cells / total_cells
if area_frac < 0.005:
continue # skip tiny puddles
cy, cx = center_of_mass(mask)
kind = "ocean" if area_frac > 0.10 else "sea" if area_frac > 0.02 else "lake"
markers["oceans"].append({
"id": f"water_{lbl}",
"name": None,
"kind": kind,
"center": [int(cy), int(cx)],
"area_fraction": round(area_frac, 4),
})
# ── Mountain ranges ──────────────────────────────────────────────────
# High-elevation connected regions on land
land = ~surface_water
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
mountains = land & (elev_norm > 0.55)
if mountains.any():
mtn_labels, n_ranges = label(mountains)
for lbl in range(1, n_ranges + 1):
mask = mtn_labels == lbl
area = int(mask.sum())
if area < 20:
continue # skip tiny peaks
cy, cx = center_of_mass(mask)
# Find the ridge line: cells with highest elevation in the range
ys, xs = np.where(mask)
peak_idx = np.argmax(elevation[ys, xs])
markers["mountain_ranges"].append({
"id": f"range_{lbl}",
"name": None,
"center": [int(cy), int(cx)],
"peak": [int(ys[peak_idx]), int(xs[peak_idx])],
"area_cells": area,
})
return markers
def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
globe_size: int, render_mode: str, output_dir: str,
chrome: bool = False):
"""Generate all outputs for a single body definition."""
body_id = body_def["id"]
if body_def.pop("_flat_output", False):
body_dir = output_dir # output directly, no body_id subdir
else:
body_dir = os.path.join(output_dir, body_id)
os.makedirs(body_dir, exist_ok=True)
planet_class = body_def.get("planet_class", "unknown")
name = body_def.get("name") or body_id
console.event(f" {body_id} ({name}) — {planet_class}")
t0 = time.time()
# ── 1. Simulate ──────────────────────────────────────────────────────
terrain = simulate(body_def)
is_gas = not terrain
t_sim = time.time()
if is_gas:
console.event(f" simulate: gas giant ({t_sim - t0:.1f}s)")
else:
console.event(f" simulate: {t_sim - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())}")
# ── 2. Render heightmap ──────────────────────────────────────────────
t_hmap = t_sim
if not is_gas:
# Clean heightmap (no title/legend)
hmap_img = render_heightmap(body_def, terrain,
out_w=hmap_w, out_h=hmap_h,
render_mode=render_mode, chrome=False)
hmap_img.save(os.path.join(body_dir, "heightmap.png"))
# Chrome version for review (optional — saved to /tmp, not shipped)
if chrome:
hmap_chrome = render_heightmap(body_def, terrain,
out_w=hmap_w, out_h=hmap_h,
render_mode=render_mode, chrome=True)
chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
hmap_chrome.save(chrome_path)
console.event(f" chrome: {chrome_path}")
t_hmap = time.time()
console.event(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
# ── 3. Render globe ──────────────────────────────────────────────────
try:
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(body_def, terrain, size=globe_size)
globe_img.save(os.path.join(body_dir, "globe.png"))
t_globe = time.time()
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
except Exception as e:
console.event(f" globe: FAILED — {e}")
t_globe = time.time()
# ── 4. Write data files ──────────────────────────────────────────────
# Body definition lives in the index.md frontmatter — no body.json needed.
if not is_gas:
# terrain.npz — grids for downstream generators
save_dict = {}
for key in ("elevation", "temperature", "moisture", "hillshade",
"biome", "surface_water", "river_grid"):
if key in terrain:
save_dict[key] = terrain[key]
save_dict["sea_level"] = np.array([terrain["sea_level"]])
np.savez_compressed(os.path.join(body_dir, "terrain.npz"), **save_dict)
# markers.json — named geographic and cultural features.
markers = _build_markers(body_def, terrain)
with open(os.path.join(body_dir, "markers.json"), "w") as f:
json.dump(markers, f, indent=2)
elapsed = time.time() - t0
console.event(f" total: {elapsed:.1f}s → {body_dir}/")
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Planet generator — heightmap + globe from body definitions")
# Input modes
parser.add_argument("body_def", nargs="?",
help="Path to body definition JSON file")
parser.add_argument("--system",
help="Path to system index.md — generates all bodies")
parser.add_argument("--overrides",
help="Path to per-body overrides JSON (used with --system)")
# Output
parser.add_argument("--output-dir", default=".",
help="Root output directory (bodies get subdirs)")
# Rendering
parser.add_argument("--heightmap-size", default="1024x512",
help="Heightmap output resolution (WxH)")
parser.add_argument("--globe-size", type=int, default=512,
help="Globe output resolution (square, locked at 512)")
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
default="cartographic")
parser.add_argument("--chrome", action="store_true",
help="Also render heightmap with title/legend (review only, not shipped)")
args = parser.parse_args(argv)
# Parse heightmap size
try:
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
except ValueError:
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
# ── Collect body definitions ─────────────────────────────────────────
body_defs = []
if args.system:
# Read from system index.md → parse bodies table
from tooling.domains.atlas.planet.body_definition_parser import parse_system
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
body_defs = parse_system(args.system, overrides=overrides)
console.event(f"System: {args.system} — {len(body_defs)} bodies")
elif args.body_def:
input_path = args.body_def
if input_path.endswith(".json"):
with open(input_path) as f:
body_defs = [json.load(f)]
elif input_path.endswith(".md"):
# Read body definition from frontmatter
import yaml
with open(input_path) as f:
content = f.read()
if content.startswith("---"):
fm_end = content.index("---", 3)
fm = yaml.safe_load(content[3:fm_end])
if "id" in fm and "planet_class" in fm:
body_defs = [fm]
fm["_flat_output"] = True # no body_id subdir
# Output alongside the body index.md if no --output-dir
if args.output_dir == ".":
args.output_dir = str(Path(input_path).parent)
else:
raise ReachError(f"{input_path} frontmatter missing 'id' or 'planet_class'", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
raise ReachError(f"{input_path} has no YAML frontmatter", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
raise ReachError(f"unrecognized input format: {input_path}", fix="pass a body definition .json or a body index.md")
else:
parser.error("Provide a body_def (.json or .md) or --system index.md")
# ── Generate ─────────────────────────────────────────────────────────
t_total = time.time()
for bd in body_defs:
_generate_body(bd, hmap_w, hmap_h, args.globe_size,
args.render_mode, args.output_dir, chrome=args.chrome)
elapsed = time.time() - t_total
console.event(f" All done: {len(body_defs)} bodies in {elapsed:.1f}s")
if __name__ == "__main__":
main()