2D Joints

a swinging pendulum chain built from PinJoint2D constraints.

▶ Run in browser

Tags: 2d

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 real rope of beads. You are watching the physics solver hold those pin constraints together every fixed step.

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.

Controls

LMB Kick the bottom ball away from the mouse R Reset the chain to its hanging start position ESC Quit

Run: uv run python examples/features/2d/joints.py

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 real rope of beads. You are watching the physics solver
  6hold those pin constraints together every fixed step.
  7
  8## What it demonstrates
  9  - PhysicsBody2D(DYNAMIC): a dynamic body that gravity and forces act on.
 10  - PhysicsBody2D(STATIC): an immovable body used as the fixed anchor at the top.
 11  - PinJoint2D: pins two bodies at a single world point; they cannot separate
 12    there but rotate freely about it (chain them to build a rope).
 13  - The Physics2DWorld solves every joint automatically: you only declare the
 14    constraints, you never integrate or correct positions by hand.
 15  - Click-to-interact: a left click kicks the bottom ball away from the cursor
 16    so you can set the whole chain swinging.
 17
 18## Controls
 19  LMB   Kick the bottom ball away from the mouse
 20  R     Reset the chain to its hanging start position
 21  ESC   Quit
 22
 23Run: uv run python examples/features/2d/joints.py
 24"""
 25
 26from simvx.core import (
 27    BodyMode,
 28    CircleShape2D,
 29    CollisionShape2D,
 30    Input,
 31    InputMap,
 32    Key,
 33    MouseButton,
 34    Node2D,
 35    PhysicsBody2D,
 36    PhysicsRoot2D,
 37    PinJoint2D,
 38    Vec2,
 39)
 40from simvx.graphics import App
 41
 42WIDTH, HEIGHT = 800, 600
 43CHAIN_LENGTH = 6
 44LINK_SPACING = 50.0
 45BALL_RADIUS = 10.0
 46ANCHOR = Vec2(WIDTH / 2, 100)
 47# Y-down world (gravity = +Y) so the chain hangs downward on screen.
 48GRAVITY = Vec2(0.0, 1200.0)
 49
 50
 51class PendulumChain(Node2D):
 52    """A chain of PhysicsBody2D nodes connected by PinJoint2D constraints."""
 53
 54    dynamic = True  # the chain swings every frame (physics body positions)
 55
 56    def on_ready(self):
 57        InputMap.add_action("apply_force", [MouseButton.LEFT])
 58        InputMap.add_action("reset_chain", [Key.R])
 59        InputMap.add_action("quit", [Key.ESCAPE])
 60
 61        self._root = self.add_child(PhysicsRoot2D(name="World", gravity=GRAVITY))
 62
 63        # Fixed anchor (static body). Disjoint collision masks across the chain
 64        # so the beads never collide as circles: only the pins act.
 65        self._anchor = PhysicsBody2D(
 66            name="Anchor", mode=BodyMode.STATIC, position=Vec2(ANCHOR.x, ANCHOR.y),
 67            collision_layer=0x1, collision_mask=0x0,
 68        )
 69        self._anchor.add_child(CollisionShape2D(shape=CircleShape2D(8.0)))
 70        self._root.add_child(self._anchor)
 71
 72        # Chain of dynamic bodies, pinned to the one above at the upper pivot.
 73        self._balls: list[PhysicsBody2D] = []
 74        self._start_positions: list[Vec2] = []
 75        prev = self._anchor
 76        prev_pos = Vec2(ANCHOR.x, ANCHOR.y)
 77        for i in range(CHAIN_LENGTH):
 78            pos = Vec2(ANCHOR.x, ANCHOR.y + LINK_SPACING * (i + 1))
 79            body = PhysicsBody2D(
 80                name=f"Ball{i}", mode=BodyMode.DYNAMIC, mass=1.0, position=pos,
 81                collision_layer=0x1, collision_mask=0x0,
 82            )
 83            body.add_child(CollisionShape2D(shape=CircleShape2D(BALL_RADIUS)))
 84            self._root.add_child(body)
 85            self._balls.append(body)
 86            self._start_positions.append(pos)
 87            # Pin this body to the previous one at the previous body's pivot.
 88            self._root.add_child(PinJoint2D(body_a=prev, body_b=body, anchor=prev_pos))
 89            prev = body
 90            prev_pos = pos
 91
 92    def _reset(self):
 93        for body, pos in zip(self._balls, self._start_positions, strict=True):
 94            body.position = Vec2(pos.x, pos.y)
 95            body.velocity = Vec2()
 96
 97    def on_update(self, dt: float):
 98        if Input.is_action_just_pressed("quit"):
 99            self.app.quit()
100            return
101        if Input.is_action_just_pressed("reset_chain"):
102            self._reset()
103            return
104        if Input.is_action_just_pressed("apply_force"):
105            # Kick the bottom ball away from the mouse (set its velocity directly).
106            p = self._balls[-1].world_position
107            mouse = Input.mouse_position
108            dx, dy = p.x - mouse.x, p.y - mouse.y
109            dist = max((dx * dx + dy * dy) ** 0.5, 1.0)
110            speed = 600.0
111            self._balls[-1].velocity = Vec2(dx / dist * speed, dy / dist * speed)
112
113    def on_draw(self, renderer):
114        # Lines between links.
115        link_colour = (0.6, 0.6, 0.7, 1.0)
116        anchor_pos = self._anchor.world_position
117        if self._balls:
118            renderer.draw_line(anchor_pos, self._balls[0].world_position, colour=link_colour)
119        for i in range(len(self._balls) - 1):
120            renderer.draw_line(self._balls[i].world_position, self._balls[i + 1].world_position, colour=link_colour)
121
122        # Anchor.
123        renderer.draw_circle(anchor_pos, 8, colour=(1.0, 0.3, 0.3, 1.0), filled=True)
124
125        # Balls (gradient).
126        for i, body in enumerate(self._balls):
127            t = i / max(1, CHAIN_LENGTH - 1)
128            colour = (0.3 + 0.5 * (1 - t), 0.6 + 0.3 * (1 - t), 1.0, 1.0)
129            renderer.draw_circle(body.world_position, BALL_RADIUS, colour=colour, filled=True)
130
131        # HUD.
132        renderer.draw_text("Pendulum Chain -- PinJoint2D Demo", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
133        renderer.draw_text("LMB: kick | R: reset | ESC: quit", (10, 50), colour=(0.71, 0.71, 0.71))
134
135
136if __name__ == "__main__":
137    App(title="2D Joints -- Pendulum Chain", width=WIDTH, height=HEIGHT).run(PendulumChain())