"""2D skeletal animation: Skeleton2D, Bone2D, and IK modifications.
Spine/DragonBones-style 2D skeletal animation built on the Node2D hierarchy.
Bones are Node2D nodes that add rest-pose semantics; a Skeleton2D groups them
and provides batch operations (reset, enumerate, IK).
"""
from __future__ import annotations
import logging
import math
import numpy as np
from .descriptors import Property, Signal
from .math.types import Vec2
from .math_types import Transform2D
from .nodes_2d.node2d import Node2D
log = logging.getLogger(__name__)
__all__ = [
"Bone2D",
"Skeleton2D",
"SkeletonModification2D",
"SkeletonModification2DCCDIK",
"SkeletonModification2DTwoBoneIK",
]
# ============================================================================
# Bone2D
# ============================================================================
[docs]
class Bone2D(Node2D):
"""A bone in a 2D skeleton hierarchy.
Extends Node2D with a *rest transform* — the bone's default pose.
Skeletal animation works by offsetting the bone from its rest pose.
Attributes:
rest_transform: Default pose (Transform2D).
bone_length: Visual length of the bone (pixels).
bone_angle: Additional angle applied on top of the rest rotation (radians).
"""
bone_length = Property(0.0, range=(0, 10000), hint="Visual bone length in pixels")
def __init__(self, bone_length: float = 0.0, rest_transform: Transform2D | None = None, **kwargs):
super().__init__(**kwargs)
self._rest_transform = rest_transform or Transform2D()
self._bone_angle: float = 0.0
self.bone_length = bone_length
# -- Rest transform -------------------------------------------------------
@property
def rest_transform(self) -> Transform2D:
"""The bone's rest (bind) pose."""
return self._rest_transform
@rest_transform.setter
def rest_transform(self, value: Transform2D):
self._rest_transform = value
self._invalidate_transform()
@property
def bone_angle(self) -> float:
"""Pose angle offset from rest rotation (radians)."""
return self._bone_angle
@bone_angle.setter
def bone_angle(self, value: float):
self._bone_angle = float(value)
self._invalidate_transform()
# -- Pose helpers ---------------------------------------------------------
[docs]
def apply_pose(self, angle: float, length: float | None = None) -> None:
"""Set the bone to a specific pose.
Args:
angle: Rotation offset from rest pose (radians).
length: Optional override for bone_length.
"""
self.bone_angle = angle
if length is not None:
self.bone_length = length
[docs]
def reset_to_rest(self) -> None:
"""Reset this bone to its rest transform."""
self.position = Vec2(self._rest_transform.position)
self.rotation = self._rest_transform.rotation
self.scale = Vec2(self._rest_transform.scale)
self._bone_angle = 0.0
self._invalidate_transform()
[docs]
def set_as_rest(self) -> None:
"""Capture current local transform as the new rest pose."""
self._rest_transform = Transform2D(
position=tuple(self._position),
rotation=self._rotation,
scale=tuple(self._scale),
)
self._bone_angle = 0.0
[docs]
def get_skeleton(self) -> Skeleton2D | None:
"""Walk up the tree to find the parent Skeleton2D."""
node = self.parent
while node is not None:
if isinstance(node, Skeleton2D):
return node
node = getattr(node, "parent", None)
return None
@property
def bone_tip(self) -> Vec2:
"""Global position of the bone tip (end point), computed from length and rotation."""
# world_rotation already includes bone_angle (applied in _recompute_global_transform)
angle = self.world_rotation
return self.world_position + Vec2(math.cos(angle), math.sin(angle)) * self.bone_length
# -- Transform override ---------------------------------------------------
def _recompute_global_transform(self):
"""Apply bone_angle on top of normal Node2D transform."""
super()._recompute_global_transform()
# Bone angle is an *additional* rotation layered on top of the local rotation
if self._bone_angle != 0.0:
self._cached_world_rotation += self._bone_angle
# ============================================================================
# Skeleton2D
# ============================================================================
[docs]
class Skeleton2D(Node2D):
"""Container node for a 2D bone hierarchy.
Holds Bone2D children (which may themselves have Bone2D children) and
provides batch operations and IK modification support.
Signals:
bones_changed: Emitted when the bone hierarchy changes.
"""
bones_changed = Signal()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._modifications: list[SkeletonModification2D] = []
self._bone_cache: list[Bone2D] | None = None
# -- Bone enumeration -----------------------------------------------------
def _invalidate_bone_cache(self):
self._bone_cache = None
def _collect_bones(self, node: Node2D, out: list[Bone2D]) -> None:
"""Recursively collect Bone2D descendants in hierarchy (depth-first pre-order)."""
for child in node.children:
if isinstance(child, Bone2D):
out.append(child)
self._collect_bones(child, out)
[docs]
def get_bones(self) -> list[Bone2D]:
"""Return all Bone2D nodes in hierarchy order (cached)."""
if self._bone_cache is None:
self._bone_cache = []
self._collect_bones(self, self._bone_cache)
return list(self._bone_cache)
@property
def bone_count(self) -> int:
"""Number of Bone2D nodes in the hierarchy."""
return len(self.get_bones())
[docs]
def get_bone(self, index: int) -> Bone2D:
"""Get Bone2D by index in hierarchy order.
Raises:
IndexError: If index is out of range.
"""
bones = self.get_bones()
if index < 0 or index >= len(bones):
raise IndexError(f"Bone index {index} out of range (bone_count={len(bones)})")
return bones[index]
[docs]
def find_bone(self, name: str) -> Bone2D | None:
"""Find a bone by name. Returns None if not found."""
for bone in self.get_bones():
if bone.name == name:
return bone
return None
[docs]
def find_bone_index(self, name: str) -> int:
"""Find bone index by name. Returns -1 if not found."""
for i, bone in enumerate(self.get_bones()):
if bone.name == name:
return i
return -1
# -- Rest pose management -------------------------------------------------
[docs]
def set_bone_rest(self, index: int, transform: Transform2D) -> None:
"""Set the rest pose for the bone at *index*."""
bone = self.get_bone(index)
bone.rest_transform = transform
[docs]
def reset_to_rest(self) -> None:
"""Reset every bone in the skeleton to its rest pose."""
for bone in self.get_bones():
bone.reset_to_rest()
[docs]
def capture_rest(self) -> None:
"""Capture the current pose of all bones as the rest pose."""
for bone in self.get_bones():
bone.set_as_rest()
# -- IK modifications -----------------------------------------------------
[docs]
def add_modification(self, mod: SkeletonModification2D) -> None:
"""Register a skeleton modification (e.g. IK solver)."""
mod._skeleton = self
self._modifications.append(mod)
[docs]
def remove_modification(self, mod: SkeletonModification2D) -> None:
"""Remove a previously registered modification."""
self._modifications.remove(mod)
mod._skeleton = None
[docs]
def execute_modifications(self, delta: float) -> None:
"""Run all registered modifications (call from process or manually)."""
for mod in self._modifications:
if mod.enabled:
mod.execute(delta)
# -- Tree hooks -----------------------------------------------------------
[docs]
def add_child(self, child, **kwargs):
super().add_child(child, **kwargs)
self._invalidate_bone_cache()
[docs]
def remove_child(self, child):
super().remove_child(child)
self._invalidate_bone_cache()
[docs]
def process(self, dt: float):
"""Execute IK modifications each frame."""
if self._modifications:
self.execute_modifications(dt)
# ============================================================================
# Skeleton Modifications (IK solvers)
# ============================================================================
[docs]
class SkeletonModification2D:
"""Base class for 2D skeleton modifications (IK, constraints, etc.)."""
def __init__(self):
self._skeleton: Skeleton2D | None = None
self.enabled: bool = True
@property
def skeleton(self) -> Skeleton2D | None:
return self._skeleton
[docs]
def execute(self, delta: float) -> None:
"""Override in subclasses to apply the modification."""
raise NotImplementedError
[docs]
class SkeletonModification2DCCDIK(SkeletonModification2D):
"""Cyclic Coordinate Descent IK for 2D bone chains.
Iteratively rotates each bone in the chain (from tip to root) to point
toward the target. Converges quickly for simple chains.
Attributes:
target: World-space target position (Vec2).
tip_bone_index: Index of the end-effector bone.
chain_length: Number of bones in the chain (walking up from tip).
max_iterations: CCD iterations per execute call.
tolerance: Distance threshold to consider the target reached.
"""
def __init__(
self,
tip_bone_index: int = 0,
chain_length: int = 2,
max_iterations: int = 10,
tolerance: float = 1.0,
):
super().__init__()
self.target: Vec2 = Vec2()
self.tip_bone_index = tip_bone_index
self.chain_length = chain_length
self.max_iterations = max_iterations
self.tolerance = tolerance
def _get_chain(self) -> list[Bone2D]:
"""Build the bone chain from tip bone walking up through parents."""
if not self._skeleton:
return []
bones = self._skeleton.get_bones()
if self.tip_bone_index >= len(bones):
return []
chain: list[Bone2D] = []
bone = bones[self.tip_bone_index]
for _ in range(self.chain_length):
if not isinstance(bone, Bone2D):
break
chain.append(bone)
bone = bone.parent
if bone is None or isinstance(bone, Skeleton2D):
break
return chain
[docs]
def execute(self, delta: float) -> None:
"""Run CCD IK iterations."""
if not self._skeleton:
return
chain = self._get_chain()
if not chain:
return
for _ in range(self.max_iterations):
# Check convergence using the tip bone's tip position
tip_pos = chain[0].bone_tip
diff = self.target - tip_pos
if float(np.dot(diff, diff)) < self.tolerance * self.tolerance:
break
# CCD: iterate from tip to root
for bone in chain:
bone_pos = bone.world_position
to_tip = chain[0].bone_tip - bone_pos
to_target = self.target - bone_pos
# Compute angle between the two vectors
angle_tip = math.atan2(to_tip.y, to_tip.x)
angle_target = math.atan2(to_target.y, to_target.x)
angle_diff = angle_target - angle_tip
# Normalise to [-pi, pi]
angle_diff = math.atan2(math.sin(angle_diff), math.cos(angle_diff))
bone.rotation += angle_diff
[docs]
class SkeletonModification2DTwoBoneIK(SkeletonModification2D):
"""Analytical two-bone IK solver.
Given a two-bone chain (upper + lower), computes exact joint angles
using the law of cosines. Faster and more stable than CCD for exactly
two bones.
Attributes:
target: World-space target position (Vec2).
upper_bone_index: Index of the upper (root-side) bone.
lower_bone_index: Index of the lower (tip-side) bone.
flip: Mirror the elbow direction.
"""
def __init__(self, upper_bone_index: int = 0, lower_bone_index: int = 1, flip: bool = False):
super().__init__()
self.target: Vec2 = Vec2()
self.upper_bone_index = upper_bone_index
self.lower_bone_index = lower_bone_index
self.flip = flip
[docs]
def execute(self, delta: float) -> None:
"""Solve two-bone IK analytically."""
if not self._skeleton:
return
bones = self._skeleton.get_bones()
if self.upper_bone_index >= len(bones) or self.lower_bone_index >= len(bones):
return
upper = bones[self.upper_bone_index]
lower = bones[self.lower_bone_index]
a = upper.bone_length # upper bone length
b = lower.bone_length # lower bone length
if a <= 0 or b <= 0:
return
origin = upper.world_position
to_target = self.target - origin
c = float(np.sqrt(np.dot(to_target, to_target))) # distance to target
if c < 1e-6:
return
# Clamp to reachable range
c = min(c, a + b - 1e-6)
c = max(c, abs(a - b) + 1e-6)
# Law of cosines: angle at the "elbow" joint
cos_angle_b = (a * a + b * b - c * c) / (2.0 * a * b)
cos_angle_b = max(-1.0, min(1.0, cos_angle_b))
elbow_angle = math.acos(cos_angle_b)
# Angle at the root joint
cos_angle_a = (a * a + c * c - b * b) / (2.0 * a * c)
cos_angle_a = max(-1.0, min(1.0, cos_angle_a))
root_offset = math.acos(cos_angle_a)
# Angle from root to target
target_angle = math.atan2(to_target.y, to_target.x)
# Apply flip
sign = -1.0 if self.flip else 1.0
# Subtract the parent's global rotation so we set *local* rotation
parent_rot = 0.0
if upper.parent and isinstance(upper.parent, Node2D):
parent_rot = upper.parent.world_rotation
upper.rotation = target_angle + sign * root_offset - parent_rot
lower.rotation = sign * (elbow_angle - math.pi)