3D Joints¶

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

â–¶ Run in browser

Tags: 3d

Demonstrates:

  • PinJoint3D: a chain of PhysicsBody3D(DYNAMIC) spheres held at a fixed separation from the one above, hanging off a static anchor

  • HingeJoint3D: a door panel that turns about a vertical axis when shoved, and stays upright while it turns

  • PhysicsBody3D(STATIC) as the fixed anchor and hinge post: the solver reads them and never writes them, so neither ever moves

  • The PhysicsWorld solves every constraint automatically each fixed step

What the default backend gives you: this scene builds its PhysicsRoot without naming a backend, and a gameplay-affecting 3D solver is never picked up just by being installed – it stays name-addressable. So unless something names a 3D-capable backend explicitly (PhysicsRoot(backend=...), or the physics_backend project setting) the scene runs on the builtin solver. Both joints hold their pivot in the frame of the body carrying it, which is what makes the two behave the way their shapes suggest:

  • the chain swings. The 4 m/s sideways kick the top bead is given in on_ready carries it through an arc 0.65 units wide over the first four seconds, the beads below taking enough of the energy to halve that arc by the third second.

  • the pushed door orbits the post rather than turning about its own centre. It reaches 179 deg, and its centre sweeps the full half-circle round the hinge (x 3.00..5.00, z -1.00..1.00).

Installing simvx-physics-jolt and building the root as PhysicsRoot(name="World", backend="jolt") changes the residue rather than the behaviour: the same door sweeps the same half-circle, and the same kick opens the chain’s arc to 0.87 units, because Jolt carries a real inertia tensor where the pure-Python solver stands in the inverse mass for it and converges in fewer iterations.

Controls: Space or click - Push the door Left/Right keys or horizontal drag - Orbit camera R - Reset scene Escape - Quit

Run: uv run python examples/features/3d/joints.py Headless self-check: uv run python examples/features/3d/joints.py –test

Source¶

  1"""3D Joints: A pinned chain and a hinged door, built from PinJoint3D and HingeJoint3D.
  2
  3# /// simvx
  4# web = { width = 1280, height = 720 }
  5# ///
  6
  7Demonstrates:
  8  - PinJoint3D: a chain of PhysicsBody3D(DYNAMIC) spheres held at a fixed
  9    separation from the one above, hanging off a static anchor
 10  - HingeJoint3D: a door panel that turns about a vertical axis when shoved,
 11    and stays upright while it turns
 12  - PhysicsBody3D(STATIC) as the fixed anchor and hinge post: the solver reads
 13    them and never writes them, so neither ever moves
 14  - The PhysicsWorld solves every constraint automatically each fixed step
 15
 16What the default backend gives you: this scene builds its ``PhysicsRoot`` without
 17naming a backend, and a gameplay-affecting 3D solver is never picked up just by
 18being installed -- it stays name-addressable. So unless something names a
 193D-capable backend explicitly (``PhysicsRoot(backend=...)``, or the
 20``physics_backend`` project setting) the scene runs on the builtin solver. Both
 21joints hold their pivot in the frame of the body carrying it, which is what makes
 22the two behave the way their shapes suggest:
 23
 24  - the chain swings. The 4 m/s sideways kick the top bead is given in
 25    ``on_ready`` carries it through an arc 0.65 units wide over the first four
 26    seconds, the beads below taking enough of the energy to halve that arc by the
 27    third second.
 28  - the pushed door orbits the post rather than turning about its own centre. It
 29    reaches 179 deg, and its centre sweeps the full half-circle round the hinge
 30    (x 3.00..5.00, z -1.00..1.00).
 31
 32Installing ``simvx-physics-jolt`` and building the root as
 33``PhysicsRoot(name="World", backend="jolt")`` changes the residue rather than the
 34behaviour: the same door sweeps the same half-circle, and the same kick opens the
 35chain's arc to 0.87 units, because Jolt carries a real inertia tensor where the
 36pure-Python solver stands in the inverse mass for it and converges in fewer
 37iterations.
 38
 39Controls:
 40    Space or click  - Push the door
 41    Left/Right keys or horizontal drag - Orbit camera
 42    R               - Reset scene
 43    Escape          - Quit
 44
 45Run: uv run python examples/features/3d/joints.py
 46Headless self-check: uv run python examples/features/3d/joints.py --test
 47"""
 48
 49import math
 50
 51from simvx.core import (
 52    BodyMode,
 53    BoxShape3D,
 54    Camera3D,
 55    CollisionShape3D,
 56    DirectionalLight3D,
 57    HingeJoint3D,
 58    Input,
 59    Key,
 60    Material,
 61    Mesh,
 62    MeshInstance3D,
 63    MouseButton,
 64    Node,
 65    PhysicsBody3D,
 66    PhysicsRoot,
 67    PinJoint3D,
 68    Quat,
 69    SphereShape3D,
 70    Text2D,
 71    Vec3,
 72)
 73from simvx.graphics import App
 74
 75CHAIN_LEN = 4
 76LINK_DIST = 1.2
 77
 78
 79class JointsDemo(Node):
 80    input_actions = {
 81        "push_door": [Key.SPACE],
 82        "reset": [Key.R],
 83        "orbit_left": [Key.LEFT],
 84        "orbit_right": [Key.RIGHT],
 85        "quit": [Key.ESCAPE],
 86    }
 87
 88    def on_ready(self):
 89        # One isolated 3D world (Y-up, default gravity).
 90        self._root = self.add_child(PhysicsRoot(name="World"))
 91
 92        # Camera
 93        self._cam = self.add_child(
 94            Camera3D(
 95                name="Camera",
 96                position=Vec3(0, 4, 14),
 97                look_at=Vec3(0, 2, 0),
 98                fov=55.0,
 99            )
100        )
101        self._orbit = 0.0
102        self._drag_dist = 0.0
103
104        # Light
105        light = self.add_child(DirectionalLight3D(name="Sun"))
106        light.look_at(Vec3(-1, -2, -1))
107
108        # Ground (visual only)
109        ground = self.add_child(MeshInstance3D(name="Ground", mesh=Mesh.cube()))
110        ground.material = Material(colour=(0.3, 0.35, 0.3), roughness=0.9)
111        ground.scale = Vec3(20, 0.1, 20)
112        ground.position = Vec3(0, -0.05, 0)
113
114        # --- Pendulum chain (left side) ---
115        anchor_pos = Vec3(-4.0, 6.0, 0.0)
116
117        # Fixed anchor (static body). Disjoint mask so beads never self-collide.
118        self._chain_anchor = self._make_body(
119            "ChainAnchor",
120            BodyMode.STATIC,
121            anchor_pos,
122            radius=0.15,
123            colour=(1.0, 0.3, 0.3, 1.0),
124            mask=0x0,
125        )
126
127        # Chain bodies, each pinned to the one above at the upper pivot.
128        self._chain_bodies: list[PhysicsBody3D] = []
129        self._chain_start: list[Vec3] = []
130        prev = self._chain_anchor
131        prev_pos = anchor_pos
132        for i in range(CHAIN_LEN):
133            pos = Vec3(anchor_pos.x, anchor_pos.y - LINK_DIST * (i + 1), anchor_pos.z)
134            body = self._make_body(
135                f"ChainBody{i}",
136                BodyMode.DYNAMIC,
137                pos,
138                radius=0.25,
139                colour=(0.3, 0.6, 1.0, 1.0),
140                mask=0x0,
141            )
142            self._chain_bodies.append(body)
143            self._chain_start.append(pos)
144            self._root.add_child(PinJoint3D(body_a=prev, body_b=body, anchor=prev_pos))
145            prev = body
146            prev_pos = pos
147
148        # Give the first ball a sideways kick so the chain swings.
149        self._chain_bodies[0].velocity = Vec3(4.0, 0, 0)
150
151        # Anchor post (visual only)
152        post = self.add_child(MeshInstance3D(name="AnchorPost", mesh=Mesh.cylinder(radius=0.08, height=1.0)))
153        post.material = Material(colour=(0.5, 0.5, 0.5), roughness=0.6)
154        post.position = Vec3(anchor_pos.x, anchor_pos.y + 0.5, anchor_pos.z)
155
156        # --- Hinge door (right side) ---
157        hinge_pos = Vec3(4, 2, 0)
158
159        # Door post (static body at the hinge position).
160        self._door_post = self._make_body(
161            "DoorPostBody",
162            BodyMode.STATIC,
163            hinge_pos,
164            radius=0.12,
165            colour=(0.6, 0.6, 0.6, 1.0),
166            mask=0x0,
167            visible=False,
168        )
169
170        # Door body (dynamic, offset from the hinge), a box panel.
171        self._door_start = Vec3(hinge_pos.x + 1.0, hinge_pos.y, hinge_pos.z)
172        self._door_body = PhysicsBody3D(
173            name="DoorBody",
174            mode=BodyMode.DYNAMIC,
175            mass=5.0,
176            position=self._door_start,
177            collision_mask=0x0,
178        )
179        self._door_body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(1.0, 1.75, 0.06))))
180        self._door_body.add_child(
181            MeshInstance3D(
182                name="DoorVis",
183                mesh=Mesh.cube(),
184                material=Material(colour=(0.7, 0.4, 0.15), roughness=0.6),
185                scale=Vec3(2.0, 3.5, 0.12),
186            )
187        )
188        self._root.add_child(self._door_body)
189
190        # Hinge joint -- rotates about the vertical Y axis at the hinge pivot.
191        self._root.add_child(
192            HingeJoint3D(
193                body_a=self._door_post,
194                body_b=self._door_body,
195                anchor=hinge_pos,
196                axis=Vec3(0, 1, 0),
197            )
198        )
199
200        # Door post visual.
201        dp = self.add_child(MeshInstance3D(name="DoorPost", mesh=Mesh.cylinder(radius=0.1, height=4.0)))
202        dp.material = Material(colour=(0.6, 0.6, 0.6), roughness=0.5)
203        dp.position = hinge_pos
204
205        # HUD
206        self.add_child(
207            Text2D(
208                name="HUD",
209                text="3D Joints: Space/click=push door | arrows/drag=orbit | R=reset | ESC=quit",
210                position=(10, 10),
211                font_scale=1.2,
212            )
213        )
214
215    def _make_body(self, name, mode, pos, *, radius, colour, mask, visible=True):
216        """Build a sphere PhysicsBody3D with a collider + visual, add to the world."""
217        body = PhysicsBody3D(name=name, mode=mode, mass=1.0, position=pos, collision_mask=mask)
218        body.add_child(CollisionShape3D(shape=SphereShape3D(radius=radius)))
219        if visible:
220            body.add_child(
221                MeshInstance3D(
222                    mesh=Mesh.sphere(radius=radius),
223                    material=Material(colour=colour, roughness=0.3, metallic=0.5),
224                )
225            )
226        self._root.add_child(body)
227        return body
228
229    def _reset(self):
230        # A full pose reset is four writes per body: position and rotation put the
231        # node back (each assignment teleports the simulated body), velocity and
232        # spin clear the momentum the solver would otherwise keep integrating.
233        for body, pos in zip(self._chain_bodies, self._chain_start, strict=True):
234            body.position = Vec3(pos.x, pos.y, pos.z)
235            body.rotation = Quat()
236            body.velocity = Vec3()
237            body.spin = Vec3()
238        self._chain_bodies[0].velocity = Vec3(4.0, 0, 0)
239        self._door_body.position = Vec3(self._door_start.x, self._door_start.y, self._door_start.z)
240        self._door_body.rotation = Quat()
241        self._door_body.velocity = Vec3()
242        self._door_body.spin = Vec3()
243
244    def on_update(self, dt: float):
245        if Input.is_action_just_pressed("quit"):
246            self.app.quit()
247            return
248        if Input.is_action_just_pressed("reset"):
249            self._reset()
250
251        push_door = Input.is_action_just_pressed("push_door")
252
253        # Mouse/touch: horizontal drag orbits, a tap (click without dragging) pushes the door.
254        if Input.is_mouse_button_pressed(MouseButton.LEFT):
255            dx = float(Input.mouse_delta.x)
256            self._drag_dist += abs(dx)
257            self._orbit -= dx * 0.005
258        if Input.is_mouse_button_just_released(MouseButton.LEFT):
259            push_door = push_door or self._drag_dist < 6.0
260            self._drag_dist = 0.0
261
262        # Push door: give it a sideways shove (the hinge converts it to swing).
263        if push_door:
264            self._door_body.velocity = Vec3(0, 0, 6.0)
265
266        # Camera orbit
267        if Input.is_action_pressed("orbit_left"):
268            self._orbit -= 1.5 * dt
269        if Input.is_action_pressed("orbit_right"):
270            self._orbit += 1.5 * dt
271        r = 14.0
272        self._cam.position = Vec3(math.sin(self._orbit) * r, 4, math.cos(self._orbit) * r)
273        self._cam.look_at(Vec3(0, 2, 0))
274
275
276def _selftest() -> bool:
277    """Headless: run the real scene offscreen and check what its joints claim to do.
278
279    One offscreen pass with the door pushed through the same named action a player
280    presses, so the constraint solve is reached the way the demo reaches it. Frames
281    are 1/60 s. Every quantity is sampled on every frame rather than at the end, so
282    a constraint that holds for a while and then lets go fails here instead of
283    being stepped over.
284    """
285    from simvx.core.testing import InputSimulator
286    from simvx.graphics.testing import assert_not_blank, save_png
287
288    PUSH = 90  # by now the chain has settled, and the door has never been touched
289    RESET = 200  # R, once the door has swung well past a right angle
290    FRAMES = 220
291
292    app = App(title="3D Joints", width=1280, height=720, visible=False)
293    scene = JointsDemo(name="JointsDemo")
294    sim = InputSimulator()
295
296    # How far the tip hangs when every pin in the chain holds its rest separation.
297    chain_span = CHAIN_LEN * LINK_DIST
298
299    links: list[float] = []  # separation of each consecutive pinned pair
300    drops: list[float] = []  # how far the last bead hangs below the anchor
301    radii: list[float] = []  # door centre to hinge post
302    sweep: list[tuple[float, float]] = []  # where the door's centre sat, in x and z
303    kicked: list[float] = []  # the top bead's x, which the on_ready kick sets swinging
304    sags: list[float] = []  # how far the door has dropped relative to its hinge
305    uprights: list[float] = []  # the door's own up axis, projected onto world up
306    turns: list[float] = []  # the door's swing about the hinge axis, in degrees
307    strays: list[float] = []  # how far either static body has moved since it was built
308    homes: dict[str, tuple[float, ...]] = {}
309    restored: dict[str, object] = {}
310
311    def where(node: PhysicsBody3D) -> tuple[float, ...]:
312        return tuple(float(v) for v in node.position)
313
314    def apart(a: tuple[float, ...], b: tuple[float, ...]) -> float:
315        return max(abs(u - v) for u, v in zip(a, b, strict=True))
316
317    def on_frame(idx: int, _t: float) -> bool:
318        anchor, post, door = scene._chain_anchor, scene._door_post, scene._door_body
319        if idx == 0:
320            homes["anchor"], homes["post"] = where(anchor), where(post)
321
322        previous = anchor.position
323        for bead in scene._chain_bodies:
324            links.append(float((bead.position - previous).length()))
325            previous = bead.position
326        drops.append(float(anchor.position.y - scene._chain_bodies[-1].position.y))
327        strays.append(max(apart(where(anchor), homes["anchor"]), apart(where(post), homes["post"])))
328
329        # The door's own axes turned into world space: where its face points (the
330        # swing) and where its top points, which the hinge's angular lock holds at
331        # world up so that turning about the hinge axis is the only freedom left.
332        facing = door.rotation * Vec3(1.0, 0.0, 0.0)
333        radii.append(float((door.position - post.position).length()))
334        sweep.append((float(door.position.x), float(door.position.z)))
335        kicked.append(float(scene._chain_bodies[0].position.x))
336        sags.append(abs(float(door.position.y - post.position.y)))
337        uprights.append(float((door.rotation * Vec3(0.0, 1.0, 0.0)).y))
338        turns.append(math.degrees(math.atan2(float(facing.z), float(facing.x))))
339
340        if idx == PUSH:
341            sim.press_key(Key.SPACE)
342        elif idx == PUSH + 1:
343            sim.release_key(Key.SPACE)
344        elif idx == RESET:
345            sim.press_key(Key.R)
346        elif idx == RESET + 1:
347            # on_frame runs before the tick, so the R pressed on the frame before
348            # has already been read: this samples the scene just after the reset.
349            sim.release_key(Key.R)
350            restored["chain"] = [where(b) for b in scene._chain_bodies]
351            restored["door"] = where(door)
352            restored["turn"] = turns[-1]
353        return True
354
355    frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[FRAMES - 1])
356    assert_not_blank(frames[0])
357    save_png(frames[0], "/tmp/joints3d_test.png")
358
359    # Which solver these numbers came from. Every figure below is the resolved
360    # backend's, and the resolution is by precedence, so a reader who does not
361    # print this cannot tell whether an optional accelerator answered.
362    print(f"backend: {type(scene._root.world).__name__}")
363
364    ok = True
365
366    def check(label: str, passed: bool, detail: str) -> None:
367        nonlocal ok
368        ok = ok and passed
369        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
370
371    # Each PinJoint3D holds its pair at the separation it was built with. The beads
372    # are ordinary dynamic bodies with nothing underneath them, so the separation is
373    # the constraint and nothing else; the residue is the solver's own softness.
374    worst_link = max(abs(d - LINK_DIST) for d in links)
375    check(
376        "every pinned link keeps its rest separation",
377        worst_link < LINK_DIST * 0.05,
378        f"worst link is {worst_link:.4f} off {LINK_DIST} ({worst_link / LINK_DIST * 100:.1f}%)",
379    )
380
381    # And the chain hangs from its anchor rather than falling away from it: the tip
382    # stays inside the reach the pins give it for the whole run, where an unpinned
383    # bead would have dropped tens of metres in the same time.
384    check(
385        "the chain hangs its full length below the static anchor",
386        chain_span * 0.9 < min(drops) and max(drops) < chain_span * 1.05,
387        f"tip hangs {min(drops):.3f}..{max(drops):.3f} below it (the chain is {chain_span:.1f} long)",
388    )
389
390    # Both ends of the demo are pinned to STATIC bodies, which the simulation reads
391    # and never writes: they hold whatever the scene built them at.
392    check("the static anchor and hinge post never move", max(strays) == 0.0, f"largest movement {max(strays):.6f}")
393
394    # Nothing touched the door before the push, so anything it does afterwards is
395    # the push's doing rather than gravity's or the solver's.
396    still = max(abs(t) for t in turns[: PUSH + 1])
397    check("the door hangs still until it is pushed", still < 0.001, f"turned {still:.6f} deg before the push")
398
399    # The chain is a pendulum, not a rod: the kick the top bead is given in
400    # on_ready traces an arc, rather than nudging a chain frozen where it was
401    # built. Four seconds, before the beads below have taken the energy out of it.
402    arc = max(kicked[: 4 * 60]) - min(kicked[: 4 * 60])
403    check("the kicked chain swings rather than hanging rigid", arc > 0.4, f"the top bead's arc is {arc:.3f} wide")
404
405    # A hinge is a pin plus an angular lock that removes the two off-axis rotational
406    # freedoms, so a sideways shove turns the panel about the hinge axis and about
407    # no other: its own up axis never leaves world up.
408    swung = max(abs(t) for t in turns)
409    check("the pushed door swings past a right angle", swung > 90.0, f"reached {swung:.1f} deg from its closed face")
410    off_axis = max(abs(1.0 - u) for u in uprights)
411    check(
412        "it turns about the hinge axis and no other",
413        off_axis < 1e-3,
414        f"the door's up axis is {off_axis:.2e} off world up at worst",
415    )
416
417    # The pin half holds the panel on its post throughout: a 6 m/s shove neither
418    # carries it off along the shove nor drops it off the hinge, where a free body
419    # would have travelled metres in each direction.
420    swing_radius = max(abs(r - radii[0]) for r in radii)
421    check(
422        "the door stays hung on its hinge post",
423        swing_radius < 0.02 and max(sags) < 0.02,
424        f"hinge radius moved {swing_radius:.4f} and the door sagged {max(sags):.4f}",
425    )
426
427    # And it goes ROUND the post rather than turning on the spot: the hinge holds
428    # its anchor in the post's frame, so the panel's centre sweeps the half circle
429    # its 179 deg of turn implies (the post is at x = 4, z = 0, one unit away).
430    xs = [x for x, _ in sweep]
431    zs = [z for _, z in sweep]
432    check(
433        "and its centre orbits the post through the half-circle",
434        max(xs) - min(xs) > 1.9 and max(zs) - min(zs) > 1.9,
435        f"the centre covered x {min(xs):.2f}..{max(xs):.2f}, z {min(zs):.2f}..{max(zs):.2f}",
436    )
437
438    # R restores position, orientation and the momentum the solver would otherwise
439    # keep integrating. The expectation is the scene's own start poses, so moving a
440    # body in the scene above moves this with it rather than breaking it.
441    chain_home = [tuple(float(v) for v in p) for p in scene._chain_start]
442    door_home = tuple(float(v) for v in scene._door_start)
443    chain_back = max(apart(a, b) for a, b in zip(restored["chain"], chain_home, strict=True))
444    door_back = apart(restored["door"], door_home)
445    check(
446        "R returns the chain and the door to their start pose",
447        chain_back < 1e-3 and door_back < 1e-3 and abs(restored["turn"]) < 1e-3,
448        f"chain off by {chain_back:.6f}, door off by {door_back:.6f} at {restored['turn']:.6f} deg",
449    )
450
451    print("screenshot: /tmp/joints3d_test.png")
452    print("SELFTEST:", "PASS" if ok else "FAIL")
453    return ok
454
455
456if __name__ == "__main__":
457    import sys
458
459    if "--test" in sys.argv:
460        sys.exit(0 if _selftest() else 1)
461    App(title="3D Joints Demo", width=1280, height=720).run(JointsDemo())