feat(stage): track unresolved assets during composition#45
feat(stage): track unresolved assets during composition#45
Conversation
Adds a new accessor to Stage that returns the list of asset paths
that could not be resolved during composition:
pub fn unresolved_assets(&self) -> &[String]
Before this change, a single missing reference or payload would
cause Stage::open() to fail with an opaque error, making it
difficult to build asset-health tools that want to inspect a stage
with some files missing.
Design notes:
- Asymmetric root handling (open for discussion):
* If the ROOT asset cannot be resolved, Stage::open() still
returns Err - there is nothing meaningful to return.
* If a TRANSITIVELY referenced asset (anchor == Some) cannot be
resolved, it is recorded in the unresolved list and composition
continues with the assets that could be loaded.
An alternative would be to make root failures also recoverable
(e.g. return an empty stage + the root path in unresolved), but
that seemed more confusing for the common case and is easy to
add later if needed.
- A new public helper `compose::collect_layers_recording` is
introduced alongside `collect_layers`; the latter is now a thin
wrapper that discards the unresolved list. This preserves the
existing `collect_layers` signature for external callers.
- Missing transitive assets are still marked as 'visited' so we
don't repeatedly attempt to resolve them in cyclic graphs.
Tests added:
- unresolved_assets_empty_for_valid_stage
- unresolved_assets_records_missing_files
Fixtures added:
- fixtures/unresolved_assets.usda (references 2 missing files)
- fixtures/unresolved_assets_none.usda (self-contained, no arcs)
|
Hi @mxpv, Apologies — this PR (and the other four in this batch: #41 #42 #43 #44 #45) was opened by an AI agent on my behalf without enough human review of upstream etiquette. I should have filed an issue first to discuss before dropping a stack of code on you. Sorry for the noise. For context: I'm building yw-look, a Windows quick-look style asset inspector for CG workflows, and I need a few small accessors on top of If the API and approach look reasonable to you, I'd be grateful if you could merge them. If not — totally fine to close, I'd just maintain them on my fork instead. Happy to revise based on any feedback. Thanks for the great crate! |
There was a problem hiding this comment.
Pull request overview
This PR introduces “partial stage open” support by recording unresolved transitive asset dependencies (references/payloads) during composition instead of failing the entire Stage::open, enabling callers to inspect what’s missing.
Changes:
- Added unresolved-asset tracking to
Stageplus a newStage::unresolved_assets()accessor. - Introduced
compose::collect_layers_recording()to return both collected layers and unresolved asset identifiers, and updatedStage::opento use it. - Added new fixture layers + tests covering both “no unresolved assets” and “missing transitive assets are recorded”.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/stage.rs |
Stores unresolved identifiers on the stage and exposes them via unresolved_assets(); open() now uses the recording collector. |
src/compose/mod.rs |
Adds collect_layers_recording() and updates recursive layer collection to record missing transitive assets instead of erroring. |
fixtures/unresolved_assets.usda |
New fixture with missing reference + payload to validate unresolved recording. |
fixtures/unresolved_assets_none.usda |
New fixture with no external dependencies to validate the empty unresolved list case. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Recursively resolves and loads all referenced layers, then builds a | ||
| /// composed stage ready for queries. |
There was a problem hiding this comment.
Stage::open's doc comment still says it "recursively resolves and loads all referenced layers". With the new behavior (missing transitive assets are recorded and skipped), this description is no longer accurate and may mislead callers about when open returns Ok. Update the doc here to explicitly mention partial construction / unresolved recording (and ideally point to unresolved_assets()).
| /// Recursively resolves and loads all referenced layers, then builds a | |
| /// composed stage ready for queries. | |
| /// Recursively resolves and loads referenced layers to build a composed | |
| /// stage ready for queries. | |
| /// | |
| /// If some transitive assets cannot be resolved, this may still return | |
| /// `Ok` with a partially constructed stage; unresolved asset paths are | |
| /// recorded and can be inspected with [`Stage::unresolved_assets`]. |
| /// Opens a root layer and recursively collects all referenced layers. | ||
| /// | ||
| /// Walks sublayers (layer-level), then traverses every prim to collect | ||
| /// references and payloads. Each discovered asset path is resolved via the | ||
| /// provided [`Resolver`], loaded, and itself walked for further references. | ||
| /// | ||
| /// Returns a [`Vec<Layer>`] with the root (strongest) layer first. | ||
| pub fn collect_layers(resolver: &impl Resolver, root_path: &str) -> Result<Vec<Layer>> { | ||
| let (layers, _unresolved) = collect_layers_recording(resolver, root_path)?; | ||
| Ok(layers) |
There was a problem hiding this comment.
collect_layers previously errored when any referenced/payloaded asset couldn't be resolved; after this change it silently drops unresolved transitive layers (and discards the list). This is a semantic change to a public API, and the current doc comment above still claims that "each discovered asset path is resolved ... loaded" and that it loads the full dependency stack. Consider either (a) keeping collect_layers strict by returning an error when the unresolved list is non-empty, or (b) updating the collect_layers docs to clearly state that unresolved transitive assets are skipped and no longer reported via Err.
| if !unresolved.contains(&identifier) { | ||
| unresolved.push(identifier.clone()); | ||
| } |
There was a problem hiding this comment.
unresolved.contains(&identifier) is redundant because visited is checked before resolution and the missing-asset branch inserts the identifier into visited before returning—so you should never reach this branch twice for the same identifier. You can drop the contains check and always push, or (if you also want to protect against future refactors) maintain membership via a HashSet<String> alongside the Vec<String> to avoid O(n) scans while preserving encounter order.
| if !unresolved.contains(&identifier) { | |
| unresolved.push(identifier.clone()); | |
| } | |
| unresolved.push(identifier.clone()); |
|
I went with a slightly different approach in #46. Instead of storing unresolved paths on the Stage, composition errors are surfaced through a callback that the caller controls: // Default behavior — hard error on any failure (unchanged)
let stage = Stage::open("scene.usda")?;
// Permissive — caller decides per-error
let stage = Stage::builder()
.on_error(|err| {
eprintln!("warning: {err}");
Ok(()) // skip and continue
})
.open("scene.usda")?;The callback receives a I'm going to close this one. Let me know if anything is missing. |
Adds the ability to inspect a stage even when some referenced / payloaded assets cannot be resolved.
When
Stage::openrecursively walks references and payloads, any asset path the resolver cannot map to a physical file is recorded rather than causing the whole open to fail. The stage is partially constructed, and callers (e.g. asset-health linters, viewers) can readunresolved_assets()to see what's missing.Implementation
compose::collect_layers_recording()next to the existingcollect_layers(). The originalcollect_layers()keeps its current signature, so no callers break.Stage::opennow usescollect_layers_recordingand stores the resultingVec<String>on the stage.Resolver::create_identifier.Design judgment that may want discussion
The root layer is treated asymmetrically: if the file passed to
Stage::openitself cannot be found,Stage::openstill returnsErrexactly like before. Only the transitive dependencies discovered during composition are recorded as unresolved.The reasoning: from a caller's perspective, "the file you asked me to open does not exist" is almost always a hard error worth surfacing immediately, while "one of its references is missing" is usually something the caller wants to inspect rather than abort on. But I can see an argument for symmetry — happy to switch to "always Ok, record everything" if you'd prefer that contract. Wanted to flag it explicitly.
Tests
Two new fixtures + tests covering: missing transitive reference, and the existing root-not-found path still returning Err.