openusd 0.7.0

Rust native USD library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
//! Scene description foundations.
//!
//! This module contains common data types used by parsers.
//! Roughly this correspond to C++ SDF module <https://openusd.org/dev/api/sdf_page_front.html>

use std::cmp::Ordering;
use std::{collections::HashMap, fmt::Debug, mem};

use bytemuck::{Pod, Zeroable};
use strum::FromRepr;

use crate::tf::Token;

mod asset_path;
mod change;
mod coerce;
mod copy;
mod data;
pub mod expr;
mod file_format;
mod layer;
pub(crate) mod layer_registry;
mod ordering;
mod path;
pub mod path_expr;
mod path_table;
pub mod schema;
pub mod sink;
mod spec;
mod value;
mod value_type;

pub use asset_path::AssetPath;
pub(crate) use asset_path::{
    AssetExpressionFailure, AssetOutcome, evaluate_asset_paths, holds_asset_expression, resolve_asset_paths,
};
pub use change::{ChangeEntry, ChangeFlags, ChangeList, FieldChange};
pub use copy::{
    CopyChildren, CopyChildrenArgs, CopyValue, CopyValueArgs, copy_spec, copy_spec_with, copy_spec_within,
    should_copy_children, should_copy_value,
};
pub(crate) use copy::{author_spec, is_children_field};
pub use data::{AbstractData, CowData, Data, DataError, Patch};
pub use expr::{Evaluation, EvaluationValue, Expr, ExprError, StringEvaluation, StringSegment};
pub use file_format::{FileFormat, FileFormatCaps, FormatError, WriteSeek};
pub use layer::{
    AuthoringError, EditError, ExportError, Layer, LayerEdit, LayerSink, LayerSinkId, PendingLayerChange,
    default_prim_path,
};
pub(crate) use layer::{dry_run_layers, edit_layers};
pub use layer_registry::LayerRegistry;
pub(crate) use layer_registry::LoadError;
pub use ordering::{apply_ordering, element_cmp};
pub use path::{IntoPath, Path, PathComponent, PathComponents, PathElement, PathParseError, path, try_into_path};
pub use path_expr::{EvalError, ExpressionReference, PathExpression, PathPattern, PredicateExpression};
pub use path_table::PathTable;
pub use schema::{ChildrenKey, FieldKey, folds_list_ops};
pub use spec::{
    AttributeSpec, AttributeSpecMut, AttributeSpecRef, PrimSpec, PrimSpecMut, PrimSpecRef, PropertySpec,
    PropertySpecMut, PropertySpecRef, PseudoRootSpec, PseudoRootSpecMut, PseudoRootSpecRef, RelationshipSpec,
    RelationshipSpecMut, RelationshipSpecRef, Spec, SpecData, SpecError, SpecMut, SpecRef, SpecType,
};
pub use value::{CastError, FromValueCast, Value, ValueKind, dictionary_over};
pub use value_type::{Dimensions, Role, ValueTypeError, ValueTypeName};

#[repr(i32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, FromRepr)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub enum Specifier {
    Def,
    Over,
    Class,
}

/// An enum that defines permission levels.
///
/// Permissions control which layers may refer to or express
/// opinions about a prim. Opinions expressed about a prim, or
/// relationships to that prim, by layers that are not allowed
/// permission to access the prim will be ignored.
#[repr(i32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, FromRepr)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub enum Permission {
    Public,
    Private,
}

/// An enum that identifies variability types for attributes.
/// Variability indicates whether the attribute may vary over time and
/// value coordinates, and if its value comes through authoring or
/// or from its owner.
#[repr(i32)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, FromRepr)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub enum Variability {
    #[default]
    Varying,
    Uniform,
}

/// A time-coded `double` (C++ `SdfTimeCode`) — the value held by a
/// `timecode`-typed attribute (e.g. `UsdMediaSpatialAudio.startTime`). Unlike
/// a plain `double`, a `TimeCode` value is retimed by layer offsets during
/// composition.
///
/// This is the authored *value* type, distinct from a time-query *parameter*
/// (C++ `UsdTimeCode`, passed to `Attribute::get_at`). Read it with
/// `Attribute::get::<sdf::TimeCode>()` and author it with
/// `set(sdf::TimeCode(..))`; it round-trips through [`Value::TimeCode`].
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Default, derive_more::From)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TimeCode(pub f64);

impl TimeCode {
    /// The wrapped time value.
    #[inline]
    pub fn value(self) -> f64 {
        self.0
    }
}

impl TryFrom<Value> for TimeCode {
    type Error = CastError;

    fn try_from(value: Value) -> Result<Self, Self::Error> {
        match value {
            Value::TimeCode(v) => Ok(v),
            other => Err(CastError::TypeMismatch {
                target: "TimeCode",
                actual: (&other).into(),
            }),
        }
    }
}

/// Represents a time offset and scale between layers.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct LayerOffset {
    /// Time offset.
    pub offset: f64,
    /// Scale factor.
    pub scale: f64,
}

impl Default for LayerOffset {
    fn default() -> Self {
        Self {
            offset: 0.0,
            scale: 1.0,
        }
    }
}

impl LayerOffset {
    /// Identity layer offset: offset 0, scale 1.
    pub const IDENTITY: LayerOffset = LayerOffset {
        offset: 0.0,
        scale: 1.0,
    };

    #[inline]
    pub fn new(offset: f64, scale: f64) -> Self {
        Self { offset, scale }
    }

    /// A pure time scaling `(0.0, scale)` with no offset. Composed onto another
    /// offset it scales without shifting — used to fold a `timeCodesPerSecond`
    /// retiming ratio into an arc's offset (spec 12.3.2).
    #[inline]
    pub fn scale_only(scale: f64) -> Self {
        Self { offset: 0.0, scale }
    }

    #[inline]
    pub fn is_valid(&self) -> bool {
        self.offset.is_finite() && self.scale.is_finite()
    }

    /// Returns `true` if this is the identity offset `(0.0, 1.0)`.
    #[inline]
    pub fn is_identity(&self) -> bool {
        self.offset == 0.0 && self.scale == 1.0
    }

    /// The `(offset, scale)` pair as exact bit patterns, for hashing it into a
    /// key or comparing two copies of it. An authored NaN offset or scale is
    /// unequal to itself under float equality, yet two offsets that reproduce it
    /// bit for bit are the same offset.
    #[inline]
    pub fn to_bits(&self) -> (u64, u64) {
        (self.offset.to_bits(), self.scale.to_bits())
    }

    /// Applies this offset to a time value as `offset + scale * time` — the
    /// retiming a layer offset performs on the time coordinate of samples and
    /// clip schedules.
    #[inline]
    pub fn apply(&self, time: f64) -> f64 {
        self.offset + self.scale * time
    }

    /// Applies this offset to every time coordinate a value holds, mapping it
    /// out of the frame of the layer that authored it (spec 12.3.2.1, C++
    /// `Usd_ApplyLayerOffsetToValue`).
    ///
    /// A `timecode` is a time coordinate, not a plain number, so an offset
    /// retimes the value itself just as [`apply`](Self::apply) retimes the times
    /// a sample map is keyed by: an attribute authoring `timecode t = 5` in a
    /// layer sublayered with `(offset = 10; scale = 2)` resolves to `20` on the
    /// stage. Dictionaries and sample maps are recursed into, either being able
    /// to carry a timecode at any depth; every other value is left as it stands,
    /// as is any value at all under the identity offset.
    ///
    /// [`Value::holds_time_codes`] answers in advance whether this would rewrite
    /// anything, for a caller holding the value behind a borrow.
    pub fn apply_to_value(&self, value: &mut Value) {
        if self.is_identity() {
            return;
        }
        match value {
            Value::TimeCode(time) => *time = TimeCode(self.apply(time.value())),
            Value::TimeCodeVec(times) => {
                for time in times {
                    *time = TimeCode(self.apply(time.value()));
                }
            }
            Value::TimeSamples(samples) => self.apply_to_samples(samples),
            Value::Dictionary(entries) => {
                for entry in entries.values_mut() {
                    self.apply_to_value(entry);
                }
            }
            Value::ValueVec(values) => {
                for value in values {
                    self.apply_to_value(value);
                }
            }
            _ => {}
        }
    }

    /// Applies this offset to a sample map in place: every key is a time in the
    /// authoring layer's frame, and every sample may itself be time-valued.
    ///
    /// A [`TimeSampleMap`] is keyed in ascending time, which a negative scale
    /// reverses; the keys are restored to ascending order so a bracketing search
    /// over the result stays valid. Composition never authors one — a
    /// non-positive scale [sanitizes](Self::sanitized) to the identity — but the
    /// arithmetic here is defined for any offset.
    pub fn apply_to_samples(&self, samples: &mut TimeSampleMap) {
        for (time, sample) in samples.iter_mut() {
            *time = self.apply(*time);
            self.apply_to_value(sample);
        }
        if self.scale < 0.0 {
            samples.reverse();
        }
    }

    /// Interpolates a sample map at stage `time`, returning the result in stage
    /// time.
    ///
    /// `samples` stays in its authoring layer's frame: `time` maps backwards
    /// through this offset to select and blend the samples, and the interpolated
    /// value maps forward again through [`apply_to_value`](Self::apply_to_value).
    /// The lerp fraction is invariant under an affine offset, so the result is
    /// the one stage-frame samples would give, and a per-time query allocates
    /// only the value it returns.
    ///
    /// `interp` supplies the interpolation policy, which lives above this tier.
    pub fn sample_in_stage_time(
        &self,
        samples: &TimeSampleMap,
        time: f64,
        interp: impl Fn(&TimeSampleMap, f64) -> Option<Value>,
    ) -> Option<Value> {
        let mut value = interp(samples, self.inverse().apply(time))?;
        self.apply_to_value(&mut value);
        Some(value)
    }

    /// Returns the inverse offset, undoing [`apply`](Self::apply): if `self`
    /// maps a source time `t` to `offset + scale * t`, the inverse maps that
    /// result back to `t`. The identity inverts to itself; a `scale == 0`
    /// offset has no inverse and yields the identity.
    #[inline]
    pub fn inverse(&self) -> LayerOffset {
        if self.scale == 0.0 {
            return LayerOffset::IDENTITY;
        }
        LayerOffset {
            offset: -self.offset / self.scale,
            scale: 1.0 / self.scale,
        }
    }

    /// Returns `true` if this offset is well-formed for composition:
    /// finite `offset` and a strictly positive, finite `scale`.
    ///
    /// Per spec 10.3.1.1 / 10.3.2.1.2, a non-positive scale is a composition
    /// error.
    #[inline]
    pub fn is_valid_composition(&self) -> bool {
        self.offset.is_finite() && self.scale.is_finite() && self.scale > 0.0
    }

    /// Returns this offset if valid for composition, or the identity otherwise.
    ///
    /// Matches OpenUSD behaviour of silently dropping back to identity when a
    /// non-positive or non-finite scale is authored.
    #[inline]
    pub fn sanitized(self) -> Self {
        if self.is_valid_composition() {
            self
        } else {
            Self::IDENTITY
        }
    }

    /// Concatenates `self` (outer / closer to root) with `inner` (deeper).
    ///
    /// Given two offsets where a time value `t` in the inner frame maps to
    /// the outer frame as `t * inner.scale + inner.offset`, and outer's own
    /// transform is `t * outer.scale + outer.offset`, the composed transform
    /// from the deepest frame to the outermost is:
    ///
    /// ```text
    /// offset = outer.offset + outer.scale * inner.offset
    /// scale  = outer.scale * inner.scale
    /// ```
    #[inline]
    pub fn concatenate(&self, inner: &LayerOffset) -> LayerOffset {
        LayerOffset {
            offset: self.offset + self.scale * inner.offset,
            scale: self.scale * inner.scale,
        }
    }
}

/// Represents a payload and all its meta data.
///
/// A payload represents a prim reference to an external layer. A payload
/// is similar to a prim reference (see `SdfReference`) with the major
/// difference that payloads are explicitly loaded by the user.
///
/// Unloaded payloads represent a boundary that lazy composition and
/// system behaviors will not traverse across, providing a user-visible
/// way to manage the working set of the scene.
#[derive(Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Payload {
    /// The asset path to the external layer.
    #[cfg_attr(feature = "serde", serde(rename = "asset", skip_serializing_if = "String::is_empty"))]
    pub asset_path: String,
    /// The root prim path to the referenced prim in the external layer.
    #[cfg_attr(feature = "serde", serde(rename = "path", skip_serializing_if = "Path::is_empty"))]
    pub prim_path: Path,
    /// The layer offset to transform time.
    #[cfg_attr(
        feature = "serde",
        serde(rename = "layerOffset", skip_serializing_if = "Option::is_none")
    )]
    pub layer_offset: Option<LayerOffset>,
}

/// Represents a reference and all its meta data.
///
/// A reference is expressed on a prim in a given layer and it identifies a
/// prim in a layer stack. All opinions in the namespace hierarchy
/// under the referenced prim will be composed with the opinions in the
/// namespace hierarchy under the referencing prim.
#[derive(Debug, Default, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Reference {
    /// The asset path to the external layer.
    #[cfg_attr(feature = "serde", serde(rename = "asset", skip_serializing_if = "String::is_empty"))]
    pub asset_path: String,
    /// The path to the referenced prim in the external layer.
    #[cfg_attr(feature = "serde", serde(rename = "path", skip_serializing_if = "Path::is_empty"))]
    pub prim_path: Path,
    /// The layer offset to transform time.
    #[cfg_attr(feature = "serde", serde(rename = "layerOffset"))]
    pub layer_offset: LayerOffset,
    /// The custom data associated with the reference.
    #[cfg_attr(
        feature = "serde",
        serde(rename = "customData", skip_serializing_if = "HashMap::is_empty")
    )]
    pub custom_data: HashMap<String, Value>,
}

mod list_op;

pub use list_op::ListOp;

/// A USD dictionary value (C++ `VtDictionary`): the payload of
/// [`Value::Dictionary`], keyed by name.
pub type Dictionary = std::collections::HashMap<String, Value>;

pub type IntListOp = ListOp<i32>;
pub type UintListOp = ListOp<u32>;

pub type Int64ListOp = ListOp<i64>;
pub type Uint64ListOp = ListOp<u64>;

pub type StringListOp = ListOp<String>;
pub type TokenListOp = ListOp<Token>;
pub type PathListOp = ListOp<Path>;
pub type ReferenceListOp = ListOp<Reference>;
pub type PayloadListOp = ListOp<Payload>;

/// A time-sample map: `(time, value)` pairs sorted ascending by
/// [`compare_sample_times`] with one entry per time, the order C++
/// `SdfTimeSampleMap` (a `std::map`) keeps. The readers establish it through
/// [`normalize_time_samples`] as they decode a layer and
/// [`AttributeSpecMut::set_time_sample`] keeps it, so value resolution and
/// interpolation can binary-search a map. A raw field write (`Spec::set`, a
/// replayed diff) must supply an ordered map.
// TODO: a `TimeSampleMap` newtype whose constructors order it would make the
// invariant unbreakable; today any `Vec<(f64, Value)>` is accepted.
pub type TimeSampleMap = Vec<(f64, Value)>;

/// The order a [`TimeSampleMap`] keeps its times in: [`f64::total_cmp`],
/// except that the two zeros are one time, as they are one key of C++'s
/// `std::map<double, VtValue>`.
#[inline]
pub fn compare_sample_times(a: f64, b: f64) -> Ordering {
    let zero_folded = |t: f64| if t == 0.0 { 0.0 } else { t };
    zero_folded(a).total_cmp(&zero_folded(b))
}

/// Orders `samples` as a [`TimeSampleMap`] requires. A repeated time keeps
/// the spelling it was first given and the sample it was last given, as
/// assigning into C++'s `std::map` does, so the two zeros collapse the way
/// [`AttributeSpecMut::set_time_sample`] collapses them. C++ builds such a
/// map for a crate file too, so it orders one as well, but it emplaces and
/// so keeps a repeated time's first sample; applying one rule to both
/// formats is a deliberate divergence there. A map already in order, as
/// every file a conforming writer produced holds, is returned untouched.
pub fn normalize_time_samples(mut samples: Vec<(f64, Value)>) -> TimeSampleMap {
    if samples.is_sorted_by(|a, b| compare_sample_times(a.0, b.0).is_lt()) {
        return samples;
    }
    // The sort is stable, so each repeated time's run stays in authoring
    // order and the dedup carries the last sample back onto the first entry.
    samples.sort_by(|a, b| compare_sample_times(a.0, b.0));
    samples.dedup_by(|later, kept| {
        if compare_sample_times(kept.0, later.0).is_ne() {
            return false;
        }
        mem::swap(&mut kept.1, &mut later.1);
        true
    });
    samples
}

/// A single namespace relocation `(source, target)`: the prim at `source` is
/// moved to `target` in composed namespace. An empty `target` is a deletion
/// that makes `source` a prohibited (invalid) child name. Mirrors C++
/// `SdfRelocate`, a `std::pair<SdfPath, SdfPath>`.
pub type Relocate = (Path, Path);

/// The ordered list of [`Relocate`]s authored in a layer's `relocates`
/// metadata. Mirrors C++ `SdfRelocates`, a `std::vector<SdfRelocate>`.
pub type RelocateList = Vec<Relocate>;

/// A boxed layer data source, used throughout the layer stack.
pub type LayerData = Box<dyn AbstractData>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalize_time_samples_order() {
        let samples = normalize_time_samples(vec![
            (10.0, Value::Int(1)),
            (-0.0, Value::Int(2)),
            (0.0, Value::Int(3)),
            (10.0, Value::Int(4)),
            (f64::NAN, Value::Int(5)),
        ]);
        let times: Vec<f64> = samples.iter().map(|(t, _)| *t).collect();
        // Compared by bits: `-0.0 == 0.0` would accept an unfolded zero.
        assert_eq!(
            times[0].to_bits(),
            (-0.0_f64).to_bits(),
            "the time keeps its first spelling"
        );
        assert_eq!(times[1], 10.0);
        assert!(times[2].is_nan(), "a positive NaN orders past every number");
        let values: Vec<&Value> = samples.iter().map(|(_, v)| v).collect();
        assert_eq!(
            values,
            [&Value::Int(3), &Value::Int(4), &Value::Int(5)],
            "the last sample of a repeated time wins, the two zeros being one time"
        );
        assert_eq!(
            normalize_time_samples(vec![(f64::NAN, Value::Int(1)), (-f64::NAN, Value::Int(2))]).len(),
            2,
            "the order separates the NaNs by sign, so they are two times"
        );
        assert_eq!(
            normalize_time_samples(vec![(0.0, Value::Int(1)), (1.0, Value::Int(2))]),
            vec![(0.0, Value::Int(1)), (1.0, Value::Int(2))]
        );
    }

    #[test]
    fn layer_offset_identity_is_identity() {
        assert!(LayerOffset::IDENTITY.is_identity());
        assert!(LayerOffset::default().is_identity());
        assert!(!LayerOffset::new(0.0, 2.0).is_identity());
        assert!(!LayerOffset::new(1.0, 1.0).is_identity());
    }

    #[test]
    fn layer_offset_valid_composition_rejects_non_positive_scale() {
        assert!(LayerOffset::new(10.0, 1.0).is_valid_composition());
        assert!(!LayerOffset::new(10.0, 0.0).is_valid_composition());
        assert!(!LayerOffset::new(10.0, -1.0).is_valid_composition());
        assert!(!LayerOffset::new(f64::INFINITY, 1.0).is_valid_composition());
        assert!(!LayerOffset::new(0.0, f64::NAN).is_valid_composition());
    }

    #[test]
    fn samples_offset_scale() {
        let mut samples: TimeSampleMap = vec![(1.0, Value::Double(0.0)), (5.0, Value::Double(1.0))];
        LayerOffset::new(10.0, 2.0).apply_to_samples(&mut samples);
        let times: Vec<f64> = samples.iter().map(|(t, _)| *t).collect();
        assert_eq!(times, vec![12.0, 20.0]);
    }

    #[test]
    fn samples_negative_scale() {
        // Reversing the time axis must leave the keys ascending, which is what
        // a bracketing search over the result relies on.
        let mut samples: TimeSampleMap = vec![(1.0, Value::Double(0.0)), (5.0, Value::Double(1.0))];
        LayerOffset::new(0.0, -1.0).apply_to_samples(&mut samples);
        let times: Vec<f64> = samples.iter().map(|(t, _)| *t).collect();
        assert_eq!(times, vec![-5.0, -1.0]);
        assert_eq!(samples[0].1, Value::Double(1.0));
    }

    #[test]
    fn value_identity_passthrough() {
        let samples: TimeSampleMap = vec![(1.0, Value::Double(0.0))];
        let mut value = Value::TimeSamples(samples.clone());
        LayerOffset::IDENTITY.apply_to_value(&mut value);
        assert_eq!(value, Value::TimeSamples(samples));
    }

    #[test]
    fn value_time_codes() {
        let offset = LayerOffset::new(10.0, 2.0);

        let mut scalar = Value::TimeCode(TimeCode(5.0));
        offset.apply_to_value(&mut scalar);
        assert_eq!(scalar, Value::TimeCode(TimeCode(20.0)));

        let mut array = Value::TimeCodeVec(vec![TimeCode(5.0), TimeCode(10.0)]);
        offset.apply_to_value(&mut array);
        assert_eq!(array, Value::TimeCodeVec(vec![TimeCode(20.0), TimeCode(30.0)]));

        // A plain number is not a time coordinate, whatever it holds.
        let mut plain = Value::Double(5.0);
        offset.apply_to_value(&mut plain);
        assert_eq!(plain, Value::Double(5.0));
    }

    #[test]
    fn value_nested() {
        let offset = LayerOffset::new(10.0, 2.0);

        // A dictionary carries timecodes at any depth.
        let inner = HashMap::from([("deep".to_string(), Value::TimeCode(TimeCode(5.0)))]);
        let mut dict = Value::Dictionary(HashMap::from([
            ("nested".to_string(), Value::Dictionary(inner)),
            ("kept".to_string(), Value::Double(5.0)),
        ]));
        assert!(dict.holds_time_codes());
        offset.apply_to_value(&mut dict);
        let entries = dict.try_as_dictionary_ref().expect("dictionary");
        assert_eq!(entries.get("kept"), Some(&Value::Double(5.0)));
        let nested = entries.get("nested").expect("nested").try_as_dictionary_ref().unwrap();
        assert_eq!(nested.get("deep"), Some(&Value::TimeCode(TimeCode(20.0))));

        // A sample map's keys and its time-valued samples both move.
        let mut samples = Value::TimeSamples(vec![(1.0, Value::TimeCode(TimeCode(5.0)))]);
        offset.apply_to_value(&mut samples);
        assert_eq!(
            samples,
            Value::TimeSamples(vec![(12.0, Value::TimeCode(TimeCode(20.0)))])
        );
    }

    #[test]
    fn layer_offset_sanitized_drops_invalid_to_identity() {
        assert_eq!(LayerOffset::new(10.0, 2.0).sanitized(), LayerOffset::new(10.0, 2.0));
        assert_eq!(LayerOffset::new(5.0, -1.0).sanitized(), LayerOffset::IDENTITY);
        assert_eq!(LayerOffset::new(5.0, 0.0).sanitized(), LayerOffset::IDENTITY);
    }

    #[test]
    fn layer_offset_concatenate_matches_spec_formula() {
        let outer = LayerOffset::new(10.0, 2.0);
        let inner = LayerOffset::new(20.0, 1.0);
        // Matches BasicTimeOffset_root pcp.txt: (10,2) concat (20,1) = (50, 2).
        assert_eq!(outer.concatenate(&inner), LayerOffset::new(50.0, 2.0));
    }

    #[test]
    fn layer_offset_concatenate_is_associative() {
        let a = LayerOffset::new(10.0, 2.0);
        let b = LayerOffset::new(20.0, 0.5);
        let c = LayerOffset::new(5.0, 3.0);
        let ab_c = a.concatenate(&b).concatenate(&c);
        let a_bc = a.concatenate(&b.concatenate(&c));
        assert!((ab_c.offset - a_bc.offset).abs() < 1e-12);
        assert!((ab_c.scale - a_bc.scale).abs() < 1e-12);
    }

    #[test]
    fn layer_offset_identity_is_neutral() {
        let a = LayerOffset::new(10.0, 2.0);
        assert_eq!(a.concatenate(&LayerOffset::IDENTITY), a);
        assert_eq!(LayerOffset::IDENTITY.concatenate(&a), a);
    }

    #[test]
    fn layer_offset_inverse_undoes_apply() {
        // A dyadic scale round-trips exactly.
        let a = LayerOffset::new(10.0, 2.0);
        assert_eq!(a.inverse(), LayerOffset::new(-5.0, 0.5));
        assert_eq!(a.inverse().apply(a.apply(7.0)), 7.0);
        // A non-dyadic scale (e.g. a 1/3 retiming ratio) round-trips only to
        // within float rounding, not exactly.
        let b = LayerOffset::new(1.0, 3.0);
        assert!((b.inverse().apply(b.apply(7.0)) - 7.0).abs() < 1e-9);
        // The identity inverts to itself; a zero scale has no inverse.
        assert_eq!(LayerOffset::IDENTITY.inverse(), LayerOffset::IDENTITY);
        assert_eq!(LayerOffset::new(3.0, 0.0).inverse(), LayerOffset::IDENTITY);
    }
}