Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions fixtures/unresolved_assets.usda
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#usda 1.0
(
defaultPrim = "Root"
)

def Xform "Root"
{
def "WithMissingRef" (
references = @./this_file_does_not_exist.usda@</World>
)
{
}

def "WithMissingPayload" (
payload = @./also_missing.usda@</Scene>
)
{
}
}
11 changes: 11 additions & 0 deletions fixtures/unresolved_assets_none.usda
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#usda 1.0
(
defaultPrim = "Root"
)

def Xform "Root"
{
def Cube "Cube"
{
}
}
49 changes: 43 additions & 6 deletions src/compose/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,36 @@ impl std::fmt::Debug for Layer {
///
/// 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)
}

/// Like [`collect_layers`] but also returns the asset paths that could not
/// be resolved during composition.
///
/// The second element of the returned tuple contains the unresolved asset
/// paths in the order they were first encountered. These paths correspond
/// to layers that are referenced or payloaded in the scene but whose files
/// could not be found by the [`Resolver`].
///
/// Note the asymmetric handling:
///
/// * If the **root** asset cannot be resolved, this function returns `Err`
/// (there is no stage to return).
/// * If a **transitively referenced** asset cannot be resolved, it is
/// recorded in the unresolved list and composition continues with the
/// assets that *could* be loaded.
pub fn collect_layers_recording(resolver: &impl Resolver, root_path: &str) -> Result<(Vec<Layer>, Vec<String>)> {
let mut layers = Vec::new();
let mut visited = HashSet::new();
let mut unresolved = Vec::new();

collect_recursive(resolver, root_path, None, &mut layers, &mut visited)?;
collect_recursive(resolver, root_path, None, &mut layers, &mut visited, &mut unresolved)?;

// Layers are collected in post-order (leaves first), reverse so root is first.
layers.reverse();

Ok(layers)
Ok((layers, unresolved))
}

/// Recursive layer collector.
Expand All @@ -71,6 +92,7 @@ fn collect_recursive(
anchor: Option<&ar::ResolvedPath>,
layers: &mut Vec<Layer>,
visited: &mut HashSet<String>,
unresolved: &mut Vec<String>,
) -> Result<()> {
// Create an anchored identifier so relative paths resolve correctly.
let identifier = resolver.create_identifier(asset_path, anchor);
Expand All @@ -81,9 +103,24 @@ fn collect_recursive(
}

// Resolve using the anchored identifier (which is absolute).
let resolved = resolver
.resolve(&identifier)
.with_context(|| format!("failed to resolve asset path: {asset_path}"))?;
// For the root asset (anchor == None) a missing file is always fatal.
// For transitively referenced assets (anchor == Some), record the path
// and continue so that callers can inspect `unresolved` afterwards.
let resolved = match resolver.resolve(&identifier) {
Some(r) => r,
None => {
if anchor.is_none() {
// Root layer must exist.
anyhow::bail!("failed to resolve asset path: {asset_path}");
}
if !unresolved.contains(&identifier) {
unresolved.push(identifier.clone());
}
Comment on lines +116 to +118
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
if !unresolved.contains(&identifier) {
unresolved.push(identifier.clone());
}
unresolved.push(identifier.clone());

Copilot uses AI. Check for mistakes.
// Mark as visited so we don't attempt to re-resolve in recursive calls.
visited.insert(identifier);
return Ok(());
}
};

visited.insert(identifier.clone());

Expand All @@ -109,7 +146,7 @@ fn collect_recursive(
}

for ref_path in &referenced {
collect_recursive(resolver, ref_path, Some(&resolved), layers, visited)?;
collect_recursive(resolver, ref_path, Some(&resolved), layers, visited, unresolved)?;
}

layers.push(Layer::new(identifier, data));
Expand Down
77 changes: 76 additions & 1 deletion src/stage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ pub struct Stage {
layers: Vec<Box<dyn AbstractData>>,
/// Layer identifiers, parallel to `layers`.
identifiers: Vec<String>,
/// Asset paths that could not be resolved during composition.
unresolved: Vec<String>,
/// Cached prim indices, built lazily per prim.
prim_indices: RefCell<HashMap<Path, PrimIndex>>,
}
Expand All @@ -53,7 +55,7 @@ impl Stage {
/// Recursively resolves and loads all referenced layers, then builds a
/// composed stage ready for queries.
Comment on lines 55 to 56
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

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()).

Suggested change
/// 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`].

Copilot uses AI. Check for mistakes.
pub fn open(resolver: &impl Resolver, root_path: &str) -> Result<Self> {
let collected = compose::collect_layers(resolver, root_path)?;
let (collected, unresolved) = compose::collect_layers_recording(resolver, root_path)?;

let mut identifiers = Vec::with_capacity(collected.len());
let mut layers = Vec::with_capacity(collected.len());
Expand All @@ -66,6 +68,7 @@ impl Stage {
Ok(Self {
layers,
identifiers,
unresolved,
prim_indices: RefCell::new(HashMap::new()),
})
}
Expand All @@ -85,6 +88,41 @@ impl Stage {
self.field::<String>(&Path::abs_root(), FieldKey::DefaultPrim).ok()?
}

/// Returns the asset paths that could not be resolved during composition.
///
/// When [`Stage::open`] recursively loads referenced and payloaded layers,
/// any transitively referenced asset path that the [`Resolver`] cannot map
/// to a physical file is recorded here rather than causing an immediate
/// error. This allows the stage to be partially constructed even when
/// some assets are missing, which is useful for asset-health inspection
/// tasks.
///
/// Note the asymmetric handling:
///
/// * If the **root** asset cannot be resolved, [`Stage::open`] returns
/// `Err` and no stage is created.
/// * If a **transitively referenced** asset cannot be resolved, it is
/// added to this list and composition continues.
///
/// The returned slice contains the canonical identifiers (as produced by
/// [`crate::ar::Resolver::create_identifier`]) in the order they were
/// first encountered during composition.
///
/// # Example
///
/// ```no_run
/// use openusd::{ar::DefaultResolver, Stage};
///
/// let resolver = DefaultResolver::new();
/// let stage = Stage::open(&resolver, "scene.usda").unwrap();
/// for path in stage.unresolved_assets() {
/// eprintln!("missing asset: {path}");
/// }
/// ```
pub fn unresolved_assets(&self) -> &[String] {
&self.unresolved
}

/// Returns the composed list of root prim names (children of the pseudo-root).
pub fn root_prims(&self) -> Result<Vec<String>> {
self.prim_children(Path::abs_root())
Expand Down Expand Up @@ -1035,4 +1073,41 @@ mod tests {

Ok(())
}

// --- Stage::unresolved_assets() ---

/// A stage with no external references should have no unresolved entries.
#[test]
fn unresolved_assets_empty_for_valid_stage() -> Result<()> {
let path = fixture_path("unresolved_assets_none.usda");
let resolver = DefaultResolver::new();
let stage = Stage::open(&resolver, &path)?;

assert!(
stage.unresolved_assets().is_empty(),
"a stage with all resolvable assets should have no unresolved entries"
);

Ok(())
}

/// A stage with missing referenced files should report them via unresolved_assets().
#[test]
fn unresolved_assets_records_missing_files() -> Result<()> {
let path = fixture_path("unresolved_assets.usda");
let resolver = DefaultResolver::new();
// Stage::open must succeed even when some transitively referenced
// assets are missing.
let stage = Stage::open(&resolver, &path)?;

let unresolved = stage.unresolved_assets();
assert_eq!(unresolved.len(), 2, "two missing assets should be recorded");

let has_missing_ref = unresolved.iter().any(|p| p.contains("this_file_does_not_exist.usda"));
let has_missing_payload = unresolved.iter().any(|p| p.contains("also_missing.usda"));
assert!(has_missing_ref, "missing reference should be in unresolved list");
assert!(has_missing_payload, "missing payload should be in unresolved list");

Ok(())
}
}
Loading