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