2D Joints¶

a swinging pendulum chain built from PinJoint2D constraints.

â–¶ Run in browser

Tags: 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())