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