Skip to content
Snippets Groups Projects
atlas.rs 6.13 KiB
Newer Older
Louis's avatar
Louis committed
use clap::Parser;
Louis's avatar
Louis committed
use etagere::{AllocId, AtlasAllocator, Rectangle, Size};
use image::{image_dimensions, GenericImage, Rgba, RgbaImage};
use rayon::prelude::{IntoParallelIterator, ParallelIterator};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
Louis's avatar
Louis committed
use std::collections::HashMap;
use std::fmt::Display;
use std::fs::File;
Louis's avatar
Louis committed
use std::path::PathBuf;
Louis's avatar
Louis committed
use crunch_cli::utils::load_image;
Louis's avatar
Louis committed

fn default_max_size() -> usize {
	2048
}

/// Given a set of images, create a single atlas image and metadata file containing all of the original
/// set
#[derive(Parser, Serialize, Deserialize, Clone, Debug)]
#[clap(author, version = "0.8.2")]
Louis's avatar
Louis committed
pub struct Atlas {
	/// A pattern evaluating to one or more image files
	#[serde(default)]
	pub glob: String,
	/// The path to use when writing the texture atlas
	#[serde(default)]
	pub output: String,
	/// The maximum width of the output texture
	#[serde(default = "default_max_size")]
Louis's avatar
Louis committed
	#[clap(short = 'x', long = "max_width")]
Louis's avatar
Louis committed
	pub max_frame_width: usize,
	/// The maximum height of the output texture
	#[serde(default = "default_max_size")]
Louis's avatar
Louis committed
	#[clap(short = 'y', long = "max_height")]
Louis's avatar
Louis committed
	pub max_frame_height: usize,
Louis's avatar
Louis committed
	/// Set a fixed size for each sprite to occupy, creating a fixed grid spritesheet. Sprites must
	/// be padded to the correct size before specifying an area
	#[clap(short = 'a', long = "area")]
	pub area: Option<usize>,
	/// Perform a numeric sort on the names of files being combined.
	///
	/// With this flag enabled, the files "104", "5", "2045" would be ordered as "5", "104", "2045"
	/// Without this flag, those same files would be ordered by OS determination, typically ""104", "2045", "5"
	#[serde(default)]
	#[clap(short = 'n')]
	pub numeric: bool,
Louis's avatar
Louis committed
}

#[derive(Copy, Clone, Hash, Eq, PartialEq)]
struct AtlasIdent {
	page: usize,
	id: u32,
}

struct SliceData {
	path: PathBuf,
}

struct AtlasBuilder {
	wh: (usize, usize),
	pages: Vec<AtlasAllocator>,
}

impl AtlasBuilder {
	pub fn new(wh: (usize, usize)) -> Self {
		Self {
			wh,
			pages: Vec::with_capacity(1),
		}
	}

	pub fn insert(
		&mut self,
		width: u32,
		height: u32,
		path: PathBuf,
	) -> Option<(AtlasIdent, SliceData)> {
		for (idx, page) in self.pages.iter_mut().enumerate() {
			if let Some(alloc) = page.allocate(Size::new(width as i32, height as i32)) {
				return Some((
					AtlasIdent {
						page: idx,
						id: alloc.id.serialize(),
					},
					SliceData { path },
				));
			}
		}

		let mut new_page = AtlasAllocator::new(Size::new(self.wh.0 as i32, self.wh.1 as i32));
		if let Some(alloc) = new_page.allocate(Size::new(width as i32, height as i32)) {
			let idx = self.pages.len();
			self.pages.push(new_page);

			Some((
				AtlasIdent {
					page: idx,
					id: alloc.id.serialize(),
				},
				SliceData { path },
			))
		} else {
			None
		}
	}

	pub fn get(&self, ident: AtlasIdent) -> Option<Rectangle> {
		self.pages
			.get(ident.page)
			.map(|page| page.get(AllocId::deserialize(ident.id)))
	}
}

impl Atlas {
	pub fn run(&self) -> anyhow::Result<()> {
		let pattern = glob::glob(self.glob.as_str())?;
		let mut builder = AtlasBuilder::new((self.max_frame_width, self.max_frame_height));

		let mut pattern = pattern.filter_map(Result::ok).collect::<Vec<PathBuf>>();

		if self.numeric {
			pattern.sort_by(|a, b| {
				let a_num: isize = match a
					.file_stem()
					.and_then(|s| s.to_str())
					.and_then(|s| s.parse().ok())
				{
					Some(v) => v,
					_ => return Ordering::Equal,
				};
				let b_num: isize = match b
					.file_stem()
					.and_then(|s| s.to_str())
					.and_then(|s| s.parse().ok())
				{
					Some(v) => v,
					_ => return Ordering::Equal,
				};
				a_num.cmp(&b_num)
			});
		} else {
			pattern.sort_by(|a, b| {
				let a_num = match a.file_stem().and_then(|s| s.to_str()) {
					Some(v) => v,
					_ => return Ordering::Equal,
				};
				let b_num = match b.file_stem().and_then(|s| s.to_str()) {
					Some(v) => v,
					_ => return Ordering::Equal,
				};
				a_num.cmp(b_num)
			})
		}

Louis's avatar
Louis committed
		let page_content_map: HashMap<usize, Vec<(PathBuf, Rectangle)>> = pattern
			.into_iter()
Louis's avatar
Louis committed
			.flat_map(|path| {
				if let Some(fixed) = &self.area {
					Ok((*fixed as u32, *fixed as u32, path))
				} else {
					image_dimensions(&path).map(|(w, h)| (w, h, path))
				}
			})
Louis's avatar
Louis committed
			.flat_map(|(w, h, path)| builder.insert(w, h, path))
			.collect::<Vec<(AtlasIdent, SliceData)>>()
			.into_iter()
			.flat_map(|(ident, slice)| {
				builder
					.get(ident)
					.map(|rect| (ident.page, slice.path, rect))
			})
			.fold(HashMap::default(), |mut map, (page, path, rect)| {
Louis's avatar
Louis committed
				let entry = map.entry(page).or_default();
Louis's avatar
Louis committed
				entry.push((path, rect));
				map
			});

		page_content_map.into_par_iter().for_each(|(page, items)| {
			if let Err(err) = write_atlas(
				self.output.clone(),
				(self.max_frame_width as u32, self.max_frame_height as u32),
				page,
				items,
			) {
				log::error!("{}", err);
			}
		});

		Ok(())
	}
}

#[derive(Serialize, Deserialize)]
struct IndexArea {
	left: usize,
	right: usize,
	top: usize,
	bottom: usize,
}

#[derive(Serialize, Deserialize)]
struct IndexEntry {
	name: String,
	area: IndexArea,
}

fn write_atlas(
	output_path: impl Display,
	image_wh: (u32, u32),
	page_num: usize,
	items: Vec<(PathBuf, Rectangle)>,
) -> anyhow::Result<()> {
	let mut new_image = RgbaImage::from_pixel(image_wh.0, image_wh.1, Rgba::from([0, 0, 0, 0]));
	let mut metadata_index = Vec::with_capacity(items.len());
	for (source, spacing) in items {
		let base_name = source
			.file_stem()
			.ok_or(anyhow::Error::msg("Path had no stem"))?
			.to_str()
			.ok_or(anyhow::Error::msg("Path was not unicode"))?
			.to_string();

		let source_image = load_image(
			source
				.to_str()
				.ok_or(anyhow::Error::msg("Path was wrong"))?,
			None,
		)?;

		metadata_index.push(IndexEntry {
			name: base_name,
			area: IndexArea {
				left: spacing.min.x as usize,
				right: spacing.max.x as usize,
				bottom: spacing.max.y as usize,
				top: spacing.min.y as usize,
			},
		});

		new_image.copy_from(&source_image, spacing.min.x as u32, spacing.min.y as u32)?;
	}

	new_image.save(format!("{}_{}.png", output_path, page_num))?;
	let new_file = File::create(format!("{}_{}.json", output_path, page_num))?;
	serde_json::to_writer_pretty(new_file, &metadata_index)?;

	Ok(())
}