use std::fs;
use std::path::{Path, PathBuf};
use image::{DynamicImage, ImageBuffer, Rgba, RgbaImage};
use serde::Serialize;
use walkdir::WalkDir;
use crate::{Error, Result};
#[derive(Clone)]
pub struct Sprite {
pub name: String,
pub image: RgbaImage,
}
impl Sprite {
pub fn width(&self) -> u32 {
self.image.width()
}
pub fn height(&self) -> u32 {
self.image.height()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Frame {
pub name: String,
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AtlasJson {
pub atlas: AtlasMeta,
pub frames: Vec<Frame>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct AtlasMeta {
pub image: String,
pub width: u32,
pub height: u32,
pub padding: u32,
}
pub struct PackedAtlas {
pub image: RgbaImage,
pub json: AtlasJson,
}
#[derive(Debug, Clone)]
pub struct PackConfig {
pub padding: u32,
pub max_size: u32,
pub power_of_two: bool,
pub image_name: String,
}
impl Default for PackConfig {
fn default() -> Self {
Self {
padding: 2,
max_size: 4096,
power_of_two: true,
image_name: "atlas.png".into(),
}
}
}
pub fn load_sprites(dir: &Path) -> Result<Vec<Sprite>> {
if !dir.is_dir() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("not a directory: {}", dir.display()),
)));
}
let mut paths: Vec<PathBuf> = WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.map(|e| e.into_path())
.filter(|p| {
p.is_file()
&& p.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("png"))
})
.collect();
paths.sort();
let mut sprites = Vec::with_capacity(paths.len());
for path in paths {
let rel = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
let dyn_img = image::open(&path)?;
let image = dyn_img.into_rgba8();
sprites.push(Sprite { name, image });
}
if sprites.is_empty() {
return Err(Error::NoSprites(dir.to_path_buf()));
}
sprites.sort_by(|a, b| a.name.cmp(&b.name));
Ok(sprites)
}
pub fn pack_images(sprites: &[Sprite], config: &PackConfig) -> Result<PackedAtlas> {
if sprites.is_empty() {
return Err(Error::NoSprites(PathBuf::from("<memory>")));
}
let mut order: Vec<usize> = (0..sprites.len()).collect();
order.sort_by(|&a, &b| {
let aa = sprites[a].width() * sprites[a].height();
let bb = sprites[b].width() * sprites[b].height();
bb.cmp(&aa)
.then_with(|| sprites[a].name.cmp(&sprites[b].name))
});
let mut size = initial_size(sprites, config)?;
loop {
if size > config.max_size {
return Err(Error::PackFailed {
width: config.max_size,
height: config.max_size,
});
}
match try_pack(sprites, &order, size, size, config.padding) {
Some(frames) => {
let image = blit(sprites, &frames, size, size);
let json = AtlasJson {
atlas: AtlasMeta {
image: config.image_name.clone(),
width: size,
height: size,
padding: config.padding,
},
frames,
};
return Ok(PackedAtlas { image, json });
}
None => {
size = next_size(size, config.power_of_two, config.max_size);
}
}
}
}
pub fn pack_directory(input_dir: &Path, config: &PackConfig) -> Result<PackedAtlas> {
let sprites = load_sprites(input_dir)?;
pack_images(&sprites, config)
}
pub fn pack_and_write(
input_dir: &Path,
output_image: &Path,
output_json: &Path,
mut config: PackConfig,
) -> Result<PackedAtlas> {
if let Some(name) = output_image.file_name().and_then(|s| s.to_str()) {
config.image_name = name.to_string();
}
let packed = pack_directory(input_dir, &config)?;
if let Some(parent) = output_image.parent() {
fs::create_dir_all(parent)?;
}
if let Some(parent) = output_json.parent() {
fs::create_dir_all(parent)?;
}
DynamicImage::ImageRgba8(packed.image.clone()).save(output_image)?;
let json = serde_json::to_string_pretty(&packed.json)?;
fs::write(output_json, json)?;
Ok(packed)
}
fn initial_size(sprites: &[Sprite], config: &PackConfig) -> Result<u32> {
let pad = config.padding;
let mut min_side = 1u32;
for s in sprites {
let need_w = s.width().saturating_add(pad);
let need_h = s.height().saturating_add(pad);
if need_w > config.max_size || need_h > config.max_size {
return Err(Error::SpriteTooLarge {
name: s.name.clone(),
sw: s.width(),
sh: s.height(),
aw: config.max_size,
ah: config.max_size,
});
}
min_side = min_side.max(need_w).max(need_h);
}
let area: u64 = sprites
.iter()
.map(|s| {
let w = u64::from(s.width().saturating_add(pad));
let h = u64::from(s.height().saturating_add(pad));
w * h
})
.sum();
let area_side = (area as f64).sqrt().ceil() as u32;
let mut size = min_side.max(area_side).max(16);
if config.power_of_two {
size = size.next_power_of_two();
}
Ok(size.min(config.max_size))
}
fn next_size(current: u32, power_of_two: bool, max: u32) -> u32 {
if power_of_two {
current.saturating_mul(2).min(max).max(current + 1)
} else {
let grown = current.saturating_add((current / 4).max(16));
grown.min(max).max(current + 1)
}
}
fn try_pack(
sprites: &[Sprite],
order: &[usize],
width: u32,
height: u32,
padding: u32,
) -> Option<Vec<Frame>> {
let mut frames = Vec::with_capacity(order.len());
let mut x = padding;
let mut y = padding;
let mut shelf_h = 0u32;
for &idx in order {
let s = &sprites[idx];
let w = s.width();
let h = s.height();
let step_x = w.saturating_add(padding);
if w > width.saturating_sub(padding) || h > height.saturating_sub(padding) {
return None;
}
if x > padding && x.saturating_add(w) > width.saturating_sub(padding) {
x = padding;
y = y.saturating_add(shelf_h).saturating_add(padding);
shelf_h = 0;
}
if y.saturating_add(h) > height.saturating_sub(padding) {
return None;
}
if x.saturating_add(w) > width.saturating_sub(padding) {
return None;
}
frames.push(Frame {
name: s.name.clone(),
x,
y,
w,
h,
});
x = x.saturating_add(step_x);
shelf_h = shelf_h.max(h);
}
frames.sort_by(|a, b| a.name.cmp(&b.name));
Some(frames)
}
fn blit(sprites: &[Sprite], frames: &[Frame], width: u32, height: u32) -> RgbaImage {
let mut atlas: RgbaImage = ImageBuffer::from_pixel(width, height, Rgba([0, 0, 0, 0]));
let by_name: std::collections::BTreeMap<&str, &Sprite> =
sprites.iter().map(|s| (s.name.as_str(), s)).collect();
for frame in frames {
let sprite = by_name[frame.name.as_str()];
for row in 0..frame.h {
for col in 0..frame.w {
let px = *sprite.image.get_pixel(col, row);
atlas.put_pixel(frame.x + col, frame.y + row, px);
}
}
}
atlas
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgba;
fn solid(name: &str, w: u32, h: u32, c: [u8; 4]) -> Sprite {
Sprite {
name: name.into(),
image: ImageBuffer::from_pixel(w, h, Rgba(c)),
}
}
#[test]
fn packs_deterministically() {
let sprites = vec![
solid("b", 8, 8, [1, 0, 0, 255]),
solid("a", 4, 4, [0, 1, 0, 255]),
solid("c", 6, 10, [0, 0, 1, 255]),
];
let cfg = PackConfig {
padding: 1,
max_size: 256,
power_of_two: true,
image_name: "atlas.png".into(),
};
let a = pack_images(&sprites, &cfg).unwrap();
let b = pack_images(&sprites, &cfg).unwrap();
assert_eq!(a.json, b.json);
assert_eq!(a.image.into_raw(), b.image.into_raw());
}
#[test]
fn frames_cover_all_sprites() {
let sprites = vec![
solid("hero", 16, 16, [255, 0, 0, 255]),
solid("coin", 8, 8, [255, 215, 0, 255]),
];
let packed = pack_images(&sprites, &PackConfig::default()).unwrap();
assert_eq!(packed.json.frames.len(), 2);
let names: Vec<_> = packed.json.frames.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, vec!["coin", "hero"]);
}
}