1use 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#[derive(Debug, Clone, PartialEq, Error)]
20#[non_exhaustive]
21pub enum DuplicateLoopEndpointError {
22 #[error("clip has no tracks")]
24 NoTracks,
25 #[error(
27 "track {track} has {value_count} values for {key_count} keys with {interpolation:?} interpolation"
28 )]
29 InvalidValueCount {
30 track: usize,
32 key_count: usize,
34 value_count: usize,
36 interpolation: Interpolation,
38 },
39 #[error("track {track} has invalid value storage")]
41 InvalidValueStorage {
42 track: usize,
44 },
45 #[error("track {track:?} contains a non-finite authored value")]
47 NonFinite {
48 track: Option<usize>,
50 },
51 #[error("track {track} timeline is not strictly increasing")]
53 NonIncreasingTime {
54 track: usize,
56 },
57 #[error("track {track} does not share the exact authored timeline")]
59 TimelineMismatch {
60 track: usize,
62 },
63 #[error("track {track} does not end at the declared duration")]
65 DurationMismatch {
66 track: usize,
68 },
69}
70
71#[derive(Debug, Clone, Copy, PartialEq)]
73#[non_exhaustive]
74pub struct DuplicateLoopEndpointOutcome {
75 pub removed_keys_per_track: usize,
77 pub duration_before_s: f64,
79 pub duration_after_s: f64,
81 pub max_translation_endpoint_delta_m: Option<f32>,
83 pub max_rotation_endpoint_delta_rad: Option<f32>,
85 pub max_scale_endpoint_delta: Option<f32>,
87}
88
89pub const DUPLICATE_ENDPOINT_VEC3_TOLERANCE: f32 = 1.0e-5;
91pub const DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD: f32 = 1.0e-4;
93
94pub const CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE: f32 = crate::checks::constant_track::VEC3_TOLERANCE;
98pub const CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD: f32 =
101 crate::checks::constant_track::QUAT_TOLERANCE_RAD;
102
103#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub struct PruneConstantTracksOutcome {
107 pub removed: Vec<ConstantTrackPruneRecord>,
109 pub retained: Vec<ConstantTrackRetainedRecord>,
111}
112
113#[derive(Debug, Clone, PartialEq)]
115#[non_exhaustive]
116pub struct ConstantTrackPruneRecord {
117 pub original_track_index: usize,
119 pub bone: BoneId,
121 pub property: Property,
123 pub interpolation: Interpolation,
125 pub key_count: usize,
127}
128
129#[derive(Debug, Clone, PartialEq)]
131#[non_exhaustive]
132pub struct ConstantTrackRetainedRecord {
133 pub record: ConstantTrackPruneRecord,
135 pub reason: ConstantTrackRetentionReason,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum ConstantTrackRetentionReason {
143 ProtectedBone,
145 InvalidTarget,
147 SamplingUnavailable,
149 PoseChanged,
151 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
169pub 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
613fn 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
754pub 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 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
847pub 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
990pub 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 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 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
1064pub 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 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#[derive(Debug, Clone)]
1115#[non_exhaustive]
1116pub struct GaitAlignOutcome {
1117 pub phase_before: f64,
1119 pub phase_after: f64,
1121 pub seam_after: Option<f64>,
1123 pub frame_offset: i32,
1125}
1126
1127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1134#[non_exhaustive]
1135pub enum GaitTrajectoryPolicy {
1136 InPlace,
1139}
1140
1141pub const GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M: f64 = 0.01;
1144
1145pub const GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG: f64 = 1.0;
1147
1148pub const GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES: usize = 1_000_000;
1153
1154const 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
1166pub 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 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 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
1286fn 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 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 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
1445fn 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
1458fn 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
1558fn 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 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
1704fn 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
1733fn 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 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}