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