Skip to content
Merged
55 changes: 54 additions & 1 deletion crates/openusd/src/pcp/clip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,59 @@ pub(crate) struct ClipQuery<'a> {
}

impl ClipCache {
pub(super) fn value_in_set(
&mut self,
graph: &LayerGraph,
diagnostics: &mut Diagnostics,
resolved: &ResolvedClipSet,
query: &ClipQuery<'_>,
time: f64,
interp: &dyn Fn(&sdf::TimeSampleMap, f64) -> Option<Value>,
) -> Result<Option<Value>, QueryError> {
let value = self.value_at_active_clip(graph, diagnostics, resolved, query, time, interp)?;
if resolved.set.active.len() < 2 || value.as_ref().is_none_or(Value::is_asset_valued) {
return Ok(value);
}
let path = clip_attr_path(query, &resolved.set.clip_prim_path(query.anchor))?;
let Some(samples) = self.clip_set_participates(graph, diagnostics, resolved, query.anchor, &path)? else {
return Ok(value);
};
let times = resolved.set.stage_sample_times(
Comment thread
mxpv marked this conversation as resolved.
Outdated
&samples
.per_clip
.iter()
.map(|clip| clip.iter().map(|(time, _)| *time).collect::<Vec<_>>())
.collect::<Vec<_>>(),
);
let upper = times.partition_point(|sample| *sample <= time);
if upper == 0 || upper == times.len() || times[upper - 1] == time {
return Ok(value);
}
let lower_time = times[upper - 1];
let upper_time = times[upper];
// A `times` jump discontinuity holds the earlier clip time up to the
// jump, so the active clip answers on its own.
let jump = resolved
.set
.times
.windows(2)
.any(|knots| knots[0].x == knots[1].x && knots[0].x > lower_time && knots[0].x <= upper_time);
if jump {
return Ok(value);
}
let lower = self.value_at_active_clip(graph, diagnostics, resolved, query, lower_time, interp)?;
let upper = self.value_at_active_clip(graph, diagnostics, resolved, query, upper_time, interp)?;
let (Some(lower), Some(upper)) = (lower, upper) else {
return Ok(value);
};
if matches!(lower, Value::ValueBlock) || matches!(upper, Value::ValueBlock) {
return Ok(Some(lower));
}
Ok(Some(
interp(&vec![(lower_time, lower), (upper_time, upper)], time).unwrap_or(Value::ValueBlock),
))
}

/// Resolves a value-clip value for `query` at `time` from `resolved` — one
/// of the clip sets composed on `query.anchor` — or `None` when that set
/// does not source the attribute. An authored value block presents as
Expand All @@ -785,7 +838,7 @@ impl ClipCache {
/// [`sdf::LayerOffset::apply_to_value`] maps a composed value out of its
/// layer's frame (C++ `Usd_Clip::_TranslateTimeToExternal`, which is also
/// why C++ skips the layer-offset transform for clip-sourced samples).
pub(super) fn value_in_set(
fn value_at_active_clip(
&mut self,
graph: &LayerGraph,
diagnostics: &mut Diagnostics,
Expand Down
26 changes: 18 additions & 8 deletions crates/openusd/src/pcp/compose_site.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,24 @@ pub(super) fn collect_payloads_in(
nodes,
FieldKey::Payload.as_str(),
graph,
|v| match v {
Value::Payload(p) => Some(PayloadListOp {
explicit: true,
explicit_items: vec![p],
..Default::default()
}),
Value::PayloadListOp(op) => Some(op),
_ => None,
|v| {
let operation = match v {
Value::Payload(p) => Some(PayloadListOp {
explicit: true,
explicit_items: vec![p],
..Default::default()
}),
Value::PayloadListOp(op) => Some(op),
_ => None,
};
operation.map(|op| {
op.map(|mut payload| {
if payload.layer_offset == Some(LayerOffset::default()) {
payload.layer_offset = None;
}
payload
})
})
},
// Fold the retiming only when it is not identity, so a payload with no
// authored offset and no rate change keeps its `layer_offset` as `None`
Expand Down
46 changes: 45 additions & 1 deletion crates/openusd/src/pcp/index_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3129,7 +3129,7 @@ impl IndexCache {
/// path in place would cache an empty index that a later mint would have to
/// evict.
pub(super) fn ensure_index(&mut self, graph: &LayerGraph, path: &Path) -> Result<(), QueryError> {
if self.is_indexed(path) || self.in_unregistered_prototype(path) {
if path.is_empty() || self.is_indexed(path) || self.in_unregistered_prototype(path) {
return Ok(());
}
// Composing a prim whose ancestor is still mid-build cannot seed from that
Expand Down Expand Up @@ -5206,6 +5206,50 @@ def "Anchor" (inherits = </Rig>) {}
Ok(())
}

/// Between two active clips a value interpolates across the activation, as
/// C++ does: samples 0:1/20:3 and 0:5/20:7 switching at 10 give 3.5 at 5.
#[test]
fn clip_switch_interpolates_activation_samples() -> Result<()> {
let directory = tempfile::tempdir()?;
std::fs::write(
directory.path().join("root.usda"),
r#"#usda 1.0
def "Model" (
clips = {
dictionary default = {
asset[] assetPaths = [@a.usda@, @b.usda@]
double2[] active = [(0, 0), (10, 1)]
string primPath = "/Model"
}
}
) {
float size
}
"#,
)?;
for (name, first, last) in [("a", 1, 3), ("b", 5, 7)] {
std::fs::write(
directory.path().join(format!("{name}.usda")),
format!("#usda 1.0\ndef \"Model\" {{\n float size.timeSamples = {{0: {first}, 20: {last}}}\n}}\n"),
)?;
}
let (graph, mut cache) = collected_stack(directory.path().join("root.usda").to_str().unwrap());
for (time, expected) in [
(0.0, 1.0),
(5.0, 3.5),
(9.0, 5.5),
(10.0, 6.0),
(15.0, 6.5),
(20.0, 7.0),
] {
assert_eq!(
cache.value_at(&graph, &sdf::path("/Model.size")?, Some(time), &lerp, None)?,
TimedValue::Value(Value::Float(expected))
);
}
Ok(())
}

/// A participating clip set reports each clip's activation time. The held
/// set contributes both activations and neither clip's samples — clip0's
/// sole sample maps to stage 50, outside its `[0, 10)` window, and clip1
Expand Down
104 changes: 104 additions & 0 deletions crates/openusd/src/pcp/prim_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2303,6 +2303,110 @@ def "Root" (
Ok(())
}

/// A reference or payload to a prim an external layer does not have, at
/// the root or below a prim it does have, reports an unresolved prim path
/// (C++ `PcpErrorUnresolvedPrimPath`).
#[test]
fn absent_external_targets_report_reference_and_payload_errors() -> Result<()> {
for (field, arc, target) in [
("references", ArcType::Reference, "/Absent"),
("payload", ArcType::Payload, "/Absent"),
("references", ArcType::Reference, "/Present/Absent"),
("payload", ArcType::Payload, "/Present/Absent"),
] {
let root = parse_usda(&format!(
"#usda 1.0\ndef Scope \"Mounted\" ({field} = @model.usda@<{target}>) {{}}\n"
));
let model = parse_usda("#usda 1.0\ndef Scope \"Present\" {}\n");
let stack = LayerGraph::from_layers(
vec![sdf::Layer::new("root.usda", root), sdf::Layer::new("model.usda", model)],
0,
sdf::LayerRegistry::default(),
);
let (_, errors, _, _) = PrimIndex::build_with_cache(
&Path::new("/Mounted").unwrap(),
&stack,
&CompositionContext::default(),
&sdf::PathTable::new(),
true,
)?;
assert!(
errors.iter().any(|error| matches!(error,
CompositionDiagnostic::UnresolvedPrimPath { arc: actual, prim_path, .. }
if *actual == arc && prim_path.as_str() == target)),
"{errors:?}"
);
}
Ok(())
}

/// A reference or payload to a prim missing from its own layer reports an
/// unresolved prim path too, at the root or below a prim, as C++ does. A
/// target a variant supplies later is not missing.
#[test]
fn absent_internal_targets_report_reference_and_payload_errors() -> Result<()> {
let layer = parse_usda(
r#"#usda 1.0
def Scope "Present" {
def Scope "Child" {}
}
def Scope "Varied" (
variants = { string v = "on" }
prepend variantSets = "v"
) {
variantSet "v" = {
"on" {
def Scope "FromVariant" {}
}
}
}
def Scope "RootMissing" (references = </Absent>) {}
def Scope "SubMissing" (references = </Present/Absent>) {}
def Scope "PayloadMissing" (payload = </Absent>) {}
def Scope "SubPresent" (references = </Present/Child>) {}
def Scope "FromVariantRef" (references = </Varied/FromVariant>) {}
"#,
);
let stack = LayerGraph::from_layers(
vec![sdf::Layer::new("root.usda", layer)],
0,
sdf::LayerRegistry::default(),
);
let unresolved = |prim: &str| -> Result<Vec<(ArcType, String)>> {
let (_, errors, _, _) = PrimIndex::build_with_cache(
&Path::new(prim).unwrap(),
&stack,
&CompositionContext::default(),
&sdf::PathTable::new(),
true,
)?;
Ok(errors
.iter()
.filter_map(|error| match error {
CompositionDiagnostic::UnresolvedPrimPath { arc, prim_path, .. } => {
Some((*arc, prim_path.as_str().to_string()))
}
_ => None,
})
.collect())
};
assert_eq!(
unresolved("/RootMissing")?,
[(ArcType::Reference, "/Absent".to_string())]
);
assert_eq!(
unresolved("/SubMissing")?,
[(ArcType::Reference, "/Present/Absent".to_string())]
);
assert_eq!(
unresolved("/PayloadMissing")?,
[(ArcType::Payload, "/Absent".to_string())]
);
assert_eq!(unresolved("/SubPresent")?, []);
assert_eq!(unresolved("/FromVariantRef")?, []);
Ok(())
}

/// A cyclic sub-root reference whose target composes *nothing* is reported
/// as `UnresolvedPrimPath` on top of the `ArcCycle` (C++
/// `PcpErrorUnresolvedPrimPath`). `/Outer` references back into `a.usd`, so
Expand Down
43 changes: 29 additions & 14 deletions crates/openusd/src/pcp/prim_indexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,8 @@ pub(crate) struct Indexer<'a, 'f> {
/// relocation target) reports no error, so only entries whose node survives
/// (is not culled) are kept — matching C++'s per-contributing-arc reporting.
pending_relocation_diagnostics: Vec<(NodeId, CompositionDiagnostic)>,
/// Sub-root targets checked after all composition tasks finish.
pending_target_diagnostics: Vec<(Option<NodeId>, CompositionDiagnostic)>,
}

impl<'a, 'f> Indexer<'a, 'f> {
Expand Down Expand Up @@ -543,6 +545,7 @@ impl<'a, 'f> Indexer<'a, 'f> {
pending_loads: Vec::new(),
expr_var_deps: ExprVarDeps::default(),
pending_relocation_diagnostics: Vec::new(),
pending_target_diagnostics: Vec::new(),
}
}

Expand Down Expand Up @@ -667,6 +670,12 @@ impl<'a, 'f> Indexer<'a, 'f> {
}
}

for (node, error) in mem::take(&mut self.pending_target_diagnostics) {
if !node.is_some_and(|node| self.subtree_has_specs(node)) {
self.errors.report(error);
}
}

// Keep only the relocation-source-opinion errors whose relocate node
// survived composition; one composed under a culled branch (an empty
// ancestral reference at the relocation target) reports nothing.
Expand Down Expand Up @@ -2891,29 +2900,35 @@ impl<'a, 'f> Indexer<'a, 'f> {
if !source.is_root_prim() {
let grafted =
self.compose_and_graft(&source, target_stack, self.frame_skip(), parent, arc, map, parent, 0)?;
// The target is unresolved only when composing it hit a cycle (its own
// ancestral chain loops back) that left nothing — not when it is merely
// empty so far (e.g. a variant supplies its opinions later).
// A target is unresolved at once when composing it hit a cycle (its own
// ancestral chain loops back) that left nothing. Otherwise it may be
// merely empty so far (e.g. a variant supplies its opinions later), so
// whether it composes nothing at all is checked once every
// composition task has run.
let unresolved = grafted.hit_cycle && !grafted.node.is_some_and(|g| self.subtree_has_specs(g));
let diagnostic = CompositionDiagnostic::UnresolvedPrimPath {
arc,
target_layer: self.inputs.stack.layer(rep).identifier.clone(),
Comment thread
mxpv marked this conversation as resolved.
Outdated
prim_path: source.clone(),
introduced_by: self.introducing_layer(parent),
site_path: parent_path.clone(),
};
if unresolved {
self.errors.report(CompositionDiagnostic::UnresolvedPrimPath {
arc,
target_layer: self.inputs.stack.layer(rep).identifier.clone(),
prim_path: source.clone(),
introduced_by: self.introducing_layer(parent),
site_path: parent_path.clone(),
});
self.errors.report(diagnostic);
} else {
self.pending_target_diagnostics.push((grafted.node, diagnostic));
}
return Ok(());
}

// An arc target authoring no spec is kept as a culled node (C++
// culling): visible to change tracking and dependency registration, but
// contributing no opinions to value resolution. A resolved-layer payload
// to such a prim is additionally an unresolved-prim-path error (C++
// `PcpErrorUnresolvedPrimPath`); the node is still culled.
// contributing no opinions to value resolution. A reference or payload
// to such a prim, in another layer or the same one, is additionally an
// unresolved-prim-path error (C++ `PcpErrorUnresolvedPrimPath`); the
// node is still culled.
let empty = !self.stack_has_spec(target_stack, &source);
if empty && !is_internal && arc == ArcType::Payload {
if empty && matches!(arc, ArcType::Reference | ArcType::Payload) {
self.errors.report(CompositionDiagnostic::UnresolvedPrimPath {
arc,
target_layer: self.inputs.stack.layer(rep).identifier.clone(),
Expand Down
Loading
Loading