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::{DQuat, DVec3, 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(
1203 skeleton: &Skeleton,
1204 clip: &mut Clip,
1205 roles: &ResolvedRoles,
1206 fps: f64,
1207 trajectory_policy: GaitTrajectoryPolicy,
1208) -> Result<GaitAlignOutcome, String> {
1209 let sampling_times = match trajectory_policy {
1210 GaitTrajectoryPolicy::InPlace => {
1211 verify_in_place_gait_trajectory(skeleton, clip, roles, fps)?
1212 }
1213 };
1214
1215 let measure = |c: &Clip| -> Option<(f64, Option<f64>, f64)> {
1216 let grid = sample_clip_at_times(skeleton, c, sampling_times.clone());
1217 let m = foot_cycle_metrics(&grid, roles, crate::metrics::MIN_STRIDE_STEP_M)?;
1218 Some((m.gait_phase?, m.loop_seam_ratio, m.lr_amplitude_m))
1219 };
1220 let Some((phase_before, _, amplitude)) = measure(clip) else {
1221 return Err(
1222 "no usable stride anchor (hips/foot roles unresolved or clip too short)".into(),
1223 );
1224 };
1225 if amplitude < 0.03 {
1226 return Err(format!(
1227 "no usable stride anchor (L−R amplitude {amplitude:.4} m) — a ring clip must \
1228 alternate its feet for anchor alignment to mean anything"
1229 ));
1230 }
1231
1232 let unrotatable: Vec<String> = clip
1237 .tracks
1238 .iter()
1239 .filter(|t| {
1240 t.interpolation == Interpolation::CubicSpline && !is_rotation_invariant_track(t)
1241 })
1242 .map(|t| format!("{} bone {}", t.property.as_str(), t.bone))
1243 .collect();
1244 if !unrotatable.is_empty() {
1245 return Err(format!(
1246 "cannot gait-anchor: these animated tracks need lossless resampling that is \
1247 not yet supported ({}); retime them to LINEAR first",
1248 unrotatable.join(", ")
1249 ));
1250 }
1251
1252 let original = clip.clone();
1253 let mut best: Option<(f64, GaitAlignOutcome, Clip)> = None;
1254 for frame_offset in [0i32, -1, 1] {
1255 let mut candidate = original.clone();
1256 rotate_values(
1257 &mut candidate,
1258 phase_before,
1259 sampling_times.len(),
1260 frame_offset,
1261 );
1262 let Some((phase_after, seam_after, _)) = measure(&candidate) else {
1263 continue;
1264 };
1265 let rank = seam_after.unwrap_or(f64::MAX);
1268 if best.as_ref().is_none_or(|(r, _, _)| rank < *r) {
1269 best = Some((
1270 rank,
1271 GaitAlignOutcome {
1272 phase_before,
1273 phase_after,
1274 seam_after,
1275 frame_offset,
1276 },
1277 candidate,
1278 ));
1279 }
1280 }
1281 let Some((_, outcome, rotated)) = best else {
1282 return Err("no rotation candidate was measurable".into());
1283 };
1284 *clip = rotated;
1285 Ok(outcome)
1286}
1287
1288fn verify_in_place_gait_trajectory(
1295 skeleton: &Skeleton,
1296 clip: &Clip,
1297 roles: &ResolvedRoles,
1298 fps: f64,
1299) -> Result<Vec<f32>, String> {
1300 validate_gait_sampling_domain(skeleton, clip, roles)?;
1301 let (role, bone) = roles
1302 .get(Role::Root)
1303 .map(|bone| ("Root", bone))
1304 .or_else(|| roles.get(Role::Hips).map(|bone| ("Hips fallback", bone)))
1305 .ok_or_else(|| {
1306 format!(
1307 "cannot gait-anchor clip {:?} under the in-place policy: selected Root/\
1308 Hips trajectory evidence is missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1309 clip.name
1310 )
1311 })?;
1312 let Some(bone_name) = skeleton.bones.get(bone).map(|entry| entry.name.as_str()) else {
1313 return Err(format!(
1314 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1315 index {bone} is outside the skeleton, so trajectory evidence is missing; \
1316 {GAIT_TRAJECTORY_ALTERNATIVES}",
1317 clip.name
1318 ));
1319 };
1320
1321 let mut trajectory_bones = vec![false; skeleton.bones.len()];
1327 let mut cursor = Some(bone);
1328 let mut ancestor_count = 0usize;
1329 while let Some(index) = cursor {
1330 let Some(entry) = skeleton.bones.get(index) else {
1331 return Err(format!(
1332 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1333 {:?} (index {bone}) has an out-of-range ancestor index {index}, so trajectory \
1334 evidence is missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1335 clip.name, bone_name
1336 ));
1337 };
1338 if trajectory_bones[index] || ancestor_count >= skeleton.bones.len() {
1339 return Err(format!(
1340 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1341 {:?} (index {bone}) has a cyclic ancestor chain, so trajectory evidence is \
1342 missing; {GAIT_TRAJECTORY_ALTERNATIVES}",
1343 clip.name, bone_name
1344 ));
1345 }
1346 trajectory_bones[index] = true;
1347 ancestor_count += 1;
1348 cursor = entry.parent;
1349 }
1350 let sampling_times =
1351 verify_trajectory_frame_grid(clip, role, bone, bone_name, skeleton.bones.len(), fps)?;
1352 let grid = sample_clip_at_times(skeleton, clip, sampling_times.clone());
1353 if grid.frame_count() < 3 {
1354 return Err(format!(
1355 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1356 {:?} (index {bone}) has fewer than three trajectory samples; \
1357 {GAIT_TRAJECTORY_ALTERNATIVES}",
1358 clip.name, bone_name
1359 ));
1360 }
1361
1362 let mut horizontal = Vec::with_capacity(grid.frame_count());
1363 let mut first_heading_deg: Option<f64> = None;
1364 let mut previous_heading_deg: Option<f64> = None;
1365 let mut winding_turns = 0i64;
1366 let mut heading_axis: Option<GaitHeadingAxis> = None;
1367 for frame in 0..grid.frame_count() {
1368 let position = grid.model_position(frame, bone);
1369 let rotation = grid.model_rotation(frame, bone);
1370 if !position.is_finite()
1371 || !rotation.is_finite()
1372 || !rotation.length_squared().is_finite()
1373 || rotation.length_squared() == 0.0
1374 {
1375 return Err(format!(
1376 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1377 {:?} (index {bone}) has non-finite trajectory evidence at sample {frame}; \
1378 {GAIT_TRAJECTORY_ALTERNATIVES}",
1379 clip.name, bone_name
1380 ));
1381 }
1382 horizontal.push(Vec3::new(position.x, 0.0, position.z));
1383
1384 let normalized = rotation.as_dquat().normalize();
1396 let axis = *heading_axis.get_or_insert_with(|| select_gait_heading_axis(normalized));
1397 let (heading_x, heading_z) = gait_heading_horizontal(normalized, axis);
1398 let horizontal_length = heading_x.hypot(heading_z);
1399 if !horizontal_length.is_finite() || horizontal_length <= f64::from(f32::EPSILON) {
1400 return Err(format!(
1401 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1402 {:?} (index {bone}) has no finite horizontal projection for its selected \
1403 local {} heading basis at sample {frame}; {GAIT_TRAJECTORY_ALTERNATIVES}",
1404 clip.name,
1405 bone_name,
1406 axis.label()
1407 ));
1408 }
1409 let heading_deg = heading_x.atan2(heading_z).to_degrees();
1410 if let Some(previous) = previous_heading_deg {
1411 let raw_delta = heading_deg - previous;
1412 if raw_delta > 180.0 {
1413 winding_turns -= 1;
1414 } else if raw_delta < -180.0 {
1415 winding_turns += 1;
1416 }
1417 } else {
1418 first_heading_deg = Some(heading_deg);
1419 }
1420 previous_heading_deg = Some(heading_deg);
1421 }
1422
1423 let last = horizontal.len() - 1;
1424 let horizontal_endpoint_m = f64::from((horizontal[last] - horizontal[0]).length());
1425 let horizontal_accumulation_m = horizontal_endpoint_m;
1426 let accumulated_yaw_deg = (previous_heading_deg.expect("non-empty pose grid")
1427 - first_heading_deg.expect("non-empty pose grid")
1428 + winding_turns as f64 * 360.0)
1429 .abs();
1430 let yaw_accumulation_deg = accumulated_yaw_deg;
1431
1432 if !horizontal_accumulation_m.is_finite()
1433 || !yaw_accumulation_deg.is_finite()
1434 || gait_derived_f32_exceeds_cap(
1435 horizontal_accumulation_m,
1436 GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M,
1437 )
1438 || gait_derived_f32_exceeds_cap(yaw_accumulation_deg, GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG)
1439 {
1440 return Err(format!(
1441 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1442 {:?} (index {bone}) accumulates horizontal translation \
1443 {horizontal_accumulation_m:.4} m (endpoint {horizontal_endpoint_m:.4} m, cap \
1444 {GAIT_ANCHOR_MAX_HORIZONTAL_ACCUMULATION_M:.4} m) and yaw \
1445 {yaw_accumulation_deg:.3} deg (sampled total {accumulated_yaw_deg:.3} deg, cap \
1446 {GAIT_ANCHOR_MAX_YAW_ACCUMULATION_DEG:.3} deg); \
1447 {GAIT_TRAJECTORY_ALTERNATIVES}",
1448 clip.name, bone_name
1449 ));
1450 }
1451 Ok(sampling_times)
1452}
1453
1454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1458enum GaitHeadingAxis {
1459 Z,
1460 Y,
1461 X,
1462}
1463
1464impl GaitHeadingAxis {
1465 fn label(self) -> &'static str {
1466 match self {
1467 Self::Z => "+Z",
1468 Self::Y => "+Y",
1469 Self::X => "+X",
1470 }
1471 }
1472}
1473
1474fn gait_heading_horizontal(rotation: DQuat, axis: GaitHeadingAxis) -> (f64, f64) {
1476 let local_axis = match axis {
1477 GaitHeadingAxis::Z => DVec3::Z,
1478 GaitHeadingAxis::Y => DVec3::Y,
1479 GaitHeadingAxis::X => DVec3::X,
1480 };
1481 let heading = rotation.mul_vec3(local_axis);
1482 (heading.x, heading.z)
1483}
1484
1485fn select_gait_heading_axis(rotation: DQuat) -> GaitHeadingAxis {
1489 let mut selected = GaitHeadingAxis::Z;
1490 let (x, z) = gait_heading_horizontal(rotation, selected);
1491 let mut best_length = x.hypot(z);
1492 for candidate in [GaitHeadingAxis::Y, GaitHeadingAxis::X] {
1493 let (x, z) = gait_heading_horizontal(rotation, candidate);
1494 let length = x.hypot(z);
1495 if length > best_length {
1496 selected = candidate;
1497 best_length = length;
1498 }
1499 }
1500 selected
1501}
1502
1503fn gait_derived_f32_exceeds_cap(measured: f64, cap: f64) -> bool {
1510 let cap = cap as f32;
1511 debug_assert!(cap.is_finite() && cap > 0.0);
1512 let tolerated = f32::from_bits(cap.to_bits() + GAIT_ANCHOR_AUTHORED_F32_ENDPOINT_ULPS);
1513 measured > f64::from(tolerated)
1514}
1515
1516fn validate_gait_sampling_domain(
1519 skeleton: &Skeleton,
1520 clip: &Clip,
1521 roles: &ResolvedRoles,
1522) -> Result<(), String> {
1523 for (bone, entry) in skeleton.bones.iter().enumerate() {
1524 if let Some(parent) = entry.parent {
1525 if parent >= skeleton.bones.len() {
1526 return Err(format!(
1527 "cannot gait-anchor clip {:?}: skeleton bone {:?} (index {bone}) has \
1528 out-of-range ancestor index {parent} (its parent), so trajectory evidence \
1529 is missing",
1530 clip.name, entry.name
1531 ));
1532 }
1533 if parent >= bone {
1534 return Err(format!(
1535 "cannot gait-anchor clip {:?}: skeleton bone {:?} (index {bone}) has parent \
1536 index {parent}, creating a cyclic ancestor chain or child-before-parent \
1537 order; whole-skeleton sampling requires an acyclic parents-before-children \
1538 order and trajectory evidence is missing",
1539 clip.name, entry.name
1540 ));
1541 }
1542 }
1543 }
1544 for (role, bone) in roles.iter() {
1545 if bone >= skeleton.bones.len() {
1546 let role = if role == Role::Hips {
1547 "Hips fallback"
1548 } else {
1549 role.as_str()
1550 };
1551 return Err(format!(
1552 "cannot gait-anchor clip {:?}: selected {role} bone index {bone} is outside the \
1553 skeleton, so trajectory evidence is missing ({} bones)",
1554 clip.name,
1555 skeleton.bones.len()
1556 ));
1557 }
1558 }
1559 let mut seen_channels = BTreeSet::new();
1560 for (track_index, track) in clip.tracks.iter().enumerate() {
1561 if track.bone >= skeleton.bones.len() {
1562 return Err(format!(
1563 "cannot gait-anchor clip {:?}: track {track_index} targets out-of-range bone \
1564 index {}",
1565 clip.name, track.bone
1566 ));
1567 }
1568 if !seen_channels.insert(track_channel_key(track)) {
1569 return Err(format!(
1570 "cannot gait-anchor clip {:?}: track {track_index} duplicates the {} channel \
1571 for bone {}",
1572 clip.name,
1573 track.property.as_str(),
1574 track.bone
1575 ));
1576 }
1577 let key_count = track.times.len();
1578 let expected_values = if track.interpolation == Interpolation::CubicSpline {
1579 key_count.checked_mul(3)
1580 } else {
1581 Some(key_count)
1582 }
1583 .ok_or_else(|| {
1584 format!(
1585 "cannot gait-anchor clip {:?}: track {track_index} value cardinality overflows",
1586 clip.name
1587 )
1588 })?;
1589 let (value_count, storage_matches) = match &track.values {
1590 TrackValues::Vec3s(values) => (values.len(), track.property != Property::Rotation),
1591 TrackValues::Quats(values) => (values.len(), track.property == Property::Rotation),
1592 };
1593 if value_count != expected_values || !storage_matches {
1594 return Err(format!(
1595 "cannot gait-anchor clip {:?}: track {track_index} has {key_count} times and \
1596 {value_count} values for {:?} {:?}; expected exactly {expected_values} values \
1597 with property-compatible storage",
1598 clip.name, track.property, track.interpolation
1599 ));
1600 }
1601 let finite_values = match &track.values {
1602 TrackValues::Vec3s(values) => values.iter().all(|value| value.is_finite()),
1603 TrackValues::Quats(values) => values.iter().all(|value| value.is_finite()),
1604 };
1605 if track.times.iter().any(|time| !time.is_finite()) || !finite_values {
1606 return Err(format!(
1607 "cannot gait-anchor clip {:?}: non-finite authored trajectory evidence in \
1608 track {track_index}; {GAIT_TRAJECTORY_ALTERNATIVES}",
1609 clip.name
1610 ));
1611 }
1612 }
1613 Ok(())
1614}
1615
1616fn verify_trajectory_frame_grid(
1624 clip: &Clip,
1625 role: &str,
1626 bone: BoneId,
1627 bone_name: &str,
1628 skeleton_bones: usize,
1629 fps: f64,
1630) -> Result<Vec<f32>, String> {
1631 let intervals = clip.duration_s * fps;
1632 let interval_tolerance = f64::from(f32::EPSILON) * intervals.abs().max(1.0) * 4.0;
1633 let rounded_intervals = intervals.round();
1634 if !fps.is_finite()
1635 || fps <= 0.0
1636 || !clip.duration_s.is_finite()
1637 || clip.duration_s <= 0.0
1638 || !intervals.is_finite()
1639 || (intervals - rounded_intervals).abs() > interval_tolerance
1640 || rounded_intervals < 1.0
1641 {
1642 return Err(format!(
1643 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1644 {:?} (index {bone}) has no finite whole-frame trajectory grid at {fps} fps over \
1645 {:.6} s; {GAIT_TRAJECTORY_ALTERNATIVES}",
1646 clip.name, bone_name, clip.duration_s
1647 ));
1648 }
1649 if rounded_intervals >= usize::MAX as f64 {
1653 return Err(format!(
1654 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1655 {:?} (index {bone}) has a whole-frame trajectory sample count that cannot be \
1656 represented on this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1657 clip.name, bone_name
1658 ));
1659 }
1660 let expected_keys = (rounded_intervals as usize).checked_add(1).ok_or_else(|| {
1661 format!(
1662 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1663 {:?} (index {bone}) has a whole-frame trajectory grid whose sample count \
1664 overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1665 clip.name, bone_name
1666 )
1667 })?;
1668 let pose_samples = expected_keys.checked_mul(skeleton_bones).ok_or_else(|| {
1669 format!(
1670 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1671 {:?} (index {bone}) has a whole-frame trajectory grid whose frame-by-bone work \
1672 overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1673 clip.name, bone_name
1674 )
1675 })?;
1676 if pose_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1677 return Err(format!(
1678 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1679 {:?} (index {bone}) requires {pose_samples} trajectory pose samples \
1680 ({expected_keys} frames x {skeleton_bones} bones), above the \
1681 {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} sample safety budget; \
1682 {GAIT_TRAJECTORY_ALTERNATIVES}",
1683 clip.name, bone_name
1684 ));
1685 }
1686 let channel_samples = expected_keys
1687 .checked_mul(clip.tracks.len())
1688 .ok_or_else(|| {
1689 format!(
1690 "cannot gait-anchor clip {:?} under the in-place policy: declared channel \
1691 sampling work overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1692 clip.name
1693 )
1694 })?;
1695 if channel_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1696 return Err(format!(
1697 "cannot gait-anchor clip {:?} under the in-place policy: declared tracks require \
1698 {channel_samples} channel samples ({expected_keys} frames x {} tracks), above \
1699 the {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} sample safety budget; \
1700 {GAIT_TRAJECTORY_ALTERNATIVES}",
1701 clip.name,
1702 clip.tracks.len()
1703 ));
1704 }
1705 let authored_frames = default_frame_count(clip);
1706 let authored_pose_samples = authored_frames.checked_mul(skeleton_bones).ok_or_else(|| {
1707 format!(
1708 "cannot gait-anchor clip {:?} under the in-place policy: authored sampling work \
1709 overflows this platform; {GAIT_TRAJECTORY_ALTERNATIVES}",
1710 clip.name
1711 )
1712 })?;
1713 if authored_pose_samples > GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES {
1714 return Err(format!(
1715 "cannot gait-anchor clip {:?} under the in-place policy: authored tracks require \
1716 {authored_pose_samples} pose samples ({authored_frames} maximum keys x \
1717 {skeleton_bones} bones), above the {GAIT_ANCHOR_MAX_TRAJECTORY_POSE_SAMPLES} \
1718 sample safety budget; {GAIT_TRAJECTORY_ALTERNATIVES}",
1719 clip.name
1720 ));
1721 }
1722
1723 let sampling_times: Vec<f32> = (0..expected_keys)
1724 .map(|key| (key as f64 / fps) as f32)
1725 .collect();
1726 for (track_index, track) in clip.tracks.iter().enumerate() {
1727 if is_rotation_invariant_track(track) {
1728 continue;
1729 }
1730 if track.key_count() != expected_keys {
1731 return Err(format!(
1732 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} bone \
1733 {:?} (index {bone}) has incomplete whole-frame rotation evidence in track \
1734 {track_index}: {} keys instead of exactly {expected_keys} at {fps} fps; \
1735 {GAIT_TRAJECTORY_ALTERNATIVES}",
1736 clip.name,
1737 bone_name,
1738 track.key_count()
1739 ));
1740 }
1741 for (key, &time) in track.times.iter().enumerate() {
1742 let expected = sampling_times[key];
1743 let grid_endpoint = ((expected_keys - 1) as f64 / fps) as f32;
1744 if time != expected
1745 || (key + 1 == expected_keys
1746 && (time != grid_endpoint || time != clip.duration_s as f32))
1747 {
1748 return Err(format!(
1749 "cannot gait-anchor clip {:?} under the in-place policy: selected {role} \
1750 bone {:?} (index {bone}) has duplicate/non-frame-aligned whole-frame \
1751 trajectory evidence in track {track_index}, key {key}: authored time \
1752 {time:.9} s, required frame time {expected:.9} s at {fps} fps; \
1753 {GAIT_TRAJECTORY_ALTERNATIVES}",
1754 clip.name, bone_name
1755 ));
1756 }
1757 }
1758 }
1759 Ok(sampling_times)
1760}
1761
1762fn is_rotation_invariant_track(track: &Track) -> bool {
1766 let n = track.key_count();
1767 if n <= 1 {
1768 return true;
1769 }
1770 let cubic = track.interpolation == Interpolation::CubicSpline;
1771 fn constant<T: Copy + PartialEq>(values: &[T], n: usize, cubic: bool, zero: T) -> bool {
1772 let value = |key: usize| if cubic { 3 * key + 1 } else { key };
1773 let Some(&first) = values.get(value(0)) else {
1774 return false;
1775 };
1776 (0..n).all(|key| {
1777 values.get(value(key)) == Some(&first)
1778 && (!cubic
1779 || (values.get(3 * key) == Some(&zero)
1780 && values.get(3 * key + 2) == Some(&zero)))
1781 })
1782 }
1783 match &track.values {
1784 TrackValues::Vec3s(values) => constant(values, n, cubic, glam::Vec3::ZERO),
1785 TrackValues::Quats(values) => {
1786 constant(values, n, cubic, glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0))
1787 }
1788 }
1789}
1790
1791fn rotate_values(clip: &mut Clip, phase: f64, frame_count: usize, frame_offset: i32) {
1801 if frame_count == 0 {
1802 return;
1803 }
1804 let shift = ((phase * frame_count as f64).round() as i64 + i64::from(frame_offset))
1805 .rem_euclid(frame_count as i64) as usize;
1806
1807 for track in &mut clip.tracks {
1808 if is_rotation_invariant_track(track) {
1813 continue;
1814 }
1815 match &mut track.values {
1816 TrackValues::Vec3s(values) => values.rotate_left(shift),
1817 TrackValues::Quats(values) => values.rotate_left(shift),
1818 }
1819 }
1820}