Skip to main content

motion_core/authoring/
skeleton.rs

1//! Stateless planar forward kinematics and timeline-compatible pose values.
2
3use crate::{Interpolate, Node, NodeModifiers, Pivot, Point, Transform, ValidationError};
4
5/// One joint's rest pivot, measured in its parent's rotated local coordinates.
6///
7/// A bone has no implicit length, artwork, scale, or skinning weights. Attach a
8/// child at an offset to establish a limb, and draw between the solved pivots or
9/// attach artwork using [`BoneTransform::apply`]. Root offsets use skeleton space.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct Bone {
12    parent: Option<usize>,
13    offset: (f64, f64),
14}
15
16impl Bone {
17    /// Stores a parent index and finite rest offset in local pixels.
18    /// Validation is deferred to [`Skeleton::new`]. A parent must appear earlier
19    /// in the array; `None` starts an independent root in the same coordinate space.
20    pub fn new(parent: Option<usize>, offset: (f64, f64)) -> Self {
21        Self { parent, offset }
22    }
23}
24
25/// Local translation/rotation channels for one bone, with an identity default.
26///
27/// Translation is added to the rest offset before the parent rotation is applied.
28/// Rotation is clockwise radians, around this bone's pivot. Rotating a bone moves
29/// its descendants but not its own pivot. There is no automatic angle wrapping:
30/// `0` to `2 * PI` animates one full turn, not a shortest-arc rotation.
31///
32/// Interpolation permits overshoot. Nonfinite or overflowing values are rejected
33/// by [`Skeleton::solve`], not by construction or timeline sampling.
34#[derive(Clone, Copy, Debug, Default, PartialEq)]
35pub struct BonePose {
36    /// Additional parent-local translation in pixels; default `(0, 0)`.
37    pub translation: (f64, f64),
38    /// Additional clockwise local rotation in radians; default zero.
39    pub rotation: f64,
40}
41
42impl Interpolate for BonePose {
43    fn interpolate(&self, target: &Self, progress: f64) -> Self {
44        Self {
45            translation: self.translation.interpolate(&target.translation, progress),
46            rotation: self.rotation.interpolate(&target.rotation, progress),
47        }
48    }
49}
50
51/// A fixed-size set of local channels, indexed exactly like its skeleton.
52///
53/// The default is the rest pose. Implements [`Interpolate`] so a whole pose can
54/// be sampled with [`crate::Keyframes`] or [`crate::Tween`], including repeat,
55/// delay, and reverse. The compile-time count prevents mismatched channel lengths;
56/// skeletons with equal counts still require the same semantic bone ordering.
57#[derive(Clone, Debug, PartialEq)]
58pub struct Pose<const BONES: usize> {
59    /// Local channels in skeleton index order.
60    pub bones: [BonePose; BONES],
61}
62
63impl<const BONES: usize> Pose<BONES> {
64    /// Creates a pose from local translation/rotation channels; does not validate.
65    pub fn new(bones: [BonePose; BONES]) -> Self {
66        Self { bones }
67    }
68}
69
70impl<const BONES: usize> Default for Pose<BONES> {
71    fn default() -> Self {
72        Self::new([BonePose::default(); BONES])
73    }
74}
75
76impl<const BONES: usize> Interpolate for Pose<BONES> {
77    fn interpolate(&self, target: &Self, progress: f64) -> Self {
78        Self::new(std::array::from_fn(|index| {
79            self.bones[index].interpolate(&target.bones[index], progress)
80        }))
81    }
82}
83
84/// An immutable, parent-first 2D skeleton evaluated without preceding frames.
85///
86/// This is forward kinematics only: no IK, constraints, dynamics, or mesh skinning.
87/// Multiple roots and empty skeletons are allowed. Solved coordinates are local
88/// to the skeleton; put the assembled scene inside a normal transformed group to
89/// position, scale, or mirror the complete character. No renderer changes are needed.
90///
91/// # Example
92///
93/// ```
94/// use motion_core::prelude::*;
95/// use motion_core::{
96///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
97///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
98/// };
99/// # fn main() -> motion_core::Result {
100/// let rig = Skeleton::new([Bone::new(None, (0.0, 0.0)), Bone::new(Some(0), (60.0, 0.0))])?;
101/// let mut pose = Pose::default();
102/// pose.bones[0].rotation = std::f64::consts::FRAC_PI_2;
103/// let joints = rig.solve(&pose)?;
104/// assert!((joints[1].position().1 - 60.0).abs() < 0.001);
105/// let artwork = joints[0]
106///     .apply(Path2D::parse_svg("M0 0L60 0")?.stroke(12.0, Color::from_hex(0x23272c)));
107/// # let _ = artwork;
108/// # Ok(())
109/// # }
110/// ```
111#[derive(Clone, Debug)]
112pub struct Skeleton<const BONES: usize> {
113    bones: [Bone; BONES],
114}
115
116impl<const BONES: usize> Skeleton<BONES> {
117    /// Validates and stores the rest hierarchy without allocating a graph.
118    ///
119    /// # Errors
120    /// Returns [`std::io::ErrorKind::InvalidInput`] for a nonfinite rest offset or a
121    /// parent index that is not smaller than its child index. The latter rejects
122    /// self-parenting, cycles, forward references, and out-of-range parents.
123    pub fn new(bones: [Bone; BONES]) -> Result<Self, ValidationError> {
124        for (index, bone) in bones.iter().enumerate() {
125            if bone.parent.is_some_and(|parent| parent >= index)
126                || ![bone.offset.0, bone.offset.1]
127                    .iter()
128                    .all(|value| value.is_finite())
129            {
130                return Err(invalid(
131                    "Skeleton requires finite offsets and parent-first bone indices",
132                ));
133            }
134        }
135        Ok(Self { bones })
136    }
137
138    /// Solves joint pivots and orientations in skeleton space in one linear pass.
139    ///
140    /// Takes only the rest skeleton and this pose; sampling out of order or
141    /// concurrently has no hidden state. A bone's translation is applied before
142    /// its rotation, so its children inherit both the pivot and orientation.
143    ///
144    /// # Errors
145    /// Returns [`std::io::ErrorKind::InvalidInput`] for nonfinite channels, overflowing
146    /// local translations, or nonrepresentable solved positions/angles. The
147    /// complete result is discarded on error; no partially solved pose is returned.
148    pub fn solve(&self, pose: &Pose<BONES>) -> Result<[BoneTransform; BONES], ValidationError> {
149        let mut solved = [BoneTransform {
150            position: (0.0, 0.0),
151            rotation: 0.0,
152        }; BONES];
153        for (index, bone) in self.bones.iter().enumerate() {
154            let channel = pose.bones[index];
155            if ![
156                channel.translation.0,
157                channel.translation.1,
158                channel.rotation,
159            ]
160            .iter()
161            .all(|value| value.is_finite())
162            {
163                return Err(invalid("Bone pose channels must be finite"));
164            }
165            let local = (
166                bone.offset.0 + channel.translation.0,
167                bone.offset.1 + channel.translation.1,
168            );
169            let joint = if let Some(parent) = bone.parent {
170                BoneTransform {
171                    position: solved[parent].point(local),
172                    rotation: solved[parent].rotation + channel.rotation,
173                }
174            } else {
175                BoneTransform {
176                    position: local,
177                    rotation: channel.rotation,
178                }
179            };
180            if ![
181                local.0,
182                local.1,
183                joint.position.0,
184                joint.position.1,
185                joint.rotation,
186            ]
187            .iter()
188            .all(|value| value.is_finite())
189            {
190                return Err(invalid("Bone pose produces overflowing joint transforms"));
191            }
192            solved[index] = joint;
193        }
194        Ok(solved)
195    }
196}
197
198/// A solved rigid transform from one bone's local frame to skeleton space.
199///
200/// Produced by [`Skeleton::solve`]. It contains no scale or inverse transform;
201/// use a surrounding group for whole-character scaling, reflection, and clipping.
202#[derive(Clone, Copy, Debug, PartialEq)]
203pub struct BoneTransform {
204    position: (f64, f64),
205    rotation: f64,
206}
207
208impl BoneTransform {
209    /// Returns the joint pivot in skeleton-local pixels, not composition pixels.
210    pub fn position(self) -> (f64, f64) {
211        self.position
212    }
213
214    /// Returns the accumulated clockwise rotation in radians, without wrapping.
215    pub fn rotation(self) -> f64 {
216        self.rotation
217    }
218
219    /// Maps a point from this bone's local coordinates into skeleton space.
220    /// Arbitrary caller-supplied points are not validated; extreme points can
221    /// overflow even when the solved joint itself is finite.
222    pub fn point(self, point: (f64, f64)) -> (f64, f64) {
223        let point = Transform::rotate(self.rotation)
224            .then(Transform::translate(self.position.0, self.position.1))
225            .apply(Point::new(point.0, point.1));
226        (point.x, point.y)
227    }
228
229    /// Wraps artwork in a group at this joint, preserving its own modifiers.
230    /// Call in your desired painter order; hierarchy order need not be draw order.
231    /// This is rigid attachment, not path deformation or isolated group blending.
232    pub fn apply(self, artwork: impl Into<Node>) -> Node {
233        Node::group([artwork.into()])
234            .pivot(Pivot::Point(Point::ORIGIN))
235            .rotate(self.rotation)
236            .translate(self.position.0, self.position.1)
237    }
238}
239
240fn invalid(message: &str) -> ValidationError {
241    ValidationError::invalid("skeleton", "input", message)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::Animation;
248    use crate::{
249        Color, DrawCommand, Easing, Keyframes, PixelSize, Rect, Size, TimeLiteral, ValidationLimits,
250    };
251    use std::f64::consts::FRAC_PI_2;
252
253    fn transform(node: &Node) -> Transform {
254        let list = node
255            .compile(
256                Size::new(64.0, 64.0),
257                PixelSize::new(64, 64),
258                ValidationLimits::default(),
259            )
260            .unwrap();
261        list.commands
262            .iter()
263            .find_map(|command| match command {
264                DrawCommand::FillRect { transform, .. } => Some(*transform),
265                _ => None,
266            })
267            .unwrap()
268    }
269
270    fn close(actual: (f64, f64), expected: (f64, f64)) {
271        assert!(
272            (actual.0 - expected.0).abs() < 0.0001 && (actual.1 - expected.1).abs() < 0.0001,
273            "{actual:?} != {expected:?}"
274        );
275    }
276
277    #[test]
278    fn child_pivots_inherit_rotation_and_translation_but_not_sibling_pose()
279    -> Result<(), ValidationError> {
280        let rig = Skeleton::new([
281            Bone::new(None, (5.0, 7.0)),
282            Bone::new(Some(0), (10.0, 0.0)),
283            Bone::new(Some(1), (4.0, 0.0)),
284            Bone::new(Some(0), (0.0, 3.0)),
285        ])?;
286        let mut pose = Pose::default();
287        pose.bones[0] = BonePose {
288            translation: (2.0, 1.0),
289            rotation: FRAC_PI_2,
290        };
291        pose.bones[1] = BonePose {
292            translation: (2.0, 0.0),
293            rotation: -FRAC_PI_2,
294        };
295        let solved = rig.solve(&pose)?;
296        close(solved[0].position(), (7.0, 8.0));
297        close(solved[1].position(), (7.0, 20.0));
298        close(solved[2].position(), (11.0, 20.0));
299        close(solved[3].position(), (4.0, 8.0));
300        close(solved[1].point((3.0, 0.0)), (10.0, 20.0));
301        assert!(solved[1].rotation().abs() < 0.0001);
302        Ok(())
303    }
304
305    #[test]
306    fn pose_tracks_are_repeatable_and_keep_unwrapped_turns() -> Result<(), ValidationError> {
307        let rig = Skeleton::new([Bone::new(None, (0.0, 0.0)), Bone::new(Some(0), (10.0, 0.0))])?;
308        let start = Pose::default();
309        let mut end = start.clone();
310        end.bones[0].rotation = std::f64::consts::TAU;
311        let track = Keyframes::new(start.clone()).to(1.seconds(), end.clone(), Easing::Linear);
312        let time = 250.milliseconds();
313        let first = rig.solve(&track.sample(time))?;
314        close(first[1].position(), (0.0, 10.0));
315        let _later = rig.solve(&track.sample(900.milliseconds()))?;
316        assert_eq!(first, rig.solve(&track.sample(time))?);
317        assert_eq!(track.sample(crate::Time::ZERO), start);
318        assert_eq!(track.sample(1.seconds()), end);
319        assert_eq!(
320            start.interpolate(&end, 1.5).bones[0].rotation,
321            std::f64::consts::TAU * 1.5
322        );
323        Ok(())
324    }
325
326    #[test]
327    fn hierarchy_channels_and_overflow_are_validated() -> Result<(), ValidationError> {
328        assert!(Skeleton::new([Bone::new(Some(0), (0.0, 0.0))]).is_err());
329        assert!(
330            Skeleton::new([
331                Bone::new(Some(1), (0.0, 0.0)),
332                Bone::new(Some(0), (0.0, 0.0))
333            ])
334            .is_err()
335        );
336        assert!(Skeleton::new([Bone::new(None, (f64::NAN, 0.0))]).is_err());
337        let rig = Skeleton::new([Bone::new(None, (0.0, 0.0))])?;
338        for invalid_value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
339            let mut pose = Pose::default();
340            pose.bones[0].rotation = invalid_value;
341            assert!(rig.solve(&pose).is_err());
342            pose.bones[0] = BonePose {
343                translation: (invalid_value, 0.0),
344                rotation: 0.0,
345            };
346            assert!(rig.solve(&pose).is_err());
347        }
348        let large = Skeleton::new([
349            Bone::new(None, (f64::MAX, 0.0)),
350            Bone::new(Some(0), (f64::MAX, 0.0)),
351        ])?;
352        assert!(large.solve(&Pose::default()).is_err());
353        let mut pose = Pose::default();
354        pose.bones[0].rotation = f64::MAX;
355        pose.bones[1].rotation = f64::MAX;
356        let rotations =
357            Skeleton::new([Bone::new(None, (0.0, 0.0)), Bone::new(Some(0), (0.0, 0.0))])?;
358        assert!(rotations.solve(&pose).is_err());
359        let mut translated = Pose::default();
360        translated.bones[0].translation = (f64::MAX, 0.0);
361        assert!(large.solve(&translated).is_err());
362        Ok(())
363    }
364
365    #[test]
366    fn attachments_preserve_artwork_local_transforms() -> Result<(), ValidationError> {
367        let rig = Skeleton::new([Bone::new(None, (20.0, 30.0))])?;
368        let pose = Pose::new([BonePose {
369            translation: (2.0, 3.0),
370            rotation: FRAC_PI_2,
371        }]);
372        let joint = rig.solve(&pose)?[0];
373        let artwork = Node::from(Rect::new(0.0, 0.0, 10.0, 4.0).fill(Color::BLACK))
374            .pivot(Pivot::Point(Point::new(3.0, 2.0)))
375            .scale_xy(-1.2, 0.8)
376            .rotate(0.3)
377            .translate(7.0, 9.0);
378        let point = (4.0, 2.0);
379        let local = transform(&artwork).apply(Point::new(point.0, point.1));
380        let expected = joint.point((local.x, local.y));
381        let attached = joint.apply(artwork);
382        let actual = transform(&attached).apply(Point::new(point.0, point.1));
383        close((actual.x, actual.y), expected);
384        Ok(())
385    }
386
387    #[test]
388    fn independent_roots_empty_rigs_and_artwork_are_supported() -> Result<(), ValidationError> {
389        let rig = Skeleton::new([Bone::new(None, (2.0, 3.0)), Bone::new(None, (8.0, 9.0))])?;
390        let solved = rig.solve(&Pose::default())?;
391        close(solved[0].position(), (2.0, 3.0));
392        close(solved[1].position(), (8.0, 9.0));
393        let artwork = Node::from(Rect::new(0.0, 0.0, 10.0, 4.0).fill(Color::BLACK))
394            .rotate(0.5)
395            .translate(2.0, 3.0);
396        assert!(
397            solved[1]
398                .apply(artwork)
399                .compile(
400                    Size::new(64.0, 64.0),
401                    PixelSize::new(64, 64),
402                    ValidationLimits::default()
403                )
404                .is_ok()
405        );
406        let empty = Skeleton::new([])?;
407        assert_eq!(empty.solve(&Pose::default())?, []);
408        Ok(())
409    }
410}