rpack/src/app.rs

547 lines
20 KiB
Rust

use std::{collections::HashMap, io::Cursor};
use egui::{CollapsingHeader, Color32, DroppedFile, FontFamily, FontId, Image, RichText, Vec2};
use image::DynamicImage;
use serde_json::Value;
use texture_packer::{importer::ImageImporter, TexturePacker, TexturePackerConfig};
pub const MY_ACCENT_COLOR32: Color32 = Color32::from_rgb(230, 102, 1);
pub const TOP_SIDE_MARGIN: f32 = 10.0;
pub const HEADER_HEIGHT: f32 = 45.0;
pub const TOP_BUTTON_WIDTH: f32 = 150.0;
pub const GIT_HASH: &str = env!("GIT_HASH");
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct AtlasFrame {
pub key: String,
pub frame: SerializableRect,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct AtlasAsset {
pub size: [u32; 2],
pub name: String,
pub frames: Vec<AtlasFrame>,
}
#[derive(Clone)]
pub struct Spritesheet {
pub data: Vec<u8>,
pub frames: HashMap<String, texture_packer::Frame<String>>,
pub atlas_asset_json: Value,
pub size: (u32, u32),
}
/// Boundaries and properties of a packed texture.
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct SerializableFrame {
/// Key used to uniquely identify this frame.
pub key: String,
/// Rectangle describing the texture coordinates and size.
pub frame: SerializableRect,
/// True if the texture was rotated during packing.
/// If it was rotated, it was rotated 90 degrees clockwise.
pub rotated: bool,
/// True if the texture was trimmed during packing.
pub trimmed: bool,
// (x, y) is the trimmed frame position at original image
// (w, h) is original image size
//
// w
// +--------------+
// | (x, y) |
// | ^ |
// | | |
// | ********* |
// | * * | h
// | * * |
// | ********* |
// | |
// +--------------+
/// Source texture size before any trimming.
pub source: SerializableRect,
}
impl From<texture_packer::Frame<String>> for SerializableFrame {
fn from(value: texture_packer::Frame<String>) -> Self {
SerializableFrame {
key: value.key,
frame: value.frame.into(),
rotated: value.rotated,
trimmed: value.trimmed,
source: value.source.into(),
}
}
}
/// Defines a rectangle in pixels with the origin at the top-left of the texture atlas.
#[derive(Copy, Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct SerializableRect {
/// Horizontal position the rectangle begins at.
pub x: u32,
/// Vertical position the rectangle begins at.
pub y: u32,
/// Width of the rectangle.
pub w: u32,
/// Height of the rectangle.
pub h: u32,
}
impl From<texture_packer::Rect> for SerializableRect {
fn from(value: texture_packer::Rect) -> Self {
SerializableRect {
h: value.h,
w: value.w,
x: value.x,
y: value.y,
}
}
}
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
pub struct TemplateApp {
#[serde(skip)]
dropped_files: Vec<DroppedFile>,
#[serde(skip)]
config: TexturePackerConfig,
#[serde(skip)]
image: Option<Image<'static>>,
#[serde(skip)]
counter: i32,
#[serde(skip)]
data: Option<Spritesheet>,
#[serde(skip)]
error: Option<String>,
}
impl Default for TemplateApp {
fn default() -> Self {
Self {
dropped_files: vec![],
config: TexturePackerConfig {
max_width: 512,
max_height: 512,
allow_rotation: false,
border_padding: 2,
trim: false,
..Default::default()
},
counter: 0,
image: None,
data: None,
error: None,
}
}
}
impl TemplateApp {
/// Called once before the first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
setup_custom_fonts(&cc.egui_ctx);
// This is also where you can customize the look and feel of egui using
// `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.
egui_extras::install_image_loaders(&cc.egui_ctx);
// Load previous app state (if any).
// Note that you must enable the `persistence` feature for this to work.
if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
}
Default::default()
}
fn get_common_prefix(paths: &[DroppedFile]) -> String {
if paths.is_empty() {
return String::new();
}
let mut prefix = file_path(&paths[0]);
for s in paths.iter().skip(1) {
let s = file_path(s);
while !s.starts_with(&prefix) {
prefix.pop(); // Remove the last character of the prefix
if prefix.is_empty() {
return String::new();
}
}
}
prefix
}
fn build_atlas(&mut self, ctx: &egui::Context) {
self.error = None;
let mut packer = TexturePacker::new_skyline(self.config);
let prefix = Self::get_common_prefix(&self.dropped_files);
println!("Prefix: {}", prefix);
for file in &self.dropped_files {
let base_id = file_path(file);
let id = base_id
.strip_prefix(&prefix)
.unwrap_or(&base_id)
.to_owned()
.replace("\\", "/");
println!("Base id: {}, ID: {}", &base_id, &id);
let texture = dynamic_image_from_file(file);
let can_pack = packer.can_pack(&texture);
if can_pack {
packer.pack_own(id, texture).unwrap();
} else {
self.error = Some(format!(
"Consider making atlas bigger. Could not make atlas, failed on: {}",
id
));
return;
}
}
for (name, frame) in packer.get_frames() {
println!(" {:7} : {:?}", name, frame.frame);
}
let mut out_vec = vec![];
let exported_image = texture_packer::exporter::ImageExporter::export(&packer).unwrap();
let mut img = image::DynamicImage::new_rgba8(self.config.max_width, self.config.max_height);
image::imageops::overlay(&mut img, &exported_image, 0, 0);
img.write_to(&mut Cursor::new(&mut out_vec), image::ImageFormat::Png)
.unwrap();
let atlas = AtlasAsset {
size: [img.width(), img.height()],
name: "Atlas".to_owned(),
frames: packer
.get_frames()
.values()
.map(|v| -> AtlasFrame {
AtlasFrame {
key: v.key.clone(),
frame: SerializableRect {
x: v.frame.x,
y: v.frame.y,
w: v.frame.w,
h: v.frame.h,
},
}
})
.collect(),
};
let frames_string = serde_json::to_string_pretty(&atlas).unwrap();
let atlas_asset_json = serde_json::from_str(&frames_string).unwrap();
self.data = Some(Spritesheet {
data: out_vec.clone(),
frames: packer.get_frames().clone(),
size: (img.width(), img.height()),
atlas_asset_json,
});
let id = format!("bytes://output_{}.png", self.counter);
self.image = None;
ctx.forget_image(&id);
self.counter += 1;
let id = format!("bytes://output_{}.png", self.counter);
ctx.include_bytes(id.clone(), out_vec.clone());
self.image = Some(Image::from_uri(id.clone()).max_size(Vec2::new(256.0, 256.0)));
ctx.request_repaint();
}
fn save_atlas(&mut self) {
if self.data.is_none() {
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
let data = self.data.clone().unwrap().data;
use std::io::Write;
let path_buf = rfd::FileDialog::new()
.set_directory(".")
.add_filter("Image", &["png"])
.set_file_name("output.png")
.save_file();
if let Some(path) = path_buf {
let mut file = std::fs::File::create(path).unwrap();
let write_result = file.write_all(&data);
if write_result.is_err() {
self.error = Some(format!(
"Could not make atlas, error: {:?}",
write_result.unwrap_err()
));
} else {
println!("Output texture stored in {:?}", file);
}
}
}
#[cfg(target_arch = "wasm32")]
{
let data = self.data.clone().unwrap().data;
wasm_bindgen_futures::spawn_local(async move {
let file = rfd::AsyncFileDialog::new()
.set_directory(".")
.set_file_name("output.png")
.save_file()
.await;
match file {
None => (),
Some(file) => {
// let module = serde_yaml::to_string(&module).unwrap();
// TODO: error handling
file.write(&data).await.unwrap();
}
}
});
}
}
}
fn setup_custom_fonts(ctx: &egui::Context) {
// Start with the default fonts (we will be adding to them rather than replacing them).
let mut fonts = egui::FontDefinitions::default();
// Install my own font (maybe supporting non-latin characters).
// .ttf and .otf files supported.
fonts.font_data.insert(
"regular".to_owned(),
egui::FontData::from_static(include_bytes!("../static/JetBrainsMonoNL-Regular.ttf")).into(),
);
fonts.font_data.insert(
"semibold".to_owned(),
egui::FontData::from_static(include_bytes!("../static/JetBrainsMono-SemiBold.ttf")).into(),
);
// Put my font first (highest priority) for proportional text:
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "regular".to_owned());
fonts
.families
.entry(egui::FontFamily::Name("semibold".into()))
.or_default()
.insert(0, "semibold".to_owned());
// Put my font as last fallback for monospace:
fonts
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.push("regular".to_owned());
// Tell egui to use these fonts:
ctx.set_fonts(fonts);
}
impl eframe::App for TemplateApp {
/// Called by the frame work to save state before shutdown.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
/// 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;
}
egui::TopBottomPanel::top("topPanel")
.frame(egui::Frame::canvas(&ctx.style()))
.show(ctx, |ui| {
ui.with_layout(
egui::Layout::left_to_right(egui::Align::Center)
.with_cross_align(eframe::emath::Align::Center),
|ui| {
let text = egui::RichText::new("rPack")
.font(FontId::new(26.0, FontFamily::Name("semibold".into())))
.color(MY_ACCENT_COLOR32)
.strong();
ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, HEADER_HEIGHT));
ui.add(egui::Label::new(text));
let available_width =
ui.available_width() - ((TOP_BUTTON_WIDTH - TOP_SIDE_MARGIN) * 3.0);
ui.allocate_space(egui::vec2(available_width, HEADER_HEIGHT));
ui.add_enabled_ui(self.data.is_some(), |ui| {
if ui
.add_sized([TOP_BUTTON_WIDTH, 30.0], egui::Button::new("Save"))
.clicked()
{
self.save_atlas();
}
});
ui.add_enabled_ui(!self.dropped_files.is_empty(), |ui| {
ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, 10.0));
if ui
.add_sized(
[TOP_BUTTON_WIDTH, 30.0],
egui::Button::new("Build atlas"),
)
.clicked()
{
self.build_atlas(ctx);
}
});
ui.allocate_space(egui::vec2(TOP_SIDE_MARGIN, 10.0));
},
);
});
ctx.input(|i| {
if !i.raw.dropped_files.is_empty() {
let mut extra = i.raw.dropped_files.clone();
self.dropped_files.append(&mut extra);
}
});
egui::TopBottomPanel::bottom("bottom_panel")
.frame(egui::Frame::canvas(&ctx.style()))
.show(ctx, |ui| {
powered_by_egui_and_eframe(ui);
});
egui::CentralPanel::default().show(ctx, |ui| {
if let Some(error) = &self.error {
let text = egui::RichText::new(format!("Error: {}",&error))
.font(FontId::new(20.0, FontFamily::Name("semibold".into())))
.color(Color32::RED)
.strong();
ui.add(egui::Label::new(text));
}
if !self.dropped_files.is_empty() {
ui.horizontal_top(|ui|{
if let Some(image) = &self.image {
ui.add(image.clone());
}
CollapsingHeader::new("Settings")
.default_open(false)
.show(ui, |ui| {
ui.add(
egui::Slider::new(&mut self.config.max_width, 64..=4096).text("Width"),
);
ui.add(
egui::Slider::new(&mut self.config.max_height, 64..=4096).text("Height"),
);
ui.add(
egui::Slider::new(&mut self.config.border_padding, 0..=10).text("Border Padding"),
);
ui.add(
egui::Slider::new(&mut self.config.texture_padding, 0..=10).text("Texture Padding"),
);
ui.checkbox(&mut self.config.allow_rotation, "Allow Rotation")
.on_hover_text("True to allow rotation of the input images. Default value is `true`. Images rotated will be rotated 90 degrees clockwise.");
ui.checkbox(&mut self.config.texture_outlines, "Texture Outlines")
.on_hover_text("True to draw the red line on the edge of the each frames. Useful for debugging.");
ui.checkbox(&mut self.config.trim, "Trim").on_hover_text("True to trim the empty pixels of the input images.");
});
});
ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui|{
egui::ScrollArea::vertical().auto_shrink(false).show(ui, |ui| {
if let Some(data) = &self.data {
ui.horizontal_top(|ui|{
ui.label(format!("{} frames, size: {}x{}",data.frames.len(),data.size.0,data.size.1));
});
ui.label(RichText::new("Frames JSON").strong());
egui_json_tree::JsonTree::new("simple-tree", &data.atlas_asset_json).show(ui);
if ui
.add(egui::Button::new("Copy JSON to Clipboard"))
.clicked()
{
ui.output_mut(|o| o.copied_text = data.atlas_asset_json.to_string());
};
}
ui.separator();
let mut index_to_remove : Option<usize> = None;
for (i, file) in self.dropped_files.iter().enumerate() {
let mut info = if let Some(path) = &file.path {
path.display().to_string()
} else if !file.name.is_empty() {
file.name.clone()
} else {
"???".to_owned()
};
if let Some(bytes) = &file.bytes {
info += &format!(" ({} bytes)", bytes.len());
}
ui.horizontal_top(|ui|{
if ui.button("x").clicked(){
index_to_remove = Some(i);
}
ui.add_space(10.0);
ui.label(info);
});
}
if let Some(index) = index_to_remove{
self.dropped_files.remove(index);
}
});
if ui.button("clear list").clicked() {
self.dropped_files.clear();
}
});
} else {
ui.vertical_centered_justified(|ui|{
ui.add_space(50.0);
ui.label(
RichText::new("Drop files here")
.heading()
.color(MY_ACCENT_COLOR32),
);
});
}
});
}
}
fn file_path(file: &DroppedFile) -> String {
let id;
#[cfg(not(target_arch = "wasm32"))]
{
let path = file.path.as_ref().unwrap().clone();
id = path.to_str().unwrap().to_owned();
}
#[cfg(target_arch = "wasm32")]
{
id = file.name.clone();
}
id.replace(".png", "")
}
fn dynamic_image_from_file(file: &DroppedFile) -> DynamicImage {
#[cfg(target_arch = "wasm32")]
{
let bytes = file.bytes.as_ref().clone();
ImageImporter::import_from_memory(&bytes.unwrap())
.expect("Unable to import file. Run this example with --features=\"png\"")
}
#[cfg(not(target_arch = "wasm32"))]
{
let path = file.path.as_ref().unwrap().clone();
ImageImporter::import_from_file(&path)
.expect("Unable to import file. Run this example with --features=\"png\"")
}
}
fn powered_by_egui_and_eframe(ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.hyperlink_to(format!("Build: {}", GIT_HASH), env!("CARGO_PKG_HOMEPAGE"));
egui::warn_if_debug_build(ui);
ui.separator();
egui::widgets::global_theme_preference_switch(ui);
ui.separator();
ui.spacing_mut().item_spacing.x = 0.0;
ui.label("Made by ");
ui.hyperlink_to("Mev Lyshkin", "https://www.mevlyshkin.com/");
ui.label(". ");
ui.label("Powered by ");
ui.hyperlink_to("egui", "https://github.com/emilk/egui");
ui.label(" and ");
ui.hyperlink_to(
"eframe",
"https://github.com/emilk/egui/tree/master/crates/eframe",
);
ui.label(".");
});
}