Debug Draw¶
Wireframe boxes, spheres, axes, and rays.
▶ Run in browserTags: debug
DebugDraw (imported from simvx.graphics.debug_draw) lets any node scribble
world-space wireframes for visualising collisions, raycasts, AI targets, and
other invisible state. Calls belong in on_update: every primitive persists for
a single frame, so anything you want to keep seeing must be redrawn each frame.
Demonstrates line, box, sphere, ray, and axes.
Run: uv run python examples/features/debug/draw.py Headless self-check: uv run python examples/features/debug/draw.py –test
Source¶
1"""Debug Draw: Wireframe boxes, spheres, axes, and rays.
2
3`DebugDraw` (imported from `simvx.graphics.debug_draw`) lets any node scribble
4world-space wireframes for visualising collisions, raycasts, AI targets, and
5other invisible state. Calls belong in `on_update`: every primitive persists for
6a single frame, so anything you want to keep seeing must be redrawn each frame.
7Demonstrates `line`, `box`, `sphere`, `ray`, and `axes`.
8
9Run: uv run python examples/features/debug/draw.py
10Headless self-check: uv run python examples/features/debug/draw.py --test
11"""
12
13import math
14
15from simvx.core import (
16 Camera3D,
17 Material,
18 Mesh,
19 MeshInstance3D,
20 Node,
21 Text2D,
22)
23from simvx.graphics import App
24from simvx.graphics.debug_draw import DebugDraw
25
26# Shared by the solid cubes and the wireframe boxes drawn around them.
27CUBE_X = (-3, 0, 3)
28
29
30class DebugDrawScene(Node):
31 def on_ready(self):
32 # Camera
33 cam = Camera3D(position=(0, 5, -12))
34 cam.look_at((0, 0, 0))
35 self.add_child(cam)
36
37 # A few solid cubes as reference
38 for x in CUBE_X:
39 mat = Material(colour=(0.4, 0.4, 0.5, 1.0))
40 cube = MeshInstance3D(
41 mesh=Mesh.cube(),
42 material=mat,
43 position=(x, 0, 0),
44 )
45 self.add_child(cube)
46
47 self.add_child(Text2D(text="Debug Draw: boxes, sphere, axes, ray", position=(10, 10), font_scale=1.5))
48 self._time = 0.0
49
50 def on_update(self, dt):
51 self._time += dt
52
53 # Wireframe boxes around each solid cube
54 for x in CUBE_X:
55 DebugDraw.box((x, 0, 0), (0.6, 0.6, 0.6), colour=(0, 1, 0, 1))
56
57 # Origin axes
58 DebugDraw.axes((0, -1.5, 0), size=3.0)
59
60 # Orbiting wireframe sphere
61 sx = 4 * math.cos(self._time)
62 sz = 4 * math.sin(self._time)
63 DebugDraw.sphere((sx, 1, sz), radius=0.8, colour=(1, 0.5, 0, 1), segments=12)
64
65 # Ray from above pointing down
66 DebugDraw.ray((0, 4, 0), (0, -1, 0), length=3.0, colour=(1, 0, 1, 1))
67
68 # Grid on the floor
69 for i in range(-5, 6):
70 DebugDraw.line((i, -1.5, -5), (i, -1.5, 5), colour=(0.3, 0.3, 0.3, 0.5))
71 DebugDraw.line((-5, -1.5, i), (5, -1.5, i), colour=(0.3, 0.3, 0.3, 0.5))
72
73
74def _selftest() -> bool:
75 """Headless: check the wireframes are submitted, drawn, and cleared each frame.
76
77 ``DebugDraw`` queues line vertices that the renderer consumes and clears, so
78 the queue is read at two points of the same real run: straight after the
79 scene's own ``on_update``, which is what that frame asked for, and at the top
80 of the next frame, which is what the renderer left behind. A demo that
81 stopped redrawing would show the first count fall to zero; one that leaked
82 would show the second grow.
83 """
84 from simvx.graphics.testing import assert_not_blank, save_png
85
86 FRAMES = 40
87 leftover: list[int] = []
88 submitted: list[int] = []
89
90 class CountingScene(DebugDrawScene):
91 """The demo itself, noting what each of its updates queued up."""
92
93 def on_update(self, dt):
94 super().on_update(dt)
95 submitted.append(DebugDraw.vertex_count())
96
97 app = App(title="Debug Draw Demo", width=1280, height=720, visible=False)
98 scene = CountingScene(name="DebugDrawScene")
99
100 def on_frame(idx: int, _t: float) -> bool:
101 # Sampled before the tick, so this is what LAST frame left behind: zero
102 # if the renderer took and cleared it.
103 leftover.append(DebugDraw.vertex_count())
104 return True
105
106 frames = app.run_headless(scene, frames=FRAMES, on_frame=on_frame, capture_frames=[FRAMES - 1])
107 assert_not_blank(frames[0])
108 save_png(frames[0], "/tmp/debug_draw_test.png")
109
110 ok = True
111
112 def check(label: str, passed: bool, detail: str) -> None:
113 nonlocal ok
114 ok = ok and passed
115 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
116
117 # Two endpoints per line: 3 boxes of 12 edges, 3 axes, a 12-segment sphere's
118 # three rings, one ray, and 22 grid lines. The sphere orbits but keeps its
119 # segment count, so every frame queues the same total.
120 expected = 2 * (len(CUBE_X) * 12 + 3 + len(CUBE_X) * 12 + 1 + 22)
121 check(
122 "every update submits every primitive the demo lists",
123 set(submitted) == {expected},
124 f"{sorted(set(submitted))} line vertices over {len(submitted)} updates, "
125 f"against {expected} for the boxes, axes, sphere, ray and grid",
126 )
127
128 check(
129 "the queue is empty at the top of every frame, so nothing accumulates",
130 set(leftover) == {0},
131 f"vertex counts seen across {len(leftover)} frames: {sorted(set(leftover))}",
132 )
133
134 # The wireframes are drawn over the scene, so the frame carries colours the
135 # solid cubes and the sky do not have: the green boxes and the magenta ray.
136 rgb = frames[0][..., :3].astype(int)
137 green = int(((rgb[..., 1] > 140) & (rgb[..., 0] < 90) & (rgb[..., 2] < 90)).sum())
138 magenta = int(((rgb[..., 0] > 140) & (rgb[..., 2] > 140) & (rgb[..., 1] < 90)).sum())
139 check(
140 "the wireframes reach the screen over the solid geometry",
141 green > 100 and magenta > 20,
142 f"{green} green box pixels and {magenta} magenta ray pixels",
143 )
144
145 print("screenshot: /tmp/debug_draw_test.png")
146 print("SELFTEST:", "PASS" if ok else "FAIL")
147 return ok
148
149
150if __name__ == "__main__":
151 import sys
152
153 if "--test" in sys.argv:
154 sys.exit(0 if _selftest() else 1)
155 app = App(title="Debug Draw Demo", width=1280, height=720)
156 app.run(DebugDrawScene())