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, Role};
10use crate::sample::{PoseGrid, default_frame_count, sample_clip, sample_clip_at_times};
11#[cfg(test)]
12use crate::sample::{TrackSample, sample_track};
13use glam::{DQuat, DVec3, Quat, Vec3};
14use std::collections::BTreeSet;
15use std::fmt;
16use thiserror::Error;
17
18/// Failure while analyzing a duplicate loop endpoint.
19#[derive(Debug, Clone, PartialEq, Error)]
20#[non_exhaustive]
21pub enum DuplicateLoopEndpointError {
22    /// The clip has no tracks.
23    #[error("clip has no tracks")]
24    NoTracks,
25    /// Stored value count does not exactly match the interpolation mode.
26    #[error(
27        "track {track} has {value_count} values for {key_count} keys with {interpolation:?} interpolation"
28    )]
29    InvalidValueCount {
30        /// Index of the malformed track.
31        track: usize,
32        /// Number of authored keys.
33        key_count: usize,
34        /// Number of stored values.
35        value_count: usize,
36        /// Interpolation mode that determines values per key.
37        interpolation: Interpolation,
38    },
39    /// A property uses incompatible value storage.
40    #[error("track {track} has invalid value storage")]
41    InvalidValueStorage {
42        /// Index of the malformed track.
43        track: usize,
44    },
45    /// A duration, key time, or stored value is non-finite.
46    #[error("track {track:?} contains a non-finite authored value")]
47    NonFinite {
48        /// Index of the malformed track, or `None` for clip duration.
49        track: Option<usize>,
50    },
51    /// A timeline is not strictly increasing.
52    #[error("track {track} timeline is not strictly increasing")]
53    NonIncreasingTime {
54        /// Index of the malformed track.
55        track: usize,
56    },
57    /// A track differs from the exact common authored timeline.
58    #[error("track {track} does not share the exact authored timeline")]
59    TimelineMismatch {
60        /// Index of the mismatching track.
61        track: usize,
62    },
63    /// A final key time does not equal the declared duration.
64    #[error("track {track} does not end at the declared duration")]
65    DurationMismatch {
66        /// Index of the mismatching track.
67        track: usize,
68    },
69}
70
71/// The lossless change made by [`drop_duplicate_loop_endpoint`].
72#[derive(Debug, Clone, Copy, PartialEq)]
73#[non_exhaustive]
74pub struct DuplicateLoopEndpointOutcome {
75    /// Number of consecutive closing keys removed from every track.
76    pub removed_keys_per_track: usize,
77    /// Declared duration before removal.
78    pub duration_before_s: f64,
79    /// Duration re-pinned to the final retained key.
80    pub duration_after_s: f64,
81    /// Largest closing translation-component delta, in metres.
82    pub max_translation_endpoint_delta_m: Option<f32>,
83    /// Largest sign-invariant closing rotation delta, in radians.
84    pub max_rotation_endpoint_delta_rad: Option<f32>,
85    /// Largest closing scale-component delta.
86    pub max_scale_endpoint_delta: Option<f32>,
87}
88
89/// Component-wise tolerance for duplicate translation and scale endpoints.
90pub const DUPLICATE_ENDPOINT_VEC3_TOLERANCE: f32 = 1.0e-5;
91/// Sign-invariant shortest-path angular tolerance for duplicate rotations.
92pub const DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD: f32 = 1.0e-4;
93
94/// Maximum component-wise local translation/scale change accepted when
95/// pruning a constant track. This aliases the `constant-track` check's
96/// classification tolerance.
97pub const CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE: f32 = crate::checks::constant_track::VEC3_TOLERANCE;
98/// Maximum sign-invariant local rotation change accepted when pruning a
99/// constant track. This aliases the `constant-track` check's tolerance.
100pub const CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD: f32 =
101    crate::checks::constant_track::QUAT_TOLERANCE_RAD;
102
103/// Outcome of [`prune_constant_tracks`].
104#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub struct PruneConstantTracksOutcome {
107    /// Candidate tracks removed, in original authored order.
108    pub removed: Vec<ConstantTrackPruneRecord>,
109    /// Candidate tracks retained, in original authored order.
110    pub retained: Vec<ConstantTrackRetainedRecord>,
111}
112
113/// One constant-track candidate considered by [`prune_constant_tracks`].
114#[derive(Debug, Clone, PartialEq)]
115#[non_exhaustive]
116pub struct ConstantTrackPruneRecord {
117    /// Original index in [`Clip::tracks`].
118    pub original_track_index: usize,
119    /// Target bone.
120    pub bone: BoneId,
121    /// Target local TRS property.
122    pub property: Property,
123    /// Authored interpolation mode.
124    pub interpolation: Interpolation,
125    /// Number of authored keyframes.
126    pub key_count: usize,
127}
128
129/// A candidate that [`prune_constant_tracks`] conservatively retained.
130#[derive(Debug, Clone, PartialEq)]
131#[non_exhaustive]
132pub struct ConstantTrackRetainedRecord {
133    /// The candidate's immutable authored evidence.
134    pub record: ConstantTrackPruneRecord,
135    /// Why it was retained.
136    pub reason: ConstantTrackRetentionReason,
137}
138
139/// Reason a constant-track candidate was not removed.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum ConstantTrackRetentionReason {
143    /// The caller identifies this bone as a required authored channel.
144    ProtectedBone,
145    /// The track targets no bone in the supplied skeleton.
146    InvalidTarget,
147    /// The original or a trial clip cannot be safely sampled.
148    SamplingUnavailable,
149    /// Removing the track changes sampled local TRS or model-space pose data.
150    PoseChanged,
151    /// Removing the track would leave no writable track in the clip.
152    LastWritableTrack,
153}
154
155impl fmt::Display for ConstantTrackRetentionReason {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        f.write_str(match self {
158            Self::ProtectedBone => "target bone is protected",
159            Self::InvalidTarget => "track target is not present in the skeleton",
160            Self::SamplingUnavailable => "the original or trial clip cannot be sampled safely",
161            Self::PoseChanged => {
162                "removal changes sampled local TRS or model-space position/rotation"
163            }
164            Self::LastWritableTrack => "removal would leave no writable track",
165        })
166    }
167}
168
169/// Remove constant multi-key tracks only when doing so preserves every local
170/// TRS and model-space position/rotation on the original clip's default sample
171/// grid.
172///
173/// This is deliberately more conservative than the `constant-track` check:
174/// an all-zero translation track, for example, only disappears if the rest
175/// pose and any other channel reproduce it. Candidate classification shares
176/// that check's interpolation-aware tolerances; accepted removals are then
177/// validated cumulatively against the untouched original. Invalid hand-built
178/// inputs are retained instead of panicking. The final edit is atomic.
179pub fn prune_constant_tracks(
180    skeleton: &Skeleton,
181    clip: &mut Clip,
182    protected_bones: &[BoneId],
183) -> PruneConstantTracksOutcome {
184    prune_constant_tracks_impl(skeleton, clip, protected_bones, || {})
185}
186
187fn prune_constant_tracks_impl(
188    skeleton: &Skeleton,
189    clip: &mut Clip,
190    protected_bones: &[BoneId],
191    mut record_sampled_trial: impl FnMut(),
192) -> PruneConstantTracksOutcome {
193    let candidates: Vec<ConstantTrackPruneRecord> = clip
194        .tracks
195        .iter()
196        .enumerate()
197        .filter(|(_, track)| is_constant_track(track))
198        .map(|(original_track_index, track)| ConstantTrackPruneRecord {
199            original_track_index,
200            bone: track.bone,
201            property: track.property,
202            interpolation: track.interpolation,
203            key_count: track.key_count(),
204        })
205        .collect();
206    if candidates.is_empty() {
207        return PruneConstantTracksOutcome {
208            removed: Vec::new(),
209            retained: Vec::new(),
210        };
211    }
212
213    if !valid_sampling_target(skeleton, clip) {
214        return PruneConstantTracksOutcome {
215            removed: Vec::new(),
216            retained: candidates
217                .into_iter()
218                .map(|record| ConstantTrackRetainedRecord {
219                    reason: if record.bone >= skeleton.bones.len() {
220                        ConstantTrackRetentionReason::InvalidTarget
221                    } else {
222                        ConstantTrackRetentionReason::SamplingUnavailable
223                    },
224                    record,
225                })
226                .collect(),
227        };
228    }
229
230    let frames = default_frame_count(clip);
231    let original = sample_clip(skeleton, clip, frames);
232    if !finite_grid(&original) {
233        return PruneConstantTracksOutcome {
234            removed: Vec::new(),
235            retained: candidates
236                .into_iter()
237                .map(|record| ConstantTrackRetainedRecord {
238                    record,
239                    reason: ConstantTrackRetentionReason::SamplingUnavailable,
240                })
241                .collect(),
242        };
243    }
244
245    let source = clip.clone();
246    let duplicate_channels = duplicate_track_channels(&source);
247    let protected_bones: BTreeSet<_> = protected_bones.iter().copied().collect();
248    let mut accepted = BTreeSet::new();
249    let mut removed_records = Vec::new();
250    let mut retained = Vec::new();
251    for record in candidates {
252        if protected_bones.contains(&record.bone) {
253            retained.push(ConstantTrackRetainedRecord {
254                record,
255                reason: ConstantTrackRetentionReason::ProtectedBone,
256            });
257            continue;
258        }
259        if source.tracks.len() <= accepted.len() + 1 {
260            retained.push(ConstantTrackRetainedRecord {
261                record,
262                reason: ConstantTrackRetentionReason::LastWritableTrack,
263            });
264            continue;
265        }
266        let exact_rest_channel = source
267            .tracks
268            .get(record.original_track_index)
269            .zip(skeleton.bones.get(record.bone))
270            .is_some_and(|(track, bone)| {
271                !duplicate_channels.contains(&track_channel_key(track))
272                    && authored_track_is_exact_rest_equivalent(track, &bone.rest, &original)
273            });
274        if exact_rest_channel {
275            accepted.insert(record.original_track_index);
276            removed_records.push(record);
277            continue;
278        }
279        record_sampled_trial();
280        let mut trial = source.clone();
281        trial.tracks = source
282            .tracks
283            .iter()
284            .enumerate()
285            .filter(|(index, _)| *index != record.original_track_index && !accepted.contains(index))
286            .map(|(_, track)| track.clone())
287            .collect();
288        let trial_grid = sample_clip(skeleton, &trial, frames);
289        if !finite_grid(&trial_grid) {
290            retained.push(ConstantTrackRetainedRecord {
291                record,
292                reason: ConstantTrackRetentionReason::SamplingUnavailable,
293            });
294        } else if !sampled_poses_match(&original, &trial_grid) {
295            retained.push(ConstantTrackRetainedRecord {
296                record,
297                reason: ConstantTrackRetentionReason::PoseChanged,
298            });
299        } else {
300            accepted.insert(record.original_track_index);
301            removed_records.push(record);
302        }
303    }
304    if !accepted.is_empty() {
305        clip.tracks = source
306            .tracks
307            .into_iter()
308            .enumerate()
309            .filter(|(index, _)| !accepted.contains(index))
310            .map(|(_, track)| track)
311            .collect();
312    }
313    PruneConstantTracksOutcome {
314        removed: removed_records,
315        retained,
316    }
317}
318
319#[cfg(test)]
320mod constant_track_fast_path_tests {
321    use super::*;
322    use crate::model::{Bone, Transform};
323
324    #[test]
325    fn thousands_of_unique_exact_rest_channels_require_no_sampled_trials() {
326        const CANDIDATE_COUNT: usize = 2_048;
327        let skeleton = Skeleton {
328            bones: (0..CANDIDATE_COUNT)
329                .map(|bone| Bone {
330                    name: format!("bone-{bone}"),
331                    parent: None,
332                    rest: Transform::IDENTITY,
333                    inverse_bind: None,
334                })
335                .collect(),
336        };
337        let mut tracks: Vec<_> = (0..CANDIDATE_COUNT)
338            .map(|bone| Track {
339                bone,
340                property: Property::Translation,
341                interpolation: Interpolation::Linear,
342                times: vec![0.0, 1.0],
343                values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
344            })
345            .collect();
346        tracks.push(Track {
347            bone: 0,
348            property: Property::Rotation,
349            interpolation: Interpolation::Linear,
350            times: vec![0.0, 1.0],
351            values: TrackValues::Quats(vec![Quat::IDENTITY, Quat::from_rotation_z(0.2)]),
352        });
353        let mut clip = Clip {
354            name: "large-exact-rest".into(),
355            duration_s: 1.0,
356            tracks,
357        };
358        let mut sampled_trials = 0;
359
360        let outcome = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || {
361            sampled_trials += 1;
362        });
363
364        assert_eq!(outcome.removed.len(), CANDIDATE_COUNT);
365        assert!(outcome.retained.is_empty());
366        assert_eq!(sampled_trials, 0);
367        assert_eq!(clip.tracks.len(), 1);
368        assert_eq!(clip.tracks[0].property, Property::Rotation);
369    }
370
371    fn sampled_trials_for(tracks: Vec<Track>) -> usize {
372        let skeleton = Skeleton {
373            bones: vec![Bone {
374                name: "root".into(),
375                parent: None,
376                rest: Transform::IDENTITY,
377                inverse_bind: None,
378            }],
379        };
380        let mut clip = Clip {
381            name: "route".into(),
382            duration_s: 1.0,
383            tracks,
384        };
385        let mut sampled_trials = 0;
386        let _ = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || sampled_trials += 1);
387        sampled_trials
388    }
389
390    fn vector_track(property: Property, interpolation: Interpolation, value: Vec3) -> Track {
391        Track {
392            bone: 0,
393            property,
394            interpolation,
395            times: vec![0.0, 1.0],
396            values: TrackValues::Vec3s(vec![value, value]),
397        }
398    }
399
400    fn moving_rotation() -> Track {
401        Track {
402            bone: 0,
403            property: Property::Rotation,
404            interpolation: Interpolation::Linear,
405            times: vec![0.0, 1.0],
406            values: TrackValues::Quats(vec![Quat::IDENTITY, Quat::from_rotation_z(0.2)]),
407        }
408    }
409
410    fn moving_scale() -> Track {
411        Track {
412            bone: 0,
413            property: Property::Scale,
414            interpolation: Interpolation::Linear,
415            times: vec![0.0, 1.0],
416            values: TrackValues::Vec3s(vec![Vec3::ONE, Vec3::splat(2.0)]),
417        }
418    }
419
420    #[test]
421    fn exact_route_and_sampled_fallback_domains_are_independently_pinned() {
422        for (property, interpolation, value) in [
423            (Property::Translation, Interpolation::Linear, Vec3::ZERO),
424            (Property::Translation, Interpolation::Step, Vec3::ZERO),
425            (Property::Scale, Interpolation::Linear, Vec3::ONE),
426            (Property::Scale, Interpolation::Step, Vec3::ONE),
427        ] {
428            assert_eq!(
429                sampled_trials_for(vec![
430                    vector_track(property, interpolation, value),
431                    moving_rotation(),
432                ]),
433                0,
434                "{property:?}/{interpolation:?} exact-rest channels use the bounded route"
435            );
436        }
437
438        let zero = Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
439        let sampled_cases = [
440            (
441                2,
442                vec![
443                    Track {
444                        bone: 0,
445                        property: Property::Rotation,
446                        interpolation: Interpolation::Linear,
447                        times: vec![0.0, 1.0],
448                        values: TrackValues::Quats(vec![Quat::IDENTITY, -Quat::IDENTITY]),
449                    },
450                    vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
451                    moving_scale(),
452                ],
453            ),
454            (
455                1,
456                vec![
457                    Track {
458                        bone: 0,
459                        property: Property::Translation,
460                        interpolation: Interpolation::CubicSpline,
461                        times: vec![0.0, 1.0],
462                        values: TrackValues::Vec3s(vec![
463                            Vec3::ZERO,
464                            Vec3::ZERO,
465                            Vec3::ZERO,
466                            Vec3::ZERO,
467                            Vec3::ZERO,
468                            Vec3::ZERO,
469                        ]),
470                    },
471                    moving_rotation(),
472                ],
473            ),
474            (
475                2,
476                vec![
477                    vector_track(Property::Scale, Interpolation::Linear, Vec3::ONE),
478                    vector_track(Property::Scale, Interpolation::Linear, Vec3::ONE),
479                    moving_rotation(),
480                ],
481            ),
482            (
483                1,
484                vec![
485                    vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
486                    moving_rotation(),
487                ],
488            ),
489            (
490                1,
491                vec![
492                    vector_track(
493                        Property::Translation,
494                        Interpolation::Linear,
495                        Vec3::splat(CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE * 0.5),
496                    ),
497                    moving_rotation(),
498                ],
499            ),
500            (
501                2,
502                vec![
503                    Track {
504                        bone: 0,
505                        property: Property::Rotation,
506                        interpolation: Interpolation::CubicSpline,
507                        times: vec![0.0, 1.0],
508                        values: TrackValues::Quats(vec![
509                            zero,
510                            Quat::IDENTITY,
511                            zero,
512                            zero,
513                            Quat::IDENTITY,
514                            zero,
515                        ]),
516                    },
517                    vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
518                    moving_scale(),
519                ],
520            ),
521            (
522                1,
523                vec![
524                    vector_track(
525                        Property::Translation,
526                        Interpolation::Linear,
527                        Vec3::new(-0.0, 0.0, 0.0),
528                    ),
529                    moving_rotation(),
530                ],
531            ),
532        ];
533        for (expected_trials, tracks) in sampled_cases {
534            assert_eq!(
535                sampled_trials_for(tracks),
536                expected_trials,
537                "each rotation, cubic, duplicate, non-rest, tolerance, and bit-distinct case retains its own sampled proof"
538            );
539        }
540    }
541
542    #[test]
543    fn linear_endpoints_that_round_on_the_grid_retain_the_sampled_proof() {
544        let rest_value = Vec3::splat(12_000.0);
545        let skeleton = Skeleton {
546            bones: vec![Bone {
547                name: "large".into(),
548                parent: None,
549                rest: Transform {
550                    translation: rest_value,
551                    ..Transform::IDENTITY
552                },
553                inverse_bind: None,
554            }],
555        };
556        let rotation_times = (0..=200).map(|key| key as f32 / 200.0).collect::<Vec<_>>();
557        let rotation_values = rotation_times
558            .iter()
559            .map(|time| Quat::from_rotation_z(*time * 0.2))
560            .collect::<Vec<_>>();
561        let mut clip = Clip {
562            name: "linear-rounding".into(),
563            duration_s: 1.0,
564            tracks: vec![
565                vector_track(Property::Translation, Interpolation::Linear, rest_value),
566                Track {
567                    bone: 0,
568                    property: Property::Rotation,
569                    interpolation: Interpolation::Linear,
570                    times: rotation_times,
571                    values: TrackValues::Quats(rotation_values),
572                },
573            ],
574        };
575        let mut sampled_trials = 0;
576
577        let outcome = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || {
578            sampled_trials += 1;
579        });
580
581        assert_eq!(sampled_trials, 1);
582        assert!(outcome.removed.is_empty());
583        assert_eq!(clip.tracks.len(), 2);
584        assert!((0..=200).any(|frame| {
585            matches!(
586                sample_track(&clip.tracks[0], frame as f32 / 200.0),
587                TrackSample::Vec3(value) if !vec3_bits_eq(value, rest_value)
588            )
589        }));
590    }
591}
592
593fn track_channel_key(track: &Track) -> (BoneId, u8) {
594    let property = match track.property {
595        Property::Translation => 0,
596        Property::Rotation => 1,
597        Property::Scale => 2,
598    };
599    (track.bone, property)
600}
601
602fn duplicate_track_channels(clip: &Clip) -> BTreeSet<(BoneId, u8)> {
603    let mut seen = BTreeSet::new();
604    let mut duplicates = BTreeSet::new();
605    for track in &clip.tracks {
606        if !seen.insert(track_channel_key(track)) {
607            duplicates.insert(track_channel_key(track));
608        }
609    }
610    duplicates
611}
612
613/// Whether deleting this sole authored vector channel produces its rest
614/// component exactly, without relying on the sampled tolerance check. This is
615/// only a stronger acceptance route for tracks that `is_constant_track`
616/// already classified as candidates; rotation and cubic candidates retain the
617/// sampled trial path.
618fn authored_track_is_exact_rest_equivalent(
619    track: &Track,
620    rest: &crate::model::Transform,
621    original: &PoseGrid,
622) -> bool {
623    let rest_value = match track.property {
624        Property::Translation => rest.translation,
625        Property::Scale => rest.scale,
626        Property::Rotation => return false,
627    };
628    let TrackValues::Vec3s(values) = &track.values else {
629        return false;
630    };
631    if !values.iter().all(|value| vec3_bits_eq(*value, rest_value)) {
632        return false;
633    }
634    match track.interpolation {
635        Interpolation::Step => true,
636        Interpolation::Linear => (0..original.frame_count()).all(|frame| {
637            let local = original.local(frame, track.bone);
638            let value = match track.property {
639                Property::Translation => local.translation,
640                Property::Scale => local.scale,
641                Property::Rotation => unreachable!("rotation was excluded above"),
642            };
643            vec3_bits_eq(value, rest_value)
644        }),
645        _ => false,
646    }
647}
648
649fn vec3_bits_eq(a: Vec3, b: Vec3) -> bool {
650    a.to_array()
651        .into_iter()
652        .zip(b.to_array())
653        .all(|(a, b)| a.to_bits() == b.to_bits())
654}
655
656fn valid_sampling_target(skeleton: &Skeleton, clip: &Clip) -> bool {
657    clip.duration_s.is_finite()
658        && clip.duration_s > 0.0
659        && skeleton.bones.iter().enumerate().all(|(index, bone)| {
660            bone.parent.is_none_or(|parent| parent < index)
661                && bone.rest.translation.is_finite()
662                && bone.rest.scale.is_finite()
663                && bone.rest.rotation.is_finite()
664                && bone.rest.rotation.length_squared() > 0.0
665        })
666        && clip.tracks.iter().all(|track| {
667            let Some(expected) = track.key_count().checked_mul(
668                if track.interpolation == Interpolation::CubicSpline {
669                    3
670                } else {
671                    1
672                },
673            ) else {
674                return false;
675            };
676            track.bone < skeleton.bones.len()
677                && track.key_count() > 0
678                && track.values.len() == expected
679                && track.times.iter().all(|time| time.is_finite())
680                && track.times.windows(2).all(|pair| pair[0] < pair[1])
681                && matches!(
682                    (track.property, &track.values),
683                    (Property::Rotation, TrackValues::Quats(_))
684                        | (
685                            Property::Translation | Property::Scale,
686                            TrackValues::Vec3s(_)
687                        )
688                )
689                && match &track.values {
690                    TrackValues::Vec3s(values) => values.iter().all(|value| value.is_finite()),
691                    TrackValues::Quats(values) => {
692                        values.iter().enumerate().all(|(index, value)| {
693                            value.is_finite()
694                                && (track.interpolation == Interpolation::CubicSpline
695                                    && index % 3 != 1
696                                    || value.length_squared() > 0.0)
697                        })
698                    }
699                }
700        })
701}
702
703fn finite_grid(grid: &crate::sample::PoseGrid) -> bool {
704    (0..grid.frame_count()).all(|frame| {
705        (0..grid.bone_count()).all(|bone| {
706            let pose = grid.local(frame, bone);
707            let model_position = grid.model_position(frame, bone);
708            let model_rotation = grid.model_rotation(frame, bone);
709            pose.translation.is_finite()
710                && pose.scale.is_finite()
711                && pose.rotation.is_finite()
712                && pose.rotation.length_squared() > 0.0
713                && model_position.is_finite()
714                && model_rotation.is_finite()
715                && model_rotation.length_squared() > 0.0
716        })
717    })
718}
719
720fn sampled_poses_match(
721    original: &crate::sample::PoseGrid,
722    trial: &crate::sample::PoseGrid,
723) -> bool {
724    original.frame_count() == trial.frame_count()
725        && original.bone_count() == trial.bone_count()
726        && (0..original.frame_count()).all(|frame| {
727            (0..original.bone_count()).all(|bone| {
728                let a = original.local(frame, bone);
729                let b = trial.local(frame, bone);
730                vec3_within(a.translation, b.translation)
731                    && vec3_within(a.scale, b.scale)
732                    && quaternion_within(a.rotation, b.rotation)
733                    && vec3_within(
734                        original.model_position(frame, bone),
735                        trial.model_position(frame, bone),
736                    )
737                    && quaternion_within(
738                        original.model_rotation(frame, bone),
739                        trial.model_rotation(frame, bone),
740                    )
741            })
742        })
743}
744
745fn vec3_within(a: Vec3, b: Vec3) -> bool {
746    (a - b).abs().max_element() <= CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE
747}
748
749fn quaternion_within(a: Quat, b: Quat) -> bool {
750    quaternion_angular_delta(a, b)
751        .is_some_and(|delta| delta <= CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD)
752}
753
754/// Analyze whether a clip has a safe, duplicated loop endpoint.
755///
756/// The authored timeline must be finite, strictly increasing, and exactly
757/// shared by every track; each track must have exact key/value cardinality,
758/// at least three keys, and a final time exactly equal to clip duration.
759/// Closing vectors compare component-wise within `1e-5`; quaternions compare
760/// with sign-invariant shortest-path angular distance within `1e-4` radians.
761/// The predicate is the mechanically removable subset of #22's future
762/// `duplicate_endpoint` mode, not a parallel endpoint-mode classifier.
763/// `Ok(None)` is a valid non-candidate, including two-key clips and stationary
764/// holds.
765pub fn analyze_duplicate_loop_endpoint(
766    clip: &Clip,
767) -> Result<Option<DuplicateLoopEndpointOutcome>, DuplicateLoopEndpointError> {
768    let Some(reference) = clip.tracks.first() else {
769        return Err(DuplicateLoopEndpointError::NoTracks);
770    };
771    if !clip.duration_s.is_finite() {
772        return Err(DuplicateLoopEndpointError::NonFinite { track: None });
773    }
774    let mut moving_terminal_count = None;
775    let mut terminal_counts = Vec::with_capacity(clip.tracks.len());
776    let mut max_translation_endpoint_delta_m: Option<f32> = None;
777    let mut max_rotation_endpoint_delta_rad: Option<f32> = None;
778    let mut max_scale_endpoint_delta: Option<f32> = None;
779    for (index, track) in clip.tracks.iter().enumerate() {
780        validate_duplicate_endpoint_track(index, track)?;
781        if track.times != reference.times {
782            return Err(DuplicateLoopEndpointError::TimelineMismatch { track: index });
783        }
784        // Authored key times are f32 even though the model carries duration as
785        // f64. Compare in the authored time domain so a preceding transform
786        // such as `slice` is not rejected only for f64 representation dust.
787        if track.end_time() != clip.duration_s as f32 {
788            return Err(DuplicateLoopEndpointError::DurationMismatch { track: index });
789        }
790        if track.key_count() < 3 {
791            return Ok(None);
792        }
793        let Some(count) = terminal_duplicate_count(track) else {
794            return Ok(None);
795        };
796        let final_key = track.key_count() - 1;
797        match track.property {
798            Property::Translation => {
799                let delta = vec3_key_delta(track, 0, final_key);
800                max_translation_endpoint_delta_m = Some(
801                    max_translation_endpoint_delta_m.map_or(delta, |current| current.max(delta)),
802                );
803            }
804            Property::Rotation => {
805                let Some(delta) = quaternion_key_delta(track, 0, final_key) else {
806                    return Ok(None);
807                };
808                max_rotation_endpoint_delta_rad = Some(
809                    max_rotation_endpoint_delta_rad.map_or(delta, |current| current.max(delta)),
810                );
811            }
812            Property::Scale => {
813                let delta = vec3_key_delta(track, 0, final_key);
814                max_scale_endpoint_delta =
815                    Some(max_scale_endpoint_delta.map_or(delta, |current| current.max(delta)));
816            }
817        }
818        let moves = track_has_motion(track);
819        if moves {
820            if moving_terminal_count.is_some_and(|expected| expected != count) {
821                return Ok(None);
822            }
823            moving_terminal_count = Some(count);
824        }
825        terminal_counts.push(count);
826    }
827    let Some(removed_keys_per_track) = moving_terminal_count else {
828        return Ok(None);
829    };
830    if terminal_counts
831        .into_iter()
832        .any(|available| available < removed_keys_per_track)
833    {
834        return Ok(None);
835    }
836    Ok(Some(DuplicateLoopEndpointOutcome {
837        removed_keys_per_track,
838        duration_before_s: clip.duration_s,
839        duration_after_s: reference.times[reference.key_count() - removed_keys_per_track - 1]
840            as f64,
841        max_translation_endpoint_delta_m,
842        max_rotation_endpoint_delta_rad,
843        max_scale_endpoint_delta,
844    }))
845}
846
847/// Atomically remove all consecutive duplicate closing keys from every track.
848///
849/// Retained times, values, and cubic tangent/value/tangent triplets are
850/// unchanged. Errors and non-candidates leave `clip` untouched.
851pub fn drop_duplicate_loop_endpoint(
852    clip: &mut Clip,
853) -> Result<Option<DuplicateLoopEndpointOutcome>, DuplicateLoopEndpointError> {
854    let Some(outcome) = analyze_duplicate_loop_endpoint(clip)? else {
855        return Ok(None);
856    };
857    for track in &mut clip.tracks {
858        let values = outcome.removed_keys_per_track
859            * if track.interpolation == Interpolation::CubicSpline {
860                3
861            } else {
862                1
863            };
864        track
865            .times
866            .truncate(track.key_count() - outcome.removed_keys_per_track);
867        match &mut track.values {
868            TrackValues::Vec3s(stored) => stored.truncate(stored.len() - values),
869            TrackValues::Quats(stored) => stored.truncate(stored.len() - values),
870        }
871    }
872    clip.duration_s = outcome.duration_after_s;
873    debug_assert!(matches!(analyze_duplicate_loop_endpoint(clip), Ok(None)));
874    Ok(Some(outcome))
875}
876
877fn validate_duplicate_endpoint_track(
878    index: usize,
879    track: &Track,
880) -> Result<(), DuplicateLoopEndpointError> {
881    let keys = track.key_count();
882    let expected = keys
883        * if track.interpolation == Interpolation::CubicSpline {
884            3
885        } else {
886            1
887        };
888    if track.values.len() != expected {
889        return Err(DuplicateLoopEndpointError::InvalidValueCount {
890            track: index,
891            key_count: keys,
892            value_count: track.values.len(),
893            interpolation: track.interpolation,
894        });
895    }
896    if !matches!(
897        (track.property, &track.values),
898        (Property::Rotation, TrackValues::Quats(_))
899            | (
900                Property::Translation | Property::Scale,
901                TrackValues::Vec3s(_)
902            )
903    ) {
904        return Err(DuplicateLoopEndpointError::InvalidValueStorage { track: index });
905    }
906    if track.times.iter().any(|time| !time.is_finite())
907        || match &track.values {
908            TrackValues::Vec3s(values) => values.iter().any(|value| !value.is_finite()),
909            TrackValues::Quats(values) => values.iter().any(|value| !value.is_finite()),
910        }
911    {
912        return Err(DuplicateLoopEndpointError::NonFinite { track: Some(index) });
913    }
914    if track.times.windows(2).any(|window| window[1] <= window[0]) {
915        return Err(DuplicateLoopEndpointError::NonIncreasingTime { track: index });
916    }
917    Ok(())
918}
919
920fn terminal_duplicate_count(track: &Track) -> Option<usize> {
921    let mut count = 0;
922    while count < track.key_count() - 2
923        && keyed_values_match(track, 0, track.key_count() - count - 1)
924    {
925        count += 1;
926    }
927    (count > 0).then_some(count)
928}
929
930fn track_has_motion(track: &Track) -> bool {
931    (1..track.key_count()).any(|key| !keyed_values_match(track, 0, key))
932        || (track.interpolation == Interpolation::CubicSpline
933            && match &track.values {
934                TrackValues::Vec3s(values) => values
935                    .iter()
936                    .enumerate()
937                    .filter(|(index, _)| index % 3 != 1)
938                    .any(|(_, value)| {
939                        value.abs().max_element() > DUPLICATE_ENDPOINT_VEC3_TOLERANCE
940                    }),
941                TrackValues::Quats(values) => values
942                    .iter()
943                    .enumerate()
944                    .filter(|(index, _)| index % 3 != 1)
945                    .any(|(_, value)| {
946                        value
947                            .to_array()
948                            .into_iter()
949                            .any(|component| component.abs() > DUPLICATE_ENDPOINT_VEC3_TOLERANCE)
950                    }),
951            })
952}
953
954fn keyed_values_match(track: &Track, first: usize, other: usize) -> bool {
955    match &track.values {
956        TrackValues::Vec3s(_) => {
957            vec3_key_delta(track, first, other) <= DUPLICATE_ENDPOINT_VEC3_TOLERANCE
958        }
959        TrackValues::Quats(_) => quaternion_key_delta(track, first, other)
960            .is_some_and(|delta| delta <= DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD),
961    }
962}
963
964fn vec3_key_delta(track: &Track, first: usize, other: usize) -> f32 {
965    let TrackValues::Vec3s(values) = &track.values else {
966        unreachable!("validated vector track")
967    };
968    (values[track.value_index(first)] - values[track.value_index(other)])
969        .abs()
970        .max_element()
971}
972
973fn quaternion_key_delta(track: &Track, first: usize, other: usize) -> Option<f32> {
974    let TrackValues::Quats(values) = &track.values else {
975        unreachable!("validated quaternion track")
976    };
977    let first = values[track.value_index(first)];
978    let other = values[track.value_index(other)];
979    let first_length_squared = first.length_squared();
980    let other_length_squared = other.length_squared();
981    if first_length_squared == 0.0 || other_length_squared == 0.0 {
982        return None;
983    }
984    let delta = first.normalize().conjugate() * other.normalize();
985    let [x, y, z, w] = delta.to_array();
986    let sin_half_angle = glam::Vec3::new(x, y, z).length();
987    Some(2.0 * sin_half_angle.atan2(w.abs()))
988}
989
990/// Keep only the keys inside `[start, end]` seconds (with a half-frame
991/// epsilon at `fps` absorbing float drift from earlier retimings) and
992/// retime them so the window starts at 0. Cubic tangent triplets move
993/// with their keys. The clip duration becomes `end - start`.
994///
995/// Boundary keys are snapped to the window, not carried past it: keys
996/// within the epsilon of `start` clamp to 0 and keys within it of `end`
997/// clamp to the new duration. When several keys land on a boundary, the
998/// one closest to the original boundary is kept and the rest dropped —
999/// so the output has at most one key at 0 and one at the end, stays
1000/// time-monotonic, and round-trips its declared duration.
1001///
1002/// # Panics
1003///
1004/// Panics if a hand-built track violates the loader invariant that
1005/// `values` contains one value per key for linear/step tracks, or one
1006/// tangent-value-tangent triplet per key for cubic-spline tracks.
1007pub fn slice(clip: &mut Clip, start_s: f64, end_s: f64, fps: f64) {
1008    let eps = (0.5 / fps) as f32;
1009    let (start, end) = (start_s as f32, end_s as f32);
1010    let duration = (end - start).max(0.0);
1011    for track in &mut clip.tracks {
1012        // (key index, retimed+clamped time), in original key order.
1013        let mut kept: Vec<(usize, f32)> = (0..track.key_count())
1014            .filter(|&k| track.times[k] >= start - eps && track.times[k] <= end + eps)
1015            .map(|k| (k, (track.times[k] - start).clamp(0.0, duration)))
1016            .collect();
1017
1018        // Drop boundary duplicates: at t=0 keep the last (closest to
1019        // `start`); at t=duration keep the first (closest to `end`).
1020        // Interior times are already distinct and monotonic.
1021        kept.retain({
1022            let times: Vec<f32> = kept.iter().map(|&(_, t)| t).collect();
1023            let mut i = 0;
1024            move |_| {
1025                let t = times[i];
1026                let keep = if t <= 0.0 {
1027                    times.get(i + 1).is_none_or(|&next| next > 0.0)
1028                } else if t >= duration {
1029                    i == 0 || times[i - 1] < duration
1030                } else {
1031                    true
1032                };
1033                i += 1;
1034                keep
1035            }
1036        });
1037
1038        track.times = kept.iter().map(|&(_, t)| t).collect();
1039        let per_key = match track.interpolation {
1040            Interpolation::CubicSpline => 3,
1041            _ => 1,
1042        };
1043        match &mut track.values {
1044            TrackValues::Vec3s(v) => {
1045                let old = std::mem::take(v);
1046                *v = kept
1047                    .iter()
1048                    .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
1049                    .collect();
1050            }
1051            TrackValues::Quats(v) => {
1052                let old = std::mem::take(v);
1053                *v = kept
1054                    .iter()
1055                    .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
1056                    .collect();
1057            }
1058        }
1059    }
1060    clip.duration_s = (end_s - start_s).max(0.0);
1061    clip.tracks.retain(|t| t.key_count() > 0);
1062}
1063
1064/// Append one key per track duplicating its final value `hold_s`
1065/// seconds after its last key (a linear hold — charge/block poses).
1066/// The clip duration extends to the longest held end.
1067///
1068/// # Panics
1069///
1070/// Panics if a hand-built track violates the loader invariant that each
1071/// key has a corresponding stored value (or cubic-spline triplet).
1072pub fn hold_extend(clip: &mut Clip, hold_s: f64) {
1073    for track in &mut clip.tracks {
1074        let Some(&last) = track.times.last() else {
1075            continue;
1076        };
1077        let key = track.key_count() - 1;
1078        track.times.push(last + hold_s as f32);
1079        let value_index = track.value_index(key);
1080        match &mut track.values {
1081            TrackValues::Vec3s(v) => {
1082                let value = v[value_index];
1083                match track.interpolation {
1084                    Interpolation::CubicSpline => {
1085                        // Zero tangents: a flat Hermite hold. Also zero
1086                        // the previous key's out-tangent so the hold
1087                        // segment stays flat.
1088                        v[key * 3 + 2] = glam::Vec3::ZERO;
1089                        v.extend_from_slice(&[glam::Vec3::ZERO, value, glam::Vec3::ZERO]);
1090                    }
1091                    _ => v.push(value),
1092                }
1093            }
1094            TrackValues::Quats(v) => {
1095                let value = v[value_index];
1096                match track.interpolation {
1097                    Interpolation::CubicSpline => {
1098                        v[key * 3 + 2] = glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
1099                        v.extend_from_slice(&[
1100                            glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1101                            value,
1102                            glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1103                        ]);
1104                    }
1105                    _ => v.push(value),
1106                }
1107            }
1108        }
1109        clip.duration_s = clip.duration_s.max((last + hold_s as f32) as f64);
1110    }
1111}
1112
1113/// Outcome of [`align_gait_anchor`].
1114#[derive(Debug, Clone)]
1115#[non_exhaustive]
1116pub struct GaitAlignOutcome {
1117    /// The measured stride-anchor phase before rotation.
1118    pub phase_before: f64,
1119    /// The phase after rotation (should sit near 0).
1120    pub phase_after: f64,
1121    /// Loop-seam ratio after rotation (the chosen candidate's wrap).
1122    pub seam_after: Option<f64>,
1123    /// The whole-frame offset (−1/0/+1) that produced the cleanest wrap.
1124    pub frame_offset: i32,
1125}
1126
1127/// Declared movement contract under which gait-anchor rotation may run.
1128///
1129/// The policy is an explicit caller obligation rather than an inference from
1130/// clip names or measured speed. Gait anchoring cyclically reorders every
1131/// animated channel, so it is only safe when the selected root trajectory is
1132/// itself cyclic.
1133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1134#[non_exhaustive]
1135pub enum GaitTrajectoryPolicy {
1136    /// The caller declares that gameplay, not the clip, owns locomotion travel.
1137    /// The transform verifies that declaration before rewriting any channel.
1138    InPlace,
1139}
1140
1141/// Maximum horizontal root-trajectory endpoint displacement admitted by the
1142/// in-place gait-anchor policy.
1143pub const GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M: f64 = 0.01;
1144
1145/// Maximum accumulated root yaw admitted by the in-place gait-anchor policy.
1146pub const GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG: f64 = 1.0;
1147
1148/// Maximum samples admitted by each in-place gait work bound.
1149///
1150/// `declared frames × skeleton bones`, `declared frames × tracks`, and
1151/// `maximum authored keys × skeleton bones` are checked independently.
1152pub const GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES: usize = 1_000_000;
1153
1154/// Binary32 comparison room for authored f32 endpoint evidence after stable
1155/// binary64 measurement.
1156///
1157/// First/final unwrapped-heading subtraction makes this independent of segment
1158/// count: per-segment trigonometric error is not summed. This gait-local room
1159/// only covers binary32 authored translation or quaternion quantization at the
1160/// endpoint.
1161const GAIT_ANCHOR_AUTHORED_F32_ENDPOINT_ULPS: u32 = 4;
1162
1163const GAIT_TRAJECTORY_ALTERNATIVES: &str = "retain source root motion, use runtime phase offsets, or use a separately designed \
1164     trajectory-preserving operation";
1165
1166/// Rotate a cyclic clip in time so its measured stride anchor (the
1167/// trough of the L−R foot-height fundamental) lands at clip time 0.
1168///
1169/// Semantics ported from the reference bake: the cycle period is
1170/// `duration + 1/fps` (an open loop's wrap step is a real frame of the
1171/// stride); the shift is quantized to whole frames and applied as an
1172/// integer-index permutation of each channel's authored output values.
1173/// Constant channels are rotation-invariant
1174/// and left alone; a non-constant CUBICSPLINE channel cannot be
1175/// permuted losslessly, so alignment refuses (naming it) rather than
1176/// rotate the rest of the rig around it. Because a ±1-frame shift stays
1177/// inside phase tolerance but moves *where the wrap lands*, all three
1178/// candidates are tried and the one with the cleanest wrap (lowest seam
1179/// ratio) wins.
1180///
1181/// # Errors
1182///
1183/// Returns an error when the clip has no measurable stride anchor, the
1184/// left-right foot amplitude is too small to define a stable phase, a
1185/// non-constant cubic-spline track would need lossy resampling, or no
1186/// tested rotation candidate remains measurable. Under
1187/// [`GaitTrajectoryPolicy::InPlace`], missing/non-finite selected-root
1188/// evidence, duplicate `(bone, property)` channels, any nonconstant channel
1189/// without a complete declared whole-frame key grid, malformed track
1190/// cardinality or skeleton/role topology, pose/channel/authored-key work above
1191/// [`GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES`], and material horizontal
1192/// translation or yaw accumulation are also errors. Trajectory measurements
1193/// use the already-verified authored f32 key times. At sample zero, yaw selects
1194/// the local `+Z`, `+Y`, or `+X` basis axis with the greatest finite horizontal
1195/// projection (using that order to break ties), then retains that one axis for
1196/// the complete proof. Yaw is the difference between binary64 first/final
1197/// headings plus counted full-turn crossings, so comparison error does not grow
1198/// with the admitted segment count; the inclusive cap admits only four binary32
1199/// successors for endpoint translation/quaternion quantization.
1200/// All errors are returned before `clip` is changed.
1201///
1202pub fn align_gait_anchor(
1203    skeleton: &Skeleton,
1204    clip: &mut Clip,
1205    roles: &ResolvedRoles,
1206    fps: f64,
1207    trajectory_policy: GaitTrajectoryPolicy,
1208) -> Result<GaitAlignOutcome, String> {
1209    let sampling_times = match trajectory_policy {
1210        GaitTrajectoryPolicy::InPlace => {
1211            verify_in_place_gait_trajectory(skeleton, clip, roles, fps)?
1212        }
1213    };
1214
1215    let measure = |c: &Clip| -> Option<(f64, Option<f64>, f64)> {
1216        let grid = sample_clip_at_times(skeleton, c, sampling_times.clone());
1217        let m = foot_cycle_metrics(&grid, roles, crate::metrics::MIN_STRIDE_STEP_M)?;
1218        Some((m.gait_phase?, m.loop_seam_ratio, m.lr_amplitude_m))
1219    };
1220    let Some((phase_before, _, amplitude)) = measure(clip) else {
1221        return Err(
1222            "no usable stride anchor (hips/foot roles unresolved or clip too short)".into(),
1223        );
1224    };
1225    if amplitude < 0.03 {
1226        return Err(format!(
1227            "no usable stride anchor (L−R amplitude {amplitude:.4} m) — a ring clip must \
1228             alternate its feet for anchor alignment to mean anything"
1229        ));
1230    }
1231
1232    // Refuse rather than rotate part of a clip: a channel we cannot
1233    // resample coherently (a non-constant CUBICSPLINE track) would be
1234    // left in place while its siblings shift, desynchronizing the rig.
1235    // Constant tracks are rotation-invariant and safely skipped.
1236    let unrotatable: Vec<String> = clip
1237        .tracks
1238        .iter()
1239        .filter(|t| {
1240            t.interpolation == Interpolation::CubicSpline && !is_rotation_invariant_track(t)
1241        })
1242        .map(|t| format!("{} bone {}", t.property.as_str(), t.bone))
1243        .collect();
1244    if !unrotatable.is_empty() {
1245        return Err(format!(
1246            "cannot gait-anchor: these animated tracks need lossless resampling that is \
1247             not yet supported ({}); retime them to LINEAR first",
1248            unrotatable.join(", ")
1249        ));
1250    }
1251
1252    let original = clip.clone();
1253    let mut best: Option<(f64, GaitAlignOutcome, Clip)> = None;
1254    for frame_offset in [0i32, -1, 1] {
1255        let mut candidate = original.clone();
1256        rotate_values(
1257            &mut candidate,
1258            phase_before,
1259            sampling_times.len(),
1260            frame_offset,
1261        );
1262        let Some((phase_after, seam_after, _)) = measure(&candidate) else {
1263            continue;
1264        };
1265        // Rank by wrap cleanliness; a missing seam (no stride at the
1266        // wrap) should not happen on a ring clip — rank it last.
1267        let rank = seam_after.unwrap_or(f64::MAX);
1268        if best.as_ref().is_none_or(|(r, _, _)| rank < *r) {
1269            best = Some((
1270                rank,
1271                GaitAlignOutcome {
1272                    phase_before,
1273                    phase_after,
1274                    seam_after,
1275                    frame_offset,
1276                },
1277                candidate,
1278            ));
1279        }
1280    }
1281    let Some((_, outcome, rotated)) = best else {
1282        return Err("no rotation candidate was measurable".into());
1283    };
1284    *clip = rotated;
1285    Ok(outcome)
1286}
1287
1288/// Verify that cyclic time rotation cannot move an authored world trajectory
1289/// wrap into the middle of the clip.
1290///
1291/// The fixed caps apply directly to endpoint displacement and accumulated yaw.
1292/// No sampled step is subtracted as an allowance: an interior outlier must
1293/// never authorize unrelated endpoint drift.
1294fn verify_in_place_gait_trajectory(
1295    skeleton: &Skeleton,
1296    clip: &Clip,
1297    roles: &ResolvedRoles,
1298    fps: f64,
1299) -> Result<Vec<f32>, String> {
1300    validate_gait_sampling_domain(skeleton, clip, roles)?;
1301    let (role, bone) = roles
1302        .get(Role::Root)
1303        .map(|bone| ("Root", bone))
1304        .or_else(|| roles.get(Role::Hips).map(|bone| ("Hips fallback", bone)))
1305        .ok_or_else(|| {
1306            format!(
1307                "cannot gait-anchor clip {:?} under the in-place policy: selected Root/\
1308                 Hips trajectory evidence is missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1309                clip.name
1310            )
1311        })?;
1312    let Some(bone_name) = skeleton.bones.get(bone).map(|entry| entry.name.as_str()) else {
1313        return Err(format!(
1314            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1315             index {bone} is outside the skeleton, so trajectory evidence is missing; \
1316             {GAIT_TRAJECTORY_ALTERNATIVES}",
1317            clip.name
1318        ));
1319    };
1320
1321    // Sampling alone is insufficient evidence for irregular or STEP tracks:
1322    // a non-finite authored interval can fall entirely between uniform grid
1323    // samples. Inspect every authored value and time on the selected bone and
1324    // its ancestors, because all of those channels contribute to the selected
1325    // model-space trajectory.
1326    let mut trajectory_bones = vec![false; skeleton.bones.len()];
1327    let mut cursor = Some(bone);
1328    let mut ancestor_count = 0usize;
1329    while let Some(index) = cursor {
1330        let Some(entry) = skeleton.bones.get(index) else {
1331            return Err(format!(
1332                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1333                 {:?} (index {bone}) has an out-of-range ancestor index {index}, so trajectory \
1334                 evidence is missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1335                clip.name, bone_name
1336            ));
1337        };
1338        if trajectory_bones[index] || ancestor_count >= skeleton.bones.len() {
1339            return Err(format!(
1340                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1341                 {:?} (index {bone}) has a cyclic ancestor chain, so trajectory evidence is \
1342                 missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1343                clip.name, bone_name
1344            ));
1345        }
1346        trajectory_bones[index] = true;
1347        ancestor_count += 1;
1348        cursor = entry.parent;
1349    }
1350    let sampling_times =
1351        verify_trajectory_frame_grid(clip, role, bone, bone_name, skeleton.bones.len(), fps)?;
1352    let grid = sample_clip_at_times(skeleton, clip, sampling_times.clone());
1353    if grid.frame_count() < 3 {
1354        return Err(format!(
1355            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1356             {:?} (index {bone}) has fewer than three trajectory samples; \
1357             {GAIT_TRAJECTORY_ALTERNATIVES}",
1358            clip.name, bone_name
1359        ));
1360    }
1361
1362    let mut horizontal = Vec::with_capacity(grid.frame_count());
1363    let mut first_heading_deg: Option<f64> = None;
1364    let mut previous_heading_deg: Option<f64> = None;
1365    let mut winding_turns = 0i64;
1366    let mut heading_axis: Option<GaitHeadingAxis> = None;
1367    for frame in 0..grid.frame_count() {
1368        let position = grid.model_position(frame, bone);
1369        let rotation = grid.model_rotation(frame, bone);
1370        if !position.is_finite()
1371            || !rotation.is_finite()
1372            || !rotation.length_squared().is_finite()
1373            || rotation.length_squared() == 0.0
1374        {
1375            return Err(format!(
1376                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1377                 {:?} (index {bone}) has non-finite trajectory evidence at sample {frame}; \
1378                 {GAIT_TRAJECTORY_ALTERNATIVES}",
1379                clip.name, bone_name
1380            ));
1381        }
1382        horizontal.push(Vec3::new(position.x, 0.0, position.z));
1383
1384        // Derive heading in f64 from the authored/model-space f32 quaternion.
1385        // A fixed local basis axis measures the same model-space yaw regardless
1386        // of whether the source convention calls +Z, +Y, or +X "forward". The
1387        // greatest horizontal projection at sample zero is the best-conditioned
1388        // available witness; the fixed priority makes exact ties deterministic.
1389        // Retaining that axis for every later sample prevents a per-sample
1390        // fallback from switching witnesses and hiding accumulated yaw.
1391        // The preceding finite/nonzero guard makes normalization total. Use
1392        // glam's f64 quaternion/vector path so every candidate axis shares one
1393        // well-tested rotation implementation rather than three hand-derived
1394        // matrix-column formulas.
1395        let normalized = rotation.as_dquat().normalize();
1396        let axis = *heading_axis.get_or_insert_with(|| select_gait_heading_axis(normalized));
1397        let (heading_x, heading_z) = gait_heading_horizontal(normalized, axis);
1398        let horizontal_length = heading_x.hypot(heading_z);
1399        if !horizontal_length.is_finite() || horizontal_length <= f64::from(f32::EPSILON) {
1400            return Err(format!(
1401                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1402                 {:?} (index {bone}) has no finite horizontal projection for its selected \
1403                 local {} heading basis at sample {frame}; {GAIT_TRAJECTORY_ALTERNATIVES}",
1404                clip.name,
1405                bone_name,
1406                axis.label()
1407            ));
1408        }
1409        let heading_deg = heading_x.atan2(heading_z).to_degrees();
1410        if let Some(previous) = previous_heading_deg {
1411            let raw_delta = heading_deg - previous;
1412            if raw_delta > 180.0 {
1413                winding_turns -= 1;
1414            } else if raw_delta < -180.0 {
1415                winding_turns += 1;
1416            }
1417        } else {
1418            first_heading_deg = Some(heading_deg);
1419        }
1420        previous_heading_deg = Some(heading_deg);
1421    }
1422
1423    let last = horizontal.len() - 1;
1424    let horizontal_endpoint_m = f64::from((horizontal[last] - horizontal[0]).length());
1425    let horizontal_accumulation_m = horizontal_endpoint_m;
1426    let accumulated_yaw_deg = (previous_heading_deg.expect("non-empty pose grid")
1427        - first_heading_deg.expect("non-empty pose grid")
1428        + winding_turns as f64 * 360.0)
1429        .abs();
1430    let yaw_accumulation_deg = accumulated_yaw_deg;
1431
1432    if !horizontal_accumulation_m.is_finite()
1433        || !yaw_accumulation_deg.is_finite()
1434        || gait_derived_f32_exceeds_cap(
1435            horizontal_accumulation_m,
1436            GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M,
1437        )
1438        || gait_derived_f32_exceeds_cap(yaw_accumulation_deg, GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG)
1439    {
1440        return Err(format!(
1441            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1442             {:?} (index {bone}) accumulates horizontal translation \
1443             {horizontal_accumulation_m:.4} m (endpoint {horizontal_endpoint_m:.4} m, cap \
1444             {GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M:.4} m) and yaw \
1445             {yaw_accumulation_deg:.3} deg (sampled total {accumulated_yaw_deg:.3} deg, cap \
1446             {GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG:.3} deg); \
1447             {GAIT_TRAJECTORY_ALTERNATIVES}",
1448            clip.name, bone_name
1449        ));
1450    }
1451    Ok(sampling_times)
1452}
1453
1454/// One local orientation witness retained for a complete gait trajectory
1455/// proof. Order is policy: conventional `+Z` wins an exact tie, followed by
1456/// the common Z-up-source `+Y` convention and finally `+X`.
1457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1458enum GaitHeadingAxis {
1459    Z,
1460    Y,
1461    X,
1462}
1463
1464impl GaitHeadingAxis {
1465    fn label(self) -> &'static str {
1466        match self {
1467            Self::Z => "+Z",
1468            Self::Y => "+Y",
1469            Self::X => "+X",
1470        }
1471    }
1472}
1473
1474/// Model-space horizontal `(x, z)` projection of one local unit basis axis.
1475fn gait_heading_horizontal(rotation: DQuat, axis: GaitHeadingAxis) -> (f64, f64) {
1476    let local_axis = match axis {
1477        GaitHeadingAxis::Z => DVec3::Z,
1478        GaitHeadingAxis::Y => DVec3::Y,
1479        GaitHeadingAxis::X => DVec3::X,
1480    };
1481    let heading = rotation.mul_vec3(local_axis);
1482    (heading.x, heading.z)
1483}
1484
1485/// Select the best-conditioned local heading witness at sample zero. Exact
1486/// ties retain the declared `+Z`, `+Y`, `+X` priority because replacement is
1487/// strict rather than `>=`.
1488fn select_gait_heading_axis(rotation: DQuat) -> GaitHeadingAxis {
1489    let mut selected = GaitHeadingAxis::Z;
1490    let (x, z) = gait_heading_horizontal(rotation, selected);
1491    let mut best_length = x.hypot(z);
1492    for candidate in [GaitHeadingAxis::Y, GaitHeadingAxis::X] {
1493        let (x, z) = gait_heading_horizontal(rotation, candidate);
1494        let length = x.hypot(z);
1495        if length > best_length {
1496            selected = candidate;
1497            best_length = length;
1498        }
1499    }
1500    selected
1501}
1502
1503/// Compare a gait-local derived binary32 quantity with an inclusive policy
1504/// cap. The measured path includes FK and quaternion/trigonometric operations
1505/// whose authored endpoint carries binary32 translation/quaternion
1506/// quantization, so admit four binary32 successors of the cap. Stable f64
1507/// endpoint heading subtraction prevents this room from growing with the
1508/// segment count. Values materially above the fixed cap remain refusals.
1509fn gait_derived_f32_exceeds_cap(measured: f64, cap: f64) -> bool {
1510    let cap = cap as f32;
1511    debug_assert!(cap.is_finite() && cap > 0.0);
1512    let tolerated = f32::from_bits(cap.to_bits() + GAIT_ANCHOR_AUTHORED_F32_ENDPOINT_ULPS);
1513    measured > f64::from(tolerated)
1514}
1515
1516/// Validate every hand-built input fact on which whole-skeleton sampling and
1517/// value rotation rely. This runs before any allocation or mutation.
1518fn validate_gait_sampling_domain(
1519    skeleton: &Skeleton,
1520    clip: &Clip,
1521    roles: &ResolvedRoles,
1522) -> Result<(), String> {
1523    for (bone, entry) in skeleton.bones.iter().enumerate() {
1524        if let Some(parent) = entry.parent {
1525            if parent >= skeleton.bones.len() {
1526                return Err(format!(
1527                    "cannot gait-anchor clip {:?}: skeleton bone {:?} (index {bone}) has \
1528                     out-of-range ancestor index {parent} (its parent), so trajectory evidence \
1529                     is missing",
1530                    clip.name, entry.name
1531                ));
1532            }
1533            if parent >= bone {
1534                return Err(format!(
1535                    "cannot gait-anchor clip {:?}: skeleton bone {:?} (index {bone}) has parent \
1536                     index {parent}, creating a cyclic ancestor chain or child-before-parent \
1537                     order; whole-skeleton sampling requires an acyclic parents-before-children \
1538                     order and trajectory evidence is missing",
1539                    clip.name, entry.name
1540                ));
1541            }
1542        }
1543    }
1544    for (role, bone) in roles.iter() {
1545        if bone >= skeleton.bones.len() {
1546            let role = if role == Role::Hips {
1547                "Hips fallback"
1548            } else {
1549                role.as_str()
1550            };
1551            return Err(format!(
1552                "cannot gait-anchor clip {:?}: selected {role} bone index {bone} is outside the \
1553                 skeleton, so trajectory evidence is missing ({} bones)",
1554                clip.name,
1555                skeleton.bones.len()
1556            ));
1557        }
1558    }
1559    let mut seen_channels = BTreeSet::new();
1560    for (track_index, track) in clip.tracks.iter().enumerate() {
1561        if track.bone >= skeleton.bones.len() {
1562            return Err(format!(
1563                "cannot gait-anchor clip {:?}: track {track_index} targets out-of-range bone \
1564                 index {}",
1565                clip.name, track.bone
1566            ));
1567        }
1568        if !seen_channels.insert(track_channel_key(track)) {
1569            return Err(format!(
1570                "cannot gait-anchor clip {:?}: track {track_index} duplicates the {} channel \
1571                 for bone {}",
1572                clip.name,
1573                track.property.as_str(),
1574                track.bone
1575            ));
1576        }
1577        let key_count = track.times.len();
1578        let expected_values = if track.interpolation == Interpolation::CubicSpline {
1579            key_count.checked_mul(3)
1580        } else {
1581            Some(key_count)
1582        }
1583        .ok_or_else(|| {
1584            format!(
1585                "cannot gait-anchor clip {:?}: track {track_index} value cardinality overflows",
1586                clip.name
1587            )
1588        })?;
1589        let (value_count, storage_matches) = match &track.values {
1590            TrackValues::Vec3s(values) => (values.len(), track.property != Property::Rotation),
1591            TrackValues::Quats(values) => (values.len(), track.property == Property::Rotation),
1592        };
1593        if value_count != expected_values || !storage_matches {
1594            return Err(format!(
1595                "cannot gait-anchor clip {:?}: track {track_index} has {key_count} times and \
1596                 {value_count} values for {:?} {:?}; expected exactly {expected_values} values \
1597                 with property-compatible storage",
1598                clip.name, track.property, track.interpolation
1599            ));
1600        }
1601        let finite_values = match &track.values {
1602            TrackValues::Vec3s(values) => values.iter().all(|value| value.is_finite()),
1603            TrackValues::Quats(values) => values.iter().all(|value| value.is_finite()),
1604        };
1605        if track.times.iter().any(|time| !time.is_finite()) || !finite_values {
1606            return Err(format!(
1607                "cannot gait-anchor clip {:?}: non-finite authored trajectory evidence in \
1608                 track {track_index}; {GAIT_TRAJECTORY_ALTERNATIVES}",
1609                clip.name
1610            ));
1611        }
1612    }
1613    Ok(())
1614}
1615
1616/// Require every nonconstant channel to carry the complete
1617/// whole-frame grid that [`rotate_values`] permutes. Sampling a sparse channel
1618/// at a shifted omitted frame would synthesize and store a new value at an
1619/// unchanged key time rather than bijectively reordering authored values.
1620/// Bounding the grid before [`sample_clip`] also keeps the public core boundary
1621/// from allocating attacker-controlled `frames × bones` pose arrays or walking
1622/// attacker-controlled `frames × tracks` channel samples.
1623fn verify_trajectory_frame_grid(
1624    clip: &Clip,
1625    role: &str,
1626    bone: BoneId,
1627    bone_name: &str,
1628    skeleton_bones: usize,
1629    fps: f64,
1630) -> Result<Vec<f32>, String> {
1631    let intervals = clip.duration_s * fps;
1632    let interval_tolerance = f64::from(f32::EPSILON) * intervals.abs().max(1.0) * 4.0;
1633    let rounded_intervals = intervals.round();
1634    if !fps.is_finite()
1635        || fps <= 0.0
1636        || !clip.duration_s.is_finite()
1637        || clip.duration_s <= 0.0
1638        || !intervals.is_finite()
1639        || (intervals - rounded_intervals).abs() > interval_tolerance
1640        || rounded_intervals < 1.0
1641    {
1642        return Err(format!(
1643            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1644             {:?} (index {bone}) has no finite whole-frame trajectory grid at {fps} fps over \
1645             {:.6} s; {GAIT_TRAJECTORY_ALTERNATIVES}",
1646            clip.name, bone_name, clip.duration_s
1647        ));
1648    }
1649    // `usize::MAX as f64` rounds upward on 64-bit targets. Rejecting the
1650    // boundary itself is the conservative checked conversion: every value
1651    // admitted below it converts and still has room for the closing `+ 1`.
1652    if rounded_intervals >= usize::MAX as f64 {
1653        return Err(format!(
1654            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1655             {:?} (index {bone}) has a whole-frame trajectory sample count that cannot be \
1656             represented on this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1657            clip.name, bone_name
1658        ));
1659    }
1660    let expected_keys = (rounded_intervals as usize).checked_add(1).ok_or_else(|| {
1661        format!(
1662            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1663                 {:?} (index {bone}) has a whole-frame trajectory grid whose sample count \
1664                 overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1665            clip.name, bone_name
1666        )
1667    })?;
1668    let pose_samples = expected_keys.checked_mul(skeleton_bones).ok_or_else(|| {
1669        format!(
1670            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1671             {:?} (index {bone}) has a whole-frame trajectory grid whose frame-by-bone work \
1672             overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1673            clip.name, bone_name
1674        )
1675    })?;
1676    if pose_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1677        return Err(format!(
1678            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1679             {:?} (index {bone}) requires {pose_samples} trajectory pose samples \
1680             ({expected_keys} frames x {skeleton_bones} bones), above the \
1681             {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} sample safety budget; \
1682             {GAIT_TRAJECTORY_ALTERNATIVES}",
1683            clip.name, bone_name
1684        ));
1685    }
1686    let channel_samples = expected_keys
1687        .checked_mul(clip.tracks.len())
1688        .ok_or_else(|| {
1689            format!(
1690                "cannot gait-anchor clip {:?} under the in-place policy: declared channel \
1691                 sampling work overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1692                clip.name
1693            )
1694        })?;
1695    if channel_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1696        return Err(format!(
1697            "cannot gait-anchor clip {:?} under the in-place policy: declared tracks require \
1698             {channel_samples} channel samples ({expected_keys} frames x {} tracks), above \
1699             the {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} sample safety budget; \
1700             {GAIT_TRAJECTORY_ALTERNATIVES}",
1701            clip.name,
1702            clip.tracks.len()
1703        ));
1704    }
1705    let authored_frames = default_frame_count(clip);
1706    let authored_pose_samples = authored_frames.checked_mul(skeleton_bones).ok_or_else(|| {
1707        format!(
1708            "cannot gait-anchor clip {:?} under the in-place policy: authored sampling work \
1709             overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1710            clip.name
1711        )
1712    })?;
1713    if authored_pose_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1714        return Err(format!(
1715            "cannot gait-anchor clip {:?} under the in-place policy: authored tracks require \
1716             {authored_pose_samples} pose samples ({authored_frames} maximum keys x \
1717             {skeleton_bones} bones), above the {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} \
1718             sample safety budget; {GAIT_TRAJECTORY_ALTERNATIVES}",
1719            clip.name
1720        ));
1721    }
1722
1723    let sampling_times: Vec<f32> = (0..expected_keys)
1724        .map(|key| (key as f64 / fps) as f32)
1725        .collect();
1726    for (track_index, track) in clip.tracks.iter().enumerate() {
1727        if is_rotation_invariant_track(track) {
1728            continue;
1729        }
1730        if track.key_count() != expected_keys {
1731            return Err(format!(
1732                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1733                 {:?} (index {bone}) has incomplete whole-frame rotation evidence in track \
1734                 {track_index}: {} keys instead of exactly {expected_keys} at {fps} fps; \
1735                 {GAIT_TRAJECTORY_ALTERNATIVES}",
1736                clip.name,
1737                bone_name,
1738                track.key_count()
1739            ));
1740        }
1741        for (key, &time) in track.times.iter().enumerate() {
1742            let expected = sampling_times[key];
1743            let grid_endpoint = ((expected_keys - 1) as f64 / fps) as f32;
1744            if time != expected
1745                || (key + 1 == expected_keys
1746                    && (time != grid_endpoint || time != clip.duration_s as f32))
1747            {
1748                return Err(format!(
1749                    "cannot gait-anchor clip {:?} under the in-place policy: selected {role} \
1750                     bone {:?} (index {bone}) has duplicate/non-frame-aligned whole-frame \
1751                     trajectory evidence in track {track_index}, key {key}: authored time \
1752                     {time:.9} s, required frame time {expected:.9} s at {fps} fps; \
1753                     {GAIT_TRAJECTORY_ALTERNATIVES}",
1754                    clip.name, bone_name
1755                ));
1756            }
1757        }
1758    }
1759    Ok(sampling_times)
1760}
1761
1762/// Exact representation-level predicate used by gait-anchor rotation. It is
1763/// intentionally stricter than the lint/prune tolerance classifier: changing
1764/// this would change which tracks gait rotation leaves untouched.
1765fn is_rotation_invariant_track(track: &Track) -> bool {
1766    let n = track.key_count();
1767    if n <= 1 {
1768        return true;
1769    }
1770    let cubic = track.interpolation == Interpolation::CubicSpline;
1771    fn constant<T: Copy + PartialEq>(values: &[T], n: usize, cubic: bool, zero: T) -> bool {
1772        let value = |key: usize| if cubic { 3 * key + 1 } else { key };
1773        let Some(&first) = values.get(value(0)) else {
1774            return false;
1775        };
1776        (0..n).all(|key| {
1777            values.get(value(key)) == Some(&first)
1778                && (!cubic
1779                    || (values.get(3 * key) == Some(&zero)
1780                        && values.get(3 * key + 2) == Some(&zero)))
1781        })
1782    }
1783    match &track.values {
1784        TrackValues::Vec3s(values) => constant(values, n, cubic, glam::Vec3::ZERO),
1785        TrackValues::Quats(values) => {
1786            constant(values, n, cubic, glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0))
1787        }
1788    }
1789}
1790
1791/// Replace each animated channel's output values with the authored value a
1792/// whole-frame shift later; times untouched. Constant
1793/// tracks (rotation-invariant) are skipped; non-constant CUBICSPLINE
1794/// tracks are refused upstream in [`align_gait_anchor`].
1795///
1796/// The in-place preflight proves this uniform-framing condition before this
1797/// function runs: every nonconstant track has exactly `frame_count` keys. The
1798/// integer permutation cannot interpolate, and exempt constant tracks cannot
1799/// influence the declared period or shift.
1800fn rotate_values(clip: &mut Clip, phase: f64, frame_count: usize, frame_offset: i32) {
1801    if frame_count == 0 {
1802        return;
1803    }
1804    let shift = ((phase * frame_count as f64).round() as i64 + i64::from(frame_offset))
1805        .rem_euclid(frame_count as i64) as usize;
1806
1807    for track in &mut clip.tracks {
1808        // Constant tracks (any key count) are invariant; cubic tracks
1809        // reaching here are constant, so the zip below only touches
1810        // LINEAR/STEP values. Non-constant short tracks (e.g. a 2-key
1811        // root ramp) are now rotated instead of silently left behind.
1812        if is_rotation_invariant_track(track) {
1813            continue;
1814        }
1815        match &mut track.values {
1816            TrackValues::Vec3s(values) => values.rotate_left(shift),
1817            TrackValues::Quats(values) => values.rotate_left(shift),
1818        }
1819    }
1820}