3D Joints¶
A pinned chain and a hinged door, built from PinJoint3D and HingeJoint3D.
â–¶ Run in browserTags: 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_readycarries 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())