Skip to content

usdGeospatial: codeless CRS schema + two-runtime reference (co-submission preview)#10

Open
asluk wants to merge 83 commits into
devfrom
aluk/geospatial-crs-prototype
Open

usdGeospatial: codeless CRS schema + two-runtime reference (co-submission preview)#10
asluk wants to merge 83 commits into
devfrom
aluk/geospatial-crs-prototype

Conversation

@asluk

@asluk asluk commented Jun 30, 2026

Copy link
Copy Markdown
Owner

usdGeospatial — PR Review Guide

For the reviewer: This is a preview PR in Aaron Luk's fork (asluk/USD) of a
codeless geospatial CRS schema we'd like to co-submit to OpenUSD. It deliberately carries
more than the eventual upstream submission would — the extra material is the
validation/evidence harness, not the proposed surface.

The grounding document is the usdGeospatial README — read it first.
This guide is a thin navigation layer over it: the three best proofs up top, a reading order
that links into the README and the code, then what's scaffolding and how we'd trim it.

Hero proofs (the four most illustrative)

What these images are: the first three are Matplotlib plots of resolved coordinates
not renderer screenshots; they demonstrate the resolver's numbers are correct. The fourth is
a real Hydra Storm render
(auto-inserted into usdview), called out as such.

1 — Real third-party asset, no overfit. A genuinely external NVIDIA OpenUSD-plugin-samples
Deutsche Bahn railway (~1,470 curves) authored in the original Omniverse geospatial
schema, converted to crs:binding/crs:position and resolved onto three real geospatial
tiles near Hamburg — no Hydra, no external renderer, no baking. (full-size)

Deutsche Bahn rails resolved onto geospatial tiles

2 — Multi-CRS co-registration, against an independent authority. The same monument
authored three independent ways (geographic EPSG:4979 / UTM 18N / NY State Plane) from
independent NOAA NCAT coordinates co-registers to the same ECEF point to ≤ ~0.5 mm —
verified against closed-form WGS84 geodesy, not a parallel PROJ call. (full-size)

Three CRSs co-registering to one ECEF point

3 — One schema, two independent runtimes (a first conformance pass). The Python reference
runtime and the compiled C++ Hydra scene index resolve the same authored stage to the same world,
agreeing to 0.0 mm — evidence the data contract is unambiguous enough to build twice the same
way (robustness), not on its own a correctness proof. Correctness is Proof 2 (vs NCAT +
closed-form geodesy, the independent authority). The schema is a contract, not one implementation. (full-size)

One schema resolved by two runtimes

4 — It just works in usdview (real Hydra Storm render). With only the built plugins on
PXR_PLUGINPATH_NAME — no application code, no hand-built scene-index chain — opening the
georef scene in usdview / usdrecord draws the railway at its correct ECEF position via Storm; a
keyless UsdImagingAPISchemaAdapter surfaces crs: into Hydra and an auto-inserted scene index
resolves it. Negative control: plugin path removed → railway absent. The stage-free Hydra-auto path
matches the oracle 30/30 at 0.0 mm (testHydraAutoParity). This is a real render, not a plot. (full-size)

usdview Storm auto-insert render


The two things that merit the most care

Both are covered in full in the README; flagged here so a reviewer reads them deliberately.

What breaks in coexist, and the fix: G1 anchor-vs-child 418 m, G2 wrong frame 4.86 m, G3 no marker 6369 km silent

  • Non-geometric georeferenced data, visualized by composition — a common case (scalar fields,
    sensor networks, benchmarks) is a prim with crs:position + a value and no geometry. A
    separate overlay layer subLayers the pristine dataset and adds marker glyphs as geometry-only
    overs (zero crs:* re-authored; base byte-identical; overlay removable) — placed by the same
    anchor-injection path, no new code. Doubles as a proof the codeless schema composes across USD
    layers. Same-data/two-visualizer parity + a same-runtime/many-CRS (5 families, both hemispheres,
    ~equator-to-78N, zero code changes) anti-overfit position set (each authored point resolves to a
    distinct ECEF location through one code path).
    Non-geometric data: visualize by composition

Same non-geometric field, matplotlib vs Storm

One runtime, five CRS families, both hemispheres

  • Where the Python reference runtime sits — it's a pure-data schema's executable
    specification / conformance oracle
    , not USD-core code, a runtime dependency, or
    Python-in-the-render-loop. No exact precedent; that placement is a deliberate design choice.
    Where the Python reference runtime sits

Open design questions we'd like Esri's / the WG's read on are listed in the README:
Status, scope, and open questions


The submission core (what we'd actually propose)

The subset to treat as the proposal; everything else in the diff exists to validate it.

  • Schema (pure data): pxr/usd/usdGeospatial/schema.usda
    (+ generated generatedSchema.usda / plugInfo.json, regenerated by regen-schema.sh). skipCodeGeneration = true ⇒ codeless. The data contract.
  • Python reference runtime (behavior contract): resolve_runtime.py,
    the projection-engine seam crs_engine.py, the converter reencode_georef.py, and the README.
  • Second, illustrative runtime: usdGeospatialSceneIndex/ — a C++ Hydra scene index modeled on the Gaussian-splat hdParticleField example (illustrative, not a prescribed production renderer). Builds opt-in and cross-platform: build_usd.py --usdGeospatial fetches and builds PROJ, then the plugin and its parity tests, exactly the hdParticleField pattern; parity is a ctest (testUsdGeospatialParity); validated on Linux and Windows. Proves the schema is a contract, not one implementation.

Guided reading order

Schema → author → resolve → engine → second runtime → proofs.

  1. Schema as dataschema.usda: the two class decls, skipCodeGeneration = true, binding is a rel not a baked transform.
  2. A neutral authored sceneworld_neutral_relbinding.usda (only crs: props, zero xformOp) vs the baked Esri form world_baked_resetxformstack.usda.
  3. The resolve seamresolve_runtime.py: crs_of_prim (binding precedence), resolve_*, then resolve_with_injection (anchors).
  4. The engine seamcrs_engine.py: reprojection + local-frame basis as a registry; WKT never reaches USD.
  5. The second runtimeSI README, then geospatialSceneIndex.cpp (HdXformSchema override) + geoResolver.cpp / crsEngine.cpp.
  6. The proofsctest -R testUsdGeospatialParity (cross-platform; or run_parity.sh) (9/9, 30/30, 30/30 — 0.0 mm; negative control: no SI ⇒ origin), generalization_suite.py (7 datasets), test_anchor_injection.py.

Full evidence narrative + figures: The proofs · docs/ gallery.


What's in this PR but NOT in the eventual submission (scaffolding)

The PR is the full prototype branch, unreorganized. The following is evidence and tooling,
trimmed/relocated before a real co-submission:

  • Extended test battery (src/test_*.py, multi_crs_example.py, testenv_equivalence.py, verify.py, SI test*.cpp) — keep a focused subset as the example's test; rest → validation harness.
  • Figure generation + PNGs (src/render_*.py, fig_*.py, docs/*.png, ~3.5 MB) — keep one or two key figures + regen scripts; drop/host the rest.
  • Scratch / process docs (docs/{TLDR,codex-review,…}.md, HYDRA_PLUGIN_PLAN.md, the untracked RESUME.md, and this PR_REVIEW_GUIDE.md) — fold durable bits into the README; drop the rest.
  • Data fixtures (data/gfs_t2m.nc 8 MB, data/thirdparty/ Deutsche Bahn asset) — confirm redistribution licensing; fetch-on-demand or a smaller slice.
  • Build/run convenience + generated output (build_standalone.sh, dump*, out/, __pycache__) — .gitignore'd / dropped.

Next steps to submission shape: extract the core to a clean branch; relocate the test
battery; trim docs to one README; resolve data-fixture licensing; confirm in-tree
--examples compile; re-green the suite; coordinate the joint submission with Esri.

@asluk asluk changed the title usdGeospatial: codeless CRS schema + two-runtime reference (Esri co-submission preview) usdGeospatial: codeless CRS schema + two-runtime reference (co-submission preview) Jun 30, 2026

@mistafunk mistafunk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly (the AI generated text/code is hard to read), you are proposing two related changes:

  • using a codeless schema (CRS representation and binding), i.e. no stage modification at composition time
  • specifically no injection of xform reset for bound Xformables, i.e. let the runtime (hydra) do this.

This looks elegant though I have these concerns/questions:

  1. Is crs:position really needed? What is its authoring UX? Why not use the natural origin of the CRS and do the rest with the USD xform stack?
  2. We should demonstrate this also works without hydra, e.g. when flattening layers into the same target CRS.
  3. I think the proposed binding mechanism with the local xform being composed onto the crs:position (in target CRS) is NOT correct for the general case where the target CRS is spherical (for non-rendering use cases).
  4. Trimble suggested to use the WKT CONVERSION parameters (the projection natural origin) for local origin definition, this would need to be reconciled/combined with crs:position.
  5. What is the purpose of 'requires-CRS'? I was expecting that the crs:binding relationship is simply ignored by the runtime if an USD distro does not have support for this schema.

HdStRenderPassState conditionally includes alphaThreshold in the uniform BAR layout only when it is not zero. Hdx OIT expects alphaThreshold to be zero for both passes, so we expect to reuse a single UBO for both passes. We also never recompile the shaders. Both optimizations have performance benefits.

Applications should never set alphaThreshold on Hdx's OIT task params. If an application does set something other than HtRenderPassState's default of zero, OIT would break due to mismatch between the UBO layout and the shader. To avoid that, OIT guards against non-zero alphaThreshold. Please note that this guard preserves the correct behavior of OIT, but applications that persist in sending erroneous non-zero alphaThreshold will experience performance loss due to frequent resizing of the uniform BAR.

(Internal change: 2417781)
@asluk
asluk force-pushed the aluk/geospatial-crs-prototype branch from af7bd5c to 576d187 Compare July 7, 2026 21:32
@asluk
asluk force-pushed the aluk/geospatial-crs-prototype branch from a1661c2 to 576d187 Compare July 9, 2026 18:11
pixar-oss and others added 5 commits July 9, 2026 11:11
1. When camera guides were enabled, the back plates would disappear. This is because the prior _PrimsAdded call would remove the whole subtree but only add in the back plate children. Turning on the camera guides causes a re-sync more frequently thus causing this to bug to surface. The solution is to revise the _RemoveSubtrees function to remove only the back plate children but ensure that _PrimsRemoved still removes the entire subtree.
2. Not recieving dirty calls for timesampled dirtied backplate properties. Modifed _PrimsDirtied to keep track of dirtied prims for HdXformSchema locators as well as modifying the adapter to issue locators for timesamples.
3. The backplate schema is at camera:backplate:APPLIED_INSTANCE_NAME, so we cannot give it a meaningful GetDefaultLocator(), instead we rely on HdCameraSchema::GetBackPlateLocator()

(Internal change: 2418144)
Checking this in for @klucknav who did the work.

- update HdStMaterialXShaderGen test
- update ShaderGen as they added an `AIRY_FRESNEL_ITERATIONS` define
- some of the changes in v1.39.5 included updates to the prefiltered environment lighting AcademySoftwareFoundation/MaterialX#2808 so the Storm baselines in the UsdImagingGL tests and testMaterialX needed to be updated
- also updated the native baslines in the Native v MaterialX test

(Internal change: 2418247)
(Internal change: 2418307)
(Internal change: 2418334)
@asluk
asluk force-pushed the aluk/geospatial-crs-prototype branch from 409ec89 to c1daef2 Compare July 11, 2026 21:11
dgovil and others added 17 commits July 13, 2026 12:50
The `--no-build-apple-framework` flag was ignored on embedded
platforms because buildAppleFramework was computed as:

    `args.build_apple_framework or MacOSTargetEmbedded(self)`

On embedded platforms MacOSTargetEmbedded() is True, so the 'or'
short-circuited to True even when the user explicitly passed
--no-build-apple-framework (which sets args.build_apple_framework
to False).

Give the flag a None default so an unspecified flag can be
distinguished from an explicit False, and only fall back to the
embedded platform default when the flag is unspecified. Embedded
platforms still default to building the framework, but can now
disable it.

Closes PixarAnimationStudios#4153

(Internal change: 2418817)
The PXR_PREFIX was set to pxr/usdIrImaging instead of pxr/usdImaging,
causing usdIrImaging headers to install to include/pxr/usdIrImaging
rather than include/pxr/usdImaging/usdIrImaging. This matches the
convention of every other subdirectory under pxr/usdImaging and the
include paths already used within the usdIrImaging sources
(e.g. pxr/usdImaging/usdIrImaging/api.h).

Closes PixarAnimationStudios#4154

(Internal change: 2419094)
This is built on top of a corresponding MaterialX PR, that fixes nested
geomprop nodes:
AcademySoftwareFoundation/MaterialX#2957

Rendering a UsdPreviewSurface network that reads a primvar through an
ND_UsdPrimvarReader_* node (for example varname="st" feeding a UsdUVTexture)
failed in Hydra Storm with:

  HdSt_ApplyMaterialXFilter -- Unable to create the Glslfx Shader.
  MxException: No "geomprop" value found for geompropvalue node "primvar".

That crash exposed a few different issues this PR fixes, all in how
hdMtlx/hdSt build the document and emit the glslfx.

1. Preserve the topology-affecting primvar name. The topological
   classification (_IsTopologicalShader) only recognized the native
   geompropvalue family, so a UsdPrimvarReader varname was stripped from the
   anonymized network.

2. Recognize primvar-reader nodes generically (hdMtlx). A node is treated as a
   primvar reader when its implementation nodegraph wraps a geompropvalue or
   geompropvalueuniform node. New helpers HdMtlxIsPrimvarReaderNodeDef,
   HdMtlxGetPrimvarNameInputName and HdMtlxGetPrimvarDefaultInputName discover
   this and the input names that carry the primvar name and fallback (read from
   the interfacename on the wrapped geompropvalue inputs, for example varname
   and fallback for UsdPrimvarReader). These drive three call sites that
   previously only understood the native geompropvalue node by category or by
   the hardcoded geomprop/default parameter names:
     - _IsTopologicalShader now treats any primvar-reader node as topological.
     - _AddMaterialXNode now registers primvar-reader nodes in hdPrimvarNodes
       so the primvar is declared and bound.
     - _UpdatePrimvarNodes now extracts the primvar name and fallback from the
       correct inputs instead of assuming geomprop/default.
   Detecting by implementation structure rather than by family name keeps this
   robust for any future node that wraps a geompropvalue. Over-preserving a
   parameter is always correctness-safe; the only cost is slightly reduced
   shader reuse for such nodes, matching how the native geompropvalue node is
   already handled.

3. Emit glslfx-compatible primvar types (hdSt/materialXShaderGen.cpp). The
   primvar attributes block of the generated glslfx took its type name from the
   target shader language syntax. On Metal that produced names like float2,
   which HioGlslfxConfig does not understand (it accepts float, double, int,
   vec2, vec3, vec4), so the primvar declaration was rejected, st fell back to
   a default, and texture coordinates were lost (surfaces rendered untextured).
   The attributes type is now mapped to a glslfx type independent of the target
   language. This is a Metal-specific defect that became reachable only once a
   primvar actually reached shader generation.

Closes PixarAnimationStudios#4091

(Internal change: 2419116)
(Internal change: 2419243)
(Internal change: 2419352)
(Internal change: 2419353)
This is the first in a set of name shortenings. Long names were causing issues on Windows systems with the 260 character limit for paths.

(Internal change: 2419625)
…horingCode

This is the second in a set of changes to rename the directories associated with irExample. Here we rename the authoring lib, and update references to it in the usdview plugin.

(Internal change: 2419724)
(Internal change: 2419742)
(Internal change: 2419750)
…wPlugin

This is the third in a set of changes to rename the directories associated with irExample. Here we rename the usdview plugin.

(Internal change: 2419757)
claw1 and others added 29 commits July 21, 2026 18:34
Aaron pointed to the 'Gaussians work': Pixar's in-tree hdParticleField example
(extras/imaging/examples/hdParticleField, OpenUSD dev) -- a sample render
delegate for a particleField prim type, the reference for gaussian-splat
rendering. Update the plan with its real file layout (pxr_plugin CMake macro,
plugInfo.json, .py converters) and the key distinction:

  - hdParticleField is a render DELEGATE; our work is a scene-index FILTER.
  - TAKE from it: the in-tree plugin SCAFFOLDING/BUILD pattern (it tracks current
    dev Hydra, so it's a better build template than the NVIDIA plugin), and the
    fact that we build inside asluk/USD means a sibling in-tree example compiles
    with the same pxr_plugin machinery -- given a from-source USD build.
  - TAKE the RUNTIME model from omniGeoSceneIndex, ported to current Hydra.

Pointer is now resolved; the remaining gating input is a from-source USD build.
…r hdParticleField)

Per Aaron: before any geospatial code, prove the full path end-to-end on Pixar's
known-good example -- build USD from source with usdview + examples, convert a
PUBLIC 3DGS .ply to USD via the example's own py3dgsPlyToUsd.py, render it in
usdview with the hdParticleField renderer, and send a screenshot. This de-risks
the from-source build / usdview / in-tree-plugin-load / converter path on a
known-good target before our new scene-index plugin depends on all of it.

Phase 0 steps 0a-0d added (build flags, PXR_PLUGINPATH_NAME, public .ply convert,
render + screenshot incl. headless xvfb/EGL or usdrecord fallback). Deliverable:
a usdview screenshot of a gsplat to Aaron. Flagged that this needs a from-source
build + GL/display, so it's a resume-session task (not the codeless venv).
Compiled Hydra scene index that performs geospatial resolution at render
time -- the production form of usdGeospatial/src/resolve_runtime.py.

- crsEngine: PROJ-backed CRS engine (Reproject + ENU->ECEF LocalFrameToEcef);
  WKT stays opaque to USD, PROJ is the only consumer (mirrors crs_engine.py).
- geoResolver: C++ port of the reference runtime (binding strength/purpose/
  collection resolution, anchor_frame, resolve_with_injection).
- geospatialSceneIndex: HdSingleInputFilteringSceneIndexBase wrapping Xformable
  prims that resolve to a geospatial anchor; overrides the HdXformSchema matrix
  locator with the inject-don't-bake world transform; dirties descendants.
  Scaffolded from hdParticleField's pxr_plugin/plugInfo pattern but registers an
  HdSceneIndexPlugin; runtime ported from NVIDIA omniGeoSceneIndex, retargeted to
  crs:binding/crs:position and updated to current Hydra (USD 0.26.8).

Parity (run_parity.sh), all 0.0 mm vs closed-form WGS84 geodesy AND the Python
reference runtime:
  - engine: 9 datasets (geographic + 5 projected CRSs)
  - resolver: + real NVIDIA Deutsche Bahn railway anchor+curve subtree + anchor
    frames (inject-don't-bake orientation)
  - Hydra scene index: same datasets pulled THROUGH the pipeline as a renderer
    would consume them.
Non-circular (GT is closed-form, not parallel PROJ) and with teeth (negative
control: stock Hydra puts these prims at the origin).
…piled Hydra), with visual proof

The codeless schema's whole point is that behavior is a data contract, not one
implementation. Now that the compiled C++ Hydra scene-index plugin exists and
matches the Python reference runtime, tell that story in the README and back it
with figures generated by the build:

- New 'Two runtimes, one schema' section: one authored stage resolved through the
  Python reference runtime AND the compiled Hydra scene index, agreeing to sub-mm.
- fig_multi_runtime.py: architecture figure (one schema -> two runtimes -> same ECEF
  world; parity table; negative control).
- fig_runtime_parity.py: VISUAL parity -- real NVIDIA Deutsche Bahn rails-on-tiles
  rendered side-by-side via both runtimes from the same stage; per-vertex
  disagreement median 0.40 mm / worst 0.68 mm across 3,526 verts; self-asserts and
  FAILS the build if >1 mm.
- render_figures.py wires both into the figure suite (multi_runtime auto-runs the
  Hydra dumper when the binary is present, else skips with a clear note).
- dumpHydraXforms.cpp: dumps HdXformSchema matrices from the scene index for the
  visual-parity figure.
- Retire the three forward-looking 'Hydra form is future work' references; the
  compiled production form is done (run_parity.sh: crsEngine 9/9, geoResolver 30/30,
  Hydra SI 30/30, all 0.0 mm vs Python oracle + closed-form GT).
…st codes

This README is shared with external collaborators (Esri). The internal
one-pager/checklist shorthand (C-04, C-05) referenced a doc those readers
can't see, so it read as a dangling reference. Replaced with self-contained
phrasing; no technical content changed:
  - 'Float32 localization falls out for free (one-pager C-05)' -> drop the code
  - 'cuProj -- this is the C-04 insertion point' -> 'a GPU engine (e.g. cuProj)'
  - test_float32_localization.py '(C-05)' -> drop the code
  - 'the C-04 insertion point' -> 'the natural insertion point'
…k' objection

nonclaw feedback: the compiled Hydra scene index forces resetXformStack=true on
the xform it injects (geospatialSceneIndex.cpp), which can read as the very
baking the design rejects. Add a note clarifying the distinction is WHERE the
flag lives: here it is on a computed, transient Hydra data source (the resolver
already composed ancestors and returns the full world/ECEF matrix, so the flag
just stops Hydra re-composing under parents at flatten time), never authored
into a layer. The authored stage stays coordinate-neutral -- verified:
world_neutral_relbinding.usda has zero resetXformStack and zero xformOp, only
crs: properties. Additive doc only.
…ract, runtime is plural)

The README had accreted three eras of incremental edits and become internally
inconsistent: its spine still claimed 'one codeless Python runtime' and 'every
figure is generated by this codeless build, no external renderer' while its
climax is now 'the schema is the contract; the behavior is plural' (Python
reference runtime + compiled C++ Hydra scene index). That blanket figure claim
was simply false -- two figures (multi_runtime, runtime_parity) come from the
compiled Hydra path and render_figures.py skips them without the Hydra binary.

Rewritten from scratch on the correct spine:
- New thesis up front: codeless data-contract schema proven by TWO independent
  runtimes resolving the same authored scene to the same world, sub-mm.
- Reproducibility claim split honestly: nine Python-reference figures (one
  command, no external renderer) vs two cross-runtime parity figures that also
  need the compiled C++ Hydra build (run_parity.sh, prebuilt USD).
- Added a short Repository-layout map (this example + ../usdGeospatialSceneIndex/).
- Merged the two separate resetXformStack discussions into one argument
  (rejects resetXformStack AUTHORED into a layer; the scene index sets it only on
  a transient computed Hydra data source -- authored scene stays neutral).
- Tests section: internal test-ID shorthand (T1-T3, S1-S7, P1-P4, ...) replaced
  with plain one-phrase descriptions of what each openable test file checks.
- Running block now two-tier (codeless Python path + full parity path).
- No internal checklist codes remain; external-audience throughout.

No technical claims changed. Numbers re-verified against live output before
commit: parity median 0.40 / worst 0.68 mm over 3,526 verts; usd-core 26.5,
pyproj 3.7.1 / PROJ 9.5.1. The differing PROJ/USD versions vs the C++ build are
a deliberate two-toolchain difference, preserved (the parity proof is exactly
that two different stacks agree).
…ower -> upper)

The railway figures painted each tile's quadnode imagery with imshow(origin=
'lower'), which puts image row 0 (the TOP of the PNG) at the south edge of the
tile -- flipping the texture north/south, so the basemap text rendered mirrored
and the (correctly-resolved) rails appeared not to sit on the tiles. The tile
quads carry st=[(0,0),(0,1),(1,1),(1,0)] with t=0 at the SOUTH corner, so image
row 0 must map to NORTH: origin='upper'. Texture-only fix; geometry untouched --
parity is byte-for-byte unchanged (median 0.40 mm / worst 0.68 mm over 3,526
verts, exit 0). Fixed in both fig_railway_render (render_figures.py) and
fig_runtime_parity, regenerated railway_render.png + runtime_parity.png.
Follow-up to the origin flip fix: the railway figures had been faking the tile
texturing by stretching each quadnode PNG into the tile's E/N bounding box. Now
they honor the asset's actual UsdUVTexture/st mapping -- a _draw_uv_textured_quad
helper solves the affine UV->world-EN transform from the mesh's per-corner st
([(0,0),(0,1),(1,1),(1,0)], t=0 at the south corner) and places image pixel (s,t)
at the world corner carrying that st. Exact for the (parallelogram) tiles here
(corner residual ~1e-10 m) and correct for any quad orientation, where a bbox
stretch would not be. Applied in both fig_railway_render and fig_runtime_parity.
Texture-only; geometry/parity unchanged (median 0.40 / worst 0.68 mm, 3,526
verts). Lets the README drop the 'we fake the texture' caveat.
…t UVs

Follow-up to commit 406123c (faithful UsdUVTexture/st rendering). Add a short
line under the railway figure noting the tile imagery is placed via the asset's
own st mapping (s->East, t->North, t=0 south; pixel (s,t) lands at the world
corner with that UV) rather than stretched into a bounding box -- so the basemap
reads north-up and the rails register against it. Keeps the README consistent
with the implementation and drops the implicit 'approximate texture' framing.
Commit 406123c (faithful UsdUVTexture/st rendering) reintroduced the exact
N/S texture flip that 56f5ea8 had fixed: the affine UV-space imshow used
origin='lower', which places the picture's TOP row at t=0. But the st contract
has t=0 at the SOUTH corner, so the image top landed in the south -> tiles
mirror-reversed and the (correctly-resolved) rails appeared misplaced against
the flipped basemap. Fix: origin='upper' in the UV-space imshow (image row 0 ->
t=1 -> north), in both _draw_uv_textured_quad sites (render_figures.py and
fig_runtime_parity.py). Regenerated railway_render.png + runtime_parity.png.

Verified by PIXEL SAMPLING this time, not just the affine math: render a known
top=red/bottom=blue test image -> north samples red (correct); and on the real
asset, figure-north matches the source image's top rows. Geometry/parity
unchanged (median 0.40 / worst 0.68 mm over 3,526 verts).
…, not production

The compiled C++ Hydra scene index is modeled on the Gaussian-splat
hdParticleField example: a second, illustrative runtime that proves the
codeless schema resolves through Hydra and agrees with the Python reference
to sub-mm. It is NOT a prescription of how a production renderer must consume
the schema. Reword README/SI-README/TLDR/plan/figure labels + source header
comments accordingly; regenerate docs/multi_runtime.png with the new label.
Promote the architectural positioning that had been living only in the PR
review guide into the canonical README: a 'Where the Python reference runtime
sits' subsection (executable spec / conformance oracle; not USD-core, not a
runtime dep, no exact precedent by design) and an 'Open design questions for
the working group' section (codeless-schema+reference-runtime shape; coexist
vs hooking CRS into UsdGeomXformable). The README is now the single source the
PR body and slide deck derive from.
Add invisible <!-- slide:* --> markers to the README and a build_deck.py
that parses them into a 13-slide presenter deck (docs/usdGeospatial.pdf):
title -> thesis -> design-call (coexist) -> anchor/UsdGeomXformable -> 3 hero
proofs (railway, coherence, two-runtimes) -> where-the-Python-sits -> open
questions. Dense sections carry a concise deck-authored body= summary so the
long-form README prose stays in the README. README is now the one grounding
doc the PR body and the deck both derive from. deck.html is gitignored
(intermediate); the PDF is the committed artifact.
…uard-rail framing

Add an adversarial head-to-head that rebuilds a multi-CRS scene (MoMA in
NAD83/UTM-17N nested under a WGS84/UTM-30N anchor) both baked (resetXformStack +
double3 translate) and neutral (crs:binding + crs:position), measuring each
against independent closed-form pyproj geodesy. No approach is graded against the
other; assertions are approach-vs-ground-truth only.

The harness found a real defect: for a PROJECTED anchor, resolve_with_injection
lifted grid-authored child offsets through the anchor's true-ENU basis, which
differs from grid axes by convergence + point-scale (~4.86 m over a ~420 m lever).
Fix: crs_engine gains is_projected() + project_grid_offset_to_target(); the
resolver composes projected-anchor child offsets IN-PLANE (grid add + reproject)
and keeps the true-ENU topocentric path for geographic/ECEF anchors. Both
approaches now land the corner to 0.0 mm; the geographic NYC path is unchanged.

README: add "Composition frame: projected vs geographic anchors" (the correctness
rule + found/fixed story), a "Guard rails" section (anchor-vs-child, compose in
the CRS-implied frame, a requires-CRS stage marker + detect-and-refuse, all
validator-checkable), a "What we'd ask of Esri and co-collaborators" section, and
reframe open-question #2 around the residual conformance surface. Rebuild the
slide deck from the README markers (18 slides).

- src/crs_engine.py: is_projected(), project_grid_offset_to_target()
- src/resolve_runtime.py: projected-anchor in-plane composition branch
- src/test_coexist_vs_baked.py: the head-to-head harness (new)
- testenv/world_multicrs_{baked,neutral}.usda: re-authored from public EPSG WKT
- README.md, docs/usdGeospatial.pdf: framing + derived deck
Make the "guard rails" concrete: for each of the three coexist invariants, show a
deliberately ill-formed asset, the exact failure measured against independent
closed-form geodesy, and the minimal authoring fix that resolves to truth.

src/test_illformed_assets.py (new):
- G1 anchor-vs-child: a corner authored as its OWN georef leaf and parented to be
  relative jumps to its own CRS point (418.9 m off); fix = plain Cartesian child
  (xformOp:translate) -> 0.000 mm.
- G2 compose-in-CRS-frame: a projected-anchor child composed through true-ENU
  bends by grid convergence + point-scale (4.86 m off); fix = compose in the grid
  plane + reproject -> 0.000 mm.
- G3 requires-CRS marker: an unmarked scene under a CRS-unaware consumer lands
  6,369 km off, silently; fix = stamp customLayerData['crsResolutionRequired'] so
  a conformant consumer detects-and-refuses.

verify.py: replace the blunt "any xformOp == baked" check with precise neutrality
(A1: no authored resetXformStack; A2: no georef prim bakes an xformOp onto itself
-- Cartesian children may), and add check G (a stage with crs:binding must declare
customLayerData['crsResolutionRequired']). Neutral fixtures + the earth2 generator
now stamp the marker; both baked scenes correctly fail A1 under the validator.

README: add "What breaks, and the fix" under Guard rails (runnable before/after for
each invariant) with a slide marker; rebuild the deck (19 slides).
…ope note

Add src/render_illformed.py, which renders three before/after figures (and a
stacked gallery) directly from the measured test_illformed_assets.py quantities:
  illformed_g1.png  anchor-vs-child   417.8 m off  -> 0.000 mm
  illformed_g2.png  wrong frame       4.86 m off   -> 0.000 mm
  illformed_g3.png  no marker         6,369 km off -> detect-and-refuse
Each plots where the object lands vs the closed-form-geodesy target (green star),
with the error distance called out; nothing is hand-drawn for effect.

README: embed the three figures under "What breaks, and the fix", add a gallery
slide:image marker for the deck, and add a subsection anticipating the debate the
requires-CRS marker will draw -- including the layer-metadata vs prim-schema
question (customLayerData: cheap O(1) up-front detect-and-refuse but coarse and
untyped; a prim applied schema: typed/validatable/local but needs traversal). Our
lean: a stage/layer signal for the cheap check, optionally refined per-prim; a WG
call. Rebuild the deck (21 slides).

PR body (PR_REVIEW_GUIDE.md, uncommitted): embed the gallery + link the test.
…mbers; tidy seams

Review pass over the README after the layered ill-formed/visuals/marker edits, to
remove Frankenstein seams from multiple edits:

- render_illformed.py: label each figure with the exact 3D ECEF distance the test
  measures against pyproj geodesy (via _localized_pair), instead of the figure's
  own 2D ENU-panel hypot. G1 now reads 418.9 m (was a 417.8 m reprojection
  artifact) so figures, slide, prose, and gallery caption all agree with
  test_illformed_assets.py (418.9 m / 4.86 m / 6,369 km). ENU coords still used for
  marker placement + zoom only.
- README: add blank lines around the three inline break/fix images so the numbered
  list keeps its continuity across renderers.
- Verified: all H2-H4 headings unique, no TODO/placeholder cruft, and every
  internal anchor link resolves against GitHub's slug rule (incl. the em-dash
  double-hyphen anchor). Deck rebuilt (21 slides). Full gate green.
…xform-cache thread-safety fix

- renderStormGeo.cpp: offscreen Storm render of the georeferenced railway with the
  UsdGeospatialSceneIndex inserted in the Hydra chain (SetStage explicit); --si/--nosi
  toggle renders the same stage twice from an identically-framed camera.
- Proof PNGs: usdview_storm_proof.png (SI on -> railway at ECEF world position, drawn by
  Storm) vs usdview_storm_noSI.png (SI off -> geometry at raw local origin, off-frame).
  Negative control confirms the scene index is what places the geometry; Storm draws both.
- geoResolver: guard UsdGeomXformCache access with a mutex. Storm syncs rprims across TBB
  worker threads and pulls the xform data source in parallel; the cache is not thread-safe
  (concurrent mutation -> double-free). Required for any real Storm render.
- probeHydra.cpp: diagnostic confirming crs: attrs are absent from the default Hydra data
  stream (motivates the APISchemaAdapter needed for usdview auto-insertion / Level 1).
- README: label every figure as a Matplotlib plot of resolved coordinates (not a Hydra/Storm
  render), so the plots are not mistaken for renderer output.
…er (Level 1)

The scene index now resolves georeferenced geometry AUTOMATICALLY when a stage is
opened in usdview/usdrecord -- no SetStage, no hand-built chain -- matching how the
hdParticleField example just works.

- geospatialAPISchemaAdapter.{h,cpp}: a keyless UsdImaging API-schema adapter
  (apiSchemaName "", the codeless-schema pattern; modeled on coordSysAPIAdapter)
  that surfaces crs:binding / crs:position / crs:wkt into the Hydra data stream so a
  downstream scene-index filter can see them. This was the missing piece: crs: attrs
  are absent from the default Hydra stream (see probeHydra).
- geospatialSchema.{h,cpp}: shared crs: token/locator definitions.
- geospatialSceneIndex / geoResolver: added a stage-free resolve path that reads
  entirely from Hydra data sources; the SetStage path is preserved as a fallback so
  renderStormGeo.cpp and the parity harness still work.
- plugInfo.json: register the API-schema adapter (keyless, isInternal).
- testHydraAutoParity.cpp: parity through the Hydra auto path == closed-form geodesy
  and the Python oracle, 30/30 rows 0.0 mm (matches the by-hand chain).
- Proof PNGs (usdview + usdrecord): auto-insert -> railway at ECEF world position vs
  negative control (plugin path removed) -> geometry at origin / absent.

Scope note: the Hydra auto path resolves direct crs:binding (+ nearest/stronger);
collection-based and purpose strength remain stage-path only for now.
Rewrite the example README from the accreted 21-section/617-line form onto a
designed ~8-section spine that leads with the proofs a reviewer can check,
consolidates the recurring resolve-don't-bake / resetXformStack framing, and
folds in the two landed proof points that were previously tracked separately:
the real Hydra Storm render and the true auto-insert into usdview (keyless
UsdImaging API-schema adapter, stage-free Hydra-auto path, 30/30 at 0.0 mm).

All proofs/numbers/caveats preserved (418.9 m, 4.86 m, 6,369 km, 162 mm,
480,000x, 0.0 mm parity, NOAA NCAT, Deutsche Bahn no-overfit, thread-safety
fix, guard rails G1/G2/G3, the two open WG questions). Slide markers
re-storyboarded deliberately; deck re-derived from README (22 slides).
…rfit renders

Visualize non-geometric georeferenced data (e.g. the earth2 GFS t2m field:
7,320 crs:position samples with no intrinsic shape) WITHOUT mutating or
duplicating the dataset, via USD composition: visualize_field_glyphs.py writes
a SEPARATE overlay layer that subLayers the pristine base and adds marker
geometry as geometry-only 'over' prims (zero crs:* re-authored). The auto-
inserted scene index resolves each glyph as a Cartesian child under its georef
prim (the same anchor-injection path test_anchor_injection.py already proves),
so the base data stays byte-identical and the overlay is fully removable.

This reframes non-visual geospatial data as a first-class, common use case and
demonstrates the codeless schema composes correctly across USD layers. Marker
size is a stated display parameter (analogous to a scatter's point size), with
a sparsity option to sparsely overlay geometry.

Anti-overfit render set: the SAME script + the SAME unchanged scene index place
glyphs correctly across 5 CRS families / both hemispheres / equator-to-78N
(NYC UTM-18N, Sydney UTM-56S, Wellington NZTM2000, Quito ~equator, Svalbard
~78N), zero code changes between locales. Plus the earth2 globe cross-visualizer
parity (matplotlib scatter vs Storm render) and refreshed railway/level1 renders.

Negative controls: no-plugin renders are empty/origin (SI does the placement).
Parity re-verified GREEN: run_parity.sh 30/30 0.0mm, testHydraAutoParity 30/30.
Retires make_renderable_globe.py (baked-geometry approach) in favor of overlays.
…overlay section

Add a section documenting non-geometric georeferenced data (scalar fields,
sensor networks, benchmarks) as a first-class use case, visualized via a USD
composition overlay: a base layer holds the pristine dataset; a separate
overlay subLayers it and adds marker glyphs as geometry-only 'over' prims
(zero crs:* re-authored, base byte-identical, overlay removable), placed by
the existing anchor-injection path (no new code). Includes the same-data/
two-visualizer globe parity and the same-runtime/many-CRS anti-overfit render
set (NYC/Sydney/Wellington/Quito/Svalbard; 5 CRS families, both hemispheres,
~equator-to-78N; zero code changes). Deck re-derived (26 slides).
The README hero pointed at usdview_level1_autoinsert.png, a 601x465 usdview
window screenshot (UI chrome, low-res). Point it instead at the clean 1280x1280
offscreen Storm render, and drop the two low-res usdview screenshots.

Rename the auto-insert renders off the internal 'level1' milestone label to
durable names:
  usdrecord_level1_autoinsert.png -> railway_storm_autoinsert.png
  usdrecord_level1_noplugin.png   -> railway_storm_noplugin.png
Update README + deck + PR-guide refs; tighten the hero caption (drop the
screenshot-coverage and internal SetStage-fallback wording). Deck re-derived.

(First of two passes; the README voice/register cleanup follows separately.)
Register-only pass (no re-structure, no facts changed): strip
implementation-history narration a reviewer never needs while keeping every
caveat as a present-tense property of the design.

- Proof 4: drop the SetStage-fallback aside ('stage-free path added alongside
  the SetStage path, preserved as fallback'); state the thread-safety as a
  present-tense fact (mutex-guarded XformCache) instead of 'getting Storm to
  render surfaced a bug'.
- Projected-vs-geographic rule: rewrite 'an earlier revision used the ENU lift
  ... the harness caught it' as a present-tense property (compose through
  true-ENU instead of the grid plane -> 4.86 m off; the test asserts frame
  selection). Same fact, no changelog.
- Guard rails / open questions: 'the adversarial testing surfaced' -> present
  tense; drop 'now'/'no longer' temporal phrasing.
- Running: drop the 'Tier 1/Tier 2' plan-numbering labels (keep both run modes).
- Status: 'Done:' checklist -> 'It demonstrates:' present-tense properties.

All numbers/proofs/caveats preserved (418.9m/4.86m/6,369km/162mm/480,000x/
0.40mm/0.0mm/NCAT/DB no-overfit/thread-safety/9-9/30-30/5-CRS anti-overfit/
composition-overlay). Deck re-derived (26 slides, unchanged; markers untouched).
PR body kept in sync (same SetStage->'no application code' edit); all 8 README
anchors verified against current slugs.
…-> resolved-position plot)

The multiCRS_glyph_renders.png montage stacked five auto-insert renders that
were pixel-identical: each locale's camera centers its glyph, and the scene is
otherwise pure black, so a centered dot at NYC is byte-identical to one at
Svalbard. All five overfit_<locale>_autoinsert.png had the same md5. The figure
looked like five CRS families but conveyed one, and read as the opposite of the
anti-overfit point it captioned. (The inputs were correct -- distinct per-locale
bases + cameras + 0.95%-vs-0%-control coverage -- but nothing varied visibly.)

Replace with src/fig_multicrs_positions.py: a Matplotlib plot (consistent with
every other figure) of WHERE each authored point resolves in ECEF, through one
code path (lon/lat -> the locale's projected CRS -> ECEF via PROJ, the registered
default engine). The five land 2,200+ km apart -- NYC/Sydney/Wellington/Quito/
Svalbard, 5 CRS families, both hemispheres, equator->78N -- with the authored
EPSG the only thing that differs. Self-checks the points are distinct; verified
sub-mm vs closed-form geodesy in generalization_suite.py.

- README: retitle 'render set' -> 'position set'; rewrite prose + caption + alt
  text as a resolved-position plot; add fig_multicrs_positions.py to the run list.
- Remove src/montage_overfit.py + the 6 identical overfit_*.png.
- Keep render_overfit_glyphs.sh + overfit_glyph_suite.py as render infra (noted)
  for a future real-globe render montage (Option A).
- Deck re-derived (26 slides, new caption); PDF refreshed.
- PR body prose synced ('render set' -> 'position set'); all 8 README anchors
  re-verified.
…nonclaw)

- matplotlib framed as same-seat consumer of resolver output (not consuming a stage)
- drop 'executable specification' -> conformance oracle; normative behavior lives in prose;
  cite AOUSD core-spec-supplemental precedent
- soften two-runtime 0.0mm to robustness/first conformance pass; correctness carried by
  NCAT + closed-form geodesy
- coexist: usdchecker validator plugin promoted to committed near-term deliverable; lost
  'just works' stated as eyes-open tradeoff (loud+recoverable vs baking silent+unrecoverable)

No design/facts changed, no headings moved (PR-body anchors intact). Deck re-derived (26 slides).
…ful about the claim')

Two internal-review phrasings that read meta to a human reader; prose-only,
no slide markers touched (deck unchanged), no facts/design changed.
…sdGeospatial), cross-platform

Make the usdGeospatialSceneIndex example an opt-in, cross-platform build like
hdParticleField -- one build_usd.py flag builds PROJ and the plugin, replacing
the Linux-only shell scripts. Validated on Linux and Windows (MSVC).

Build:
- build_usd.py: --usdGeospatial builds PROJ (+ its SQLite3 dependency) as opt-in
  third-party deps, then passes -DPXR_ENABLE_GEOSPATIAL_SUPPORT to the USD build.
- cmake/defaults: PXR_ENABLE_GEOSPATIAL_SUPPORT option + find_package(PROJ).
- Register the example under extras/usd/examples (gated on the flag + imaging);
  fix its CMakeLists: add the missing geospatialAPISchemaAdapter/geospatialSchema
  sources, define USDGEOSPATIAL_SI_EXPORTS, DISABLE_PRECOMPILED_HEADERS, link
  PROJ::proj.

Parity as a ctest:
- run_parity.py + pxr_register_test(testUsdGeospatialParity): CRS engine 9/9,
  stage resolver 30/30, Hydra scene index 30/30, stage-free auto-insert 30/30 --
  all 0.0 mm. dumpHydraXforms built for the runtime_parity figure.
- dump_oracle.py auto-converts the committed railway asset so the oracle is the
  full 30-row set.

Cross-platform figure/doc tooling:
- fig_runtime_parity.py / render_figures.py: portable dumper + tsv paths
  (GEO_HYDRA_TSV / GEO_HYDRA_DUMPER, system temp, os.pathsep) -- no /tmp or
  /usr/share/proj hardcodes.
- docs/build_deck.py: read the README as UTF-8 (Windows).
- probe_autoinsert.py: drop dead Hd/HdGp imports.

Docs (README is the base artifact; deck derives from it):
- README build sections -> the cross-platform build + ctest; add a
  reproducibility slide; regenerate the deck PDF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@asluk
asluk force-pushed the aluk/geospatial-crs-prototype branch from c1daef2 to 6f276df Compare July 22, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.