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