diff --git a/.gitignore b/.gitignore index 6b39d31a96291e28ad5c033dc1267db1ff1b278d..673431564e4776f2ddc92de759bf02c814cb8b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target -.idea/ \ No newline at end of file +.idea/ +exmp/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8ccd4838bcddea3f432af2cba51cba72cf36c490..177243163b414f4ad45b06e51fa04835682bc4b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,12 +233,13 @@ dependencies = [ [[package]] name = "crunch-cli" -version = "0.5.3" +version = "0.6.0" dependencies = [ "anyhow", "clap", "deltae", "env_logger", + "etagere", "glam", "glob", "image", @@ -310,6 +311,25 @@ dependencies = [ "libc", ] +[[package]] +name = "etagere" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcf22f748754352918e082e0039335ee92454a5d62bcaf69b5e8daf5907d9644" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f253bc5c813ca05792837a0ff4b3a580336b224512d48f7eda1d7dd9210787" +dependencies = [ + "num-traits", +] + [[package]] name = "exr" version = "1.7.0" @@ -825,6 +845,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "svg_fmt" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb1df15f412ee2e9dfc1c504260fa695c1c3f10fe9f4a6ee2d2184d7d6450e2" + [[package]] name = "syn" version = "2.0.27" diff --git a/Cargo.toml b/Cargo.toml index 702df2958eb714858cc64b3c50b8b17a2c4a790a..a18b1f041807a145dc0b3cad205f218702afb3fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crunch-cli" -version = "0.5.3" +version = "0.6.0" edition = "2021" homepage = "https://microhacks.lcr.app/crunch/" @@ -35,3 +35,4 @@ toml = "0.7.3" serde = { version = "1.0.156", features = ["derive"] } serde_json = "1.0.94" glob = "0.3.1" +etagere = "0.2.8" diff --git a/src/cli_args.rs b/src/cli_args.rs index 6bb2d464f6509bdebeec64aa5a95dc25ac12d53b..8e3db8198eba9484b4816c95f53afc32715a272f 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -2,8 +2,9 @@ use clap::Parser; use image::ImageFormat; use serde::{Deserialize, Serialize}; -// use crate::commands::{calculate_mapping, execute_pipeline, extrude, flip, palette, remap_image, rescale, rotate, write_palette, FlipDirection, RotateDegree, Rotate}; -use crate::commands::{Extrude, Flip, Palette, Pipeline, Reduce, Remap, Rotate, Scale, Split}; +use crate::commands::{ + Atlas, Extrude, Flip, Palette, Pipeline, Reduce, Remap, Rotate, Scale, Split, +}; use crate::load_image; @@ -43,6 +44,9 @@ pub enum Args { #[clap(name = "split")] #[serde(alias = "split")] Split(Split), + #[clap(name = "atlas")] + #[serde(alias = "atlas")] + Atlas(Atlas), } impl Args { @@ -108,7 +112,7 @@ impl Args { split.run(&image) } Args::Pipeline(pipeline) => pipeline.execute(), - _ => Ok(()), + Args::Atlas(atlas) => atlas.run(), } } } diff --git a/src/commands/atlas.rs b/src/commands/atlas.rs new file mode 100644 index 0000000000000000000000000000000000000000..d867dbcc07edec7f33674ff69e2ac34bfa50ddfa --- /dev/null +++ b/src/commands/atlas.rs @@ -0,0 +1,209 @@ +use clap::Parser; +use std::collections::HashMap; +use std::fmt::Display; +use std::fs::File; + +use image::{ + image_dimensions, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba, RgbaImage, +}; + +use num_traits::AsPrimitive; + +use serde::{Deserialize, Serialize}; + +use crate::format::load_image; +use etagere::{AllocId, AtlasAllocator, Rectangle, Size}; +use glob::MatchOptions; +use rayon::prelude::{IntoParallelIterator, ParallelBridge, ParallelIterator}; +use std::path::{Path, PathBuf}; +use thiserror::__private::DisplayAsDisplay; + +#[inline(always)] +fn tile_size() -> u32 { + 32 +} + +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.7.0")] +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")] + #[clap(short = 'w', long = "max_width")] + pub max_frame_width: usize, + /// The maximum height of the output texture + #[serde(default = "default_max_size")] + #[clap(short = 'h', long = "max_height")] + pub max_frame_height: usize, +} + +#[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 page_content_map: HashMap<usize, Vec<(PathBuf, Rectangle)>> = pattern + .into_iter() + .filter_map(Result::ok) + .flat_map(|path| image_dimensions(&path).map(|(w, h)| (w, h, path))) + .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)| { + let mut entry = map.entry(page).or_default(); + 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(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c2538e25d4e4d57d742b0db79c879eb96989837b..d39c70b421e64788e03364af810702d7f92d3d3d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod atlas; mod extrude; mod flip; mod palette; @@ -8,6 +9,7 @@ mod rotate; mod scale; mod split; +pub use atlas::Atlas; pub use extrude::Extrude; pub use flip::Flip; pub use palette::Palette;