D-258 rulings 5 and 6: the pre-seed biome input is class ids only, on two maps, because compute_biome was already computing them apart and throwing one away. It gave every land pixel its Whittaker (climate) class, then painted terrain over it (ocean bands, ice, altitude snow, mountain rock, lava and ash, the dry, lunar and ferric ground). planet_simulation: - compute_biome_layers returns (biome, terrain). Each rule declares the layer it writes, by RULE not by class: ice from the cold climate box is biome, altitude snow is terrain with the lowland biome kept beneath. Rules that place life (thermophiles, mats, crust, the oasis rings) write the biome and clear the terrain under them. - 255 means "nothing on this layer", not 0, because class 0 is ocean_deep, itself terrain. Ids stay literal. - compute_biome is now compose_layers() of the pair, so the renderers are untouched. Proven byte-identical, dtype included, on 65 real bodies: every 8th standard-atmosphere body plus every thin, thick, reducing and trace one, captured before the edit and compared after it. - simulate() carries both maps as biome_layer / terrain_layer. reach atlas planet bake-biome [--body --limit --dry-run --check]: - Writes biomemap.png + terrainmap.png (8-bit, 1024x512, tEXt: layer, none, classes, decision) beside each heightmap. Scope is the heightmap bake set with an atmosphere (257 bodies; airless are deferred, per D-258). - Pre-seed by construction: simulate() is keyed on the body frontmatter's seed, and no world seed is accepted anywhere. - --check re-simulates and compares DECODED PIXELS, never bytes, since PNG encoding drifts across Pillow/zlib versions (T-1291) and a check that trips on that gets muted. Mutation-proven: one flipped pixel exits 1 and names the file; the restored file exits 0. - import_heightmaps' body lookup is extracted as body_def_for and shared, not copied. Tests (make test-tooling): rock keeps its biome beneath, thin-atmosphere ground is terrain with only scattered life, water is terrain only, no pixel is empty on both maps, and stacking equals the rendered grid. Routing rock through the biome map fails two of them by name. The router drift test covers the new verb. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""Transport for `atlas planet` — args in, delegate, format out.
|
||
|
||
Every verb here declares its options explicitly rather than forwarding an
|
||
opaque argument list. That is deliberate and it is the more expensive option:
|
||
the underlying modules already parse their own arguments, so this restates ten
|
||
surfaces that exist elsewhere.
|
||
|
||
It is worth it because the primary user of reach is an agent (D-263), and an
|
||
agent discovers a command by reading its `--help`. A passthrough would make
|
||
`reach atlas planet generate --help` describe nothing, and the real surface
|
||
would only be findable by reading the module — which is the fragmentation the
|
||
whole CLI exists to end.
|
||
|
||
The cost is a second place that can drift. `tooling/test_planet_router.py`
|
||
closes that: it hands every declared option to the module's own parser and
|
||
fails if the parser does not recognise it.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import typer
|
||
|
||
from tooling.core import cli
|
||
from tooling.core.command import command
|
||
|
||
app = cli.domain("planet", "Bodies — scaffold, generate, import and audit.")
|
||
|
||
|
||
@app.callback()
|
||
def _rung() -> None:
|
||
"""Keeps `planet` a group (Typer collapses a single-command app)."""
|
||
|
||
|
||
def _flags(**pairs: object) -> list[str]:
|
||
"""Turn declared options into the argv the module's parser expects.
|
||
|
||
None means "not given" and is dropped, so the module's own defaults stay
|
||
authoritative — restating them here would be a second source of truth for
|
||
every default in the domain. A list repeats the flag, for the parsers that
|
||
declare `action="append"`.
|
||
"""
|
||
argv: list[str] = []
|
||
for name, value in pairs.items():
|
||
flag = "--" + name.replace("_", "-")
|
||
if value is None or value is False:
|
||
continue
|
||
if value is True:
|
||
argv.append(flag)
|
||
elif isinstance(value, (list, tuple)):
|
||
for item in value:
|
||
argv += [flag, str(item)]
|
||
else:
|
||
argv += [flag, str(value)]
|
||
return argv
|
||
|
||
|
||
# --- generation -----------------------------------------------------------
|
||
|
||
|
||
@app.command("generate")
|
||
@command
|
||
def generate(
|
||
body_def: Path = typer.Argument(..., help="Body definition JSON."),
|
||
system: str = typer.Option(None, "--system", help="System id to generate for."),
|
||
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
|
||
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
|
||
heightmap_size: str = typer.Option(
|
||
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
|
||
),
|
||
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
|
||
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
|
||
chrome: bool = typer.Option(False, "--chrome", help="Draw chrome on the render."),
|
||
) -> None:
|
||
"""Generate one body — simulate, render the heightmap, write the globe."""
|
||
from tooling.domains.atlas.planet import generate as impl
|
||
|
||
impl.main([
|
||
str(body_def),
|
||
*_flags(
|
||
system=system,
|
||
overrides=overrides,
|
||
output_dir=output_dir,
|
||
heightmap_size=heightmap_size,
|
||
globe_size=globe_size,
|
||
render_mode=render_mode,
|
||
chrome=chrome,
|
||
),
|
||
])
|
||
|
||
|
||
@app.command("batch")
|
||
@command
|
||
def batch(
|
||
system: str = typer.Option(None, "--system", help="Limit to one system."),
|
||
body: str = typer.Option(None, "--body", help="Limit to one body."),
|
||
scaffold_only: bool = typer.Option(False, "--scaffold-only", help="Scaffold, do not generate."),
|
||
generate_only: bool = typer.Option(False, "--generate-only", help="Generate, do not scaffold."),
|
||
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
|
||
force: bool = typer.Option(False, "--force", help="Regenerate what already exists."),
|
||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
|
||
verify_determinism: int = typer.Option(
|
||
None, "--verify-determinism", metavar="N",
|
||
help="Generate N random bodies twice and verify the output is identical.",
|
||
),
|
||
heightmap_size: str = typer.Option(
|
||
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
|
||
),
|
||
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
|
||
) -> None:
|
||
"""Generate every system unattended — the long one; consider --detach."""
|
||
from tooling.domains.atlas.planet import batch as impl
|
||
|
||
impl.main(_flags(
|
||
system=system,
|
||
body=body,
|
||
scaffold_only=scaffold_only,
|
||
generate_only=generate_only,
|
||
overrides=overrides,
|
||
force=force,
|
||
dry_run=dry_run,
|
||
verify_determinism=verify_determinism,
|
||
heightmap_size=heightmap_size,
|
||
globe_size=globe_size,
|
||
))
|
||
|
||
|
||
@app.command("scaffold")
|
||
@command
|
||
def scaffold(
|
||
system_index: Path = typer.Argument(..., help="System index JSON."),
|
||
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
|
||
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
|
||
) -> None:
|
||
"""Write body definitions for a system, ready for `generate`."""
|
||
from tooling.domains.atlas.planet import scaffold_bodies as impl
|
||
|
||
impl.main([str(system_index), *_flags(overrides=overrides, dry_run=dry_run)])
|
||
|
||
|
||
# --- imports into the atlas DB -------------------------------------------
|
||
|
||
|
||
@app.command("import-heightmaps")
|
||
@command
|
||
def import_heightmaps(
|
||
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
|
||
body: str = typer.Option(None, "--body", help="Limit to one body."),
|
||
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
|
||
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
|
||
skip_rename: bool = typer.Option(False, "--skip-rename", help="Do not rename source files."),
|
||
) -> None:
|
||
"""Load heightmap grids into atlas_body_heightmaps.
|
||
|
||
A one-time build import, deliberately NOT part of `make regen-db` and not
|
||
stamped (.claude/rules/asset-pipeline.md). Running it is a decision, not a
|
||
step in the pipeline.
|
||
"""
|
||
from tooling.domains.atlas.planet import import_heightmaps as impl
|
||
|
||
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, skip_rename=skip_rename))
|
||
|
||
|
||
@app.command("bake-biome")
|
||
@command
|
||
def bake_biome(
|
||
db: Path = typer.Option(None, "--db", help="systems.db path (reads the body scope)."),
|
||
body: str = typer.Option(None, "--body", help="Bake only this body."),
|
||
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
|
||
dry_run: bool = typer.Option(False, "--dry-run", help="Count the scope, write nothing."),
|
||
check: bool = typer.Option(
|
||
False, "--check", help="Write nothing; fail if a committed map differs from a fresh bake."
|
||
),
|
||
) -> None:
|
||
"""Bake biomemap.png + terrainmap.png beside each body's heightmap (D-258).
|
||
|
||
Pre-seed: keyed on the body's own seed, never the world's. Scope is the
|
||
heightmap bake set with an atmosphere. A full bake is ~27 minutes;
|
||
`--detach` and tail it. Stage the maps under wiki/star-systems after.
|
||
"""
|
||
from tooling.domains.atlas.planet import bake_biome as impl
|
||
|
||
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, check=check))
|
||
|
||
|
||
@app.command("import-provinces")
|
||
@command
|
||
def import_provinces(
|
||
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
|
||
body: str = typer.Option(None, "--body", help="Limit to one body."),
|
||
force: bool = typer.Option(False, "--force", help="Recompute bodies that already have rows."),
|
||
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
|
||
verbose: bool = typer.Option(False, "--verbose", help="Per-body detail."),
|
||
) -> None:
|
||
"""Derive province boundaries from watershed analysis (D-205, D-208).
|
||
|
||
The other one-time build import — same standing as import-heightmaps.
|
||
Expect ~15–20 minutes for a full run; `--detach` and tail it.
|
||
"""
|
||
from tooling.domains.atlas.planet import import_province_boundaries as impl
|
||
|
||
impl.main(_flags(db=db, body=body, force=force, dry_run=dry_run, verbose=verbose))
|
||
|
||
|
||
@app.command("terrain-reference")
|
||
@command
|
||
def terrain_reference(
|
||
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
|
||
) -> None:
|
||
"""Populate the terrain reference table."""
|
||
from tooling.domains.atlas.planet import populate_terrain_reference as impl
|
||
|
||
impl.main(_flags(db=db))
|
||
|
||
|
||
# --- Sol ------------------------------------------------------------------
|
||
|
||
|
||
@app.command("sol-import")
|
||
@command
|
||
def sol_import(
|
||
body: list[str] = typer.Option(None, "--body", help="Limit to these Sol bodies (repeatable)."),
|
||
download_only: bool = typer.Option(False, "--download-only", help="Fetch source data only."),
|
||
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
|
||
heightmap_size: str = typer.Option(
|
||
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
|
||
),
|
||
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
|
||
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
|
||
) -> None:
|
||
"""Import real Sol bodies from published elevation data."""
|
||
from tooling.domains.atlas.planet import sol_import as impl
|
||
|
||
impl.main(_flags(
|
||
body=body,
|
||
download_only=download_only,
|
||
output_dir=output_dir,
|
||
heightmap_size=heightmap_size,
|
||
globe_size=globe_size,
|
||
render_mode=render_mode,
|
||
))
|
||
|
||
|
||
@app.command("sol-name-fixes")
|
||
@command
|
||
def sol_name_fixes(
|
||
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
|
||
) -> None:
|
||
"""Name the auto-detected features on Sol bodies that were left unnamed."""
|
||
from tooling.domains.atlas.planet import sol_name_fixes as impl
|
||
|
||
impl.main(_flags(dry_run=dry_run))
|
||
|
||
|
||
# --- audits ---------------------------------------------------------------
|
||
|
||
|
||
@app.command("audit")
|
||
@command
|
||
def audit(
|
||
system: str = typer.Option(None, "--system", help="Limit to one system."),
|
||
body: str = typer.Option(None, "--body", help="Limit to one body."),
|
||
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
|
||
) -> None:
|
||
"""Cohesion audit — does the atlas hang together across bodies."""
|
||
from tooling.domains.atlas.planet import atlas_cohesion_audit as impl
|
||
|
||
impl.main(_flags(system=system, body=body, db=db))
|
||
|
||
|
||
@app.command("quality")
|
||
@command
|
||
def quality(
|
||
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
|
||
system: str = typer.Option(None, "--system", help="Limit to one system."),
|
||
body: str = typer.Option(None, "--body", help="Limit to one body."),
|
||
top_collisions: int = typer.Option(None, "--top-collisions", help="How many to list."),
|
||
) -> None:
|
||
"""Quality analysis — name collisions and distribution."""
|
||
from tooling.domains.atlas.planet import atlas_quality_analysis as impl
|
||
|
||
impl.main(_flags(db=db, system=system, body=body, top_collisions=top_collisions))
|