"""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 logging
import math
from contextlib import contextmanager
from itertools import pairwise
from typing import ClassVar
import numpy as np
from .draw2d_ops import BLEND_MODES, 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"]
_log = logging.getLogger(__name__)
_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
_TRI_EPS = 1e-9
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 _point_in_triangle_closed(
px: float, py: float,
ax: float, ay: float, bx: float, by: float, cx: float, cy: float,
eps: float = _TRI_EPS,
) -> bool:
"""Inclusive point-in-(CCW)-triangle test (boundary counts as inside).
Used to decide whether a reflex vertex blocks a candidate ear. A reflex
vertex sitting *on* a thin candidate triangle's edge (the notch-valley
case that produced "webbing") must block the ear, so the test is
inclusive with an epsilon tolerance, unlike the strict ``_point_in_triangle``.
Assumes (a, b, c) wound CCW (positive area).
"""
d1 = (bx - ax) * (py - ay) - (by - ay) * (px - ax)
d2 = (cx - bx) * (py - by) - (cy - by) * (px - bx)
d3 = (ax - cx) * (py - cy) - (ay - cy) * (px - cx)
return d1 >= -eps and d2 >= -eps and d3 >= -eps
def _ear_clip_triangulate(pts: list[tuple[float, float]]) -> list[int]:
"""Triangulate a simple (non-self-intersecting) polygon by ear-clipping.
Implements the standard ear-clipping algorithm (David Eberly,
"Triangulation by Ear Clipping"): each remaining vertex is classified
convex or reflex, and a convex vertex ``b`` is an ear iff *no reflex
vertex* lies inside triangle ``(a, b, c)``. Checking only reflex
vertices is both the correct test (a convex vertex can never block an
ear) and faster. Near-degenerate (collinear) candidate corners are
rejected via an epsilon so slivers spanning a concave notch are never
accepted as ears -- this is the defect the old code had.
Returns a flat list of indices into *pts*: three consecutive entries
per triangle (n-2 triangles for a simple polygon). Genuinely
degenerate / self-intersecting input falls back to a fan from vertex 0
(logged at debug level), so callers always receive a triangle list.
"""
n = len(pts)
if n < 3:
return []
# Normalise to CCW so an ear has positive cross product. ``_polygon_signed_area``
# and the ear cross below share the same sign convention (verified: a CCW
# ring is positive under both), so reversing on negative area is correct.
indices = list(range(n))
if _polygon_signed_area(pts) < 0:
indices.reverse()
def is_reflex(ia: int, ib: int, ic: int) -> bool:
ax, ay = pts[ia]
bx, by = pts[ib]
cx, cy = pts[ic]
return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax) < 0.0
triangles: list[int] = []
guard = 0
while len(indices) > 3 and guard < n * n:
guard += 1
m = len(indices)
# Vertices that are reflex *right now* are the only ones that can block an ear.
reflex = {
indices[k]
for k in range(m)
if is_reflex(indices[(k - 1) % m], indices[k], indices[(k + 1) % m])
}
ear_found = False
for i in range(m):
ia = indices[(i - 1) % m]
ib = indices[i]
ic = indices[(i + 1) % m]
ax, ay = pts[ia]
bx, by = pts[ib]
cx, cy = pts[ic]
cross = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
if cross <= _TRI_EPS:
continue # reflex or near-collinear (sliver): never an ear
# Ear iff no reflex vertex lies in (a, b, c). Inclusive test so a reflex
# vertex on the candidate edge (notch valley) blocks the would-be ear.
blocked = False
for j in reflex:
if j in (ia, ib, ic):
continue
if _point_in_triangle_closed(*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 (still a
# valid index list, but geometrically only correct for convex input).
_log.debug(
"ear-clip fell back to fan: polygon may be self-intersecting or degenerate (n=%d)", n
)
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 _triangulation_is_valid(
pts: list[tuple[float, float]], indices: list[int], eps: float = 1e-3
) -> bool:
"""Debug helper: True if *indices* form a valid triangulation of *pts*.
Checks triangle count (n-2), non-degenerate triangles, and that the sum
of triangle areas equals the polygon area. Intended for tests/asserts;
not called on the hot draw path.
"""
n = len(pts)
if n < 3 or len(indices) != (n - 2) * 3:
return False
def tri_area(a, b, c):
return abs((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) * 0.5
total = 0.0
for i in range(0, len(indices), 3):
ta, tb, tc = indices[i], indices[i + 1], indices[i + 2]
if not (0 <= ta < n and 0 <= tb < n and 0 <= tc < n):
return False
area = tri_area(pts[ta], pts[tb], pts[tc])
if area <= eps:
return False
total += area
return abs(total - abs(_polygon_signed_area(pts))) <= eps * max(1.0, abs(_polygon_signed_area(pts)))
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
# Saved-value stack for nested ``push_post_layer`` (web per-post CanvasLayer).
_post_layer_stack: ClassVar[list[int]] = []
# N1 (2D-in-HDR) lane flag. Set at the top of every public draw method to the
# call's ``screen_space`` (False for methods without that param), then read by
# the op-append sites so every emitted ``Op`` carries its lane by role. Reset
# each frame in ``_reset`` so no value leaks across frames.
_pending_screen_space: ClassVar[bool] = False
# Per-post CanvasLayer band (design §5.6, web backend). Set by
# ``push_post_layer`` while the 2D walk is inside a CanvasLayer whose
# ``environment`` is set; written onto every emitted ``Op.post_layer_id`` so
# the web serializer can peel that band into its own post chain. ``-1`` means
# "no post layer" -- the common case, kept byte-identical (desktop ignores the
# field; it bands via the item pipeline's ``layer`` column). Reset each frame
# in ``_reset`` so no value leaks across frames.
_pending_post_layer: ClassVar[int] = -1
[docs]
@classmethod
def push_post_layer(cls, band: int) -> None:
"""Enter a per-post CanvasLayer band: tag every op emitted until pop (web).
Mirrors :meth:`push_screen_transform` as a context the tree walk opens
when it reaches a CanvasLayer with an ``environment`` set; the band is its
``layer`` value (the same key the desktop item pipeline bands on). Stored
on a one-deep stack -- a post CanvasLayer never nests inside another post
CanvasLayer in v1, so a single slot + saved-value suffices.
"""
cls._post_layer_stack.append(cls._pending_post_layer)
cls._pending_post_layer = int(band)
[docs]
@classmethod
def pop_post_layer(cls) -> None:
"""Leave the current per-post CanvasLayer band (restore the previous)."""
cls._pending_post_layer = cls._post_layer_stack.pop() if cls._post_layer_stack else -1
[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)}")
@staticmethod
def _norm_blend(blend: str) -> str:
"""Validate a blend mode, raising on anything outside BLEND_MODES.
``"alpha"`` (default) is standard over-compositing, ``"add"`` is
additive (glows, flashes), ``"multiply"`` is dst*src (darkening
overlays, day/night tints).
"""
if blend not in BLEND_MODES:
raise ValueError(
f"blend must be one of {BLEND_MODES}, got {blend!r}"
)
return blend
# ---- 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], blend: str = "alpha") -> None:
cls._ops.append(
Op(OpKind.FILL, cls._current_clip, vert_list, indices, -1, blend,
cls._pending_screen_space, cls._pending_post_layer)
)
@classmethod
def _emit_line(cls, vert_list: list[tuple]) -> None:
cls._ops.append(
Op(OpKind.LINE, cls._current_clip, vert_list, None, -1, "alpha",
cls._pending_screen_space, cls._pending_post_layer)
)
# ---- Rectangle ----
[docs]
@classmethod
def draw_rect(
cls, pos, size, *, colour=None, filled=False, thickness=1.0, screen_space=False,
blend="alpha",
) -> 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). ``blend`` is one of ``"alpha"``
(default), ``"add"`` (additive glow/flash), or ``"multiply"`` (dst*src
darkening overlay); it applies to the filled path and thick outlines
(1 px outlines always alpha-blend on the line pipeline).
"""
cls._pending_screen_space = bool(screen_space)
blend = cls._norm_blend(blend)
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], blend)
else:
cls._emit_rect_outline(x0, y0, x1, y1, x2, y2, x3, y3, c, thickness, blend)
@classmethod
def _emit_rect_outline(cls, x0, y0, x1, y1, x2, y2, x3, y3, c, thickness, blend="alpha"):
"""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], blend,
)
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], blend,
)
# 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], blend,
)
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], blend,
)
# ---- 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.
"""
cls._pending_screen_space = bool(screen_space)
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."""
cls._pending_screen_space = False
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.
"""
cls._pending_screen_space = bool(screen_space)
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."""
cls._pending_screen_space = False
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)."""
cls._pending_screen_space = False
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."""
cls._pending_screen_space = False
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, blend="alpha"):
"""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. ``blend`` is one of
``"alpha"`` (default), ``"add"``, or ``"multiply"`` and applies to the
filled path (outlines always alpha-blend on the line pipeline).
"""
cls._pending_screen_space = False
blend = cls._norm_blend(blend)
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, blend)
[docs]
@classmethod
def fill_rect_gradient(cls, x, y, w, h, colour_top, colour_bottom):
"""Fill rect with vertical gradient (top colour -> bottom colour)."""
cls._pending_screen_space = False
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._pending_screen_space = False
cls._pending_post_layer = -1
cls._post_layer_stack.clear()
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", "_pending_screen_space",
"_pending_post_layer", "_post_layer_stack",
"_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._pending_screen_space = False
cls._pending_post_layer = -1
cls._post_layer_stack = []
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)