diff --git a/Cargo.toml b/Cargo.toml index 0f176d6..ce47858 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ serde = ["dep:serde", "half/serde"] [dependencies] anyhow = "1.0.79" +bitflags = "2.11.1" bytemuck = { version = "1.14.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] } thiserror = "2" diff --git a/ROADMAP.md b/ROADMAP.md index ff8d10c..f2466f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,8 +99,8 @@ Legend: :white_check_mark: Supported | :construction: Planned | :thinking: Consi | Ordered property children | `11.3.2` | :white_check_mark: | `0.2.0` | Merged `propertyChildren` | | Ordered property children (`propertyOrder` reordering) | `11.3.2` | :white_check_mark: | `main` | Strongest opinion applied via `sdf::apply_ordering` | | [Scene graph instancing](https://openusd.org/release/glossary.html#usdglossary-instancing) | `11.3.3` | :construction: | | `instanceable` readable; shared representation not implemented | -| Model hierarchy (kind) | `11.4` | :construction: | | `kind` readable; hierarchy validation not implemented | -| [Stage queries](https://openusd.org/release/api/prim_flags_8h.html) (Active, Loaded, Defined, Abstract, Instance) | `11.5` | :construction: | | Predicate flags for traversal filtering | +| Model hierarchy (kind) | `11.4` | :white_check_mark: | `main` | `Stage::is_model`/`is_group`/`is_component`/`is_subcomponent` validate the contiguous kind hierarchy | +| [Stage queries](https://openusd.org/release/api/prim_flags_8h.html) (Active, Loaded, Defined, Abstract, Instance) | `11.5` | :white_check_mark: | `main` | `Stage::is_active`/`is_loaded`/`is_defined`/`is_abstract`/`is_instance` and `prim_status`; load rules pending | | [Session layer](https://openusd.org/release/glossary.html#usdglossary-sessionlayer) | `11.2` | :white_check_mark: | `0.3.0` | `StageBuilder::session_layer` | ## Value Resolution (Spec 12) diff --git a/fixtures/stage_queries.usda b/fixtures/stage_queries.usda new file mode 100644 index 0000000..3054ada --- /dev/null +++ b/fixtures/stage_queries.usda @@ -0,0 +1,91 @@ +#usda 1.0 +( + defaultPrim = "World" +) + +def Scope "World" ( + kind = "assembly" +) +{ + def Scope "ActiveParent" + { + def Scope "Child" + { + } + } + + def Scope "InactiveParent" ( + active = false + ) + { + def Scope "Child" ( + active = true + ) + { + } + } + + over "OverOnly" + { + } + + over "OverParent" + { + def Scope "Child" + { + } + } + + class "ClassParent" + { + def Scope "Child" + { + } + } + + def Scope "Group" ( + kind = "group" + ) + { + def Scope "Component" ( + kind = "component" + ) + { + } + + def Scope "Subcomponent" ( + kind = "subcomponent" + ) + { + } + } + + def Scope "InvalidComponentParent" + { + def Scope "Component" ( + kind = "component" + ) + { + } + } + + def Scope "Instance" ( + instanceable = true + references = + ) + { + } + + def Scope "InstanceableNoArc" ( + instanceable = true + ) + { + } + + def Scope "Prototype" + { + def Scope "ReferencedChild" + { + } + } +} diff --git a/src/lib.rs b/src/lib.rs index f65f05c..e528087 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub mod usdz; pub use half::f16; pub use layer::{DependencyKind, LayerFormat}; -pub use stage::{Stage, StageBuilder}; +pub use stage::{PrimStatus, Stage, StageBuilder}; /// A recoverable error encountered during stage composition. /// diff --git a/src/pcp/cache.rs b/src/pcp/cache.rs index 494c876..e254989 100644 --- a/src/pcp/cache.rs +++ b/src/pcp/cache.rs @@ -118,6 +118,12 @@ impl Cache { Ok(None) } + /// Returns `true` if the composed prim index contains any non-local arc. + pub(crate) fn has_composition_arc(&mut self, path: &Path) -> Result { + self.ensure_index(path)?; + Ok(self.indices.get(path).is_some_and(|index| index.has_composition_arc())) + } + /// Resolves a field value from the strongest opinion across all composition nodes. pub fn resolve_field(&mut self, path: &Path, field: &str) -> Result> { if path.is_property_path() { diff --git a/src/pcp/index.rs b/src/pcp/index.rs index 4de5e49..9e187dd 100644 --- a/src/pcp/index.rs +++ b/src/pcp/index.rs @@ -166,6 +166,11 @@ impl PrimIndex { self.graph.is_empty() } + /// Returns `true` if any node was introduced by a composition arc. + pub(crate) fn has_composition_arc(&self) -> bool { + self.nodes().iter().any(|node| node.arc != ArcType::Root) + } + /// Returns the nodes in strength order (index 0 = strongest). pub fn nodes(&self) -> &[Node] { &self.graph diff --git a/src/sdf/value.rs b/src/sdf/value.rs index 97b4a18..24a2940 100644 --- a/src/sdf/value.rs +++ b/src/sdf/value.rs @@ -332,6 +332,7 @@ impl_try_from_value!(i64, try_as_int_64, "Int64"); impl_try_from_value!(u64, try_as_uint_64, "Uint64"); impl_try_from_value!(f32, try_as_float, "Float"); impl_try_from_value!(f64, try_as_double, "Double"); +impl_try_from_value!(Specifier, try_as_specifier, "Specifier"); impl_try_from_value!(ReferenceListOp, try_as_reference_list_op, "ReferenceListOp"); impl_try_from_value!(PayloadListOp, try_as_payload_list_op, "PayloadListOp"); impl_try_from_value!(PathListOp, try_as_path_list_op, "PathListOp"); diff --git a/src/stage.rs b/src/stage.rs index 0df5352..b74760e 100644 --- a/src/stage.rs +++ b/src/stage.rs @@ -38,11 +38,31 @@ use std::cell::RefCell; use anyhow::Result; +use bitflags::bitflags; use crate::ar::{DefaultResolver, Resolver}; -use crate::sdf::{Path, SpecType, Value}; +use crate::sdf::{FieldKey, Path, SpecType, Specifier, Value}; use crate::{layer, pcp, CompositionError}; +bitflags! { + /// Resolved stage-level status bits for a prim. + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] + pub struct PrimStatus: u32 { + /// The prim and all ancestors are active. + const ACTIVE = 1 << 0; + /// The prim is loaded according to the stage's current load behavior. + const LOADED = 1 << 1; + /// The prim and all ancestors have defining specifiers. + const DEFINED = 1 << 2; + /// The prim or an ancestor has a `class` specifier. + const ABSTRACT = 1 << 3; + /// The prim is instanceable and has at least one composition arc. + const INSTANCE = 1 << 4; + /// The prim is part of the contiguous model hierarchy. + const MODEL = 1 << 5; + } +} + /// A composed USD stage. /// /// Owns the loaded layer stack and provides composed access to prims, @@ -239,6 +259,168 @@ impl Stage { self.field::(prim, "typeName") } + /// Returns the composed specifier for a prim, if one resolves. + pub fn specifier(&self, prim: impl Into) -> Result> { + self.field::(prim.into().prim_path(), FieldKey::Specifier) + } + + /// Returns the composed `kind` metadata for a prim, if authored. + pub fn kind(&self, prim: impl Into) -> Result> { + self.field::(prim.into().prim_path(), FieldKey::Kind) + } + + /// Returns `true` if the prim and all ancestors are active. + /// + /// Missing `active` opinions default to `true`; a non-existent prim returns + /// `false`. + pub fn is_active(&self, prim: impl Into) -> Result { + let prim = prim.into().prim_path(); + if prim == Path::abs_root() { + return Ok(true); + } + if !self.has_spec(&prim)? { + return Ok(false); + } + for path in Self::prim_ancestors_inclusive(prim) { + if self + .field::(&path, FieldKey::Active)? + .is_some_and(|active| !active) + { + return Ok(false); + } + } + Ok(true) + } + + /// Returns `true` if the prim is loaded. + /// + /// The current stage implementation has no unload rules yet, so all active + /// prims are considered loaded. This will become load-rule aware when + /// payload loading control is added. + pub fn is_loaded(&self, prim: impl Into) -> Result { + self.is_active(prim) + } + + /// Returns `true` if the prim and all ancestors have defining specifiers. + /// + /// `def` and `class` are defining. `over`, missing specs, and missing + /// specifier opinions are not defining. + pub fn is_defined(&self, prim: impl Into) -> Result { + let prim = prim.into().prim_path(); + if prim == Path::abs_root() { + return Ok(true); + } + if !self.has_spec(&prim)? { + return Ok(false); + } + for path in Self::prim_ancestors_inclusive(prim) { + if !matches!(self.specifier(path)?, Some(Specifier::Def | Specifier::Class)) { + return Ok(false); + } + } + Ok(true) + } + + /// Returns `true` if the prim or any ancestor resolves to `class`. + pub fn is_abstract(&self, prim: impl Into) -> Result { + let prim = prim.into().prim_path(); + if prim == Path::abs_root() || !self.has_spec(&prim)? { + return Ok(false); + } + for path in Self::prim_ancestors_inclusive(prim) { + if self.specifier(path)? == Some(Specifier::Class) { + return Ok(true); + } + } + Ok(false) + } + + /// Returns `true` if the prim index contains at least one composition arc. + pub fn has_composition_arc(&self, prim: impl Into) -> Result { + let prim = prim.into().prim_path(); + self.try_or_handle(|cache| cache.has_composition_arc(&prim)) + } + + /// Returns `true` if `instanceable` resolves to true and the prim has a composition arc. + pub fn is_instance(&self, prim: impl Into) -> Result { + let prim = prim.into().prim_path(); + if prim == Path::abs_root() || !self.has_spec(&prim)? { + return Ok(false); + } + if !self.field::(&prim, FieldKey::Instanceable)?.unwrap_or(false) { + return Ok(false); + } + self.has_composition_arc(&prim) + } + + /// Returns `true` if the prim is in the contiguous model hierarchy. + /// + /// A model prim has `kind` equal to `group`, `assembly`, or `component`. + /// Any ancestor below the pseudo-root must have `kind` equal to `group` or + /// `assembly`; `subcomponent` is intentionally not part of the hierarchy. + pub fn is_model(&self, prim: impl Into) -> Result { + Ok(self.model_kind(prim.into())?.is_some()) + } + + /// Returns `true` if the prim is a group-like model (`group` or `assembly`). + pub fn is_group(&self, prim: impl Into) -> Result { + Ok(matches!(self.model_kind(prim.into())?, Some("group" | "assembly"))) + } + + /// Returns `true` if the prim is a component model in a valid model hierarchy. + pub fn is_component(&self, prim: impl Into) -> Result { + Ok(self.model_kind(prim.into())? == Some("component")) + } + + /// Returns `true` if the prim has `kind = "subcomponent"`. + pub fn is_subcomponent(&self, prim: impl Into) -> Result { + Ok(self.kind(prim)?.as_deref() == Some("subcomponent")) + } + + /// Returns the resolved stage status bits for a prim. + pub fn prim_status(&self, prim: impl Into) -> Result { + let prim = prim.into().prim_path(); + let active = self.is_active(&prim)?; + let mut status = PrimStatus::empty(); + status.set(PrimStatus::ACTIVE, active); + status.set(PrimStatus::LOADED, active); + status.set(PrimStatus::DEFINED, self.is_defined(&prim)?); + status.set(PrimStatus::ABSTRACT, self.is_abstract(&prim)?); + status.set(PrimStatus::INSTANCE, self.is_instance(&prim)?); + status.set(PrimStatus::MODEL, self.is_model(&prim)?); + Ok(status) + } + + /// Returns the model-hierarchy `kind` for the prim — `Some("group" | "assembly" | "component")` + /// when the prim and all ancestors form a contiguous model hierarchy, else `None`. + fn model_kind(&self, prim: Path) -> Result> { + let prim = prim.prim_path(); + if prim == Path::abs_root() || !self.has_spec(&prim)? { + return Ok(None); + } + let leaf = match self.kind(&prim)?.as_deref() { + Some("group") => "group", + Some("assembly") => "assembly", + Some("component") => "component", + _ => return Ok(None), + }; + let Some(parent) = prim.parent() else { + return Ok(Some(leaf)); + }; + for ancestor in Self::prim_ancestors_inclusive(parent) { + if !matches!(self.kind(ancestor)?.as_deref(), Some("group" | "assembly")) { + return Ok(None); + } + } + Ok(Some(leaf)) + } + + /// Iterates the prim path and its ancestors from leaf to root, stopping + /// before the pseudo-root. Assumes `start` is already a prim path. + fn prim_ancestors_inclusive(start: Path) -> impl Iterator { + std::iter::successors(Some(start), Path::parent).take_while(|p| *p != Path::abs_root()) + } + /// Calls `f` with a mutable reference to the composition cache. If `f` /// returns a [`pcp::Error`], the error handler decides whether to skip /// (returning a default value) or abort (propagating the error). @@ -999,4 +1181,92 @@ mod tests { assert_eq!(stage.type_name(&Path::new("/World")?)?, Some("Xform".to_string())); Ok(()) } + + fn open_stage_queries_fixture() -> Result { + Stage::open("fixtures/stage_queries.usda") + } + + #[test] + fn active_and_loaded_are_ancestor_aware() -> Result<()> { + let stage = open_stage_queries_fixture()?; + + assert!(stage.is_active("/World/ActiveParent/Child")?); + assert!(stage.is_loaded("/World/ActiveParent/Child")?); + + assert!(!stage.is_active("/World/InactiveParent")?); + assert!(!stage.is_active("/World/InactiveParent/Child")?); + assert!(!stage.is_loaded("/World/InactiveParent/Child")?); + + assert!(!stage.is_active("/World/Missing")?); + Ok(()) + } + + #[test] + fn defined_and_abstract_are_ancestor_aware() -> Result<()> { + let stage = open_stage_queries_fixture()?; + + assert_eq!(stage.specifier("/World/OverOnly")?, Some(Specifier::Over)); + assert!(stage.is_defined("/World/ActiveParent/Child")?); + assert!(!stage.is_defined("/World/OverOnly")?); + assert!(!stage.is_defined("/World/OverParent/Child")?); + + assert!(stage.is_defined("/World/ClassParent/Child")?); + assert!(stage.is_abstract("/World/ClassParent")?); + assert!(stage.is_abstract("/World/ClassParent/Child")?); + assert!(!stage.is_abstract("/World/ActiveParent/Child")?); + Ok(()) + } + + #[test] + fn instance_requires_instanceable_and_composition_arc() -> Result<()> { + let stage = open_stage_queries_fixture()?; + + assert!(stage.has_composition_arc("/World/Instance")?); + assert!(stage.is_instance("/World/Instance")?); + + assert!(!stage.has_composition_arc("/World/InstanceableNoArc")?); + assert!(!stage.is_instance("/World/InstanceableNoArc")?); + Ok(()) + } + + #[test] + fn model_queries_follow_contiguous_kind_hierarchy() -> Result<()> { + let stage = open_stage_queries_fixture()?; + + assert_eq!(stage.kind("/World")?, Some("assembly".to_string())); + assert!(stage.is_model("/World")?); + assert!(stage.is_group("/World")?); + + assert!(stage.is_model("/World/Group")?); + assert!(stage.is_group("/World/Group")?); + assert!(stage.is_model("/World/Group/Component")?); + assert!(stage.is_component("/World/Group/Component")?); + + assert!(!stage.is_model("/World/Group/Subcomponent")?); + assert!(stage.is_subcomponent("/World/Group/Subcomponent")?); + + assert_eq!( + stage.kind("/World/InvalidComponentParent/Component")?, + Some("component".to_string()) + ); + assert!(!stage.is_model("/World/InvalidComponentParent/Component")?); + assert!(!stage.is_component("/World/InvalidComponentParent/Component")?); + Ok(()) + } + + #[test] + fn prim_status_groups_query_bits() -> Result<()> { + let stage = open_stage_queries_fixture()?; + + assert_eq!( + stage.prim_status("/World/ClassParent/Child")?, + PrimStatus::ACTIVE | PrimStatus::LOADED | PrimStatus::DEFINED | PrimStatus::ABSTRACT + ); + + assert_eq!( + stage.prim_status("/World/Instance")?, + PrimStatus::ACTIVE | PrimStatus::LOADED | PrimStatus::DEFINED | PrimStatus::INSTANCE + ); + Ok(()) + } }