Skip to main content

animsmith_core/
transform.rs

1//! Pipeline-mechanical clip transforms, ported from the incubating
2//! bake's Python: frame-window slicing, hold-extension, and gait-anchor
3//! rotation. Scope rule (DESIGN.md §1): animsmith may rewrite a clip
4//! only in ways whose correctness its own checks can verify.
5
6use crate::metrics::foot_cycle_metrics;
7use crate::model::{Clip, Interpolation, Skeleton, Track, TrackValues};
8use crate::profile::ResolvedRoles;
9use crate::sample::{TrackSample, sample_clip, sample_track};
10
11/// Keep only the keys inside `[start, end]` seconds (with a half-frame
12/// epsilon at `fps` absorbing float drift from earlier retimings) and
13/// retime them so the window starts at 0. Cubic tangent triplets move
14/// with their keys. The clip duration becomes `end - start`.
15///
16/// Boundary keys are snapped to the window, not carried past it: keys
17/// within the epsilon of `start` clamp to 0 and keys within it of `end`
18/// clamp to the new duration. When several keys land on a boundary, the
19/// one closest to the original boundary is kept and the rest dropped —
20/// so the output has at most one key at 0 and one at the end, stays
21/// time-monotonic, and round-trips its declared duration.
22///
23/// # Panics
24///
25/// Panics if a hand-built track violates the loader invariant that
26/// `values` contains one value per key for linear/step tracks, or one
27/// tangent-value-tangent triplet per key for cubic-spline tracks.
28pub fn slice(clip: &mut Clip, start_s: f64, end_s: f64, fps: f64) {
29    let eps = (0.5 / fps) as f32;
30    let (start, end) = (start_s as f32, end_s as f32);
31    let duration = (end - start).max(0.0);
32    for track in &mut clip.tracks {
33        // (key index, retimed+clamped time), in original key order.
34        let mut kept: Vec<(usize, f32)> = (0..track.key_count())
35            .filter(|&k| track.times[k] >= start - eps && track.times[k] <= end + eps)
36            .map(|k| (k, (track.times[k] - start).clamp(0.0, duration)))
37            .collect();
38
39        // Drop boundary duplicates: at t=0 keep the last (closest to
40        // `start`); at t=duration keep the first (closest to `end`).
41        // Interior times are already distinct and monotonic.
42        kept.retain({
43            let times: Vec<f32> = kept.iter().map(|&(_, t)| t).collect();
44            let mut i = 0;
45            move |_| {
46                let t = times[i];
47                let keep = if t <= 0.0 {
48                    times.get(i + 1).is_none_or(|&next| next > 0.0)
49                } else if t >= duration {
50                    i == 0 || times[i - 1] < duration
51                } else {
52                    true
53                };
54                i += 1;
55                keep
56            }
57        });
58
59        track.times = kept.iter().map(|&(_, t)| t).collect();
60        let per_key = match track.interpolation {
61            Interpolation::CubicSpline => 3,
62            _ => 1,
63        };
64        match &mut track.values {
65            TrackValues::Vec3s(v) => {
66                let old = std::mem::take(v);
67                *v = kept
68                    .iter()
69                    .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
70                    .collect();
71            }
72            TrackValues::Quats(v) => {
73                let old = std::mem::take(v);
74                *v = kept
75                    .iter()
76                    .flat_map(|&(k, _)| old[k * per_key..(k + 1) * per_key].to_vec())
77                    .collect();
78            }
79        }
80    }
81    clip.duration_s = (end_s - start_s).max(0.0);
82    clip.tracks.retain(|t| t.key_count() > 0);
83}
84
85/// Append one key per track duplicating its final value `hold_s`
86/// seconds after its last key (a linear hold — charge/block poses).
87/// The clip duration extends to the longest held end.
88///
89/// # Panics
90///
91/// Panics if a hand-built track violates the loader invariant that each
92/// key has a corresponding stored value (or cubic-spline triplet).
93pub fn hold_extend(clip: &mut Clip, hold_s: f64) {
94    for track in &mut clip.tracks {
95        let Some(&last) = track.times.last() else {
96            continue;
97        };
98        let key = track.key_count() - 1;
99        track.times.push(last + hold_s as f32);
100        let value_index = track.value_index(key);
101        match &mut track.values {
102            TrackValues::Vec3s(v) => {
103                let value = v[value_index];
104                match track.interpolation {
105                    Interpolation::CubicSpline => {
106                        // Zero tangents: a flat Hermite hold. Also zero
107                        // the previous key's out-tangent so the hold
108                        // segment stays flat.
109                        v[key * 3 + 2] = glam::Vec3::ZERO;
110                        v.extend_from_slice(&[glam::Vec3::ZERO, value, glam::Vec3::ZERO]);
111                    }
112                    _ => v.push(value),
113                }
114            }
115            TrackValues::Quats(v) => {
116                let value = v[value_index];
117                match track.interpolation {
118                    Interpolation::CubicSpline => {
119                        v[key * 3 + 2] = glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
120                        v.extend_from_slice(&[
121                            glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
122                            value,
123                            glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
124                        ]);
125                    }
126                    _ => v.push(value),
127                }
128            }
129        }
130        clip.duration_s = clip.duration_s.max((last + hold_s as f32) as f64);
131    }
132}
133
134/// Outcome of [`align_gait_anchor`].
135#[derive(Debug, Clone)]
136#[non_exhaustive]
137pub struct GaitAlignOutcome {
138    /// The measured stride-anchor phase before rotation.
139    pub phase_before: f64,
140    /// The phase after rotation (should sit near 0).
141    pub phase_after: f64,
142    /// Loop-seam ratio after rotation (the chosen candidate's wrap).
143    pub seam_after: Option<f64>,
144    /// The whole-frame offset (−1/0/+1) that produced the cleanest wrap.
145    pub frame_offset: i32,
146}
147
148/// Rotate a cyclic clip in time so its measured stride anchor (the
149/// trough of the L−R foot-height fundamental) lands at clip time 0.
150///
151/// Semantics ported from the reference bake: the cycle period is
152/// `duration + 1/fps` (an open loop's wrap step is a real frame of the
153/// stride); the shift is quantized to whole frames so every resample
154/// lands on an existing key; each animated channel keeps its times and
155/// gets its output values replaced by the channel sampled at
156/// `(t + shift) mod period`. Constant channels are rotation-invariant
157/// and left alone; a non-constant CUBICSPLINE channel cannot be
158/// resampled losslessly, so alignment refuses (naming it) rather than
159/// rotate the rest of the rig around it. Because a ±1-frame shift stays
160/// inside phase tolerance but moves *where the wrap lands*, all three
161/// candidates are tried and the one with the cleanest wrap (lowest seam
162/// ratio) wins.
163///
164/// # Errors
165///
166/// Returns an error when the clip has no measurable stride anchor, the
167/// left-right foot amplitude is too small to define a stable phase, a
168/// non-constant cubic-spline track would need lossy resampling, or no
169/// tested rotation candidate remains measurable.
170///
171/// # Panics
172///
173/// Panics if `roles` contains bone indices outside `skeleton`, or if a
174/// hand-built clip violates the loader key/value-count invariants.
175pub fn align_gait_anchor(
176    skeleton: &Skeleton,
177    clip: &mut Clip,
178    roles: &ResolvedRoles,
179    fps: f64,
180) -> Result<GaitAlignOutcome, String> {
181    let measure = |c: &Clip| -> Option<(f64, Option<f64>, f64)> {
182        let frames = crate::metrics::metric_frame_count(c)?;
183        let grid = sample_clip(skeleton, c, frames);
184        let m = foot_cycle_metrics(&grid, roles, crate::metrics::MIN_STRIDE_STEP_M)?;
185        Some((m.gait_phase?, m.loop_seam_ratio, m.lr_amplitude_m))
186    };
187    let Some((phase_before, _, amplitude)) = measure(clip) else {
188        return Err(
189            "no usable stride anchor (hips/foot roles unresolved or clip too short)".into(),
190        );
191    };
192    if amplitude < 0.03 {
193        return Err(format!(
194            "no usable stride anchor (L−R amplitude {amplitude:.4} m) — a ring clip must \
195             alternate its feet for anchor alignment to mean anything"
196        ));
197    }
198
199    // Refuse rather than rotate part of a clip: a channel we cannot
200    // resample coherently (a non-constant CUBICSPLINE track) would be
201    // left in place while its siblings shift, desynchronizing the rig.
202    // Constant tracks are rotation-invariant and safely skipped.
203    let unrotatable: Vec<String> = clip
204        .tracks
205        .iter()
206        .filter(|t| t.interpolation == Interpolation::CubicSpline && !is_constant_track(t))
207        .map(|t| format!("{} bone {}", t.property.as_str(), t.bone))
208        .collect();
209    if !unrotatable.is_empty() {
210        return Err(format!(
211            "cannot gait-anchor: these animated tracks need lossless resampling that is \
212             not yet supported ({}); retime them to LINEAR first",
213            unrotatable.join(", ")
214        ));
215    }
216
217    let original = clip.clone();
218    let mut best: Option<(f64, GaitAlignOutcome, Clip)> = None;
219    for frame_offset in [0i32, -1, 1] {
220        let mut candidate = original.clone();
221        rotate_values(&mut candidate, phase_before, fps, frame_offset);
222        let Some((phase_after, seam_after, _)) = measure(&candidate) else {
223            continue;
224        };
225        // Rank by wrap cleanliness; a missing seam (no stride at the
226        // wrap) should not happen on a ring clip — rank it last.
227        let rank = seam_after.unwrap_or(f64::MAX);
228        if best.as_ref().is_none_or(|(r, _, _)| rank < *r) {
229            best = Some((
230                rank,
231                GaitAlignOutcome {
232                    phase_before,
233                    phase_after,
234                    seam_after,
235                    frame_offset,
236                },
237                candidate,
238            ));
239        }
240    }
241    let Some((_, outcome, rotated)) = best else {
242        return Err("no rotation candidate was measurable".into());
243    };
244    *clip = rotated;
245    Ok(outcome)
246}
247
248/// True when `track` holds the same pose at every time — such a track
249/// is invariant under time rotation, so it can be skipped safely. For
250/// CUBICSPLINE this means the keyed values agree *and* every in/out
251/// tangent is zero: equal values with non-zero tangents still describe
252/// an animated Hermite curve (the sampler interpolates through the
253/// tangents), so that track must be rotated or refused, never skipped.
254/// A short or inconsistent value array reads as animated (never wrongly
255/// skipped).
256fn is_constant_track(track: &Track) -> bool {
257    let n = track.key_count();
258    if n <= 1 {
259        return true;
260    }
261    let cubic = track.interpolation == Interpolation::CubicSpline;
262    fn constant<T: Copy + PartialEq>(v: &[T], n: usize, cubic: bool, zero: T) -> bool {
263        let value = |k: usize| if cubic { 3 * k + 1 } else { k };
264        let Some(&first) = v.get(value(0)) else {
265            return false;
266        };
267        (0..n).all(|k| {
268            // Keyed value matches, and for cubic the in/out tangents
269            // (indices 3k and 3k+2) are zero — a genuinely flat hold.
270            v.get(value(k)) == Some(&first)
271                && (!cubic || (v.get(3 * k) == Some(&zero) && v.get(3 * k + 2) == Some(&zero)))
272        })
273    }
274    match &track.values {
275        TrackValues::Vec3s(v) => constant(v, n, cubic, glam::Vec3::ZERO),
276        TrackValues::Quats(v) => constant(v, n, cubic, glam::Quat::from_xyzw(0.0, 0.0, 0.0, 0.0)),
277    }
278}
279
280/// Replace each animated channel's output values with the channel
281/// sampled at `(t + shift) mod period`; times untouched. Constant
282/// tracks (rotation-invariant) are skipped; non-constant CUBICSPLINE
283/// tracks are refused upstream in [`align_gait_anchor`].
284///
285/// Uniform-framing assumption: the cycle period is `duration + 1/fps`,
286/// i.e. every track is framed on the same uniform `1/fps` grid and the
287/// open-loop wrap step spans exactly one such frame. On a clip with
288/// irregular key spacing — or tracks framed at different rates — that
289/// period is only approximate, the quantized shift may miss an existing
290/// key by a fraction of a frame, and the resample stops being exactly
291/// lossless. animsmith's pipeline emits uniformly-framed clips, so the
292/// assumption holds for the inputs this transform is applied to.
293fn rotate_values(clip: &mut Clip, phase: f64, fps: f64, frame_offset: i32) {
294    let duration = clip
295        .tracks
296        .iter()
297        .map(Track::end_time)
298        .fold(0.0f32, f32::max) as f64;
299    if duration <= 0.0 {
300        return;
301    }
302    let period = duration + 1.0 / fps;
303    let mut shift = ((phase * period * fps).round() + frame_offset as f64) / fps;
304    shift = shift.rem_euclid(period);
305
306    for track in &mut clip.tracks {
307        // Constant tracks (any key count) are invariant; cubic tracks
308        // reaching here are constant, so the zip below only touches
309        // LINEAR/STEP values. Non-constant short tracks (e.g. a 2-key
310        // root ramp) are now rotated instead of silently left behind.
311        if is_constant_track(track) {
312            continue;
313        }
314        let sampled: Vec<TrackSample> = track
315            .times
316            .iter()
317            .map(|&t| sample_track(track, ((t as f64 + shift) % period) as f32))
318            .collect();
319        match &mut track.values {
320            TrackValues::Vec3s(v) => {
321                for (slot, s) in v.iter_mut().zip(&sampled) {
322                    if let TrackSample::Vec3(x) = s {
323                        *slot = *x;
324                    }
325                }
326            }
327            TrackValues::Quats(v) => {
328                for (slot, s) in v.iter_mut().zip(&sampled) {
329                    if let TrackSample::Quat(q) = s {
330                        *slot = *q;
331                    }
332                }
333            }
334        }
335    }
336}