"""Immediate-mode 2D drawing API for Vulkan backend.
Matches the SDL3 Renderer2D interface so 2D games work on both backends.
Collects per-frame geometry into CPU buffers; Draw2DPass uploads and renders.
"""
from __future__ import annotations
import math
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"]
# Backwards-compat alias: examples that imported _find_default_font get the unified version
def _find_default_font() -> str | None:
from .text_renderer import _find_font
return _find_font()
[docs]
class Draw2D(Draw2DTransformMixin, Draw2DTextMixin, Draw2DTextureMixin, Draw2DBatchMixin):
"""Vulkan-backed 2D drawing API matching SDL3 Renderer2D interface."""
_fill_verts: list[tuple] = []
_fill_indices: list[int] = []
_line_verts: list[tuple] = []
_colour: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
@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 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))
[docs]
@classmethod
def set_colour(cls, r=255, g=255, b=255, a=255):
"""Set the active draw colour. Accepts 0-255 ints or 0.0-1.0 floats."""
cls._colour = cls._norm_colour((r, g, b, a))
# ---- Filled rect ----
[docs]
@classmethod
def fill_rect(cls, pos, size_or_y=None, w=None, h=None):
if hasattr(pos, "x"):
x, y = float(pos.x), float(pos.y)
rw, rh = float(size_or_y.x), float(size_or_y.y)
elif size_or_y is not None and w is not None and h is not None:
x, y, rw, rh = float(pos), float(size_or_y), float(w), float(h)
else:
x, y, rw, rh = float(pos), float(size_or_y), float(w), float(h)
c = cls._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)
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])
# ---- Rect outline ----
[docs]
@classmethod
def draw_rect(cls, pos, size_or_y=None, w=None, h=None, colour=None):
"""Draw a rect outline. Accepts:
- draw_rect(Vec2_pos, Vec2_size)
- draw_rect(x, y, w, h)
- draw_rect(x, y, w, h, colour)
"""
if colour is not None:
cls._colour = cls._norm_colour(colour)
if hasattr(pos, "x"):
x, y = float(pos.x), float(pos.y)
rw, rh = float(size_or_y.x), float(size_or_y.y)
else:
x, y, rw, rh = float(pos), float(size_or_y), float(w), float(h)
c = cls._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)
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 drawing ----
[docs]
@classmethod
def draw_line(cls, a, b_or_y=None, x2=None, y2=None):
"""Draw a line. Accepts:
- draw_line(Vec2_a, Vec2_b)
- draw_line((x1,y1), (x2,y2))
- draw_line((x1,y1), (x2,y2), colour)
- draw_line((x1,y1), (x2,y2), colour, width)
- draw_line(x1, y1, x2, y2)
"""
p = cls._xf_pt
if hasattr(a, "x"):
ax, ay = p(float(a.x), float(a.y))
bx, by = p(float(b_or_y.x), float(b_or_y.y))
elif isinstance(a, tuple | list):
ax, ay = p(float(a[0]), float(a[1]))
bx, by = p(float(b_or_y[0]), float(b_or_y[1]))
# x2 may carry colour tuple from Line2D: draw_line(pt, pt, colour, width)
if isinstance(x2, tuple | list):
cls._colour = cls._norm_colour(x2)
else:
ax, ay = p(float(a), float(b_or_y))
bx, by = p(float(x2), float(y2))
c = cls._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):
if colour:
cls.set_colour(*colour)
c = cls._colour
p = cls._xf_pt
for a, b in pairwise(points):
ax = float(a.x) if hasattr(a, "x") else float(a[0])
ay = float(a.y) if hasattr(a, "x") else float(a[1])
bx = float(b.x) if hasattr(b, "x") else float(b[0])
by = float(b.y) if hasattr(b, "x") else float(b[1])
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 = float(a.x) if hasattr(a, "x") else float(a[0])
ay = float(a.y) if hasattr(a, "x") else float(a[1])
bx = float(b.x) if hasattr(b, "x") else float(b[0])
by = float(b.y) if hasattr(b, "x") else float(b[1])
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 drawing ----
[docs]
@classmethod
def draw_circle(cls, center, radius_or_y=None, radius=None, segments=24, colour=None):
if colour is not None:
cls._colour = cls._norm_colour(colour)
if hasattr(center, "x"):
cx, cy, r = float(center.x), float(center.y), float(radius_or_y)
else:
cx, cy, r = float(center), float(radius_or_y), float(radius)
cx, cy = cls._xf_pt(cx, cy)
r *= cls._xf_sc()
c = cls._colour
step = math.tau / segments
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),
]
)
# ---- Filled shape primitives (use fill vertex/index buffers) ----
[docs]
@classmethod
def fill_triangle(cls, x1, y1, x2, y2, x3, y3):
"""Emit a single filled triangle."""
c = cls._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 draw_polygon(cls, vertices, colour=None):
"""Fill a convex polygon using triangle fan from first vertex.
Args:
vertices: List of (x, y) tuples or Vec2 objects.
colour: Optional RGBA float tuple to set before drawing.
"""
if colour is not None:
cls._colour = cls._norm_colour(colour)
if len(vertices) < 3:
return
c = cls._colour
p = cls._xf_pt
def _pt(v):
if hasattr(v, "x"):
return p(float(v.x), float(v.y))
return p(float(v[0]), float(v[1]))
pts = [_pt(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_quad(cls, x1, y1, x2, y2, x3, y3, x4, y4):
"""Emit a filled quad from four arbitrary corners (two triangles)."""
c = cls._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):
"""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,
)
[docs]
@classmethod
def fill_circle(cls, cx, cy, radius, segments=24):
"""Emit a filled circle as a triangle fan (indexed triangles)."""
cx, cy = cls._xf_pt(cx, cy)
radius *= cls._xf_sc()
c = cls._colour
base = len(cls._fill_verts)
# Center vertex
cls._fill_verts.append((cx, cy, 0, 0, *c))
step = math.tau / segments
for i in range(segments):
a = i * step
cls._fill_verts.append((cx + math.cos(a) * radius, cy + math.sin(a) * radius, 0, 0, *c))
for i in range(segments):
cls._fill_indices.extend([base, base + 1 + i, base + 1 + (i + 1) % segments])
# ---- Convenience wrappers (float colours, one-call draw) ----
[docs]
@classmethod
def draw_thick_line_coloured(cls, x1, y1, x2, y2, width, colour):
"""Set colour (float 0-1 tuple) and draw_thick_line in one call."""
cls._colour = cls._norm_colour(colour)
cls.draw_thick_line(x1, y1, x2, y2, width)
[docs]
@classmethod
def draw_filled_circle(cls, cx, cy, radius, colour, segments=24):
"""Set colour and fill_circle in one call."""
cls._colour = cls._norm_colour(colour)
cls.fill_circle(cx, cy, radius, segments)
[docs]
@classmethod
def draw_filled_triangle(cls, x1, y1, x2, y2, x3, y3, colour):
"""Set colour and fill_triangle in one call."""
cls._colour = cls._norm_colour(colour)
cls.fill_triangle(x1, y1, x2, y2, x3, y3)
[docs]
@classmethod
def draw_filled_quad(cls, x1, y1, x2, y2, x3, y3, x4, y4, colour):
"""Set colour and fill_quad in one call."""
cls._colour = cls._norm_colour(colour)
cls.fill_quad(x1, y1, x2, y2, x3, y3, x4, y4)
[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])
[docs]
@classmethod
def draw_gradient_rect(cls, x, y, w, h, colour_top, colour_bottom):
"""Convenience: fill_rect_gradient with float-tuple colours."""
cls.fill_rect_gradient(x, y, w, h, colour_top, colour_bottom)
[docs]
@classmethod
def draw_filled_rect(cls, x, y, w, h, colour):
"""Set colour (float 0-1 tuple) and fill_rect in one call."""
cls._colour = cls._norm_colour(colour)
cls.fill_rect(x, y, w, h)
[docs]
@classmethod
def draw_rect_coloured(cls, x, y, w, h, colour):
"""Set colour (float 0-1 tuple) and draw_rect in one call."""
cls._colour = cls._norm_colour(colour)
cls.draw_rect(x, y, w, h)
[docs]
@classmethod
def draw_line_coloured(cls, x1, y1, x2, y2, colour):
"""Set colour (float 0-1 tuple) and draw_line in one call."""
cls._colour = cls._norm_colour(colour)
cls.draw_line(x1, y1, x2, y2)
# ---- 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()