Skip to content

Usd import rework - #358

Merged
jeffamstutz merged 78 commits into
next_releasefrom
usd-import-rework
Aug 19, 2026
Merged

Usd import rework#358
jeffamstutz merged 78 commits into
next_releasefrom
usd-import-rework

Conversation

@jeffamstutz

Copy link
Copy Markdown
Collaborator

No description provided.

@jeffamstutz
jeffamstutz requested a review from tarcila August 14, 2026 02:58
jeffamstutz and others added 25 commits August 14, 2026 11:33
Records the design for reworking USD Stage import, derived from a fresh
look at TSD and USD together rather than from the existing importer.

ADRs:
- 0015: consume a Hydra scene index rather than traversing UsdGeom and
  UsdShade directly, so composition, purpose/visibility, instancing,
  material binding, primvar interpolation and skinning are resolved by
  OpenUSD instead of being reimplemented
- 0016: bake prototype-internal transforms, since the render index never
  pushes a transform-array node's matrices onto the transform stack
- 0017: deviate from usdview defaults for purpose and subdivision, and
  record why neither deviation is a bug
- 0018: let imported scenes retain an open UsdStage so deforming
  geometry can be bound lazily instead of baked per frame

Glossary:
- Add a Foreign-Format Import section to the TSD I/O context, grouping
  the existing terms under Native Persistence, and reserve "Layer" and
  "Instance" for their TSD and ANARI meanings

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The USD importer rework consumes OpenUSD's own resolved scene through a Hydra
scene index (ADR 0015), so tsd_io needs the imaging, scene-index filter, and
image libraries, plus OpenSubdiv for subdivision refinement.

TSD_USE_USD becomes a public compile definition in both its enabled and
disabled forms so that consumers -- the test target in particular -- can
compile conditionally against USD support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace the hand-written UsdGeom/UsdShade traversal with a converter over
OpenUSD's own resolved scene (ADR 0015). Composition, Purpose and visibility
resolution, native and point instancing, material binding, primvar
interpolation, implicit shapes, NURBS approximation and skinning are resolved
by OpenUSD before TSD sees them; TSD converts already-resolved prims into
Scene objects and Layer nodes.

The entry point gains a typed options value and returns an Import Report, so
"content was silently dropped" becomes an assertable condition rather than
something to notice by eye. Prims that do not become renderable content leave
a named, disabled Placeholder Node at their place in the hierarchy, tagged
with why, and are counted by reason in the report.

What this changes for a user:

- Point instancers import, as one transform-array node over shared Prototype
  objects; placements USD marks invisible are omitted.
- USD Instances share one converted Prototype across placements, with each
  gprim's Prototype-relative transform baked into its vertex data (ADR 0016).
- Guide and proxy Purpose are excluded by default and render is included
  (ADR 0017); exclusions are reported per Purpose and are configurable.
- Prims resolving to invisible import as disabled nodes rather than vanishing.
- Meshes tessellate through OpenUSD's topology-aware utilities, so non-convex
  polygons and holes are handled; per-face material subsets become several
  Surfaces sharing the mesh's vertex arrays.
- The UV primvar comes from the material's own reader node instead of being
  assumed to be named "st"; texture colour space is honoured.
- Unmaterialed prims take display colour and display opacity.
- Light intensity accounts for exposure, normalization and colour temperature;
  shaped sphere and disk lights become spot lights; dome orientation is baked
  into the light's own direction and up.
- Sphere, cone and cylinder stay analytic; capsule, cube and plane become
  meshes.
- Subdivision surfaces are refined with OpenSubdiv, carrying vertex primvars
  through the same refinement and honouring creases, corners and holes.
- Transform animation uses the authored sample times, densified only where a
  two-key spherical interpolation would collapse the rotation.
- Deforming geometry imports one eager frame plus a file-backed binding that
  re-pulls from the retained Stage, and survives save and reload (ADR 0018).
- Stage up-axis and unit scale are recorded on the import root; coordinates
  are left exactly as authored.

Two open questions from the spec are settled here. Refinement applies only to
meshes that explicitly author a subdivision scheme: USD's schema default is
catmullClark for every mesh, so refining unauthored meshes would silently
distort ordinary polygon meshes. And OpenUSD's imaging libraries carry no GL
or X11 link dependency, so headless and CI builds are unaffected.

The TSD dialect stays a claim-and-prune pre-pass: markers on the raw Stage
claim whole subtrees, which are pruned from the resolved scene and routed to
the existing EnSight, volume, transfer-function and render-settings handlers,
so a carrier prim is never also converted as generic geometry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correctness:

- Cameras rescaled their bindings onto raw USD time codes while transform
  bindings used the Stage's normalized clock, so a Stage with both animated on
  different clocks. Both now go through one shared rescale.
- Cameras and dome lights read their pose at UsdTimeCode's default rather than
  the Stage's start of time, reintroducing the very bug this rework set out to
  remove: values authored only as time samples did not resolve.
- The deforming-geometry binding picked its frame off an even grid, undoing
  the authored spacing it had just stored. It now selects the authored sample
  the current time falls in.
- Prototypes whose internal transforms are animated converted their geometry
  once per placement. Baking is what an animated Prototype gives up, not
  sharing: its gprims now convert once and each placement references them.
- Transform-stack resets were recorded as an instance parameter nothing reads,
  so the parent transform still composed. A resetting node now cancels the
  accumulated ancestor transform, keeping its place in the hierarchy while
  landing where USD puts it.

Completeness:

- Subdivision refinement carries face-varying and uniform primvars through the
  same refinement instead of dropping them, so a textured subdivision mesh
  keeps its UVs -- and stops reporting a false skip that broke the empty
  skip-list invariant. Holes are mapped onto the refined faces.
- Points, curves, and quadrics take the display-colour fallback that meshes
  already had, rather than falling back to TSD's default material.
- Time-varying visibility and time-varying material values are now reported.

Cleanups: one shared clock helper, one shared native-instancing root, one
shared material-mode conversion (the Lua binding had grown its own copy), and
shared helpers for position baking, width-to-radius conversion, and material
binding lookup. Skip-reason counts derive from the enum instead of a parallel
list that could silently fall out of date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The OpenUSD install now ships hdMtlx, so the MaterialX emission mode does the
thing the spec asked for: the document is generated from the resolved network
by OpenUSD's own conversion rather than rebuilt by hand.

The mode is gated on what the OpenUSD build actually has. CMake looks for the
hdMtlx header and the MaterialX package and sets TSD_USD_HAS_MATERIALX
accordingly; where either is missing the importer keeps reporting the mode as
unavailable and emits portable materials, so installs without MaterialX still
build. The flag is public for the same reason TSD_USE_USD is: the test guards
itself on it. pxrConfig already points MaterialX_DIR at the build OpenUSD was
linked against, so no extra hint is needed.

One thing the implementation had to learn: MaterialX has no node definition
for UsdPreviewSurface, and asking hdMtlx to convert such a terminal anyway
produces a document that fails MaterialX's own validation -- a material node
with no category. The converter now checks for a node definition first and
falls back to the portable mapping, reporting that it did, rather than
emitting a document no renderer can consume.

Tests cover both paths: an authored MaterialX network passes through as an
inline document whose material name selects into it, and a preview-surface
network falls back with the fallback named in the Import Report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The importer has had a MaterialX emission mode since the previous commit, but
nothing an application could reach set it: every import path in the tree calls
import_USD with default options, and the only thing that could pass anything
else was the Lua binding. A Stage whose materials are authored as MaterialX
therefore imported as untextured defaults -- the OpenPBR terminal shares no
input names with UsdPreviewSurface, so the portable mapping had nothing to
read and every material fell back to a flat base colour.

USD_MATX sits beside USD the way ASSIMP_FLAT sits beside ASSIMP: a second
Importer Type naming the same reader with one option set. It is exposed
wherever an Importer Type is named -- the -usd_matx command-line flag, both
import dialogs, and SciVis Studio's project-file mapping so a project that
loaded one reloads as one.

Fixing the viewer's import dialog was a prerequisite rather than a bonus. It
cast the combo index straight to ImporterType against a hand-maintained list
of labels that had fallen one entry short of the enum, so every choice from
SWC_SDF on named one importer and ran another -- picking "USD" imported TRK.
Adding an entry would have shifted the skew rather than fixed it, so the
labels now carry the type they select. The trailing "TSD" label went with it:
no Importer Type backed it, it ran VOLUME_ANIMATION, and Scene Archives load
through their own path.

Two neighbouring cases were missing from SciVis Studio's toString, which is
also what importerTypeFromString round-trips through: PBRT and SWC_SDF
serialized as "NONE" and could not be reloaded.

Tests pin the dispatch rather than only the option it sets, since the dispatch
is the part that was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenUSD's conversion writes each texture filename exactly as it was authored
-- SdfAssetPath::GetAssetPath(), not the resolved path -- and hands back the
texture nodes it wrote so the consumer can finish the job. Every Hydra render
delegate does finish it; TSD did not. The document then travelled to the
device as inline text, which has no file of its own for a relative path to be
relative to, so a filename like "textures/wood.png" named nothing at all.

The anchor is the one the rest of this importer already uses for textures: the
resolved path where the Stage's resolver produced one, and the Stage's own
directory otherwise. The fallback is not a nicety -- a UDIM path names a set
of tiles rather than a file, so no resolver ever resolves it, and in the asset
this was found with that is 92 of 117 texture inputs.

A path that survives anchoring and still names nothing is now reported as
TEXTURE_LOAD_FAILED against the material that asked for it. The device that
opens the document later cannot say which prim wanted the file, and silent
loss is the failure mode this whole area keeps producing.

This is necessary but not yet sufficient: the paths that reach the device are
now correct and absolute, verified against the asset, but the device's MDL
resource resolution does not accept absolute host paths without a matching
mdlResourceSearchPaths root, and has no UDIM expansion. Those are device-side
and unaddressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correct paths in the document turned out not to be enough, because the device
does not open the files at all. Its MaterialX material publishes every
`filename` input under the input's document path and reads texels from an
ANARI sampler bound to that name -- the device's own test says so plainly:
"The file input carries no resolvable path; the bound sampler supplies the
texels." Nothing bound one, so a MaterialX import produced no samplers at all
while a preview-surface import of the same Stage produced them normally.

The import now loads each texture through the same importTexture and cache the
preview-surface path uses, so a texture shared between materials is read once,
and binds the result under the input's MaterialX element path. The colour
space comes from the document rather than being assumed: MaterialX names the
encoding per input, and only an sRGB encoding is de-gamma'd.

The absolute paths stay. They cost nothing, they are what any consumer that
does resolve files would need, and for a tile set -- where no sampler can be
bound -- they are all the document carries.

Two limits are now reported rather than silent, both of which this asset hits:
a tiled (UDIM) path names a set rather than a file and nothing in the stack
expands one, and TIFF is not a format the image loader decodes. Both arrive as
TEXTURE_LOAD_FAILED naming the file and the material that wanted it.

Verified against the asset this started with: the device accepts every binding
(no "sampler not bound" warnings), and of its 117 texture inputs 21 now bind,
89 are reported as tiled, and 7 as TIFF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stb has no TIFF decoder, so `importTexture` fell through to it for
`.tif`/`.tiff` and every such texture failed to load. On the OpenPBR
Shader Playground asset that cost 7 of 117 MaterialX texture inputs
(the `walls`, `iceCube` and `iceCubesInner` materials).

Rather than take on a format-specific decoder, dispatch to OpenImageIO,
which is already installed alongside the OpenUSD builds this importer
targets and covers every other format it has a reader for. It is gated
behind `TSD_USE_OIIO` (default OFF) like the other optional
dependencies; with it off, `.tif` now reports that TSD was built without
a decoder instead of surfacing an opaque stb decode failure.

OIIO hands back raw normalized values where stb applies the sRGB
transfer function via `stbi_ldr_to_hdr_gamma()`, so the decode is
applied here, alpha untouched, to keep all texture paths on one
contract. `TSD_USE_OIIO` is a PUBLIC definition so the test can assert
the decoded path only where a decoder exists -- the same widening
`TSD_USE_USD` already makes.

Measured on the reference asset, with `TSD_USE_OIIO=ON`: skipped prims
went from 96 texture-load failures to 89, and every one that remains is
a `<UDIM>` tile set, which is a separate gap. Zero `sampler not bound`
warnings from the device. Full suite 30/30.
anari_cpp maps only `const char *` onto ANARI_STRING, so
`ANARITypeFor<std::string>::value` is ANARI_UNKNOWN and every generic
Any template silently missed strings: `is<std::string>()` reduced to
`is(ANARI_UNKNOWN)` and was false for every value ever held, and
`Any a = std::string("hello")` did not compile at all -- despite being
the example in Any.hpp's own header comment.

The visible symptom was Lua: `ref:getParameter()` returned nil for
every ANARI_STRING parameter, because ParameterHelpers reaches for
`val.is<std::string>()`. That made scripted inspection of string
parameters -- `source` and `materialName` on imported USD materials
among them -- impossible.

Fix it at the root, in Any, so the two RenderBindings call sites with
the same shape are covered too. The specializations are local to Any
rather than a global ANARI_TYPEFOR_SPECIALIZATION, which would leak the
mapping into anari_cpp calls that would then memcpy a std::string into
device storage. `get<std::string>()` routes through `getString()`,
since `getAs<>()` static_asserts on ANARI_STRING for exactly that
reason.

Verified against a freshly built `tsdLua`: the new
`ref:setParameter with string` case fails on the pre-fix binary and
passes after. Unit coverage added at the Any level. Full suite 30/30.
89 of the OpenPBR Shader Playground asset's texture inputs are `<UDIM>`
tile sets, and after TIFF decoding landed they are the entirety of the
importer's remaining texture failures. The obvious next move -- lean on
MDL's native `<UDIM>` support and let the device resolve the tiles --
does not work, and the reason is not visible from TSD, so record it.

VisRTX does load MDL texture resources from disk
(`SamplerRegistry::loadFromImage` stb-loads `textureDesc.url`, which
corrects the "the device does not open texture files" reading -- that
holds only for the sampler-bound MaterialX path). But
`libmdl::Core::resolveResource` returns `get_element(0)->get_filename(0)`,
one filename, while MDL's entity resolver returns one element per tile;
all but the first are discarded, and `loadFromImage` then builds exactly
one `Image2D`. Nothing under `devices/` carries a UDIM concept at all.

So `mdlResourceSearchPaths` -- the seam that would clear the cosmetic
`Failed to resolve texture resource` log noise -- would not change the
outcome, and TSD-side tile expansion has nowhere to send the texels
either: an ANARI sampler is a single image. Binding tile 1001 and
dropping the rest was considered and rejected; it turns a reported gap
into a silently wrong render for any mesh spanning more than one tile.

The gap stays reported, with the anchored absolute path still written
into the document so a consumer that gains UDIM support finds it
well-formed. Revisit when ANARI grows a tiled or array sampler, or when
the MDL runtime grows tile-indexed lookup.
Review of fca25dd turned up three ways the OpenImageIO decode diverged
from the stb paths it claimed to match, each of which would have shown
up only on a TIFF with alpha or a float TIFF -- shapes the 1x1 RGB8
fixture never exercised.

Alpha was being gamma-corrected on 2-channel images. stb's rule is
`if (comp & 1) n = comp; else n = comp-1` (stb_image.h:1573), so an even
channel count ends in alpha and an odd one is all colour; `min(n, 3)`
agrees for 1, 3 and 4 channels but not for grey+alpha. The comment
claiming "alpha stays untouched, matching stb" was therefore false for
exactly the case it was written about.

OpenImageIO also premultiplies unassociated alpha into the colour
channels by default, which stb never does; the new grey+alpha fixture
caught it. Ask for the file's own values with `oiio:UnassociatedAlpha`.

Third, float and half TIFFs already carry linear values, and stb only
ever gamma-decodes integer input, so the curve is now applied only when
the file's format is integral.

Also from review: rename `srgbToLinearInPlace` to `applyGamma22InPlace`,
since it applies pow(x, 2.2) rather than the true sRGB EOTF -- matching
stb is right, the old name overclaimed; extract the channel-count to
ANARI-type cascade, which was duplicated verbatim between the stb and
OIIO decoders; drop three redundant `image->close()` calls, as the
unique_ptr's deleter closes on every exit path; reorder the Any
specializations so `is<std::string>()` precedes `get<std::string>()`,
which removes an ordering hazard and the comment explaining it; record
in io/CMakeLists.txt why OpenUSD's Hio was rejected in favour of
depending on OpenImageIO directly; and list `TSD_USE_OIIO` in AGENTS.md
alongside the other optional flags.

Adds docs/usd-materialx-known-gaps.md for the MaterialX import
observations that are understood but undiagnosed -- the single
'Surface' nodedef transcode failure, the geometry_opacity version skew,
and the cosmetic MDL resolve noise -- so they survive the handoff.

Full suite 30/30, 74 Lua cases pass, and the reference asset is
unchanged: 89 skips, all UDIM, zero `sampler not bound`.
…oise

Setting the parameter was tried against the OpenPBR Shader Playground asset
and is blocked from both ends. Anchoring on the texture directories the
importer already knows is a measured no-op -- MDL resolves a leading `/`
root-relative, so the only root that can match a fully absolute host path is
`/` itself. Registering `/` does silence all 109 failures, but it also resolves
the 85 UDIM paths, and libmdl hands back tile 1001, which the device then
binds: the render changes while the import report still claims the tile sets
were skipped. That is the outcome ADR 0019 rejects.

No code change -- the gap stays open, now with the routes out of it named and
the dead one ruled out by measurement rather than left to be retried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One material out of 55 on the reference asset failed shader generation
inside the device with `Could not find a nodedef for node 'Surface'`,
then fell back to the default material with nothing in the Import
Report to say so.

`Surface` was a red herring: hdMtlx names the surface node after the
shader prim, so every emitted document has one. The material is
`/World/Looks/OJfoam`, and the defect is in the asset -- it connects
`geometry_opacity`, declared `float` on `open_pbr_surface`, to a
`color3` colorcorrect node. MaterialX matches a node to its definition
on category, type and the exact set of inputs, so one mistyped port
leaves the surface node resolving to no nodedef at all. MaterialX
1.39.6 rejects the source `.mtlx` standalone, so nothing between the
Stage and codegen introduced it.

This also reclassifies the `geometry_opacity` validation warning that
was recorded as benign OpenPBR version skew. It is neither: it is
MaterialX reporting this exact port, on the materials that author it
rather than on all of them.

TSD cannot fix the asset, so `documentResolves` checks every node
against the standard libraries before emission and reports a failure as
MATERIAL_RESOLUTION_FAILED naming the offending port, falling back to
the portable mapping. The check sits after the texture pass and before
sampler creation, which is load-bearing: checking first drops the
material's tile-set reports, since the fallback reads the network by
UsdPreviewSurface names and reports none of them -- ADR 0019's failure
mode by the back door.

Also adds `TSD_USD_MATERIALX_DUMP_DIR`, which writes each generated
document out named after its material prim. That is what isolated this,
and it is the tool to reach for when a device rejects a document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Mesh with GeomSubset children converted to one Surface per subset, and
each subset borrowed the parent's attributes through a hard-coded list of
vertex.normal, vertex.attribute0, and vertex.color. Face-varying data is
indexed by 3*triangle+corner against the mesh's whole triangulation, so a
subset -- drawing only some of those triangles -- cannot borrow it. USD
assets overwhelmingly author texture coordinates as faceVarying, so the
subsets that actually render carried no UVs at all, and samplers read
whatever attribute0 defaults to. Uniform data was lost the same way.

Attributes are now expanded onto the triangulation once per mesh and
gathered per Surface for the triangles that Surface draws. Vertex data
still shares one Array across every Surface, since for it the gather is
the identity.

Two consequences of binding per Surface, both intended:

- A subset resolves its own material's UV primvar name instead of taking
  whatever the mesh-level binding chose, and falls back to the mesh's
  answer and then to "st".
- Faces no subset claims become a Surface under the mesh's own material.
  They were previously built into a parent geometry that was never
  surfaced: invisible, and dead objects in the scene.

ADR 0020 records the design, including the pre-existing coarse-versus-
refined face mismatch that subsets on subdivision meshes still have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HdMeshUtil::ComputeTriangulatedFaceVaryingPrimvar reports Unchanged, not
Success, when every face is already a triangle, leaving the output value
untouched. Treating that as failure silently dropped every face-varying
primvar (UVs, normals) on pre-triangulated exports. Unchanged means the
flattened input is already one value per triangle corner, so bind it
directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TSD already stores every non-owning scene back-reference as a raw pointer --
Layer, AnariHandleCache, AnyObjectUsePtr, and the network messages all use
`Scene *m_scene{nullptr}` -- but the convention was only implicit in the code.
Write it down, with the lifetime rationale that motivates it: a reference
member deletes assignment and forces construction-time binding, which conflicts
with the movable-not-copyable lifetime declared everywhere else.

Scopes the rule to stored members so it does not contradict the File I/O
section, whose importer and exporter signatures take `Scene &` deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven decode paths in tsd_io each carry a private assumption about which
row of a decoded image is row 0. They cancel for glTF, ASSIMP, and PBRT
and do not cancel for OBJ and USD, whose textures render mirrored.

Record the survey, the ANARI-orientation contract, the ImageCache API,
the importer changes that must land with the flip, and the sequencing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assert the two halves of a correct texture fetch separately: that a
decoded array's row 0 is the picture's bottom row, and that the mesh
corner at the top of the picture addresses the picture's top row.

The suite is red on purpose. It confirms the survey in
docs/tsd-io-image-import.md from local evidence rather than from source
reading: glTF and PBRT pass the end-to-end assertion, OBJ and USD fail
it, and the storage assertion fails everywhere because every decode path
stores top-down today. The following commits make it green.

Fixtures are synthesized into the temp directory, following the TGA in
tests/test_UsdImport.cpp and the TIFF in tests/test_Importers.cpp. One
1x2 TGA serves every importer; its rows are stored bottom-up, so the
storage assertion cannot pass by a decoder's byte order happening to
agree with the contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven decode paths each did their own keying, Array creation, sampler
construction, and orientation assumption. Move all of them into
src/tsd/io/images: decoders report the row order their library produced,
ImageCache normalizes and stores, and makeImageSampler builds the
sampler. One cache key -- (id, colorSpace) -- replaces the three schemes
in the tree, and the cache holds the Scene it caches for so a cached
ArrayRef cannot reach a different one.

No behavior change: SCENE_ROW_ORDER is TOP_DOWN, which is what all seven
paths produced. The characterization suite fails in exactly the same
three places as before this commit.

Folded in while here, because they blocked the type change:

- glTF's private decode path becomes acquireDecoded, keeping its native
  element types and per-texture sampler settings. Its dead
  flipNormalMapY parameter, which only ever reached the cache key, is
  gone from all 18 call sites.
- PBRT's height-to-normal map stores through acquireDecoded.
- ASSIMP's embedded textures key off the texture id directly, so
  makeTextureCacheKey has no callers left.

ImageSource drops the displayName the proposal sketched: makeImageSampler
already takes the sampler's name, and ImageSource identifies content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Row 0 of a decoded array is now the picture's bottom row, so texture
coordinate (0, 0) addresses the lower-left corner, and importers hand
ANARI texture coordinates in ANARI's own convention. This is the commit
that fixes OBJ and USD textures rendering vertically mirrored.

Every part of it has to land together, because flipping the arrays
without the uv changes would just move the bug:

- ImageCache stores BOTTOM_UP.
- glTF flips v on vertex.attributeN, and on the raw accessor data it
  re-reads for tangent generation.
- ASSIMP drops aiProcess_FlipUVs; its own output is already v-up.
- PBRT drops `v = 1 - v` on trianglemesh and plymesh, and its uv
  transform loses the compensating `(1 - vs - vd)` term.
- OBJ and USD change nothing and become correct.
- SceneToUSD reverses rows writing PNG and EXR, both top-down formats.
- calcTangentsForTriangleMesh defaults flipTexCoordY to false.

Block-compressed DDS is the exception: 4x4 blocks cannot be row-reversed
without decoding and re-encoding, so its texels stay as authored and
makeImageSampler composes a v-flip into the sampler's inTransform and
inOffset. That is why makeImageSampler now owns those two parameters
outright and takes the importer's uv transform through SamplerSettings
-- ASSIMP's 14 sites, USD's uvTransform, and PBRT's uscale/vscale all
set them afterwards before, which would have silently dropped the flip.

PBRT's height-derived normal map negates its v gradient, since the row
axis it was taken over is now reversed relative to the fetching v.

The characterization suite is green. It also gained the two cases this
commit created the need for: the block-compressed flip, including that
it composes onto a caller's transform rather than replacing it, and
ASSIMP, which had no coverage at all and whose uv handling changed here.
TSD_USE_ASSIMP becomes PUBLIC so the test target can see it, matching
TSD_USE_OIIO and TSD_USE_USD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HDRImage was the last decoder creating Scene arrays on its own, and the
one place in the tree whose comment named an orientation -- the opposite
of what every other path then produced. It keeps its decode, because it
handles multipart EXR and forces three channels and the shared texture
path does neither, but it now declares the row order it emits and its
callers store through ImageCache::acquireDecoded. That gets HDRIs the
same keying and Scene-scoped lifetime as any other image, and caching
they had none of before.

The USD dome light bakes its radiometry scale into the texels, so it
keys on the colour as well as the file.

The new test covers the .hdr path end to end. PBRT's equal-area-to-
equirectangular conversion still consumes HDRImage's raw buffer directly
rather than a cached Image; it is correct as it stands, and rewriting
its frame of reference has no test to hold it, so it is left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one real defect: ASSIMP set `hasUvTransform` unconditionally, so
every ASSIMP texture got an explicit identity inTransform/inOffset even
where assimp authored no transform -- contradicting the flag's own
documented contract.

Standards:

- `Image::operator bool` and `DecodedImage::operator bool` move out of
  the struct bodies into Inlined definitions sections (parent SS5).
- `ImageCache` states its copy/move intent with the TypeMacros (SS6), and
  drops the private `lookup` returning `Image *` so one kind of lookup
  has one idiom (SS11 Fallible Returns).
- `SamplerSettings`'s `hasUvTransform` bool and the value it guarded
  become one `std::optional<UvTransform>` (SS7).
- clang-format had hoisted `<vector>` above the project headers in
  import_ASSIMP.cpp; restored.
- `src/tsd/io/CONTEXT.md` gains the vocabulary this work introduced --
  Image, Image Source, Image Cache, Row Order, Color Space.

Correctness and clarity:

- glTF image ids are scoped to their file, as the proposal specified.
  Harmless while each importer owns its cache; a landmine the moment one
  is shared.
- `import_HDRI` checks the acquired Image before dereferencing it.
- `gltfTexelType` drops a parameter it never read.
- ADR 0014 said glTF and PBRT both flip v. PBRT stopped flipping in
  c4106e7; corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The proposal doc read entirely in the future tense, which is misleading
now that most of it is in the tree. Add a status banner and a Status
table, mark the Survey as describing the tree at 017f7d9, and note at
each point where the implementation diverged from the plan: HDRImage
kept its own flip, and the DDS recommendation was ratified but shaped as
SamplerSettings rather than a helper callers must remember to call.

Add a Remaining work section covering the six items in rough payoff
order -- one cache per import rather than per importer, retiring the
shims, PBRT's infinite light, HDRImage's flip, native element types, and
the EXR/TIFF/equal-area test gaps -- each with what it needs and why it
did not land here.

Also record that ASSIMP binds no textures for OBJ. Its GL-style material
branch reads no texture slot at all, so an OBJ with map_Kd imports
untextured through ASSIMP and correctly through import_OBJ. Unrelated to
this work, found by writing its tests, and it will mislead someone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Width-less BasisCurves and Points prims (common in Blender hair
exports) previously left the geometry radius unset, inheriting ANARI's
default of 1 world unit — hair strands imported as giant overlapping
tubes. Fall back to an explicit radius of 1e-3 of the prim's own
bounding-box diagonal so strands stay hair-like at any scene scale;
authored widths convert exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeffamstutz
jeffamstutz removed the request for review from tarcila August 14, 2026 16:35
@jeffamstutz
jeffamstutz marked this pull request as draft August 14, 2026 16:35

@tarcila tarcila left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A few nit-pick comments and some questions WRT image orientation.
Side note that I was not able to build the branch with USD 25.05, it seems there is some OpenUSD 26.05 specifics.

Comment thread tsd/apps/tools/tsdOffline.cpp Outdated
Comment thread tsd/docs/adr/0014-store-images-in-anari-orientation.md Outdated
Comment thread tsd/docs/tsd-io-image-import.md
Comment thread tsd/docs/adr/0019-report-udim-tile-sets-as-unsupported.md
Comment thread tsd/docs/tsd-io-image-import.md
Comment thread tsd/src/tsd/io/images/detail/decoders.hpp
jeffamstutz and others added 19 commits August 19, 2026 10:07
The three texture shims took a `Scene &` beside the `ImageCache &` the image
came from, so a caller could name a Scene the image had never reached -- the
very thing an ImageCache holding its own Scene exists to prevent. They now
take the cache alone and read `ImageCache::scene()`, which nothing had called
until now.

`makeImageSampler` gains an overload taking the cache for the same reason, so
the two callers that acquire an image themselves -- glTF's
`importGLTFTexture` and PBRT's `importHeightAsNormalMap` -- cannot name a
second Scene either.

With no Scene needed to build a Sampler, it fell out of four importer-local
helpers that were only passing it along: ASSIMP's `importEmbeddedTexture` and
PBRT's `bakeTexture`, `bakeTextureSlot`, and `resolveTexture`.

Every ImageCache in the tree is constructed from the Scene that used to be
passed beside it, so no call site changes behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Remaining work" item 2 of the image-import note called for deleting
`importTexture` and its two siblings as pure forwarding. Now that they take
the ImageCache alone, they are what keeps the cache's Scene the only Scene a
caller can put a Sampler in, and they still carry the path normalisation and
colour-space choice their call sites share. Record the decision and its
reason in place of a plan the code has gone against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
import_HDRI decodes exactly one image per call, so the function-local
ImageCache it routed radiance through could never take a hit: it keyed a
single entry, copied the texels a second time into a DecodedImage, and
was destroyed at scope exit. Its acquireDecoded row-order normalization
was a no-op too, because HDRImage already emits the BOTTOM_UP order the
ImageSource asked for. The array now comes straight from the Scene, as
import_PBRT's infinite light has always built its own.

Behaviour is unchanged: store() ended in the same createArray + setData,
and the error path this drops was unreachable -- store() only fails on a
null Scene or an empty decode, and HDRImage::import already rejects a
zero-sized image.

The comment left in its place records why this path skips the cache, and
that ADR 0014 still describes an hdri light's radiance as reaching the
light through ImageCache -- true of the UsdLights dome-light path, which
keys on file plus radiometry and genuinely dedups, but no longer of this
one. Whether that ADR and the "owner of every decoded Image" wording in
io/CONTEXT.md should be amended is left for a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The DDS scenario asserted the sampler's subtype and its uv transform, but
nothing checked the two parameters that tell the device how to read a block
stream. The Array behind a compressed image is a flat run of bytes, so
`format` and `size` are the only description of the picture the sampler
carries, and a wrong or missing one renders garbage without failing a test.

Both values are what the path already produced; this only records them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ImageCache::store() wrote the block format and the picture's dimensions as
stringly-keyed metadata on the texel Array, and makeImageSampler read them
back untyped twenty lines later -- a side channel between two functions in
one file, when Image was already the thing being passed between them. Both
values now live on Image, where the ArrayRef and vFlipInSampler already did.

blockCompressed collapses into the format being non-empty: the DDS decoder
is the only producer of compressed texels and it yields no image at all for
a format it does not recognize, so the two could never disagree. store()
derives it the same way makeImageSampler does rather than copying the
decoder's bool, which keeps the one Image it builds self-consistent.

Nothing else read either metadata key -- serialization walks metadata
generically, and the device's BC table in mdl/SamplerRegistry.cpp maps from
MDL image formats rather than from these -- so the writes go away with the
reads, and with them the file-scope ANARI_TYPEFOR_SPECIALIZATION that
existed only to move the dimensions through an Any. The sampler's "size"
parameter is now set untyped, which is the one place that needed the
mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three readers of the UsdPreviewSurface network each open with the same
TfToken-or-string unwrapping of a shader parameter. That access belongs to
NetworkWalker, which already owns every other way the network is read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four branches of convertLight repeated the same three decisions -- the
subtype's object, the prim's name, and where the radiometry's colour and
brightness land. Only the brightness parameter actually differs, so it is the
one thing the caller still passes. The dome light stays hand-built: it has no
`color` parameter, and contorting the helper around that would cost more than
the branch does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Traversal::visit() wrote every prim on the Stage into
InstancerRegistry::nodeForPrimPath, a Stage-sized map serving a lookup
that attachNativeInstances() makes only for its own placement paths --
and that Stages without native instancing never make at all.

InstancerRegistry now discovers those placement paths up front, from the
instancers under the propagated-prototypes root, and recordNode() keeps
a node only when a placement will be attached to it. The map and the
lookup are private to the registry, so the general traversal no longer
reaches into the instancing feature's data structure directly.
This branch's additions landed between existing groups: UsdImport.cpp
and the images/ sources between animation/ and exporters/, and the three
animation/Usd*FileBinding.cpp inside the importers/detail/ block. Put
each back with its own directory, top-level source first, as the rest of
tsd's source lists are written. No source is added or removed.
The registry's placement-path discovery and attachNativeInstances() each
walked the propagated-prototypes subtree and filtered instancer prims out
of it themselves. The two have to agree on that set for a placement to
land on its own node, so give them one nativeInstancerPaths() to share
and let attachNativeInstances() keep only the filtering that is its own.
Splitting the USD import suite moved this baseline below the ImportedStage
construction that runs the import, so the count was sampled after the fact
and the REQUIRE compared it to itself. Take it from a fresh Scene, the way
the sibling camera case already does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The split left the eight suite files and UsdTestFixtures.h unformatted, on
the reasoning that formatting would bury the move. The move has landed, so
run clang-format over them. The two hand-laid-out byte tables -- the TGA
fixture and the EnSight coordinates -- are fenced off, since one value per
line reads worse than the grid they describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nanovdb::io::readGrid does not bound its own reads. On a file too short
to hold a header it retries against a stream already at EOF and never
returns -- an empty file, four bytes of text, and a truncated OpenVDB
grid all hang it indefinitely, with no timeout to escape through. A file
long enough to read a header out of throws cleanly, which is why this
went unnoticed: a real OpenVDB grid is fine, an interrupted download of
one is not.

Read the 8-byte magic first and reject anything that is not one of the
three NanoVDB numbers. Teaching import_spatial_field '.vdb' put this
behind the viewer's drag-drop, so the hang is now reachable by dropping
a stray file rather than only by naming one on the command line.

An OpenVDB grid says so specifically. '.vdb' means NanoVDB on this path,
which the extension gives no hint of, so the error names the converter
that turns one into the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Silo scenario stood alone for a change that also put '.hdf5' within
reach of a UsdVol field for the first time. Add its counterpart, and
assert on both that the dispatcher's own "no loader for file type"
fallback stayed quiet, so neither can pass on a file that reached no
importer at all.

Both now write the file they name. It only has to exist: a filePath
that resolves to nothing stays the bare name the Stage authored, and
import_FLASH reads no such name, so the scenario would have measured
that rather than which importer the extension chose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fileOf() scanned for the last path separator and answered "" when there
was none, so a bare 'volume.raw' -- the form anyone types for a file in
the working directory -- reported no file at all. Six importers guard on
that result before doing any work, which made them silent no-ops; the
thirty-odd call sites that only wanted a display name got an empty one,
including every Sampler an importer names by a scoped id rather than a
path.

Hand both halves to std::filesystem::path, which knows a path may carry
no directory and, on Windows, that '/' separates as well as '\'. The
one-character scan missed that too.

pathOf() keeps its trailing separator, since callers concatenate a
sibling file onto it, and keeps answering "" when there is no directory
-- that answer was always right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six importers opened with `if (fileOf(path).empty()) return;`, which
only ever fired for the bare filename fileOf() used to misread. With
that fixed it can only mean a path that is empty or names a directory,
and the work below it already copes: RAW reads no dimensions out of no
name, FLASH and NVDB fail to open, and the display name is simply empty.

import_XYZDP and import_E57XYZ needed a real check first. Both fread()
straight off the fopen() result without testing it, so the filename
guard was the only thing between them and a null FILE* -- and only by
accident, since it never looked at whether the file existed. Check what
they actually depend on.

import_AGX keeps its guard: it is the one that says something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
import_ENSIGHT picks a part's material as per-part binding > carrier
binding > scalar colormap > default, and converting carrier bindings
turned on two rungs that had never run. Which one wins over the colormap
was left to whoever reads the if-chain.

Cover it both ways over one dataset: with no carrier binding the part
takes the colormap built from the field, and with one it takes the bound
material instead.

The dataset writer grows a scalar variable to have something for a
colormap to be built from, and its two record writers move out to file
scope so the variable file can use them too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pathOf() rebuilt its answer from parent_path() and appended the
platform's own separator, so on Windows '/a/b/volume.raw' came back as
'/a/b\' -- Windows accepts a forward slash, and the two halves no longer
rejoined into the path that was passed in. Callers concatenate them to
reach a sibling file, so that is the property that matters.

Take the prefix fileOf() left behind instead. The separator is then
whichever one the path was written with, on either platform, and the
split is reversible by construction.

Assert that directly rather than only through expected strings, since
the expected strings were what quietly passed on Linux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jeffamstutz and others added 8 commits August 19, 2026 10:48
…cene

makeImageSampler had two forms: the original taking a Scene, and an
overload taking the ImageCache that produced the image, added so the
texture shims could not pair an image with a Scene it never reached.
Adding the second one left the first exported from ImageCache.hpp with
exactly one caller in the tree -- the overload delegating to it -- so
tsd_io still published an entry point that takes an arbitrary Scene, and
there were two ways to build a Sampler where the point was to have one.

Fold the body into the ImageCache form and delete the Scene one. Every
call site already passed a cache; none of them change. The null-Scene
answer moves into the merged function and stays what it was: a cache
with no Scene holds no valid image either, so there is nothing to build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
convertLight took an `ImageCache &` and opened by discarding it. Its one
image-shaped job, an infinite light's radiance, goes through
loadInfiniteRadiance(), which converts equal-area to equirectangular and
so binds an array that is not the decoded image and could not be keyed
as one. The parameter was never going to be read.

This is the same vestigial `Scene &`/`ImageCache &` weight the texture
shims shed; it survived because the parameter was silenced rather than
removed. convertMaterial and applyShapeAlpha keep both -- they create
Materials, so the Scene is doing work -- and both callers still take
their pair from the single `ImageCache texCache(&scene)` above them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`textureCache` is an ImageCache built from the `scene` member, so the
aggregate carried the Scene pointer twice. Nothing copies an
ImportContext today -- one is made in import_USD() and passed by
reference everywhere below -- but a copy followed by a reassignment of
`scene` is all it would take for the cache to keep naming the old Scene,
and every Sampler the import made would land there while its Materials
went elsewhere.

Delete the copy operations so that cannot be written. That costs the
struct its aggregate status, so the initialization at import_USD.cpp:315
now runs through a constructor taking the same eight members in the same
order -- the call site is unchanged, braces and all. `importAnimationIndex`
loses the comment explaining why it could not be private; it still isn't,
because nothing else about how this struct is used has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The texture shims' note counted "nine call sites"; there are ten in
`tsd/src` (OBJ 1, ASSIMP 3, PBRT 3, UsdMaterials 3) plus six in the
tests. The nine came from a task document that listed only two of
UsdMaterials' three. Since nothing in the sentence needs a number, drop
it -- a count is a fact that goes stale on the next call site, and the
point of writing the note down was that a wrong number is debt with a
label on it.

The same section said makeImageSampler "gained an overload taking the
cache"; there is no overload now, only the cache form.

The function-local ImageCache inventory in Remaining work had drifted:
three of its five line numbers were off, and import_HDRI no longer
builds a cache at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0014 named an hdri light's radiance as an image that asks ImageCache
for the order its consumer wants, and io/CONTEXT.md called the cache
"the owner of every decoded Image for one scene". Since 0caf746 that is
not what the tree does: import_HDRI builds its radiance array directly,
because it decodes exactly one image per call and a cache scoped to the
call can never be hit.

Narrow both to the images the cache owns, and say plainly that a
light-bound radiance array may be built without it. The contract itself
does not change -- a sampled image is still stored top-down -- and the
one path that still routes radiance through the cache, UsdLights, still
asks for bottom-up on its ImageSource, which is now named as the example.

The ADR's closing sentence said no decoder emits bottom-up rows, which
read as false: HDRImage does. It is not one of io/images/detail's
decoders, which is what the paragraph was about, so the ambiguity is
resolved rather than the sentence deleted -- and the conclusion it
supports still holds. No row reversal runs in the tree today, because
HDRImage's one cached consumer asks for the order it already produced.

The in-code note in import_HDRI said PBRT's infinite light binds its
radiance directly "for the same reason". It does not: it resamples
equal-area to equirectangular, so what it binds is not the decoded image
and could not be keyed as one. That is a different reason and worth
being the one written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The light converter decides per branch which ANARI parameter carries a
light's brightness -- `irradiance` for a distant light, `intensity` for
rect and sphere, `scale` for a dome -- and since 904e3dd three of those
names are arguments passed to a shared helper rather than literals at
the site that means them. A wrong name still produces a light of the
right subtype, so nothing that existed caught it: coverage was point and
spot only.

Three scenarios, one per untested branch, each asserting the name as
well as the value:

- distant: `irradiance` carries it and `intensity` is unset, and
  `normalize` leaves the intensity alone, because a distant light
  subtends no area to divide by.
- rect: the quad's corner and two edge vectors describe the authored
  width and height, and `normalize` divides by their product.
- dome: brightness rides on `scale`, `color` stays unset, and the colour
  arrives baked into a synthesized 1x1 radiance -- which is why this
  branch cannot go through the shared helper, and the assertion that
  says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A single over-long argument list, unformatted since 0ccece9. No code
change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root STYLEGUIDE §6 asks every type to say what copying it means.
InstancerRegistry said nothing, so it was copyable by default -- harmless
today, since it holds only two containers and no pointer into the scene
index its constructor reads, but "harmless" was left for the reader to
work out.

It is built once per import and passed by reference, so the answer is
that copying it is not intended: not copyable, movable by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jeffamstutz
jeffamstutz merged commit f556040 into next_release Aug 19, 2026
12 checks passed
@jeffamstutz
jeffamstutz deleted the usd-import-rework branch August 19, 2026 16:31
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.

2 participants