Source code for simvx.core.atlas

"""Texture atlas, sprite sheet, and atlas packing utilities.

Provides AtlasTexture (sub-region reference), TextureAtlas (named region container),
SpriteSheet (grid-based uniform frames), and pack_atlas (shelf-based rectangle packer).
These are the foundation for batched 2D sprite rendering.
"""


from __future__ import annotations

import logging

log = logging.getLogger(__name__)

__all__ = ["AtlasTexture", "TextureAtlas", "SpriteSheet", "pack_atlas"]


[docs] class AtlasTexture: """Reference to a rectangular region within a texture atlas. Used by Sprite2D, AnimatedSprite2D, and NinePatchRect to render a portion of a shared atlas texture. """ __slots__ = ("atlas", "region", "margin") def __init__( self, atlas: TextureAtlas | str | None = None, region: tuple[int, int, int, int] = (0, 0, 0, 0), margin: tuple[int, int, int, int] = (0, 0, 0, 0), ): self.atlas = atlas self.region = region # (x, y, width, height) in atlas pixels self.margin = margin # (left, top, right, bottom) empty margins for trimming @property def x(self) -> int: return self.region[0] @property def y(self) -> int: return self.region[1] @property def width(self) -> int: return self.region[2] @property def height(self) -> int: return self.region[3]
[docs] def get_uv_rect(self, atlas_width: int, atlas_height: int) -> tuple[float, float, float, float]: """Return normalised UV coordinates (u0, v0, u1, v1) for this region.""" if atlas_width <= 0 or atlas_height <= 0: return (0.0, 0.0, 0.0, 0.0) x, y, w, h = self.region return (x / atlas_width, y / atlas_height, (x + w) / atlas_width, (y + h) / atlas_height)
[docs] def __repr__(self) -> str: return f"AtlasTexture(region={self.region}, margin={self.margin})"
[docs] def __eq__(self, other: object) -> bool: if not isinstance(other, AtlasTexture): return NotImplemented return self.region == other.region and self.margin == other.margin
[docs] class TextureAtlas: """Texture atlas with named regions for batched sprite rendering. Can be created manually by adding regions, or auto-packed from a set of individual image files/sizes via :func:`pack_atlas`. """ def __init__(self, width: int = 0, height: int = 0, texture_path: str | None = None): self.width = width self.height = height self.texture_path = texture_path self._regions: dict[str, AtlasTexture] = {}
[docs] def add_region(self, name: str, x: int, y: int, w: int, h: int) -> AtlasTexture: """Add a named region to the atlas. Returns the AtlasTexture.""" tex = AtlasTexture(atlas=self, region=(x, y, w, h)) self._regions[name] = tex return tex
[docs] def get_region(self, name: str) -> AtlasTexture | None: """Look up a named region.""" return self._regions.get(name)
[docs] def remove_region(self, name: str) -> bool: """Remove a named region. Returns True if it existed.""" if name in self._regions: del self._regions[name] return True return False
[docs] def has_region(self, name: str) -> bool: """Check whether a named region exists.""" return name in self._regions
@property def region_count(self) -> int: return len(self._regions) @property def region_names(self) -> list[str]: return list(self._regions)
[docs] def get_uv(self, name: str) -> tuple[float, float, float, float]: """Convenience: normalised UV rect for a named region.""" tex = self._regions.get(name) if tex is None: return (0.0, 0.0, 0.0, 0.0) return tex.get_uv_rect(self.width, self.height)
[docs] def __repr__(self) -> str: return f"TextureAtlas({self.width}x{self.height}, {self.region_count} regions)"
[docs] class SpriteSheet(TextureAtlas): """Grid-based sprite sheet with uniform frame size. Auto-generates AtlasTexture regions for each cell in a grid layout. Used for character animations, tile sets, icon grids. Regions are named ``frame_0``, ``frame_1``, ... in row-major order. """ def __init__( self, texture_path: str | None = None, frame_width: int = 0, frame_height: int = 0, columns: int = 0, rows: int = 0, margin: int = 0, spacing: int = 0, ): self.frame_width = frame_width self.frame_height = frame_height self.columns = columns self.rows = rows self.margin = margin self.spacing = spacing # Compute atlas size from grid parameters w = margin * 2 + columns * frame_width + max(columns - 1, 0) * spacing if columns > 0 else 0 h = margin * 2 + rows * frame_height + max(rows - 1, 0) * spacing if rows > 0 else 0 super().__init__(width=w, height=h, texture_path=texture_path) # Auto-generate frame regions self._generate_frames() def _generate_frames(self) -> None: """Populate regions for every cell in the grid.""" for row in range(self.rows): for col in range(self.columns): idx = row * self.columns + col x = self.margin + col * (self.frame_width + self.spacing) y = self.margin + row * (self.frame_height + self.spacing) self.add_region(f"frame_{idx}", x, y, self.frame_width, self.frame_height) @property def frame_count(self) -> int: return self.columns * self.rows
[docs] def get_frame(self, index: int) -> AtlasTexture | None: """Get the AtlasTexture for frame at *index*.""" if index < 0 or index >= self.frame_count: return None return self.get_region(f"frame_{index}")
[docs] def get_frame_rect(self, index: int) -> tuple[int, int, int, int]: """Get (x, y, w, h) for frame at *index*. Returns (0,0,0,0) if out of range.""" tex = self.get_frame(index) if tex is None: return (0, 0, 0, 0) return tex.region
[docs] def get_frame_uv(self, index: int) -> tuple[float, float, float, float]: """Normalised UV rect for a frame by index.""" tex = self.get_frame(index) if tex is None: return (0.0, 0.0, 0.0, 0.0) return tex.get_uv_rect(self.width, self.height)
[docs] def __repr__(self) -> str: return ( f"SpriteSheet({self.columns}x{self.rows}, " f"frame={self.frame_width}x{self.frame_height}, " f"{self.frame_count} frames)" )
[docs] def pack_atlas( sizes: list[tuple[str, int, int]], padding: int = 1, ) -> tuple[int, int, dict[str, tuple[int, int, int, int]]]: """Pack rectangles into a minimal atlas using a shelf algorithm. Items are sorted by height (descending) then placed in rows. The resulting atlas dimensions are rounded up to the next power of two for GPU friendliness. Args: sizes: List of ``(name, width, height)`` for each item to pack. padding: Pixels between packed items (and around the border). Returns: ``(atlas_width, atlas_height, regions)`` where *regions* maps ``name -> (x, y, w, h)``. """ if not sizes: return (0, 0, {}) # Sort by height descending (better shelf packing) sorted_items = sorted(sizes, key=lambda s: -s[2]) # First pass: determine required width (widest padded item) max_item_w = max(w + padding * 2 for _, w, _ in sorted_items) # Start with a width that fits everything reasonably atlas_w = _next_pow2(max(max_item_w, 64)) # Shelf packing regions: dict[str, tuple[int, int, int, int]] = {} cursor_x = padding cursor_y = padding shelf_h = 0 for name, w, h in sorted_items: pw, ph = w + padding, h + padding # padded size (padding on right/bottom) if cursor_x + pw > atlas_w: # Start a new shelf cursor_y += shelf_h cursor_x = padding shelf_h = 0 regions[name] = (cursor_x, cursor_y, w, h) shelf_h = max(shelf_h, ph) cursor_x += pw atlas_h = cursor_y + shelf_h + padding # Round up to power of two atlas_w = _next_pow2(atlas_w) atlas_h = _next_pow2(atlas_h) return (atlas_w, atlas_h, regions)
def _next_pow2(v: int) -> int: """Round up to the next power of two (minimum 1).""" if v <= 0: return 1 v -= 1 v |= v >> 1 v |= v >> 2 v |= v >> 4 v |= v >> 8 v |= v >> 16 return v + 1