Source code for simvx.graphics.draw2d

"""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()