diff --git a/ROADMAP.md b/ROADMAP.md index 89cc809d..8a7bb4c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -119,8 +119,8 @@ Legend: :white_check_mark: Supported | :construction: Planned | :thinking: Consi | Attribute resolution | `12.3` | :white_check_mark: | `0.2.0` | timeSamples, default, ValueBlock | | Attribute resolution (with time) | `12.3` | :white_check_mark: | `0.1.2` | Time sample lookup | | Spline evaluation | `12.5.3` | :construction: | | Bezier/Hermite curve interpolation | -| Interpolation (Held) | `12.5.1` | :construction: | | Not implemented at stage level | -| Interpolation (Linear) | `12.5.2` | :construction: | | Not implemented at stage level | +| Interpolation (Held) | `12.5.1` | :white_check_mark: | `main` | `Stage::value_at(attr, time)` with `InterpolationType::Held`. | +| Interpolation (Linear) | `12.5.2` | :white_check_mark: | `main` | `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 | diff --git a/fixtures/interp_scene.usda b/fixtures/interp_scene.usda new file mode 100644 index 00000000..d1c8797c --- /dev/null +++ b/fixtures/interp_scene.usda @@ -0,0 +1,43 @@ +#usda 1.0 +( + defaultPrim = "Prim" + doc = "Time-sample interpolation integration test fixture: covers scalar double, vec3f, quatf (slerp), matrix4d, the unsupported-type → held fallback, and a blocked sample." +) + +def Xform "Prim" +{ + # Scalar — linear interp over a known span. + double scalar.timeSamples = { + 0: 0.0, + 10: 20.0, + } + + # Vector — component-wise linear. + float3 vec.timeSamples = { + 0: (0.0, 0.0, 0.0), + 10: (10.0, 20.0, 30.0), + } + + # Quaternion — 0 → 180° rotation about +X via slerp. At t=5 the + # result should be a 90° rotation: (cos(45°), sin(45°), 0, 0). + quatf rot.timeSamples = { + 0: (1, 0, 0, 0), + 10: (0, 1, 0, 0), + } + + # Token is NOT in §12.5.2's linear-supported set — falls back to + # held. + token label.timeSamples = { + 0: "alpha", + 10: "omega", + } + + # ValueBlock anywhere in the bracketing pair → value_at returns None. + double blocked.timeSamples = { + 0: 1.0, + 10: None, + } + + # No timeSamples at all → fall back to the attribute's default. + double static = 42.0 +} diff --git a/src/interp.rs b/src/interp.rs new file mode 100644 index 00000000..e5602fa2 --- /dev/null +++ b/src/interp.rs @@ -0,0 +1,397 @@ +//! Stage-level time-sample interpolation. +//! +//! Implements §12.5 of the AOUSD Core Specification — the universal +//! evaluator that turns a sorted `Vec<(timeCode, Value)>` and a +//! query time into a single resolved [`Value`]. Mirrors the C++ +//! `UsdAttribute::Get(time)` surface: one entry point, one set of +//! type-aware rules, used by every consumer that wants a value at a +//! specific point in time. +//! +//! Two stage-level modes exist (spec §12.5): +//! +//! - [`InterpolationType::Held`]: the value of the nearest *previous* +//! authored sample. Held at both ends (queries before the first +//! sample return the first value; queries after the last return +//! the last). +//! - [`InterpolationType::Linear`]: component-wise lerp for the +//! types listed in [`is_linear_supported`]. Quaternion types +//! (`Quath` / `Quatf` / `Quatd`) interpolate via spherical linear +//! interpolation per the spec. Past the last sample, or for types +//! that don't support linear, the behaviour falls back to held. +//! +//! When the bracketing samples include a [`Value::ValueBlock`] or +//! [`Value::None`], evaluation returns `None` — the spec semantics +//! for a "blocked" sample. + +use crate::sdf::Value; + +/// Stage-level interpolation mode for time-sampled attributes. +/// +/// Per AOUSD §12.5: when no mode is specified at the stage level, +/// the default is [`InterpolationType::Linear`]. C++ OpenUSD ships +/// the equivalent default through `UsdStage::SetInterpolationType`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InterpolationType { + /// Held interpolation — value of the nearest previous authored + /// sample, held at both ends. + Held, + /// Linear interpolation for spec-supported types; held otherwise. + #[default] + Linear, +} + +/// Returns `true` for the value types AOUSD §12.5.2 lists as +/// supporting linear interpolation. +/// +/// Anything outside this set falls back to held behaviour even when +/// the stage's interpolation type is [`InterpolationType::Linear`]. +pub fn is_linear_supported(v: &Value) -> bool { + matches!( + v, + Value::Half(_) + | Value::Float(_) + | Value::Double(_) + | Value::TimeCode(_) + | Value::Matrix2d(_) + | Value::Matrix3d(_) + | Value::Matrix4d(_) + | Value::Vec2h(_) + | Value::Vec2f(_) + | Value::Vec2d(_) + | Value::Vec3h(_) + | Value::Vec3f(_) + | Value::Vec3d(_) + | Value::Vec4h(_) + | Value::Vec4f(_) + | Value::Vec4d(_) + | Value::Quath(_) + | Value::Quatf(_) + | Value::Quatd(_) + ) +} + +/// Evaluate a sorted `(timeCode, Value)` sample list at `time` under +/// the requested interpolation `mode`. +/// +/// `samples` MUST be sorted ascending by time code — `usdc` and +/// `usda` readers both produce them in that order. Returns `None` +/// when the list is empty or when the bracketing samples encode a +/// blocked value (`Value::ValueBlock` / `Value::None`). +pub fn evaluate(samples: &[(f64, Value)], time: f64, mode: InterpolationType) -> Option { + if samples.is_empty() { + return None; + } + let (first_t, first_v) = &samples[0]; + if time <= *first_t { + return clean(first_v.clone()); + } + let (last_t, last_v) = samples.last().unwrap(); + if time >= *last_t { + return clean(last_v.clone()); + } + + // `samples` is sorted; locate the bracketing pair. + let idx = samples.binary_search_by(|(t, _)| t.partial_cmp(&time).unwrap_or(std::cmp::Ordering::Equal)); + let (lo, hi) = match idx { + Ok(i) => return clean(samples[i].1.clone()), + Err(i) => (i - 1, i), + }; + let (t_lo, v_lo) = &samples[lo]; + let (t_hi, v_hi) = &samples[hi]; + + // Blocked-sample fallthrough applies regardless of mode. + if is_blocked(v_lo) || is_blocked(v_hi) { + return None; + } + + match mode { + InterpolationType::Held => clean(v_lo.clone()), + InterpolationType::Linear => { + let t = ((time - t_lo) / (t_hi - t_lo)) as f32; + lerp_value(v_lo, v_hi, t).or_else(|| clean(v_lo.clone())) + } + } +} + +fn is_blocked(v: &Value) -> bool { + matches!(v, Value::ValueBlock | Value::None) +} + +/// Filter out blocked sentinels at the read boundary so callers can +/// treat the return shape as "Some(real value) or no value". +fn clean(v: Value) -> Option { + if is_blocked(&v) { + None + } else { + Some(v) + } +} + +/// Component-wise lerp between two authored samples. Returns `None` +/// when `(a, b)` aren't the same linear-interpolatable type — the +/// caller then falls back to held. The local alias `V` avoids +/// `use Value::*` (which would shadow `Option::None` with the +/// `Value::None` variant in `match` arms). +fn lerp_value(a: &Value, b: &Value, t: f32) -> Option { + use crate::sdf::Value as V; + let t64 = t as f64; + Some(match (a, b) { + (V::Half(x), V::Half(y)) => V::Half(half::f16::from_f32(lerp_f32(x.to_f32(), y.to_f32(), t))), + (V::Float(x), V::Float(y)) => V::Float(lerp_f32(*x, *y, t)), + (V::Double(x), V::Double(y)) => V::Double(lerp_f64(*x, *y, t64)), + (V::TimeCode(x), V::TimeCode(y)) => V::TimeCode(lerp_f64(*x, *y, t64)), + + (V::Matrix2d(x), V::Matrix2d(y)) => V::Matrix2d(lerp_array_f64::<4>(x, y, t64)), + (V::Matrix3d(x), V::Matrix3d(y)) => V::Matrix3d(lerp_array_f64::<9>(x, y, t64)), + (V::Matrix4d(x), V::Matrix4d(y)) => V::Matrix4d(lerp_array_f64::<16>(x, y, t64)), + + (V::Vec2h(x), V::Vec2h(y)) => V::Vec2h(lerp_half_array::<2>(x, y, t)), + (V::Vec2f(x), V::Vec2f(y)) => V::Vec2f(lerp_array_f32::<2>(x, y, t)), + (V::Vec2d(x), V::Vec2d(y)) => V::Vec2d(lerp_array_f64::<2>(x, y, t64)), + (V::Vec3h(x), V::Vec3h(y)) => V::Vec3h(lerp_half_array::<3>(x, y, t)), + (V::Vec3f(x), V::Vec3f(y)) => V::Vec3f(lerp_array_f32::<3>(x, y, t)), + (V::Vec3d(x), V::Vec3d(y)) => V::Vec3d(lerp_array_f64::<3>(x, y, t64)), + (V::Vec4h(x), V::Vec4h(y)) => V::Vec4h(lerp_half_array::<4>(x, y, t)), + (V::Vec4f(x), V::Vec4f(y)) => V::Vec4f(lerp_array_f32::<4>(x, y, t)), + (V::Vec4d(x), V::Vec4d(y)) => V::Vec4d(lerp_array_f64::<4>(x, y, t64)), + + (V::Quath(x), V::Quath(y)) => V::Quath(slerp_half(x, y, t)), + (V::Quatf(x), V::Quatf(y)) => V::Quatf(slerp_quatf(x, y, t)), + (V::Quatd(x), V::Quatd(y)) => V::Quatd(slerp_quatd(x, y, t64)), + + _ => return None, + }) +} + +fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +fn lerp_f64(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +fn lerp_array_f32(a: &[f32; N], b: &[f32; N], t: f32) -> [f32; N] { + let mut out = [0.0f32; N]; + for i in 0..N { + out[i] = lerp_f32(a[i], b[i], t); + } + out +} + +fn lerp_array_f64(a: &[f64; N], b: &[f64; N], t: f64) -> [f64; N] { + let mut out = [0.0f64; N]; + for i in 0..N { + out[i] = lerp_f64(a[i], b[i], t); + } + out +} + +fn lerp_half_array(a: &[half::f16; N], b: &[half::f16; N], t: f32) -> [half::f16; N] { + let mut out = [half::f16::ZERO; N]; + for i in 0..N { + out[i] = half::f16::from_f32(lerp_f32(a[i].to_f32(), b[i].to_f32(), t)); + } + out +} + +// ────────────────────────────────────────────────────────────────── +// Quaternion slerp — spec §12.5.2 mandates slerp for quat types. +// USD's quaternion layout is (w, x, y, z) per the textual encoding; +// the slerp math below treats element [0] as the real part. +// ────────────────────────────────────────────────────────────────── + +fn slerp_quatf(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] { + let aa = [a[0] as f64, a[1] as f64, a[2] as f64, a[3] as f64]; + let bb = [b[0] as f64, b[1] as f64, b[2] as f64, b[3] as f64]; + let out = slerp_f64(&aa, &bb, t as f64); + [out[0] as f32, out[1] as f32, out[2] as f32, out[3] as f32] +} + +fn slerp_quatd(a: &[f64; 4], b: &[f64; 4], t: f64) -> [f64; 4] { + slerp_f64(a, b, t) +} + +fn slerp_half(a: &[half::f16; 4], b: &[half::f16; 4], t: f32) -> [half::f16; 4] { + let aa = [ + a[0].to_f32() as f64, + a[1].to_f32() as f64, + a[2].to_f32() as f64, + a[3].to_f32() as f64, + ]; + let bb = [ + b[0].to_f32() as f64, + b[1].to_f32() as f64, + b[2].to_f32() as f64, + b[3].to_f32() as f64, + ]; + let out = slerp_f64(&aa, &bb, t as f64); + [ + half::f16::from_f32(out[0] as f32), + half::f16::from_f32(out[1] as f32), + half::f16::from_f32(out[2] as f32), + half::f16::from_f32(out[3] as f32), + ] +} + +/// Quaternion slerp in `(w, x, y, z)` order. Chooses the +/// shorter great-circle arc, and falls back to nlerp when the two +/// quaternions are within numerical noise of each other to avoid +/// the sin(0)/0 singularity. +fn slerp_f64(a: &[f64; 4], b: &[f64; 4], t: f64) -> [f64; 4] { + let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; + let sign = if dot < 0.0 { -1.0 } else { 1.0 }; + dot = dot.abs(); + if dot > 0.9995 { + // Quaternions are colinear — lerp + normalise is stable and + // visually indistinguishable from slerp at this range. + return normalise_quat(&[ + a[0] + (sign * b[0] - a[0]) * t, + a[1] + (sign * b[1] - a[1]) * t, + a[2] + (sign * b[2] - a[2]) * t, + a[3] + (sign * b[3] - a[3]) * t, + ]); + } + let theta = dot.acos(); + let sin_theta = theta.sin(); + let s_a = ((1.0 - t) * theta).sin() / sin_theta; + let s_b = (t * theta).sin() / sin_theta * sign; + [ + a[0] * s_a + b[0] * s_b, + a[1] * s_a + b[1] * s_b, + a[2] * s_a + b[2] * s_b, + a[3] * s_a + b[3] * s_b, + ] +} + +fn normalise_quat(q: &[f64; 4]) -> [f64; 4] { + let mag = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); + if mag == 0.0 { + return [1.0, 0.0, 0.0, 0.0]; + } + [q[0] / mag, q[1] / mag, q[2] / mag, q[3] / mag] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn samples_f64(pairs: &[(f64, f64)]) -> Vec<(f64, Value)> { + pairs.iter().map(|(t, v)| (*t, Value::Double(*v))).collect() + } + + #[test] + fn empty_samples_return_none() { + assert!(evaluate(&[], 0.0, InterpolationType::Linear).is_none()); + assert!(evaluate(&[], 0.0, InterpolationType::Held).is_none()); + } + + #[test] + fn before_first_clamps_to_first() { + let s = samples_f64(&[(10.0, 1.0), (20.0, 2.0)]); + assert_eq!(evaluate(&s, 0.0, InterpolationType::Linear), Some(Value::Double(1.0))); + assert_eq!(evaluate(&s, 0.0, InterpolationType::Held), Some(Value::Double(1.0))); + } + + #[test] + fn after_last_clamps_to_last() { + let s = samples_f64(&[(10.0, 1.0), (20.0, 2.0)]); + assert_eq!(evaluate(&s, 100.0, InterpolationType::Linear), Some(Value::Double(2.0))); + assert_eq!(evaluate(&s, 100.0, InterpolationType::Held), Some(Value::Double(2.0))); + } + + #[test] + fn linear_double_lerps() { + let s = samples_f64(&[(0.0, 0.0), (10.0, 20.0)]); + assert_eq!(evaluate(&s, 5.0, InterpolationType::Linear), Some(Value::Double(10.0))); + } + + #[test] + fn held_returns_previous_sample() { + let s = samples_f64(&[(0.0, 0.0), (10.0, 20.0)]); + // At t=5 in Held mode, we get the value at t=0. + assert_eq!(evaluate(&s, 5.0, InterpolationType::Held), Some(Value::Double(0.0))); + } + + #[test] + fn linear_unsupported_type_falls_back_to_held() { + // String is not in the linear-supported set per §12.5.2. + let s = vec![(0.0, Value::String("a".into())), (10.0, Value::String("b".into()))]; + assert_eq!( + evaluate(&s, 5.0, InterpolationType::Linear), + Some(Value::String("a".into())) + ); + } + + #[test] + fn linear_blocked_sample_returns_none() { + let s = vec![(0.0, Value::Double(1.0)), (10.0, Value::ValueBlock)]; + assert_eq!(evaluate(&s, 5.0, InterpolationType::Linear), None); + // At t == 10 exactly, the sample IS the block — return None. + assert_eq!(evaluate(&s, 10.0, InterpolationType::Linear), None); + } + + #[test] + fn linear_vec3f_lerps_componentwise() { + let s = vec![ + (0.0, Value::Vec3f([0.0, 0.0, 0.0])), + (10.0, Value::Vec3f([10.0, 20.0, 30.0])), + ]; + assert_eq!( + evaluate(&s, 5.0, InterpolationType::Linear), + Some(Value::Vec3f([5.0, 10.0, 15.0])) + ); + } + + #[test] + fn linear_matrix4d_lerps_componentwise() { + let mut a = [0.0f64; 16]; + let mut b = [0.0f64; 16]; + a[0] = 1.0; + b[0] = 3.0; + let s = vec![(0.0, Value::Matrix4d(a)), (10.0, Value::Matrix4d(b))]; + let out = evaluate(&s, 5.0, InterpolationType::Linear).unwrap(); + if let Value::Matrix4d(m) = out { + assert!((m[0] - 2.0).abs() < 1e-9); + } else { + panic!("expected Matrix4d, got {out:?}"); + } + } + + #[test] + fn slerp_quatf_90deg_at_t_half() { + // Identity → 180° about +X. At t=0.5 the result should be a + // 90° rotation about +X: (w=cos(45°), x=sin(45°), 0, 0). + let id = [1.0f32, 0.0, 0.0, 0.0]; + let half_turn = [0.0f32, 1.0, 0.0, 0.0]; + let out = slerp_quatf(&id, &half_turn, 0.5); + let expected_w = (std::f32::consts::FRAC_PI_4).cos(); + let expected_x = (std::f32::consts::FRAC_PI_4).sin(); + assert!( + (out[0] - expected_w).abs() < 1e-5, + "w: got {} expected {}", + out[0], + expected_w + ); + assert!( + (out[1] - expected_x).abs() < 1e-5, + "x: got {} expected {}", + out[1], + expected_x + ); + assert!(out[2].abs() < 1e-5); + assert!(out[3].abs() < 1e-5); + } + + #[test] + fn slerp_takes_shorter_arc_when_dot_is_negative() { + // q and -q represent the same rotation; slerp should detect + // the sign flip and produce a result close to identity, not + // the long way around. + let id = [1.0f64, 0.0, 0.0, 0.0]; + let neg_id = [-1.0f64, 0.0, 0.0, 0.0]; + let out = slerp_f64(&id, &neg_id, 0.5); + // Result should still represent identity (up to sign). + assert!((out[0].abs() - 1.0).abs() < 1e-9); + } +} diff --git a/src/lib.rs b/src/lib.rs index bec8069f..c059c10e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ //! | [`pcp`] | Prim Cache Population — the composition engine. Implements LIVRPS strength ordering, per-prim index caching, and namespace mapping via [`MapFunction`](pcp::MapFunction). | //! | [`stage`] | Composed stage. [`Stage`](stage::Stage) merges opinions across layers using [LIVERPS](https://docs.nvidia.com/learn-openusd/latest/creating-composition-arcs/strength-ordering/what-is-liverps.html) strength ordering. | //! | [`expr`] | Variable expression parser and evaluator for USD's `\`...\`` expression syntax. | +//! | [`interp`] | Stage-level time-sample interpolation (AOUSD §12.5) — held + linear over every supported type. | //! | [`schemas`] | Domain-schema readers (UsdPhysics, UsdSkel, …) — non-core extensions, feature-gated. | //! //! # Quick start @@ -32,6 +33,7 @@ pub mod ar; pub mod expr; +pub mod interp; pub mod layer; pub mod pcp; pub mod schemas; @@ -42,6 +44,7 @@ pub mod usdc; pub mod usdz; pub use half::f16; +pub use interp::InterpolationType; pub use layer::{Collector, DependencyKind, LayerFormat}; pub use stage::{InitialLoadSet, PrimPredicate, PrimStatus, Stage, StageBuilder, StagePopulationMask}; diff --git a/src/stage.rs b/src/stage.rs index 2ac1c49b..3aa516ce 100644 --- a/src/stage.rs +++ b/src/stage.rs @@ -39,12 +39,13 @@ //! //! [LIVERPS]: https://docs.nvidia.com/learn-openusd/latest/creating-composition-arcs/strength-ordering/what-is-liverps.html -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use anyhow::Result; use bitflags::bitflags; use crate::ar::{DefaultResolver, Resolver}; +use crate::interp::{self, InterpolationType}; use crate::sdf::{FieldKey, Path, Payload, SpecType, Specifier, Value}; use crate::{layer, pcp, CompositionError}; @@ -242,6 +243,10 @@ pub struct Stage { population_mask: StagePopulationMask, /// PCP error handler wrapping the user's unified callback. on_composition_error: Box Result<()>>, + /// Stage-level interpolation mode for time-sampled attributes + /// (AOUSD §12.5). Defaults to [`InterpolationType::Linear`] per + /// spec. + interpolation_type: Cell, } impl Stage { @@ -316,6 +321,49 @@ impl Stage { self.graph.borrow().default_prim() } + /// Returns the stage-level interpolation mode used by + /// [`Stage::value_at`]. AOUSD §12.5 defaults this to + /// [`InterpolationType::Linear`]. + pub fn interpolation_type(&self) -> InterpolationType { + self.interpolation_type.get() + } + + /// Override the stage-level interpolation mode at runtime. + /// Cheap — no recomputation, the next [`Stage::value_at`] call + /// reads the new mode. + pub fn set_interpolation_type(&self, mode: InterpolationType) { + self.interpolation_type.set(mode); + } + + /// Evaluate an attribute's value at `time` under the stage's + /// current [`InterpolationType`]. Mirrors C++ `UsdAttribute::Get` + /// — the universal entry point for any consumer that needs a + /// resolved value at a specific time code. + /// + /// Resolution order: + /// 1. If the attribute authors `timeSamples`, apply [§12.5 + /// interpolation](crate::interp) over them. + /// 2. Otherwise fall back to the attribute's `default` value. + /// + /// Returns `Ok(None)` when the attribute is unauthored, when the + /// authored value is a [`Value::ValueBlock`] / [`Value::None`] + /// (the spec sentinels for "no value"), or when the queried prim + /// is excluded by the stage's population mask. + pub fn value_at(&self, attr_path: impl Into, time: f64) -> Result> { + let attr_path = attr_path.into(); + if !self.population_mask.includes(&attr_path.prim_path()) { + return Ok(None); + } + if let Some(Value::TimeSamples(samples)) = self.field::(attr_path.clone(), "timeSamples")? { + return Ok(interp::evaluate(&samples, time, self.interpolation_type.get())); + } + let default = self.field::(attr_path, FieldKey::Default)?; + Ok(default.and_then(|v| match v { + Value::ValueBlock | Value::None => None, + other => Some(other), + })) + } + /// Returns the composed list of root prim names (children of the pseudo-root). pub fn root_prims(&self) -> Result> { let root = Path::abs_root(); @@ -731,6 +779,7 @@ pub struct StageBuilder, initial_load_set: InitialLoadSet, population_mask: StagePopulationMask, + interpolation_type: InterpolationType, } impl StageBuilder { @@ -742,6 +791,7 @@ impl StageBuilder { session_layer: None, initial_load_set: InitialLoadSet::LoadAll, population_mask: StagePopulationMask::all(), + interpolation_type: InterpolationType::default(), } } } @@ -756,6 +806,7 @@ impl Result<()>> StageBuilder { session_layer: self.session_layer, initial_load_set: self.initial_load_set, population_mask: self.population_mask, + interpolation_type: self.interpolation_type, } } @@ -773,9 +824,18 @@ impl Result<()>> StageBuilder { session_layer: self.session_layer, initial_load_set: self.initial_load_set, population_mask: self.population_mask, + interpolation_type: self.interpolation_type, } } + /// Sets the stage-level interpolation mode for time-sampled + /// attribute queries through [`Stage::value_at`]. Default per + /// AOUSD §12.5 is [`InterpolationType::Linear`]. + pub fn interpolation_type(mut self, mode: InterpolationType) -> Self { + self.interpolation_type = mode; + self + } + /// Sets the session layer for the stage. /// /// The session layer provides the strongest opinions in the composition, @@ -896,6 +956,7 @@ impl Result<()>> StageBuilder { initial_load_set, population_mask, on_composition_error, + interpolation_type: Cell::new(self.interpolation_type), }) } } diff --git a/tests/interp.rs b/tests/interp.rs new file mode 100644 index 00000000..3424b32a --- /dev/null +++ b/tests/interp.rs @@ -0,0 +1,121 @@ +//! Integration tests for `Stage::value_at` — the universal +//! time-sample evaluator wired through stage composition. + +use anyhow::Result; +use openusd::sdf::{path, Value}; +use openusd::{InterpolationType, Stage}; + +const FIXTURE: &str = "fixtures/interp_scene.usda"; + +fn open() -> Result { + Stage::open(FIXTURE) +} + +#[test] +fn default_interpolation_is_linear() -> Result<()> { + let stage = open()?; + assert_eq!(stage.interpolation_type(), InterpolationType::Linear); + Ok(()) +} + +#[test] +fn linear_double_midpoint() -> Result<()> { + let stage = open()?; + let v = stage.value_at(path("/Prim.scalar")?, 5.0)?.unwrap(); + assert_eq!(v, Value::Double(10.0)); + Ok(()) +} + +#[test] +fn linear_vec3f_componentwise() -> Result<()> { + let stage = open()?; + let v = stage.value_at(path("/Prim.vec")?, 5.0)?.unwrap(); + assert_eq!(v, Value::Vec3f([5.0, 10.0, 15.0])); + Ok(()) +} + +#[test] +fn linear_quatf_uses_slerp() -> Result<()> { + let stage = open()?; + let v = stage.value_at(path("/Prim.rot")?, 5.0)?.unwrap(); + if let Value::Quatf(q) = v { + let expected_w = std::f32::consts::FRAC_PI_4.cos(); + let expected_x = std::f32::consts::FRAC_PI_4.sin(); + assert!((q[0] - expected_w).abs() < 1e-5, "w: {q:?}"); + assert!((q[1] - expected_x).abs() < 1e-5, "x: {q:?}"); + assert!(q[2].abs() < 1e-5); + assert!(q[3].abs() < 1e-5); + } else { + panic!("expected Quatf, got {v:?}"); + } + Ok(()) +} + +#[test] +fn unsupported_type_falls_back_to_held() -> Result<()> { + let stage = open()?; + // Token in linear mode → spec mandates held fallback. The USDA + // parser stores `token` values as `Value::String`; either way it + // isn't in §12.5.2's linear-supported set. + let v = stage.value_at(path("/Prim.label")?, 5.0)?.unwrap(); + match v { + Value::Token(s) | Value::String(s) => assert_eq!(s, "alpha"), + other => panic!("expected token/string `alpha`, got {other:?}"), + } + Ok(()) +} + +#[test] +fn held_mode_returns_previous_sample() -> Result<()> { + let stage = open()?; + stage.set_interpolation_type(InterpolationType::Held); + let v = stage.value_at(path("/Prim.scalar")?, 5.0)?.unwrap(); + // Previous sample is at t=0 with value 0.0. + assert_eq!(v, Value::Double(0.0)); + Ok(()) +} + +#[test] +fn blocked_sample_returns_none() -> Result<()> { + let stage = open()?; + // Bracketing pair includes a ValueBlock — Stage::value_at returns None. + assert!(stage.value_at(path("/Prim.blocked")?, 5.0)?.is_none()); + // At the blocked sample itself. + assert!(stage.value_at(path("/Prim.blocked")?, 10.0)?.is_none()); + Ok(()) +} + +#[test] +fn before_first_sample_clamps() -> Result<()> { + let stage = open()?; + let v = stage.value_at(path("/Prim.scalar")?, -100.0)?.unwrap(); + assert_eq!(v, Value::Double(0.0)); + Ok(()) +} + +#[test] +fn after_last_sample_clamps() -> Result<()> { + let stage = open()?; + let v = stage.value_at(path("/Prim.scalar")?, 100.0)?.unwrap(); + assert_eq!(v, Value::Double(20.0)); + Ok(()) +} + +#[test] +fn falls_back_to_default_when_no_timesamples() -> Result<()> { + let stage = open()?; + let v = stage.value_at(path("/Prim.static")?, 5.0)?.unwrap(); + assert_eq!(v, Value::Double(42.0)); + Ok(()) +} + +#[test] +fn set_interpolation_type_via_builder() -> Result<()> { + let stage = Stage::builder() + .interpolation_type(InterpolationType::Held) + .open(FIXTURE)?; + assert_eq!(stage.interpolation_type(), InterpolationType::Held); + let v = stage.value_at(path("/Prim.scalar")?, 5.0)?.unwrap(); + assert_eq!(v, Value::Double(0.0)); + Ok(()) +}