This commit is contained in:
Piotr Siuszko 2025-09-15 14:03:05 +02:00
parent 48447703f5
commit 61d4cd239e
6 changed files with 151 additions and 134 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ result.png
/.idea
/.vscode
Cargo.lock
.DS_Store

View File

@ -2,8 +2,8 @@ use crate::{AtlasAsset, SerializableRect};
use bevy::asset::{AssetLoader, AsyncReadExt};
use bevy::ecs::system::SystemParam;
use bevy::image::ImageSampler;
use bevy::prelude::*;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use thiserror::Error;
/// Errors that can occur while accessing and creating components from [`RpackAtlasAsset`].

View File

@ -9,7 +9,7 @@ use std::{
fmt::Display,
path::{Path, PathBuf},
};
use texture_packer::{importer::ImageImporter, TexturePacker, TexturePackerConfig};
use texture_packer::{TexturePacker, TexturePackerConfig, importer::ImageImporter};
use thiserror::Error;
#[derive(Clone)]
@ -267,7 +267,7 @@ impl TilemapGenerationConfig {
let dir = match &self.working_dir {
None => std::env::current_dir().expect("msg"),
Some(p) => {
if p.to_string_lossy().len() == 0 {
if p.as_os_str().is_empty() {
std::env::current_dir().expect("msg")
} else {
p.clone()
@ -336,7 +336,9 @@ impl TilemapGenerationConfig {
#[cfg(feature = "dds")]
spritesheet.save_as_dds(&atlas_image_path);
#[cfg(not(feature = "dds"))]
panic!("Program is compiled without support for dds. Compile it yourself with feature `dds` enabled.");
panic!(
"Program is compiled without support for dds. Compile it yourself with feature `dds` enabled."
);
}
SaveImageFormat::Png => {
spritesheet
@ -346,7 +348,9 @@ impl TilemapGenerationConfig {
#[cfg(feature = "basis")]
SaveImageFormat::Basis => {
spritesheet.save_as_basis(&atlas_image_path)?;
panic!("Program is compiled without support for basis. Compile it yourself with feature `basis` enabled.");
panic!(
"Program is compiled without support for basis. Compile it yourself with feature `basis` enabled."
);
}
}
let json = serde_json::to_string_pretty(&spritesheet.atlas_asset_json)?;

View File

@ -1,8 +1,4 @@
use std::collections::HashMap;
use egui::{
CollapsingHeader, Color32, DroppedFile, FontFamily, FontId, Grid, Image, Label, RichText,
};
use egui::{CollapsingHeader, Color32, FontFamily, FontId, Grid, Image, Label, RichText};
use rpack_cli::{ImageFile, Spritesheet, SpritesheetError};
use texture_packer::TexturePackerConfig;
@ -17,26 +13,23 @@ pub const GIT_HASH: &str = env!("GIT_HASH");
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
pub struct Application {
#[serde(skip)]
dropped_files: Vec<DroppedFile>,
#[serde(skip)]
config: TexturePackerConfig,
#[serde(skip)]
image: Option<Image<'static>>,
output: Option<SpriteSheetResult>,
#[serde(skip)]
name: String,
#[serde(skip)]
counter: i32,
#[serde(skip)]
data: Option<Result<Spritesheet, SpritesheetError>>,
#[serde(skip)]
min_size: [u32; 2],
#[serde(skip)]
max_size: u32,
#[serde(skip)]
image_data: HashMap<String, AppImageData>,
image_data: Vec<AppImageData>,
}
type SpriteSheetResult = Result<Spritesheet, SpritesheetError>;
#[derive(Clone)]
pub struct AppImageData {
pub width: u32,
pub height: u32,
@ -48,12 +41,19 @@ impl AppImageData {
pub fn id(&self) -> &str {
self.data.id.as_str()
}
pub fn update_id(&mut self, prefix: &str) {
self.data.id = self
.path
.strip_prefix(prefix)
.unwrap_or(&self.path)
.to_owned();
}
}
impl Default for Application {
fn default() -> Self {
Self {
dropped_files: vec![],
config: TexturePackerConfig {
max_width: 512,
max_height: 512,
@ -63,46 +63,40 @@ impl Default for Application {
force_max_dimensions: true,
..Default::default()
},
counter: 0,
image: None,
data: None,
output: None,
max_size: 4096,
name: String::from("Tilemap"),
min_size: [32, 32],
image_data: HashMap::new(),
image_data: Vec::new(),
}
}
}
impl Application {
pub fn rebuild_image_data(&mut self) {
pub fn get_common_prefix(&self) -> String {
let file_paths: Vec<String> = self
.dropped_files
.image_data
.iter()
.map(|dropped_file| dropped_file.file_path())
.collect();
let prefix = rpack_cli::get_common_prefix(&file_paths);
self.image_data = self
.dropped_files
.iter()
.flat_map(|f| f.create_image(&prefix).map(|i| (i.id().to_string(), i)))
.map(|image| image.path.clone())
.collect();
rpack_cli::get_common_prefix(&file_paths)
}
pub fn rebuild_image_data(&mut self) {
let prefix = self.get_common_prefix();
self.image_data
.iter_mut()
.for_each(|f| f.update_id(prefix.as_str()));
self.update_min_size();
}
pub fn update_min_size(&mut self) {
if let Some(file) = self
.image_data
.values()
.max_by(|a, b| a.width.cmp(&b.width))
{
if let Some(file) = self.image_data.iter().max_by(|a, b| a.width.cmp(&b.width)) {
self.min_size[0] = file.width;
} else {
self.min_size[0] = 32;
}
if let Some(file) = self
.image_data
.values()
.iter()
.max_by(|a, b| a.height.cmp(&b.height))
{
self.min_size[1] = file.height;
@ -127,11 +121,11 @@ impl Application {
}
fn build_atlas(&mut self, ctx: &egui::Context) {
self.data = None;
self.image = None;
self.output = None;
ctx.forget_image("bytes://output.png");
let images: Vec<ImageFile> = self
.image_data
.values()
.iter()
.map(|file| file.data.clone())
.collect();
@ -147,12 +141,8 @@ impl Application {
max_height: size,
..self.config
};
self.data = Some(Spritesheet::build(
config,
&images,
format!("{}.png", &self.name),
));
if let Some(Ok(data)) = &self.data {
match Spritesheet::build(config, &images, format!("{}.png", &self.name)) {
Ok(data) => {
let mut out_vec = vec![];
data.image_data
.write_to(
@ -161,15 +151,20 @@ impl Application {
)
.unwrap();
ctx.include_bytes("bytes://output.png", out_vec);
self.image = Some(Image::from_uri("bytes://output.png"));
self.output = Some(Ok(data));
break;
}
Err(e) => {
self.output = Some(Err(e));
}
}
}
ctx.request_repaint();
}
fn save_json(&self) -> Result<(), String> {
let Some(Ok(spritesheet)) = &self.data else {
let Some(Ok(spritesheet)) = &self.output else {
return Err("Data is incorrect".to_owned());
};
let data = spritesheet.atlas_asset_json.to_string();
@ -209,7 +204,7 @@ impl Application {
}
fn save_atlas(&self) -> Result<(), String> {
let Some(Ok(spritesheet)) = &self.data else {
let Some(Ok(spritesheet)) = &self.output else {
return Err("Data is incorrect".to_owned());
};
let filename = format!("{}.png", self.name);
@ -265,10 +260,10 @@ impl eframe::App for Application {
/// Called each time the UI needs repainting, which may be many times per second.
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.dropped_files.is_empty() && self.image.is_some() {
self.image = None;
self.data = None;
}
// if self.dropped_files.is_empty() && self.image.is_some() {
// self.image = None;
// self.data = None;
// }
egui::TopBottomPanel::top("topPanel")
.frame(egui::Frame::canvas(&ctx.style()))
.show(ctx, |ui| {
@ -283,9 +278,34 @@ impl eframe::App for Application {
});
ctx.input(|i| {
if !i.raw.dropped_files.is_empty() {
let mut extra = i.raw.dropped_files.clone();
self.dropped_files.append(&mut extra);
self.data = None;
for file in i.raw.dropped_files.iter() {
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = &file.path {
if path.is_dir() {
let Ok(dir) = path.read_dir() else {
continue;
};
for entry in dir {
if let Ok(entry) = entry {
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_file() {
let Some(dyn_image) = entry.create_image("") else {
continue;
};
self.image_data.push(dyn_image);
}
}
}
}
}
let Some(dyn_image) = file.create_image("") else {
continue;
};
self.image_data.push(dyn_image);
}
self.output = None;
self.rebuild_image_data();
}
});
@ -329,13 +349,11 @@ impl eframe::App for Application {
// ui.checkbox(&mut self.config.trim, "Trim").on_hover_text("True to trim the empty pixels of the input images.");
ui.add_space(10.0);
ui.add_enabled_ui(!self.dropped_files.is_empty(), |ui| {
ui.add_enabled_ui(!self.image_data.is_empty(), |ui| {
if ui
.add_sized([TOP_BUTTON_WIDTH, 30.0], egui::Button::new("Build atlas"))
.clicked()
{
self.image = None;
ctx.forget_image("bytes://output.png");
self.build_atlas(ctx);
}
ui.add_space(10.0);
@ -346,13 +364,12 @@ impl eframe::App for Application {
ui.separator();
CollapsingHeader::new("Image list")
.default_open(true)
.show(ui, |ui| {
.show_unindented(ui, |ui| {
ui.horizontal(|ui|{
if !self.image_data.is_empty() && ui.button("clear list").clicked() {
self.image_data.clear();
self.dropped_files.clear();
self.data = None;
self.output = None;
self.update_min_size();
}
ui.add_space(10.0);
@ -362,38 +379,36 @@ impl eframe::App for Application {
for file in files.iter() {
let Ok(image) = texture_packer::importer::ImageImporter::import_from_file(file) else { continue };
let id = crate::helpers::id_from_path(&file.to_string_lossy());
self.image_data.insert(file.to_string_lossy().to_string(), AppImageData { width: image.width(), height: image.height(), data: ImageFile { id: id, image }, path: file.to_string_lossy().to_string() });
self.image_data.push(AppImageData { width: image.width(), height: image.height(), data: ImageFile { id: id, image }, path: file.to_string_lossy().to_string() });
}
self.update_min_size();
self.rebuild_image_data();
}
}
});
let mut to_remove: Option<String> = None;
Grid::new("Image List").num_columns(4).striped(true).spacing((10.0,10.0)).show(ui, |ui|{
let mut to_remove: Option<usize> = None;
let columns = if cfg!(target_arch = "wasm32") {
3
} else {
4
};
Grid::new("Image List").num_columns(columns).striped(true).spacing((10.0,10.0)).show(ui, |ui|{
for (id, file) in self.image_data.iter() {
for (index, file) in self.image_data.iter().enumerate() {
if ui.button("x").clicked() {
to_remove = Some(id.clone());
to_remove = Some(index);
}
ui.image(format!("file://{}", file.path));
ui.add(Label::new(file.id()).selectable(false));
#[cfg(not(target_arch = "wasm32"))]
ui.image(format!("file://{}", file.path.as_str()));
ui.add(Label::new(format!("{}x{}", file.width, file.height)).selectable(false));
ui.add(Label::new(file.id()).selectable(false));
ui.end_row();
}
});
if let Some(index) = to_remove {
if let Some(i) = self
.dropped_files
.iter()
.position(|e| e.file_path().eq(&index))
{
self.dropped_files.remove(i);
self.image_data.remove(&index);
self.data = None;
self.image_data.remove(index);
self.output = None;
self.rebuild_image_data();
}
}
});
});
});
@ -401,7 +416,7 @@ impl eframe::App for Application {
egui::ScrollArea::vertical()
.id_salt("vertical_scroll")
.show(ui, |ui| {
if let Some(Err(error)) = &self.data {
if let Some(Err(error)) = &self.output {
let text = egui::RichText::new(format!("Error: {}", &error))
.font(FontId::new(20.0, FontFamily::Name("semibold".into())))
.color(Color32::RED)
@ -409,7 +424,7 @@ impl eframe::App for Application {
ui.add(egui::Label::new(text));
return;
}
if self.dropped_files.is_empty() {
if self.image_data.is_empty() {
ui.vertical_centered_justified(|ui| {
ui.add_space(50.0);
ui.add(
@ -422,17 +437,14 @@ impl eframe::App for Application {
);
});
}
let Some(image) = &self.image else {
return;
};
let Some(Ok(data)) = &self.data else {
let Some(Ok(data)) = &self.output else {
return;
};
ui.vertical_centered_justified(|ui| {
egui::Frame::canvas(&ctx.style()).show(ui, |ui| {
ui.add_space(10.0);
ui.heading(
egui::RichText::new("Crated atlas").color(MY_ACCENT_COLOR32),
egui::RichText::new("Created atlas").color(MY_ACCENT_COLOR32),
);
ui.add_space(10.0);
ui.label(format!(
@ -490,10 +502,9 @@ impl eframe::App for Application {
});
ui.add_space(10.0);
ui.separator();
ui.add(image.clone());
ui.add(Image::from_uri("bytes://output.png"));
ui.separator();
ui.add_space(10.0);
ui.add_space(10.0);
ui.add_space(20.0);
});
});
});

View File

@ -9,9 +9,36 @@ pub trait DroppedFileHelper {
fn file_path(&self) -> String;
fn create_image<P>(&self, prefix: P) -> Option<AppImageData>
where
P: AsRef<str>;
P: AsRef<str>,
{
let path = self.file_path();
let base_id = id_from_path(&path);
let id = base_id
.strip_prefix(prefix.as_ref())
.unwrap_or(&base_id)
.to_owned();
let image: DynamicImage = self.dynamic_image()?;
Some(AppImageData {
width: image.width(),
height: image.height(),
data: ImageFile { id, image },
path,
})
}
fn dynamic_image(&self) -> Option<DynamicImage>;
}
#[cfg(not(target_arch = "wasm32"))]
impl DroppedFileHelper for std::fs::DirEntry {
fn file_path(&self) -> String {
self.path().to_string_lossy().to_string()
}
fn dynamic_image(&self) -> Option<DynamicImage> {
ImageImporter::import_from_file(&self.path()).ok()
}
}
pub fn id_from_path(path: &str) -> String {
match path.rfind('.') {
@ -28,47 +55,21 @@ impl DroppedFileHelper for DroppedFile {
None => self.name.clone(),
}
}
fn create_image<P>(&self, prefix: P) -> Option<AppImageData>
where
P: AsRef<str>,
{
let path = self.file_path();
let base_id = id_from_path(&path);
let id = base_id
.strip_prefix(prefix.as_ref())
.unwrap_or(&base_id)
.to_owned();
let image: DynamicImage = self.dynamic_image()?;
Some(AppImageData {
width: image.width(),
height: image.height(),
data: ImageFile { id: id, image },
path,
})
}
fn dynamic_image(&self) -> Option<DynamicImage> {
#[cfg(target_arch = "wasm32")]
{
let bytes = self.bytes.as_ref().clone()?;
if let Ok(r) = ImageImporter::import_from_memory(bytes) {
Some(r.into())
} else {
None
}
ImageImporter::import_from_memory(bytes)
.ok()
.map(|r| r.into())
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = self.path.as_ref()?;
if let Ok(r) = ImageImporter::import_from_file(path) {
Some(r)
} else {
None
}
ImageImporter::import_from_file(path).ok()
}
}
}

View File

@ -29,7 +29,7 @@ fn main() {
wasm_bindgen_futures::spawn_local(async {
use web_sys::wasm_bindgen::JsCast;
use web_sys::{window, HtmlCanvasElement};
use web_sys::{HtmlCanvasElement, window};
let canvas = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("the_canvas_id"))