1use 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#[derive(Debug, Clone, PartialEq, Error)]
22#[non_exhaustive]
23pub enum DuplicateLoopEndpointError {
24 #[error("clip has no tracks")]
26 NoTracks,
27 #[error(
29 "track {track} has {value_count} values for {key_count} keys with {interpolation:?} interpolation"
30 )]
31 InvalidValueCount {
32 track: usize,
34 key_count: usize,
36 value_count: usize,
38 interpolation: Interpolation,
40 },
41 #[error("track {track} has invalid value storage")]
43 InvalidValueStorage {
44 track: usize,
46 },
47 #[error("track {track:?} contains a non-finite authored value")]
49 NonFinite {
50 track: Option<usize>,
52 },
53 #[error("track {track} timeline is not strictly increasing")]
55 NonIncreasingTime {
56 track: usize,
58 },
59 #[error("track {track} does not share the exact authored timeline")]
61 TimelineMismatch {
62 track: usize,
64 },
65 #[error("track {track} does not end at the declared duration")]
67 DurationMismatch {
68 track: usize,
70 },
71}
72
73#[derive(Debug, Clone, Copy, PartialEq)]
75#[non_exhaustive]
76pub struct DuplicateLoopEndpointOutcome {
77 pub removed_keys_per_track: usize,
79 pub duration_before_s: f64,
81 pub duration_after_s: f64,
83 pub max_translation_endpoint_delta_m: Option<f32>,
85 pub max_rotation_endpoint_delta_rad: Option<f32>,
87 pub max_scale_endpoint_delta: Option<f32>,
89}
90
91pub const DUPLICATE_ENDPOINT_VEC3_TOLERANCE: f32 = 1.0e-5;
93pub const DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD: f32 = 1.0e-4;
95
96pub const CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE: f32 = crate::checks::constant_track::VEC3_TOLERANCE;
100pub const CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD: f32 =
103 crate::checks::constant_track::QUAT_TOLERANCE_RAD;
104
105#[derive(Debug, Clone, PartialEq)]
107#[non_exhaustive]
108pub struct PruneConstantTracksOutcome {
109 pub removed: Vec<ConstantTrackPruneRecord>,
111 pub retained: Vec<ConstantTrackRetainedRecord>,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117#[non_exhaustive]
118pub struct ConstantTrackPruneRecord {
119 pub original_track_index: usize,
121 pub bone: BoneId,
123 pub property: Property,
125 pub interpolation: Interpolation,
127 pub key_count: usize,
129}
130
131#[derive(Debug, Clone, PartialEq)]
133#[non_exhaustive]
134pub struct ConstantTrackRetainedRecord {
135 pub record: ConstantTrackPruneRecord,
137 pub reason: ConstantTrackRetentionReason,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143#[non_exhaustive]
144pub enum ConstantTrackRetentionReason {
145 ProtectedBone,
147 InvalidTarget,
149 SamplingUnavailable,
151 PoseChanged,
153 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
171pub 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
615fn 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
756pub 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 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
849pub 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
992pub 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 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 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
1066pub 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 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#[derive(Debug, Clone)]
1117#[non_exhaustive]
1118pub struct GaitAlignOutcome {
1119 pub phase_before: f64,
1121 pub phase_after: f64,
1123 pub seam_after: Option<f64>,
1125 pub frame_offset: i32,
1127}
1128
1129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1136#[non_exhaustive]
1137pub enum GaitTrajectoryPolicy {
1138 InPlace,
1141}
1142
1143pub const GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M: f64 = 0.01;
1146
1147pub const GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG: f64 = 1.0;
1149
1150pub const GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES: usize = 1_000_000;
1155
1156const 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
1168pub 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 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 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
1290fn 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 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 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
1456fn 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
1469fn 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
1569fn 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 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
1715fn 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
1744fn 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 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}