Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Adding Terrain Sites + Catalog Entries

This guide walks through extending the curated catalog with new terrain sites, HDRI skydomes, rock sets, and lighting presets. The same shape applies to all four; the manifest at srb/terrain/manifest.yaml is the master record.

Adding a new terrain site

  1. Identify the source DEM. Public-domain or compatibly-licensed only (USGS, NASA, ESA HiRISE-derived). Note the URL, expected sha256, the CRS (e.g., IAU_Moon_2015), and the ground sampling distance in meters.

  2. Edit srb/terrain/manifest.yaml. Add an entry:

    sites:
      my_site:
        body: moon  # or mars, earth
        name: "My Site (display name)"
        coords: { lat: 20.5, lon: 30.7 }
        sources:
          dem:
            provider: usgs_astro  # or lroc_pds, pds_geosciences, openplanetary, local
            url: "https://example.org/dem.tif"
            sha256: "<64-char hex>"
            crs: "IAU_Moon_2015"
            gsd_m: 2.0
            license: "Public Domain (NASA/USGS)"
            attribution: "USGS Astrogeology / LROC NAC"
        patches:
          default: { center_xy_m: [256, 256], size_m: 200, lod: medium }
        sky: lunar_default_low      # null if no companion
        lighting: lunar_default      # null to disable
        rocks: lunar_basalt_set      # null to disable
        augment_preset: lunar_default
    
  3. Validate.

    srb asset validate-manifest
    
  4. Test the bake.

    srb asset download terrain my_site
    
  5. Verify the per-site shortcut. srb/assets/scenery/real_terrain_sites.py auto-generates LunarSiteMySite (or MartianSiteMySite) at import time. Smoke-test (run inside the SRB/Isaac Sim Python environment — importing srb.assets.scenery.real_terrain_sites pulls in Isaac Lab; see tests/terrain/unit/test_real_terrain_sites.py for the equivalent gated test):

    from srb.assets.scenery.real_terrain_sites import LunarSiteMySite
    t = LunarSiteMySite()
    assert t.asset_cfg.spawn.usd_path
    

Adding a v2 source layer

Real-terrain v2 layers live in a separate, per-body catalog (srb/terrain/catalog/<body>.yaml, e.g. srb/terrain/catalog/moon.yaml) — not the srb/terrain/manifest.yaml sites above. The catalog is reviewed, pinned data: an entry is added only after the product has been downloaded once, sha256’d, and its footprint reviewed by a human (operator action O3). srb/terrain/catalog/refresh.py can propose candidates from upstream indexes (NAC_DTMS_180.SHP / ODE / USGS STAC), but it only ever writes a separate review file — never srb/terrain/catalog/<body>.yaml itself. Promoting a candidate into the real catalog is a manual, by-hand step.

srb/terrain/catalog/moon.yaml carries 19 real, sha256-pinned layers today; mars.yaml and earth.yaml carry 2 each — see Terrain Stacks → Catalog status per body for the exact list. This is curated coverage of a handful of sites, not a global catalog: a resolve against a footprint none of those layers cover still comes back empty until more entries are added (a site request falls back to the v1 single-source path above; an arbitrary-region request degrades to the procedural terrain in envs and errors clearly on the CLI — see Terrain Stacks). LDAM is now curated as imbrium_ldam_50s_1000m. Its detached PDS3 .LBL/.IMG pair is expressed through SourceLayer.sidecars: each file has its own hash and exact basename, acquisition publishes the bundle atomically, and the loader opens the label so GDAL follows ^IMAGE. The real pair was downloaded, hash/size verified, footprint/radiometry reviewed, and production-baked twice. WAC_EMP 643 nm stays priority 3, ahead of priority-4 LDAM 1064 nm where they overlap; that wavelength difference can create a seam. LDAM’s cap:50S adds colour coverage, but no shipped Moon DEM covers the 50S–60S annulus. The NAC_POLE_SOUTH_CM_065/CM_135 mosaics were blocked on the same list for their per-tile size until 2026-08-02, when the one band-1 tile covering Connecting Ridge was downloaded and pinned per epoch (lroc_nac_pole_south_cm065_p892s2250_1m and its cm135 sibling, 8,276,814,528 B each, allow_large_download: true plus reference_only: true — the latter keeps them out of footprint-driven resolution entirely, so they are never fetched as bake input and are reachable only by naming their layer_id, which is how the §6.11 validation study uses them); the remaining tiles stay unpinned, an operator decision per site. A third, SfS A3CLR, was blocked on the same host-allowlist ground until 2026-08-02, when the allowlist was widened and its Connecting Ridge / Haworth DEM + orthomosaic layers were pinned from the Zenodo archive.

Authoring Mars/Earth entries

Mars and Earth catalog entries follow the exact same schema as Moon ones — there is nothing body-specific about SourceLayer itself. The one authoring choice that differs in practice is footprint:

  • Prefer bbox:<lonmin>,<latmin>,<lonmax>,<latmax> (plain geographic degrees) for non-polar layers — which is most of what Mars and Earth curation will look like (equatorial HiRISE sites, mid-latitude Copernicus/ 3DEP tiles). See Terrain Stacks → Footprints: the bbox: form for the exact parsing/normalization/antimeridian rules. Accepts the 0–360°E convention most planetary product metadata already uses — no manual conversion to ±180° needed.
  • WKT-in-projected-metres stays for polar canonical CRSs only. A WKT POLYGON(...) footprint’s coordinates must be in the same projected CRS the region resolver uses for that request, which only exists ahead of authoring time for a polar request (the canonical moon_south/ mars_south/earth_south-style CRS, resolved by latitude alone). A non-polar region resolves into a per-request local stereographic CRS generated fresh at request time, which cannot be known when a catalog entry is authored — so a non-polar layer should always use bbox:, never WKT.
  • cap:<lat><N|S> remains the right shorthand for a full polar-cap product, regardless of body.

Once layers exist, they are consumed two ways: a declared-stack manifest site (below) or an arbitrary region requestRealTerrain(body="moon", lat_deg=..., lon_deg=..., size_m=...) / srb asset download terrain --lat --lon --size-m [--deshade] — which needs no manifest entry at all; the resolver finds coverage by footprint intersection. The region form’s identity, normalization, and fallback rules are documented in Terrain Stacks.

Schema

Validated by srb.terrain.catalog_schema.SourceLayer (srb/terrain/catalog_schema.py):

body: moon
layers:
  - layer_id: ldem_87s # unique within the catalog
    body: moon
    kind: dem # dem | color | mask | quality
    product_family: "LOLA/Kaguya merged polar DEM"
    provider: pgda # ProviderName -- see below
    urls:
      ["https://pgda.gsfc.nasa.gov/data/LOLA_GDR/CYLINDRICAL/IMG/ldem_87s_10m.tif"]
    sha256:
      "ldem_87s_10m.tif": "<64-char hex>" # see "sha256 keys" below
    native_gsd_m: 10.0
    footprint: "cap:83S" # or a WKT POLYGON/MULTIPOLYGON -- see below
    alignment: { source: assumed_zero }
    license: "Public Domain (NASA/GSFC)"
    attribution: "NASA GSFC PGDA"
    size_bytes: 1_400_000_000
    access: cog_remote # cog_remote | download | zip_member
    priority_class: 1 # lower wins -- see below
    allow_large_download: false # curated half of the consent gate

provider accepts the same providers as the v1 manifest above (usgs_astro, lroc_pds, pds_geosciences, openplanetary, local) plus five v2-only additions:

ProviderHostsTypical body
pgdapgda.gsfc.nasa.govmoon
zenodozenodo.organy (zip-member archives)
uahirisehirise.lpl.arizona.edu, www.uahirise.org, uahirise.orgmars
copernicus_s3copernicus-dem-30m.s3.amazonaws.com, copernicus-dem-90m.s3.amazonaws.comearth
usgs_3depprd-tnm.s3.amazonaws.comearth

Every provider is a closed, exact-hostname allowlist — adding a new host means editing that provider module (srb/terrain/sources/*.py), not the catalog. uahirise, copernicus_s3, and usgs_3dep reach public, unauthenticated hosts/S3 buckets only — plain HTTPS GETs, no credential plumbing; a product needing authenticated access cannot be curated with these providers as they stand. Mars USGS products (CTX, HRSC-MOLA) need no new provider — usgs_astro’s existing hosts already cover them.

priority_class — lower wins

The resolver sorts candidate layers by the total key (is_synthetic, priority_class, native_gsd_m, layer_id) — never ground-sample distance alone (two products can tie at the same resolution and still need a deliberate order). priority_class defaults to 100; curated entries should set it explicitly. Convention (design spec §4.1/§4.2): DEM rows 1..5, sunlit-color rows 1..4, PSR-color rows 1..4 — lower numbers are preferred, and the lowest fully-covering entry becomes the stack’s role="base". The leading is_synthetic term (Phase 5c, see below) is False for every layer without radiometry: synthetic, so it is inert for every catalog entry described in this doc — priority_class is still the effective tiebreaker for ordinary layers.

radiometry: synthetic — the super-resolution slot

radiometry accepts one more value than the photometric-processing classes above: synthetic, reserved for a super-resolution or model-generated DEM/color product — a layer that was not captured from a real sensor. Authoring one means the resolver treats it specially, not that it behaves like an ordinary curated layer:

  • A footprint-searched request (resolve_stacks, and the mask/quality/PSR-color candidates a declared-stack site still derives by footprint) skips a radiometry: synthetic layer outright unless the caller opts in (RegionRequest.allow_synthetic=True / TerrainSpec.allow_synthetic=True) — it never becomes a resolvable candidate by accident.
  • When admitted, it still sorts strictly after every non-synthetic candidate, regardless of how low a priority_class you give it — the slot is deliberately a last resort, not a normal priority tier.
  • A declared-stack site’s dem_stack/color_stack entries are matched by layer_id, not filtered by radiometry — if you name a synthetic layer in a manifest’s declared stack, that is explicit authorial intent and it is never skipped, opt-in flag or not. Only reach for this if the site’s DEM/color truly is a synthetic product and you want every bake of that site to use it.
  • No shipped catalog entry uses radiometry: synthetic today, and the underlying super-resolution model this slot anticipates does not exist yet in-repo — authoring one is schema-legal but currently only exercised against fixture catalogs in tests.

See Terrain Stacks → Synthetic super-resolution layer slot for the resolver contract and the cache-key effect (allow_synthetic=True appends synthetic=1 to CacheKey.hash12(); False — the default — reproduces every existing golden hash unchanged).

access — how a layer’s bytes are fetched

  • cog_remote — windowed reads over GDAL /vsicurl (srb/terrain/sources/vsicurl.py); the point is that a multi-GB mosaic is never downloaded whole. Use for large products you only need a patch of.
  • download — a single plain file, fetched whole (BaseHttpProvider.fetch).
  • zip_member — an archive (currently only exercised via zenodo) fetched whole, then specific pinned members extracted. Use when the product only ships bundled with files you don’t need.

sha256 keys — the D4 convention (read this exactly)

sha256 is a map keyed by basename — defined as urllib.parse.urlsplit(url).path.rsplit("/", 1)[-1] (query string and fragment stripped) — of urls[0], the layer’s primary URL. The rule differs by access, and the schema validator (SourceLayer._validate_sha_map_matches_access) enforces it before a single byte moves:

  • access != "zip_member" (i.e. cog_remote or download) — the single-file key rule: sha256 MUST have exactly one entry, and that entry’s key MUST equal basename(urls[0]). Zero entries, extra entries, or a mismatched key are all rejected at catalog-load time — one URL, one hash, one key, and the key is not arbitrary.

  • access == "zip_member"sha256 MUST contain the archive’s own key (basename(urls[0])) and at least one further key: one per extracted member, keyed by its in-zip path:

    access: zip_member
    urls: ["https://zenodo.org/records/<id>/files/<archive>.zip"]
    sha256:
      "<archive>.zip": "<64 hex>" # the downloaded archive itself
      "dtm/<region>_dem.tif": "<64 hex>" # each extracted member, by in-zip path
    

Getting this wrong is not a soft warning: the fetch path’s CatalogLayerPlan.archive_sha256 does a bare sha256[basename(urls[0])] lookup, so a conforming-but-unpinned layer would KeyError at fetch time if the schema validator hadn’t already caught it at load time. srb asset stack refuses to build a plan from a catalog file that fails this validation at all.

Compute a real hash the same way as v1:

curl -L "<url>" | sha256sum

The placeholder "f" * 64 is a hard refusal here too, exactly like the v1 manifest (PLACEHOLDER_SHA256, srb/terrain/sources/acquisition.py) — a catalog layer with any placeholder sha256 value (archive OR member key) is refused before a byte moves.

allow_large_download + SRB_TERRAIN_LARGE_OK

Unlike the v1 manifest — where allow_large_download: true alone is sufficient, and that behavior is unchanged (D6) — a v2 catalog layer needs both halves to agree before an unapproved-size fetch proceeds:

  1. allow_large_download: true on the layer itself (curated — set this when you add the entry, asserting you’ve reviewed the size), and
  2. the operator’s SRB_TERRAIN_LARGE_OK=1 environment variable at fetch time (never baked into the catalog — a per-run opt-in).

Without both, a layer whose estimated fetch exceeds 1 GiB (MAX_UNAPPROVED_SOURCE_BYTES) is refused (LargeDownloadConsentRequiredError) rather than silently capped or prompted — nothing in this codebase prompts interactively (D11: SRB runs headless in containers and CI, where a prompt is an unbounded hang, not a question).

Do not confuse this real, AND-based enforcement gate (check_layer_consent / layer_max_bytes, srb/terrain/sources/acquisition.py) with srb asset stack’s printed “consent required” line, which is a descriptive, OR-based preview (--allow-large OR SRB_TERRAIN_LARGE_OK) that never touches the fetch path — see Real Terrain Assets.

mask_encoding — required in practice for every kind: mask layer

Read this before adding any kind: mask entry. A mask layer’s raw pixel values are product-specific DNs. Without a mask_encoding block the raster is passed through to fusion raw, byte-for-byte — the schema’s default is None and absence is defined as “unchanged” — and srb.terrain.fusion.color.psr_mask’s predicate is simply finite and nonzero. For a product whose “not masked” DN is a large nonzero number, that predicate is true nearly everywhere, and the mask silently covers the whole region instead of the small part it describes. This is not hypothetical: it is exactly how the entire lunar south pole was classed as permanently shadowed until the field was added.

The worked example is pgda_lpsr_85s_60m in srb/terrain/catalog/moon.yaml. The raw int16 LPSR raster the URL actually serves is tri-valued — 20000 means permanently shadowed, -32768 means nodata, and everything else (in practice -20000) means not shadowed:

  kind: mask
  # ...
  mask_encoding:
    masked_values: [20000]     # -> 1.0  ("inside the mask")
    nodata_values: [-32768]    # -> NaN  ("no observation")
                               # everything else -> 0.0 ("outside the mask")

With the block, -20000 decodes to 0.0 and drops out; without it, -20000 is finite and nonzero, so 99.99998% of the raster reads as masked. The decode happens at ingest (srb.terrain.ingest.mask.decode_mask_dn), before any array reaches fusion/.

Three rules when authoring one:

  • Values are compared for exact equality against the raster’s own pixel values read as float, so list the product’s real DNs — not a range, not a threshold. Non-finite and duplicate values are rejected at load.
  • A typo’d key is a hard error, not a silent fallback. SourceLayer is extra="forbid", so mask_encodding: fails catalog validation rather than loading with mask_encoding is None and quietly restoring the bug.
  • Check the bytes the URL actually serves, not the product’s documented canonical form. PGDA’s LPSR is documented as a {0, 1, 255} uint8 mask; the .TIF at the pinned URL is the raw int16 one. Open the file and look at its histogram before writing the block. Keep the constants in sync with srb/core/ephemeris/importers.py::_read_pgda_lpsr, which decodes the same product independently.

footprint — three forms, and the CRS requirement (D12)

  • cap:<lat><N|S>, e.g. cap:83S — a spherical cap poleward of that latitude. The common case for polar mosaics.
  • A WKT POLYGON(...) / MULTIPOLYGON(...) string, with coordinates in the same projected CRS (meters) the region resolver uses — never latitude/longitude degrees. Only practical for polar layers (below).
  • bbox:<lonmin>,<latmin>,<lonmax>,<latmax> — plain geographic degrees, body-agnostic (Phase 5b, D12 lift). Recommended for non-polar layers — see Authoring Mars/Earth entries above and Terrain Stacks → Footprints: the bbox: form for the full parsing/normalization/antimeridian rules.

A cap:/WKT footprint and the region it is tested against must share one projected CRS — comparing a footprint’s projected meters against a request expressed in degrees would silently produce a nonsense intersection; bbox: sidesteps this entirely by staying in degrees and letting the resolver project each sample point back before comparing. Every request now projects into a body-aware CRS before the footprint math runs (Phase 5b, D12 lift): a polar request (|lat| >= 60) uses the canonical moon_south/moon_north- style CRS (IAU_2015:30135/:30130, and the Mars/Earth equivalents — see Terrain Stacks → Bodies and canonical CRSs), and any other request uses a per-request local stereographic CRS centered on its own lat/lon — there is no longer a structural non-polar gate, only a genuine no-covering-footprint miss, which still returns an empty plan with a warning and falls back to the v1 single-source path.

Validate

srb asset stack --body moon --lat -89.46 --lon 137.3 --size 2000
srb asset stack --body moon --lat -89.46 --lon 137.3 --size 2000 --json

A malformed entry (bad sha256 shape, unrecognized footprint, duplicate layer_id, unknown provider) raises a pydantic.ValidationError at load time, before any plan is built.

Adding a v2 site with a declared stack

A v2 site is different from a v2 source layer (above): the layer is reviewed, pinned catalog data (srb/terrain/catalog/<body>.yaml); the site is a srb/terrain/manifest.yaml entry that names which catalog layers to fuse for one bake, the same way a v1 site names one dem/ortho. This section assumes the layers you want to reference already exist in the catalog (curated, per the section above, or — for local testing — a hand- built catalog like the one tests/terrain/fixtures/fusion_stack.py builds).

  1. Bump the manifest to version: 2. This is required before ANY site may declare a stack, and is backward compatible: existing v1 sites in the same file are completely unaffected (Sources still validates “exactly one of dem / dem_stack” per site, independent of the manifest’s own version).

    version: 2
    sites:
      apollo17: { ... }  # existing v1 sites, unchanged
    
  2. Add the site, declaring dem_stack (exactly one role: base, plus zero or more role: inset) and, optionally, color_stack (same shape), instead of the v1 sources.dem/sources.ortho:

    sites:
      connecting_ridge:
        body: moon
        name: "Connecting Ridge (v2 fused)"
        coords: { lat: -89.46, lon: 137.3 }
        sources:
          dem_stack:
            - { layer_id: ldem_87s, role: base }
            - { layer_id: nac_dtm_esall_cr1, role: inset }
          color_stack:
            - { layer_id: cm_avg_87s, role: base }
            - { layer_id: shadowcam_cmosaic_cr1, role: inset }
        patches:
          default: { center_xy_m: [0, 10309.97], size_m: 2000, lod: medium }
          # -- or, equivalently, a lat/lon patch center instead of projected xy:
          # default: { center_latlon: [-89.66, 0.0], size_m: 2000, lod: medium }
        sky: lunar_default_low
        lighting: lunar_default
        rocks: lunar_basalt_set
        deshade: true # optional: bake illumination-corrected colour by default
    

    The optional deshade: true makes every bake of this site divide each sunlit colour layer by a modelled hillshade of the fused DEM at that layer’s catalogued sun_azimuth_deg/sun_elevation_deg (PSR layers are never deshaded; a layer without catalogued angles is skipped with a warning). Precedence with the request-level knob is manifest or request — a RealTerrain(..., deshade=True) request can turn deshading on for a site that doesn’t declare it, but can never turn a manifest-declared deshade: true off. The effective flag is part of the cache key. See Terrain Stacks → Deshade.

    Every layer_id must exist in that body’s catalog — an unknown one raises at resolve time, naming the missing layer. role (not list order) decides the base; StackRef.max_gsd_m can drop an overly coarse candidate layer with a warning if you need to bound resolution.

  3. Validate and bake exactly like a v1 site — same commands, same srb asset validate-manifest / srb asset download terrain <site> / auto-generated LunarSiteConnectingRidge shortcut class. Nothing about the CLI or the RealTerrain constructor differs; bake_or_load detects the declared stack (sources.dem is None) and routes to the fusion engine automatically.

  4. What comes out differs from a v1 bake: meta.json carries a fusion block, provenance.tif sits alongside terrain.usd, and ATTRIBUTION.txt lists one block per contributing layer instead of a single string. See Real Terrain Assets → the fusion engine for what each of those actually contains, the mesh/texture budget tiers, and the QC/degradation rules (a demoted or unreachable inset drops with a warning and the bake still succeeds; an unreachable base has no v1 fallback to take instead, and raises).

This only works once the layers you name actually exist in a real catalog. srb/terrain/catalog/moon.yaml now carries 19 real layers (operator action O3 curated them — see Adding a v2 source layer above), but the connecting_ridge example’s own layer ids (ldem_87s, nac_dtm_esall_cr1, cm_avg_87s, shadowcam_cmosaic_cr1) are still illustrative — they don’t match any real, shipped layer id, and no dem_stack/color_stack manifest site has actually been declared against the real catalog yet (manifest.yaml is still version: 1). What is proven against the real catalog today is the arbitrary-region request form (no manifest site) — srb asset download terrain --lat -89.66 --lon 0.0 --size-m 2000 --body moon completes a real fused bake end to end; see Terrain Stacks → Catalog status per body.

Adding a new HDRI skydome

srb/terrain/hdri/catalog.yaml:

skydomes:
  my_dome:
    body: moon
    url: "https://example.org/dome.hdr"
    sha256: "<64-char hex>"
    format: hdr        # or png, exr
    intensity: 1.0
    resize_to: 2048
    license: "Public Domain"
    attribution: "Source name"

EXR support requires the optional imageio[freeimage] dependency (pip install srb[terrain-extras]); PNG and HDR work out of the box.

Adding a new rock set

srb/terrain/rocks/catalog.yaml:

sets:
  my_set:
    body: moon
    rocks:
      rock_a:
        url: "https://example.org/rock_a.glb"
        sha256: "<64-char hex>"
        format: glb       # or obj, ply, stl
        target_face_count: 5000
        license: "Public Domain"
        attribution: "Source"
      rock_b:
        url: "..."
        ...

The pipeline decimates each rock to target_face_count (default 5000), welds duplicate vertices, recenters at centroid, rescales the AABB diagonal to 1.0. Caller scales at scatter time.

Adding a new lighting preset

srb/terrain/lights/catalog.yaml (no fetch — pure-config):

presets:
  my_preset:
    body: moon
    sun_elevation_deg: 30
    sun_azimuth_deg: 90
    sun_intensity_lux: 130000
    sun_color_temperature_k: 5800
    sun_angular_diameter_deg: 0.53
    earthshine_intensity_lux: 200      # optional
    earthshine_color_temperature_k: 7500
    license: "Public Domain"
    attribution: "Synthesized from ephemeris X"

Sun pose can be derived from a real ephemeris for a given mission timestamp

  • site coordinates, or synthesized for an artistic look.

sha256 backfill workflow

For new entries the placeholder convention is "f"*64. To compute the real hash:

curl -L "<url>" | sha256sum

Update the manifest, re-run srb asset validate-manifest, and confirm srb asset download terrain <site> succeeds.

Common mistakes

  • Wrong CRS string. Use the IAU 2015 names: IAU_Moon_2015, IAU_Mars_2015. Misspellings raise MissingCRSError or DemLoadError during ingest.
  • Patch size larger than DEM. Cropping outside the DEM extent raises EmptyHeightmapError. Pick smaller patch_size_m or different center_xy_m.
  • Forgetting attribution on user-supplied DEMs. License compliance is a hard refusal — bakes won’t run.
  • Stale companion catalog references. sky:/lighting:/rocks: must match a name in the corresponding catalog. Use null to disable.