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}