The 30-file tree moves under atlas as its third rung (D-243), ten verbs fronting it. Each verb restates its module's options so `--help` describes something; tooling/test_planet_router.py hands every declared option to the module's own argparse and fails on drift, and now runs in make test-tooling. The 2026-09-02 half of this move had converted the top-level imports and the repo roots. Finishing it found what the half-move left: - Lazy in-function imports, and all of sol_data/, still named siblings bare. They resolved only through sys.path.insert hacks, so under reach the first globe render in generate, batch or sol-import would have raised ModuleNotFoundError. Qualified; the hacks are gone. - 247 print() calls and a stdout progress writer that fired once per 8 KB block. Report verbs (audit, quality) write through console.out, progress through console.event, and download progress is throttled to 10% steps so a job log is not tens of thousands of lines. - Every error exit raises ReachError with a fix. Two checks that could not fail: - batch --verify-determinism printed a warning and exited 0 on a mismatch. - import-provinces exited 0 with errors > 0. Both now raise. The 271-body bake is only safe to re-run because the first one holds. sol-import --body is action="append" in the module but the router took one value, so --body GJ0d --body GJ0e kept one. Now repeatable, and _flags repeats list options. test_conformance walked one level, so a nested group was reported as a verb missing @command and its ten verbs were never checked. It recurses now; proven by stripping @command from `planet quality` and watching it fail. Stray PNGs from the 2026-09-03 runaway router-test run are parked in .cache/t1288-stray-pngs/, not committed. Their reliefmaps differ from HEAD while the heightmap regenerated byte-identical — filed as T-1291. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
262 lines
9.9 KiB
Python
262 lines
9.9 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("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))
|