Skeleton2D¶
bone hierarchies, rest poses and two-bone IK.
â–¶ Run in browserTags: 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, andreset_to_rest()to snap the whole skeleton back to it.The two rotation channels a bone has:
rotationis the authored local angle,bone_angleis a pose offset layered on top. The sway and the wave both drivebone_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.
SkeletonModification2DTwoBoneIKregistered withadd_modification(), whichSkeleton2D.on_updatethen runs every frame without any calling code.bone_tipfor 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())