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

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
[docs] @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
[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)