Skip to main content

animsmith_core/
stance_support.rs

1//! Shared V1 sampled stance-support classification.
2//!
3//! This module owns the foot-first/toe-fallback selection and the sampled
4//! model-space height predicate shared by contact-oriented consumers. It is a
5//! sampled observation, not a physical-contact or gameplay claim.
6
7use crate::model::BoneId;
8use crate::profile::{ResolvedRoles, Role};
9use crate::sample::PoseGrid;
10
11/// One independently resolved side of a bilateral stance observation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum StanceSideV1 {
14    /// The character's left side.
15    Left,
16    /// The character's right side.
17    Right,
18}
19
20impl StanceSideV1 {
21    pub(crate) const fn label(self) -> &'static str {
22        match self {
23            Self::Left => "left",
24            Self::Right => "right",
25        }
26    }
27
28    fn resolved_role(self, roles: &ResolvedRoles) -> Option<(Role, BoneId)> {
29        let preferred = match self {
30            Self::Left => [Role::LeftFoot, Role::LeftToe],
31            Self::Right => [Role::RightFoot, Role::RightToe],
32        };
33        preferred
34            .into_iter()
35            .find_map(|role| roles.get(role).map(|bone| (role, bone)))
36    }
37}
38
39/// One maximal V1 support run, expressed as inclusive sampled-frame indices.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct StanceSupportRunV1 {
42    /// First supporting frame in the run.
43    pub start_frame: usize,
44    /// Last supporting frame in the run.
45    pub end_frame: usize,
46}
47
48/// Sampled support evidence for one selected foot or toe role.
49///
50/// The classifier intentionally preserves the legacy `foot-slide` reduction
51/// and comparison semantics, including their behavior for non-finite sampled
52/// heights. Strict consumers that need finite evidence must validate it at
53/// their own boundary before using this observation.
54#[derive(Debug)]
55pub struct ResolvedStanceSupportV1<'a> {
56    grid: &'a PoseGrid,
57    role: Role,
58    bone: BoneId,
59    ground_y_m: f64,
60    contact_height_m: f64,
61}
62
63/// Resolve one side and classify its sampled stance support.
64///
65/// The selected role is Foot first and Toe only as a same-side fallback. The
66/// ground reference is the selected role's own minimum model-space Y over the
67/// complete grid; sides never share a ground reference. The reduction and
68/// comparison preserve the existing `foot-slide` behavior exactly.
69pub fn resolve_stance_support_v1<'a>(
70    grid: &'a PoseGrid,
71    roles: &ResolvedRoles,
72    side: StanceSideV1,
73    contact_height_m: f64,
74) -> Option<ResolvedStanceSupportV1<'a>> {
75    let (role, bone) = side.resolved_role(roles)?;
76    let ground_y_m = (0..grid.frame_count())
77        .map(|frame| grid.model_position(frame, bone).y as f64)
78        .fold(f64::MAX, f64::min);
79    Some(ResolvedStanceSupportV1 {
80        grid,
81        role,
82        bone,
83        ground_y_m,
84        contact_height_m,
85    })
86}
87
88impl ResolvedStanceSupportV1<'_> {
89    /// The Foot or Toe role selected for this side.
90    pub const fn role(&self) -> Role {
91        self.role
92    }
93
94    /// The selected role's skeleton bone index.
95    pub const fn bone(&self) -> BoneId {
96        self.bone
97    }
98
99    /// Iterate every existing `foot-slide` eligible adjacent-pair endpoint.
100    ///
101    /// An emitted frame `f` means both `f - 1` and `f` satisfy the V1 support
102    /// predicate. Frames are yielded in strictly increasing order; no loop
103    /// seam pair is introduced.
104    pub fn supported_adjacent_frames(&self) -> impl Iterator<Item = usize> + '_ {
105        (1..self.grid.frame_count())
106            .filter(|&frame| self.is_support_frame(frame - 1) && self.is_support_frame(frame))
107    }
108
109    /// Iterate maximal contiguous supporting runs that contain at least two
110    /// sampled frames.
111    ///
112    /// Singleton supporting samples are deliberately omitted, matching the
113    /// adjacent-pair prerequisite consumed by [`Self::supported_adjacent_frames`].
114    pub fn retained_runs(&self) -> impl Iterator<Item = StanceSupportRunV1> + '_ {
115        let mut next_frame = 0;
116        std::iter::from_fn(move || {
117            loop {
118                while next_frame < self.grid.frame_count() && !self.is_support_frame(next_frame) {
119                    next_frame += 1;
120                }
121                if next_frame == self.grid.frame_count() {
122                    return None;
123                }
124                let start_frame = next_frame;
125                while next_frame < self.grid.frame_count() && self.is_support_frame(next_frame) {
126                    next_frame += 1;
127                }
128                let end_frame = next_frame - 1;
129                if end_frame > start_frame {
130                    return Some(StanceSupportRunV1 {
131                        start_frame,
132                        end_frame,
133                    });
134                }
135            }
136        })
137    }
138
139    fn is_support_frame(&self, frame: usize) -> bool {
140        // Do not simplify this to `height <= ...`: the established FootSlide
141        // predicate deliberately treats NaN differently. The `f64::MAX`
142        // ground seed above likewise differs from an infinity seed for +∞.
143        let above_threshold = (self.grid.model_position(frame, self.bone).y as f64)
144            > self.ground_y_m + self.contact_height_m;
145        !above_threshold
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::model::{
153        Bone, Clip, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
154    };
155    use crate::sample::sample_clip;
156    use glam::Vec3;
157
158    fn skeleton(names: &[(&str, f32)]) -> Skeleton {
159        Skeleton {
160            bones: names
161                .iter()
162                .map(|(name, y)| Bone {
163                    name: (*name).into(),
164                    parent: None,
165                    rest: Transform {
166                        translation: Vec3::new(0.0, *y, 0.0),
167                        ..Transform::IDENTITY
168                    },
169                    inverse_bind: None,
170                })
171                .collect(),
172        }
173    }
174
175    fn grid(skeleton: Skeleton, tracks: Vec<Track>) -> (Skeleton, PoseGrid) {
176        let clip = Clip {
177            name: "stance".into(),
178            duration_s: 1.0,
179            tracks,
180        };
181        let grid = sample_clip(&skeleton, &clip, 3);
182        (skeleton, grid)
183    }
184
185    fn y_track(bone: BoneId, values: Vec<f32>) -> Track {
186        Track {
187            bone,
188            property: Property::Translation,
189            interpolation: Interpolation::Linear,
190            times: vec![0.0, 0.5, 1.0],
191            values: TrackValues::Vec3s(
192                values.into_iter().map(|y| Vec3::new(0.0, y, 0.0)).collect(),
193            ),
194        }
195    }
196
197    #[test]
198    fn each_side_uses_its_own_model_y_minimum() {
199        let (skeleton, grid) = grid(
200            skeleton(&[("left", 0.0), ("right", 10.0)]),
201            vec![
202                y_track(0, vec![0.0, 0.5, 0.0]),
203                y_track(1, vec![10.0, 10.5, 10.0]),
204            ],
205        );
206        let roles = ResolvedRoles::from_names(
207            &skeleton,
208            [
209                (Role::LeftFoot, "left".to_string()),
210                (Role::RightFoot, "right".to_string()),
211            ],
212        );
213
214        assert_eq!(grid.model_position(0, 0).y, 0.0);
215        assert_eq!(grid.model_position(0, 1).y, 10.0);
216        let left = resolve_stance_support_v1(&grid, &roles, StanceSideV1::Left, 0.5).unwrap();
217        let right = resolve_stance_support_v1(&grid, &roles, StanceSideV1::Right, 0.5).unwrap();
218        assert_eq!(left.supported_adjacent_frames().collect::<Vec<_>>(), [1, 2]);
219        assert_eq!(
220            right.supported_adjacent_frames().collect::<Vec<_>>(),
221            [1, 2]
222        );
223    }
224
225    #[test]
226    fn foot_and_toe_selection_is_independent_per_side() {
227        let (skeleton, grid) = grid(
228            skeleton(&[("left-foot", 0.0), ("left-toe", 0.0), ("right-toe", 0.0)]),
229            vec![y_track(0, vec![0.0, 0.0, 0.0])],
230        );
231        let roles = ResolvedRoles::from_names(
232            &skeleton,
233            [
234                (Role::LeftFoot, "left-foot".to_string()),
235                (Role::LeftToe, "left-toe".to_string()),
236                (Role::RightToe, "right-toe".to_string()),
237            ],
238        );
239
240        let left = resolve_stance_support_v1(&grid, &roles, StanceSideV1::Left, 0.0).unwrap();
241        let right = resolve_stance_support_v1(&grid, &roles, StanceSideV1::Right, 0.0).unwrap();
242        assert_eq!((left.role(), left.bone()), (Role::LeftFoot, 0));
243        assert_eq!((right.role(), right.bone()), (Role::RightToe, 2));
244    }
245
246    #[test]
247    fn retained_runs_are_maximal_and_match_adjacent_pairs() {
248        let skeleton = skeleton(&[("left", 0.0)]);
249        let clip = Clip {
250            name: "stance".into(),
251            duration_s: 1.0,
252            tracks: vec![Track {
253                bone: 0,
254                property: Property::Translation,
255                interpolation: Interpolation::Linear,
256                times: (0..8).map(|index| index as f32 / 7.0).collect(),
257                values: TrackValues::Vec3s(
258                    [0.0, 0.0, 0.1, 0.0, 0.1, 0.0, 0.0, 0.0]
259                        .into_iter()
260                        .map(|y| Vec3::new(0.0, y, 0.0))
261                        .collect(),
262                ),
263            }],
264        };
265        let grid = sample_clip(&skeleton, &clip, 8);
266        let roles = ResolvedRoles::from_names(&skeleton, [(Role::LeftFoot, "left".to_string())]);
267        let support = resolve_stance_support_v1(&grid, &roles, StanceSideV1::Left, 0.0).unwrap();
268
269        assert_eq!(
270            support.supported_adjacent_frames().collect::<Vec<_>>(),
271            [1, 6, 7]
272        );
273        assert_eq!(
274            support.retained_runs().collect::<Vec<_>>(),
275            [
276                StanceSupportRunV1 {
277                    start_frame: 0,
278                    end_frame: 1,
279                },
280                StanceSupportRunV1 {
281                    start_frame: 5,
282                    end_frame: 7,
283                },
284            ]
285        );
286    }
287
288    #[test]
289    fn non_finite_samples_keep_the_legacy_predicate() {
290        for (values, expected_endpoints) in [
291            (vec![f32::NAN, 0.0, 0.0], vec![1, 2]),
292            (vec![f32::INFINITY; 3], vec![]),
293        ] {
294            let (skeleton, grid) = grid(skeleton(&[("left", 0.0)]), vec![y_track(0, values)]);
295            let roles =
296                ResolvedRoles::from_names(&skeleton, [(Role::LeftFoot, "left".to_string())]);
297            let support =
298                resolve_stance_support_v1(&grid, &roles, StanceSideV1::Left, 0.0).unwrap();
299            assert_eq!(
300                support.supported_adjacent_frames().collect::<Vec<_>>(),
301                expected_endpoints
302            );
303        }
304    }
305}