Skeleton2D¶

bone hierarchies, rest poses and two-bone IK.

â–¶ Run in browser

Tags: 2d skeleton bones animation ik procedural

An articulated figure built entirely from Bone2D nodes, with no art assets: each bone carries a tapered Polygon2D limb as a child, so the bone hierarchy alone places the geometry. The spine and neck sway from a pose offset, the right arm waves from an AnimationClip, and the left arm is driven by an analytical two-bone IK solver that keeps the hand on a moving target while the whole torso rotates underneath it.

What it demonstrates¶

  • Building a rig from a declarative table: Bone2D parented to Bone2D, each one offset along its parent by a fraction of the parent’s bone_length.

  • set_as_rest() to capture the authored pose, and reset_to_rest() to snap the whole skeleton back to it.

  • The two rotation channels a bone has: rotation is the authored local angle, bone_angle is a pose offset layered on top. The sway and the wave both drive bone_angle, so neither one destroys the rest pose.

  • Attaching drawables to bones. The limbs and the skull are ordinary Polygon2D children; nothing recomputes their positions, the transform hierarchy does it.

  • SkeletonModification2DTwoBoneIK registered with add_modification(), which Skeleton2D.on_update then runs every frame without any calling code.

  • bone_tip for the end point of a bone, used both by the IK check and by the gizmo overlay.

Controls: SPACE - Wave the right arm G - Toggle the bone gizmos F - Flip the IK elbow R - Reset the skeleton to its rest pose ESC - Quit

Run: uv run python examples/features/2d/skeleton2d.py Headless self-check: uv run python examples/features/2d/skeleton2d.py –test

Source¶

  1"""Skeleton2D: bone hierarchies, rest poses and two-bone IK.
  2
  3An articulated figure built entirely from Bone2D nodes, with no art assets: each
  4bone carries a tapered Polygon2D limb as a child, so the bone hierarchy alone
  5places the geometry. The spine and neck sway from a pose offset, the right arm
  6waves from an AnimationClip, and the left arm is driven by an analytical
  7two-bone IK solver that keeps the hand on a moving target while the whole torso
  8rotates underneath it.
  9
 10# /// simvx
 11# tags = ["2d", "skeleton", "bones", "animation", "ik", "procedural"]
 12# web = { root = "SkeletonDemo", width = 800, height = 600, responsive = true }
 13# ///
 14
 15## What it demonstrates
 16- Building a rig from a declarative table: Bone2D parented to Bone2D, each one
 17  offset along its parent by a fraction of the parent's `bone_length`.
 18- `set_as_rest()` to capture the authored pose, and `reset_to_rest()` to snap the
 19  whole skeleton back to it.
 20- The two rotation channels a bone has: `rotation` is the authored local angle,
 21  `bone_angle` is a pose offset layered on top. The sway and the wave both drive
 22  `bone_angle`, so neither one destroys the rest pose.
 23- Attaching drawables to bones. The limbs and the skull are ordinary Polygon2D
 24  children; nothing recomputes their positions, the transform hierarchy does it.
 25- `SkeletonModification2DTwoBoneIK` registered with `add_modification()`, which
 26  `Skeleton2D.on_update` then runs every frame without any calling code.
 27- `bone_tip` for the end point of a bone, used both by the IK check and by the
 28  gizmo overlay.
 29
 30Controls:
 31  SPACE - Wave the right arm
 32  G     - Toggle the bone gizmos
 33  F     - Flip the IK elbow
 34  R     - Reset the skeleton to its rest pose
 35  ESC   - Quit
 36
 37Run: uv run python examples/features/2d/skeleton2d.py
 38Headless self-check: uv run python examples/features/2d/skeleton2d.py --test
 39"""
 40
 41import math
 42from typing import NamedTuple
 43
 44from simvx.core import (
 45    AnimationClip,
 46    AnimationPlayer,
 47    Bone2D,
 48    Input,
 49    InputMap,
 50    Key,
 51    Line2D,
 52    Node2D,
 53    Polygon2D,
 54    Skeleton2D,
 55    SkeletonModification2DTwoBoneIK,
 56    Vec2,
 57)
 58from simvx.graphics import App
 59
 60WIDTH, HEIGHT = 800, 600
 61HIP = Vec2(WIDTH * 0.44, HEIGHT * 0.60)  # where the skeleton node itself sits
 62
 63LIMB = (0.62, 0.68, 0.86, 1.0)
 64TORSO = (0.42, 0.52, 0.78, 1.0)
 65SKIN = (0.92, 0.78, 0.62, 1.0)
 66GIZMO = (0.15, 0.95, 0.75, 1.0)
 67JOINT = (1.0, 0.85, 0.25, 1.0)
 68TARGET = (1.0, 0.42, 0.32, 1.0)
 69
 70
 71class BoneSpec(NamedTuple):
 72    """One bone in the rig, authored relative to its parent."""
 73
 74    name: str
 75    parent: str | None
 76    angle: float  # local rotation in degrees, relative to the parent bone
 77    length: float
 78    root_w: float  # limb width at the joint
 79    tip_w: float  # limb width at the far end
 80    attach: float = 1.0  # fraction along the parent bone where this bone starts
 81    colour: tuple = LIMB
 82
 83
 84#: Angles are screen-space: 0 points right, +90 points down. The spine at -90
 85#: therefore points up, and every child angle is read relative to that.
 86RIG = (
 87    BoneSpec("spine", None, -90, 130, 48, 34, colour=TORSO),
 88    BoneSpec("neck", "spine", 0, 30, 18, 16, colour=SKIN),
 89    BoneSpec("arm_r", "spine", 118, 78, 18, 14, attach=0.84),
 90    BoneSpec("forearm_r", "arm_r", 34, 70, 14, 10),
 91    BoneSpec("arm_l", "spine", -118, 78, 18, 14, attach=0.84),
 92    # The two-bone IK solver measures the upper segment to wherever the lower
 93    # bone actually sits, so an IK pair may attach anywhere along its parent.
 94    # This elbow is at the tip only because that is where an elbow goes.
 95    BoneSpec("forearm_l", "arm_l", -34, 70, 14, 10),
 96    BoneSpec("thigh_r", None, 72, 92, 24, 18),
 97    BoneSpec("shin_r", "thigh_r", 20, 86, 18, 12),
 98    BoneSpec("thigh_l", None, 108, 92, 24, 18),
 99    BoneSpec("shin_l", "thigh_l", -20, 86, 18, 12),
100)
101
102SKULL_R = 27.0
103GIZMO_Z = 10  # gizmo overlay sorts above the limbs
104
105WAVE_CLIP = 1.6  # seconds
106SWAY_SPEED = 1.7
107
108#: The IK target orbits here, in skeleton-local pixels. The left shoulder sits
109#: about 109px above the hip and the arm reaches 148px, so this orbit (centre 92px
110#: from the shoulder, radius 42) stays inside the reachable ring. A target outside
111#: it does not fail: the solver clamps to the ring and the hand stops visibly short.
112TARGET_CENTRE = Vec2(-84, -72)
113TARGET_RADIUS = 42.0
114TARGET_ORBIT = 3.0  # seconds per revolution
115
116
117def _disc(centre: Vec2, radius: float, segments: int = 24) -> list[tuple[float, float]]:
118    """Polygon vertices approximating a circle, in a bone's local space."""
119    step = math.tau / segments
120    return [(centre.x + math.cos(i * step) * radius, centre.y + math.sin(i * step) * radius) for i in range(segments)]
121
122
123def _limb(spec: BoneSpec) -> list[tuple[float, float]]:
124    """A tapered quad running from the bone's origin to its tip."""
125    return [
126        (0.0, -spec.root_w / 2),
127        (spec.length, -spec.tip_w / 2),
128        (spec.length, spec.tip_w / 2),
129        (0.0, spec.root_w / 2),
130    ]
131
132
133class SkeletonDemo(Node2D):
134    """A Bone2D rig posed by a sway, an animation clip and an IK solver."""
135
136    dynamic = True  # every bone moves every frame
137
138    def on_ready(self):
139        InputMap.add_action("wave", [Key.SPACE])
140        InputMap.add_action("gizmos", [Key.G])
141        InputMap.add_action("flip", [Key.F])
142        InputMap.add_action("reset", [Key.R])
143        InputMap.add_action("quit", [Key.ESCAPE])
144
145        self._t = 0.0
146        self._show_gizmos = True
147
148        self._skeleton = Skeleton2D(name="Figure", position=HIP)
149        self.add_child(self._skeleton)
150        self._bones = self._build_rig()
151
152        # A moving IK target, drawn as a disc. It lives on the scene root rather
153        # than on the skeleton, because the solver reads it in world space.
154        self._target = Polygon2D(name="Target", polygon=_disc(Vec2(0, 0), 9), colour=TARGET, z_index=GIZMO_Z)
155        self.add_child(self._target)
156
157        # Two-bone IK on the left arm. Registering it with the skeleton is all
158        # that is needed: Skeleton2D.on_update executes it every frame.
159        self._ik = SkeletonModification2DTwoBoneIK(
160            upper_bone_index=self._skeleton.find_bone_index("arm_l"),
161            lower_bone_index=self._skeleton.find_bone_index("forearm_l"),
162        )
163        self._skeleton.add_modification(self._ik)
164
165        # The wave drives bone_angle, a pose offset, so the arm's authored rest
166        # rotation survives it and the clip keys stay small and readable.
167        self._wave_upper = self._add_wave_player(self._bones["arm_r"], [0.0, -1.35, -0.95, -1.35, 0.0])
168        self._wave_lower = self._add_wave_player(self._bones["forearm_r"], [0.0, -0.75, -0.15, -0.75, 0.0])
169
170    # -- Rig construction ----------------------------------------------------
171
172    def _build_rig(self) -> dict[str, Bone2D]:
173        """Instantiate RIG, attaching a limb polygon and a gizmo to every bone."""
174        bones: dict[str, Bone2D] = {}
175        for spec in RIG:
176            parent = bones[spec.parent] if spec.parent else self._skeleton
177            offset = Vec2(bones[spec.parent].bone_length * spec.attach, 0) if spec.parent else Vec2(0, 0)
178            bone = Bone2D(
179                name=spec.name,
180                bone_length=spec.length,
181                position=offset,
182                rotation=math.radians(spec.angle),
183            )
184            # The authored pose becomes the rest pose, so reset_to_rest() returns
185            # here rather than to an unposed heap at the origin.
186            bone.set_as_rest()
187            parent.add_child(bone)
188
189            bone.add_child(Polygon2D(name=f"{spec.name}_limb", polygon=_limb(spec), colour=spec.colour))
190            bone.add_child(
191                Line2D(
192                    name=f"{spec.name}_gizmo",
193                    points=[(0, 0), (spec.length, 0)],
194                    colour=GIZMO,
195                    z_index=GIZMO_Z,
196                )
197            )
198            bone.add_child(
199                Polygon2D(
200                    name=f"{spec.name}_joint",
201                    polygon=_disc(Vec2(0, 0), 4, segments=8),
202                    colour=JOINT,
203                    z_index=GIZMO_Z,
204                )
205            )
206            bones[spec.name] = bone
207
208        # The skull rides the neck bone, offset to sit on its tip. Because the
209        # offset is the node's position rather than baked into the vertices, the
210        # skull's world position stays exactly neck.bone_tip for free.
211        neck = bones["neck"]
212        neck.add_child(
213            Polygon2D(
214                name="skull",
215                position=Vec2(neck.bone_length, 0),
216                polygon=_disc(Vec2(0, 0), SKULL_R),
217                colour=SKIN,
218            )
219        )
220        return bones
221
222    def _add_wave_player(self, bone: Bone2D, keys: list[float]) -> AnimationPlayer:
223        """An AnimationPlayer that sweeps one bone's pose offset and returns to rest."""
224        clip = AnimationClip(f"wave_{bone.name}", duration=WAVE_CLIP)
225        clip.add_track("bone_angle", [(WAVE_CLIP * i / (len(keys) - 1), v) for i, v in enumerate(keys)])
226        player = AnimationPlayer(target=bone, name=f"player_{bone.name}")
227        player.add_clip(clip)
228        bone.add_child(player)
229        return player
230
231    # -- Per-frame -----------------------------------------------------------
232
233    @property
234    def _waving(self) -> bool:
235        return self._wave_upper.playing
236
237    def on_update(self, dt: float):
238        if Input.is_action_just_pressed("quit"):
239            self.app.quit()
240        if Input.is_action_just_pressed("wave") and not self._waving:
241            self._wave_upper.play(f"wave_{self._bones['arm_r'].name}")
242            self._wave_lower.play(f"wave_{self._bones['forearm_r'].name}")
243        if Input.is_action_just_pressed("gizmos"):
244            self.set_gizmos(not self._show_gizmos)
245        if Input.is_action_just_pressed("flip"):
246            self._ik.flip = not self._ik.flip
247        if Input.is_action_just_pressed("reset"):
248            self.reset()
249
250        self._t += dt
251        self._sway()
252        self._move_target()
253
254    def _sway(self) -> None:
255        """Layer an idle sway onto the rest pose via bone_angle."""
256        s = math.sin(self._t * SWAY_SPEED)
257        self._bones["spine"].bone_angle = s * 0.055
258        self._bones["neck"].bone_angle = -s * 0.09
259        # The right arm yields its pose channel to the wave clip while it plays.
260        if not self._waving:
261            self._bones["arm_r"].bone_angle = s * 0.12
262            self._bones["forearm_r"].bone_angle = s * 0.16
263        # The left arm is IK-driven and takes no pose offset: the solver owns
264        # both joints, so an animated bone_angle here would fight it for the
265        # same channel every frame.
266
267    def _move_target(self) -> None:
268        """Orbit the IK target and hand its world position to the solver."""
269        a = self._t * math.tau / TARGET_ORBIT
270        self._target.position = HIP + TARGET_CENTRE + Vec2(math.cos(a), math.sin(a)) * TARGET_RADIUS
271        self._ik.target = Vec2(self._target.world_position)
272
273    # -- Actions -------------------------------------------------------------
274
275    def set_gizmos(self, on: bool) -> None:
276        """Show or hide the bone overlay by toggling the gizmo nodes' visibility."""
277        self._show_gizmos = on
278        for bone in self._skeleton.bones:
279            for child in bone.children:
280                if child.name.endswith(("_gizmo", "_joint")):
281                    child.visible = on
282
283    def reset(self) -> None:
284        """Stop the wave and put every bone back on its rest transform."""
285        self._wave_upper.stop()
286        self._wave_lower.stop()
287        self._skeleton.reset_to_rest()
288        self._t = 0.0
289
290    # -- HUD -----------------------------------------------------------------
291
292    def on_draw(self, renderer):
293        hand = self._bones["forearm_l"].bone_tip
294        reach = float(math.dist(tuple(hand), tuple(self._ik.target)))
295        renderer.draw_text("Skeleton2D", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
296        renderer.draw_text(
297            f"{self._skeleton.bone_count} bones   IK error: {reach:5.1f}px   elbow: "
298            f"{'flipped' if self._ik.flip else 'normal'}   gizmos: {'on' if self._show_gizmos else 'off'}",
299            (10, 44),
300            colour=(0.75, 0.78, 0.85),
301        )
302        renderer.draw_text(
303            "waving" if self._waving else "idle sway",
304            (10, 68),
305            colour=(1.0, 0.85, 0.35) if self._waving else (0.6, 0.62, 0.68),
306        )
307        renderer.draw_text(
308            "SPACE: wave   G: gizmos   F: flip elbow   R: rest pose   ESC: quit",
309            (10, HEIGHT - 28),
310            colour=(0.55, 0.57, 0.62),
311        )
312
313
314def _selftest() -> bool:
315    """Headless: check the rig, the pose channels, the IK and the rendered result."""
316    from simvx.core.testing import InputSimulator
317    from simvx.graphics.testing import assert_not_blank, save_png
318
319    FRAMES = 240
320    app = App(title="Skeleton2D", width=WIDTH, height=HEIGHT, visible=False)
321    scene = SkeletonDemo(name="SkeletonDemo")
322    sim = InputSimulator()
323
324    skull_track: list[Vec2] = []
325    ik_error: list[float] = []
326    spine_world: list[float] = []
327    arm_r_angle: list[float] = []
328    after_reset: list[tuple[float, float]] = []
329
330    def on_frame(idx: int, _t: float) -> bool:
331        bones = scene._bones
332        skull_track.append(Vec2(bones["neck"].bone_tip))
333        hand = bones["forearm_l"].bone_tip
334        ik_error.append(float(math.dist(tuple(hand), tuple(scene._ik.target))))
335        spine_world.append(float(bones["spine"].world_rotation))
336        arm_r_angle.append(float(bones["arm_r"].bone_angle))
337
338        if idx == 60:
339            sim.press_key(Key.SPACE)
340        elif idx == 61:
341            sim.release_key(Key.SPACE)
342        elif idx == 200:
343            sim.press_key(Key.R)
344        elif idx == 201:
345            sim.release_key(Key.R)
346        elif idx in (202, 203):
347            after_reset.append((float(bones["spine"].bone_angle), float(bones["arm_r"].bone_angle)))
348        return True
349
350    frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[0, 90])
351    assert_not_blank(frames[0])
352    save_png(frames[0], "/tmp/skeleton2d_test.png")
353
354    ok = True
355
356    def check(label: str, passed: bool, detail: str) -> None:
357        nonlocal ok
358        ok = ok and passed
359        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
360
361    bones = scene._bones
362    skel = scene._skeleton
363
364    check(
365        "the rig built every bone in RIG", skel.bone_count == len(RIG), f"{skel.bone_count} bones for {len(RIG)} specs"
366    )
367    check(
368        "bones are reachable by name and in depth-first order",
369        skel.bones[0] is bones["spine"] and skel.find_bone("forearm_l") is bones["forearm_l"],
370        f"first bone is {skel.bones[0].name}",
371    )
372    rest_ok = all(abs(bones[s.name].rest_transform.rotation - math.radians(s.angle)) < 1e-5 for s in RIG)
373    check("set_as_rest captured the authored angles", rest_ok, "every rest_transform matches its BoneSpec")
374
375    # The skull is a plain Polygon2D child of the neck bone, centred on the tip.
376    skull = next(c for c in bones["neck"].children if c.name == "skull")
377    tip_gap = float(math.dist(tuple(skull.world_position), tuple(bones["neck"].bone_tip)))
378    check(
379        "an attached drawable rides the bone with no per-frame code",
380        tip_gap < 1e-3,
381        f"skull sits {tip_gap:.2e}px from neck.bone_tip",
382    )
383
384    travel = max(float(math.dist(tuple(skull_track[0]), tuple(p))) for p in skull_track)
385    check("the sway actually moves the rig", travel > 3.0, f"the head travelled {travel:.1f}px")
386
387    swing = max(spine_world) - min(spine_world)
388    check("bone_angle rotates the spine without touching its rotation", swing > 0.08, f"{swing:.3f} rad of world swing")
389    check(
390        "the sway left the authored rotation alone",
391        abs(bones["spine"].rotation - math.radians(-90)) < 1e-5,
392        f"spine.rotation is still {math.degrees(bones['spine'].rotation):.1f} deg",
393    )
394
395    # SPACE at frame 60 starts the clip; it is 1.6s long, so it is over well
396    # before the reset at frame 200.
397    wave_peak = min(arm_r_angle[60:190])
398    check("SPACE drove the wave clip", wave_peak < -1.0, f"arm_r.bone_angle reached {wave_peak:.2f} rad")
399    check("the wave finished and released the arm", not scene._wave_upper.playing, "the player stopped on its own")
400
401    # The IK runs from Skeleton2D.on_update alone, while the spine sways beneath it.
402    worst = max(ik_error[5:])
403    check(
404        "two-bone IK held the hand on the moving target",
405        worst < 2.0,
406        f"worst hand-to-target error {worst:.2f}px over {len(ik_error) - 5} frames",
407    )
408
409    check(
410        "R put every pose offset back to rest",
411        bool(after_reset) and all(abs(a) < 0.02 and abs(b) < 0.02 for a, b in after_reset),
412        f"bone_angles just after the reset: {[(round(a, 3), round(b, 3)) for a, b in after_reset]}",
413    )
414
415    # Gizmo toggle: the overlay nodes go invisible and the picture changes.
416    gizmo_scene = SkeletonDemo(name="SkeletonDemo")
417    gsim = InputSimulator()
418
419    def gizmo_frame(idx: int, _t: float) -> bool:
420        if idx == 20:
421            gsim.press_key(Key.G)
422        elif idx == 21:
423            gsim.release_key(Key.G)
424        return True
425
426    gframes = App(title="Skeleton2D gizmos", width=WIDTH, height=HEIGHT, visible=False).run_headless(
427        gizmo_scene, frames=40, on_frame=gizmo_frame, capture_frames=[10, 39]
428    )
429    gizmo_nodes = [c for b in gizmo_scene._skeleton.bones for c in b.children if c.name.endswith("_gizmo")]
430    check(
431        "G hid the gizmo nodes",
432        not gizmo_scene._show_gizmos and all(not n.visible for n in gizmo_nodes),
433        f"{len(gizmo_nodes)} gizmo nodes, all hidden: {all(not n.visible for n in gizmo_nodes)}",
434    )
435    changed = int((abs(gframes[0].astype(int) - gframes[1].astype(int)).sum(axis=-1) > 20).sum())
436    check("hiding the gizmos changed the picture", changed > 500, f"{changed} pixels differ")
437
438    # And the figure is really on screen and really moving.
439    moved = int((abs(frames[0].astype(int) - frames[1].astype(int)).sum(axis=-1) > 20).sum())
440    check("the rendered figure moves", moved > 500, f"{moved} pixels changed between frame 0 and 90")
441
442    quit_scene = SkeletonDemo(name="SkeletonDemo")
443    qsim = InputSimulator()
444    last = [-1]
445
446    def quit_frame(idx: int, _t: float) -> bool:
447        last[0] = idx
448        if idx == 30:
449            qsim.press_key(Key.ESCAPE)
450        elif idx == 31:
451            qsim.release_key(Key.ESCAPE)
452        return True
453
454    App(title="Skeleton2D quit", width=WIDTH, height=HEIGHT, visible=False).run_headless(
455        quit_scene, frames=200, on_frame=quit_frame
456    )
457    check("ESC ends the run", last[0] < 60, f"the loop stopped at frame {last[0]} of 200")
458
459    print("screenshot: /tmp/skeleton2d_test.png")
460    print("SELFTEST:", "PASS" if ok else "FAIL")
461    return ok
462
463
464if __name__ == "__main__":
465    import sys
466
467    if "--test" in sys.argv:
468        sys.exit(0 if _selftest() else 1)
469    App(title="Skeleton2D Rig", width=WIDTH, height=HEIGHT).run(SkeletonDemo())