Physics Raycast Sandbox¶

drop bodies into a PhysicsRoot and raycast against them.

â–¶ Run in browser

Tags: 3d

Spawn sphere and box bodies into the scene’s physics world (a PhysicsRoot with custom gravity), watch the engine simulate and bounce them on a static ground body, and fire camera-through-cursor rays that query the world via self.world.physics.raycast_all. Each hit flashes its body.

Controls: A / D - Orbit camera left / right W / S - Zoom in / out Q / E - Raise / lower camera 1 - Drop a sphere 2 - Drop a box LClick / 3 - Fire raycast toward mouse cursor R - Reset scene

Mouse / touch: tap the Sphere / Box / Reset buttons in the bottom bar to spawn and reset; click or tap anywhere above the bar to fire a ray.

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

Source¶

  1"""Physics Raycast Sandbox: drop bodies into a PhysicsRoot and raycast against them.
  2
  3Spawn sphere and box bodies into the scene's physics world (a ``PhysicsRoot``
  4with custom gravity), watch the engine simulate and bounce them on a static
  5ground body, and fire camera-through-cursor rays that query the world via
  6``self.world.physics.raycast_all``. Each hit flashes its body.
  7
  8Controls:
  9    A / D       - Orbit camera left / right
 10    W / S       - Zoom in / out
 11    Q / E       - Raise / lower camera
 12    1           - Drop a sphere
 13    2           - Drop a box
 14    LClick / 3  - Fire raycast toward mouse cursor
 15    R           - Reset scene
 16
 17Mouse / touch: tap the Sphere / Box / Reset buttons in the bottom bar to spawn
 18and reset; click or tap anywhere above the bar to fire a ray.
 19
 20Usage:
 21    uv run python examples/features/3d/collision_world.py
 22    Headless self-check: uv run python examples/features/3d/collision_world.py --test
 23"""
 24
 25import math
 26import random
 27
 28import numpy as np
 29
 30from simvx.core import (
 31    AnchorPreset,
 32    BodyMode,
 33    BoxShape3D,
 34    Button,
 35    Camera3D,
 36    CollisionShape3D,
 37    Colour,
 38    DirectionalLight3D,
 39    HBoxContainer,
 40    Input,
 41    InputMap,
 42    Key,
 43    Label,
 44    Material,
 45    Mesh,
 46    MeshInstance3D,
 47    MouseButton,
 48    Node3D,
 49    Panel,
 50    PhysicsBody3D,
 51    PhysicsMaterial,
 52    PhysicsRoot,
 53    PointLight3D,
 54    Quat,
 55    SphereShape3D,
 56    Vec3,
 57    screen_to_ray,
 58)
 59from simvx.graphics import App
 60from simvx.graphics.debug_draw import DebugDraw
 61
 62# ============================================================================
 63# Constants
 64# ============================================================================
 65
 66GRAVITY = Vec3(0, -18.0, 0)
 67GROUND_Y = 0.0
 68SPAWN_HEIGHT = 10.0
 69WIDTH, HEIGHT = 1024, 768
 70
 71PRESETS = [
 72    {"colour": (0.95, 0.08, 0.08), "metallic": 0.0, "roughness": 0.7},  # Bold red
 73    {"colour": (0.10, 0.40, 0.95), "metallic": 0.9, "roughness": 0.08},  # Chrome blue
 74    {
 75        "colour": (1.0, 0.75, 0.0),
 76        "metallic": 1.0,
 77        "roughness": 0.2,  # Gold (emissive glow)
 78        "emissive_colour": (1.0, 0.85, 0.2, 3.0),
 79    },
 80    {"colour": (0.05, 0.85, 0.25), "metallic": 0.0, "roughness": 0.8},  # Vivid green
 81    {
 82        "colour": (0.75, 0.10, 0.95),
 83        "metallic": 0.5,
 84        "roughness": 0.15,  # Neon purple (emissive glow)
 85        "emissive_colour": (0.8, 0.2, 1.0, 2.0),
 86    },
 87    {"colour": (0.95, 0.95, 1.0), "metallic": 1.0, "roughness": 0.02},  # Mirror
 88    {"colour": (1.0, 0.45, 0.0), "metallic": 0.0, "roughness": 0.5},  # Bright orange
 89    {
 90        "colour": (0.12, 0.12, 0.14),
 91        "metallic": 0.95,
 92        "roughness": 0.05,  # Dark chrome (blue emissive)
 93        "emissive_colour": (0.2, 0.5, 1.0, 1.5),
 94    },
 95]
 96
 97
 98# ============================================================================
 99# Body node: a PhysicsBody3D that owns its mesh + a hit-flash timer
100# ============================================================================
101
102
103class SandboxBody(PhysicsBody3D):
104    """A simulated body (sphere or box) that renders a mesh and tracks ray hits."""
105
106    def __init__(self, shape_type, render_mesh, half_extent, *, mode=BodyMode.DYNAMIC, **kwargs):
107        super().__init__(mode=mode, material=PhysicsMaterial(friction=0.5, restitution=0.55), **kwargs)
108        self.shape_type = shape_type
109        self.half_extent = half_extent  # bounding half-size for debug wireframe
110        self.hit_flash: float = 0.0
111        # One shape, so the `shape` Property is the whole collider. A
112        # CollisionShape3D child is for compound colliders; adding one here as well
113        # would be dead weight, because the Property wins over any child.
114        if shape_type == "sphere":
115            self.shape = SphereShape3D(radius=half_extent)
116        else:
117            self.shape = BoxShape3D(half_extents=Vec3(half_extent, half_extent, half_extent))
118        self.mesh = self.add_child(render_mesh)
119
120
121# ============================================================================
122# Main scene
123# ============================================================================
124
125
126class CollisionWorldDemo(Node3D):
127    def __init__(self, **kwargs):
128        super().__init__(name="PhysicsRaycastSandbox", **kwargs)
129
130        # ---- Physics world ----
131        self.world = self.add_child(PhysicsRoot(name="World", gravity=GRAVITY))
132
133        # ---- Camera ----
134        self._cam_angle: float = 35.0
135        self._cam_height: float = 14.0
136        self._cam_dist: float = 28.0
137        self.camera = self.add_child(Camera3D(name="Camera", fov=50, near=0.1, far=200.0))
138        self._update_camera()
139
140        # ---- Lights ----
141        sun = self.add_child(DirectionalLight3D(name="Sun"))
142        sun.colour = (1.0, 0.97, 0.90)
143        sun.intensity = 1.5
144        sun.rotation = Quat.from_euler(math.radians(-55), math.radians(-40), 0)
145
146        fill = self.add_child(PointLight3D(name="Fill", position=Vec3(-10, 8, 10)))
147        fill.colour = (0.3, 0.4, 0.9)
148        fill.intensity = 0.8
149        fill.range = 35.0
150
151        rim = self.add_child(PointLight3D(name="Rim", position=Vec3(12, 5, -8)))
152        rim.colour = (1.0, 0.6, 0.2)
153        rim.intensity = 0.6
154        rim.range = 30.0
155
156        # ---- Ground: a STATIC body so dropped bodies rest on it ----
157        ground = PhysicsBody3D(
158            name="Ground",
159            mode=BodyMode.STATIC,
160            position=Vec3(0, -0.05, 0),
161            material=PhysicsMaterial(friction=0.8, restitution=0.3),
162        )
163        ground.add_child(CollisionShape3D(shape=BoxShape3D(half_extents=Vec3(15, 0.05, 15))))
164        ground.add_child(
165            MeshInstance3D(
166                name="GroundMesh",
167                mesh=Mesh.cube(1.0),
168                material=Material(colour=(0.06, 0.06, 0.08), metallic=0.1, roughness=0.9),
169                scale=Vec3(30, 0.1, 30),
170            )
171        )
172        self.world.add_child(ground)
173
174        self._bodies: list[SandboxBody] = []
175        self._spawn_count: int = 0
176
177        # ---- Raycast state (multiple rays persist with fade) ----
178        self._rays: list[dict] = []  # [{origin, target, hits, timer}, ...]
179        self._max_rays = 10
180
181        # ---- Status ----
182        self._last_action = "Ready"
183
184        self._build_hud()
185
186    def on_ready(self):
187        InputMap.add_action("cam_left", [Key.A, Key.LEFT])
188        InputMap.add_action("cam_right", [Key.D, Key.RIGHT])
189        InputMap.add_action("cam_fwd", [Key.W, Key.UP])
190        InputMap.add_action("cam_back", [Key.S, Key.DOWN])
191        InputMap.add_action("cam_up", [Key.Q])
192        InputMap.add_action("cam_down", [Key.E])
193        InputMap.add_action("spawn_sphere", [Key.KEY_1])
194        InputMap.add_action("spawn_box", [Key.KEY_2])
195        InputMap.add_action("fire_ray", [Key.KEY_3])
196        InputMap.add_action("reset", [Key.R])
197        self._place_pedestals()
198
199    # ------------------------------------------------------------------
200    # Pedestal ring: 8 settled bodies showcasing different meshes + materials
201    # ------------------------------------------------------------------
202
203    def _place_pedestals(self):
204        meshes_and_shapes = [
205            (Mesh.sphere(0.7, rings=16, segments=24), "sphere", 0.7),
206            (Mesh.cube(1.0), "box", 0.5),
207            (Mesh.sphere(0.7, rings=16, segments=24), "sphere", 0.7),
208            (Mesh.sphere(0.7, rings=16, segments=24), "sphere", 0.7),
209            (Mesh.sphere(0.65, rings=12, segments=16), "sphere", 0.65),
210            (Mesh.cube(0.9), "box", 0.45),
211            (Mesh.sphere(0.6, rings=12, segments=16), "sphere", 0.6),
212            (Mesh.sphere(0.6, rings=12, segments=16), "sphere", 0.6),
213        ]
214
215        for i, (mesh, stype, half) in enumerate(meshes_and_shapes):
216            angle = (i / len(meshes_and_shapes)) * math.tau
217            x = math.cos(angle) * 9.0
218            z = math.sin(angle) * 9.0
219            y = half + GROUND_Y
220
221            preset = PRESETS[i % len(PRESETS)]
222            scale = Vec3(half * 2, half * 2, half * 2) if stype == "box" else Vec3(1, 1, 1)
223            mi = MeshInstance3D(name=f"Pedestal{i}Mesh", mesh=mesh, material=Material(**preset), scale=scale)
224            # Pedestals are STATIC: they sit on the ring and serve as ray targets.
225            body = SandboxBody(stype, mi, half, mode=BodyMode.STATIC, name=f"Pedestal{i}", position=Vec3(x, y, z))
226            self.world.add_child(body)
227            self._bodies.append(body)
228            self._spawn_count += 1
229
230    # ------------------------------------------------------------------
231    # Camera
232    # ------------------------------------------------------------------
233
234    def _update_camera(self):
235        rad = math.radians(self._cam_angle)
236        x = math.cos(rad) * self._cam_dist
237        z = math.sin(rad) * self._cam_dist
238        self.camera.position = Vec3(x, self._cam_height, z)
239        self.camera.look_at(Vec3(0, 2.5, 0))
240
241    # ------------------------------------------------------------------
242    # Spawning
243    # ------------------------------------------------------------------
244
245    def _spawn(self, shape_type: str):
246        x = random.uniform(-5, 5)
247        z = random.uniform(-5, 5)
248        preset = random.choice(PRESETS)
249        self._spawn_count += 1
250
251        if shape_type == "sphere":
252            r = random.uniform(0.35, 0.8)
253            mi = MeshInstance3D(
254                name=f"Sphere{self._spawn_count}Mesh",
255                mesh=Mesh.sphere(r, rings=12, segments=16),
256                material=Material(**preset),
257            )
258            body = SandboxBody("sphere", mi, r, name=f"Sphere{self._spawn_count}", position=Vec3(x, SPAWN_HEIGHT, z))
259        else:
260            h = random.uniform(0.25, 0.65)
261            mi = MeshInstance3D(
262                name=f"Box{self._spawn_count}Mesh",
263                mesh=Mesh.cube(1.0),
264                material=Material(**preset),
265                scale=Vec3(h * 2, h * 2, h * 2),
266            )
267            body = SandboxBody("box", mi, h, name=f"Box{self._spawn_count}", position=Vec3(x, SPAWN_HEIGHT, z))
268
269        self.world.add_child(body)
270        self._bodies.append(body)
271        self._last_action = f"Dropped {shape_type}"
272
273    # ------------------------------------------------------------------
274    # Raycast: fires from camera through mouse cursor
275    # ------------------------------------------------------------------
276
277    def _fire_ray(self):
278        mouse = Input.mouse_position
279        sw, sh = self.tree.screen_size
280        view = self.camera.view_matrix
281        proj = self.camera.projection_matrix(sw / sh if sh > 0 else 1.0)
282        origin, d = screen_to_ray(mouse, (sw, sh), view, proj)
283
284        # Query the SAME world the bodies live in: ``self.physics`` resolves to
285        # the nearest PhysicsRoot *ancestor* (or the tree default), but our bodies
286        # are children of ``self.world`` -- a child PhysicsRoot -- so we must query
287        # through it, else the ray hits the (empty) default world.
288        hits = self.world.physics.raycast_all(Vec3(*origin), Vec3(*d), distance=60.0)
289
290        # A camera-through-cursor ray is collinear with the view, so it would draw
291        # as a single dot from this camera. Offset the beam's origin slightly below
292        # the camera to give it visible screen-space length; its endpoint stays on
293        # the true target (nearest hit, or a far point along the ray) so the beam
294        # visibly terminates where the cursor points.
295        vis_origin = origin - self.camera.up * 1.5
296        vis_origin_np = np.array([vis_origin.x, vis_origin.y, vis_origin.z], dtype=np.float32)
297        if hits:
298            target_pt = np.asarray(hits[0].point, dtype=np.float32)
299        else:
300            ray_dir = np.array([d.x, d.y, d.z], dtype=np.float32)
301            target_pt = np.array([origin.x, origin.y, origin.z], dtype=np.float32) + ray_dir * 60.0
302
303        for hit in hits:
304            if isinstance(hit.node, SandboxBody):
305                hit.node.hit_flash = 1.0
306
307        self._rays.append({"origin": vis_origin_np, "target": target_pt, "hits": hits, "timer": 5.0})
308        if len(self._rays) > self._max_rays:
309            self._rays.pop(0)
310
311        n = len(hits)
312        self._last_action = f"Ray: {n} hit{'s' if n != 1 else ''}"
313
314    # ------------------------------------------------------------------
315    # HUD: anchored UI widgets, so nothing here does pixel arithmetic
316    # ------------------------------------------------------------------
317
318    def _build_hud(self):
319        """Status panel top-left, tappable action bar along the bottom.
320
321        Both are anchored Controls, so they track the window on resize and their
322        rects are already in the same (window-logical) space as the mouse: the
323        click router below just asks the bar whether it was hit.
324        """
325        panel = Panel(name="InfoPanel")
326        panel.set_anchor_preset(AnchorPreset.TOP_LEFT)
327        panel.margin_left = 12.0
328        panel.margin_top = 12.0
329        panel.size = (460.0, 86.0)
330        panel.bg_colour = Colour((0.0, 0.0, 0.0, 0.45))
331        self.add_child(panel)
332
333        self._info = Label("", name="Info")
334        self._info.set_anchor_preset(AnchorPreset.FULL_RECT)
335        self._info.margin_left = 12.0
336        self._info.margin_top = 10.0
337        self._info.margin_right = 12.0
338        self._info.margin_bottom = 10.0
339        self._info.font_size = 15.0
340        self._info.vertical_alignment = "top"
341        panel.add_child(self._info)
342
343        actions = [
344            ("Sphere", lambda: self._spawn("sphere")),
345            ("Box", lambda: self._spawn("box")),
346            ("Reset", self._reset),
347        ]
348        buttons = []
349        for label, handler in actions:
350            btn = Button(label, name=f"Btn{label}", on_press=handler)
351            btn.size = (120.0, 34.0)
352            buttons.append(btn)
353
354        self._hud_bar = Panel(name="ActionBar")
355        self._hud_bar.place_bottom_strip(58.0)
356        self._hud_bar.bg_colour = Colour((0.0, 0.0, 0.0, 0.45))
357        self.add_child(self._hud_bar)
358
359        row = HBoxContainer(name="ActionRow", children=buttons)
360        row.separation = 8.0
361        row.set_anchor_preset(AnchorPreset.FULL_RECT)
362        row.margin_left = row.margin_right = 12.0
363        row.margin_top = row.margin_bottom = 12.0
364        self._hud_bar.add_child(row)
365
366    # ------------------------------------------------------------------
367    # Reset
368    # ------------------------------------------------------------------
369
370    def _reset(self):
371        for b in self._bodies:
372            b.destroy()
373        self._bodies.clear()
374        self._spawn_count = 0
375        self._rays.clear()
376        self._place_pedestals()
377        self._last_action = "Reset"
378
379    # ------------------------------------------------------------------
380    # Physics (fixed timestep)
381    # ------------------------------------------------------------------
382
383    def on_fixed_update(self, dt: float):
384        # Camera (continuous input)
385        speed = 45.0
386        if Input.is_action_pressed("cam_left"):
387            self._cam_angle += speed * dt
388        if Input.is_action_pressed("cam_right"):
389            self._cam_angle -= speed * dt
390        if Input.is_action_pressed("cam_fwd"):
391            self._cam_dist = max(10, self._cam_dist - 12 * dt)
392        if Input.is_action_pressed("cam_back"):
393            self._cam_dist = min(45, self._cam_dist + 12 * dt)
394        if Input.is_action_pressed("cam_up"):
395            self._cam_height = min(30, self._cam_height + 8 * dt)
396        if Input.is_action_pressed("cam_down"):
397            self._cam_height = max(3, self._cam_height - 8 * dt)
398        self._update_camera()
399
400        for b in self._bodies:
401            if b.hit_flash > 0:
402                b.hit_flash = max(0, b.hit_flash - dt * 2.5)
403
404        for ray in self._rays:
405            ray["timer"] -= dt
406        self._rays = [r for r in self._rays if r["timer"] > 0]
407
408    # ------------------------------------------------------------------
409    # Visual (process runs every frame)
410    # ------------------------------------------------------------------
411
412    def on_update(self, dt: float):
413        # ---- Discrete input ----
414        if Input.is_action_just_pressed("spawn_sphere"):
415            self._spawn("sphere")
416        if Input.is_action_just_pressed("spawn_box"):
417            self._spawn("box")
418        if Input.is_action_just_pressed("fire_ray"):
419            self._fire_ray()
420        # A click on the action bar belongs to its Buttons, not to the world.
421        if Input.is_mouse_button_just_pressed(MouseButton.LEFT) and not self._hud_bar.is_point_inside(
422            Input.mouse_position
423        ):
424            self._fire_ray()
425        if Input.is_action_just_pressed("reset"):
426            self._reset()
427
428        # ---- DebugDraw: ground grid ----
429        half = 15
430        gc = (0.15, 0.16, 0.22, 0.35)
431        for i in range(-half, half + 1, 3):
432            fi = float(i)
433            DebugDraw.line((-half, 0.01, fi), (half, 0.01, fi), gc)
434            DebugDraw.line((fi, 0.01, -half), (fi, 0.01, half), gc)
435
436        # Origin axes
437        DebugDraw.axes((0, 0.02, 0), size=1.5)
438
439        # ---- DebugDraw: collision wireframes ----
440        for b in self._bodies:
441            p = b.world_position
442            c = (p.x, p.y, p.z)
443            if b.hit_flash > 0:
444                t = b.hit_flash
445                col = (1.0, 0.1 + 0.4 * t, 0.05, 0.95)
446            else:
447                col = (0.1, 0.9, 0.3, 0.5)
448            if b.shape_type == "sphere":
449                DebugDraw.sphere(c, b.half_extent, col, segments=10)
450            else:
451                DebugDraw.box(c, (b.half_extent, b.half_extent, b.half_extent), col)
452
453        # ---- DebugDraw: active rays (all persist with fade) ----
454        # timer: 5->3 full brightness, 3->0 fade out
455        for ray in self._rays:
456            a = min(1.0, ray["timer"] / 3.0)
457            DebugDraw.line(tuple(ray["origin"]), tuple(ray["target"]), colour=(1.0, 1.0, 0.0, a))
458            for hit in ray["hits"]:
459                pt = hit.point
460                DebugDraw.sphere((float(pt[0]), float(pt[1]), float(pt[2])), 0.5, (1.0, 0.0, 0.0, a), segments=10)
461
462        # ---- HUD update ----
463        total_hits = sum(len(r["hits"]) for r in self._rays)
464        self._info.text = (
465            "PHYSICS RAYCAST\n"
466            f"Bodies: {len(self._bodies)}   Rays: {len(self._rays)}   Hits: {total_hits}   [{self._last_action}]\n"
467            "Click / 3: ray    1 / 2: drop    R: reset    WASD, QE: camera"
468        )
469
470
471# ============================================================================
472# Main
473# ============================================================================
474
475
476def main():
477    app = App(title="SimVX Physics Raycast Sandbox", width=WIDTH, height=HEIGHT, physics_fps=60)
478    app.run(CollisionWorldDemo())
479
480
481def _selftest() -> bool:
482    """Headless: drop bodies with the real keys, let them settle, then query them with a ray.
483
484    One offscreen run of the real scene. The number keys are pressed on the frames
485    a player would press them, so spawning and resetting go through the same named
486    actions and ``is_action_just_pressed`` edges the demo reads. ``on_frame`` runs
487    BEFORE the frame's tick, so a key pressed on frame N is acted on by that
488    frame's update and read back on frame N + 1. Frames are 1/60 s.
489    """
490    import random
491
492    from simvx.core.testing import InputSimulator
493    from simvx.graphics.testing import assert_not_blank, save_png
494
495    # The script: drop one of each, watch them fall and settle, look for them with a
496    # ray, then clear the scene.
497    SPHERE = 5  # press 1
498    BOX = 12  # press 2
499    FALL = (20, 30, 40)  # three evenly spaced samples of the sphere in free flight
500    REST = 380  # long settled, bounces and all
501    STILL = 430  # and still exactly there, so "at rest" means at rest
502    RESET = 440  # press R
503    AFTER = 445  # by which the reset has been processed
504
505    app = App(title="Physics Raycast Sandbox", width=WIDTH, height=HEIGHT, visible=False, physics_fps=60)
506    # Drop positions, sizes and colours are drawn at random; one seed makes the run
507    # reproducible without changing anything the demo does.
508    random.seed(7)
509    scene = CollisionWorldDemo()
510    sim = InputSimulator()
511    seen: dict[str, object] = {}
512    dropped: list[SandboxBody] = []
513    falls: list[float] = []
514
515    def in_world() -> list[SandboxBody]:
516        """The simulated bodies living in the PhysicsRoot, read off the node tree."""
517        return [n for n in scene.world.children if isinstance(n, SandboxBody)]
518
519    def ray_down(x: float, z: float) -> list:
520        """Fire the demo's own query straight down the world at (x, z).
521
522        The scene aims its rays from the camera through the mouse cursor, which
523        needs an unprojection this run has no cursor for; the query underneath is
524        the same one, so this hands it a ray it can build without a pointer.
525        """
526        return scene.world.physics.raycast_all(Vec3(x, 30.0, z), Vec3(0, -1, 0), distance=60.0)
527
528    def on_frame(idx: int, _t: float) -> bool:
529        if idx == 2:
530            seen["pedestals"] = len(in_world())
531            # Read the root's own gravity now: the world is dropped when the root
532            # leaves the tree, and touching it afterwards would build a fresh one.
533            seen["gravity"] = abs(float(scene.world.world.gravity.y))
534        elif idx == SPHERE:
535            sim.press_key(Key.KEY_1)
536        elif idx == SPHERE + 1:
537            sim.release_key(Key.KEY_1)
538            bodies = in_world()
539            dropped.append(bodies[-1])
540            seen["after_sphere"] = (len(bodies), bodies[-1].shape_type)
541        elif idx == BOX:
542            sim.press_key(Key.KEY_2)
543        elif idx == BOX + 1:
544            sim.release_key(Key.KEY_2)
545            bodies = in_world()
546            dropped.append(bodies[-1])
547            seen["after_box"] = (len(bodies), bodies[-1].shape_type)
548        elif idx == RESET:
549            sim.press_key(Key.R)
550        elif idx == RESET + 1:
551            sim.release_key(Key.R)
552        elif idx == AFTER:
553            seen["after_reset"] = len(in_world())
554
555        if idx in FALL:
556            falls.append(float(dropped[0].world_position.y))
557        elif idx == REST:
558            seen["rest"] = [(b.shape_type, b.half_extent, float(b.world_position.y)) for b in dropped]
559            seen["rays"] = [(b, ray_down(float(b.world_position.x), float(b.world_position.z))) for b in dropped]
560            # Well clear of the ground slab, so there is nothing out there to hit.
561            seen["miss"] = ray_down(40.0, 40.0)
562        elif idx == STILL:
563            seen["still"] = [float(b.world_position.y) for b in dropped]
564        return True
565
566    frames = app.run_headless(scene, frames=450, on_frame=on_frame, capture_frames=[449])
567    assert_not_blank(frames[0])
568    save_png(frames[0], "/tmp/collision_world_test.png")
569
570    ok = True
571
572    def check(label: str, passed: bool, detail: str) -> None:
573        nonlocal ok
574        ok = ok and passed
575        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
576
577    # Both number keys put one more body into the world the demo simulates, through
578    # the action map: the count is taken off the PhysicsRoot's children, so a body
579    # parented anywhere else would not count.
580    pedestals = seen["pedestals"]
581    count, kind = seen["after_sphere"]
582    check(
583        "1 drops a sphere into the PhysicsRoot",
584        count == pedestals + 1 and kind == "sphere",
585        f"{pedestals} -> {count} bodies, the new one a {kind}",
586    )
587    count, kind = seen["after_box"]
588    check(
589        "2 drops a box into the PhysicsRoot",
590        count == pedestals + 2 and kind == "box",
591        f"{pedestals + 1} -> {count} bodies, the new one a {kind}",
592    )
593
594    # The drop accelerates at the rate this root was built with, not at some
595    # default: three samples of free flight, and the second difference between them
596    # is the acceleration. It reads a couple of percent under the configured figure
597    # because the world sheds a little speed to linear damping on every step.
598    span = (FALL[1] - FALL[0]) / 60.0
599    measured = -(falls[0] - 2 * falls[1] + falls[2]) / (span * span)
600    check(
601        "it falls under the root's own gravity",
602        abs(measured - seen["gravity"]) < 0.03 * seen["gravity"],
603        f"measured {measured:.2f} m/s^2 against the root's {seen['gravity']:.2f}",
604    )
605
606    # Each one stops with its own half-extent between its centre and the ground's
607    # top face, which is where a body resting on a static floor has to be, and it
608    # is in exactly the same place a second later.
609    for (kind, half, y), y_later in zip(seen["rest"], seen["still"], strict=True):
610        check(
611            f"the dropped {kind} comes to rest on the ground and stays",
612            abs(y - (half + GROUND_Y)) < 0.01 and abs(y_later - y) < 1e-3,
613            f"y={y:.4f} (resting height {half + GROUND_Y:.4f}), unmoved {STILL - REST} frames later",
614        )
615
616    # A ray down each body returns that body and the ground under it, nearest
617    # first, and every hit carries the scene node that owns the geometry.
618    ground = scene.node_at("World/Ground")
619    for body, hits in seen["rays"]:
620        names = ", ".join(f"{h.node.name}@{h.distance:.2f}" for h in hits)
621        check(
622            f"the ray down the {body.shape_type} resolves back to its node",
623            len(hits) >= 2 and hits[0].node is body,
624            f"{len(hits)} hits: {names}",
625        )
626        distances = [float(h.distance) for h in hits]
627        check(
628            f"the {body.shape_type}'s hits are ordered nearest-first, ground last",
629            bool(hits) and distances == sorted(distances) and hits[-1].node is ground,
630            f"{[round(d, 2) for d in distances]}, farthest is {hits[-1].node.name if hits else 'nothing'}",
631        )
632    check("a ray into empty space returns nothing", seen["miss"] == [], f"{len(seen['miss'])} hits")
633
634    # R takes the world back to the pedestal ring: everything dropped is gone.
635    check(
636        "R resets the scene to no dropped bodies",
637        seen["after_reset"] == pedestals,
638        f"{pedestals + 2} -> {seen['after_reset']} bodies",
639    )
640
641    print("screenshot: /tmp/collision_world_test.png")
642    print("SELFTEST:", "PASS" if ok else "FAIL")
643    return ok
644
645
646if __name__ == "__main__":
647    import sys
648
649    if "--test" in sys.argv:
650        sys.exit(0 if _selftest() else 1)
651    main()