Physics Raycast Sandbox¶
drop bodies into a PhysicsRoot and raycast against them.
â–¶ Run in browserTags: 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()