Source code for simvx.core.nodes_2d.ninepatch

"""PatchRect and NinePatchRect -- 9-slice scalable bordered texture."""

import math
from typing import NamedTuple

from ..descriptors import Property
from ..graphics.texture_slot import TextureSlot
from ..properties import Colour
from .node2d import Node2D

#: Below this a length counts as zero: a tile run shorter than a thousandth of a
#: pixel is invisible, and admitting it would cost a draw call and risk a
#: division producing one repetition too many.
_EPSILON = 1e-3


def _axis_runs(
    mode: str,
    src_start: float,
    src_len: float,
    dst_start: float,
    dst_len: float,
) -> list[tuple[float, float, float, float]]:
    """Split one axis of one patch into ``(src_start, src_len, dst_start, dst_len)`` runs.

    ``stretch`` is a single run covering the whole span, which is what a corner
    always gets. ``tile`` repeats the source at its own pixel size and clips the
    final repetition, so the artwork keeps its scale however far the rect is
    dragged. ``tile_fit`` repeats a whole number of times, resizing each
    repetition by the fraction needed to divide the span exactly, so no
    repetition is ever cut in half.
    """
    if mode == "stretch" or src_len <= 0.0 or dst_len <= 0.0:
        return [(src_start, src_len, dst_start, dst_len)]
    if mode == "tile_fit":
        # ``floor(x + 0.5)`` rather than ``round``, which rounds a half to even
        # and so would fit a span of exactly 4.5 source widths with 4 stretched
        # repetitions while fitting 5.5 with 6 squeezed ones. A half always
        # rounds up here, so the repetition count grows monotonically with the
        # span, which is what a rect being dragged wider needs.
        count = max(1, math.floor(dst_len / src_len + 0.5))
        step = dst_len / count
        return [(src_start, src_len, dst_start + i * step, step) for i in range(count)]
    # "tile": whole repetitions at the source's own size, then the remainder.
    count = max(1, math.ceil((dst_len - _EPSILON) / src_len))
    runs = []
    for i in range(count):
        run = min(src_len, dst_len - i * src_len)
        if run <= _EPSILON:
            break
        runs.append((src_start, run, dst_start + i * src_len, run))
    return runs


[docs] class PatchRect(NamedTuple): """A rectangle defined by position and size.""" x: float y: float w: float h: float
[docs] class NinePatchRect(TextureSlot, Node2D): """9-slice scalable bordered texture for UI panels, speech bubbles, and HUD elements. Divides a source texture into 9 regions using margin values. Corners stay fixed-size, edges grow along one axis, and the centre fills the remainder. ``axis_stretch_horizontal`` and ``axis_stretch_vertical`` choose how that growth happens. ``"stretch"`` (the default) scales one run of the source to fill the span. ``"tile"`` repeats the source at its own pixel size and clips the last repetition, which keeps a patterned border at its authored scale however far the rect is dragged. ``"tile_fit"`` repeats a whole number of times, resizing each repetition just enough to divide the span exactly, so no repetition is ever cut off. The horizontal mode governs the top edge, bottom edge and centre; the vertical mode governs the left edge, right edge and centre. Corners are never repeated: their source and destination are the same size by construction. A tiled span emits one draw per repetition, so a one-pixel margin tiled across a wide rect costs a draw call per pixel. Size the source region for the span it has to cover. ``texture`` takes the same sources ``Sprite2D`` does, including a :class:`~simvx.core.graphics.Texture` resource, and reassigning it re-resolves. ``texture_size`` is seeded from the image when it was left unset, and re-seeded when the texture changes; a value the author set -- at construction or at any point after -- is never overwritten. """ texture = Property( None, hint="Texture source: file path, PNG bytes, or RGBA uint8 ndarray", on_change="_invalidate_texture_slot", ) texture_size = Property(None, hint="Source texture dimensions (w, h)", on_change="_texture_size_authored") size = Property(None, hint="Display size (Vec2). None = texture size") patch_margin_left = Property(0, range=(0, 1000), hint="Left border width") patch_margin_top = Property(0, range=(0, 1000), hint="Top border height") patch_margin_right = Property(0, range=(0, 1000), hint="Right border width") patch_margin_bottom = Property(0, range=(0, 1000), hint="Bottom border height") draw_center = Property(True, hint="Whether to draw the centre region") axis_stretch_horizontal = Property("stretch", enum=["stretch", "tile", "tile_fit"], hint="Horizontal stretch mode") axis_stretch_vertical = Property("stretch", enum=["stretch", "tile", "tile_fit"], hint="Vertical stretch mode") modulate = Colour((1.0, 1.0, 1.0, 1.0)) #: Whether ``texture_size`` was filled in from the resolved image rather than #: authored. Only a seeded value is dropped when the texture is reassigned. _texture_size_seeded: bool = False def _texture_size_authored(self) -> None: """Any write that is not a backend seed makes the value the author's.""" self._texture_size_seeded = False
[docs] def seed_texture_size(self, size: tuple[int, int]) -> None: """Record the resolved image's dimensions, if the author did not set them. The backend calls this once it knows the pixel size. An authored ``texture_size`` wins and is left alone; a seeded one is remembered as seeded so it can be re-seeded from the next texture. A backend that cannot measure the image hands back a degenerate size: the browser decodes PNG and JPEG itself, so a web export knows no pixel dimensions at seed time. Fall back to reading them off the source, and if that fails too leave the property untouched rather than poisoning it with a size that would make every patch collapse to nothing. """ if self.texture_size is not None and not self._texture_size_seeded: return resolved = tuple(size) if resolved[0] <= 0 or resolved[1] <= 0: # Imported here because the size reader lives beside the sprite that # shares it, and that module imports this package. from ..animation.sprite import _native_size_of native = _native_size_of(self.texture) if native is None: return resolved = native self.texture_size = resolved # After the assignment: writing the property clears the flag, and this # is the one write that is not the author's. self._texture_size_seeded = True
def _invalidate_texture_slot(self) -> None: """Drop the cached handle, and any size that came from the old image.""" super()._invalidate_texture_slot() if self._texture_size_seeded: self._texture_size_seeded = False self.texture_size = None
[docs] def get_rect(self) -> PatchRect: """Bounding rectangle at global position with current display size.""" sz = self._display_size() p = self.world_position return PatchRect(p.x, p.y, sz[0], sz[1])
[docs] @property def nine_patch_rects(self) -> list[tuple[PatchRect, PatchRect]]: """``(source_uv_rect, dest_position_rect)`` pairs for the patches. Up to 9 pairs (8 if ``draw_center`` is False) while both axes stretch. A ``tile`` or ``tile_fit`` axis splits each region it governs into one pair per repetition, so the list is longer. Patches with zero width or height are omitted. Pairs come out in region row-major order (top-left, top-centre, top-right, then the middle row, then the bottom), and within a region in the same order across its repetitions. """ tex = self.texture_size if tex is None or (tex[0] <= 0 and tex[1] <= 0): return [] tw, th = float(tex[0]), float(tex[1]) dw, dh = self._display_size() ml = min(float(self.patch_margin_left), tw, dw) mr = min(float(self.patch_margin_right), tw - ml, max(0, dw - ml)) mt = min(float(self.patch_margin_top), th, dh) mb = min(float(self.patch_margin_bottom), th - mt, max(0, dh - mt)) sx = [0, ml, tw - mr, tw] sy = [0, mt, th - mb, th] dx = [0, ml, dw - mr, dw] dy = [0, mt, dh - mb, dh] ox, oy = float(self.world_position.x), float(self.world_position.y) patches: list[tuple[PatchRect, PatchRect]] = [] for row in range(3): for col in range(3): if row == 1 and col == 1 and not self.draw_center: continue sw = sx[col + 1] - sx[col] sh = sy[row + 1] - sy[row] ddw = dx[col + 1] - dx[col] ddh = dy[row + 1] - dy[row] if sw <= 0 or sh <= 0 or ddw <= 0 or ddh <= 0: continue # Only the middle column grows horizontally and only the middle # row grows vertically, so a corner is left on "stretch" and # comes back as the single 1:1 run it already is. h_mode = self.axis_stretch_horizontal if col == 1 else "stretch" v_mode = self.axis_stretch_vertical if row == 1 else "stretch" h_runs = _axis_runs(h_mode, sx[col], sw, ox + dx[col], ddw) v_runs = _axis_runs(v_mode, sy[row], sh, oy + dy[row], ddh) for src_y, src_h, dst_y, dst_h in v_runs: for src_x, src_w, dst_x, dst_w in h_runs: patches.append( (PatchRect(src_x, src_y, src_w, src_h), PatchRect(dst_x, dst_y, dst_w, dst_h)), ) return patches
[docs] def on_draw(self, renderer) -> None: """Draw the 9-patch texture via renderer.draw_texture_region().""" if self._texture_id < 0 or not self.visible: return tex = self.texture_size if tex is None: return tw, th = float(tex[0]), float(tex[1]) if tw <= 0 or th <= 0: return patches = self.nine_patch_rects colour = self.modulate if self.modulate else None for src, dst in patches: renderer.draw_texture_region( self._texture_id, (dst.x, dst.y), (dst.w, dst.h), (src.x / tw, src.y / th), ((src.x + src.w) / tw, (src.y + src.h) / th), colour=colour, )
def _display_size(self) -> tuple[float, float]: if self.size is not None: return (float(self.size[0]), float(self.size[1])) if self.texture_size is not None: return (float(self.texture_size[0]), float(self.texture_size[1])) return (0.0, 0.0)