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, ResourceStore, 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::Shape;
248    use crate::Signal;
249    use crate::{
250        Color, DrawCommand, Easing, Keyframes, PixelSize, Rect, Size, TimeLiteral, ValidationLimits,
251    };
252    use std::f64::consts::FRAC_PI_2;
253
254    fn transform(node: &Node) -> Transform {
255        let list = node
256            .compile(
257                Size::new(64.0, 64.0),
258                PixelSize::new(64, 64),
259                ValidationLimits::default(),
260            )
261            .unwrap();
262        list.commands
263            .iter()
264            .find_map(|command| match command {
265                DrawCommand::FillRect { transform, .. } => Some(*transform),
266                _ => None,
267            })
268            .unwrap()
269    }
270
271    fn close(actual: (f64, f64), expected: (f64, f64)) {
272        assert!(
273            (actual.0 - expected.0).abs() < 0.0001 && (actual.1 - expected.1).abs() < 0.0001,
274            "{actual:?} != {expected:?}"
275        );
276    }
277
278    #[test]
279    fn child_pivots_inherit_rotation_and_translation_but_not_sibling_pose()
280    -> Result<(), ValidationError> {
281        let rig = Skeleton::new([
282            Bone::new(None, (5.0, 7.0)),
283            Bone::new(Some(0), (10.0, 0.0)),
284            Bone::new(Some(1), (4.0, 0.0)),
285            Bone::new(Some(0), (0.0, 3.0)),
286        ])?;
287        let mut pose = Pose::default();
288        pose.bones[0] = BonePose {
289            translation: (2.0, 1.0),
290            rotation: FRAC_PI_2,
291        };
292        pose.bones[1] = BonePose {
293            translation: (2.0, 0.0),
294            rotation: -FRAC_PI_2,
295        };
296        let solved = rig.solve(&pose)?;
297        close(solved[0].position(), (7.0, 8.0));
298        close(solved[1].position(), (7.0, 20.0));
299        close(solved[2].position(), (11.0, 20.0));
300        close(solved[3].position(), (4.0, 8.0));
301        close(solved[1].point((3.0, 0.0)), (10.0, 20.0));
302        assert!(solved[1].rotation().abs() < 0.0001);
303        Ok(())
304    }
305
306    #[test]
307    fn pose_tracks_are_repeatable_and_keep_unwrapped_turns() -> Result<(), ValidationError> {
308        let rig = Skeleton::new([Bone::new(None, (0.0, 0.0)), Bone::new(Some(0), (10.0, 0.0))])?;
309        let start = Pose::default();
310        let mut end = start.clone();
311        end.bones[0].rotation = std::f64::consts::TAU;
312        let track = Keyframes::new(start.clone()).to(1.seconds(), end.clone(), Easing::Linear);
313        let time = 250.milliseconds().after_origin();
314        let first = rig.solve(&track.sample(time))?;
315        close(first[1].position(), (0.0, 10.0));
316        let _later = rig.solve(&track.sample(900.milliseconds().after_origin()))?;
317        assert_eq!(first, rig.solve(&track.sample(time))?);
318        assert_eq!(track.sample(crate::Time::ZERO), start);
319        assert_eq!(track.sample(1.seconds().after_origin()), end);
320        assert_eq!(
321            start.interpolate(&end, 1.5).bones[0].rotation,
322            std::f64::consts::TAU * 1.5
323        );
324        Ok(())
325    }
326
327    #[test]
328    fn hierarchy_channels_and_overflow_are_validated() -> Result<(), ValidationError> {
329        assert!(Skeleton::new([Bone::new(Some(0), (0.0, 0.0))]).is_err());
330        assert!(
331            Skeleton::new([
332                Bone::new(Some(1), (0.0, 0.0)),
333                Bone::new(Some(0), (0.0, 0.0))
334            ])
335            .is_err()
336        );
337        assert!(Skeleton::new([Bone::new(None, (f64::NAN, 0.0))]).is_err());
338        let rig = Skeleton::new([Bone::new(None, (0.0, 0.0))])?;
339        for invalid_value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
340            let mut pose = Pose::default();
341            pose.bones[0].rotation = invalid_value;
342            assert!(rig.solve(&pose).is_err());
343            pose.bones[0] = BonePose {
344                translation: (invalid_value, 0.0),
345                rotation: 0.0,
346            };
347            assert!(rig.solve(&pose).is_err());
348        }
349        let large = Skeleton::new([
350            Bone::new(None, (f64::MAX, 0.0)),
351            Bone::new(Some(0), (f64::MAX, 0.0)),
352        ])?;
353        assert!(large.solve(&Pose::default()).is_err());
354        let mut pose = Pose::default();
355        pose.bones[0].rotation = f64::MAX;
356        pose.bones[1].rotation = f64::MAX;
357        let rotations =
358            Skeleton::new([Bone::new(None, (0.0, 0.0)), Bone::new(Some(0), (0.0, 0.0))])?;
359        assert!(rotations.solve(&pose).is_err());
360        let mut translated = Pose::default();
361        translated.bones[0].translation = (f64::MAX, 0.0);
362        assert!(large.solve(&translated).is_err());
363        Ok(())
364    }
365
366    #[test]
367    fn attachments_preserve_artwork_local_transforms() -> Result<(), ValidationError> {
368        let rig = Skeleton::new([Bone::new(None, (20.0, 30.0))])?;
369        let pose = Pose::new([BonePose {
370            translation: (2.0, 3.0),
371            rotation: FRAC_PI_2,
372        }]);
373        let joint = rig.solve(&pose)?[0];
374        let artwork = Node::from(Rect::new(0.0, 0.0, 10.0, 4.0).fill(Color::BLACK))
375            .pivot(Pivot::Point(Point::new(3.0, 2.0)))
376            .scale_xy(-1.2, 0.8)
377            .rotate(0.3)
378            .translate(7.0, 9.0);
379        let point = (4.0, 2.0);
380        let local = transform(&artwork).apply(Point::new(point.0, point.1));
381        let expected = joint.point((local.x, local.y));
382        let attached = joint.apply(artwork);
383        let actual = transform(&attached).apply(Point::new(point.0, point.1));
384        close((actual.x, actual.y), expected);
385        Ok(())
386    }
387
388    #[test]
389    fn independent_roots_empty_rigs_and_artwork_are_supported() -> Result<(), ValidationError> {
390        let rig = Skeleton::new([Bone::new(None, (2.0, 3.0)), Bone::new(None, (8.0, 9.0))])?;
391        let solved = rig.solve(&Pose::default())?;
392        close(solved[0].position(), (2.0, 3.0));
393        close(solved[1].position(), (8.0, 9.0));
394        let artwork = Node::from(Rect::new(0.0, 0.0, 10.0, 4.0).fill(Color::BLACK))
395            .rotate(0.5)
396            .translate(2.0, 3.0);
397        assert!(
398            solved[1]
399                .apply(artwork)
400                .compile(
401                    Size::new(64.0, 64.0),
402                    PixelSize::new(64, 64),
403                    ValidationLimits::default()
404                )
405                .is_ok()
406        );
407        let empty = Skeleton::new([])?;
408        assert_eq!(empty.solve(&Pose::default())?, []);
409        Ok(())
410    }
411}