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;
10use crate::sample::{PoseGrid, TrackSample, default_frame_count, sample_clip, sample_track};
11use glam::{Quat, Vec3};
12use std::collections::BTreeSet;
13use std::fmt;
14use thiserror::Error;
15
16#[derive(Debug, Clone, PartialEq, Error)]
18#[non_exhaustive]
19pub enum DuplicateLoopEndpointError {
20 #[error("clip has no tracks")]
22 NoTracks,
23 #[error(
25 "track {track} has {value_count} values for {key_count} keys with {interpolation:?} interpolation"
26 )]
27 InvalidValueCount {
28 track: usize,
30 key_count: usize,
32 value_count: usize,
34 interpolation: Interpolation,
36 },
37 #[error("track {track} has invalid value storage")]
39 InvalidValueStorage {
40 track: usize,
42 },
43 #[error("track {track:?} contains a non-finite authored value")]
45 NonFinite {
46 track: Option<usize>,
48 },
49 #[error("track {track} timeline is not strictly increasing")]
51 NonIncreasingTime {
52 track: usize,
54 },
55 #[error("track {track} does not share the exact authored timeline")]
57 TimelineMismatch {
58 track: usize,
60 },
61 #[error("track {track} does not end at the declared duration")]
63 DurationMismatch {
64 track: usize,
66 },
67}
68
69#[derive(Debug, Clone, Copy, PartialEq)]
71#[non_exhaustive]
72pub struct DuplicateLoopEndpointOutcome {
73 pub removed_keys_per_track: usize,
75 pub duration_before_s: f64,
77 pub duration_after_s: f64,
79 pub max_translation_endpoint_delta_m: Option<f32>,
81 pub max_rotation_endpoint_delta_rad: Option<f32>,
83 pub max_scale_endpoint_delta: Option<f32>,
85}
86
87pub const DUPLICATE_ENDPOINT_VEC3_TOLERANCE: f32 = 1.0e-5;
89pub const DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD: f32 = 1.0e-4;
91
92pub const CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE: f32 = crate::checks::constant_track::VEC3_TOLERANCE;
96pub const CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD: f32 =
99 crate::checks::constant_track::QUAT_TOLERANCE_RAD;
100
101#[derive(Debug, Clone, PartialEq)]
103#[non_exhaustive]
104pub struct PruneConstantTracksOutcome {
105 pub removed: Vec<ConstantTrackPruneRecord>,
107 pub retained: Vec<ConstantTrackRetainedRecord>,
109}
110
111#[derive(Debug, Clone, PartialEq)]
113#[non_exhaustive]
114pub struct ConstantTrackPruneRecord {
115 pub original_track_index: usize,
117 pub bone: BoneId,
119 pub property: Property,
121 pub interpolation: Interpolation,
123 pub key_count: usize,
125}
126
127#[derive(Debug, Clone, PartialEq)]
129#[non_exhaustive]
130pub struct ConstantTrackRetainedRecord {
131 pub record: ConstantTrackPruneRecord,
133 pub reason: ConstantTrackRetentionReason,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139#[non_exhaustive]
140pub enum ConstantTrackRetentionReason {
141 ProtectedBone,
143 InvalidTarget,
145 SamplingUnavailable,
147 PoseChanged,
149 LastWritableTrack,
151}
152
153impl fmt::Display for ConstantTrackRetentionReason {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 f.write_str(match self {
156 Self::ProtectedBone => "target bone is protected",
157 Self::InvalidTarget => "track target is not present in the skeleton",
158 Self::SamplingUnavailable => "the original or trial clip cannot be sampled safely",
159 Self::PoseChanged => {
160 "removal changes sampled local TRS or model-space position/rotation"
161 }
162 Self::LastWritableTrack => "removal would leave no writable track",
163 })
164 }
165}
166
167pub fn prune_constant_tracks(
178 skeleton: &Skeleton,
179 clip: &mut Clip,
180 protected_bones: &[BoneId],
181) -> PruneConstantTracksOutcome {
182 prune_constant_tracks_impl(skeleton, clip, protected_bones, || {})
183}
184
185fn prune_constant_tracks_impl(
186 skeleton: &Skeleton,
187 clip: &mut Clip,
188 protected_bones: &[BoneId],
189 mut record_sampled_trial: impl FnMut(),
190) -> PruneConstantTracksOutcome {
191 let candidates: Vec<ConstantTrackPruneRecord> = clip
192 .tracks
193 .iter()
194 .enumerate()
195 .filter(|(_, track)| is_constant_track(track))
196 .map(|(original_track_index, track)| ConstantTrackPruneRecord {
197 original_track_index,
198 bone: track.bone,
199 property: track.property,
200 interpolation: track.interpolation,
201 key_count: track.key_count(),
202 })
203 .collect();
204 if candidates.is_empty() {
205 return PruneConstantTracksOutcome {
206 removed: Vec::new(),
207 retained: Vec::new(),
208 };
209 }
210
211 if !valid_sampling_target(skeleton, clip) {
212 return PruneConstantTracksOutcome {
213 removed: Vec::new(),
214 retained: candidates
215 .into_iter()
216 .map(|record| ConstantTrackRetainedRecord {
217 reason: if record.bone >= skeleton.bones.len() {
218 ConstantTrackRetentionReason::InvalidTarget
219 } else {
220 ConstantTrackRetentionReason::SamplingUnavailable
221 },
222 record,
223 })
224 .collect(),
225 };
226 }
227
228 let frames = default_frame_count(clip);
229 let original = sample_clip(skeleton, clip, frames);
230 if !finite_grid(&original) {
231 return PruneConstantTracksOutcome {
232 removed: Vec::new(),
233 retained: candidates
234 .into_iter()
235 .map(|record| ConstantTrackRetainedRecord {
236 record,
237 reason: ConstantTrackRetentionReason::SamplingUnavailable,
238 })
239 .collect(),
240 };
241 }
242
243 let source = clip.clone();
244 let duplicate_channels = duplicate_track_channels(&source);
245 let protected_bones: BTreeSet<_> = protected_bones.iter().copied().collect();
246 let mut accepted = BTreeSet::new();
247 let mut removed_records = Vec::new();
248 let mut retained = Vec::new();
249 for record in candidates {
250 if protected_bones.contains(&record.bone) {
251 retained.push(ConstantTrackRetainedRecord {
252 record,
253 reason: ConstantTrackRetentionReason::ProtectedBone,
254 });
255 continue;
256 }
257 if source.tracks.len() <= accepted.len() + 1 {
258 retained.push(ConstantTrackRetainedRecord {
259 record,
260 reason: ConstantTrackRetentionReason::LastWritableTrack,
261 });
262 continue;
263 }
264 let exact_rest_channel = source
265 .tracks
266 .get(record.original_track_index)
267 .zip(skeleton.bones.get(record.bone))
268 .is_some_and(|(track, bone)| {
269 !duplicate_channels.contains(&track_channel_key(track))
270 && authored_track_is_exact_rest_equivalent(track, &bone.rest, &original)
271 });
272 if exact_rest_channel {
273 accepted.insert(record.original_track_index);
274 removed_records.push(record);
275 continue;
276 }
277 record_sampled_trial();
278 let mut trial = source.clone();
279 trial.tracks = source
280 .tracks
281 .iter()
282 .enumerate()
283 .filter(|(index, _)| *index != record.original_track_index && !accepted.contains(index))
284 .map(|(_, track)| track.clone())
285 .collect();
286 let trial_grid = sample_clip(skeleton, &trial, frames);
287 if !finite_grid(&trial_grid) {
288 retained.push(ConstantTrackRetainedRecord {
289 record,
290 reason: ConstantTrackRetentionReason::SamplingUnavailable,
291 });
292 } else if !sampled_poses_match(&original, &trial_grid) {
293 retained.push(ConstantTrackRetainedRecord {
294 record,
295 reason: ConstantTrackRetentionReason::PoseChanged,
296 });
297 } else {
298 accepted.insert(record.original_track_index);
299 removed_records.push(record);
300 }
301 }
302 if !accepted.is_empty() {
303 clip.tracks = source
304 .tracks
305 .into_iter()
306 .enumerate()
307 .filter(|(index, _)| !accepted.contains(index))
308 .map(|(_, track)| track)
309 .collect();
310 }
311 PruneConstantTracksOutcome {
312 removed: removed_records,
313 retained,
314 }
315}
316
317#[cfg(test)]
318mod constant_track_fast_path_tests {
319 use super::*;
320 use crate::model::{Bone, Transform};
321
322 #[test]
323 fn thousands_of_unique_exact_rest_channels_require_no_sampled_trials() {
324 const CANDIDATE_COUNT: usize = 2_048;
325 let skeleton = Skeleton {
326 bones: (0..CANDIDATE_COUNT)
327 .map(|bone| Bone {
328 name: format!("bone-{bone}"),
329 parent: None,
330 rest: Transform::IDENTITY,
331 inverse_bind: None,
332 })
333 .collect(),
334 };
335 let mut tracks: Vec<_> = (0..CANDIDATE_COUNT)
336 .map(|bone| Track {
337 bone,
338 property: Property::Translation,
339 interpolation: Interpolation::Linear,
340 times: vec![0.0, 1.0],
341 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::ZERO]),
342 })
343 .collect();
344 tracks.push(Track {
345 bone: 0,
346 property: Property::Rotation,
347 interpolation: Interpolation::Linear,
348 times: vec![0.0, 1.0],
349 values: TrackValues::Quats(vec![Quat::IDENTITY, Quat::from_rotation_z(0.2)]),
350 });
351 let mut clip = Clip {
352 name: "large-exact-rest".into(),
353 duration_s: 1.0,
354 tracks,
355 };
356 let mut sampled_trials = 0;
357
358 let outcome = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || {
359 sampled_trials += 1;
360 });
361
362 assert_eq!(outcome.removed.len(), CANDIDATE_COUNT);
363 assert!(outcome.retained.is_empty());
364 assert_eq!(sampled_trials, 0);
365 assert_eq!(clip.tracks.len(), 1);
366 assert_eq!(clip.tracks[0].property, Property::Rotation);
367 }
368
369 fn sampled_trials_for(tracks: Vec<Track>) -> usize {
370 let skeleton = Skeleton {
371 bones: vec![Bone {
372 name: "root".into(),
373 parent: None,
374 rest: Transform::IDENTITY,
375 inverse_bind: None,
376 }],
377 };
378 let mut clip = Clip {
379 name: "route".into(),
380 duration_s: 1.0,
381 tracks,
382 };
383 let mut sampled_trials = 0;
384 let _ = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || sampled_trials += 1);
385 sampled_trials
386 }
387
388 fn vector_track(property: Property, interpolation: Interpolation, value: Vec3) -> Track {
389 Track {
390 bone: 0,
391 property,
392 interpolation,
393 times: vec![0.0, 1.0],
394 values: TrackValues::Vec3s(vec![value, value]),
395 }
396 }
397
398 fn moving_rotation() -> Track {
399 Track {
400 bone: 0,
401 property: Property::Rotation,
402 interpolation: Interpolation::Linear,
403 times: vec![0.0, 1.0],
404 values: TrackValues::Quats(vec![Quat::IDENTITY, Quat::from_rotation_z(0.2)]),
405 }
406 }
407
408 fn moving_scale() -> Track {
409 Track {
410 bone: 0,
411 property: Property::Scale,
412 interpolation: Interpolation::Linear,
413 times: vec![0.0, 1.0],
414 values: TrackValues::Vec3s(vec![Vec3::ONE, Vec3::splat(2.0)]),
415 }
416 }
417
418 #[test]
419 fn exact_route_and_sampled_fallback_domains_are_independently_pinned() {
420 for (property, interpolation, value) in [
421 (Property::Translation, Interpolation::Linear, Vec3::ZERO),
422 (Property::Translation, Interpolation::Step, Vec3::ZERO),
423 (Property::Scale, Interpolation::Linear, Vec3::ONE),
424 (Property::Scale, Interpolation::Step, Vec3::ONE),
425 ] {
426 assert_eq!(
427 sampled_trials_for(vec![
428 vector_track(property, interpolation, value),
429 moving_rotation(),
430 ]),
431 0,
432 "{property:?}/{interpolation:?} exact-rest channels use the bounded route"
433 );
434 }
435
436 let zero = Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
437 let sampled_cases = [
438 (
439 2,
440 vec![
441 Track {
442 bone: 0,
443 property: Property::Rotation,
444 interpolation: Interpolation::Linear,
445 times: vec![0.0, 1.0],
446 values: TrackValues::Quats(vec![Quat::IDENTITY, -Quat::IDENTITY]),
447 },
448 vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
449 moving_scale(),
450 ],
451 ),
452 (
453 1,
454 vec![
455 Track {
456 bone: 0,
457 property: Property::Translation,
458 interpolation: Interpolation::CubicSpline,
459 times: vec![0.0, 1.0],
460 values: TrackValues::Vec3s(vec![
461 Vec3::ZERO,
462 Vec3::ZERO,
463 Vec3::ZERO,
464 Vec3::ZERO,
465 Vec3::ZERO,
466 Vec3::ZERO,
467 ]),
468 },
469 moving_rotation(),
470 ],
471 ),
472 (
473 2,
474 vec![
475 vector_track(Property::Scale, Interpolation::Linear, Vec3::ONE),
476 vector_track(Property::Scale, Interpolation::Linear, Vec3::ONE),
477 moving_rotation(),
478 ],
479 ),
480 (
481 1,
482 vec![
483 vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
484 moving_rotation(),
485 ],
486 ),
487 (
488 1,
489 vec![
490 vector_track(
491 Property::Translation,
492 Interpolation::Linear,
493 Vec3::splat(CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE * 0.5),
494 ),
495 moving_rotation(),
496 ],
497 ),
498 (
499 2,
500 vec![
501 Track {
502 bone: 0,
503 property: Property::Rotation,
504 interpolation: Interpolation::CubicSpline,
505 times: vec![0.0, 1.0],
506 values: TrackValues::Quats(vec![
507 zero,
508 Quat::IDENTITY,
509 zero,
510 zero,
511 Quat::IDENTITY,
512 zero,
513 ]),
514 },
515 vector_track(Property::Translation, Interpolation::Linear, Vec3::X),
516 moving_scale(),
517 ],
518 ),
519 (
520 1,
521 vec![
522 vector_track(
523 Property::Translation,
524 Interpolation::Linear,
525 Vec3::new(-0.0, 0.0, 0.0),
526 ),
527 moving_rotation(),
528 ],
529 ),
530 ];
531 for (expected_trials, tracks) in sampled_cases {
532 assert_eq!(
533 sampled_trials_for(tracks),
534 expected_trials,
535 "each rotation, cubic, duplicate, non-rest, tolerance, and bit-distinct case retains its own sampled proof"
536 );
537 }
538 }
539
540 #[test]
541 fn linear_endpoints_that_round_on_the_grid_retain_the_sampled_proof() {
542 let rest_value = Vec3::splat(12_000.0);
543 let skeleton = Skeleton {
544 bones: vec![Bone {
545 name: "large".into(),
546 parent: None,
547 rest: Transform {
548 translation: rest_value,
549 ..Transform::IDENTITY
550 },
551 inverse_bind: None,
552 }],
553 };
554 let rotation_times = (0..=200).map(|key| key as f32 / 200.0).collect::<Vec<_>>();
555 let rotation_values = rotation_times
556 .iter()
557 .map(|time| Quat::from_rotation_z(*time * 0.2))
558 .collect::<Vec<_>>();
559 let mut clip = Clip {
560 name: "linear-rounding".into(),
561 duration_s: 1.0,
562 tracks: vec![
563 vector_track(Property::Translation, Interpolation::Linear, rest_value),
564 Track {
565 bone: 0,
566 property: Property::Rotation,
567 interpolation: Interpolation::Linear,
568 times: rotation_times,
569 values: TrackValues::Quats(rotation_values),
570 },
571 ],
572 };
573 let mut sampled_trials = 0;
574
575 let outcome = prune_constant_tracks_impl(&skeleton, &mut clip, &[], || {
576 sampled_trials += 1;
577 });
578
579 assert_eq!(sampled_trials, 1);
580 assert!(outcome.removed.is_empty());
581 assert_eq!(clip.tracks.len(), 2);
582 assert!((0..=200).any(|frame| {
583 matches!(
584 sample_track(&clip.tracks[0], frame as f32 / 200.0),
585 TrackSample::Vec3(value) if !vec3_bits_eq(value, rest_value)
586 )
587 }));
588 }
589}
590
591fn track_channel_key(track: &Track) -> (BoneId, u8) {
592 let property = match track.property {
593 Property::Translation => 0,
594 Property::Rotation => 1,
595 Property::Scale => 2,
596 };
597 (track.bone, property)
598}
599
600fn duplicate_track_channels(clip: &Clip) -> BTreeSet<(BoneId, u8)> {
601 let mut seen = BTreeSet::new();
602 let mut duplicates = BTreeSet::new();
603 for track in &clip.tracks {
604 if !seen.insert(track_channel_key(track)) {
605 duplicates.insert(track_channel_key(track));
606 }
607 }
608 duplicates
609}
610
611fn authored_track_is_exact_rest_equivalent(
617 track: &Track,
618 rest: &crate::model::Transform,
619 original: &PoseGrid,
620) -> bool {
621 let rest_value = match track.property {
622 Property::Translation => rest.translation,
623 Property::Scale => rest.scale,
624 Property::Rotation => return false,
625 };
626 let TrackValues::Vec3s(values) = &track.values else {
627 return false;
628 };
629 if !values.iter().all(|value| vec3_bits_eq(*value, rest_value)) {
630 return false;
631 }
632 match track.interpolation {
633 Interpolation::Step => true,
634 Interpolation::Linear => (0..original.frame_count()).all(|frame| {
635 let local = original.local(frame, track.bone);
636 let value = match track.property {
637 Property::Translation => local.translation,
638 Property::Scale => local.scale,
639 Property::Rotation => unreachable!("rotation was excluded above"),
640 };
641 vec3_bits_eq(value, rest_value)
642 }),
643 _ => false,
644 }
645}
646
647fn vec3_bits_eq(a: Vec3, b: Vec3) -> bool {
648 a.to_array()
649 .into_iter()
650 .zip(b.to_array())
651 .all(|(a, b)| a.to_bits() == b.to_bits())
652}
653
654fn valid_sampling_target(skeleton: &Skeleton, clip: &Clip) -> bool {
655 clip.duration_s.is_finite()
656 && clip.duration_s > 0.0
657 && skeleton.bones.iter().enumerate().all(|(index, bone)| {
658 bone.parent.is_none_or(|parent| parent < index)
659 && bone.rest.translation.is_finite()
660 && bone.rest.scale.is_finite()
661 && bone.rest.rotation.is_finite()
662 && bone.rest.rotation.length_squared() > 0.0
663 })
664 && clip.tracks.iter().all(|track| {
665 let Some(expected) = track.key_count().checked_mul(
666 if track.interpolation == Interpolation::CubicSpline {
667 3
668 } else {
669 1
670 },
671 ) else {
672 return false;
673 };
674 track.bone < skeleton.bones.len()
675 && track.key_count() > 0
676 && track.values.len() == expected
677 && track.times.iter().all(|time| time.is_finite())
678 && track.times.windows(2).all(|pair| pair[0] < pair[1])
679 && matches!(
680 (track.property, &track.values),
681 (Property::Rotation, TrackValues::Quats(_))
682 | (
683 Property::Translation | Property::Scale,
684 TrackValues::Vec3s(_)
685 )
686 )
687 && match &track.values {
688 TrackValues::Vec3s(values) => values.iter().all(|value| value.is_finite()),
689 TrackValues::Quats(values) => {
690 values.iter().enumerate().all(|(index, value)| {
691 value.is_finite()
692 && (track.interpolation == Interpolation::CubicSpline
693 && index % 3 != 1
694 || value.length_squared() > 0.0)
695 })
696 }
697 }
698 })
699}
700
701fn finite_grid(grid: &crate::sample::PoseGrid) -> bool {
702 (0..grid.frame_count()).all(|frame| {
703 (0..grid.bone_count()).all(|bone| {
704 let pose = grid.local(frame, bone);
705 let model_position = grid.model_position(frame, bone);
706 let model_rotation = grid.model_rotation(frame, bone);
707 pose.translation.is_finite()
708 && pose.scale.is_finite()
709 && pose.rotation.is_finite()
710 && pose.rotation.length_squared() > 0.0
711 && model_position.is_finite()
712 && model_rotation.is_finite()
713 && model_rotation.length_squared() > 0.0
714 })
715 })
716}
717
718fn sampled_poses_match(
719 original: &crate::sample::PoseGrid,
720 trial: &crate::sample::PoseGrid,
721) -> bool {
722 original.frame_count() == trial.frame_count()
723 && original.bone_count() == trial.bone_count()
724 && (0..original.frame_count()).all(|frame| {
725 (0..original.bone_count()).all(|bone| {
726 let a = original.local(frame, bone);
727 let b = trial.local(frame, bone);
728 vec3_within(a.translation, b.translation)
729 && vec3_within(a.scale, b.scale)
730 && quaternion_within(a.rotation, b.rotation)
731 && vec3_within(
732 original.model_position(frame, bone),
733 trial.model_position(frame, bone),
734 )
735 && quaternion_within(
736 original.model_rotation(frame, bone),
737 trial.model_rotation(frame, bone),
738 )
739 })
740 })
741}
742
743fn vec3_within(a: Vec3, b: Vec3) -> bool {
744 (a - b).abs().max_element() <= CONSTANT_TRACK_PRUNE_VEC3_TOLERANCE
745}
746
747fn quaternion_within(a: Quat, b: Quat) -> bool {
748 quaternion_angular_delta(a, b)
749 .is_some_and(|delta| delta <= CONSTANT_TRACK_PRUNE_QUAT_TOLERANCE_RAD)
750}
751
752pub fn analyze_duplicate_loop_endpoint(
764 clip: &Clip,
765) -> Result<Option<DuplicateLoopEndpointOutcome>, DuplicateLoopEndpointError> {
766 let Some(reference) = clip.tracks.first() else {
767 return Err(DuplicateLoopEndpointError::NoTracks);
768 };
769 if !clip.duration_s.is_finite() {
770 return Err(DuplicateLoopEndpointError::NonFinite { track: None });
771 }
772 let mut moving_terminal_count = None;
773 let mut terminal_counts = Vec::with_capacity(clip.tracks.len());
774 let mut max_translation_endpoint_delta_m: Option<f32> = None;
775 let mut max_rotation_endpoint_delta_rad: Option<f32> = None;
776 let mut max_scale_endpoint_delta: Option<f32> = None;
777 for (index, track) in clip.tracks.iter().enumerate() {
778 validate_duplicate_endpoint_track(index, track)?;
779 if track.times != reference.times {
780 return Err(DuplicateLoopEndpointError::TimelineMismatch { track: index });
781 }
782 if track.end_time() != clip.duration_s as f32 {
786 return Err(DuplicateLoopEndpointError::DurationMismatch { track: index });
787 }
788 if track.key_count() < 3 {
789 return Ok(None);
790 }
791 let Some(count) = terminal_duplicate_count(track) else {
792 return Ok(None);
793 };
794 let final_key = track.key_count() - 1;
795 match track.property {
796 Property::Translation => {
797 let delta = vec3_key_delta(track, 0, final_key);
798 max_translation_endpoint_delta_m = Some(
799 max_translation_endpoint_delta_m.map_or(delta, |current| current.max(delta)),
800 );
801 }
802 Property::Rotation => {
803 let Some(delta) = quaternion_key_delta(track, 0, final_key) else {
804 return Ok(None);
805 };
806 max_rotation_endpoint_delta_rad = Some(
807 max_rotation_endpoint_delta_rad.map_or(delta, |current| current.max(delta)),
808 );
809 }
810 Property::Scale => {
811 let delta = vec3_key_delta(track, 0, final_key);
812 max_scale_endpoint_delta =
813 Some(max_scale_endpoint_delta.map_or(delta, |current| current.max(delta)));
814 }
815 }
816 let moves = track_has_motion(track);
817 if moves {
818 if moving_terminal_count.is_some_and(|expected| expected != count) {
819 return Ok(None);
820 }
821 moving_terminal_count = Some(count);
822 }
823 terminal_counts.push(count);
824 }
825 let Some(removed_keys_per_track) = moving_terminal_count else {
826 return Ok(None);
827 };
828 if terminal_counts
829 .into_iter()
830 .any(|available| available < removed_keys_per_track)
831 {
832 return Ok(None);
833 }
834 Ok(Some(DuplicateLoopEndpointOutcome {
835 removed_keys_per_track,
836 duration_before_s: clip.duration_s,
837 duration_after_s: reference.times[reference.key_count() - removed_keys_per_track - 1]
838 as f64,
839 max_translation_endpoint_delta_m,
840 max_rotation_endpoint_delta_rad,
841 max_scale_endpoint_delta,
842 }))
843}
844
845pub fn drop_duplicate_loop_endpoint(
850 clip: &mut Clip,
851) -> Result<Option<DuplicateLoopEndpointOutcome>, DuplicateLoopEndpointError> {
852 let Some(outcome) = analyze_duplicate_loop_endpoint(clip)? else {
853 return Ok(None);
854 };
855 for track in &mut clip.tracks {
856 let values = outcome.removed_keys_per_track
857 * if track.interpolation == Interpolation::CubicSpline {
858 3
859 } else {
860 1
861 };
862 track
863 .times
864 .truncate(track.key_count() - outcome.removed_keys_per_track);
865 match &mut track.values {
866 TrackValues::Vec3s(stored) => stored.truncate(stored.len() - values),
867 TrackValues::Quats(stored) => stored.truncate(stored.len() - values),
868 }
869 }
870 clip.duration_s = outcome.duration_after_s;
871 debug_assert!(matches!(analyze_duplicate_loop_endpoint(clip), Ok(None)));
872 Ok(Some(outcome))
873}
874
875fn validate_duplicate_endpoint_track(
876 index: usize,
877 track: &Track,
878) -> Result<(), DuplicateLoopEndpointError> {
879 let keys = track.key_count();
880 let expected = keys
881 * if track.interpolation == Interpolation::CubicSpline {
882 3
883 } else {
884 1
885 };
886 if track.values.len() != expected {
887 return Err(DuplicateLoopEndpointError::InvalidValueCount {
888 track: index,
889 key_count: keys,
890 value_count: track.values.len(),
891 interpolation: track.interpolation,
892 });
893 }
894 if !matches!(
895 (track.property, &track.values),
896 (Property::Rotation, TrackValues::Quats(_))
897 | (
898 Property::Translation | Property::Scale,
899 TrackValues::Vec3s(_)
900 )
901 ) {
902 return Err(DuplicateLoopEndpointError::InvalidValueStorage { track: index });
903 }
904 if track.times.iter().any(|time| !time.is_finite())
905 || match &track.values {
906 TrackValues::Vec3s(values) => values.iter().any(|value| !value.is_finite()),
907 TrackValues::Quats(values) => values.iter().any(|value| !value.is_finite()),
908 }
909 {
910 return Err(DuplicateLoopEndpointError::NonFinite { track: Some(index) });
911 }
912 if track.times.windows(2).any(|window| window[1] <= window[0]) {
913 return Err(DuplicateLoopEndpointError::NonIncreasingTime { track: index });
914 }
915 Ok(())
916}
917
918fn terminal_duplicate_count(track: &Track) -> Option<usize> {
919 let mut count = 0;
920 while count < track.key_count() - 2
921 && keyed_values_match(track, 0, track.key_count() - count - 1)
922 {
923 count += 1;
924 }
925 (count > 0).then_some(count)
926}
927
928fn track_has_motion(track: &Track) -> bool {
929 (1..track.key_count()).any(|key| !keyed_values_match(track, 0, key))
930 || (track.interpolation == Interpolation::CubicSpline
931 && match &track.values {
932 TrackValues::Vec3s(values) => values
933 .iter()
934 .enumerate()
935 .filter(|(index, _)| index % 3 != 1)
936 .any(|(_, value)| {
937 value.abs().max_element() > DUPLICATE_ENDPOINT_VEC3_TOLERANCE
938 }),
939 TrackValues::Quats(values) => values
940 .iter()
941 .enumerate()
942 .filter(|(index, _)| index % 3 != 1)
943 .any(|(_, value)| {
944 value
945 .to_array()
946 .into_iter()
947 .any(|component| component.abs() > DUPLICATE_ENDPOINT_VEC3_TOLERANCE)
948 }),
949 })
950}
951
952fn keyed_values_match(track: &Track, first: usize, other: usize) -> bool {
953 match &track.values {
954 TrackValues::Vec3s(_) => {
955 vec3_key_delta(track, first, other) <= DUPLICATE_ENDPOINT_VEC3_TOLERANCE
956 }
957 TrackValues::Quats(_) => quaternion_key_delta(track, first, other)
958 .is_some_and(|delta| delta <= DUPLICATE_ENDPOINT_QUATERNION_TOLERANCE_RAD),
959 }
960}
961
962fn vec3_key_delta(track: &Track, first: usize, other: usize) -> f32 {
963 let TrackValues::Vec3s(values) = &track.values else {
964 unreachable!("validated vector track")
965 };
966 (values[track.value_index(first)] - values[track.value_index(other)])
967 .abs()
968 .max_element()
969}
970
971fn quaternion_key_delta(track: &Track, first: usize, other: usize) -> Option<f32> {
972 let TrackValues::Quats(values) = &track.values else {
973 unreachable!("validated quaternion track")
974 };
975 let first = values[track.value_index(first)];
976 let other = values[track.value_index(other)];
977 let first_length_squared = first.length_squared();
978 let other_length_squared = other.length_squared();
979 if first_length_squared == 0.0 || other_length_squared == 0.0 {
980 return None;
981 }
982 let delta = first.normalize().conjugate() * other.normalize();
983 let [x, y, z, w] = delta.to_array();
984 let sin_half_angle = glam::Vec3::new(x, y, z).length();
985 Some(2.0 * sin_half_angle.atan2(w.abs()))
986}
987
988pub fn slice(clip: &mut Clip, start_s: f64, end_s: f64, fps: f64) {
1006 let eps = (0.5 / fps) as f32;
1007 let (start, end) = (start_s as f32, end_s as f32);
1008 let duration = (end - start).max(0.0);
1009 for track in &mut clip.tracks {
1010 let mut kept: Vec<(usize, f32)> = (0..track.key_count())
1012 .filter(|&k| track.times[k] >= start - eps && track.times[k] <= end + eps)
1013 .map(|k| (k, (track.times[k] - start).clamp(0.0, duration)))
1014 .collect();
1015
1016 kept.retain({
1020 let times: Vec<f32> = kept.iter().map(|&(_, t)| t).collect();
1021 let mut i = 0;
1022 move |_| {
1023 let t = times[i];
1024 let keep = if t <= 0.0 {
1025 times.get(i + 1).is_none_or(|&next| next > 0.0)
1026 } else if t >= duration {
1027 i == 0 || times[i - 1] < duration
1028 } else {
1029 true
1030 };
1031 i += 1;
1032 keep
1033 }
1034 });
1035
1036 track.times = kept.iter().map(|&(_, t)| t).collect();
1037 let per_key = match track.interpolation {
1038 Interpolation::CubicSpline => 3,
1039 _ => 1,
1040 };
1041 match &mut track.values {
1042 TrackValues::Vec3s(v) => {
1043 let old = std::mem::take(v);
1044 *v = kept
1045 .iter()
1046 .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
1047 .collect();
1048 }
1049 TrackValues::Quats(v) => {
1050 let old = std::mem::take(v);
1051 *v = kept
1052 .iter()
1053 .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
1054 .collect();
1055 }
1056 }
1057 }
1058 clip.duration_s = (end_s - start_s).max(0.0);
1059 clip.tracks.retain(|t| t.key_count() > 0);
1060}
1061
1062pub fn hold_extend(clip: &mut Clip, hold_s: f64) {
1071 for track in &mut clip.tracks {
1072 let Some(&last) = track.times.last() else {
1073 continue;
1074 };
1075 let key = track.key_count() - 1;
1076 track.times.push(last + hold_s as f32);
1077 let value_index = track.value_index(key);
1078 match &mut track.values {
1079 TrackValues::Vec3s(v) => {
1080 let value = v[value_index];
1081 match track.interpolation {
1082 Interpolation::CubicSpline => {
1083 v[key * 3 + 2] = glam::Vec3::ZERO;
1087 v.extend_from_slice(&[glam::Vec3::ZERO, value, glam::Vec3::ZERO]);
1088 }
1089 _ => v.push(value),
1090 }
1091 }
1092 TrackValues::Quats(v) => {
1093 let value = v[value_index];
1094 match track.interpolation {
1095 Interpolation::CubicSpline => {
1096 v[key * 3 + 2] = glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
1097 v.extend_from_slice(&[
1098 glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1099 value,
1100 glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1101 ]);
1102 }
1103 _ => v.push(value),
1104 }
1105 }
1106 }
1107 clip.duration_s = clip.duration_s.max((last + hold_s as f32) as f64);
1108 }
1109}
1110
1111#[derive(Debug, Clone)]
1113#[non_exhaustive]
1114pub struct GaitAlignOutcome {
1115 pub phase_before: f64,
1117 pub phase_after: f64,
1119 pub seam_after: Option<f64>,
1121 pub frame_offset: i32,
1123}
1124
1125pub fn align_gait_anchor(
1153 skeleton: &Skeleton,
1154 clip: &mut Clip,
1155 roles: &ResolvedRoles,
1156 fps: f64,
1157) -> Result<GaitAlignOutcome, String> {
1158 let measure = |c: &Clip| -> Option<(f64, Option<f64>, f64)> {
1159 let frames = crate::metrics::metric_frame_count(c)?;
1160 let grid = sample_clip(skeleton, c, frames);
1161 let m = foot_cycle_metrics(&grid, roles, crate::metrics::MIN_STRIDE_STEP_M)?;
1162 Some((m.gait_phase?, m.loop_seam_ratio, m.lr_amplitude_m))
1163 };
1164 let Some((phase_before, _, amplitude)) = measure(clip) else {
1165 return Err(
1166 "no usable stride anchor (hips/foot roles unresolved or clip too short)".into(),
1167 );
1168 };
1169 if amplitude < 0.03 {
1170 return Err(format!(
1171 "no usable stride anchor (L−R amplitude {amplitude:.4} m) — a ring clip must \
1172 alternate its feet for anchor alignment to mean anything"
1173 ));
1174 }
1175
1176 let unrotatable: Vec<String> = clip
1181 .tracks
1182 .iter()
1183 .filter(|t| {
1184 t.interpolation == Interpolation::CubicSpline && !is_rotation_invariant_track(t)
1185 })
1186 .map(|t| format!("{} bone {}", t.property.as_str(), t.bone))
1187 .collect();
1188 if !unrotatable.is_empty() {
1189 return Err(format!(
1190 "cannot gait-anchor: these animated tracks need lossless resampling that is \
1191 not yet supported ({}); retime them to LINEAR first",
1192 unrotatable.join(", ")
1193 ));
1194 }
1195
1196 let original = clip.clone();
1197 let mut best: Option<(f64, GaitAlignOutcome, Clip)> = None;
1198 for frame_offset in [0i32, -1, 1] {
1199 let mut candidate = original.clone();
1200 rotate_values(&mut candidate, phase_before, fps, frame_offset);
1201 let Some((phase_after, seam_after, _)) = measure(&candidate) else {
1202 continue;
1203 };
1204 let rank = seam_after.unwrap_or(f64::MAX);
1207 if best.as_ref().is_none_or(|(r, _, _)| rank < *r) {
1208 best = Some((
1209 rank,
1210 GaitAlignOutcome {
1211 phase_before,
1212 phase_after,
1213 seam_after,
1214 frame_offset,
1215 },
1216 candidate,
1217 ));
1218 }
1219 }
1220 let Some((_, outcome, rotated)) = best else {
1221 return Err("no rotation candidate was measurable".into());
1222 };
1223 *clip = rotated;
1224 Ok(outcome)
1225}
1226
1227fn is_rotation_invariant_track(track: &Track) -> bool {
1231 let n = track.key_count();
1232 if n <= 1 {
1233 return true;
1234 }
1235 let cubic = track.interpolation == Interpolation::CubicSpline;
1236 fn constant<T: Copy + PartialEq>(values: &[T], n: usize, cubic: bool, zero: T) -> bool {
1237 let value = |key: usize| if cubic { 3 * key + 1 } else { key };
1238 let Some(&first) = values.get(value(0)) else {
1239 return false;
1240 };
1241 (0..n).all(|key| {
1242 values.get(value(key)) == Some(&first)
1243 && (!cubic
1244 || (values.get(3 * key) == Some(&zero)
1245 && values.get(3 * key + 2) == Some(&zero)))
1246 })
1247 }
1248 match &track.values {
1249 TrackValues::Vec3s(values) => constant(values, n, cubic, glam::Vec3::ZERO),
1250 TrackValues::Quats(values) => {
1251 constant(values, n, cubic, glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0))
1252 }
1253 }
1254}
1255
1256fn rotate_values(clip: &mut Clip, phase: f64, fps: f64, frame_offset: i32) {
1270 let duration = clip
1271 .tracks
1272 .iter()
1273 .map(Track::end_time)
1274 .fold(0.0f32, f32::max) as f64;
1275 if duration <= 0.0 {
1276 return;
1277 }
1278 let period = duration + 1.0 / fps;
1279 let mut shift = ((phase * period * fps).round() + frame_offset as f64) / fps;
1280 shift = shift.rem_euclid(period);
1281
1282 for track in &mut clip.tracks {
1283 if is_rotation_invariant_track(track) {
1288 continue;
1289 }
1290 let sampled: Vec<TrackSample> = track
1291 .times
1292 .iter()
1293 .map(|&t| sample_track(track, ((t as f64 + shift) % period) as f32))
1294 .collect();
1295 match &mut track.values {
1296 TrackValues::Vec3s(v) => {
1297 for (slot, s) in v.iter_mut().zip(&sampled) {
1298 if let TrackSample::Vec3(x) = s {
1299 *slot = *x;
1300 }
1301 }
1302 }
1303 TrackValues::Quats(v) => {
1304 for (slot, s) in v.iter_mut().zip(&sampled) {
1305 if let TrackSample::Quat(q) = s {
1306 *slot = *q;
1307 }
1308 }
1309 }
1310 }
1311 }
1312}