Body and World Knobs¶

damping and gravity scale per body, the rest per world.

â–¶ Run in browser

Tags: 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 as v = v * max(0, 1 - damping * dt) + a * dt. 0 coasts forever.

  • gravity_scale: 1 falls, 0 hangs where it is put, negative rises.

  • PhysicsMaterial as 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_threshold and contact_slop.

  • That the two iteration counts are different dials: the solver one governs the velocity loop every body goes through, while position_iterations drains 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: see examples/features/2d/joints.py and docs/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())