Debug Draw

Wireframe boxes, spheres, axes, and rays.

▶ Run in browser

Tags: 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())