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)

Colour values are RGBA float tuples in [0, 1] (3-tuples default alpha to 1.0).
``None`` means opaque white. Designers thinking in 0-255 / hex should use
``simvx.core.properties.Colour.from_rgb255(...)`` or ``Colour.hex(...)`` to
convert to the canonical float form.

Every call appends one ``Op`` to ``Draw2D._ops``; Draw2DPass walks that list
in submission order. Submission order is the GPU order; there is no other
ordering mechanism.
"""

import math
from contextlib import contextmanager
from itertools import pairwise
from typing import ClassVar

import numpy as np

from .draw2d_ops import Op, OpKind
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()


def _polygon_signed_area(pts: list[tuple[float, float]]) -> float:
    """Shoelace signed area. Positive = counter-clockwise."""
    n = len(pts)
    s = 0.0
    for i in range(n):
        x0, y0 = pts[i]
        x1, y1 = pts[(i + 1) % n]
        s += (x1 - x0) * (y1 + y0)
    return -s * 0.5  # negate to match CCW=positive in y-down screen space


def _polygon_is_convex(pts: list[tuple[float, float]]) -> bool:
    """True if every turn between consecutive edges has the same sign."""
    n = len(pts)
    if n < 3:
        return True
    sign = 0
    for i in range(n):
        ax, ay = pts[i]
        bx, by = pts[(i + 1) % n]
        cx, cy = pts[(i + 2) % n]
        cross = (bx - ax) * (cy - by) - (by - ay) * (cx - bx)
        if cross != 0:
            if sign == 0:
                sign = 1 if cross > 0 else -1
            elif (cross > 0) != (sign > 0):
                return False
    return True


def _point_in_triangle(
    px: float, py: float,
    ax: float, ay: float, bx: float, by: float, cx: float, cy: float,
) -> bool:
    """Strict-inside test (boundary considered outside)."""
    d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by)
    d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy)
    d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay)
    neg = d1 < 0 or d2 < 0 or d3 < 0
    pos = d1 > 0 or d2 > 0 or d3 > 0
    return not (neg and pos)


def _ear_clip_triangulate(pts: list[tuple[float, float]]) -> list[int]:
    """Triangulate a simple (non-self-intersecting) polygon by ear-clipping.

    Returns a flat list of indices into *pts*: three consecutive entries
    per triangle. Assumes the polygon outline is simple; degenerate input
    falls back to a fan from vertex 0.
    """
    n = len(pts)
    if n < 3:
        return []
    # Build CCW index list so cross(a,b,c) > 0 == ear in y-down screen space
    indices = list(range(n))
    if _polygon_signed_area(pts) < 0:
        indices.reverse()

    triangles: list[int] = []
    guard = 0
    while len(indices) > 3 and guard < n * n:
        guard += 1
        ear_found = False
        for i in range(len(indices)):
            ia = indices[(i - 1) % len(indices)]
            ib = indices[i]
            ic = indices[(i + 1) % len(indices)]
            ax, ay = pts[ia]
            bx, by = pts[ib]
            cx, cy = pts[ic]
            cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
            if cross <= 0:
                continue  # reflex (or colinear) corner, not an ear
            # Reject if any other polygon vertex sits inside triangle (a,b,c)
            blocked = False
            for j in indices:
                if j in (ia, ib, ic):
                    continue
                if _point_in_triangle(*pts[j], ax, ay, bx, by, cx, cy):
                    blocked = True
                    break
            if blocked:
                continue
            triangles.extend([ia, ib, ic])
            del indices[i]
            ear_found = True
            break
        if not ear_found:
            # Self-intersecting / numerically degenerate: fan-fallback.
            return [j for i in range(1, n - 1) for j in (0, i, i + 1)]
    if len(indices) == 3:
        triangles.extend(indices)
    return triangles


def _verts_from_tuples(vert_list) -> np.ndarray:
    """Convert 8-float tuples (pos.xy, uv.xy, colour.rgba) to a UI_VERTEX_DTYPE array.

    Accepts a plain list-of-tuples (op submission shape) or an already-built
    structured numpy array (callers that have one). One numpy allocation per
    coalesced run, not per draw call.
    """
    if isinstance(vert_list, np.ndarray) and vert_list.dtype == UI_VERTEX_DTYPE:
        return vert_list
    arr = np.asarray(vert_list, dtype=np.float32)  # (N, 8)
    verts = np.empty(arr.shape[0], dtype=UI_VERTEX_DTYPE)
    verts["position"] = arr[:, :2]
    verts["uv"] = arr[:, 2:4]
    verts["colour"] = arr[:, 4:8]
    return verts


[docs] class Draw2D(Draw2DTransformMixin, Draw2DTextMixin, Draw2DTextureMixin): """Vulkan-backed immediate-mode 2D drawing API. Owns the ordered ops list (``_ops``) and the scissor clip stack (``_clip_stack`` / ``_current_clip``). Every Draw2D submission appends a single ``Op`` to ``_ops``; ``Draw2DPass`` walks the list in order and coalesces adjacent same-(kind, clip, tex_id) ops into one GPU draw. Submission order is the GPU order; there is no other ordering mechanism. ``push_clip`` / ``pop_clip`` / ``reset_clip`` are the only public hooks that mutate state without emitting an op. """ # ---- Op list + clip stack (formerly Draw2DBatchMixin) ---- _ops: ClassVar[list[Op]] = [] _clip_stack: ClassVar[list[tuple[int, int, int, int] | None]] = [] _current_clip: ClassVar[tuple[int, int, int, int] | None] = None
[docs] @classmethod def push_clip(cls, x: int, y: int, w: int, h: int) -> None: """Push a scissor clip rect; nested clips intersect with the current clip.""" new_clip = (int(x), int(y), int(w), int(h)) if cls._current_clip: cx, cy, cw, ch = cls._current_clip nx = max(cx, new_clip[0]) ny = max(cy, new_clip[1]) nx2 = min(cx + cw, new_clip[0] + new_clip[2]) ny2 = min(cy + ch, new_clip[1] + new_clip[3]) new_clip = (nx, ny, max(0, nx2 - nx), max(0, ny2 - ny)) cls._clip_stack.append(cls._current_clip) cls._current_clip = new_clip
[docs] @classmethod def pop_clip(cls) -> None: """Pop the last clip rect, restoring the previous one.""" if cls._clip_stack: cls._current_clip = cls._clip_stack.pop() else: cls._current_clip = None
[docs] @classmethod def reset_clip(cls) -> None: """Clear the clip stack and current clip, restoring full-screen drawing.""" cls._clip_stack.clear() cls._current_clip = None
# ---- Colour normalisation ---- @classmethod def _norm_colour(cls, c) -> tuple[float, float, float, float]: """Validate a colour input and return a 4-component RGBA float tuple. Strict policy: colours are 0-1 floats (HDR may exceed 1.0). The validator only widens 3-tuples to 4-tuples and casts components with ``float()``. Use ``Colour.from_rgb255(...)`` or ``Colour.hex(...)`` for 0-255 / hex input. Accepts: - ``None`` → opaque white ``(1.0, 1.0, 1.0, 1.0)`` - 3-tuple → alpha defaults to ``1.0`` - 4-tuple → passed through, cast to float """ if c is None: return _WHITE if len(c) == 3: return (float(c[0]), float(c[1]), float(c[2]), 1.0) if len(c) == 4: return (float(c[0]), float(c[1]), float(c[2]), float(c[3])) raise ValueError(f"colour must be a 3- or 4-tuple, got length {len(c)}") # ---- 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]) # ---- Op emission ---- @classmethod def _emit_fill(cls, vert_list: list[tuple], indices: list[int]) -> None: cls._ops.append(Op(OpKind.FILL, cls._current_clip, vert_list, indices, -1)) @classmethod def _emit_line(cls, vert_list: list[tuple]) -> None: cls._ops.append(Op(OpKind.LINE, cls._current_clip, vert_list, None, -1)) # ---- Rectangle ----
[docs] @classmethod def draw_rect( cls, pos, size, *, colour=None, filled=False, thickness=1.0, screen_space=False, ) -> None: """Draw a rectangle. filled=False draws an outline, filled=True fills the rect. ``thickness`` controls outline weight (``filled=False``); 1 px outlines ride the line pipeline, thicker outlines emit four filled rectangles (one per edge). ``screen_space=True`` bypasses the active Camera2D transform (HUDs, minimaps, overlays). """ x, y = cls._xy(pos) rw, rh = cls._xy(size) c = cls._norm_colour(colour) p = (lambda px, py: (px, py)) if screen_space else 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: verts = [ (x0, y0, 0, 0, *c), (x1, y1, 0, 0, *c), (x2, y2, 0, 0, *c), (x3, y3, 0, 0, *c), ] cls._emit_fill(verts, [0, 1, 2, 0, 2, 3]) else: cls._emit_rect_outline(x0, y0, x1, y1, x2, y2, x3, y3, c, thickness)
@classmethod def _emit_rect_outline(cls, x0, y0, x1, y1, x2, y2, x3, y3, c, thickness): """Outline emission. thickness <= 1 → 4 line segments; > 1 → 4 filled rectangles (one per edge) so the GPU honours the requested weight. Corners are filled twice (the top/bottom bars span the full width and the left/right bars span only the interior height). The overlap is intentional: it keeps mitred joins solid without per-corner geometry. """ if thickness <= 1.0: cls._emit_line( [ (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), ] ) return thk = float(thickness) # Bars hug the outside of the rect (outset by thk/2 on each side # would invert mitre direction). The rect bounds [x0..x2, y0..y2] are # treated as the *outer* edge; bars grow inward. left, right = min(x0, x3), max(x1, x2) top, bot = min(y0, y1), max(y2, y3) # Top + bottom bars span full width cls._emit_fill( [(left, top, 0, 0, *c), (right, top, 0, 0, *c), (right, top + thk, 0, 0, *c), (left, top + thk, 0, 0, *c)], [0, 1, 2, 0, 2, 3], ) cls._emit_fill( [(left, bot - thk, 0, 0, *c), (right, bot - thk, 0, 0, *c), (right, bot, 0, 0, *c), (left, bot, 0, 0, *c)], [0, 1, 2, 0, 2, 3], ) # Left + right bars span the interior height only cls._emit_fill( [(left, top + thk, 0, 0, *c), (left + thk, top + thk, 0, 0, *c), (left + thk, bot - thk, 0, 0, *c), (left, bot - thk, 0, 0, *c)], [0, 1, 2, 0, 2, 3], ) cls._emit_fill( [(right - thk, top + thk, 0, 0, *c), (right, top + thk, 0, 0, *c), (right, bot - thk, 0, 0, *c), (right - thk, bot - thk, 0, 0, *c)], [0, 1, 2, 0, 2, 3], ) # ---- Line ----
[docs] @classmethod def draw_line(cls, a, b, *, colour=None, thickness=1.0, screen_space=False) -> None: """Draw a line from a to b. ``thickness <= 1`` rides the 1-px line pipeline; ``thickness > 1`` emits an oriented filled quad with perpendicular offsets so the weight is GPU-honoured rather than driver-clamped. ``screen_space=True`` bypasses the active Camera2D transform. """ ax, ay = cls._xy(a) bx, by = cls._xy(b) if not screen_space: ax, ay = cls._xf_pt(ax, ay) bx, by = cls._xf_pt(bx, by) c = cls._norm_colour(colour) if thickness <= 1.0: cls._emit_line([(ax, ay, 0, 0, *c), (bx, by, 0, 0, *c)]) return dx = bx - ax dy = by - ay ln = math.sqrt(dx * dx + dy * dy) if ln < 1e-6: return h = float(thickness) * 0.5 nx, ny = -dy / ln * h, dx / ln * h cls._emit_fill( [ (ax + nx, ay + ny, 0, 0, *c), (bx + nx, by + ny, 0, 0, *c), (bx - nx, by - ny, 0, 0, *c), (ax - nx, ay - ny, 0, 0, *c), ], [0, 1, 2, 0, 2, 3], )
[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 verts: list[tuple] = [] 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) 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) verts.extend([(ax, ay, 0, 0, *c), (bx, by, 0, 0, *c)]) if verts: cls._emit_line(verts)
# ---- Circle ----
[docs] @classmethod def draw_circle( cls, center, radius, *, colour=None, filled=False, segments=32, screen_space=False, ) -> None: """Draw a circle. filled=False draws an outline, filled=True fills a triangle fan. Set ``screen_space=True`` to bypass the active Camera2D transform. """ cx, cy = cls._xy(center) r = float(radius) if not screen_space: cx, cy = cls._xf_pt(cx, cy) r *= cls._xf_sc() c = cls._norm_colour(colour) step = math.tau / segments if filled: verts = [(cx, cy, 0, 0, *c)] verts.extend( (cx + math.cos(i * step) * r, cy + math.sin(i * step) * r, 0, 0, *c) for i in range(segments) ) indices: list[int] = [] for i in range(segments): indices.extend([0, 1 + i, 1 + (i + 1) % segments]) cls._emit_fill(verts, indices) else: verts = [] for i in range(segments): a, b = i * step, (i + 1) * step 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), ] ) cls._emit_line(verts)
# ---- 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) cls._emit_fill( [ (x1, y1, 0, 0, *c), (x2, y2, 0, 0, *c), (x3, y3, 0, 0, *c), ], [0, 1, 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) cls._emit_fill( [ (x1, y1, 0, 0, *c), (x2, y2, 0, 0, *c), (x3, y3, 0, 0, *c), (x4, y4, 0, 0, *c), ], [0, 1, 2, 0, 2, 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, filled=True): """Fill or outline an arbitrary polygon. ``filled=True`` (default) triangulates via ear-clipping so concave shapes render correctly. Convex polygons hit a fast triangle-fan path. ``filled=False`` emits a closed line ring. """ if len(vertices) < 3: return c = cls._norm_colour(colour) p = cls._xf_pt pts = [p(*cls._xy(v)) for v in vertices] if not filled: verts: list[tuple] = [] for a, b in zip(pts, pts[1:] + pts[:1], strict=True): verts.extend([(a[0], a[1], 0, 0, *c), (b[0], b[1], 0, 0, *c)]) cls._emit_line(verts) return verts = [(px, py, 0, 0, *c) for px, py in pts] if _polygon_is_convex(pts): # Fast path: fan from vertex 0 indices = [j for i in range(1, len(pts) - 1) for j in (0, i, i + 1)] else: indices = _ear_clip_triangulate(pts) cls._emit_fill(verts, indices)
[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) cls._emit_fill( [ (x0, y0, 0, 0, *ct), (x1, y1, 0, 0, *ct), (x2, y2, 0, 0, *cb), (x3, y3, 0, 0, *cb), ], [0, 1, 2, 0, 2, 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._ops.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 = ( "_ops", "_clip_stack", "_current_clip", "_xf", "_xf_stack", "_has_xf", "_text_width_cache", ) @classmethod @contextmanager def _isolated(cls): """Run a Draw2D session against fresh state, then restore. Used by play-mode to collect a secondary tree's draw ops without interfering with the primary tree. """ saved = {f: getattr(cls, f) for f in cls._STATE_FIELDS} cls._ops = [] 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)