diff --git a/fixtures/unresolved_assets.usda b/fixtures/unresolved_assets.usda new file mode 100644 index 0000000..9b20b5f --- /dev/null +++ b/fixtures/unresolved_assets.usda @@ -0,0 +1,19 @@ +#usda 1.0 +( + defaultPrim = "Root" +) + +def Xform "Root" +{ + def "WithMissingRef" ( + references = @./this_file_does_not_exist.usda@ + ) + { + } + + def "WithMissingPayload" ( + payload = @./also_missing.usda@ + ) + { + } +} diff --git a/fixtures/unresolved_assets_none.usda b/fixtures/unresolved_assets_none.usda new file mode 100644 index 0000000..6f7218f --- /dev/null +++ b/fixtures/unresolved_assets_none.usda @@ -0,0 +1,11 @@ +#usda 1.0 +( + defaultPrim = "Root" +) + +def Xform "Root" +{ + def Cube "Cube" + { + } +} diff --git a/src/compose/mod.rs b/src/compose/mod.rs index 66fcc0b..e410a07 100644 --- a/src/compose/mod.rs +++ b/src/compose/mod.rs @@ -53,15 +53,36 @@ impl std::fmt::Debug for Layer { /// /// Returns a [`Vec`] with the root (strongest) layer first. pub fn collect_layers(resolver: &impl Resolver, root_path: &str) -> Result> { + 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, Vec)> { 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. @@ -71,6 +92,7 @@ fn collect_recursive( anchor: Option<&ar::ResolvedPath>, layers: &mut Vec, visited: &mut HashSet, + unresolved: &mut Vec, ) -> Result<()> { // Create an anchored identifier so relative paths resolve correctly. let identifier = resolver.create_identifier(asset_path, anchor); @@ -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()); + } + // Mark as visited so we don't attempt to re-resolve in recursive calls. + visited.insert(identifier); + return Ok(()); + } + }; visited.insert(identifier.clone()); @@ -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)); diff --git a/src/stage.rs b/src/stage.rs index 05d393a..01bbfc1 100644 --- a/src/stage.rs +++ b/src/stage.rs @@ -43,6 +43,8 @@ pub struct Stage { layers: Vec>, /// Layer identifiers, parallel to `layers`. identifiers: Vec, + /// Asset paths that could not be resolved during composition. + unresolved: Vec, /// Cached prim indices, built lazily per prim. prim_indices: RefCell>, } @@ -53,7 +55,7 @@ impl Stage { /// Recursively resolves and loads all referenced layers, then builds a /// composed stage ready for queries. pub fn open(resolver: &impl Resolver, root_path: &str) -> Result { - 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()); @@ -66,6 +68,7 @@ impl Stage { Ok(Self { layers, identifiers, + unresolved, prim_indices: RefCell::new(HashMap::new()), }) } @@ -85,6 +88,41 @@ impl Stage { self.field::(&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> { self.prim_children(Path::abs_root()) @@ -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(()) + } }