"""Immediate-mode debug line drawing.
Usage:
from simvx.graphics.debug_draw import DebugDraw
# In your process() callback:
DebugDraw.line((0,0,0), (1,1,1), colour=(1,0,0,1))
DebugDraw.box((0,0,0), (1,1,1), colour=(0,1,0,1))
DebugDraw.axes((0,0,0), size=2.0)
Lines are rendered after the main 3D pass and cleared each frame.
"""
from __future__ import annotations
import logging
import math
import numpy as np
log = logging.getLogger(__name__)
__all__ = ["DebugDraw"]
# Vertex dtype: position (vec3) + colour (vec4) = 28 bytes, matching line.vert
DEBUG_VERTEX_DTYPE = np.dtype(
[
("position", np.float32, 3),
("colour", np.float32, 4),
]
)
[docs]
class DebugDraw:
"""Singleton immediate-mode debug line renderer."""
_vertices: list[tuple] = []
_enabled: bool = True
[docs]
@classmethod
def line(cls, start, end, colour=(1.0, 1.0, 1.0, 1.0)):
"""Draw a line from start to end."""
if not cls._enabled:
return
c = _rgba(colour)
cls._vertices.append((start[0], start[1], start[2], *c))
cls._vertices.append((end[0], end[1], end[2], *c))
[docs]
@classmethod
def box(cls, center, half_extents, colour=(1.0, 1.0, 1.0, 1.0)):
"""Draw a wireframe axis-aligned box."""
if not cls._enabled:
return
cx, cy, cz = center[0], center[1], center[2]
hx, hy, hz = half_extents[0], half_extents[1], half_extents[2]
# 8 corners
corners = [
(cx - hx, cy - hy, cz - hz),
(cx + hx, cy - hy, cz - hz),
(cx + hx, cy + hy, cz - hz),
(cx - hx, cy + hy, cz - hz),
(cx - hx, cy - hy, cz + hz),
(cx + hx, cy - hy, cz + hz),
(cx + hx, cy + hy, cz + hz),
(cx - hx, cy + hy, cz + hz),
]
# 12 edges
edges = [
(0, 1),
(1, 2),
(2, 3),
(3, 0), # front
(4, 5),
(5, 6),
(6, 7),
(7, 4), # back
(0, 4),
(1, 5),
(2, 6),
(3, 7), # connecting
]
for a, b in edges:
cls.line(corners[a], corners[b], colour)
[docs]
@classmethod
def sphere(cls, center, radius, colour=(1.0, 1.0, 1.0, 1.0), segments=16):
"""Draw a wireframe sphere (3 circles: XY, XZ, YZ planes)."""
if not cls._enabled:
return
cx, cy, cz = center[0], center[1], center[2]
step = 2.0 * math.pi / segments
for plane in range(3):
for i in range(segments):
a0 = i * step
a1 = (i + 1) * step
if plane == 0: # XY
p0 = (cx + radius * math.cos(a0), cy + radius * math.sin(a0), cz)
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1), cz)
elif plane == 1: # XZ
p0 = (cx + radius * math.cos(a0), cy, cz + radius * math.sin(a0))
p1 = (cx + radius * math.cos(a1), cy, cz + radius * math.sin(a1))
else: # YZ
p0 = (cx, cy + radius * math.cos(a0), cz + radius * math.sin(a0))
p1 = (cx, cy + radius * math.cos(a1), cz + radius * math.sin(a1))
cls.line(p0, p1, colour)
[docs]
@classmethod
def ray(cls, origin, direction, length=10.0, colour=(1.0, 1.0, 0.0, 1.0)):
"""Draw a ray from origin along direction."""
if not cls._enabled:
return
end = (
origin[0] + direction[0] * length,
origin[1] + direction[1] * length,
origin[2] + direction[2] * length,
)
cls.line(origin, end, colour)
[docs]
@classmethod
def axes(cls, position=(0, 0, 0), size=1.0):
"""Draw RGB XYZ axes gizmo at position."""
if not cls._enabled:
return
px, py, pz = position[0], position[1], position[2]
cls.line((px, py, pz), (px + size, py, pz), (1, 0, 0, 1)) # X = Red
cls.line((px, py, pz), (px, py + size, pz), (0, 1, 0, 1)) # Y = Green
cls.line((px, py, pz), (px, py, pz + size), (0, 0, 1, 1)) # Z = Blue
[docs]
@classmethod
def get_vertex_data(cls) -> np.ndarray | None:
"""Return vertex data as structured numpy array, or None if empty."""
if not cls._vertices:
return None
data = np.zeros(len(cls._vertices), dtype=DEBUG_VERTEX_DTYPE)
for i, v in enumerate(cls._vertices):
data[i]["position"] = (v[0], v[1], v[2])
data[i]["colour"] = (v[3], v[4], v[5], v[6])
return data
[docs]
@classmethod
def vertex_count(cls) -> int:
return len(cls._vertices)
@classmethod
def _clear(cls):
"""Clear all debug lines (called after rendering)."""
cls._vertices.clear()
def _rgba(colour) -> tuple:
"""Normalize colour to RGBA tuple."""
if len(colour) == 3:
return (colour[0], colour[1], colour[2], 1.0)
return (colour[0], colour[1], colour[2], colour[3])