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::{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. Yaw is the difference
1194/// between binary64 first/final headings plus counted full-turn crossings, so
1195/// comparison error does not grow with the admitted segment count; the
1196/// inclusive cap admits only four binary32 successors for endpoint
1197/// translation/quaternion quantization.
1198/// All errors are returned before `clip` is changed.
1199///
1200pub fn align_gait_anchor(
1201    skeleton: &Skeleton,
1202    clip: &mut Clip,
1203    roles: &ResolvedRoles,
1204    fps: f64,
1205    trajectory_policy: GaitTrajectoryPolicy,
1206) -> Result<GaitAlignOutcome, String> {
1207    let sampling_times = match trajectory_policy {
1208        GaitTrajectoryPolicy::InPlace => {
1209            verify_in_place_gait_trajectory(skeleton, clip, roles, fps)?
1210        }
1211    };
1212
1213    let measure = |c: &Clip| -> Option<(f64, Option<f64>, f64)> {
1214        let grid = sample_clip_at_times(skeleton, c, sampling_times.clone());
1215        let m = foot_cycle_metrics(&grid, roles, crate::metrics::MIN_STRIDE_STEP_M)?;
1216        Some((m.gait_phase?, m.loop_seam_ratio, m.lr_amplitude_m))
1217    };
1218    let Some((phase_before, _, amplitude)) = measure(clip) else {
1219        return Err(
1220            "no usable stride anchor (hips/foot roles unresolved or clip too short)".into(),
1221        );
1222    };
1223    if amplitude < 0.03 {
1224        return Err(format!(
1225            "no usable stride anchor (L−R amplitude {amplitude:.4} m) — a ring clip must \
1226             alternate its feet for anchor alignment to mean anything"
1227        ));
1228    }
1229
1230    // Refuse rather than rotate part of a clip: a channel we cannot
1231    // resample coherently (a non-constant CUBICSPLINE track) would be
1232    // left in place while its siblings shift, desynchronizing the rig.
1233    // Constant tracks are rotation-invariant and safely skipped.
1234    let unrotatable: Vec<String> = clip
1235        .tracks
1236        .iter()
1237        .filter(|t| {
1238            t.interpolation == Interpolation::CubicSpline && !is_rotation_invariant_track(t)
1239        })
1240        .map(|t| format!("{} bone {}", t.property.as_str(), t.bone))
1241        .collect();
1242    if !unrotatable.is_empty() {
1243        return Err(format!(
1244            "cannot gait-anchor: these animated tracks need lossless resampling that is \
1245             not yet supported ({}); retime them to LINEAR first",
1246            unrotatable.join(", ")
1247        ));
1248    }
1249
1250    let original = clip.clone();
1251    let mut best: Option<(f64, GaitAlignOutcome, Clip)> = None;
1252    for frame_offset in [0i32, -1, 1] {
1253        let mut candidate = original.clone();
1254        rotate_values(
1255            &mut candidate,
1256            phase_before,
1257            sampling_times.len(),
1258            frame_offset,
1259        );
1260        let Some((phase_after, seam_after, _)) = measure(&candidate) else {
1261            continue;
1262        };
1263        // Rank by wrap cleanliness; a missing seam (no stride at the
1264        // wrap) should not happen on a ring clip — rank it last.
1265        let rank = seam_after.unwrap_or(f64::MAX);
1266        if best.as_ref().is_none_or(|(r, _, _)| rank < *r) {
1267            best = Some((
1268                rank,
1269                GaitAlignOutcome {
1270                    phase_before,
1271                    phase_after,
1272                    seam_after,
1273                    frame_offset,
1274                },
1275                candidate,
1276            ));
1277        }
1278    }
1279    let Some((_, outcome, rotated)) = best else {
1280        return Err("no rotation candidate was measurable".into());
1281    };
1282    *clip = rotated;
1283    Ok(outcome)
1284}
1285
1286/// Verify that cyclic time rotation cannot move an authored world trajectory
1287/// wrap into the middle of the clip.
1288///
1289/// The fixed caps apply directly to endpoint displacement and accumulated yaw.
1290/// No sampled step is subtracted as an allowance: an interior outlier must
1291/// never authorize unrelated endpoint drift.
1292fn verify_in_place_gait_trajectory(
1293    skeleton: &Skeleton,
1294    clip: &Clip,
1295    roles: &ResolvedRoles,
1296    fps: f64,
1297) -> Result<Vec<f32>, String> {
1298    validate_gait_sampling_domain(skeleton, clip, roles)?;
1299    let (role, bone) = roles
1300        .get(Role::Root)
1301        .map(|bone| ("Root", bone))
1302        .or_else(|| roles.get(Role::Hips).map(|bone| ("Hips fallback", bone)))
1303        .ok_or_else(|| {
1304            format!(
1305                "cannot gait-anchor clip {:?} under the in-place policy: selected Root/\
1306                 Hips trajectory evidence is missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1307                clip.name
1308            )
1309        })?;
1310    let Some(bone_name) = skeleton.bones.get(bone).map(|entry| entry.name.as_str()) else {
1311        return Err(format!(
1312            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1313             index {bone} is outside the skeleton, so trajectory evidence is missing; \
1314             {GAIT_TRAJECTORY_ALTERNATIVES}",
1315            clip.name
1316        ));
1317    };
1318
1319    // Sampling alone is insufficient evidence for irregular or STEP tracks:
1320    // a non-finite authored interval can fall entirely between uniform grid
1321    // samples. Inspect every authored value and time on the selected bone and
1322    // its ancestors, because all of those channels contribute to the selected
1323    // model-space trajectory.
1324    let mut trajectory_bones = vec![false; skeleton.bones.len()];
1325    let mut cursor = Some(bone);
1326    let mut ancestor_count = 0usize;
1327    while let Some(index) = cursor {
1328        let Some(entry) = skeleton.bones.get(index) else {
1329            return Err(format!(
1330                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1331                 {:?} (index {bone}) has an out-of-range ancestor index {index}, so trajectory \
1332                 evidence is missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1333                clip.name, bone_name
1334            ));
1335        };
1336        if trajectory_bones[index] || ancestor_count >= skeleton.bones.len() {
1337            return Err(format!(
1338                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1339                 {:?} (index {bone}) has a cyclic ancestor chain, so trajectory evidence is \
1340                 missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1341                clip.name, bone_name
1342            ));
1343        }
1344        trajectory_bones[index] = true;
1345        ancestor_count += 1;
1346        cursor = entry.parent;
1347    }
1348    let sampling_times =
1349        verify_trajectory_frame_grid(clip, role, bone, bone_name, skeleton.bones.len(), fps)?;
1350    let grid = sample_clip_at_times(skeleton, clip, sampling_times.clone());
1351    if grid.frame_count() < 3 {
1352        return Err(format!(
1353            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1354             {:?} (index {bone}) has fewer than three trajectory samples; \
1355             {GAIT_TRAJECTORY_ALTERNATIVES}",
1356            clip.name, bone_name
1357        ));
1358    }
1359
1360    let mut horizontal = Vec::with_capacity(grid.frame_count());
1361    let mut first_heading_deg: Option<f64> = None;
1362    let mut previous_heading_deg: Option<f64> = None;
1363    let mut winding_turns = 0i64;
1364    for frame in 0..grid.frame_count() {
1365        let position = grid.model_position(frame, bone);
1366        let rotation = grid.model_rotation(frame, bone);
1367        if !position.is_finite()
1368            || !rotation.is_finite()
1369            || !rotation.length_squared().is_finite()
1370            || rotation.length_squared() == 0.0
1371        {
1372            return Err(format!(
1373                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1374                 {:?} (index {bone}) has non-finite trajectory evidence at sample {frame}; \
1375                 {GAIT_TRAJECTORY_ALTERNATIVES}",
1376                clip.name, bone_name
1377            ));
1378        }
1379        horizontal.push(Vec3::new(position.x, 0.0, position.z));
1380
1381        // Derive heading in f64 from the authored/model-space f32 quaternion.
1382        // Summing f32 atan2 steps made exact 1° clips drift with segment count.
1383        // Track only full-turn crossings, then subtract the first heading from
1384        // the final unwrapped heading. Unlike summing per-frame deltas, that
1385        // result has no segment-count-dependent accumulation error.
1386        let [x, y, z, w] = rotation.to_array().map(f64::from);
1387        let norm = (x * x + y * y + z * z + w * w).sqrt();
1388        let (x, y, z, w) = (x / norm, y / norm, z / norm, w / norm);
1389        let forward_x = 2.0 * (x * z + w * y);
1390        let forward_z = 1.0 - 2.0 * (x * x + y * y);
1391        let horizontal_length = forward_x.hypot(forward_z);
1392        if !horizontal_length.is_finite() || horizontal_length <= f64::from(f32::EPSILON) {
1393            return Err(format!(
1394                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1395                 {:?} (index {bone}) has no finite horizontal forward axis at sample {frame}; \
1396                 {GAIT_TRAJECTORY_ALTERNATIVES}",
1397                clip.name, bone_name
1398            ));
1399        }
1400        let heading_deg = forward_x.atan2(forward_z).to_degrees();
1401        if let Some(previous) = previous_heading_deg {
1402            let raw_delta = heading_deg - previous;
1403            if raw_delta > 180.0 {
1404                winding_turns -= 1;
1405            } else if raw_delta < -180.0 {
1406                winding_turns += 1;
1407            }
1408        } else {
1409            first_heading_deg = Some(heading_deg);
1410        }
1411        previous_heading_deg = Some(heading_deg);
1412    }
1413
1414    let last = horizontal.len() - 1;
1415    let horizontal_endpoint_m = f64::from((horizontal[last] - horizontal[0]).length());
1416    let horizontal_accumulation_m = horizontal_endpoint_m;
1417    let accumulated_yaw_deg = (previous_heading_deg.expect("non-empty pose grid")
1418        - first_heading_deg.expect("non-empty pose grid")
1419        + winding_turns as f64 * 360.0)
1420        .abs();
1421    let yaw_accumulation_deg = accumulated_yaw_deg;
1422
1423    if !horizontal_accumulation_m.is_finite()
1424        || !yaw_accumulation_deg.is_finite()
1425        || gait_derived_f32_exceeds_cap(
1426            horizontal_accumulation_m,
1427            GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M,
1428        )
1429        || gait_derived_f32_exceeds_cap(yaw_accumulation_deg, GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG)
1430    {
1431        return Err(format!(
1432            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1433             {:?} (index {bone}) accumulates horizontal translation \
1434             {horizontal_accumulation_m:.4} m (endpoint {horizontal_endpoint_m:.4} m, cap \
1435             {GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M:.4} m) and yaw \
1436             {yaw_accumulation_deg:.3} deg (sampled total {accumulated_yaw_deg:.3} deg, cap \
1437             {GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG:.3} deg); \
1438             {GAIT_TRAJECTORY_ALTERNATIVES}",
1439            clip.name, bone_name
1440        ));
1441    }
1442    Ok(sampling_times)
1443}
1444
1445/// Compare a gait-local derived binary32 quantity with an inclusive policy
1446/// cap. The measured path includes FK and quaternion/trigonometric operations
1447/// whose authored endpoint carries binary32 translation/quaternion
1448/// quantization, so admit four binary32 successors of the cap. Stable f64
1449/// endpoint heading subtraction prevents this room from growing with the
1450/// segment count. Values materially above the fixed cap remain refusals.
1451fn gait_derived_f32_exceeds_cap(measured: f64, cap: f64) -> bool {
1452    let cap = cap as f32;
1453    debug_assert!(cap.is_finite() && cap > 0.0);
1454    let tolerated = f32::from_bits(cap.to_bits() + GAIT_ANCHOR_AUTHORED_F32_ENDPOINT_ULPS);
1455    measured > f64::from(tolerated)
1456}
1457
1458/// Validate every hand-built input fact on which whole-skeleton sampling and
1459/// value rotation rely. This runs before any allocation or mutation.
1460fn validate_gait_sampling_domain(
1461    skeleton: &Skeleton,
1462    clip: &Clip,
1463    roles: &ResolvedRoles,
1464) -> Result<(), String> {
1465    for (bone, entry) in skeleton.bones.iter().enumerate() {
1466        if let Some(parent) = entry.parent {
1467            if parent >= skeleton.bones.len() {
1468                return Err(format!(
1469                    "cannot gait-anchor clip {:?}: skeleton bone {:?} (index {bone}) has \
1470                     out-of-range ancestor index {parent} (its parent), so trajectory evidence \
1471                     is missing",
1472                    clip.name, entry.name
1473                ));
1474            }
1475            if parent >= bone {
1476                return Err(format!(
1477                    "cannot gait-anchor clip {:?}: skeleton bone {:?} (index {bone}) has parent \
1478                     index {parent}, creating a cyclic ancestor chain or child-before-parent \
1479                     order; whole-skeleton sampling requires an acyclic parents-before-children \
1480                     order and trajectory evidence is missing",
1481                    clip.name, entry.name
1482                ));
1483            }
1484        }
1485    }
1486    for (role, bone) in roles.iter() {
1487        if bone >= skeleton.bones.len() {
1488            let role = if role == Role::Hips {
1489                "Hips fallback"
1490            } else {
1491                role.as_str()
1492            };
1493            return Err(format!(
1494                "cannot gait-anchor clip {:?}: selected {role} bone index {bone} is outside the \
1495                 skeleton, so trajectory evidence is missing ({} bones)",
1496                clip.name,
1497                skeleton.bones.len()
1498            ));
1499        }
1500    }
1501    let mut seen_channels = BTreeSet::new();
1502    for (track_index, track) in clip.tracks.iter().enumerate() {
1503        if track.bone >= skeleton.bones.len() {
1504            return Err(format!(
1505                "cannot gait-anchor clip {:?}: track {track_index} targets out-of-range bone \
1506                 index {}",
1507                clip.name, track.bone
1508            ));
1509        }
1510        if !seen_channels.insert(track_channel_key(track)) {
1511            return Err(format!(
1512                "cannot gait-anchor clip {:?}: track {track_index} duplicates the {} channel \
1513                 for bone {}",
1514                clip.name,
1515                track.property.as_str(),
1516                track.bone
1517            ));
1518        }
1519        let key_count = track.times.len();
1520        let expected_values = if track.interpolation == Interpolation::CubicSpline {
1521            key_count.checked_mul(3)
1522        } else {
1523            Some(key_count)
1524        }
1525        .ok_or_else(|| {
1526            format!(
1527                "cannot gait-anchor clip {:?}: track {track_index} value cardinality overflows",
1528                clip.name
1529            )
1530        })?;
1531        let (value_count, storage_matches) = match &track.values {
1532            TrackValues::Vec3s(values) => (values.len(), track.property != Property::Rotation),
1533            TrackValues::Quats(values) => (values.len(), track.property == Property::Rotation),
1534        };
1535        if value_count != expected_values || !storage_matches {
1536            return Err(format!(
1537                "cannot gait-anchor clip {:?}: track {track_index} has {key_count} times and \
1538                 {value_count} values for {:?} {:?}; expected exactly {expected_values} values \
1539                 with property-compatible storage",
1540                clip.name, track.property, track.interpolation
1541            ));
1542        }
1543        let finite_values = match &track.values {
1544            TrackValues::Vec3s(values) => values.iter().all(|value| value.is_finite()),
1545            TrackValues::Quats(values) => values.iter().all(|value| value.is_finite()),
1546        };
1547        if track.times.iter().any(|time| !time.is_finite()) || !finite_values {
1548            return Err(format!(
1549                "cannot gait-anchor clip {:?}: non-finite authored trajectory evidence in \
1550                 track {track_index}; {GAIT_TRAJECTORY_ALTERNATIVES}",
1551                clip.name
1552            ));
1553        }
1554    }
1555    Ok(())
1556}
1557
1558/// Require every nonconstant channel to carry the complete
1559/// whole-frame grid that [`rotate_values`] permutes. Sampling a sparse channel
1560/// at a shifted omitted frame would synthesize and store a new value at an
1561/// unchanged key time rather than bijectively reordering authored values.
1562/// Bounding the grid before [`sample_clip`] also keeps the public core boundary
1563/// from allocating attacker-controlled `frames × bones` pose arrays or walking
1564/// attacker-controlled `frames × tracks` channel samples.
1565fn verify_trajectory_frame_grid(
1566    clip: &Clip,
1567    role: &str,
1568    bone: BoneId,
1569    bone_name: &str,
1570    skeleton_bones: usize,
1571    fps: f64,
1572) -> Result<Vec<f32>, String> {
1573    let intervals = clip.duration_s * fps;
1574    let interval_tolerance = f64::from(f32::EPSILON) * intervals.abs().max(1.0) * 4.0;
1575    let rounded_intervals = intervals.round();
1576    if !fps.is_finite()
1577        || fps <= 0.0
1578        || !clip.duration_s.is_finite()
1579        || clip.duration_s <= 0.0
1580        || !intervals.is_finite()
1581        || (intervals - rounded_intervals).abs() > interval_tolerance
1582        || rounded_intervals < 1.0
1583    {
1584        return Err(format!(
1585            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1586             {:?} (index {bone}) has no finite whole-frame trajectory grid at {fps} fps over \
1587             {:.6} s; {GAIT_TRAJECTORY_ALTERNATIVES}",
1588            clip.name, bone_name, clip.duration_s
1589        ));
1590    }
1591    // `usize::MAX as f64` rounds upward on 64-bit targets. Rejecting the
1592    // boundary itself is the conservative checked conversion: every value
1593    // admitted below it converts and still has room for the closing `+ 1`.
1594    if rounded_intervals >= usize::MAX as f64 {
1595        return Err(format!(
1596            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1597             {:?} (index {bone}) has a whole-frame trajectory sample count that cannot be \
1598             represented on this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1599            clip.name, bone_name
1600        ));
1601    }
1602    let expected_keys = (rounded_intervals as usize).checked_add(1).ok_or_else(|| {
1603        format!(
1604            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1605                 {:?} (index {bone}) has a whole-frame trajectory grid whose sample count \
1606                 overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1607            clip.name, bone_name
1608        )
1609    })?;
1610    let pose_samples = expected_keys.checked_mul(skeleton_bones).ok_or_else(|| {
1611        format!(
1612            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1613             {:?} (index {bone}) has a whole-frame trajectory grid whose frame-by-bone work \
1614             overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1615            clip.name, bone_name
1616        )
1617    })?;
1618    if pose_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1619        return Err(format!(
1620            "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1621             {:?} (index {bone}) requires {pose_samples} trajectory pose samples \
1622             ({expected_keys} frames x {skeleton_bones} bones), above the \
1623             {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} sample safety budget; \
1624             {GAIT_TRAJECTORY_ALTERNATIVES}",
1625            clip.name, bone_name
1626        ));
1627    }
1628    let channel_samples = expected_keys
1629        .checked_mul(clip.tracks.len())
1630        .ok_or_else(|| {
1631            format!(
1632                "cannot gait-anchor clip {:?} under the in-place policy: declared channel \
1633                 sampling work overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1634                clip.name
1635            )
1636        })?;
1637    if channel_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1638        return Err(format!(
1639            "cannot gait-anchor clip {:?} under the in-place policy: declared tracks require \
1640             {channel_samples} channel samples ({expected_keys} frames x {} tracks), above \
1641             the {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} sample safety budget; \
1642             {GAIT_TRAJECTORY_ALTERNATIVES}",
1643            clip.name,
1644            clip.tracks.len()
1645        ));
1646    }
1647    let authored_frames = default_frame_count(clip);
1648    let authored_pose_samples = authored_frames.checked_mul(skeleton_bones).ok_or_else(|| {
1649        format!(
1650            "cannot gait-anchor clip {:?} under the in-place policy: authored sampling work \
1651             overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1652            clip.name
1653        )
1654    })?;
1655    if authored_pose_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1656        return Err(format!(
1657            "cannot gait-anchor clip {:?} under the in-place policy: authored tracks require \
1658             {authored_pose_samples} pose samples ({authored_frames} maximum keys x \
1659             {skeleton_bones} bones), above the {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} \
1660             sample safety budget; {GAIT_TRAJECTORY_ALTERNATIVES}",
1661            clip.name
1662        ));
1663    }
1664
1665    let sampling_times: Vec<f32> = (0..expected_keys)
1666        .map(|key| (key as f64 / fps) as f32)
1667        .collect();
1668    for (track_index, track) in clip.tracks.iter().enumerate() {
1669        if is_rotation_invariant_track(track) {
1670            continue;
1671        }
1672        if track.key_count() != expected_keys {
1673            return Err(format!(
1674                "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1675                 {:?} (index {bone}) has incomplete whole-frame rotation evidence in track \
1676                 {track_index}: {} keys instead of exactly {expected_keys} at {fps} fps; \
1677                 {GAIT_TRAJECTORY_ALTERNATIVES}",
1678                clip.name,
1679                bone_name,
1680                track.key_count()
1681            ));
1682        }
1683        for (key, &time) in track.times.iter().enumerate() {
1684            let expected = sampling_times[key];
1685            let grid_endpoint = ((expected_keys - 1) as f64 / fps) as f32;
1686            if time != expected
1687                || (key + 1 == expected_keys
1688                    && (time != grid_endpoint || time != clip.duration_s as f32))
1689            {
1690                return Err(format!(
1691                    "cannot gait-anchor clip {:?} under the in-place policy: selected {role} \
1692                     bone {:?} (index {bone}) has duplicate/non-frame-aligned whole-frame \
1693                     trajectory evidence in track {track_index}, key {key}: authored time \
1694                     {time:.9} s, required frame time {expected:.9} s at {fps} fps; \
1695                     {GAIT_TRAJECTORY_ALTERNATIVES}",
1696                    clip.name, bone_name
1697                ));
1698            }
1699        }
1700    }
1701    Ok(sampling_times)
1702}
1703
1704/// Exact representation-level predicate used by gait-anchor rotation. It is
1705/// intentionally stricter than the lint/prune tolerance classifier: changing
1706/// this would change which tracks gait rotation leaves untouched.
1707fn is_rotation_invariant_track(track: &Track) -> bool {
1708    let n = track.key_count();
1709    if n <= 1 {
1710        return true;
1711    }
1712    let cubic = track.interpolation == Interpolation::CubicSpline;
1713    fn constant<T: Copy + PartialEq>(values: &[T], n: usize, cubic: bool, zero: T) -> bool {
1714        let value = |key: usize| if cubic { 3 * key + 1 } else { key };
1715        let Some(&first) = values.get(value(0)) else {
1716            return false;
1717        };
1718        (0..n).all(|key| {
1719            values.get(value(key)) == Some(&first)
1720                && (!cubic
1721                    || (values.get(3 * key) == Some(&zero)
1722                        && values.get(3 * key + 2) == Some(&zero)))
1723        })
1724    }
1725    match &track.values {
1726        TrackValues::Vec3s(values) => constant(values, n, cubic, glam::Vec3::ZERO),
1727        TrackValues::Quats(values) => {
1728            constant(values, n, cubic, glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0))
1729        }
1730    }
1731}
1732
1733/// Replace each animated channel's output values with the authored value a
1734/// whole-frame shift later; times untouched. Constant
1735/// tracks (rotation-invariant) are skipped; non-constant CUBICSPLINE
1736/// tracks are refused upstream in [`align_gait_anchor`].
1737///
1738/// The in-place preflight proves this uniform-framing condition before this
1739/// function runs: every nonconstant track has exactly `frame_count` keys. The
1740/// integer permutation cannot interpolate, and exempt constant tracks cannot
1741/// influence the declared period or shift.
1742fn rotate_values(clip: &mut Clip, phase: f64, frame_count: usize, frame_offset: i32) {
1743    if frame_count == 0 {
1744        return;
1745    }
1746    let shift = ((phase * frame_count as f64).round() as i64 + i64::from(frame_offset))
1747        .rem_euclid(frame_count as i64) as usize;
1748
1749    for track in &mut clip.tracks {
1750        // Constant tracks (any key count) are invariant; cubic tracks
1751        // reaching here are constant, so the zip below only touches
1752        // LINEAR/STEP values. Non-constant short tracks (e.g. a 2-key
1753        // root ramp) are now rotated instead of silently left behind.
1754        if is_rotation_invariant_track(track) {
1755            continue;
1756        }
1757        match &mut track.values {
1758            TrackValues::Vec3s(values) => values.rotate_left(shift),
1759            TrackValues::Quats(values) => values.rotate_left(shift),
1760        }
1761    }
1762}