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
from abc import ABC, abstractmethod

import numpy as np

from .descriptors import Property
from .math import Transform2D
from .math.types import Vec2
from .node import Node, T
from .nodes_2d.node2d import Node2D
from .signals import Signal

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
# -- Tree hooks -----------------------------------------------------------
[docs] def add_child(self, node: T) -> T: result = super().add_child(node) skeleton = self.skeleton if skeleton is not None: skeleton._hierarchy_mutated(isinstance(node, Bone2D)) return result
[docs] def remove_child(self, node: Node) -> None: was_bone = isinstance(node, Bone2D) and node in self.children super().remove_child(node) skeleton = self.skeleton if skeleton is not None: skeleton._hierarchy_mutated(was_bone)
def _world_segment(self, local_offset: Vec2 | tuple[float, float]) -> tuple[float, float]: """World-space ``(length, angle)`` of an offset authored in this bone's local space. A child placed at *local_offset* lands exactly ``length`` along ``angle`` from this bone's ``world_position``, because :class:`Node2D` scales a child's local position by the parent's ``world_scale`` and only then rotates it by the parent's ``world_rotation``. Measuring a bone segment any other way (a raw ``bone_length``, a raw ``position``) disagrees with where the child actually is the moment the skeleton is scaled, which is why the IK solvers and :attr:`bone_tip` all go through here. The measurement holds under a non-uniform or mirrored scale as well: it is the same map the child positions themselves take, so the segment is where the rig is drawn, and its length and angle are both unaffected by this bone's own rotation. """ # An arithmetic operator on a Vec2 hands a Vec2 back at run time, but # numpy describes it as returning a plain array, so the wrap is what # carries the component names ``.x`` / ``.y`` through the operator. scaled = Vec2(Vec2(local_offset) * self.world_scale) length = float(math.hypot(scaled.x, scaled.y)) return length, self.world_rotation + math.atan2(scaled.y, scaled.x)
[docs] @property def bone_tip(self) -> Vec2: """Global position of the bone tip, ``bone_length`` along the bone's local x axis. Equivalently: where a child node at ``(bone_length, 0)`` sits, so the tip follows a scaled skeleton. ``world_rotation`` already includes ``bone_angle`` (applied in ``_recompute_global_transform``). """ length, angle = self._world_segment((self.bone_length, 0.0)) return Vec2(self.world_position + Vec2(math.cos(angle), math.sin(angle)) * 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 # ============================================================================ def _collect_stray_bones(node, out: list[Bone2D]) -> None: """Collect the topmost Bone2D nodes under a non-bone node. These are the bones a skeleton's membership rule excludes. A bone below one of them is excluded for the same reason, so reporting the topmost is enough and the walk stops there. """ for child in node.children: if isinstance(child, Bone2D): out.append(child) else: _collect_stray_bones(child, out)
[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. **Membership rule:** a bone belongs to this skeleton only when *every* node between it and the skeleton is itself a :class:`Bone2D`. A bone parented under a plain grouping :class:`~simvx.core.Node2D` is therefore not collected, and reading :attr:`bones` logs a warning naming it. The rule exists so a bone's index is a function of the bone hierarchy alone: inserting or removing an unrelated organiser node must not renumber the bones an animation or an IK modification refers to. Signals: bones_changed: Emitted when a bone is added to or removed from this skeleton. Adding or removing a non-bone node does not emit. Reparenting a bone within one skeleton emits twice, because reparenting is a removal followed by an addition. """ bones_changed = Signal() def __init__(self, **kwargs): super().__init__(**kwargs) self._modifications: list[SkeletonModification2D] = [] self._bone_cache: tuple[Bone2D, ...] | None = None # -- Bone enumeration ----------------------------------------------------- def _invalidate_bone_cache(self): self._bone_cache = None def _hierarchy_mutated(self, bone_changed: bool) -> None: """Drop the bone cache after a child mutation anywhere in the hierarchy. Args: bone_changed: True when the mutated child was a Bone2D, which is the only case ``bones_changed`` announces. The cache is dropped either way: a plain node can carry Bone2D children of its own, and the warning about them is recomputed with the bone list. """ self._invalidate_bone_cache() if bone_changed: self.bones_changed() def _collect_bones(self, node: Node2D, out: list[Bone2D], stray: list[Bone2D]) -> None: """Recursively collect Bone2D descendants in hierarchy (depth-first pre-order). Recurses through Bone2D children only, per the membership rule. Bones found under a non-bone child are appended to *stray* instead, so the caller can report them rather than dropping them in silence. """ for child in node.children: if isinstance(child, Bone2D): out.append(child) self._collect_bones(child, out, stray) else: _collect_stray_bones(child, stray)
[docs] @property def bones(self) -> tuple[Bone2D, ...]: """All Bone2D nodes in hierarchy order (cached). Bones excluded by the membership rule are reported through the log the first time the list is rebuilt after the hierarchy changes. """ if self._bone_cache is None: collected: list[Bone2D] = [] stray: list[Bone2D] = [] self._collect_bones(self, collected, stray) if stray: log.warning( "Skeleton2D %r ignores %d bone(s) parented under a non-bone node: %s. " "A bone counts as part of a skeleton only when every node between it and " "the skeleton is a Bone2D, so bone indices do not depend on unrelated tree " "structure. Parent it under the skeleton or under another bone.", self.name, len(stray), ", ".join(repr(bone.name) for bone in stray), ) self._bone_cache = tuple(collected) return 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, node: T) -> T: result = super().add_child(node) self._hierarchy_mutated(isinstance(node, Bone2D)) return result
[docs] def remove_child(self, node: Node) -> None: was_bone = isinstance(node, Bone2D) and node in self.children super().remove_child(node) self._hierarchy_mutated(was_bone)
[docs] def on_update(self, dt: float): """Execute IK modifications each frame.""" if self._modifications: self.execute_modifications(dt)
# ============================================================================ # Skeleton Modifications (IK solvers) # ============================================================================
[docs] class SkeletonModification2D(ABC): """Abstract base for 2D skeleton modifications (IK, constraints, etc.). Subclasses must implement :meth:`execute`. The base class cannot be instantiated directly: a subclass that forgets ``execute`` will fail at instantiation rather than silently no-opping. """ def __init__(self): self._skeleton: Skeleton2D | None = None self.enabled: bool = True
[docs] @property def skeleton(self) -> Skeleton2D | None: return self._skeleton
[docs] @abstractmethod def execute(self, delta: float) -> None: """Apply the modification to the bound skeleton. Implemented by subclasses.""" 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. The lower bone must be a direct child of the upper one, and both segments are measured in world space: the upper segment runs from the upper bone's origin to the joint (wherever ``lower.position`` puts it, which need not be ``upper.bone_length`` along the upper bone), and the lower segment runs one ``bone_length`` along the lower bone. The chain therefore solves under a scaled skeleton and under a joint offset from the parent's tip. 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. Raises: ValueError: If the two indices do not name a parent and its direct child, or if either segment measures zero. Both are rigs that cannot be solved, and both used to produce a silent wrong answer. """ 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] if lower.parent is not upper: raise ValueError( f"Two-bone IK needs an adjacent pair: bone {self.lower_bone_index} " f"({lower.name!r}) is not a direct child of bone {self.upper_bone_index} " f"({upper.name!r}). Bone indices come from a flat hierarchy walk, so any " f"two of them can be named; only a parent and its child form a chain." ) # The upper segment ends at the joint, which is where the lower bone # sits: bone_length is a drawing length and the two need not agree. # Both segments are measured in world space so a scaled rig solves. a, a_angle = upper._world_segment(lower.position) b, b_angle = lower._world_segment((lower.bone_length, 0.0)) if a <= 0.0: raise ValueError( f"Two-bone IK: bone {self.lower_bone_index} ({lower.name!r}) sits on top of its " f"parent {upper.name!r}, so the upper segment has no length or direction to solve." ) if b <= 0.0: raise ValueError( f"Two-bone IK: bone {self.lower_bone_index} ({lower.name!r}) reaches nothing: " f"bone_length {lower.bone_length} at world scale {tuple(lower.world_scale)} " f"measures zero. Set a positive bone_length." ) # Each segment leaves its bone at an angle of its own: the joint offset # for the upper bone, and (under a mirrored scale) a half turn for the # lower. Solving without them fixes the segment lengths and still points # both bones the wrong way. joint_offset = a_angle - upper.world_rotation tip_offset = b_angle - lower.world_rotation 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 # Each bone's world rotation also includes its own bone_angle, so # subtract that as well: rotation plus bone_angle lands on the solved angle. # The solved angles are those of the two segments, so each bone is turned # by its segment's own offset on top. upper.rotation = target_angle + sign * root_offset - joint_offset - parent_rot - upper.bone_angle lower.rotation = sign * (elbow_angle - math.pi) + joint_offset - tip_offset - lower.bone_angle