3D Joints

A pinned chain and a hinged door, built from PinJoint3D and HingeJoint3D.

▶ Run in browser

Tags: 3d

A joint constrains two PhysicsBody3D nodes in the same PhysicsRoot, and the world solves every joint on each fixed step. Add a joint after both of its bodies: it resolves them once, when it enters the tree.

world.add_child(PinJoint3D(body_a=above, body_b=bead, anchor=above.position))
world.add_child(HingeJoint3D(body_a=post, body_b=door, anchor=(4, 2, 0), axis=(0, 1, 0)))

PinJoint3D holds the two bodies together at anchor and leaves them free to rotate about it, so a column of pinned beads hanging from a static anchor swings as a chain. HingeJoint3D is a pin plus a lock on the two off-axis rotations, leaving one free turn about axis: the door panel is shoved sideways and swings round its post while staying upright. Both joints hold their pivot in the frame of the body carrying it, which is why the door orbits the post instead of spinning about its own centre.

The anchor and the hinge post are BodyMode.STATIC bodies: the solver reads them and never moves them. Every body has collision_mask=0, so the beads never collide with each other or the door; only the joints act on them.

PhysicsRoot() runs the builtin solver. PhysicsRoot(backend="jolt") runs the same scene on Jolt, with the same behaviour; the built-in solver has no inertia tensor, so its chain swings a little less freely.

Controls: Space / click or tap / Push door - Shove the door R / Reset - Put every body back and kick the chain again Drag / Left, Right arrows - Orbit the camera Scroll / pinch - Zoom

Source

  1# /// simvx
  2# web = { width = 1280, height = 720 }
  3# ///
  4"""3D Joints: A pinned chain and a hinged door, built from PinJoint3D and HingeJoint3D.
  5
  6A joint constrains two ``PhysicsBody3D`` nodes in the same ``PhysicsRoot``, and
  7the world solves every joint on each fixed step. Add a joint after both of its
  8bodies: it resolves them once, when it enters the tree.
  9
 10    world.add_child(PinJoint3D(body_a=above, body_b=bead, anchor=above.position))
 11    world.add_child(HingeJoint3D(body_a=post, body_b=door, anchor=(4, 2, 0), axis=(0, 1, 0)))
 12
 13``PinJoint3D`` holds the two bodies together at ``anchor`` and leaves them free
 14to rotate about it, so a column of pinned beads hanging from a static anchor
 15swings as a chain. ``HingeJoint3D`` is a pin plus a lock on the two off-axis
 16rotations, leaving one free turn about ``axis``: the door panel is shoved
 17sideways and swings round its post while staying upright. Both joints hold
 18their pivot in the frame of the body carrying it, which is why the door orbits
 19the post instead of spinning about its own centre.
 20
 21The anchor and the hinge post are ``BodyMode.STATIC`` bodies: the solver reads
 22them and never moves them. Every body has ``collision_mask=0``, so the beads
 23never collide with each other or the door; only the joints act on them.
 24
 25``PhysicsRoot()`` runs the builtin solver. ``PhysicsRoot(backend="jolt")`` runs
 26the same scene on Jolt, with the same behaviour; the built-in solver has no
 27inertia tensor, so its chain swings a little less freely.
 28
 29Controls:
 30    Space / click or tap / Push door  - Shove the door
 31    R / Reset                         - Put every body back and kick the chain again
 32    Drag / Left, Right arrows         - Orbit the camera
 33    Scroll / pinch                    - Zoom
 34"""
 35
 36import math
 37
 38from simvx.core import (
 39    Anchor,
 40    Axis1D,
 41    BodyMode,
 42    BoxShape3D,
 43    Button,
 44    CanvasLayer,
 45    DirectionalLight3D,
 46    HBoxContainer,
 47    HingeJoint3D,
 48    Input,
 49    Key,
 50    Label,
 51    Material,
 52    Mesh,
 53    MeshInstance3D,
 54    Node3D,
 55    OrbitCamera3D,
 56    PhysicsBody3D,
 57    PhysicsRoot,
 58    PinJoint3D,
 59    Pointer,
 60    Quat,
 61    SphereShape3D,
 62    VBoxContainer,
 63    Vec3,
 64    on_input,
 65)
 66from simvx.graphics import App
 67
 68CHAIN_LEN = 4
 69LINK_DIST = 1.2
 70CHAIN_ANCHOR = Vec3(-4, 6, 0)
 71HINGE = Vec3(4, 2, 0)
 72KICK = Vec3(4, 0, 0)  # the top bead's starting velocity, so the chain swings
 73SHOVE = Vec3(0, 0, 6)  # the door's velocity when pushed; the hinge turns it into a swing
 74
 75METAL = Material(colour=(0.6, 0.6, 0.6), roughness=0.5)
 76
 77
 78def ball(name: str, position: Vec3, radius: float, colour, mode=BodyMode.DYNAMIC) -> PhysicsBody3D:
 79    body = PhysicsBody3D(name=name, mode=mode, position=position, shape=SphereShape3D(radius=radius), collision_mask=0)
 80    body.add_child(
 81        MeshInstance3D(mesh=Mesh.sphere(radius), material=Material(colour=colour, roughness=0.3, metallic=0.5))
 82    )
 83    return body
 84
 85
 86class JointsDemo(Node3D):
 87    input_context = {
 88        "push_door": [Key.SPACE],
 89        "reset": [Key.R],
 90        "orbit": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
 91    }
 92
 93    def on_ready(self):
 94        self.camera = self.add_child(
 95            OrbitCamera3D(pivot=(0, 2, 0), distance=14.1, yaw=0.0, pitch=math.radians(-8), fov=55.0)
 96        )
 97        self.add_child(DirectionalLight3D(direction=(-1, -2, -1)))
 98        self.add_child(
 99            MeshInstance3D(
100                name="Ground", mesh=Mesh.plane(20), material=Material(colour=(0.3, 0.35, 0.3), roughness=0.9)
101            )
102        )
103        world = self.add_child(PhysicsRoot(name="World"))
104
105        # The chain: each bead pinned to the one above it, at that body's centre.
106        above = world.add_child(ball("ChainAnchor", CHAIN_ANCHOR, 0.15, (1.0, 0.3, 0.3), BodyMode.STATIC))
107        above.add_child(MeshInstance3D(mesh=Mesh.cylinder(0.08, 1.0), material=METAL, position=(0, 0.5, 0)))
108        self.beads = []
109        for i in range(CHAIN_LEN):
110            bead = world.add_child(
111                ball(f"ChainBody{i}", CHAIN_ANCHOR - Vec3(0, LINK_DIST * (i + 1), 0), 0.25, (0.3, 0.6, 1.0))
112            )
113            world.add_child(PinJoint3D(body_a=above, body_b=bead, anchor=above.position))
114            self.beads.append(bead)
115            above = bead
116
117        # The door: a panel whose edge is on the post, hinged about the vertical axis.
118        post = world.add_child(
119            PhysicsBody3D(
120                name="DoorPostBody",
121                mode=BodyMode.STATIC,
122                position=HINGE,
123                shape=SphereShape3D(radius=0.12),
124                collision_mask=0,
125            )
126        )
127        post.add_child(MeshInstance3D(mesh=Mesh.cylinder(0.1, 4.0), material=METAL))
128        self.door = world.add_child(
129            PhysicsBody3D(
130                name="DoorBody",
131                mass=5.0,
132                position=HINGE + Vec3(1, 0, 0),
133                shape=BoxShape3D(half_extents=(1.0, 1.75, 0.06)),
134                collision_mask=0,
135            )
136        )
137        self.door.add_child(
138            MeshInstance3D(mesh=Mesh.box((2.0, 3.5, 0.12)), material=Material(colour=(0.7, 0.4, 0.15), roughness=0.6))
139        )
140        world.add_child(HingeJoint3D(body_a=post, body_b=self.door, anchor=HINGE, axis=(0, 1, 0)))
141
142        self.homes = {body: Vec3(body.position) for body in [*self.beads, self.door]}
143        self.beads[0].velocity = KICK
144
145        # Example help overlay: not part of the technique shown.
146        footer = self.add_child(CanvasLayer()).add_child(
147            VBoxContainer(anchor=Anchor.BOTTOM_LEFT, inset=12, separation=8)
148        )
149        footer.add_child(Label("Space / click: push door   R: reset   Drag / Left, Right: orbit   Scroll: zoom"))
150        bar = footer.add_child(HBoxContainer(separation=8))
151        bar.add_child(Button("Push door", action="push_door"))
152        bar.add_child(Button("Reset", action="reset"))
153        # A container keeps its own size rather than growing to its content, so a
154        # bottom-anchored stack is fitted by hand or it runs off the bottom edge.
155        footer.size = footer.get_minimum_size()
156
157    @on_input("push_door")
158    def push_door(self, _event=None):
159        self.door.velocity = SHOVE
160
161    @on_input(Pointer.PRIMARY, edge="release")
162    def _on_tap(self, event):
163        # A drag orbits the camera; a press that never became a drag pushes the door.
164        if not event.dragging:
165            self.push_door()
166
167    @on_input("reset")
168    def reset(self, _event):
169        # Position and rotation teleport a body; velocity and spin clear its momentum.
170        for body, home in self.homes.items():
171            body.position = home
172            body.rotation = Quat()
173            body.velocity = Vec3()
174            body.spin = Vec3()
175        self.beads[0].velocity = KICK
176
177    def on_update(self, dt):
178        self.camera.orbit(Input.value("orbit") * 1.5 * dt, 0.0)
179
180
181if __name__ == "__main__":
182    App(title="3D Joints", width=1280, height=720).run(JointsDemo())