Source code for simvx.core.skeleton2d

"""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)