use crate::compat::{HashMap, Vec};
use crate::core::Size;
#[derive(Debug, Clone, Copy)]
pub struct AtlasRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone)]
pub struct AtlasEntry {
pub rect: AtlasRect,
pub texture_id: u64,
}
pub struct TextureAtlas {
max_size: Size,
entries: HashMap<u64, AtlasEntry>,
next_id: u64,
cursor_x: u32,
cursor_y: u32,
row_height: u32,
free_rects: Vec<AtlasRect>,
used_area: u64,
}
fn rect_area(rect: &AtlasRect) -> u64 {
rect.width as u64 * rect.height as u64
}
fn rectangles_are_adjacent(a: &AtlasRect, b: &AtlasRect) -> bool {
let horizontal = a.y == b.y
&& a.height == b.height
&& (a.x.saturating_add(a.width) == b.x || b.x.saturating_add(b.width) == a.x);
let vertical = a.x == b.x
&& a.width == b.width
&& (a.y.saturating_add(a.height) == b.y || b.y.saturating_add(b.height) == a.y);
horizontal || vertical
}
fn merge_adjacent(a: &AtlasRect, b: &AtlasRect) -> AtlasRect {
let horizontal = a.y == b.y && a.height == b.height;
let vertical = a.x == b.x && a.width == b.width;
AtlasRect {
x: a.x.min(b.x),
y: a.y.min(b.y),
width: if horizontal { a.width + b.width } else { a.width },
height: if vertical { a.height + b.height } else { a.height },
}
}
impl TextureAtlas {
pub fn new(max_size: Size) -> Self {
Self {
max_size,
entries: HashMap::new(),
next_id: 1,
cursor_x: 0,
cursor_y: 0,
row_height: 0,
free_rects: Vec::new(),
used_area: 0,
}
}
fn first_fitting_free_rect(&self, width: u32, height: u32) -> Option<usize> {
let required_area = width as u64 * height as u64;
let start = self.free_rects.partition_point(|rect| rect_area(rect) < required_area);
self.free_rects[start..]
.iter()
.position(|rect| width <= rect.width && height <= rect.height)
.map(|offset| start + offset)
}
fn insert_free_rect(&mut self, rect: AtlasRect) {
let area = rect_area(&rect);
let index = self.free_rects.partition_point(|other| rect_area(other) <= area);
self.free_rects.insert(index, rect);
}
pub fn allocate(&mut self, width: u32, height: u32) -> Option<(u64, AtlasRect)> {
if width > self.max_size.width || height > self.max_size.height {
return None;
}
let id = self.next_id;
let next_id = id.checked_add(1)?;
if width > 0 && height > 0 {
if let Some(index) = self.first_fitting_free_rect(width, height) {
let free = self.free_rects.remove(index);
let rect = AtlasRect { x: free.x, y: free.y, width, height };
if free.width > width {
self.insert_free_rect(AtlasRect {
x: free.x + width,
y: free.y,
width: free.width - width,
height,
});
}
if free.height > height {
self.insert_free_rect(AtlasRect {
x: free.x,
y: free.y + height,
width: free.width,
height: free.height - height,
});
}
self.next_id = next_id;
self.used_area += rect_area(&rect);
self.entries.insert(id, AtlasEntry { rect, texture_id: id });
return Some((id, rect));
}
}
let wrap = self.cursor_x.checked_add(width).map(|next| next > self.max_size.width)?;
let x = if wrap { 0 } else { self.cursor_x };
let y = if wrap { self.cursor_y.checked_add(self.row_height)? } else { self.cursor_y };
if y.checked_add(height)? > self.max_size.height {
return None; }
self.next_id = next_id;
let rect = AtlasRect { x, y, width, height };
self.used_area += rect_area(&rect);
self.entries.insert(id, AtlasEntry { rect, texture_id: id });
self.cursor_x = x.checked_add(width)?;
self.cursor_y = y;
self.row_height = if wrap { height } else { self.row_height.max(height) };
Some((id, rect))
}
pub fn get(&self, id: u64) -> Option<&AtlasEntry> {
self.entries.get(&id)
}
pub fn remove(&mut self, id: u64) {
let Some(entry) = self.entries.remove(&id) else { return };
self.used_area = self.used_area.saturating_sub(rect_area(&entry.rect));
if self.entries.is_empty() {
self.cursor_x = 0;
self.cursor_y = 0;
self.row_height = 0;
self.free_rects.clear();
self.used_area = 0;
return;
}
let mut rect = entry.rect;
if rect.width == 0 || rect.height == 0 {
return;
}
let pending = core::mem::take(&mut self.free_rects);
let mut kept = Vec::with_capacity(pending.len());
for other in pending {
if rectangles_are_adjacent(&rect, &other) {
rect = merge_adjacent(&rect, &other);
} else {
kept.push(other);
}
}
self.free_rects = kept;
self.insert_free_rect(rect);
}
pub fn clear(&mut self) {
self.entries.clear();
self.free_rects.clear();
self.cursor_x = 0;
self.cursor_y = 0;
self.row_height = 0;
self.used_area = 0;
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn utilization(&self) -> f32 {
if self.max_size.width == 0 || self.max_size.height == 0 {
return 0.0;
}
let total_pixels = self.max_size.width as u64 * self.max_size.height as u64;
self.used_area as f32 / total_pixels as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entries_pack_left_to_right_and_wrap_to_a_new_row() {
let mut atlas = TextureAtlas::new(Size::new(100, 100));
let (_, a) = atlas.allocate(60, 20).expect("first entry fits");
assert_eq!((a.x, a.y), (0, 0));
let (_, b) = atlas.allocate(60, 20).expect("second entry wraps to a new row");
assert_eq!((b.x, b.y), (0, 20), "it starts at column 0 on the row below");
let (_, c) = atlas.allocate(30, 20).expect("a narrow entry follows on the current row");
assert_eq!((c.x, c.y), (60, 20));
}
#[test]
fn an_entry_larger_than_the_atlas_is_refused() {
let mut atlas = TextureAtlas::new(Size::new(64, 64));
assert!(atlas.allocate(128, 16).is_none(), "wider than the atlas");
assert!(atlas.allocate(16, 128).is_none(), "taller than the atlas");
assert!(atlas.allocate(64, 64).is_some(), "an exactly-atlas-sized entry still fits");
}
#[test]
fn a_huge_request_is_refused_without_panicking() {
let mut atlas = TextureAtlas::new(Size::new(64, 64));
assert!(atlas.allocate(u32::MAX, 1).is_none());
assert!(atlas.allocate(1, u32::MAX).is_none());
assert!(atlas.allocate(u32::MAX, u32::MAX).is_none());
assert!(atlas.allocate(8, 8).is_some());
}
#[test]
fn a_failed_allocation_does_not_corrupt_packer_state() {
let mut atlas = TextureAtlas::new(Size::new(10, 10));
assert!(atlas.allocate(6, 6).is_some());
assert!(atlas.allocate(5, 5).is_none());
let (_, rect) = atlas.allocate(4, 6).expect("packer state must remain intact");
assert_eq!((rect.x, rect.y), (6, 0));
}
#[test]
fn removed_rectangles_are_split_and_coalesced_without_moving_live_entries() {
let mut atlas = TextureAtlas::new(Size::new(16, 8));
let (live, _) = atlas.allocate(8, 8).unwrap();
let (removed, _) = atlas.allocate(8, 8).unwrap();
for _ in 0..100 {
atlas.remove(removed);
let (a, first) = atlas.allocate(4, 8).unwrap();
let (b, second) = atlas.allocate(4, 8).unwrap();
assert_eq!((first.x, second.x), (8, 12));
atlas.remove(a);
atlas.remove(b);
let (id, rect) = atlas.allocate(8, 8).unwrap();
assert_eq!((rect.x, rect.y), (8, 0));
atlas.remove(id);
}
assert_eq!(atlas.get(live).unwrap().rect.x, 0);
assert!(atlas.get(removed).is_none());
}
#[test]
fn exhausted_ids_do_not_replace_live_entries() {
let mut atlas = TextureAtlas::new(Size::new(8, 8));
atlas.next_id = u64::MAX;
assert!(atlas.allocate(1, 1).is_none());
assert!(atlas.is_empty());
}
#[test]
fn utilization_tracks_the_used_area() {
let mut atlas = TextureAtlas::new(Size::new(100, 100));
assert_eq!(atlas.utilization(), 0.0);
let (_, rect) = atlas.allocate(50, 50).expect("fits");
assert_eq!(rect.width, 50);
assert!((atlas.utilization() - 0.25).abs() < 1e-6);
}
#[test]
fn utilization_tracks_removals_incrementally() {
let mut atlas = TextureAtlas::new(Size::new(100, 100));
let (first, _) = atlas.allocate(40, 50).expect("fits"); let (second, _) = atlas.allocate(20, 25).expect("fits"); assert!((atlas.utilization() - 0.25).abs() < 1e-6);
atlas.remove(first);
assert!((atlas.utilization() - 0.05).abs() < 1e-6, "2000 texels released");
atlas.remove(second);
assert_eq!(atlas.utilization(), 0.0);
}
#[test]
fn free_rect_size_index_skips_undersized_holes() {
let mut atlas = TextureAtlas::new(Size::new(32, 32));
let (small, _) = atlas.allocate(8, 8).expect("fits");
let (large, _) = atlas.allocate(24, 24).expect("fits");
let (_keep, _) = atlas.allocate(4, 4).expect("fits on the next row");
atlas.remove(small);
atlas.remove(large);
let (_, rect) = atlas.allocate(16, 16).expect("the large hole fits");
assert_eq!((rect.x, rect.y), (8, 0));
}
}