Body and World Knobs¶
damping and gravity scale per body, the rest per world.
â–¶ Run in browserTags: physics 3d damping gravity materials
A physics knob has exactly one home, and which one follows what the knob describes. Damping and gravity scale describe THIS object’s dynamics, so they live on the body: a feather and a cannonball want different drag in the same air, and a balloon and a pickup want different gravity in the same world. Solver iterations, the sleep thresholds and the contact slop describe the space everything is in, so they live on the world, where one number governs every body.
Three balls fall side by side from the same height with three damping rates, so
the only thing between them is the knob. A balloon rises on a negative gravity
scale and a pickup hangs on a zero one. Two tops get the same kick and wind down
at different rates, because angular damping is independent of linear damping.
Four crates share ONE PhysicsMaterial: swap it and all four change surface at
once, because a material is a resource many bodies hold rather than four loose
numbers copied onto each.
The world knobs are live, and visibly so. Loosen contact_slop and the settled
crates sink into the floor; shorten sleep_time_threshold and the pile parks
sooner. Changing one wakes nothing by itself – a world knob names no body, so it
cannot take support away from one – which is why the key that changes the slop
wakes the crates itself, or you would watch four sleeping boxes ignore it.
Shows:
linear_damping/angular_damping: the per-second rate at which a body sheds motion with nothing touching it, applied once per step asv = v * max(0, 1 - damping * dt) + a * dt.0coasts forever.gravity_scale:1falls,0hangs where it is put, negative rises.PhysicsMaterialas a shared resource: one instance, four bodies, and a live reassignment that reaches all of them.The world knobs on
PhysicsRoot.world:gravity,solver_iterations,position_iterations,sleep_time_thresholdandcontact_slop.That the two iteration counts are different dials: the solver one governs the velocity loop every body goes through, while
position_iterationsdrains the error left in rigid JOINTS. Nothing here is jointed, so the readout is the only place it shows – it is cycled anyway, because the knob belongs to the world beside the others and a scene that hid it would imply it did not exist. A jointed chain is what it acts on, and only on the two builtin solvers, which are the ones that run the seam’s own position pass: seeexamples/features/2d/joints.pyanddocs/core/physics_backends.md.That a world knob is deliberately outside the wake gate, and what a scene does about it.
Controls: 1 - drop the three balls again 2 - kick both tops again 3 - release the pickup (gravity_scale 0 -> 1), or park it again 4 - swap the material the four crates share (grippy <-> bouncy) G - world gravity: Earth / Moon / none I - solver_iterations: 4 / 8 / 32 P - position_iterations: 1 / 3 / 16 (joints only; nothing here is jointed) T - sleep_time_threshold: 0.1 / 0.5 / 2.0 s K - contact_slop: 0.001 / 0.02 / 0.1 (and wake the crates to show it) Arrows - orbit the camera R - rebuild the scene Escape - quit
Run: uv run python examples/features/physics/body_knobs.py Headless self-check: uv run python examples/features/physics/body_knobs.py –test
Source¶
1"""Body and World Knobs: damping and gravity scale per body, the rest per world.
2
3A physics knob has exactly one home, and which one follows what the knob
4describes. Damping and gravity scale describe THIS object's dynamics, so they
5live on the body: a feather and a cannonball want different drag in the same air,
6and a balloon and a pickup want different gravity in the same world. Solver
7iterations, the sleep thresholds and the contact slop describe the space
8everything is in, so they live on the world, where one number governs every body.
9
10Three balls fall side by side from the same height with three damping rates, so
11the only thing between them is the knob. A balloon rises on a negative gravity
12scale and a pickup hangs on a zero one. Two tops get the same kick and wind down
13at different rates, because angular damping is independent of linear damping.
14Four crates share ONE ``PhysicsMaterial``: swap it and all four change surface at
15once, because a material is a resource many bodies hold rather than four loose
16numbers copied onto each.
17
18The world knobs are live, and visibly so. Loosen ``contact_slop`` and the settled
19crates sink into the floor; shorten ``sleep_time_threshold`` and the pile parks
20sooner. Changing one wakes nothing by itself -- a world knob names no body, so it
21cannot take support away from one -- which is why the key that changes the slop
22wakes the crates itself, or you would watch four sleeping boxes ignore it.
23
24Shows:
25 - ``linear_damping`` / ``angular_damping``: the per-second rate at which a body
26 sheds motion with nothing touching it, applied once per step as
27 ``v = v * max(0, 1 - damping * dt) + a * dt``. ``0`` coasts forever.
28 - ``gravity_scale``: ``1`` falls, ``0`` hangs where it is put, negative rises.
29 - ``PhysicsMaterial`` as a shared resource: one instance, four bodies, and a
30 live reassignment that reaches all of them.
31 - The world knobs on ``PhysicsRoot.world``: ``gravity``, ``solver_iterations``,
32 ``position_iterations``, ``sleep_time_threshold`` and ``contact_slop``.
33 - That the two iteration counts are different dials: the solver one governs the
34 velocity loop every body goes through, while ``position_iterations`` drains
35 the error left in rigid JOINTS. Nothing here is jointed, so the readout is the
36 only place it shows -- it is cycled anyway, because the knob belongs to the
37 world beside the others and a scene that hid it would imply it did not exist.
38 A jointed chain is what it acts on, and only on the two builtin solvers,
39 which are the ones that run the seam's own position pass: see
40 ``examples/features/2d/joints.py`` and ``docs/core/physics_backends.md``.
41 - That a world knob is deliberately outside the wake gate, and what a scene
42 does about it.
43
44Controls:
45 1 - drop the three balls again
46 2 - kick both tops again
47 3 - release the pickup (gravity_scale 0 -> 1), or park it again
48 4 - swap the material the four crates share (grippy <-> bouncy)
49 G - world gravity: Earth / Moon / none
50 I - solver_iterations: 4 / 8 / 32
51 P - position_iterations: 1 / 3 / 16 (joints only; nothing here is jointed)
52 T - sleep_time_threshold: 0.1 / 0.5 / 2.0 s
53 K - contact_slop: 0.001 / 0.02 / 0.1 (and wake the crates to show it)
54 Arrows - orbit the camera
55 R - rebuild the scene
56 Escape - quit
57
58Run: uv run python examples/features/physics/body_knobs.py
59Headless self-check: uv run python examples/features/physics/body_knobs.py --test
60
61# /// simvx
62# tags = ["3d", "physics", "damping", "gravity", "materials"]
63# ///
64"""
65
66from __future__ import annotations
67
68import math
69
70from simvx.core import (
71 BodyMode,
72 BoxShape3D,
73 Camera3D,
74 CollisionShape3D,
75 DirectionalLight3D,
76 Input,
77 Key,
78 Material,
79 Mesh,
80 MeshInstance3D,
81 Node,
82 PhysicsBody3D,
83 PhysicsMaterial,
84 PhysicsRoot,
85 SphereShape3D,
86 Text2D,
87 Vec3,
88)
89from simvx.graphics import App
90
91#: Height the drop rack is released from, and the three rates it compares. The
92#: middle one is the seam default, so the middle ball is what an unstated body does.
93_DROP_HEIGHT = 9.0
94_BALL_RADIUS = 0.4
95_RACK = (
96 ("cannonball", -5.0, 0.0, (0.85, 0.30, 0.25, 1.0)),
97 ("default", -3.4, 0.05, (0.90, 0.86, 0.35, 1.0)),
98 ("feather", -1.8, 2.0, (0.55, 0.85, 0.95, 1.0)),
99)
100
101#: The balloon rises at a terminal speed its own damping sets: the upward
102#: acceleration is ``-gravity_scale * g`` and the drag cancels it at
103#: ``0.2 * 9.81 / 2.0`` ~ 1 m/s, which is slow enough to watch.
104_BALLOON_X = 0.6
105_BALLOON_START_Y = 1.0
106_BALLOON_CEILING = 9.0
107
108#: The two tops hang side by side on ``gravity_scale = 0`` and are given the same
109#: kick, so the only difference between them is angular damping.
110_PICKUP_X = 3.0
111_SPINNER_X = 4.6
112_TOP_Y = 2.6
113_SPIN_KICK = Vec3(0.0, 9.0, 0.0)
114
115#: Four crates on the ground, all holding one material resource between them.
116_CRATE_XS = (6.6, 8.2, 9.8, 11.4)
117_CRATE_DROP_Y = 2.0
118
119#: The rack re-drops itself on this period, so the scene keeps showing the thing it
120#: is about without anyone pressing a key. Long enough for the feather, which
121#: reaches a terminal speed of ``9.81 / 2`` m/s and takes about 2.3 s to arrive.
122_DROP_PERIOD = 3.5
123
124_GRAVITIES = ((0.0, -9.81, 0.0), (0.0, -1.62, 0.0), (0.0, 0.0, 0.0))
125_GRAVITY_NAMES = ("Earth", "Moon", "none")
126_ITERATIONS = (4, 8, 32)
127#: Joint position passes per step. Cycled beside the velocity count because both
128#: live on the world; only a jointed scene sees the difference (see the docstring).
129_POSITION_ITERATIONS = (1, 3, 16)
130_SLEEP_TIMES = (0.1, 0.5, 2.0)
131_SLOPS = (0.001, 0.02, 0.1)
132
133#: The two surfaces the crates swap between. Each is ONE instance held by all four
134#: bodies, which is the whole point of a material being a resource; it is frozen,
135#: so no crate can change what the other three are made of.
136_GRIPPY = PhysicsMaterial(friction=0.9, restitution=0.0, friction_combine="max")
137_BOUNCY = PhysicsMaterial(friction=0.1, restitution=0.8, restitution_combine="max")
138
139#: Backend for the scene's ``PhysicsRoot``. Named here as a literal rather than
140#: left to resolve at runtime so a web export can tell which runtime to bundle:
141#: set it to ``"jolt"`` to watch the same knobs on the native solver, and an
142#: export picks the Jolt runtime up with it.
143BACKEND = "builtin"
144
145_GRIPPY_LOOK = Material(colour=(0.45, 0.40, 0.34, 1.0), roughness=0.95)
146_BOUNCY_LOOK = Material(colour=(0.90, 0.45, 0.70, 1.0), roughness=0.25, metallic=0.1)
147_ASLEEP_LOOK = Material(colour=(0.28, 0.30, 0.36, 1.0), roughness=0.9)
148
149
150class BodyKnobsScene(Node):
151 # The canonical, web-safe registration path: the scene tree reads this at mount
152 # and re-applies it on every scene swap.
153 input_actions = {
154 "drop_rack": [Key.KEY_1],
155 "kick_tops": [Key.KEY_2],
156 "toggle_pickup": [Key.KEY_3],
157 "swap_material": [Key.KEY_4],
158 "cycle_gravity": [Key.G],
159 "cycle_iterations": [Key.I],
160 "cycle_position_iterations": [Key.P],
161 "cycle_sleep_time": [Key.T],
162 "cycle_slop": [Key.K],
163 "orbit_left": [Key.LEFT],
164 "orbit_right": [Key.RIGHT],
165 "rebuild": [Key.R],
166 "quit": [Key.ESCAPE],
167 }
168
169 def on_ready(self):
170 self._cam_angle = 0.35
171 self._cam = self.add_child(Camera3D())
172 self._update_camera()
173
174 sun = DirectionalLight3D(position=(8, 14, 10))
175 sun.colour = (1.0, 0.96, 0.88)
176 sun.intensity = 3.0
177 sun.look_at((3, 0, 0))
178 self.add_child(sun)
179
180 self._cube = Mesh.cube()
181 self._ball_mesh = Mesh.sphere()
182 # An explicit root, because the world knobs are reached through the world a
183 # root owns. Bodies under it simulate in that world and nothing else does.
184 self._root = self.add_child(PhysicsRoot(name="World", backend=BACKEND))
185
186 self._gravity_i, self._iterations_i, self._sleep_time_i, self._slop_i = 0, 1, 1, 0
187 self._position_iterations_i = 1
188 self._pickup_released = False
189 self._drop_clock = 0.0
190 self._built: list[PhysicsBody3D] = []
191 self._crate_material = _GRIPPY
192 self._message = "three damping rates, one drop height"
193 self._build()
194
195 # Two lines rather than one: the whole HUD has to stay inside the frame at
196 # the size the site publishes screenshots at, not just at the window size.
197 self._hud = self.add_child(
198 Text2D(
199 text="1 drop balls | 2 kick tops | 3 release pickup | 4 swap material",
200 position=(10, 10),
201 font_scale=1.2,
202 )
203 )
204 self._hud_world = self.add_child(
205 Text2D(
206 text="G gravity | I solver iters | P joint iters | T sleep time | K slop | R reset",
207 position=(10, 34),
208 font_scale=1.2,
209 )
210 )
211 self._body_line = self.add_child(Text2D(text="", position=(10, 58), font_scale=1.05))
212 self._world_line = self.add_child(Text2D(text="", position=(10, 82), font_scale=1.05))
213 self._note = self.add_child(Text2D(text="", position=(10, 106), font_scale=1.0))
214
215 # -- scene construction -------------------------------------------------
216
217 def _attach(self, body: PhysicsBody3D) -> PhysicsBody3D:
218 """Parent a body under the root and remember it, so R can tear the scene down."""
219 self._built.append(body)
220 return self._root.add_child(body)
221
222 def _ball(self, x: float, damping: float, colour: tuple[float, float, float, float]) -> PhysicsBody3D:
223 body = PhysicsBody3D(
224 mode=BodyMode.DYNAMIC,
225 position=(x, _DROP_HEIGHT, 0.0),
226 mass=1.0,
227 linear_damping=damping,
228 angular_damping=damping,
229 )
230 body.add_child(CollisionShape3D(shape=SphereShape3D(radius=_BALL_RADIUS)))
231 body.add_child(
232 MeshInstance3D(
233 mesh=self._ball_mesh, material=Material(colour=colour, roughness=0.5), scale=Vec3(_BALL_RADIUS * 2)
234 )
235 )
236 self._attach(body)
237 return body
238
239 def _build(self):
240 for body in self._built:
241 body.destroy()
242 self._built = []
243
244 ground = PhysicsBody3D(mode=BodyMode.STATIC, position=(3.0, -0.5, 0.0))
245 ground.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(11.0, 0.5, 5.0))))
246 ground.add_child(
247 MeshInstance3D(
248 mesh=self._cube,
249 material=Material(colour=(0.20, 0.22, 0.26, 1.0), roughness=0.95),
250 scale=(22, 1, 10),
251 )
252 )
253 self._attach(ground)
254 self._ground = ground
255
256 # The drop rack: identical in every respect but linear_damping.
257 self._balls = [self._ball(x, damping, colour) for _, x, damping, colour in _RACK]
258
259 # A balloon: negative gravity scale, and enough drag to rise at a readable
260 # speed rather than accelerating out of shot.
261 self._balloon = PhysicsBody3D(
262 mode=BodyMode.DYNAMIC,
263 position=(_BALLOON_X, _BALLOON_START_Y, 0.0),
264 mass=0.2,
265 gravity_scale=-0.2,
266 linear_damping=2.0,
267 can_sleep=False,
268 )
269 self._balloon.add_child(CollisionShape3D(shape=SphereShape3D(radius=0.45)))
270 self._balloon.add_child(
271 MeshInstance3D(
272 mesh=self._ball_mesh,
273 material=Material(colour=(0.95, 0.35, 0.45, 1.0), roughness=0.3),
274 scale=Vec3(0.9),
275 )
276 )
277 self._attach(self._balloon)
278
279 # Two tops that hang on gravity_scale 0, given the same kick. The pickup
280 # keeps spinning (no angular damping); the spinner winds down.
281 self._pickup = self._top(_PICKUP_X, angular_damping=0.0, colour=(0.35, 0.90, 0.55, 1.0))
282 self._spinner = self._top(_SPINNER_X, angular_damping=1.5, colour=(0.60, 0.55, 0.95, 1.0))
283 self._pickup_released = False
284 self._kick_tops()
285
286 # Four crates holding ONE material resource between them.
287 self._crates = []
288 self._crate_visuals: dict[PhysicsBody3D, MeshInstance3D] = {}
289 for x in _CRATE_XS:
290 crate = PhysicsBody3D(mode=BodyMode.DYNAMIC, position=(x, _CRATE_DROP_Y, 0.0), mass=1.0)
291 crate.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(0.5, 0.5, 0.5))))
292 crate.material = self._crate_material
293 self._attach(crate)
294 self._crates.append(crate)
295 self._crate_visuals[crate] = crate.add_child(MeshInstance3D(mesh=self._cube, material=_GRIPPY_LOOK))
296 self._apply_world_knobs()
297
298 def _top(self, x: float, *, angular_damping: float, colour) -> PhysicsBody3D:
299 body = PhysicsBody3D(
300 mode=BodyMode.DYNAMIC,
301 position=(x, _TOP_Y, 0.0),
302 mass=1.0,
303 gravity_scale=0.0,
304 linear_damping=0.0,
305 angular_damping=angular_damping,
306 can_sleep=False,
307 )
308 body.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(0.45, 0.45, 0.45))))
309 body.add_child(
310 MeshInstance3D(mesh=self._cube, material=Material(colour=colour, roughness=0.4), scale=(0.9, 0.9, 0.9))
311 )
312 self._attach(body)
313 return body
314
315 def _update_camera(self):
316 radius, height = 20.0, 7.5
317 self._cam.position = (
318 3.0 + radius * math.sin(self._cam_angle),
319 height,
320 radius * math.cos(self._cam_angle),
321 )
322 self._cam.look_at((3.0, 3.5, 0.0), up=(0, 1, 0))
323
324 # -- the things the keys do ---------------------------------------------
325
326 def _drop_rack(self, *, announce: bool = True):
327 """Put the three balls back at the same height with no motion, and let go."""
328 for ball in self._balls:
329 ball.position = (float(ball.position.x), _DROP_HEIGHT, 0.0)
330 ball.velocity = Vec3(0.0, 0.0, 0.0)
331 ball.spin = Vec3(0.0, 0.0, 0.0)
332 ball.wake()
333 self._drop_clock = 0.0
334 if announce:
335 self._message = "dropped: same height, damping 0 / 0.05 / 2.0"
336
337 def _kick_tops(self, *, announce: bool = True):
338 """The same angular impulse to both, so only angular_damping differs."""
339 for top in (self._pickup, self._spinner):
340 top.spin = _SPIN_KICK
341 top.wake()
342 if announce:
343 self._message = "both tops kicked at the same rate; only one of them keeps it"
344
345 def _toggle_pickup(self):
346 """``gravity_scale`` is what makes a pickup hang, and letting go is one write."""
347 self._pickup_released = not self._pickup_released
348 self._pickup.gravity_scale = 1.0 if self._pickup_released else 0.0
349 if not self._pickup_released:
350 self._pickup.position = (_PICKUP_X, _TOP_Y, 0.0)
351 self._pickup.velocity = Vec3(0.0, 0.0, 0.0)
352 self._message = (
353 "pickup released (gravity_scale 1)" if self._pickup_released else "pickup parked (gravity_scale 0)"
354 )
355
356 def _swap_material(self):
357 """One resource serves all four crates, so the swap is one instance, four writes.
358
359 Assigning a material whose values differ is what reaches the simulation.
360 A ``PhysicsMaterial`` is frozen, so a surface is never edited: to change
361 one coefficient, assign ``replace(crate.material, friction=0.1)``.
362 """
363 self._crate_material = _BOUNCY if self._crate_material is _GRIPPY else _GRIPPY
364 for crate in self._crates:
365 crate.material = self._crate_material
366 crate.position = (float(crate.position.x), _CRATE_DROP_Y, 0.0)
367 crate.velocity = Vec3(0.0, 0.0, 0.0)
368 crate.wake()
369 surface = "bouncy" if self._crate_material is _BOUNCY else "grippy"
370 self._message = f"one material, four crates: now {surface}, dropped again to show it"
371
372 # -- world knobs --------------------------------------------------------
373
374 def _apply_world_knobs(self):
375 world = self._root.world
376 world.gravity = Vec3(*_GRAVITIES[self._gravity_i])
377 world.solver_iterations = _ITERATIONS[self._iterations_i]
378 world.position_iterations = _POSITION_ITERATIONS[self._position_iterations_i]
379 world.sleep_time_threshold = _SLEEP_TIMES[self._sleep_time_i]
380 world.contact_slop = _SLOPS[self._slop_i]
381
382 def _cycle(self, attr: str, values, index: int, *, wake_crates: bool = False) -> int:
383 index = (index + 1) % len(values)
384 setattr(self._root.world, attr, values[index])
385 if wake_crates:
386 # A world knob names no body, so the seam deliberately wakes nothing for
387 # it. The crates are asleep by now and would sit at their old depth for
388 # ever; waking them is the scene's job, not the seam's.
389 for crate in self._crates:
390 crate.wake()
391 return index
392
393 # -- per-frame ----------------------------------------------------------
394
395 def on_update(self, dt):
396 if Input.is_action_just_pressed("quit"):
397 self.app.quit()
398 return
399 if Input.is_action_just_pressed("rebuild"):
400 self._build()
401 self._message = "rebuilt"
402 if Input.is_action_just_pressed("drop_rack"):
403 self._drop_rack()
404 if Input.is_action_just_pressed("kick_tops"):
405 self._kick_tops()
406 if Input.is_action_just_pressed("toggle_pickup"):
407 self._toggle_pickup()
408 if Input.is_action_just_pressed("swap_material"):
409 self._swap_material()
410 if Input.is_action_just_pressed("cycle_gravity"):
411 self._gravity_i = (self._gravity_i + 1) % len(_GRAVITIES)
412 self._root.world.gravity = Vec3(*_GRAVITIES[self._gravity_i])
413 for ball in self._balls:
414 ball.wake()
415 self._message = f"world gravity: {_GRAVITY_NAMES[self._gravity_i]}"
416 if Input.is_action_just_pressed("cycle_iterations"):
417 self._iterations_i = self._cycle("solver_iterations", _ITERATIONS, self._iterations_i)
418 self._message = f"solver_iterations: {_ITERATIONS[self._iterations_i]}"
419 if Input.is_action_just_pressed("cycle_position_iterations"):
420 self._position_iterations_i = self._cycle(
421 "position_iterations", _POSITION_ITERATIONS, self._position_iterations_i
422 )
423 self._message = (
424 f"position_iterations: {_POSITION_ITERATIONS[self._position_iterations_i]} "
425 "(joint passes; nothing in this scene is jointed)"
426 )
427 if Input.is_action_just_pressed("cycle_sleep_time"):
428 self._sleep_time_i = self._cycle("sleep_time_threshold", _SLEEP_TIMES, self._sleep_time_i, wake_crates=True)
429 self._message = (
430 f"sleep_time_threshold: {_SLEEP_TIMES[self._sleep_time_i]} s (the crates were woken to show it)"
431 )
432 if Input.is_action_just_pressed("cycle_slop"):
433 self._slop_i = self._cycle("contact_slop", _SLOPS, self._slop_i, wake_crates=True)
434 self._message = f"contact_slop: {_SLOPS[self._slop_i]} (the crates were woken to sink to their new depth)"
435
436 # The rack re-drops itself, so the difference between the three rates is on
437 # screen continuously rather than once when the scene loads.
438 self._drop_clock += dt
439 if self._drop_clock >= _DROP_PERIOD:
440 self._drop_rack(announce=False)
441 self._kick_tops(announce=False)
442
443 self._cam_angle += Input.get_axis("orbit_left", "orbit_right") * 1.5 * dt
444 self._update_camera()
445
446 # The balloon leaves the top of the shot eventually; send it back down so the
447 # scene keeps showing what a negative gravity scale does.
448 if float(self._balloon.position.y) > _BALLOON_CEILING:
449 self._balloon.position = (_BALLOON_X, _BALLOON_START_Y, 0.0)
450 self._balloon.velocity = Vec3(0.0, 0.0, 0.0)
451
452 for crate in self._crates:
453 look = _BOUNCY_LOOK if self._crate_material is _BOUNCY else _GRIPPY_LOOK
454 self._crate_visuals[crate].material = _ASLEEP_LOOK if crate.is_sleeping else look
455
456 self._refresh_readouts()
457
458 def _refresh_readouts(self):
459 heights = " ".join(
460 f"{name} {float(b.position.y):5.2f}" for (name, *_), b in zip(_RACK, self._balls, strict=True)
461 )
462 spin = f"spin kept {float(self._pickup.spin.y):.2f} damped {float(self._spinner.spin.y):.2f}"
463 self._body_line.text = f"fall y: {heights} | {spin} | balloon {float(self._balloon.position.y):5.2f}"
464
465 world = self._root.world
466 asleep = sum(1 for c in self._crates if c.is_sleeping)
467 rest = sum(float(c.position.y) for c in self._crates) / len(self._crates)
468 self._world_line.text = (
469 f"gravity {_GRAVITY_NAMES[self._gravity_i]} | solver_iters {world.solver_iterations} | "
470 f"joint_iters {world.position_iterations} | "
471 f"sleep_time {world.sleep_time_threshold} | contact_slop {world.contact_slop} | "
472 f"crates {asleep}/{len(self._crates)} asleep at {rest:.3f}"
473 )
474 self._note.text = self._message
475
476
477def _seam_fall(damping: float, seconds: float, gravity: float = -9.81, dt: float = 1.0 / 60.0) -> float:
478 """The distance the seam's damping formula falls, as the contract states it."""
479 v = y = 0.0
480 for _ in range(int(seconds / dt)):
481 v = v * max(0.0, 1.0 - damping * dt) + gravity * dt
482 y += v * dt
483 return y
484
485
486def _selftest() -> bool:
487 """Headless: render one frame, then check every knob does what the docs claim."""
488 from simvx.graphics.testing import assert_not_blank, save_png
489
490 app = App(title="Body Knobs", width=1280, height=720, visible=False)
491 scene = BodyKnobsScene(name="BodyKnobsScene")
492 # Captured 40 frames after an automatic re-drop, so the shot has the three
493 # balls at three different heights rather than all three already landed.
494 frames = app.run_headless(scene, frames=260, capture_frames=[250])
495 assert_not_blank(frames[0])
496 save_png(frames[0], "/tmp/physics_body_knobs_test.png")
497
498 world = scene._root.world
499 ok = True
500
501 def advance(seconds: float) -> None:
502 for _ in range(int(seconds * 60.0)):
503 world.step(1.0 / 60.0)
504 world.drain_contact_events()
505 world.drain_overlap_events()
506
507 def y_of(body) -> float:
508 return float(world.body_transform(body.handle)[0][1])
509
510 # 1. Damping: three balls, one drop height, and the fall each rate predicts.
511 scene._drop_rack()
512 fall_time = 1.0
513 advance(fall_time)
514 for (name, _, damping, _colour), ball in zip(_RACK, scene._balls, strict=True):
515 want = _DROP_HEIGHT + _seam_fall(damping, fall_time)
516 got = y_of(ball)
517 # 4%: Jolt damps after adding the step's acceleration rather than before, so
518 # a body under sustained acceleration runs a relative damping*dt slower there.
519 good = abs(got - want) <= max(0.04 * abs(want), 0.02)
520 print(f"ball {name:<11} damping {damping:<5} y={got:7.3f} (seam formula {want:7.3f}) {'ok' if good else 'BAD'}")
521 ok = ok and good
522 ordered = y_of(scene._balls[0]) < y_of(scene._balls[1]) < y_of(scene._balls[2])
523 print(f"more damping means less distance fallen: {ordered}")
524 ok = ok and ordered
525
526 # 2. gravity_scale: the balloon rose, the parked pickup did not move at all.
527 balloon_y = y_of(scene._balloon)
528 print(f"balloon (gravity_scale -0.2) rose to y={balloon_y:.3f} from {_BALLOON_START_Y}")
529 ok = ok and balloon_y > _BALLOON_START_Y + 1.0
530 pickup_y = y_of(scene._pickup)
531 print(f"pickup (gravity_scale 0) sits at y={pickup_y:.3f}, placed at {_TOP_Y}")
532 ok = ok and abs(pickup_y - _TOP_Y) < 0.01
533
534 # 3. Angular damping is independent, and independent of linear damping.
535 scene._kick_tops()
536 advance(2.0)
537 kept = abs(float(scene._pickup.spin.y))
538 lost = abs(float(scene._spinner.spin.y))
539 print(f"after 2 s: pickup spin {kept:.3f} (kick {_SPIN_KICK.y}), damped spinner {lost:.3f}")
540 ok = ok and kept > 0.95 * float(_SPIN_KICK.y) and lost < 0.5 * kept
541
542 # 4. Releasing the pickup is one write, and it falls.
543 scene._toggle_pickup()
544 advance(1.0)
545 released_y = y_of(scene._pickup)
546 print(f"released pickup fell to y={released_y:.3f}, from {_TOP_Y}")
547 ok = ok and released_y < _TOP_Y - 1.5
548
549 # 5. One material resource, four bodies.
550 shared = all(c.material is scene._crates[0].material for c in scene._crates)
551 print(f"all four crates hold the same PhysicsMaterial instance: {shared}")
552 ok = ok and shared and scene._crates[0].material is _GRIPPY
553 scene._swap_material()
554 swapped = all(c.material is _BOUNCY for c in scene._crates)
555 print(f"after one swap they all hold the bouncy one: {swapped}")
556 ok = ok and swapped
557
558 # 6. The world knobs reach the solver. contact_slop is the visible one: a looser
559 # tolerance lets a settled crate rest deeper, and it wakes nothing by itself.
560 depths = {}
561 for slop in (_SLOPS[0], _SLOPS[2]):
562 world.contact_slop = slop
563 for crate in scene._crates:
564 # Back on the grippy surface: a restitution of 0.8 keeps a crate
565 # micro-bouncing, and resting depth is what this half measures.
566 crate.material = _GRIPPY
567 crate.position = (float(crate.position.x), _CRATE_DROP_Y, 0.0)
568 crate.velocity = Vec3(0.0, 0.0, 0.0)
569 crate.wake()
570 advance(3.0)
571 depths[slop] = sum(y_of(c) for c in scene._crates) / len(scene._crates)
572 print(f"contact_slop {slop}: crates rest at y={depths[slop]:.4f} (geometric 0.5)")
573 sank = depths[_SLOPS[2]] < depths[_SLOPS[0]] - 0.005
574 print(f"a looser slop rests deeper: {sank}")
575 ok = ok and sank
576
577 world.solver_iterations = 32
578 world.sleep_time_threshold = 2.0
579 read_back = world.solver_iterations == 32 and world.sleep_time_threshold == 2.0
580 print(f"solver_iterations / sleep_time_threshold read back: {read_back}")
581 ok = ok and read_back
582
583 # position_iterations drains the error left in rigid JOINTS, and this scene has
584 # none. So what is checked is the knob itself and the claim the docstring makes
585 # about it: each value reads back, and the same drop under 1, 3 and 16 passes
586 # traces the same fall. Asserting a visible change here would be asserting
587 # something the solver does not do.
588 falls = []
589 for count in _POSITION_ITERATIONS:
590 world.position_iterations = count
591 if world.position_iterations != count:
592 print(f"position_iterations {count} did not read back")
593 ok = False
594 scene._drop_rack()
595 advance(0.75)
596 falls.append(tuple(y_of(b) for b in scene._balls))
597 same = all(all(abs(a - b) < 1e-9 for a, b in zip(fall, falls[0], strict=True)) for fall in falls)
598 print(f"position_iterations {list(_POSITION_ITERATIONS)}: same drop, same fall (no joints here): {same}")
599 ok = ok and same
600
601 world.contact_slop = _SLOPS[0]
602 world.sleep_time_threshold = 0.5
603 for crate in scene._crates:
604 crate.wake()
605 advance(4.0)
606 asleep = sum(1 for c in scene._crates if world.sleeping(c.handle))
607 print(f"crates asleep after settling at sleep_time_threshold 0.5: {asleep}/{len(scene._crates)}")
608
609 # 7. A world knob names no body, so it takes support from none and wakes none.
610 world.contact_slop = _SLOPS[2]
611 still_asleep = sum(1 for c in scene._crates if world.sleeping(c.handle))
612 print(f"changing contact_slop woke none of them: {still_asleep}/{len(scene._crates)} still asleep")
613 ok = ok and still_asleep == asleep
614
615 print("screenshot: /tmp/physics_body_knobs_test.png")
616 print("SELFTEST:", "PASS" if ok else "FAIL")
617 return ok
618
619
620if __name__ == "__main__":
621 import sys
622
623 if "--test" in sys.argv:
624 sys.exit(0 if _selftest() else 1)
625 app = App(title="Body and World Knobs", width=1280, height=720)
626 app.run(BodyKnobsScene())