Source code for simvx.core.port_helpers.procedural_textures

"""Procedural NumPy textures for game ports: checkered, grids, sprite atlases, glyphs.

All functions return RGBA uint8 ndarrays of shape ``(H, W, 4)`` that can be
passed directly to ``Sprite2D.texture`` (or any source ``TextureManager.resolve``
accepts). Pure NumPy, no GPU, no PIL: handy for prototyping ports before art
lands, or for procedural game art.

The colour palette format is ``(R, G, B, A)`` uint8 tuples or 0..255 ints.
"""

from __future__ import annotations

from collections.abc import Iterable, Sequence

import numpy as np

RGBA = tuple[int, int, int, int]


def _norm_rgba(c: RGBA | tuple[int, int, int]) -> RGBA:
    """Coerce an RGB or RGBA tuple to RGBA uint8."""
    if len(c) == 3:
        return (c[0], c[1], c[2], 255)
    return c  # type: ignore[return-value]


[docs] def solid(size: int | tuple[int, int], colour: RGBA | tuple[int, int, int]) -> np.ndarray: """``size × size`` (or ``(W, H)``) RGBA texture filled with one colour.""" if isinstance(size, int): w = h = size else: w, h = size img = np.zeros((h, w, 4), dtype=np.uint8) img[:] = _norm_rgba(colour) return img
[docs] def checkerboard( size: int | tuple[int, int] = 64, cell: int = 8, light: RGBA | tuple[int, int, int] = (220, 220, 220), dark: RGBA | tuple[int, int, int] = (60, 60, 60), ) -> np.ndarray: """Classic two-tone checkerboard. Vectorised: no Python-level loops.""" if isinstance(size, int): w = h = size else: w, h = size ys = np.arange(h)[:, None] // cell xs = np.arange(w)[None, :] // cell mask = ((xs + ys) % 2).astype(bool) # True for "light" img = np.zeros((h, w, 4), dtype=np.uint8) img[~mask] = _norm_rgba(dark) img[mask] = _norm_rgba(light) return img
[docs] def packed_grid( cell_size: int, columns: int, rows: int, colours: Sequence[RGBA | tuple[int, int, int]], *, line_colour: RGBA | tuple[int, int, int] | None = None, line_width: int = 0, ) -> np.ndarray: """A grid of ``columns × rows`` solid cells, each ``cell_size`` square. ``colours`` cycles when shorter than ``columns * rows``. ``line_colour`` optionally draws separator lines of ``line_width`` pixels between cells. Useful for ad-hoc tile atlases, palette swatches, or pip displays. """ if not colours: raise ValueError("packed_grid: colours must not be empty") w, h = cell_size * columns, cell_size * rows img = np.zeros((h, w, 4), dtype=np.uint8) palette = [_norm_rgba(c) for c in colours] for r in range(rows): for c in range(columns): idx = r * columns + c colour = palette[idx % len(palette)] y0, y1 = r * cell_size, (r + 1) * cell_size x0, x1 = c * cell_size, (c + 1) * cell_size img[y0:y1, x0:x1] = colour if line_colour is not None and line_width > 0: lc = _norm_rgba(line_colour) # Vertical grid lines. for c in range(columns + 1): x0 = max(0, c * cell_size - line_width // 2) x1 = min(w, x0 + line_width) img[:, x0:x1] = lc # Horizontal grid lines. for r in range(rows + 1): y0 = max(0, r * cell_size - line_width // 2) y1 = min(h, y0 + line_width) img[y0:y1, :] = lc return img
[docs] def sprite_atlas( frames: Iterable[np.ndarray], *, columns: int | None = None, ) -> tuple[np.ndarray, int, int]: """Stitch per-frame RGBA ndarrays into an atlas. Returns ``(atlas, columns, rows)``: the atlas is shaped ``(rows * fh, columns * fw, 4)``. Frames must share dimensions. If ``columns`` is ``None``, a near-square grid is chosen (``columns = ceil(sqrt(N))``). """ frame_list = list(frames) if not frame_list: raise ValueError("sprite_atlas: at least one frame required") fh, fw = frame_list[0].shape[:2] for i, f in enumerate(frame_list): if f.shape[:2] != (fh, fw): raise ValueError(f"sprite_atlas: frame {i} size {f.shape[:2]} != frame 0 {(fh, fw)}") if f.shape != (fh, fw, 4) or f.dtype != np.uint8: raise ValueError("sprite_atlas: frames must be RGBA uint8 (H, W, 4)") n = len(frame_list) if columns is None: columns = int(np.ceil(np.sqrt(n))) rows = int(np.ceil(n / columns)) atlas = np.zeros((rows * fh, columns * fw, 4), dtype=np.uint8) for i, f in enumerate(frame_list): r, c = divmod(i, columns) atlas[r * fh:(r + 1) * fh, c * fw:(c + 1) * fw] = f return atlas, columns, rows
# ---------------------------------------------------------------------------- # Glyph rasteriser # ---------------------------------------------------------------------------- # 5x7 monospace bitmap font covering ASCII 0x20..0x7E. Each glyph is encoded # as 7 row-bytes; bit 4 is the leftmost pixel of the 5-wide row. Empty rows # encode as 0. The font is *intentionally minimal*: port helpers need # legible debug text, not aesthetics. _FONT_5x7: dict[str, tuple[int, ...]] = { " ": (0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), "0": (0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E), "1": (0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E), "2": (0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F), "3": (0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E), "4": (0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02), "5": (0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E), "6": (0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E), "7": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08), "8": (0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E), "9": (0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C), "A": (0x0E, 0x11, 0x11, 0x11, 0x1F, 0x11, 0x11), "B": (0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E), "C": (0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E), "D": (0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E), "E": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F), "F": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10), "G": (0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E), "H": (0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11), "I": (0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E), "J": (0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C), "K": (0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11), "L": (0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F), "M": (0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11), "N": (0x11, 0x11, 0x19, 0x15, 0x13, 0x11, 0x11), "O": (0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E), "P": (0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10), "Q": (0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D), "R": (0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11), "S": (0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E), "T": (0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04), "U": (0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E), "V": (0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04), "W": (0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11), "X": (0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11), "Y": (0x11, 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04), "Z": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F), "-": (0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00), "_": (0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F), "+": (0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00), ".": (0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C), ",": (0x00, 0x00, 0x00, 0x00, 0x0C, 0x04, 0x08), "!": (0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x00), "?": (0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04), ":": (0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00), }
[docs] def glyph( ch: str, *, scale: int = 1, colour: RGBA | tuple[int, int, int] = (255, 255, 255), background: RGBA = (0, 0, 0, 0), ) -> np.ndarray: """Rasterise a single character to a ``(7·scale, 5·scale, 4)`` RGBA bitmap. Unsupported characters return a blank glyph (background-filled). Lower-case letters fall through to upper-case. """ key = ch.upper() if ch.isalpha() else ch bits = _FONT_5x7.get(key) fg = _norm_rgba(colour) bg = background if len(background) == 4 else (*background, 0) if bits is None: return solid((5 * scale, 7 * scale), bg) h, w = 7, 5 grid = np.zeros((h, w, 4), dtype=np.uint8) grid[:] = bg for y, row in enumerate(bits): for x in range(w): if (row >> (w - 1 - x)) & 1: grid[y, x] = fg if scale == 1: return grid return np.repeat(np.repeat(grid, scale, axis=0), scale, axis=1)
[docs] def text_strip( text: str, *, scale: int = 2, colour: RGBA | tuple[int, int, int] = (255, 255, 255), spacing: int = 1, background: RGBA = (0, 0, 0, 0), ) -> np.ndarray: """Rasterise a short string as a horizontal strip of glyphs. Useful for debug HUDs / number displays where pulling in the engine's MSDF text pass is overkill (e.g. a `Sprite2D` carrying the player's score). """ if not text: return solid((1, 7 * scale), background) glyphs = [glyph(ch, scale=scale, colour=colour, background=background) for ch in text] gh, gw = glyphs[0].shape[:2] sep_w = spacing * scale total_w = sum(g.shape[1] for g in glyphs) + sep_w * (len(glyphs) - 1) strip = np.zeros((gh, total_w, 4), dtype=np.uint8) bg = background if len(background) == 4 else (*background, 0) strip[:] = bg x = 0 for i, g in enumerate(glyphs): strip[:, x:x + gw] = g x += gw if i != len(glyphs) - 1: x += sep_w return strip