Skip to main content

animsmith_core/
transform.rs

1//! Pipeline-mechanical clip transforms, ported from the incubating
2//! bake's Python: frame-window slicing, hold-extension, duplicate-endpoint
3//! removal, and gait-anchor rotation. Scope rule (DESIGN.md §1): animsmith may rewrite a clip
4//! only in ways whose correctness its own checks can verify.
5
6use crate::checks::constant_track::{is_constant_track, quaternion_angular_delta};
7use crate::metrics::foot_cycle_metrics;
8use crate::model::{BoneId, Clip, Interpolation, Property, Skeleton, Track, TrackValues};
9use crate::profile::ResolvedRoles;
10use crate::sample::{PoseGrid, TrackSample, default_frame_count, sample_clip, sample_track};
11use glam::{Quat, Vec3};
12use std::collections::BTreeSet;
13use std::fmt;
14use thiserror::Error;
15
16/// Failure while analyzing a duplicate loop endpoint.
17#[derive(Debug, Clone, PartialEq, Error)]
18#[non_exhaustive]
19pub enum DuplicateLoopEndpointError {
20    /// The clip has no tracks.
21    #[error("clip has no tracks")]
22    NoTracks,
23    /// Stored value count does not exactly match the interpolation mode.
24    #[error(
25        "track {track} has {value_count} values for {key_count} keys with {interpolation:?} interpolation"
26    )]
27    InvalidValueCount {
28        /// Index of the malformed track.
29        track: usize,
30        /// Number of authored keys.
31        key_count: usize,
32        /// Number of stored values.
33        value_count: usize,
34        /// Interpolation mode that determines values per key.
35        interpolation: Interpolation,
36    },
37    /// A property uses incompatible value storage.
38    #[error("track {track} has invalid value storage")]
39    InvalidValueStorage {
40        /// Index of the malformed track.
41        track: usize,
42    },
43    /// A duration, key time, or stored value is non-finite.
44    #[error("track {track:?} contains a non-finite authored value")]
45    NonFinite {
46        /// Index of the malformed track, or `None` for clip duration.
47        track: Option<usize>,
48    },
49    /// A timeline is not strictly increasing.
50    #[error("track {track} timeline is not strictly increasing")]
51    NonIncreasingTime {
52        /// Index of the malformed track.
53        track: usize,
54    },
55    /// A track differs from the exact common authored timeline.
56    #[error("track {track} does not share the exact authored timeline")]
57    TimelineMismatch {
58        /// Index of the mismatching track.
59        track: usize,
60    },
61    /// A final key time does not equal the declared duration.
62    #[error("track {track} does not end at the declared duration")]
63    DurationMismatch {
64        /// Index of the mismatching track.
65        track: usize,
66    },
67}
68
69/// The lossless change made by [`drop_duplicate_loop_endpoint`].
70#[derive(Debug, Clone, Copy, PartialEq)]
71#[non_exhaustive]
72pub struct DuplicateLoopEndpointOutcome {
73    /// Number of consecutive closing keys removed from every track.
74    pub removed_keys_per_track: usize,
75    /// Declared duration before removal.
76    pub duration_before_s: f64,
77    /// Duration re-pinned to the final retained key.
78    pub duration_after_s: f64,
79    /// Largest closing translation-component delta, in metres.
80    pub max_translation_endpoint_delta_m: Option<f32>,
81    /// Largest sign-invariant closing rotation delta, in radians.
82    pub max_rotation_endpoint_delta_rad: Option<f32>,
83    /// Largest closing scale-component delta.
84    pub max_scale_endpoint_delta: Option<f32>,
85}
86
87/// Component-wise tolerance for duplicate translation and scale endpoints.
88pub const DUPLICATE_ENDPOINT_VEC3_TOLERANCE: f32 = 1.0e-5;
89/// Sign-invariant shortest-path angular tolerance for duplicate rotations.
90pub const DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD: f32 = 1.0e-4;
91
92/// Maximum component-wise local translation/scale change accepted when
93/// pruning a constant track. This aliases the `constant-track` check's
94/// classification tolerance.
95pub const CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE: f32 = crate::checks::constant_track::VEC3_TOLERANCE;
96/// Maximum sign-invariant local rotation change accepted when pruning a
97/// constant track. This aliases the `constant-track` check's tolerance.
98pub const CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD: f32 =
99    crate::checks::constant_track::QUAT_TOLERANCE_RAD;
100
101/// Outcome of [`prune_constant_tracks`].
102#[derive(Debug, Clone, PartialEq)]
103#[non_exhaustive]
104pub struct PruneConstantTracksOutcome {
105    /// Candidate tracks removed, in original authored order.
106    pub removed: Vec<ConstantTrackPruneRecord>,
107    /// Candidate tracks retained, in original authored order.
108    pub retained: Vec<ConstantTrackRetainedRecord>,
109}
110
111/// One constant-track candidate considered by [`prune_constant_tracks`].
112#[derive(Debug, Clone, PartialEq)]
113#[non_exhaustive]
114pub struct ConstantTrackPruneRecord {
115    /// Original index in [`Clip::tracks`].
116    pub original_track_index: usize,
117    /// Target bone.
118    pub bone: BoneId,
119    /// Target local TRS property.
120    pub property: Property,
121    /// Authored interpolation mode.
122    pub interpolation: Interpolation,
123    /// Number of authored keyframes.
124    pub key_count: usize,
125}
126
127/// A candidate that [`prune_constant_tracks`] conservatively retained.
128#[derive(Debug, Clone, PartialEq)]
129#[non_exhaustive]
130pub struct ConstantTrackRetainedRecord {
131    /// The candidate's immutable authored evidence.
132    pub record: ConstantTrackPruneRecord,
133    /// Why it was retained.
134    pub reason: ConstantTrackRetentionReason,
135}
136
137/// Reason a constant-track candidate was not removed.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139#[non_exhaustive]
140pub enum ConstantTrackRetentionReason {
141    /// The caller identifies this bone as a required authored channel.
142    ProtectedBone,
143    /// The track targets no bone in the supplied skeleton.
144    InvalidTarget,
145    /// The original or a trial clip cannot be safely sampled.
146    SamplingUnavailable,
147    /// Removing the track changes sampled local TRS or model-space pose data.
148    PoseChanged,
149    /// Removing the track would leave no writable track in the clip.
150    LastWritableTrack,
151}
152
153impl fmt::Display for ConstantTrackRetentionReason {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.write_str(match self {
156            Self::ProtectedBone => "target bone is protected",
157            Self::InvalidTarget => "track target is not present in the skeleton",
158            Self::SamplingUnavailable => "the original or trial clip cannot be sampled safely",
159            Self::PoseChanged => {
160                "removal changes sampled local TRS or model-space position/rotation"
161            }
162            Self::LastWritableTrack => "removal would leave no writable track",
163        })
164    }
165}
166
167/// Remove constant multi-key tracks only when doing so preserves every local
168/// TRS and model-space position/rotation on the original clip's default sample
169/// grid.
170///
171/// This is deliberately more conservative than the `constant-track` check:
172/// an all-zero translation track, for example, only disappears if the rest
173/// pose and any other channel reproduce it. Candidate classification shares
174/// that check's interpolation-aware tolerances; accepted removals are then
175/// validated cumulatively against the untouched original. Invalid hand-built
176/// inputs are retained instead of panicking. The final edit is atomic.
177pub fn prune_constant_tracks(
178    skeleton: &Skeleton,
179    clip: &mut Clip,
180    protected_bones: &[BoneId],
181) -> PruneConstantTracksOutcome {
182    prune_constant_tracks_impl(skeleton, clip, protected_bones, || {})
183}
184
185fn prune_constant_tracks_impl(
186    skeleton: &Skeleton,
187    clip: &mut Clip,
188    protected_bones: &[BoneId],
189    mut record_sampled_trial: impl FnMut(),
190) -> PruneConstantTracksOutcome {
191    let candidates: Vec<ConstantTrackPruneRecord> = clip
192        .tracks
193        .iter()
194        .enumerate()
195        .filter(|(_, track)| is_constant_track(track))
196        .map(|(original_track_index, track)| ConstantTrackPruneRecord {
197            original_track_index,
198            bone: track.bone,
199            property: track.property,
200            interpolation: track.interpolation,
201            key_count: track.key_count(),
202        })
203        .collect();
204    if candidates.is_empty() {
205        return PruneConstantTracksOutcome {
206            removed: Vec::new(),
207            retained: Vec::new(),
208        };
209    }
210
211    if !valid_sampling_target(skeleton, clip) {
212        return PruneConstantTracksOutcome {
213            removed: Vec::new(),
214            retained: candidates
215                .into_iter()
216                .map(|record| ConstantTrackRetainedRecord {
217                    reason: if record.bone >= skeleton.bones.len() {
218                        ConstantTrackRetentionReason::InvalidTarget
219                    } else {
220                        ConstantTrackRetentionReason::SamplingUnavailable
221                    },
222                    record,
223                })
224                .collect(),
225        };
226    }
227
228    let frames = default_frame_count(clip);
229    let original = sample_clip(skeleton, clip, frames);
230    if !finite_grid(&original) {
231        return PruneConstantTracksOutcome {
232            removed: Vec::new(),
233            retained: candidates
234                .into_iter()
235                .map(|record| ConstantTrackRetainedRecord {
236                    record,
237                    reason: ConstantTrackRetentionReason::SamplingUnavailable,
238                })
239                .collect(),
240        };
241    }
242
243    let source = clip.clone();
244    let duplicate_channels = duplicate_track_channels(&source);
245    let protected_bones: BTreeSet<_> = protected_bones.iter().copied().collect();
246    let mut accepted = BTreeSet::new();
247    let mut removed_records = Vec::new();
248    let mut retained = Vec::new();
249    for record in candidates {
250        if protected_bones.contains(&record.bone) {
251            retained.push(ConstantTrackRetainedRecord {
252                record,
253                reason: ConstantTrackRetentionReason::ProtectedBone,
254            });
255            continue;
256        }
257        if source.tracks.len() <= accepted.len() + 1 {
258            retained.push(ConstantTrackRetainedRecord {
259                record,
260                reason: ConstantTrackRetentionReason::LastWritableTrack,
261            });
262            continue;
263        }
264        let exact_rest_channel = source
265            .tracks
266            .get(record.original_track_index)
267            .zip(skeleton.bones.get(record.bone))
268            .is_some_and(|(track, bone)| {
269                !duplicate_channels.contains(&track_channel_key(track))
270                    && authored_track_is_exact_rest_equivalent(track, &bone.rest, &original)
271            });
272        if exact_rest_channel {
273            accepted.insert(record.original_track_index);
274            removed_records.push(record);
275            continue;
276        }
277        record_sampled_trial();
278        let mut trial = source.clone();
279        trial.tracks = source
280            .tracks
281            .iter()
282            .enumerate()
283            .filter(|(index, _)| *index != record.original_track_index && !accepted.contains(index))
284            .map(|(_, track)| track.clone())
285            .collect();
286        let trial_grid = sample_clip(skeleton, &trial, frames);
287        if !finite_grid(&trial_grid) {
288            retained.push(ConstantTrackRetainedRecord {
289                record,
290                reason: ConstantTrackRetentionReason::SamplingUnavailable,
291            });
292        } else if !sampled_poses_match(&original, &trial_grid) {
293            retained.push(ConstantTrackRetainedRecord {
294                record,
295                reason: ConstantTrackRetentionReason::PoseChanged,
296            });
297        } else {
298            accepted.insert(record.original_track_index);
299            removed_records.push(record);
300        }
301    }
302    if !accepted.is_empty() {
303        clip.tracks = source
304            .tracks
305            .into_iter()
306            .enumerate()
307            .filter(|(index, _)| !accepted.contains(index))
308            .map(|(_, track)| track)
309            .collect();
310    }
311    PruneConstantTracksOutcome {
312        removed: removed_records,
313        retained,
314    }
315}
316
317#[cfg(test)]
318mod constant_track_fast_path_tests {
319    use super::*;
320    use crate::model::{Bone, Transform};
321
322    #[test]
323    fn thousands_of_unique_exact_rest_channels_require_no_sampled_trials() {
324        const CANDIDATE_COUNT: usize = 2_048;
325        let skeleton = Skeleton {
326            bones: (0..CANDIDATE_COUNT)
327                .map(|bone| Bone {
328                    name: format!("bone-{bone}"),
329                    parent: None,
330                    rest: Transform::IDENTITY,
331                    inverse_bind: None,
332                })
333                .collect(),
334        };
335        let mut tracks: Vec<_> = (0..CANDIDATE_COUNT)
336            .map(|bone| Track {
337                bone,
338                property: Property::Translation,
339                interpolation: Interpolation::Linear,
340                times: vec![0.0, 1.0],
341                values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
342            })
343            .collect();
344        tracks.push(Track {
345            bone: 0,
346            property: Property::Rotation,
347            interpolation: Interpolation::Linear,
348            times: vec![0.0, 1.0],
349            values: TrackValues::Quats(vec![Quat::IDENTITY, Quat::from_rotation_z(0.2)]),
350        });
351        let mut clip = Clip {
352            name: "large-exact-rest".into(),
353            duration_s: 1.0,
354            tracks,
355        };
356        let mut sampled_trials = 0;
357
358        let outcome = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || {
359            sampled_trials += 1;
360        });
361
362        assert_eq!(outcome.removed.len(), CANDIDATE_COUNT);
363        assert!(outcome.retained.is_empty());
364        assert_eq!(sampled_trials, 0);
365        assert_eq!(clip.tracks.len(), 1);
366        assert_eq!(clip.tracks[0].property, Property::Rotation);
367    }
368
369    fn sampled_trials_for(tracks: Vec<Track>) -> usize {
370        let skeleton = Skeleton {
371            bones: vec![Bone {
372                name: "root".into(),
373                parent: None,
374                rest: Transform::IDENTITY,
375                inverse_bind: None,
376            }],
377        };
378        let mut clip = Clip {
379            name: "route".into(),
380            duration_s: 1.0,
381            tracks,
382        };
383        let mut sampled_trials = 0;
384        let _ = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || sampled_trials += 1);
385        sampled_trials
386    }
387
388    fn vector_track(property: Property, interpolation: Interpolation, value: Vec3) -> Track {
389        Track {
390            bone: 0,
391            property,
392            interpolation,
393            times: vec![0.0, 1.0],
394            values: TrackValues::Vec3s(vec![value, value]),
395        }
396    }
397
398    fn moving_rotation() -> Track {
399        Track {
400            bone: 0,
401            property: Property::Rotation,
402            interpolation: Interpolation::Linear,
403            times: vec![0.0, 1.0],
404            values: TrackValues::Quats(vec![Quat::IDENTITY, Quat::from_rotation_z(0.2)]),
405        }
406    }
407
408    fn moving_scale() -> Track {
409        Track {
410            bone: 0,
411            property: Property::Scale,
412            interpolation: Interpolation::Linear,
413            times: vec![0.0, 1.0],
414            values: TrackValues::Vec3s(vec![Vec3::ONE, Vec3::splat(2.0)]),
415        }
416    }
417
418    #[test]
419    fn exact_route_and_sampled_fallback_domains_are_independently_pinned() {
420        for (property, interpolation, value) in [
421            (Property::Translation, Interpolation::Linear, Vec3::ZERO),
422            (Property::Translation, Interpolation::Step, Vec3::ZERO),
423            (Property::Scale, Interpolation::Linear, Vec3::ONE),
424            (Property::Scale, Interpolation::Step, Vec3::ONE),
425        ] {
426            assert_eq!(
427                sampled_trials_for(vec![
428                    vector_track(property, interpolation, value),
429                    moving_rotation(),
430                ]),
431                0,
432                "{property:?}/{interpolation:?} exact-rest channels use the bounded route"
433            );
434        }
435
436        let zero = Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
437        let sampled_cases = [
438            (
439                2,
440                vec![
441                    Track {
442                        bone: 0,
443                        property: Property::Rotation,
444                        interpolation: Interpolation::Linear,
445                        times: vec![0.0, 1.0],
446                        values: TrackValues::Quats(vec![Quat::IDENTITY, -Quat::IDENTITY]),
447                    },
448                    vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
449                    moving_scale(),
450                ],
451            ),
452            (
453                1,
454                vec![
455                    Track {
456                        bone: 0,
457                        property: Property::Translation,
458                        interpolation: Interpolation::CubicSpline,
459                        times: vec![0.0, 1.0],
460                        values: TrackValues::Vec3s(vec![
461                            Vec3::ZERO,
462                            Vec3::ZERO,
463                            Vec3::ZERO,
464                            Vec3::ZERO,
465                            Vec3::ZERO,
466                            Vec3::ZERO,
467                        ]),
468                    },
469                    moving_rotation(),
470                ],
471            ),
472            (
473                2,
474                vec![
475                    vector_track(Property::Scale, Interpolation::Linear, Vec3::ONE),
476                    vector_track(Property::Scale, Interpolation::Linear, Vec3::ONE),
477                    moving_rotation(),
478                ],
479            ),
480            (
481                1,
482                vec![
483                    vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
484                    moving_rotation(),
485                ],
486            ),
487            (
488                1,
489                vec![
490                    vector_track(
491                        Property::Translation,
492                        Interpolation::Linear,
493                        Vec3::splat(CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE * 0.5),
494                    ),
495                    moving_rotation(),
496                ],
497            ),
498            (
499                2,
500                vec![
501                    Track {
502                        bone: 0,
503                        property: Property::Rotation,
504                        interpolation: Interpolation::CubicSpline,
505                        times: vec![0.0, 1.0],
506                        values: TrackValues::Quats(vec![
507                            zero,
508                            Quat::IDENTITY,
509                            zero,
510                            zero,
511                            Quat::IDENTITY,
512                            zero,
513                        ]),
514                    },
515                    vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
516                    moving_scale(),
517                ],
518            ),
519            (
520                1,
521                vec![
522                    vector_track(
523                        Property::Translation,
524                        Interpolation::Linear,
525                        Vec3::new(-0.0, 0.0, 0.0),
526                    ),
527                    moving_rotation(),
528                ],
529            ),
530        ];
531        for (expected_trials, tracks) in sampled_cases {
532            assert_eq!(
533                sampled_trials_for(tracks),
534                expected_trials,
535                "each rotation, cubic, duplicate, non-rest, tolerance, and bit-distinct case retains its own sampled proof"
536            );
537        }
538    }
539
540    #[test]
541    fn linear_endpoints_that_round_on_the_grid_retain_the_sampled_proof() {
542        let rest_value = Vec3::splat(12_000.0);
543        let skeleton = Skeleton {
544            bones: vec![Bone {
545                name: "large".into(),
546                parent: None,
547                rest: Transform {
548                    translation: rest_value,
549                    ..Transform::IDENTITY
550                },
551                inverse_bind: None,
552            }],
553        };
554        let rotation_times = (0..=200).map(|key| key as f32 / 200.0).collect::<Vec<_>>();
555        let rotation_values = rotation_times
556            .iter()
557            .map(|time| Quat::from_rotation_z(*time * 0.2))
558            .collect::<Vec<_>>();
559        let mut clip = Clip {
560            name: "linear-rounding".into(),
561            duration_s: 1.0,
562            tracks: vec![
563                vector_track(Property::Translation, Interpolation::Linear, rest_value),
564                Track {
565                    bone: 0,
566                    property: Property::Rotation,
567                    interpolation: Interpolation::Linear,
568                    times: rotation_times,
569                    values: TrackValues::Quats(rotation_values),
570                },
571            ],
572        };
573        let mut sampled_trials = 0;
574
575        let outcome = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || {
576            sampled_trials += 1;
577        });
578
579        assert_eq!(sampled_trials, 1);
580        assert!(outcome.removed.is_empty());
581        assert_eq!(clip.tracks.len(), 2);
582        assert!((0..=200).any(|frame| {
583            matches!(
584                sample_track(&clip.tracks[0], frame as f32 / 200.0),
585                TrackSample::Vec3(value) if !vec3_bits_eq(value, rest_value)
586            )
587        }));
588    }
589}
590
591fn track_channel_key(track: &Track) -> (BoneId, u8) {
592    let property = match track.property {
593        Property::Translation => 0,
594        Property::Rotation => 1,
595        Property::Scale => 2,
596    };
597    (track.bone, property)
598}
599
600fn duplicate_track_channels(clip: &Clip) -> BTreeSet<(BoneId, u8)> {
601    let mut seen = BTreeSet::new();
602    let mut duplicates = BTreeSet::new();
603    for track in &clip.tracks {
604        if !seen.insert(track_channel_key(track)) {
605            duplicates.insert(track_channel_key(track));
606        }
607    }
608    duplicates
609}
610
611/// Whether deleting this sole authored vector channel produces its rest
612/// component exactly, without relying on the sampled tolerance check. This is
613/// only a stronger acceptance route for tracks that `is_constant_track`
614/// already classified as candidates; rotation and cubic candidates retain the
615/// sampled trial path.
616fn authored_track_is_exact_rest_equivalent(
617    track: &Track,
618    rest: &crate::model::Transform,
619    original: &PoseGrid,
620) -> bool {
621    let rest_value = match track.property {
622        Property::Translation => rest.translation,
623        Property::Scale => rest.scale,
624        Property::Rotation => return false,
625    };
626    let TrackValues::Vec3s(values) = &track.values else {
627        return false;
628    };
629    if !values.iter().all(|value| vec3_bits_eq(*value, rest_value)) {
630        return false;
631    }
632    match track.interpolation {
633        Interpolation::Step => true,
634        Interpolation::Linear => (0..original.frame_count()).all(|frame| {
635            let local = original.local(frame, track.bone);
636            let value = match track.property {
637                Property::Translation => local.translation,
638                Property::Scale => local.scale,
639                Property::Rotation => unreachable!("rotation was excluded above"),
640            };
641            vec3_bits_eq(value, rest_value)
642        }),
643        _ => false,
644    }
645}
646
647fn vec3_bits_eq(a: Vec3, b: Vec3) -> bool {
648    a.to_array()
649        .into_iter()
650        .zip(b.to_array())
651        .all(|(a, b)| a.to_bits() == b.to_bits())
652}
653
654fn valid_sampling_target(skeleton: &Skeleton, clip: &Clip) -> bool {
655    clip.duration_s.is_finite()
656        && clip.duration_s > 0.0
657        && skeleton.bones.iter().enumerate().all(|(index, bone)| {
658            bone.parent.is_none_or(|parent| parent < index)
659                && bone.rest.translation.is_finite()
660                && bone.rest.scale.is_finite()
661                && bone.rest.rotation.is_finite()
662                && bone.rest.rotation.length_squared() > 0.0
663        })
664        && clip.tracks.iter().all(|track| {
665            let Some(expected) = track.key_count().checked_mul(
666                if track.interpolation == Interpolation::CubicSpline {
667                    3
668                } else {
669                    1
670                },
671            ) else {
672                return false;
673            };
674            track.bone < skeleton.bones.len()
675                && track.key_count() > 0
676                && track.values.len() == expected
677                && track.times.iter().all(|time| time.is_finite())
678                && track.times.windows(2).all(|pair| pair[0] < pair[1])
679                && matches!(
680                    (track.property, &track.values),
681                    (Property::Rotation, TrackValues::Quats(_))
682                        | (
683                            Property::Translation | Property::Scale,
684                            TrackValues::Vec3s(_)
685                        )
686                )
687                && match &track.values {
688                    TrackValues::Vec3s(values) => values.iter().all(|value| value.is_finite()),
689                    TrackValues::Quats(values) => {
690                        values.iter().enumerate().all(|(index, value)| {
691                            value.is_finite()
692                                && (track.interpolation == Interpolation::CubicSpline
693                                    && index % 3 != 1
694                                    || value.length_squared() > 0.0)
695                        })
696                    }
697                }
698        })
699}
700
701fn finite_grid(grid: &crate::sample::PoseGrid) -> bool {
702    (0..grid.frame_count()).all(|frame| {
703        (0..grid.bone_count()).all(|bone| {
704            let pose = grid.local(frame, bone);
705            let model_position = grid.model_position(frame, bone);
706            let model_rotation = grid.model_rotation(frame, bone);
707            pose.translation.is_finite()
708                && pose.scale.is_finite()
709                && pose.rotation.is_finite()
710                && pose.rotation.length_squared() > 0.0
711                && model_position.is_finite()
712                && model_rotation.is_finite()
713                && model_rotation.length_squared() > 0.0
714        })
715    })
716}
717
718fn sampled_poses_match(
719    original: &crate::sample::PoseGrid,
720    trial: &crate::sample::PoseGrid,
721) -> bool {
722    original.frame_count() == trial.frame_count()
723        && original.bone_count() == trial.bone_count()
724        && (0..original.frame_count()).all(|frame| {
725            (0..original.bone_count()).all(|bone| {
726                let a = original.local(frame, bone);
727                let b = trial.local(frame, bone);
728                vec3_within(a.translation, b.translation)
729                    && vec3_within(a.scale, b.scale)
730                    && quaternion_within(a.rotation, b.rotation)
731                    && vec3_within(
732                        original.model_position(frame, bone),
733                        trial.model_position(frame, bone),
734                    )
735                    && quaternion_within(
736                        original.model_rotation(frame, bone),
737                        trial.model_rotation(frame, bone),
738                    )
739            })
740        })
741}
742
743fn vec3_within(a: Vec3, b: Vec3) -> bool {
744    (a - b).abs().max_element() <= CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE
745}
746
747fn quaternion_within(a: Quat, b: Quat) -> bool {
748    quaternion_angular_delta(a, b)
749        .is_some_and(|delta| delta <= CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD)
750}
751
752/// Analyze whether a clip has a safe, duplicated loop endpoint.
753///
754/// The authored timeline must be finite, strictly increasing, and exactly
755/// shared by every track; each track must have exact key/value cardinality,
756/// at least three keys, and a final time exactly equal to clip duration.
757/// Closing vectors compare component-wise within `1e-5`; quaternions compare
758/// with sign-invariant shortest-path angular distance within `1e-4` radians.
759/// The predicate is the mechanically removable subset of #22's future
760/// `duplicate_endpoint` mode, not a parallel endpoint-mode classifier.
761/// `Ok(None)` is a valid non-candidate, including two-key clips and stationary
762/// holds.
763pub fn analyze_duplicate_loop_endpoint(
764    clip: &Clip,
765) -> Result<Option<DuplicateLoopEndpointOutcome>, DuplicateLoopEndpointError> {
766    let Some(reference) = clip.tracks.first() else {
767        return Err(DuplicateLoopEndpointError::NoTracks);
768    };
769    if !clip.duration_s.is_finite() {
770        return Err(DuplicateLoopEndpointError::NonFinite { track: None });
771    }
772    let mut moving_terminal_count = None;
773    let mut terminal_counts = Vec::with_capacity(clip.tracks.len());
774    let mut max_translation_endpoint_delta_m: Option<f32> = None;
775    let mut max_rotation_endpoint_delta_rad: Option<f32> = None;
776    let mut max_scale_endpoint_delta: Option<f32> = None;
777    for (index, track) in clip.tracks.iter().enumerate() {
778        validate_duplicate_endpoint_track(index, track)?;
779        if track.times != reference.times {
780            return Err(DuplicateLoopEndpointError::TimelineMismatch { track: index });
781        }
782        // Authored key times are f32 even though the model carries duration as
783        // f64. Compare in the authored time domain so a preceding transform
784        // such as `slice` is not rejected only for f64 representation dust.
785        if track.end_time() != clip.duration_s as f32 {
786            return Err(DuplicateLoopEndpointError::DurationMismatch { track: index });
787        }
788        if track.key_count() < 3 {
789            return Ok(None);
790        }
791        let Some(count) = terminal_duplicate_count(track) else {
792            return Ok(None);
793        };
794        let final_key = track.key_count() - 1;
795        match track.property {
796            Property::Translation => {
797                let delta = vec3_key_delta(track, 0, final_key);
798                max_translation_endpoint_delta_m = Some(
799                    max_translation_endpoint_delta_m.map_or(delta, |current| current.max(delta)),
800                );
801            }
802            Property::Rotation => {
803                let Some(delta) = quaternion_key_delta(track, 0, final_key) else {
804                    return Ok(None);
805                };
806                max_rotation_endpoint_delta_rad = Some(
807                    max_rotation_endpoint_delta_rad.map_or(delta, |current| current.max(delta)),
808                );
809            }
810            Property::Scale => {
811                let delta = vec3_key_delta(track, 0, final_key);
812                max_scale_endpoint_delta =
813                    Some(max_scale_endpoint_delta.map_or(delta, |current| current.max(delta)));
814            }
815        }
816        let moves = track_has_motion(track);
817        if moves {
818            if moving_terminal_count.is_some_and(|expected| expected != count) {
819                return Ok(None);
820            }
821            moving_terminal_count = Some(count);
822        }
823        terminal_counts.push(count);
824    }
825    let Some(removed_keys_per_track) = moving_terminal_count else {
826        return Ok(None);
827    };
828    if terminal_counts
829        .into_iter()
830        .any(|available| available < removed_keys_per_track)
831    {
832        return Ok(None);
833    }
834    Ok(Some(DuplicateLoopEndpointOutcome {
835        removed_keys_per_track,
836        duration_before_s: clip.duration_s,
837        duration_after_s: reference.times[reference.key_count() - removed_keys_per_track - 1]
838            as f64,
839        max_translation_endpoint_delta_m,
840        max_rotation_endpoint_delta_rad,
841        max_scale_endpoint_delta,
842    }))
843}
844
845/// Atomically remove all consecutive duplicate closing keys from every track.
846///
847/// Retained times, values, and cubic tangent/value/tangent triplets are
848/// unchanged. Errors and non-candidates leave `clip` untouched.
849pub fn drop_duplicate_loop_endpoint(
850    clip: &mut Clip,
851) -> Result<Option<DuplicateLoopEndpointOutcome>, DuplicateLoopEndpointError> {
852    let Some(outcome) = analyze_duplicate_loop_endpoint(clip)? else {
853        return Ok(None);
854    };
855    for track in &mut clip.tracks {
856        let values = outcome.removed_keys_per_track
857            * if track.interpolation == Interpolation::CubicSpline {
858                3
859            } else {
860                1
861            };
862        track
863            .times
864            .truncate(track.key_count() - outcome.removed_keys_per_track);
865        match &mut track.values {
866            TrackValues::Vec3s(stored) => stored.truncate(stored.len() - values),
867            TrackValues::Quats(stored) => stored.truncate(stored.len() - values),
868        }
869    }
870    clip.duration_s = outcome.duration_after_s;
871    debug_assert!(matches!(analyze_duplicate_loop_endpoint(clip), Ok(None)));
872    Ok(Some(outcome))
873}
874
875fn validate_duplicate_endpoint_track(
876    index: usize,
877    track: &Track,
878) -> Result<(), DuplicateLoopEndpointError> {
879    let keys = track.key_count();
880    let expected = keys
881        * if track.interpolation == Interpolation::CubicSpline {
882            3
883        } else {
884            1
885        };
886    if track.values.len() != expected {
887        return Err(DuplicateLoopEndpointError::InvalidValueCount {
888            track: index,
889            key_count: keys,
890            value_count: track.values.len(),
891            interpolation: track.interpolation,
892        });
893    }
894    if !matches!(
895        (track.property, &track.values),
896        (Property::Rotation, TrackValues::Quats(_))
897            | (
898                Property::Translation | Property::Scale,
899                TrackValues::Vec3s(_)
900            )
901    ) {
902        return Err(DuplicateLoopEndpointError::InvalidValueStorage { track: index });
903    }
904    if track.times.iter().any(|time| !time.is_finite())
905        || match &track.values {
906            TrackValues::Vec3s(values) => values.iter().any(|value| !value.is_finite()),
907            TrackValues::Quats(values) => values.iter().any(|value| !value.is_finite()),
908        }
909    {
910        return Err(DuplicateLoopEndpointError::NonFinite { track: Some(index) });
911    }
912    if track.times.windows(2).any(|window| window[1] <= window[0]) {
913        return Err(DuplicateLoopEndpointError::NonIncreasingTime { track: index });
914    }
915    Ok(())
916}
917
918fn terminal_duplicate_count(track: &Track) -> Option<usize> {
919    let mut count = 0;
920    while count < track.key_count() - 2
921        && keyed_values_match(track, 0, track.key_count() - count - 1)
922    {
923        count += 1;
924    }
925    (count > 0).then_some(count)
926}
927
928fn track_has_motion(track: &Track) -> bool {
929    (1..track.key_count()).any(|key| !keyed_values_match(track, 0, key))
930        || (track.interpolation == Interpolation::CubicSpline
931            && match &track.values {
932                TrackValues::Vec3s(values) => values
933                    .iter()
934                    .enumerate()
935                    .filter(|(index, _)| index % 3 != 1)
936                    .any(|(_, value)| {
937                        value.abs().max_element() > DUPLICATE_ENDPOINT_VEC3_TOLERANCE
938                    }),
939                TrackValues::Quats(values) => values
940                    .iter()
941                    .enumerate()
942                    .filter(|(index, _)| index % 3 != 1)
943                    .any(|(_, value)| {
944                        value
945                            .to_array()
946                            .into_iter()
947                            .any(|component| component.abs() > DUPLICATE_ENDPOINT_VEC3_TOLERANCE)
948                    }),
949            })
950}
951
952fn keyed_values_match(track: &Track, first: usize, other: usize) -> bool {
953    match &track.values {
954        TrackValues::Vec3s(_) => {
955            vec3_key_delta(track, first, other) <= DUPLICATE_ENDPOINT_VEC3_TOLERANCE
956        }
957        TrackValues::Quats(_) => quaternion_key_delta(track, first, other)
958            .is_some_and(|delta| delta <= DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD),
959    }
960}
961
962fn vec3_key_delta(track: &Track, first: usize, other: usize) -> f32 {
963    let TrackValues::Vec3s(values) = &track.values else {
964        unreachable!("validated vector track")
965    };
966    (values[track.value_index(first)] - values[track.value_index(other)])
967        .abs()
968        .max_element()
969}
970
971fn quaternion_key_delta(track: &Track, first: usize, other: usize) -> Option<f32> {
972    let TrackValues::Quats(values) = &track.values else {
973        unreachable!("validated quaternion track")
974    };
975    let first = values[track.value_index(first)];
976    let other = values[track.value_index(other)];
977    let first_length_squared = first.length_squared();
978    let other_length_squared = other.length_squared();
979    if first_length_squared == 0.0 || other_length_squared == 0.0 {
980        return None;
981    }
982    let delta = first.normalize().conjugate() * other.normalize();
983    let [x, y, z, w] = delta.to_array();
984    let sin_half_angle = glam::Vec3::new(x, y, z).length();
985    Some(2.0 * sin_half_angle.atan2(w.abs()))
986}
987
988/// Keep only the keys inside `[start, end]` seconds (with a half-frame
989/// epsilon at `fps` absorbing float drift from earlier retimings) and
990/// retime them so the window starts at 0. Cubic tangent triplets move
991/// with their keys. The clip duration becomes `end - start`.
992///
993/// Boundary keys are snapped to the window, not carried past it: keys
994/// within the epsilon of `start` clamp to 0 and keys within it of `end`
995/// clamp to the new duration. When several keys land on a boundary, the
996/// one closest to the original boundary is kept and the rest dropped —
997/// so the output has at most one key at 0 and one at the end, stays
998/// time-monotonic, and round-trips its declared duration.
999///
1000/// # Panics
1001///
1002/// Panics if a hand-built track violates the loader invariant that
1003/// `values` contains one value per key for linear/step tracks, or one
1004/// tangent-value-tangent triplet per key for cubic-spline tracks.
1005pub fn slice(clip: &mut Clip, start_s: f64, end_s: f64, fps: f64) {
1006    let eps = (0.5 / fps) as f32;
1007    let (start, end) = (start_s as f32, end_s as f32);
1008    let duration = (end - start).max(0.0);
1009    for track in &mut clip.tracks {
1010        // (key index, retimed+clamped time), in original key order.
1011        let mut kept: Vec<(usize, f32)> = (0..track.key_count())
1012            .filter(|&k| track.times[k] >= start - eps && track.times[k] <= end + eps)
1013            .map(|k| (k, (track.times[k] - start).clamp(0.0, duration)))
1014            .collect();
1015
1016        // Drop boundary duplicates: at t=0 keep the last (closest to
1017        // `start`); at t=duration keep the first (closest to `end`).
1018        // Interior times are already distinct and monotonic.
1019        kept.retain({
1020            let times: Vec<f32> = kept.iter().map(|&(_, t)| t).collect();
1021            let mut i = 0;
1022            move |_| {
1023                let t = times[i];
1024                let keep = if t <= 0.0 {
1025                    times.get(i + 1).is_none_or(|&next| next > 0.0)
1026                } else if t >= duration {
1027                    i == 0 || times[i - 1] < duration
1028                } else {
1029                    true
1030                };
1031                i += 1;
1032                keep
1033            }
1034        });
1035
1036        track.times = kept.iter().map(|&(_, t)| t).collect();
1037        let per_key = match track.interpolation {
1038            Interpolation::CubicSpline => 3,
1039            _ => 1,
1040        };
1041        match &mut track.values {
1042            TrackValues::Vec3s(v) => {
1043                let old = std::mem::take(v);
1044                *v = kept
1045                    .iter()
1046                    .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
1047                    .collect();
1048            }
1049            TrackValues::Quats(v) => {
1050                let old = std::mem::take(v);
1051                *v = kept
1052                    .iter()
1053                    .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
1054                    .collect();
1055            }
1056        }
1057    }
1058    clip.duration_s = (end_s - start_s).max(0.0);
1059    clip.tracks.retain(|t| t.key_count() > 0);
1060}
1061
1062/// Append one key per track duplicating its final value `hold_s`
1063/// seconds after its last key (a linear hold — charge/block poses).
1064/// The clip duration extends to the longest held end.
1065///
1066/// # Panics
1067///
1068/// Panics if a hand-built track violates the loader invariant that each
1069/// key has a corresponding stored value (or cubic-spline triplet).
1070pub fn hold_extend(clip: &mut Clip, hold_s: f64) {
1071    for track in &mut clip.tracks {
1072        let Some(&last) = track.times.last() else {
1073            continue;
1074        };
1075        let key = track.key_count() - 1;
1076        track.times.push(last + hold_s as f32);
1077        let value_index = track.value_index(key);
1078        match &mut track.values {
1079            TrackValues::Vec3s(v) => {
1080                let value = v[value_index];
1081                match track.interpolation {
1082                    Interpolation::CubicSpline => {
1083                        // Zero tangents: a flat Hermite hold. Also zero
1084                        // the previous key's out-tangent so the hold
1085                        // segment stays flat.
1086                        v[key * 3 + 2] = glam::Vec3::ZERO;
1087                        v.extend_from_slice(&[glam::Vec3::ZERO, value, glam::Vec3::ZERO]);
1088                    }
1089                    _ => v.push(value),
1090                }
1091            }
1092            TrackValues::Quats(v) => {
1093                let value = v[value_index];
1094                match track.interpolation {
1095                    Interpolation::CubicSpline => {
1096                        v[key * 3 + 2] = glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
1097                        v.extend_from_slice(&[
1098                            glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1099                            value,
1100                            glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1101                        ]);
1102                    }
1103                    _ => v.push(value),
1104                }
1105            }
1106        }
1107        clip.duration_s = clip.duration_s.max((last + hold_s as f32) as f64);
1108    }
1109}
1110
1111/// Outcome of [`align_gait_anchor`].
1112#[derive(Debug, Clone)]
1113#[non_exhaustive]
1114pub struct GaitAlignOutcome {
1115    /// The measured stride-anchor phase before rotation.
1116    pub phase_before: f64,
1117    /// The phase after rotation (should sit near 0).
1118    pub phase_after: f64,
1119    /// Loop-seam ratio after rotation (the chosen candidate's wrap).
1120    pub seam_after: Option<f64>,
1121    /// The whole-frame offset (−1/0/+1) that produced the cleanest wrap.
1122    pub frame_offset: i32,
1123}
1124
1125/// Rotate a cyclic clip in time so its measured stride anchor (the
1126/// trough of the L−R foot-height fundamental) lands at clip time 0.
1127///
1128/// Semantics ported from the reference bake: the cycle period is
1129/// `duration + 1/fps` (an open loop's wrap step is a real frame of the
1130/// stride); the shift is quantized to whole frames so every resample
1131/// lands on an existing key; each animated channel keeps its times and
1132/// gets its output values replaced by the channel sampled at
1133/// `(t + shift) mod period`. Constant channels are rotation-invariant
1134/// and left alone; a non-constant CUBICSPLINE channel cannot be
1135/// resampled losslessly, so alignment refuses (naming it) rather than
1136/// rotate the rest of the rig around it. Because a ±1-frame shift stays
1137/// inside phase tolerance but moves *where the wrap lands*, all three
1138/// candidates are tried and the one with the cleanest wrap (lowest seam
1139/// ratio) wins.
1140///
1141/// # Errors
1142///
1143/// Returns an error when the clip has no measurable stride anchor, the
1144/// left-right foot amplitude is too small to define a stable phase, a
1145/// non-constant cubic-spline track would need lossy resampling, or no
1146/// tested rotation candidate remains measurable.
1147///
1148/// # Panics
1149///
1150/// Panics if `roles` contains bone indices outside `skeleton`, or if a
1151/// hand-built clip violates the loader key/value-count invariants.
1152pub fn align_gait_anchor(
1153    skeleton: &Skeleton,
1154    clip: &mut Clip,
1155    roles: &ResolvedRoles,
1156    fps: f64,
1157) -> Result<GaitAlignOutcome, String> {
1158    let measure = |c: &Clip| -> Option<(f64, Option<f64>, f64)> {
1159        let frames = crate::metrics::metric_frame_count(c)?;
1160        let grid = sample_clip(skeleton, c, frames);
1161        let m = foot_cycle_metrics(&grid, roles, crate::metrics::MIN_STRIDE_STEP_M)?;
1162        Some((m.gait_phase?, m.loop_seam_ratio, m.lr_amplitude_m))
1163    };
1164    let Some((phase_before, _, amplitude)) = measure(clip) else {
1165        return Err(
1166            "no usable stride anchor (hips/foot roles unresolved or clip too short)".into(),
1167        );
1168    };
1169    if amplitude < 0.03 {
1170        return Err(format!(
1171            "no usable stride anchor (L−R amplitude {amplitude:.4} m) — a ring clip must \
1172             alternate its feet for anchor alignment to mean anything"
1173        ));
1174    }
1175
1176    // Refuse rather than rotate part of a clip: a channel we cannot
1177    // resample coherently (a non-constant CUBICSPLINE track) would be
1178    // left in place while its siblings shift, desynchronizing the rig.
1179    // Constant tracks are rotation-invariant and safely skipped.
1180    let unrotatable: Vec<String> = clip
1181        .tracks
1182        .iter()
1183        .filter(|t| {
1184            t.interpolation == Interpolation::CubicSpline && !is_rotation_invariant_track(t)
1185        })
1186        .map(|t| format!("{} bone {}", t.property.as_str(), t.bone))
1187        .collect();
1188    if !unrotatable.is_empty() {
1189        return Err(format!(
1190            "cannot gait-anchor: these animated tracks need lossless resampling that is \
1191             not yet supported ({}); retime them to LINEAR first",
1192            unrotatable.join(", ")
1193        ));
1194    }
1195
1196    let original = clip.clone();
1197    let mut best: Option<(f64, GaitAlignOutcome, Clip)> = None;
1198    for frame_offset in [0i32, -1, 1] {
1199        let mut candidate = original.clone();
1200        rotate_values(&mut candidate, phase_before, fps, frame_offset);
1201        let Some((phase_after, seam_after, _)) = measure(&candidate) else {
1202            continue;
1203        };
1204        // Rank by wrap cleanliness; a missing seam (no stride at the
1205        // wrap) should not happen on a ring clip — rank it last.
1206        let rank = seam_after.unwrap_or(f64::MAX);
1207        if best.as_ref().is_none_or(|(r, _, _)| rank < *r) {
1208            best = Some((
1209                rank,
1210                GaitAlignOutcome {
1211                    phase_before,
1212                    phase_after,
1213                    seam_after,
1214                    frame_offset,
1215                },
1216                candidate,
1217            ));
1218        }
1219    }
1220    let Some((_, outcome, rotated)) = best else {
1221        return Err("no rotation candidate was measurable".into());
1222    };
1223    *clip = rotated;
1224    Ok(outcome)
1225}
1226
1227/// Exact representation-level predicate used by gait-anchor rotation. It is
1228/// intentionally stricter than the lint/prune tolerance classifier: changing
1229/// this would change which tracks gait rotation leaves untouched.
1230fn is_rotation_invariant_track(track: &Track) -> bool {
1231    let n = track.key_count();
1232    if n <= 1 {
1233        return true;
1234    }
1235    let cubic = track.interpolation == Interpolation::CubicSpline;
1236    fn constant<T: Copy + PartialEq>(values: &[T], n: usize, cubic: bool, zero: T) -> bool {
1237        let value = |key: usize| if cubic { 3 * key + 1 } else { key };
1238        let Some(&first) = values.get(value(0)) else {
1239            return false;
1240        };
1241        (0..n).all(|key| {
1242            values.get(value(key)) == Some(&first)
1243                && (!cubic
1244                    || (values.get(3 * key) == Some(&zero)
1245                        && values.get(3 * key + 2) == Some(&zero)))
1246        })
1247    }
1248    match &track.values {
1249        TrackValues::Vec3s(values) => constant(values, n, cubic, glam::Vec3::ZERO),
1250        TrackValues::Quats(values) => {
1251            constant(values, n, cubic, glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0))
1252        }
1253    }
1254}
1255
1256/// Replace each animated channel's output values with the channel
1257/// sampled at `(t + shift) mod period`; times untouched. Constant
1258/// tracks (rotation-invariant) are skipped; non-constant CUBICSPLINE
1259/// tracks are refused upstream in [`align_gait_anchor`].
1260///
1261/// Uniform-framing assumption: the cycle period is `duration + 1/fps`,
1262/// i.e. every track is framed on the same uniform `1/fps` grid and the
1263/// open-loop wrap step spans exactly one such frame. On a clip with
1264/// irregular key spacing — or tracks framed at different rates — that
1265/// period is only approximate, the quantized shift may miss an existing
1266/// key by a fraction of a frame, and the resample stops being exactly
1267/// lossless. animsmith's pipeline emits uniformly-framed clips, so the
1268/// assumption holds for the inputs this transform is applied to.
1269fn rotate_values(clip: &mut Clip, phase: f64, fps: f64, frame_offset: i32) {
1270    let duration = clip
1271        .tracks
1272        .iter()
1273        .map(Track::end_time)
1274        .fold(0.0f32, f32::max) as f64;
1275    if duration <= 0.0 {
1276        return;
1277    }
1278    let period = duration + 1.0 / fps;
1279    let mut shift = ((phase * period * fps).round() + frame_offset as f64) / fps;
1280    shift = shift.rem_euclid(period);
1281
1282    for track in &mut clip.tracks {
1283        // Constant tracks (any key count) are invariant; cubic tracks
1284        // reaching here are constant, so the zip below only touches
1285        // LINEAR/STEP values. Non-constant short tracks (e.g. a 2-key
1286        // root ramp) are now rotated instead of silently left behind.
1287        if is_rotation_invariant_track(track) {
1288            continue;
1289        }
1290        let sampled: Vec<TrackSample> = track
1291            .times
1292            .iter()
1293            .map(|&t| sample_track(track, ((t as f64 + shift) % period) as f32))
1294            .collect();
1295        match &mut track.values {
1296            TrackValues::Vec3s(v) => {
1297                for (slot, s) in v.iter_mut().zip(&sampled) {
1298                    if let TrackSample::Vec3(x) = s {
1299                        *slot = *x;
1300                    }
1301                }
1302            }
1303            TrackValues::Quats(v) => {
1304                for (slot, s) in v.iter_mut().zip(&sampled) {
1305                    if let TrackSample::Quat(q) = s {
1306                        *slot = *q;
1307                    }
1308                }
1309            }
1310        }
1311    }
1312}