Source code for simvx.graphics.draw2d_texture

"""Textured quad drawing for Draw2D.

Handles texture registration, textured quad emission, and 9-patch rendering.
"""

import logging
import math
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar

from .draw2d_ops import Op, OpKind

log = logging.getLogger(__name__)


[docs] class Draw2DTextureMixin: """Mixin providing textured quad drawing for Draw2D.""" # ---- TYPE_CHECKING-only sibling-attribute declarations ---- # Real implementations live on Draw2DTransformMixin (xf) and Draw2D # itself (ops/clip). Declared here purely so mypy can resolve them # inside this mixin. if TYPE_CHECKING: _ops: ClassVar[list[Op]] _current_clip: ClassVar[tuple[int, int, int, int] | None] _has_xf: ClassVar[bool] @classmethod def _xf_pt(cls, x: float, y: float) -> tuple[float, float]: ... # CPU-side texture data for streaming: texture_id -> PNG bytes _texture_data: dict[int, bytes] = {} _next_cpu_texture_id: int = 1 # Path → texture_id cache for ``draw_image()``. One entry per (resolved # path, filter) so repeated calls in on_draw don't re-resolve. _path_texture_cache: dict[tuple[str, str], int] = {}
[docs] @classmethod def register_texture(cls, png_data: bytes) -> int: """Register a CPU-side texture for streaming. Returns a texture ID. Use this in CPU-only mode (web editor) where there's no GPU TextureManager. The returned ID can be passed to ``draw_texture()`` / ``draw_texture_region()``. """ tex_id = cls._next_cpu_texture_id cls._next_cpu_texture_id += 1 cls._texture_data[tex_id] = png_data return tex_id
[docs] @classmethod def register_texture_with_id(cls, texture_id: int, png_data: bytes) -> None: """Register PNG data for a known texture ID (for streaming sync).""" cls._texture_data[texture_id] = png_data
[docs] @classmethod def draw_texture( cls, texture_id: int, x: float, y: float, w: float, h: float, colour: tuple[float, ...] | None = None, rotation: float = 0.0, ): """Draw a textured quad at (x, y) with size (w, h). Args: texture_id: Bindless texture index from TextureManager. x, y: Top-left position in screen pixels. w, h: Width and height in screen pixels. colour: Optional RGBA tint (0.0-1.0 floats). Default white. rotation: Rotation in radians around the quad center. """ cls.draw_texture_region(texture_id, x, y, w, h, 0.0, 0.0, 1.0, 1.0, colour, rotation)
[docs] @classmethod def draw_texture_region( cls, texture_id: int, x: float, y: float, w: float, h: float, u0: float = 0.0, v0: float = 0.0, u1: float = 1.0, v1: float = 1.0, colour: tuple[float, ...] | None = None, rotation: float = 0.0, ): """Draw a sub-rectangle of a texture as a quad. Args: texture_id: Bindless texture index. x, y: Top-left position in screen pixels. w, h: Width and height in screen pixels. u0, v0, u1, v1: UV coordinates for the source region (0-1). colour: Optional RGBA tint (0.0-1.0 floats). Default white. rotation: Rotation in radians around the quad center. """ c = colour if colour else (1.0, 1.0, 1.0, 1.0) if len(c) == 3: c = (*c, 1.0) # Corner positions if abs(rotation) < 1e-6: x0, y0 = x, y x1, y1 = x + w, y x2, y2 = x + w, y + h x3, y3 = x, y + h else: cx, cy = x + w * 0.5, y + h * 0.5 cos_r, sin_r = math.cos(rotation), math.sin(rotation) hw, hh = w * 0.5, h * 0.5 corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)] rotated = [(cx + px * cos_r - py * sin_r, cy + px * sin_r + py * cos_r) for px, py in corners] x0, y0 = rotated[0] x1, y1 = rotated[1] x2, y2 = rotated[2] x3, y3 = rotated[3] if cls._has_xf: x0, y0 = cls._xf_pt(x0, y0) x1, y1 = cls._xf_pt(x1, y1) x2, y2 = cls._xf_pt(x2, y2) x3, y3 = cls._xf_pt(x3, y3) verts = [ (x0, y0, u0, v0, *c), (x1, y1, u1, v0, *c), (x2, y2, u1, v1, *c), (x3, y3, u0, v1, *c), ] cls._ops.append( Op(OpKind.TEX, cls._current_clip, verts, [0, 1, 2, 0, 2, 3], int(texture_id)) )
[docs] @classmethod def draw_image( cls, path: str | Path, x: float, y: float, w: float, h: float, *, colour: tuple[float, ...] | None = None, rotation: float = 0.0, filter: str = "linear", ): """Draw an image file as a quad. Path-cached. Resolves *path* through the running App's ``TextureManager`` (one entry per ``(resolved_path, filter)`` pair) and emits a textured quad. Subsequent frames reuse the cached texture id, so an ``on_draw`` that calls ``draw_image("hero.png", ...)`` every frame does one disk read and one GPU upload total. Returns silently when no App / engine is available (CPU-only mode); callers that need explicit handling should use ``draw_texture()`` with a manually-resolved id. """ tex_id = cls._resolve_path_texture(str(path), filter) if tex_id < 0: return cls.draw_texture_region( tex_id, x, y, w, h, 0.0, 0.0, 1.0, 1.0, colour=colour, rotation=rotation, )
@classmethod def _resolve_path_texture(cls, path: str, filter: str) -> int: """Resolve a path to a bindless texture id via TextureManager. Cached. Returns -1 if no engine is available (e.g. CPU-only test runner) or if the texture cannot be loaded. """ key = (path, filter) cached = cls._path_texture_cache.get(key) if cached is not None: return cached try: from .app import App # local import to avoid cycle at module load except ImportError: return -1 app = App.current() if app is None or getattr(app, "_engine", None) is None: return -1 try: tm = app.texture_manager tex_id = tm.load(path, filter=filter) except (OSError, ValueError) as exc: log.warning("Draw2D.draw_image: failed to load %s: %s", path, exc) return -1 cls._path_texture_cache[key] = int(tex_id) return int(tex_id)
[docs] @classmethod def draw_nine_patch( cls, texture_id: int, x: float, y: float, w: float, h: float, tex_w: float, tex_h: float, margin_left: float = 0.0, margin_right: float = 0.0, margin_top: float = 0.0, margin_bottom: float = 0.0, draw_centre: bool = True, colour: tuple[float, ...] | None = None, ): """Draw a 9-slice textured rectangle. Divides the source texture into 9 regions using the margin values. Corners remain fixed-size, edges stretch in one direction, and the centre fills the remaining space. This is the standard 9-patch / 9-slice technique for scalable UI panels, buttons, and speech bubbles. Args: texture_id: Bindless texture index from TextureManager. x, y: Top-left position in screen pixels. w, h: Display width and height in screen pixels. tex_w, tex_h: Source texture dimensions in pixels. margin_left, margin_right: Left/right border widths (pixels in source texture). margin_top, margin_bottom: Top/bottom border heights (pixels in source texture). draw_centre: Whether to draw the centre region (default True). colour: Optional RGBA tint (0.0-1.0 floats). Default white. """ if tex_w <= 0 or tex_h <= 0: return # Clamp margins to texture and display bounds ml = min(margin_left, tex_w, w) mr = min(margin_right, tex_w - ml, max(0, w - ml)) mt = min(margin_top, tex_h, h) mb = min(margin_bottom, tex_h - mt, max(0, h - mt)) # Source (texture-space) and destination (screen-space) column/row boundaries sx = [0.0, ml, tex_w - mr, tex_w] sy = [0.0, mt, tex_h - mb, tex_h] dx = [x, x + ml, x + w - mr, x + w] dy = [y, y + mt, y + h - mb, y + h] for row in range(3): for col in range(3): if row == 1 and col == 1 and not draw_centre: continue sw = sx[col + 1] - sx[col] sh = sy[row + 1] - sy[row] dw = dx[col + 1] - dx[col] dh = dy[row + 1] - dy[row] if sw <= 0 or sh <= 0 or dw <= 0 or dh <= 0: continue cls.draw_texture_region( texture_id, dx[col], dy[row], dw, dh, sx[col] / tex_w, sy[row] / tex_h, sx[col + 1] / tex_w, sy[row + 1] / tex_h, colour=colour, )