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
-
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. -
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 -
Validate.
srb asset validate-manifest -
Test the bake.
srb asset download terrain my_site -
Verify the per-site shortcut.
srb/assets/scenery/real_terrain_sites.pyauto-generatesLunarSiteMySite(orMartianSiteMySite) at import time. Smoke-test (run inside the SRB/Isaac Sim Python environment — importingsrb.assets.scenery.real_terrain_sitespulls in Isaac Lab; seetests/terrain/unit/test_real_terrain_sites.pyfor 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: thebbox: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 canonicalmoon_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 usebbox:, 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 request —
RealTerrain(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:
| Provider | Hosts | Typical body |
|---|---|---|
pgda | pgda.gsfc.nasa.gov | moon |
zenodo | zenodo.org | any (zip-member archives) |
uahirise | hirise.lpl.arizona.edu, www.uahirise.org, uahirise.org | mars |
copernicus_s3 | copernicus-dem-30m.s3.amazonaws.com, copernicus-dem-90m.s3.amazonaws.com | earth |
usgs_3dep | prd-tnm.s3.amazonaws.com | earth |
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 aradiometry: syntheticlayer 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_classyou give it — the slot is deliberately a last resort, not a normal priority tier. - A declared-stack site’s
dem_stack/color_stackentries are matched bylayer_id, not filtered byradiometry— 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: synthetictoday, 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 viazenodo) 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_remoteordownload) — the single-file key rule:sha256MUST have exactly one entry, and that entry’s key MUST equalbasename(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"—sha256MUST 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:
allow_large_download: trueon the layer itself (curated — set this when you add the entry, asserting you’ve reviewed the size), and- the operator’s
SRB_TERRAIN_LARGE_OK=1environment 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.
SourceLayerisextra="forbid", somask_encodding:fails catalog validation rather than loading withmask_encoding is Noneand 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.TIFat 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 withsrb/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: thebbox: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).
-
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 (Sourcesstill validates “exactly one ofdem/dem_stack” per site, independent of the manifest’s own version).version: 2 sites: apollo17: { ... } # existing v1 sites, unchanged -
Add the site, declaring
dem_stack(exactly onerole: base, plus zero or morerole: inset) and, optionally,color_stack(same shape), instead of the v1sources.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 defaultThe optional
deshade: truemakes every bake of this site divide each sunlit colour layer by a modelled hillshade of the fused DEM at that layer’s cataloguedsun_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 ismanifest or request— aRealTerrain(..., deshade=True)request can turn deshading on for a site that doesn’t declare it, but can never turn a manifest-declareddeshade: trueoff. The effective flag is part of the cache key. See Terrain Stacks → Deshade.Every
layer_idmust 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_mcan drop an overly coarse candidate layer with a warning if you need to bound resolution. -
Validate and bake exactly like a v1 site — same commands, same
srb asset validate-manifest/srb asset download terrain <site>/ auto-generatedLunarSiteConnectingRidgeshortcut class. Nothing about the CLI or theRealTerrainconstructor differs;bake_or_loaddetects the declared stack (sources.dem is None) and routes to the fusion engine automatically. -
What comes out differs from a v1 bake:
meta.jsoncarries afusionblock,provenance.tifsits alongsideterrain.usd, andATTRIBUTION.txtlists 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 raiseMissingCRSErrororDemLoadErrorduring ingest. - Patch size larger than DEM. Cropping outside the DEM extent raises
EmptyHeightmapError. Pick smallerpatch_size_mor differentcenter_xy_m. - Forgetting
attributionon 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. Usenullto disable.