"""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 _snapped_edges(
dst_start: float,
dst_len: float,
ideal: list[float],
*,
nearest: bool,
) -> list[float]:
"""Put every interior run boundary on a whole destination pixel.
A boundary that lands mid-pixel makes that pixel's centre map to the exact
edge of a source region, where a linear filter tapping both sides blends in
the neighbouring region: a one-pixel seam of the border colour at every
repetition. On a whole pixel the outermost sample lands half a texel inside
its own region instead, so the neighbour is never reached.
The span's own two ends are left where the patch put them, so the run
sequence still abuts the regions either side of it with no gap or overlap.
Each interior boundary is snapped from its *ideal* position rather than from
the end of the previous run, which spreads the rounding across the sequence
instead of accumulating it into the last run: run lengths then differ by at
most one pixel.
*nearest* chooses the direction. ``tile_fit`` is resizing its repetitions
anyway, so it takes the closer pixel. ``tile`` only ever snaps a boundary
down: moving one up would make the run longer than the source it repeats,
and it would sample past the region to fill the difference.
"""
end = dst_start + dst_len
edges = [dst_start]
for boundary in ideal:
snapped = math.floor(boundary + 0.5) if nearest else math.floor(boundary)
edges.append(min(max(float(snapped), edges[-1]), end))
edges.append(end)
return edges
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.
Run boundaries are snapped to whole destination pixels; see
:func:`_snapped_edges` for why.
"""
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
ideal = [dst_start + i * step for i in range(1, count)]
edges = _snapped_edges(dst_start, dst_len, ideal, nearest=True)
return [(src_start, src_len, a, b - a) for a, b in zip(edges, edges[1:], strict=False) if b - a > _EPSILON]
# "tile": whole repetitions at the source's own size, then the remainder.
count = max(1, math.ceil((dst_len - _EPSILON) / src_len))
ideal = [dst_start + i * src_len for i in range(1, count)]
edges = _snapped_edges(dst_start, dst_len, ideal, nearest=False)
# A tiled run samples its own pixels 1:1, so its source length is whatever
# the snap left of its destination length.
return [(src_start, b - a, a, b - a) for a, b in zip(edges, edges[1:], strict=False) if b - a > _EPSILON]
[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, destinations in the patch's own space.
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]
# Run boundaries snap to whole pixels where the patch lands, so they are
# laid out at its world origin and shifted back into its own space.
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 - ox, dst_y - oy, dst_w, dst_h)),
)
return patches
[docs]
def on_draw(self, renderer) -> None:
"""Draw the 9-patch texture via renderer.texture()."""
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.texture(
self._texture_id,
(dst.x, dst.y),
(dst.w, dst.h),
region=((src.x / tw, src.y / th), ((src.x + src.w) / tw, (src.y + src.h) / th)),
tint=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)