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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ that broader spec behavior can be considered fully covered.
| variability resolution (weakest opinion) | `12.2.3` | :white_check_mark: | `0.4.0` | Weakest authored opinion via `PrimIndex::resolve_variability` |
| custom field resolution (any-true) | `12.2.4` | :white_check_mark: | `0.4.0` | Logical OR across opinions via `PrimIndex::resolve_custom` |
| Dictionary combining | `12.2.5` | :white_check_mark: | `0.4.0` | Recursive merge across dictionary-valued opinions |
| List op resolution | `12.2.6` | :construction: | `0.4.0` | Partial: composition-arc list ops and composed `apiSchemas` are resolved. Left for full metadata coverage: generic list-op field resolution for `targetPaths`, `connectionPaths`, `clipSets`, and any registered list-op metadata field. |
| List op resolution | `12.2.6` | :construction: | `0.4.0` | Partial: composition-arc list ops, composed `apiSchemas`, and composed `connectionPaths` (via `PrimIndex::resolve_path_list_op`) are resolved. Left for full metadata coverage: generic list-op field resolution for `targetPaths`, `clipSets`, and any registered list-op metadata field. |
| Layer metadata (root layer only) | `12.2.7` | :white_check_mark: | `0.2.0` | `defaultPrim`, timing fields, etc. |
| Fallback values | `12.2.8` | :construction: | | Requires schema registry |
| Basic attribute resolution | `12.3` | :white_check_mark: | `0.2.0` | Resolves authored `default`, `timeSamples`, and `ValueBlock`. Layer-offset retiming, value clips, and splines are tracked separately. |
Expand All @@ -129,7 +129,7 @@ that broader spec behavior can be considered fully covered.
| Interpolation (Linear) | `12.5.2` | :white_check_mark: | `0.4.0` | `Stage::value_at(attr, time)` with `InterpolationType::Linear` (default). All §12.5.2 types incl. `quath`/`f`/`d` via slerp; held-fallback for unsupported types and past-last-sample. |
| [Value clips](https://openusd.org/release/api/_usd__page__value_clips.html) | `12.3` | :construction: | | `clips`/`clipSets` for split time samples |
| Relationship targets (raw + forwarded) | `12.4` | :construction: | | `targetPaths` readable; forwarding not implemented |
| Attribute connections | `12.4` | :construction: | | `connectionPaths` readable; not resolved |
| Attribute connections | `12.4` | :white_check_mark: | `0.4.0` | `Stage::connection_paths` folds list-op edits across every contributing layer via `PrimIndex::resolve_path_list_op`. Authoring on `Attribute` (`set_connections`, `add_connection`, `add_connection_prepended`, `remove_connection`, `clear_connections`, `has_authored_connections`). Stage-wide `usd::ConnectionGraph` indexes every edge and resolves chains to terminal sources. |

## Schemas (Spec 13)

Expand Down
13 changes: 13 additions & 0 deletions src/pcp/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,19 @@ impl Cache {
self.indices[&path].resolve_token_list_op(FieldKey::ApiSchemas, &self.stack, None)
}

/// Returns the composed `connectionPaths` list for an attribute path,
/// folding list-op edits (prepend / append / add / delete) across every
/// contributing layer. Non-property paths trivially return an empty list.
pub fn connection_paths(&mut self, path: &Path) -> Result<Vec<Path>> {
if !path.is_property_path() {
return Ok(Vec::new());
}
let prim_path = path.prim_path();
let prop_suffix = &path.as_str()[prim_path.as_str().len()..];
self.ensure_index(&prim_path)?;
self.indices[&prim_path].resolve_path_list_op(FieldKey::ConnectionPaths, &self.stack, Some(prop_suffix))
}

/// Returns pseudo-root layer metadata from the root layer only.
///
/// Session-layer and sublayer opinions are intentionally ignored here,
Expand Down
71 changes: 71 additions & 0 deletions src/pcp/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,77 @@ impl PrimIndex {
Ok(result)
}

/// Resolves a path-list-op field by composing list edits from strongest
/// to weakest across all contributing nodes. Used for property-level
/// fields like `connectionPaths` and `targetPaths`. A value block stops
/// weaker opinions while preserving any stronger composed edits.
pub(crate) fn resolve_path_list_op(
&self,
field: FieldKey,
stack: &LayerStack,
prop_suffix: Option<&str>,
) -> Result<Vec<Path>> {
let field = field.as_str();
let mut ops = Vec::new();

for node in self.nodes() {
let query_path = Self::query_path(node, prop_suffix)?;
let data = stack.layer(node.layer_index);
let Some(value) = data.try_get(&query_path, field)? else {
continue;
};
// A bare `PathVec` (no list-op envelope) is treated as an explicit
// replacement of weaker opinions — the natural interpretation for
// a non-list-op-typed value when the field is declared list-op.
let list_op = match value.into_owned() {
Value::ValueBlock => break,
Value::PathListOp(op) => op,
Value::PathVec(paths) => sdf::PathListOp::explicit(paths),
_ => continue,
};
ops.push(Self::map_path_list_op_to_root(list_op, &query_path, &node.map_to_root));
}

let mut result = Vec::new();
for op in ops.iter().rev() {
result = op.compose_over(&result);
}
Ok(result)
}

/// Translate a path-list-op opinion from one contributing node into the
/// composed stage namespace before list-op composition.
///
/// Every bucket must be translated, not just contributed values: delete
/// and reorder opinions only work when they compare against weaker items
/// in the same namespace. Unmappable paths are dropped, matching a
/// namespace map whose source domain does not include the authored target.
fn map_path_list_op_to_root(op: sdf::PathListOp, anchor: &Path, map: &MapFunction) -> sdf::PathListOp {
fn map_paths(paths: Vec<Path>, anchor: &Path, map: &MapFunction) -> Vec<Path> {
paths
.into_iter()
.filter_map(|path| {
// List-op targets are authored in the contributing node's
// namespace; compose them only after translating to the
// stage root namespace so deletes and reorders compare
// like-for-like across layers and arcs.
let absolute = anchor.make_absolute(&path);
map.map_source_to_target(&absolute)
})
.collect()
}

sdf::PathListOp {
explicit: op.explicit,
explicit_items: map_paths(op.explicit_items, anchor, map),
added_items: map_paths(op.added_items, anchor, map),
prepended_items: map_paths(op.prepended_items, anchor, map),
appended_items: map_paths(op.appended_items, anchor, map),
deleted_items: map_paths(op.deleted_items, anchor, map),
ordered_items: map_paths(op.ordered_items, anchor, map),
}
}

/// Builds the query path for a node, applying `prop_suffix` if given.
/// Borrows the node's path when no suffix is needed (zero-copy).
fn query_path<'a>(node: &'a Node, prop_suffix: Option<&str>) -> Result<Cow<'a, Path>> {
Expand Down
29 changes: 23 additions & 6 deletions src/sdf/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,14 +224,15 @@ impl Path {
/// Returns `true` if this path starts with `prefix` at a path boundary.
///
/// A match requires either equality with `prefix` or that the suffix
/// following `prefix` begins with a path separator (`/`) or a variant
/// segment opener (`{`). This avoids false positives like
/// following `prefix` begins with a path separator (`/`), property
/// separator (`.`), or variant segment opener (`{`). This avoids false positives like
/// `/Foobar` starting with `/Foo`.
///
/// ```text
/// "/A/B".has_prefix("/A") -> true
/// "/A".has_prefix("/A") -> true
/// "/A{set=sel}".has_prefix("/A")-> true
/// "/A.attr".has_prefix("/A") -> true
/// "/Ab".has_prefix("/A") -> false
/// "/X".has_prefix("/") -> true
/// ```
Expand All @@ -244,7 +245,7 @@ impl Path {
let Some(suffix) = me.strip_prefix(old) else {
return false;
};
old == "/" || suffix.starts_with('/') || suffix.starts_with('{')
old == "/" || suffix.starts_with('/') || suffix.starts_with('.') || suffix.starts_with('{')
}

/// Replaces a prefix path with a new prefix, used for namespace remapping
Expand All @@ -265,11 +266,14 @@ impl Path {
return Some(new_prefix.clone());
}

// Must start with old_prefix followed by '/' or '{' (variant segment).
// Must start with old_prefix followed by '/', '.', or '{'. Property
// targets in connection/relationship list ops rely on prim-prefix
// mappings crossing the property separator, e.g.
// `/Asset.outputs:out` -> `/Instance.outputs:out`.
let suffix = me.strip_prefix(old)?;
// The absolute root "/" is a prefix of all absolute paths; after
// stripping it the remainder won't start with '/' (e.g. "Foo/Bar").
if old != "/" && !suffix.starts_with('/') && !suffix.starts_with('{') {
if old != "/" && !suffix.starts_with('/') && !suffix.starts_with('.') && !suffix.starts_with('{') {
return None;
}
// Ensure a separator between new prefix and suffix for non-root.
Expand All @@ -282,7 +286,9 @@ impl Path {
}

let new = new_prefix.as_str();
if new == "/" {
if new == "/" && suffix.starts_with('.') {
Some(Path::from_str_unchecked(&format!("/{suffix}")))
} else if new == "/" {
Some(Path::from_str_unchecked(suffix))
} else {
Some(Path::from_str_unchecked(&format!("{new}{suffix}")))
Expand Down Expand Up @@ -548,6 +554,7 @@ mod tests {
("/A/B/C", "/A/B/D", false),
("/Foobar", "/Foo", false),
("/A{set=sel}", "/A", true),
("/A.attr", "/A", true),
("/A", "/", true),
("/", "/", true),
("/A/B", "/A/B/C", false),
Expand Down Expand Up @@ -645,6 +652,16 @@ mod tests {
"/Child"
);

// Property paths still live under the owning prim namespace for
// composition maps; the `.` separator must count as a prefix boundary.
assert_eq!(
p("/Ref.outputs:out")
.replace_prefix(&p("/Ref"), &p("/MyPrim"))
.unwrap()
.as_str(),
"/MyPrim.outputs:out"
);

// No match.
assert!(p("/Other").replace_prefix(&p("/Ref"), &p("/MyPrim")).is_none());

Expand Down
137 changes: 136 additions & 1 deletion src/sdf/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ where
self.add(sdf::FieldKey::AllowedTokens, sdf::Value::TokenVec(tokens));
}

/// Set the `connectionPaths`.
/// Set the `connectionPaths` (explicit list op, replacing any prior).
pub fn set_connection_paths<I>(&mut self, paths: I)
where
I: IntoIterator<Item = sdf::Path>,
Expand All @@ -570,6 +570,117 @@ where
sdf::Value::PathListOp(sdf::PathListOp::explicit(paths)),
);
}

/// Append a single connection path. Returns `true` if the spec was
/// mutated, `false` when the path was already present.
///
/// `prepend = true` joins the prepended-items list (the new path
/// composes stronger than weaker layers); `prepend = false` joins
/// the appended-items list (weaker than prepends, but still
/// composes with weaker-layer opinions). When the existing op is
/// `explicit`, the path is added to whichever side `prepend`
/// selects without flipping the op out of explicit mode. A
/// pre-existing non-`PathListOp` value is overwritten — debug
/// builds assert.
pub fn add_connection_path(&mut self, path: sdf::Path, prepend: bool) -> bool {
match self.get_mut(sdf::FieldKey::ConnectionPaths.as_str()) {
Some(sdf::Value::PathListOp(op)) => {
// Re-adding a previously deleted target must first clear the
// delete bucket; otherwise the newly authored connection can
// still be removed during list-op application.
let mut changed = remove_path(&mut op.deleted_items, &path);
if op.iter().any(|p| p == &path) {
return changed;
}
if op.explicit {
// Stay explicit; honour `prepend` to control position.
if prepend {
op.explicit_items.insert(0, path);
} else {
op.explicit_items.push(path);
}
} else if prepend {
op.prepended_items.push(path);
} else {
op.appended_items.push(path);
Comment on lines +602 to +605
}
changed = true;
changed
}
Some(other) => {
debug_assert!(false, "connectionPaths field is not a sdf::PathListOp (got {other:?})");
let op = if prepend {
sdf::PathListOp::prepended([path])
} else {
sdf::PathListOp::appended([path])
};
self.add(sdf::FieldKey::ConnectionPaths, sdf::Value::PathListOp(op));
true
}
None => {
// Default to a non-explicit list op so the new path composes
// with weaker-layer opinions, matching C++ `UsdAttribute::AddConnection`.
let op = if prepend {
sdf::PathListOp::prepended([path])
} else {
sdf::PathListOp::appended([path])
};
self.add(sdf::FieldKey::ConnectionPaths, sdf::Value::PathListOp(op));
true
}
Comment on lines +620 to +630
}
}

/// Remove a single connection path. Returns `true` if it was present.
pub fn remove_connection_path(&mut self, path: &sdf::Path) -> bool {
if let Some(sdf::Value::PathListOp(op)) = self.get_mut(sdf::FieldKey::ConnectionPaths.as_str()) {
return remove_path(&mut op.explicit_items, path)
| remove_path(&mut op.added_items, path)
| remove_path(&mut op.prepended_items, path)
| remove_path(&mut op.appended_items, path);
}
false
}

/// Author a removal for a connection path. Local contributions are
/// stripped first; non-explicit list ops also get a delete opinion so
/// weaker-layer contributions stay removed in the composed result.
pub fn delete_connection_path(&mut self, path: &sdf::Path) -> bool {
match self.get_mut(sdf::FieldKey::ConnectionPaths.as_str()) {
Some(sdf::Value::PathListOp(op)) => {
let removed = remove_path(&mut op.explicit_items, path)
| remove_path(&mut op.added_items, path)
| remove_path(&mut op.prepended_items, path)
| remove_path(&mut op.appended_items, path);
if op.explicit || op.deleted_items.iter().any(|p| p == path) {
return removed;
}
op.deleted_items.push(path.clone());
true
}
Some(other) => {
debug_assert!(false, "connectionPaths field is not a sdf::PathListOp (got {other:?})");
self.add(
sdf::FieldKey::ConnectionPaths,
sdf::Value::PathListOp(sdf::PathListOp::deleted([path.clone()])),
);
true
}
None => {
self.add(
sdf::FieldKey::ConnectionPaths,
sdf::Value::PathListOp(sdf::PathListOp::deleted([path.clone()])),
);
true
}
}
}

/// Clear all authored `connectionPaths`. Returns `true` if an
/// opinion was actually removed.
pub fn clear_connection_paths(&mut self) -> bool {
self.remove(sdf::FieldKey::ConnectionPaths.as_str()).is_some()
}
}

fn upsert_time_sample(map: &mut Vec<(f64, sdf::Value)>, time: f64, value: sdf::Value) {
Expand Down Expand Up @@ -934,6 +1045,30 @@ mod tests {
Ok(())
}

#[test]
fn add_connection_path_dedups() {
let mut spec = Spec::new(sdf::SpecType::Attribute);
let mut attr = spec.as_attr_mut().expect("attr spec");
let path = sdf::Path::new("/A.out").expect("path");

assert!(attr.add_connection_path(path.clone(), false));
// Duplicate — must not mutate, must not trip the change tracker.
assert!(!attr.add_connection_path(path, false));
}

#[test]
fn clear_connection_paths_noop() {
let mut spec = Spec::new(sdf::SpecType::Attribute);
let mut attr = spec.as_attr_mut().expect("attr spec");

// Nothing authored — clear is a no-op.
assert!(!attr.clear_connection_paths());

attr.add_connection_path(sdf::Path::new("/A.out").expect("path"), false);
assert!(attr.clear_connection_paths());
assert!(!attr.clear_connection_paths());
}

#[test]
fn add_api_schema_explicit() -> Result<(), SpecError> {
let mut spec = Spec::new(sdf::SpecType::Prim);
Expand Down
Loading
Loading