2D Joints

a pendulum chain built from PinJoint2D.

▶ Run in browser

Tags: 2d physics joints constraints

A joint constrains two physics bodies relative to each other, and the physics world keeps it satisfied on every fixed step: you declare the constraint and never correct a position by hand. PinJoint2D(body_a=..., body_b=..., anchor=...) pins two bodies together at a world-space point. They cannot separate there but rotate freely about it, so a pin to a STATIC body makes a pendulum and a line of pins makes a rope.

Here a line of DYNAMIC balls hangs from a STATIC anchor, each pinned to the one above it at that body’s centre. The bodies and joints sit under a PhysicsRoot2D, whose gravity points down the screen (+Y). The anchor is captured into each body’s own frame when the joint enters the tree, so the pivot travels and turns with the bodies it joins. The balls’ collision_mask is 0, so they never collide as circles and only the pins hold them together.

Click or tap to kick the bottom ball away from the pointer and set the chain swinging.

An iterative solver lets a loaded chain stretch a little. world_2d.position_iterations is how many position passes each step spends on the joints: press I to cycle it through 3, 8 and 32 and watch the “worst link” figure, which is how far the most stretched link is from its rest length. More passes pull the chain tighter for a step that costs more. The pymunk backend has no position pass, ignores the setting and hangs the chain close to its rest length anyway. Press R to hang the chain back up (both keys are buttons on screen too); a reset clears each ball’s rotation and spin too, because a pin’s pivot is held in its body’s frame.

Source

  1# /// simvx
  2# tags = ["2d", "physics", "joints", "constraints"]
  3# web = { width = 800, height = 600 }
  4# ///
  5"""2D Joints: a pendulum chain built from PinJoint2D.
  6
  7A joint constrains two physics bodies relative to each other, and the physics world keeps it
  8satisfied on every fixed step: you declare the constraint and never correct a position by hand.
  9`PinJoint2D(body_a=..., body_b=..., anchor=...)` pins two bodies together at a world-space point.
 10They cannot separate there but rotate freely about it, so a pin to a `STATIC` body makes a
 11pendulum and a line of pins makes a rope.
 12
 13Here a line of `DYNAMIC` balls hangs from a `STATIC` anchor, each pinned to the one above it at
 14that body's centre. The bodies and joints sit under a `PhysicsRoot2D`, whose `gravity` points
 15down the screen (+Y). The anchor is captured into each body's own frame when the joint enters
 16the tree, so the pivot travels and turns with the bodies it joins. The balls' `collision_mask`
 17is 0, so they never collide as circles and only the pins hold them together.
 18
 19Click or tap to kick the bottom ball away from the pointer and set the chain swinging.
 20
 21An iterative solver lets a loaded chain stretch a little. `world_2d.position_iterations` is how
 22many position passes each step spends on the joints: press I to cycle it through 3, 8 and 32 and
 23watch the "worst link" figure, which is how far the most stretched link is from its rest length.
 24More passes pull the chain tighter for a step that costs more. The pymunk backend has no
 25position pass, ignores the setting and hangs the chain close to its rest length anyway. Press R
 26to hang the chain back up (both keys are buttons on screen too); a reset clears each ball's
 27rotation and spin too, because a pin's pivot is held in its body's frame.
 28"""
 29
 30from itertools import pairwise
 31
 32from simvx.core import (
 33    Anchor,
 34    BodyMode,
 35    Button,
 36    CanvasLayer,
 37    CircleShape2D,
 38    HBoxContainer,
 39    Input,
 40    Key,
 41    Label,
 42    Node2D,
 43    PhysicsBody2D,
 44    PhysicsRoot2D,
 45    PinJoint2D,
 46    Pointer,
 47    Vec2,
 48    on_input,
 49)
 50from simvx.graphics import App
 51
 52LINKS = 6
 53SPACING = 50.0
 54RADIUS = 10.0
 55KICK_SPEED = 600.0
 56POSITION_PASSES = (3, 8, 32)
 57
 58
 59class PendulumChain(Node2D):
 60    dynamic = True
 61    input_context = {"kick": [Pointer.PRIMARY], "reset": [Key.R], "passes": [Key.I]}
 62
 63    def on_ready(self):
 64        self.world = self.add_child(PhysicsRoot2D(name="World", gravity=(0.0, 1200.0)))
 65        top = Vec2(self.viewport_size.x / 2, 100)
 66        self.anchor = self.world.add_child(
 67            PhysicsBody2D(name="Anchor", mode=BodyMode.STATIC, position=top, shape=CircleShape2D(8.0))
 68        )
 69        self.balls = []
 70        above = self.anchor
 71        for i in range(LINKS):
 72            ball = self.world.add_child(
 73                PhysicsBody2D(
 74                    name=f"Ball{i}",
 75                    mode=BodyMode.DYNAMIC,
 76                    position=top + Vec2(0, SPACING * (i + 1)),
 77                    collision_mask=0,
 78                    shape=CircleShape2D(RADIUS),
 79                )
 80            )
 81            self.world.add_child(PinJoint2D(body_a=above, body_b=ball, anchor=above.position))
 82            self.balls.append(ball)
 83            above = ball
 84        self.starts = [ball.position.copy() for ball in self.balls]
 85        self.status = self.add_child(CanvasLayer()).add_child(Label(anchor=Anchor.TOP_LEFT, inset=12))
 86        self._add_help()
 87
 88    @on_input("kick")
 89    def kick(self, event):
 90        tip = self.balls[-1]
 91        tip.velocity = (tip.world_position - Input.mouse_position).normalized() * KICK_SPEED
 92
 93    @on_input("reset")
 94    def reset(self, event=None):
 95        for ball, start in zip(self.balls, self.starts, strict=True):
 96            ball.position = start
 97            ball.velocity = Vec2()
 98            ball.rotation = 0.0
 99            ball.spin = 0.0
100
101    @on_input("passes")
102    def cycle_passes(self, event=None):
103        world = self.world.world_2d
104        world.position_iterations = POSITION_PASSES[
105            (POSITION_PASSES.index(world.position_iterations) + 1) % len(POSITION_PASSES)
106        ]
107
108    def points(self):
109        return [self.anchor.world_position] + [ball.world_position for ball in self.balls]
110
111    def on_update(self, dt):
112        points = self.points()
113        worst = max(abs((b - a).length() - SPACING) for a, b in pairwise(points))
114        self.status.text = (
115            f"Click or tap to kick the chain\n"
116            f"position_iterations {self.world.world_2d.position_iterations}   "
117            f"worst link {worst / SPACING:.1%} long"
118        )
119
120    def on_draw(self, renderer):
121        points = self.points()
122        for a, b in pairwise(points):
123            renderer.line(a, b, stroke=(0.6, 0.6, 0.7, 1.0))
124        renderer.circle(points[0], 8, fill=(1.0, 0.3, 0.3, 1.0))
125        for i, point in enumerate(points[1:]):
126            fade = 1 - i / (LINKS - 1)
127            renderer.circle(point, RADIUS, fill=(0.3 + 0.5 * fade, 0.6 + 0.3 * fade, 1.0, 1.0))
128
129    # Example help overlay: not part of the technique shown.
130    def _add_help(self):
131        buttons = [Button("R: reset", on_press=self.reset), Button("I: position passes", on_press=self.cycle_passes)]
132        self.status.parent.add_child(HBoxContainer(children=buttons, anchor=Anchor.BOTTOM_LEFT, inset=12))
133
134
135if __name__ == "__main__":
136    App(title="2D Joints", width=800, height=600).run(PendulumChain())