Source code for simvx.graphics.draw2d

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