Skip to content
Merged
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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Features from the C++ reference implementation not covered by the core specifica
| Parallelism (Rayon) | :construction: | | Composition graph is `&`-only, ready for parallel execution |
| [Incremental invalidation](https://openusd.org/release/api/class_pcp_changes.html) | :thinking: | | Dependency tracking and change processing |
| [UsdGeom](https://openusd.org/release/api/usd_geom_page_front.html) (geometry, transforms, cameras) | :white_check_mark: | `0.4.0` | Schema reader behind `geom` feature: Imageable/Boundable, Xformable (full `xformOpOrder` evaluator), all intrinsic shapes, Camera, Mesh + GeomSubset + PrimvarsAPI, the curve/point types, and PointInstancer<br>Remaining — authoring helpers, ModelAPI / MotionAPI / VisibilityAPI / BBoxCache / XformCache |
| [UsdShade](https://openusd.org/release/api/usd_shade_page_front.html) (materials, shaders) | :white_check_mark: | `main` | Reader + authoring behind `shade` feature: Material / NodeGraph / Shader, connectable `inputs:`/`outputs:` with connections, render-context terminal outputs, MaterialBindingAPI (direct + collection, purpose-namespaced, `bindMaterialAs` strength), and UsdPreviewSurface + UsdUVTexture channel readers<br>Remaining — renderer-specific shader dialects (MDL / MaterialX standard_surface) left to consumers |
| [UsdShade](https://openusd.org/release/api/usd_shade_page_front.html) (materials, shaders) | :white_check_mark: | `main` | Reader + authoring behind `shade` feature: Material / NodeGraph / Shader, connectable `inputs:`/`outputs:` with connections, render-context terminal outputs, MaterialBindingAPI (direct + collection, purpose-namespaced, `bindMaterialAs` strength) with `compute_bound_material` resolution (namespace inheritance, strength override, purpose fallback, collection-beats-direct via `UsdCollectionAPI` membership), and UsdPreviewSurface + UsdUVTexture channel readers<br>Remaining — renderer-specific shader dialects (MDL / MaterialX standard_surface) left to consumers |
| [UsdLux](https://openusd.org/release/api/usd_lux_page_front.html) (lighting) | :white_check_mark: | `0.4.0` | Schema reader behind `lux` feature: all 8 concrete light prims, the applied LightAPI / ShapingAPI / ShadowAPI / LightListAPI, and Pixar-exact defaults<br>Remaining — authoring helpers |
| [UsdSkel](https://openusd.org/release/api/usd_skel_page_front.html) (skeletons, skinning) | :white_check_mark: | `0.4.0` | Schema reader behind `skel` feature: SkelRoot, Skeleton, SkelAnimation, BlendShape (incl. inbetween shapes), and SkelBindingAPI with namespace-inherited bindings<br>Remaining — authoring helpers; stage-level interpolation of SkelAnimation time samples (left to the consumer) |
| [UsdVol](https://openusd.org/release/api/usd_vol_page_front.html) (volumes) | :construction: | | |
Expand Down
293 changes: 292 additions & 1 deletion src/schemas/shade/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@
//! binding has two (the collection path + the Material). The optional
//! `bindMaterialAs` metadata on the rel records binding strength.

use std::collections::hash_map::Entry;
use std::collections::HashMap;

use anyhow::Result;

use crate::sdf::{Path, Value};
use crate::usd::{Prim, Relationship, Stage};
use crate::usd::{is_collection_api_path, Collection, MembershipQuery, Prim, Relationship, Stage};

use super::tokens::{
API_MATERIAL_BINDING, META_BIND_MATERIAL_AS, PURPOSE_ALL, REL_MATERIAL_BINDING, REL_MATERIAL_BINDING_COLLECTION,
Expand Down Expand Up @@ -156,6 +159,134 @@ pub fn read_binding_strength(stage: &Stage, prim: &Path, purpose: &str) -> Resul
composed_strength(stage, &rel)
}

/// Resolve the material bound to `prim` for `purpose`, walking `prim` and
/// its ancestors (spec §15 / `UsdShadeMaterialBindingAPI::ComputeBoundMaterial`).
///
/// A restricted purpose (`purpose != ""`) is resolved across the whole
/// ancestor chain before falling back to all-purpose, so a restricted binding
/// on any ancestor outranks an all-purpose binding on a closer prim. Within a
/// single purpose, a binding on a closer prim wins over one on an ancestor
/// unless the ancestor binding is `strongerThanDescendants` (the topmost such
/// ancestor then wins). At a single prim, a collection-based binding whose
/// collection includes `prim` beats a direct binding, and collection bindings
/// resolve in native property order.
pub fn compute_bound_material(stage: &Stage, prim: &Path, purpose: &str) -> Result<Option<Path>> {
// Cache each collection's membership query across the namespace walk.
let mut cache: HashMap<Path, Option<MembershipQuery>> = HashMap::new();
for pur in purpose_fallbacks(purpose) {
if let Some(material) = bound_material_for_single_purpose(stage, prim, pur, &mut cache)? {
return Ok(Some(material));
}
}
Ok(None)
}

/// Resolve the bound material for one concrete `purpose` by walking `prim` and
/// its ancestors. The closest binding wins unless a `strongerThanDescendants`
/// ancestor overrides it (the topmost such ancestor then wins).
fn bound_material_for_single_purpose(
stage: &Stage,
prim: &Path,
purpose: &str,
cache: &mut HashMap<Path, Option<MembershipQuery>>,
) -> Result<Option<Path>> {
let mut winner: Option<Path> = None;
let mut current = Some(prim.clone());
while let Some(p) = current {
if !p.is_abs_root() {
if let Some((material, strength)) = winning_binding_at(stage, &p, prim, purpose, cache)? {
if winner.is_none() || strength == BindingStrength::StrongerThanDescendants {
winner = Some(material);
}
}
}
current = p.parent();
}
Ok(winner)
}

/// The binding that wins at a single prim `p` for a concrete `purpose`, as
/// `(material, strength)`. A collection binding whose collection includes
/// `queried` beats the direct binding, in native property order.
fn winning_binding_at(
stage: &Stage,
p: &Path,
queried: &Path,
purpose: &str,
cache: &mut HashMap<Path, Option<MembershipQuery>>,
) -> Result<Option<(Path, BindingStrength)>> {
// Collection bindings beat direct, in native property order.
for (collection, material, strength) in collection_bindings_on(stage, p, purpose)? {
if is_collection_member(stage, &collection, queried, cache)? {
return Ok(Some((material, strength)));
}
}
if let Some(material) = read_direct_binding(stage, p, purpose)? {
return Ok(Some((material, read_binding_strength(stage, p, purpose)?)));
}
Ok(None)
}

/// The collection bindings authored on `p` for `purpose`, as
/// `(collection, material, strength)`, in native property order.
fn collection_bindings_on(stage: &Stage, p: &Path, purpose: &str) -> Result<Vec<(Path, Path, BindingStrength)>> {
let prefix = format!("{REL_MATERIAL_BINDING_COLLECTION}:");
let mut out = Vec::new();
for name in stage.prim_properties(p.clone())? {
let Some(rest) = name.strip_prefix(&prefix) else {
continue;
};
// `rest` is `<purpose>:<name>` (restricted) or `<name>` (all-purpose),
// classified by token count to mirror C++ `_GetMaterialPurpose`: only a
// single-colon remainder is purpose-restricted; a plain name or any
// deeper-namespaced name is all-purpose.
let binding_purpose = match rest.split(':').collect::<Vec<_>>().as_slice() {
[pur, _name] => *pur,
_ => PURPOSE_ALL,
};
if binding_purpose != purpose {
continue;
}
let rel = p.append_property(&name)?;
if let [collection, material] = stage.relationship_targets(&rel)?.as_slice() {
out.push((collection.clone(), material.clone(), composed_strength(stage, &rel)?));
}
}
Ok(out)
}
Comment thread
mxpv marked this conversation as resolved.

/// Whether `queried` is a member of the collection at `collection_path`,
/// caching the result by collection path. A path that is not a collection
/// identity caches `None` and is never a member.
fn is_collection_member(
stage: &Stage,
collection_path: &Path,
queried: &Path,
cache: &mut HashMap<Path, Option<MembershipQuery>>,
) -> Result<bool> {
let query = match cache.entry(collection_path.clone()) {
Entry::Occupied(e) => e.into_mut(),
Entry::Vacant(e) => {
let query = match is_collection_api_path(collection_path) {
Some((prim, name)) => Some(Collection::new(prim, name).compute_membership_query(stage)?),
None => None,
};
e.insert(query)
}
};
Ok(query.as_ref().is_some_and(|q| q.is_path_included(queried)))
}
Comment thread
mxpv marked this conversation as resolved.

/// Purposes to try in preference order: a restricted purpose first, then the
/// all-purpose fallback (spec §15 — restricted preferred over all-purpose).
fn purpose_fallbacks(purpose: &str) -> Vec<&str> {
if purpose == PURPOSE_ALL {
vec![PURPOSE_ALL]
} else {
vec![purpose, PURPOSE_ALL]
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -275,4 +406,164 @@ mod tests {
assert_eq!(material.as_str(), "/Set/Mat");
Ok(())
}

fn bound(stage: &Stage, prim: &str, purpose: &str) -> Option<String> {
compute_bound_material(stage, &sdf::path(prim).unwrap(), purpose)
.unwrap()
.map(|p| p.as_str().to_string())
}

#[test]
fn closer_binding_wins() -> Result<()> {
let stage = Stage::builder().in_memory("anon.usda")?;
stage.define_prim("/Set")?.set_type_name("Xform")?;
stage.define_prim("/Set/Mesh")?.set_type_name("Mesh")?;
bind_material(&stage, sdf::path("/Set")?, sdf::path("/MatA")?)?;

// Inherited from the ancestor.
assert_eq!(bound(&stage, "/Set/Mesh", ""), Some("/MatA".to_string()));

// A binding on the closer prim wins (both weakerThanDescendants).
bind_material(&stage, sdf::path("/Set/Mesh")?, sdf::path("/MatB")?)?;
assert_eq!(bound(&stage, "/Set/Mesh", ""), Some("/MatB".to_string()));
// The ancestor itself still resolves to its own binding.
assert_eq!(bound(&stage, "/Set", ""), Some("/MatA".to_string()));
// Unbound prim → None.
assert_eq!(bound(&stage, "/Other", ""), None);
Ok(())
}

#[test]
fn stronger_ancestor_wins() -> Result<()> {
let stage = Stage::builder().in_memory("anon.usda")?;
stage.define_prim("/Set")?.set_type_name("Xform")?;
stage.define_prim("/Set/Mesh")?.set_type_name("Mesh")?;
// Ancestor binding is stronger; closer binding is the default weak.
bind_material_for_purpose(
&stage,
sdf::path("/Set")?,
"",
sdf::path("/MatStrong")?,
BindingStrength::StrongerThanDescendants,
)?;
bind_material(&stage, sdf::path("/Set/Mesh")?, sdf::path("/MatWeak")?)?;

// The stronger ancestor wins despite the closer binding.
assert_eq!(bound(&stage, "/Set/Mesh", ""), Some("/MatStrong".to_string()));
Ok(())
}

#[test]
fn restricted_purpose_preferred() -> Result<()> {
let stage = Stage::builder().in_memory("anon.usda")?;
stage.define_prim("/Mesh")?.set_type_name("Mesh")?;
bind_material(&stage, sdf::path("/Mesh")?, sdf::path("/MatAll")?)?; // all-purpose
bind_material_for_purpose(
&stage,
sdf::path("/Mesh")?,
"preview",
sdf::path("/MatPreview")?,
BindingStrength::WeakerThanDescendants,
)?;

// Restricted purpose preferred over the all-purpose binding.
assert_eq!(bound(&stage, "/Mesh", "preview"), Some("/MatPreview".to_string()));
// A purpose with no restricted binding falls back to all-purpose.
assert_eq!(bound(&stage, "/Mesh", "full"), Some("/MatAll".to_string()));
Ok(())
}

#[test]
fn restricted_ancestor_wins() -> Result<()> {
let stage = Stage::builder().in_memory("anon.usda")?;
stage.define_prim("/Set")?.set_type_name("Xform")?;
stage.define_prim("/Set/Mesh")?.set_type_name("Mesh")?;
// Restricted "preview" binding on the ancestor; the queried prim
// carries only an all-purpose binding.
bind_material_for_purpose(
&stage,
sdf::path("/Set")?,
"preview",
sdf::path("/MatPreview")?,
BindingStrength::WeakerThanDescendants,
)?;
bind_material(&stage, sdf::path("/Set/Mesh")?, sdf::path("/MatAll")?)?;

// The restricted purpose is resolved across the whole chain first, so
// the ancestor's "preview" binding outranks the closer all-purpose one.
assert_eq!(bound(&stage, "/Set/Mesh", "preview"), Some("/MatPreview".to_string()));
// An unrestricted query still gets the closer all-purpose binding.
assert_eq!(bound(&stage, "/Set/Mesh", ""), Some("/MatAll".to_string()));
Ok(())
}

/// Build `/Set` with a `metal` collection including `/Set/A`, and a
/// collection binding to `/MatMetal`, plus a direct binding to `/MatDir`.
fn collection_scene() -> Result<Stage> {
let stage = Stage::builder().in_memory("anon.usda")?;
stage.define_prim("/Set")?.set_type_name("Xform")?;
stage.define_prim("/Set/A")?.set_type_name("Mesh")?;
stage.define_prim("/Set/B")?.set_type_name("Mesh")?;
let coll = crate::usd::apply_collection(&stage, sdf::path("/Set")?, "metal")?;
coll.include_path(&stage, sdf::path("/Set/A")?)?;
bind_material(&stage, sdf::path("/Set")?, sdf::path("/MatDir")?)?;
bind_material_collection(
&stage,
sdf::path("/Set")?,
"metalBits",
sdf::path("/Set.collection:metal")?,
sdf::path("/MatMetal")?,
"",
BindingStrength::WeakerThanDescendants,
)?;
Ok(stage)
}

#[test]
fn collection_beats_direct() -> Result<()> {
let stage = collection_scene()?;
// /Set/A is a collection member → the collection binding wins over direct.
assert_eq!(bound(&stage, "/Set/A", ""), Some("/MatMetal".to_string()));
// /Set/B is not a member → falls back to the inherited direct binding.
assert_eq!(bound(&stage, "/Set/B", ""), Some("/MatDir".to_string()));
Ok(())
}

#[test]
fn collection_native_order() -> Result<()> {
let stage = Stage::builder().in_memory("anon.usda")?;
stage.define_prim("/Set")?.set_type_name("Xform")?;
stage.define_prim("/Set/A")?.set_type_name("Mesh")?;
// Two collections that both include /Set/A.
for c in ["first", "second"] {
let coll = crate::usd::apply_collection(&stage, sdf::path("/Set")?, c)?;
coll.include_path(&stage, sdf::path("/Set/A")?)?;
}
// Author binding "aaa" (collection `second`) before "zzz" (collection
// `first`) — native *property* order, not target order, decides.
bind_material_collection(
&stage,
sdf::path("/Set")?,
"aaa",
sdf::path("/Set.collection:second")?,
sdf::path("/MatSecond")?,
"",
BindingStrength::WeakerThanDescendants,
)?;
bind_material_collection(
&stage,
sdf::path("/Set")?,
"zzz",
sdf::path("/Set.collection:first")?,
sdf::path("/MatFirst")?,
"",
BindingStrength::WeakerThanDescendants,
)?;

// `aaa` precedes `zzz` in native property order, so its collection
// binding (to `/MatSecond`) is the one tested and wins — even though
// its collection `second` was authored after `first`.
assert_eq!(bound(&stage, "/Set/A", ""), Some("/MatSecond".to_string()));
Ok(())
}
}
4 changes: 2 additions & 2 deletions src/schemas/shade/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ mod shader;
mod types;

pub use binding::{
bind_material, bind_material_collection, bind_material_for_purpose, read_binding_strength, read_collection_binding,
read_direct_binding,
bind_material, bind_material_collection, bind_material_for_purpose, compute_bound_material, read_binding_strength,
read_collection_binding, read_direct_binding,
};
pub use connectable::{create_input, create_output, input_name, input_path, output_name, output_path};
pub use material::{define_material, define_node_graph, MaterialAuthor, NodeGraphAuthor};
Expand Down
Loading