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