"""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).
"""
import logging
import math
import numpy as np
from .descriptors import Property, Signal
from .math import Transform2D
from .math.types import Vec2
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
@property
def bone_angle(self) -> float:
"""Pose angle offset from rest rotation (radians)."""
return self._bone_angle
[docs]
@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]
@property
def skeleton(self) -> Skeleton2D | None:
"""Parent Skeleton2D, found by walking up the tree."""
node = self.parent
while node is not None:
if isinstance(node, Skeleton2D):
return node
node = getattr(node, "parent", None)
return None
[docs]
@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]
@property
def bones(self) -> list[Bone2D]:
"""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)
[docs]
@property
def bone_count(self) -> int:
"""Number of Bone2D nodes in the hierarchy."""
return len(self.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.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.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.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.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.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
[docs]
@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.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.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)