"""Immediate-mode 2D drawing API for Vulkan backend.
Canonical API — one entry point per primitive, keyword-only colour/filled/thickness:
Draw2D.draw_rect(pos, size, *, colour=None, filled=False, thickness=1.0)
Draw2D.draw_line(a, b, *, colour=None, thickness=1.0)
Draw2D.draw_circle(center, radius, *, colour=None, filled=False, segments=32)
Draw2D.draw_text(text, pos, *, colour=None, scale=1.0)
Collects per-frame geometry into CPU buffers; Draw2DPass uploads and renders.
"""
import math
from contextlib import contextmanager
from itertools import pairwise
from .draw2d_batch import Draw2DBatchMixin
from .draw2d_text import Draw2DTextMixin
from .draw2d_texture import Draw2DTextureMixin
from .draw2d_transform import Draw2DTransformMixin
from .draw2d_vertex import UI_VERTEX_DTYPE
__all__ = ["Draw2D", "UI_VERTEX_DTYPE"]
_WHITE: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
def _find_default_font() -> str | None:
"""Resolve the default MSDF font path (shared with TextRenderer)."""
from .text_renderer import _find_font
return _find_font()
[docs]
class Draw2D(Draw2DTransformMixin, Draw2DTextMixin, Draw2DTextureMixin, Draw2DBatchMixin):
"""Vulkan-backed immediate-mode 2D drawing API."""
_fill_verts: list[tuple] = []
_fill_indices: list[int] = []
_line_verts: list[tuple] = []
# ---- Colour normalisation ----
@classmethod
def _norm_colour(cls, c) -> tuple[float, float, float, float]:
"""Normalise any colour input to a 4-component 0.0-1.0 float tuple."""
if c is None:
return _WHITE
if len(c) == 3:
c = (*c, 255 if isinstance(c[0], int) else 1.0)
r, g, b, a = c
if isinstance(r, int):
return (r / 255, g / 255, b / 255, a / 255)
return (float(r), float(g), float(b), float(a))
# ---- Geometry helpers ----
@staticmethod
def _xy(v) -> tuple[float, float]:
"""Extract (x, y) from a Vec2 or (x, y) tuple/list."""
if hasattr(v, "x"):
return float(v.x), float(v.y)
return float(v[0]), float(v[1])
# ---- Rectangle ----
[docs]
@classmethod
def draw_rect(cls, pos, size, *, colour=None, filled=False, thickness=1.0) -> None:
"""Draw a rectangle. filled=False draws an outline, filled=True fills the rect."""
x, y = cls._xy(pos)
rw, rh = cls._xy(size)
c = cls._norm_colour(colour)
p = cls._xf_pt
x0, y0 = p(x, y)
x1, y1 = p(x + rw, y)
x2, y2 = p(x + rw, y + rh)
x3, y3 = p(x, y + rh)
if filled:
base = len(cls._fill_verts)
cls._fill_verts.extend(
[
(x0, y0, 0, 0, *c),
(x1, y1, 0, 0, *c),
(x2, y2, 0, 0, *c),
(x3, y3, 0, 0, *c),
]
)
cls._fill_indices.extend([base, base + 1, base + 2, base, base + 2, base + 3])
else:
# Outline: thickness is currently advisory (line width handled by pipeline).
del thickness
cls._line_verts.extend(
[
(x0, y0, 0, 0, *c),
(x1, y1, 0, 0, *c),
(x1, y1, 0, 0, *c),
(x2, y2, 0, 0, *c),
(x2, y2, 0, 0, *c),
(x3, y3, 0, 0, *c),
(x3, y3, 0, 0, *c),
(x0, y0, 0, 0, *c),
]
)
# ---- Line ----
[docs]
@classmethod
def draw_line(cls, a, b, *, colour=None, thickness=1.0) -> None:
"""Draw a line from a to b."""
del thickness # Advisory; line width handled by pipeline.
ax, ay = cls._xy(a)
bx, by = cls._xy(b)
ax, ay = cls._xf_pt(ax, ay)
bx, by = cls._xf_pt(bx, by)
c = cls._norm_colour(colour)
cls._line_verts.extend(
[
(ax, ay, 0, 0, *c),
(bx, by, 0, 0, *c),
]
)
[docs]
@classmethod
def draw_lines(cls, points, closed=True, colour=None):
"""Draw a polyline (optionally closed) through the given points."""
c = cls._norm_colour(colour)
p = cls._xf_pt
for a, b in pairwise(points):
ax, ay = cls._xy(a)
bx, by = cls._xy(b)
ax, ay = p(ax, ay)
bx, by = p(bx, by)
cls._line_verts.extend(
[
(ax, ay, 0, 0, *c),
(bx, by, 0, 0, *c),
]
)
if closed and len(points) > 2:
a, b = points[-1], points[0]
ax, ay = cls._xy(a)
bx, by = cls._xy(b)
ax, ay = p(ax, ay)
bx, by = p(bx, by)
cls._line_verts.extend(
[
(ax, ay, 0, 0, *c),
(bx, by, 0, 0, *c),
]
)
# ---- Circle ----
[docs]
@classmethod
def draw_circle(cls, center, radius, *, colour=None, filled=False, segments=32) -> None:
"""Draw a circle. filled=False draws an outline, filled=True fills a triangle fan."""
cx, cy = cls._xy(center)
r = float(radius)
cx, cy = cls._xf_pt(cx, cy)
r *= cls._xf_sc()
c = cls._norm_colour(colour)
step = math.tau / segments
if filled:
base = len(cls._fill_verts)
cls._fill_verts.append((cx, cy, 0, 0, *c))
for i in range(segments):
a = i * step
cls._fill_verts.append((cx + math.cos(a) * r, cy + math.sin(a) * r, 0, 0, *c))
for i in range(segments):
cls._fill_indices.extend([base, base + 1 + i, base + 1 + (i + 1) % segments])
else:
for i in range(segments):
a, b = i * step, (i + 1) * step
cls._line_verts.extend(
[
(cx + math.cos(a) * r, cy + math.sin(a) * r, 0, 0, *c),
(cx + math.cos(b) * r, cy + math.sin(b) * r, 0, 0, *c),
]
)
# ---- Triangle / quad primitives (named *_triangle / *_quad for clarity) ----
[docs]
@classmethod
def fill_triangle(cls, x1, y1, x2, y2, x3, y3, *, colour=None):
"""Emit a single filled triangle."""
c = cls._norm_colour(colour)
p = cls._xf_pt
x1, y1 = p(x1, y1)
x2, y2 = p(x2, y2)
x3, y3 = p(x3, y3)
base = len(cls._fill_verts)
cls._fill_verts.extend(
[
(x1, y1, 0, 0, *c),
(x2, y2, 0, 0, *c),
(x3, y3, 0, 0, *c),
]
)
cls._fill_indices.extend([base, base + 1, base + 2])
[docs]
@classmethod
def fill_quad(cls, x1, y1, x2, y2, x3, y3, x4, y4, *, colour=None):
"""Emit a filled quad from four arbitrary corners (two triangles)."""
c = cls._norm_colour(colour)
p = cls._xf_pt
x1, y1 = p(x1, y1)
x2, y2 = p(x2, y2)
x3, y3 = p(x3, y3)
x4, y4 = p(x4, y4)
base = len(cls._fill_verts)
cls._fill_verts.extend(
[
(x1, y1, 0, 0, *c),
(x2, y2, 0, 0, *c),
(x3, y3, 0, 0, *c),
(x4, y4, 0, 0, *c),
]
)
cls._fill_indices.extend([base, base + 1, base + 2, base, base + 2, base + 3])
[docs]
@classmethod
def draw_thick_line(cls, x1, y1, x2, y2, width=2.0, *, colour=None):
"""Draw a thick line as a filled quad using perpendicular offsets."""
dx = x2 - x1
dy = y2 - y1
ln = math.sqrt(dx * dx + dy * dy)
if ln < 1e-6:
return
hw = width * 0.5
px, py = -dy / ln * hw, dx / ln * hw
cls.fill_quad(
x1 + px, y1 + py,
x2 + px, y2 + py,
x2 - px, y2 - py,
x1 - px, y1 - py,
colour=colour,
)
[docs]
@classmethod
def draw_polygon(cls, vertices, *, colour=None):
"""Fill a convex polygon using a triangle fan from the first vertex."""
if len(vertices) < 3:
return
c = cls._norm_colour(colour)
p = cls._xf_pt
pts = [p(*cls._xy(v)) for v in vertices]
base = len(cls._fill_verts)
cls._fill_verts.extend([(px, py, 0, 0, *c) for px, py in pts])
for i in range(1, len(pts) - 1):
cls._fill_indices.extend([base, base + i, base + i + 1])
[docs]
@classmethod
def fill_rect_gradient(cls, x, y, w, h, colour_top, colour_bottom):
"""Fill rect with vertical gradient (top colour -> bottom colour)."""
p = cls._xf_pt
x0, y0 = p(x, y)
x1, y1 = p(x + w, y)
x2, y2 = p(x + w, y + h)
x3, y3 = p(x, y + h)
ct, cb = cls._norm_colour(colour_top), cls._norm_colour(colour_bottom)
base = len(cls._fill_verts)
cls._fill_verts.extend(
[
(x0, y0, 0, 0, *ct),
(x1, y1, 0, 0, *ct),
(x2, y2, 0, 0, *cb),
(x3, y3, 0, 0, *cb),
]
)
cls._fill_indices.extend([base, base + 1, base + 2, base, base + 2, base + 3])
# ---- No-ops (handled by engine) ----
[docs]
@classmethod
def clear(cls, r=0, g=0, b=0):
pass # Background clear handled by engine
[docs]
@classmethod
def present(cls):
pass # Handled by engine
# ---- Frame reset ----
@classmethod
def _reset(cls):
cls._fill_verts.clear()
cls._fill_indices.clear()
cls._line_verts.clear()
cls._text_verts.clear()
cls._text_indices.clear()
cls._textured_quads.clear()
cls._batches.clear()
cls._clip_stack.clear()
cls._current_clip = None
cls._xf = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
cls._xf_stack.clear()
cls._has_xf = False
cls._text_width_cache.clear()
# ---- Isolated context ----
_STATE_FIELDS = (
"_fill_verts", "_fill_indices", "_line_verts",
"_text_verts", "_text_indices", "_textured_quads",
"_batches", "_clip_stack", "_current_clip",
"_xf", "_xf_stack", "_has_xf", "_text_width_cache",
)
@classmethod
@contextmanager
def _isolated(cls):
"""Provide an isolated Draw2D session without disturbing current state.
Swaps out all mutable state, installs fresh empty state, yields, then
restores the original state. Used to collect Draw2D batches from a
secondary tree (e.g. a game tree in play mode) without interfering
with the primary tree's drawing.
"""
saved = {f: getattr(cls, f) for f in cls._STATE_FIELDS}
cls._fill_verts = []
cls._fill_indices = []
cls._line_verts = []
cls._text_verts = []
cls._text_indices = []
cls._textured_quads = []
cls._batches = []
cls._clip_stack = []
cls._current_clip = None
cls._xf = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
cls._xf_stack = []
cls._has_xf = False
cls._text_width_cache = {}
try:
yield
finally:
for f, v in saved.items():
setattr(cls, f, v)