2D Joints¶
a swinging pendulum chain built from PinJoint2D constraints.
â–¶ Run in browserTags: 2d physics joints constraints
A line of dynamic balls hangs from a fixed anchor at the top of the screen. Each ball is pinned to the one above it, so the whole chain swings and settles under gravity like a rope of beads. You are watching the physics solver hold those pin constraints together every fixed step.
It is a rope with give in it, and how much depends on which solver the scene
resolves. The pure-Python builtin (the fallback when pymunk is not installed)
runs a handful of impulse iterations and a soft positional bias per step, which a
six-link chain pulling at 1200 px/s^2 stretches faster than the bias drains: a
second in, the tip already hangs 312 px below an anchor 300 px of chain away from
it, and left hanging it settles there, with its worst link 7.0% long. The
optional pymunk backend (PhysicsRoot2D(backend="pymunk"), and the default
whenever pymunk is importable) hangs the same chain on its rest length, tip at
300 px.
The self-check below kicks the chain and prints the worst link it sees while the
chain hangs and while it swings. Forcing each backend in turn, it is 10.8% on the
builtin against 12.6% on pymunk, so the swing peak is very nearly a wash: the two
solvers differ on the hang, not on the worst instant of a kick. What separates
them is what happens next. pymunk’s stretch recedes (0.3% hanging, 14.2% at the
peak, 1.7% thirty frames later) while the builtin’s does not (7.0%, 8.9%, and
still 8.9% thirty frames later), and that residual is a known defect rather than
a solver budget: see BUGS.md for the awake builtin solver that never drains.
Running the example plainly measures whichever backend resolved, so read the
backend: line the self-check prints rather than assuming.
The sag is a solver budget rather than a property of the model, and the world
says how big that budget is. world.position_iterations is the number of
position passes a step makes over the joints, and pressing I cycles it: on
the builtin the same six-link chain settles with its worst link 10.3% long at the
default 3 and 0.8% at 32, for a step that costs proportionally more.
pymunk ignores it – Chipmunk has no position solver, which is exactly why its
chain hangs on its rest length to begin with.
What does NOT depend on the solver is where the pivot is: each pin is held in the frame of the body carrying it, so it goes where that body goes.
What it demonstrates¶
PhysicsBody2D(DYNAMIC): a dynamic body that gravity and forces act on.
PhysicsBody2D(STATIC): an immovable body used as the fixed anchor at the top.
PinJoint2D: pins two bodies at a single world point; they cannot separate there but rotate freely about it (chain them to build a rope).
The Physics2DWorld solves every joint automatically: you only declare the constraints, you never integrate or correct positions by hand.
Click-to-interact: a left click kicks the bottom ball away from the cursor so you can set the whole chain swinging.
Physics2DWorld.position_iterations: the world’s joint-convergence dial, live on a key, with the chain’s sag as the observable.
Controls¶
LMB Kick the bottom ball away from the mouse I Cycle world.position_iterations: 3 / 8 / 32 (watch the chain tighten) R Reset the chain to its hanging start position ESC Quit
Run: uv run python examples/features/2d/joints.py Headless self-check: uv run python examples/features/2d/joints.py –test
Source¶
1"""2D Joints: a swinging pendulum chain built from PinJoint2D constraints.
2
3A line of dynamic balls hangs from a fixed anchor at the top of the screen.
4Each ball is pinned to the one above it, so the whole chain swings and settles
5under gravity like a rope of beads. You are watching the physics solver hold
6those pin constraints together every fixed step.
7
8It is a rope with give in it, and how much depends on which solver the scene
9resolves. The pure-Python builtin (the fallback when pymunk is not installed)
10runs a handful of impulse iterations and a soft positional bias per step, which a
11six-link chain pulling at 1200 px/s^2 stretches faster than the bias drains: a
12second in, the tip already hangs 312 px below an anchor 300 px of chain away from
13it, and left hanging it settles there, with its worst link 7.0% long. The
14optional pymunk backend (``PhysicsRoot2D(backend="pymunk")``, and the default
15whenever pymunk is importable) hangs the same chain on its rest length, tip at
16300 px.
17
18The self-check below kicks the chain and prints the worst link it sees while the
19chain hangs and while it swings. Forcing each backend in turn, it is 10.8% on the
20builtin against 12.6% on pymunk, so the swing peak is very nearly a wash: the two
21solvers differ on the *hang*, not on the worst instant of a kick. What separates
22them is what happens next. pymunk's stretch recedes (0.3% hanging, 14.2% at the
23peak, 1.7% thirty frames later) while the builtin's does not (7.0%, 8.9%, and
24still 8.9% thirty frames later), and that residual is a known defect rather than
25a solver budget: see ``BUGS.md`` for the awake builtin solver that never drains.
26Running the example plainly measures whichever backend resolved, so read the
27``backend:`` line the self-check prints rather than assuming.
28
29The *sag* is a solver budget rather than a property of the model, and the world
30says how big that budget is. ``world.position_iterations`` is the number of
31position passes a step makes over the joints, and pressing ``I`` cycles it: on
32the builtin the same six-link chain settles with its worst link 10.3% long at the
33default ``3`` and 0.8% at ``32``, for a step that costs proportionally more.
34pymunk ignores it -- Chipmunk has no position solver, which is exactly why its
35chain hangs on its rest length to begin with.
36
37What does NOT depend on the solver is where the pivot is: each pin is held in the
38frame of the body carrying it, so it goes where that body goes.
39
40# /// simvx
41# tags = ["2d", "physics", "joints", "constraints"]
42# web = { root = "PendulumChain", width = 800, height = 600 }
43# ///
44
45## What it demonstrates
46 - PhysicsBody2D(DYNAMIC): a dynamic body that gravity and forces act on.
47 - PhysicsBody2D(STATIC): an immovable body used as the fixed anchor at the top.
48 - PinJoint2D: pins two bodies at a single world point; they cannot separate
49 there but rotate freely about it (chain them to build a rope).
50 - The Physics2DWorld solves every joint automatically: you only declare the
51 constraints, you never integrate or correct positions by hand.
52 - Click-to-interact: a left click kicks the bottom ball away from the cursor
53 so you can set the whole chain swinging.
54 - Physics2DWorld.position_iterations: the world's joint-convergence dial, live
55 on a key, with the chain's sag as the observable.
56
57## Controls
58 LMB Kick the bottom ball away from the mouse
59 I Cycle world.position_iterations: 3 / 8 / 32 (watch the chain tighten)
60 R Reset the chain to its hanging start position
61 ESC Quit
62
63Run: uv run python examples/features/2d/joints.py
64Headless self-check: uv run python examples/features/2d/joints.py --test
65"""
66
67from simvx.core import (
68 BodyMode,
69 CircleShape2D,
70 Input,
71 InputMap,
72 Key,
73 MouseButton,
74 Node2D,
75 PhysicsBody2D,
76 PhysicsRoot2D,
77 PinJoint2D,
78 Vec2,
79)
80from simvx.graphics import App
81
82WIDTH, HEIGHT = 800, 600
83CHAIN_LENGTH = 6
84LINK_SPACING = 50.0
85BALL_RADIUS = 10.0
86ANCHOR = Vec2(WIDTH / 2, 100)
87# Y-down world (gravity = +Y) so the chain hangs downward on screen.
88GRAVITY = Vec2(0.0, 1200.0)
89#: The joint-convergence settings the I key cycles. The first is the seam default.
90POSITION_PASSES = (3, 8, 32)
91
92
93class PendulumChain(Node2D):
94 """A chain of PhysicsBody2D nodes connected by PinJoint2D constraints."""
95
96 dynamic = True # the chain swings every frame (physics body positions)
97
98 def on_ready(self):
99 InputMap.add_action("apply_force", [MouseButton.LEFT])
100 InputMap.add_action("reset_chain", [Key.R])
101 InputMap.add_action("tighten", [Key.I])
102 InputMap.add_action("quit", [Key.ESCAPE])
103 self._passes_i = 0
104
105 self._root = self.add_child(PhysicsRoot2D(name="World", gravity=GRAVITY))
106
107 # Fixed anchor (static body). Disjoint collision masks across the chain
108 # so the beads never collide as circles: only the pins act.
109 self._anchor = PhysicsBody2D(
110 name="Anchor",
111 mode=BodyMode.STATIC,
112 position=Vec2(ANCHOR.x, ANCHOR.y),
113 collision_layer=0x1,
114 collision_mask=0x0,
115 shape=CircleShape2D(8.0),
116 )
117 self._root.add_child(self._anchor)
118
119 # Chain of dynamic bodies, pinned to the one above at the upper pivot.
120 self._balls: list[PhysicsBody2D] = []
121 self._start_positions: list[Vec2] = []
122 prev = self._anchor
123 prev_pos = Vec2(ANCHOR.x, ANCHOR.y)
124 for i in range(CHAIN_LENGTH):
125 pos = Vec2(ANCHOR.x, ANCHOR.y + LINK_SPACING * (i + 1))
126 body = PhysicsBody2D(
127 name=f"Ball{i}",
128 mode=BodyMode.DYNAMIC,
129 mass=1.0,
130 position=pos,
131 collision_layer=0x1,
132 collision_mask=0x0,
133 shape=CircleShape2D(BALL_RADIUS),
134 )
135 self._root.add_child(body)
136 self._balls.append(body)
137 self._start_positions.append(pos)
138 # Pin this body to the previous one at the previous body's pivot.
139 self._root.add_child(PinJoint2D(body_a=prev, body_b=body, anchor=prev_pos))
140 prev = body
141 prev_pos = pos
142
143 def _worst_link(self) -> float:
144 """How far the most stretched link is from its rest length, in pixels."""
145 points = [self._anchor.world_position] + [b.world_position for b in self._balls]
146 worst = 0.0
147 for a, b in zip(points, points[1:], strict=False):
148 gap = ((b.x - a.x) ** 2 + (b.y - a.y) ** 2) ** 0.5
149 worst = max(worst, abs(gap - LINK_SPACING))
150 return worst
151
152 def _reset(self):
153 """Hang the chain back up exactly as it was authored.
154
155 Rotation and spin are part of that, not decoration: a pin's anchor is
156 held in the frame of the body carrying it, so a bead left rotated from
157 the swing puts its pivot somewhere the rest pose does not have it, and
158 the chain settles crooked from a reset that looks complete. The rig
159 authors every bead upright, which is what makes zero the right value
160 here rather than something to capture.
161 """
162 for body, pos in zip(self._balls, self._start_positions, strict=True):
163 body.position = Vec2(pos.x, pos.y)
164 body.velocity = Vec2()
165 body.rotation = 0.0
166 body.spin = 0.0
167
168 def on_update(self, dt: float):
169 if Input.is_action_just_pressed("quit"):
170 self.app.quit()
171 return
172 if Input.is_action_just_pressed("reset_chain"):
173 self._reset()
174 return
175 if Input.is_action_just_pressed("tighten"):
176 self._passes_i = (self._passes_i + 1) % len(POSITION_PASSES)
177 self._root.world_2d.position_iterations = POSITION_PASSES[self._passes_i]
178 return
179 if Input.is_action_just_pressed("apply_force"):
180 # Kick the bottom ball away from the mouse (set its velocity directly).
181 p = self._balls[-1].world_position
182 mouse = Input.mouse_position
183 dx, dy = p.x - mouse.x, p.y - mouse.y
184 dist = max((dx * dx + dy * dy) ** 0.5, 1.0)
185 speed = 600.0
186 self._balls[-1].velocity = Vec2(dx / dist * speed, dy / dist * speed)
187
188 def on_draw(self, renderer):
189 # Lines between links.
190 link_colour = (0.6, 0.6, 0.7, 1.0)
191 anchor_pos = self._anchor.world_position
192 if self._balls:
193 renderer.draw_line(anchor_pos, self._balls[0].world_position, colour=link_colour)
194 for i in range(len(self._balls) - 1):
195 renderer.draw_line(self._balls[i].world_position, self._balls[i + 1].world_position, colour=link_colour)
196
197 # Anchor.
198 renderer.draw_circle(anchor_pos, 8, colour=(1.0, 0.3, 0.3, 1.0), filled=True)
199
200 # Balls (gradient).
201 for i, body in enumerate(self._balls):
202 t = i / max(1, CHAIN_LENGTH - 1)
203 colour = (0.3 + 0.5 * (1 - t), 0.6 + 0.3 * (1 - t), 1.0, 1.0)
204 renderer.draw_circle(body.world_position, BALL_RADIUS, colour=colour, filled=True)
205
206 # HUD.
207 renderer.draw_text("Pendulum Chain -- PinJoint2D Demo", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
208 renderer.draw_text("LMB: kick | I: position passes | R: reset | ESC: quit", (10, 50), colour=(0.71, 0.71, 0.71))
209 worst = self._worst_link()
210 renderer.draw_text(
211 f"position_iterations {self._root.world_2d.position_iterations} | "
212 f"worst link {worst / LINK_SPACING * 100:.1f}% long",
213 (10, 74),
214 colour=(0.71, 0.71, 0.71),
215 )
216
217
218#: How far a loaded link may stretch before the pin counts as having let go.
219#: Calibrated to the LOOSEST solver the scene can resolve. Across the hang and
220#: the kicked swing below, the pure-Python builtin's worst link reaches 10.8% and
221#: pymunk's 12.6%, so neither is reliably the looser one and the bar has to clear
222#: both. The measured figure is printed so a reader can see which they got.
223#: Judging by a number one backend cannot make would fail a clean install.
224MAX_STRETCH = 0.25
225
226
227def _selftest() -> bool:
228 """Headless: let the chain hang, kick it with a real click, and watch the pins.
229
230 Every link separation is measured on every frame rather than sampled, so a
231 constraint that holds while hanging and lets go under load cannot slip past.
232 The stretch bounds are the solver's; the SHAPE of the hang -- straight down,
233 the tip at the end of the chain, the anchor immovable -- is not, and is
234 judged tightly.
235 """
236 from simvx.core.testing import InputSimulator
237 from simvx.graphics.testing import assert_not_blank, save_png
238
239 HUNG = 60 # the chain has settled straight down
240 KICK = 70 # a left click, from off to the left of the chain
241 #: The kick is an instantaneous 600px/s on one bead, which the iterative solver
242 #: cannot satisfy in a single step: the bottom link stretches and comes back
243 #: over the following few frames. Links are judged outside that recovery window,
244 #: and the stretch inside it is bounded and watched receding separately.
245 RECOVERED = KICK + 30
246 RESET = 250
247 RESET_SEEN = 251
248 #: The chain has re-hung at the DEFAULT dial by here, so this is the sag the
249 #: knob is judged against.
250 DEFAULT_SETTLED = 300
251 #: Two presses of I take position_iterations from 3 to 32, and the chain is
252 #: given as long again to settle on the tighter budget.
253 TIGHTEN = 310
254 TIGHTENED_SETTLED = 410
255 FRAMES = 420
256 MOUSE = (100.0, 400.0)
257
258 app = App(title="Joints2D", width=WIDTH, height=HEIGHT, visible=False)
259 scene = PendulumChain(name="PendulumChain")
260 sim = InputSimulator()
261 seen: dict[str, object] = {}
262 hang_link = 0.0 # largest separation error while the chain just hangs
263 kick_link = 0.0 # and inside the kick's recovery window
264 swing_link = 0.0 # and afterwards, while it swings freely
265 settled_link = 0.0 # the error on the single frame the window closes on
266 anchor_drift = 0.0
267 swing = 0.0 # how far the tip ever travelled sideways from under the anchor
268
269 def separations() -> list[float]:
270 points = [scene._anchor.world_position] + [b.world_position for b in scene._balls]
271 return [float((points[i + 1] - points[i]).length()) for i in range(len(points) - 1)]
272
273 def on_frame(idx: int, _t: float) -> bool:
274 nonlocal hang_link, kick_link, swing_link, settled_link, anchor_drift, swing
275 error = max(abs(s - LINK_SPACING) for s in separations())
276 # Three windows, kept apart so the kick can be judged against what the chain
277 # held before it and what it holds after: the kick and the reset are both
278 # instantaneous teleports, and the frames right after them are not the
279 # frames that say whether a pin holds.
280 if idx < KICK:
281 hang_link = max(hang_link, error)
282 elif idx < RECOVERED:
283 kick_link = max(kick_link, error)
284 elif idx < DEFAULT_SETTLED:
285 swing_link = max(swing_link, error)
286 if idx == RECOVERED:
287 settled_link = error
288 anchor_drift = max(anchor_drift, float((scene._anchor.world_position - ANCHOR).length()))
289 swing = max(swing, abs(float(scene._balls[-1].world_position.x) - ANCHOR.x))
290
291 if idx == DEFAULT_SETTLED:
292 seen["default_sag"] = error
293 elif idx == TIGHTENED_SETTLED:
294 seen["tight_sag"] = error
295 seen["passes"] = scene._root.world_2d.position_iterations
296
297 if idx == HUNG:
298 seen["hung"] = [tuple(float(v) for v in b.world_position) for b in scene._balls]
299 elif idx == KICK:
300 # The kick reads Input.mouse_position, so the cursor has to be somewhere
301 # before the button goes down.
302 sim.move_mouse(*MOUSE)
303 sim.press_mouse(MouseButton.LEFT)
304 elif idx == KICK + 1:
305 sim.release_mouse(MouseButton.LEFT)
306 seen["kick"] = tuple(float(v) for v in scene._balls[-1].velocity)
307 elif idx == RESET:
308 sim.press_key(Key.R)
309 elif idx == RESET + 1:
310 sim.release_key(Key.R)
311 elif idx == RESET_SEEN + 1:
312 seen["reset"] = [tuple(float(v) for v in b.world_position) for b in scene._balls]
313 elif idx in (TIGHTEN, TIGHTEN + 2):
314 sim.press_key(Key.I)
315 elif idx in (TIGHTEN + 1, TIGHTEN + 3):
316 sim.release_key(Key.I)
317 return True
318
319 frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[FRAMES - 1])
320 assert_not_blank(frames[0])
321 save_png(frames[0], "/tmp/joints2d_test.png")
322
323 # Which solver these numbers came from. Every figure below is the resolved
324 # backend's, and the resolution is by precedence, so a reader who does not
325 # print this cannot tell whether an optional accelerator answered.
326 print(f"backend: {type(scene._root.world_2d).__name__}")
327
328 ok = True
329
330 def check(label: str, passed: bool, detail: str) -> None:
331 nonlocal ok
332 ok = ok and passed
333 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
334
335 # Hanging STRAIGHT down: every bead directly under the anchor, whatever the
336 # solver's stretch does to how far down each of them ends up. This one is the
337 # pins' doing and nothing else's, so it is judged to the pixel.
338 hung = seen["hung"]
339 lean = max(abs(x - ANCHOR.x) for x, _ in hung)
340 check("the chain hangs straight below the anchor", lean < 1.0, f"worst bead leans {lean:.3f}px off centre")
341
342 # And in order, each bead below the last, with the tip the chain's length down
343 # give or take the stretch the solver settles at.
344 nominal = CHAIN_LENGTH * LINK_SPACING
345 drop = hung[-1][1] - ANCHOR.y
346 descends = all(hung[i][1] < hung[i + 1][1] for i in range(CHAIN_LENGTH - 1))
347 check(
348 "and hangs its full length, in order",
349 descends and nominal * 0.99 <= drop < nominal * (1.0 + MAX_STRETCH),
350 f"the tip is {drop:.1f} below the anchor (the chain is {nominal:.0f} long)",
351 )
352
353 # The click kicks the bottom bead directly away from the cursor.
354 vx, vy = seen["kick"]
355 tip_x, tip_y = hung[-1]
356 away = (tip_x - MOUSE[0]) * vx + (tip_y - MOUSE[1]) * vy
357 check(
358 "a left click kicks the bottom bead away from the cursor",
359 away > 0 and abs((vx * vx + vy * vy) ** 0.5 - 600.0) < 1.0,
360 f"velocity {vx:.0f},{vy:.0f} pointing away from the cursor: {away > 0}",
361 )
362 check("the kick sets the whole chain swinging", swing > 100.0, f"the tip swung {swing:.0f}px off centre")
363
364 # The point of the demo: the solver holds every pin through that swing, without
365 # the scene correcting a single position by hand. An unpinned bead would have
366 # fallen off the bottom of the screen in the same time.
367 worst_link = max(hang_link, swing_link)
368 check(
369 "every pin holds while the chain hangs and swings",
370 worst_link < LINK_SPACING * MAX_STRETCH,
371 f"worst link was {worst_link:.3f} off {LINK_SPACING:.0f} ({worst_link / LINK_SPACING * 100:.1f}%)",
372 )
373 # The kick is the hardest thing the constraint is asked to absorb: it stretches
374 # a link beyond anything the hanging chain does, the stretch stays bounded, and
375 # it is already receding by the time the window closes. How much of it has come
376 # back by then is the solver's business, and printed rather than judged.
377 check(
378 "the kick stretches a link, and the stretch is bounded and recedes",
379 kick_link > hang_link and kick_link < LINK_SPACING * (MAX_STRETCH + 0.05) and settled_link < kick_link,
380 f"{hang_link / LINK_SPACING * 100:.1f}% hanging, {kick_link / LINK_SPACING * 100:.1f}% at its worst"
381 f" during the kick, {settled_link / LINK_SPACING * 100:.1f}% {RECOVERED - KICK} frames later",
382 )
383 check("the static anchor never moved", anchor_drift < 0.001, f"largest movement {anchor_drift:.6f}")
384
385 # R puts every bead back where it started. One physics step runs between the
386 # reset and this reading, so the beads have begun to fall again by a hair.
387 reset_error = max(abs(reset[1] - (ANCHOR.y + LINK_SPACING * (i + 1))) for i, reset in enumerate(seen["reset"]))
388 check("R hangs the chain back up", reset_error < 3.0, f"worst bead is {reset_error:.3f} off its start")
389
390 # And the dial the I key drives. What every backend owes is that spending more
391 # of the budget never costs slack, which is what is judged; how much it BUYS is
392 # the solver's, and printed. The builtin prints about 10.3% against 0.8%, which
393 # is the knob doing real work. pymunk prints about 1.3% against 0.3%, and that
394 # is the chain settling rather than the knob: Chipmunk has no position pass to
395 # spend it on, and hangs the chain near its rest length regardless. The
396 # measured pair says which backend resolved the scene.
397 default_sag, tight_sag = float(seen["default_sag"]), float(seen["tight_sag"])
398 check(
399 "the I key reaches the world, and more position passes never loosen the chain",
400 seen["passes"] == POSITION_PASSES[-1] and tight_sag <= default_sag + 1e-3,
401 f"{default_sag / LINK_SPACING * 100:.1f}% at {POSITION_PASSES[0]} passes, "
402 f"{tight_sag / LINK_SPACING * 100:.1f}% at {seen['passes']}",
403 )
404
405 print("screenshot: /tmp/joints2d_test.png")
406 print("SELFTEST:", "PASS" if ok else "FAIL")
407 return ok
408
409
410if __name__ == "__main__":
411 import sys
412
413 if "--test" in sys.argv:
414 sys.exit(0 if _selftest() else 1)
415 App(title="2D Joints -- Pendulum Chain", width=WIDTH, height=HEIGHT).run(PendulumChain())