From b9810bf8730f04e2dba94c6790653b088fe23bdf Mon Sep 17 00:00:00 2001 From: grimsace Date: Wed, 22 Apr 2026 13:46:19 -0500 Subject: [PATCH] added export for images --- Cargo.lock | 87 +++++++++++++++++++++++----- Cargo.toml | 3 +- src/app.rs | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 233 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e872457..8c25ef8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,7 @@ dependencies = [ "image", "rand", "rayon", + "rfd", ] [[package]] @@ -461,6 +462,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" version = "1.6.2" @@ -755,6 +765,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1536,10 +1548,23 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -1939,7 +1964,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -1955,6 +1980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -1968,7 +1994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -1980,7 +2006,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1992,7 +2018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2027,7 +2053,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2039,7 +2065,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2058,7 +2084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -2092,7 +2118,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -2105,7 +2131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2117,7 +2143,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2140,7 +2166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -2160,7 +2186,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2172,7 +2198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.11.1", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2339,6 +2365,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2558,6 +2590,33 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3953,7 +4012,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.11.1", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", diff --git a/Cargo.toml b/Cargo.toml index 844f2e2..94ed4e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,10 @@ edition = "2024" arboard = "3.6" eframe = "0.33" egui = "0.33" -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } rand = "0.9" rayon = "1.10" +rfd = "0.17.2" [profile.release] opt-level = "z" diff --git a/src/app.rs b/src/app.rs index 08d9eff..4d07d1f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -43,6 +43,8 @@ pub struct TerrainApp { generation_in_progress: bool, copy_receiver: Option>>, copy_in_progress: bool, + export_receiver: Option>>, + export_in_progress: bool, status_message: String, } @@ -59,6 +61,8 @@ impl TerrainApp { generation_in_progress: false, copy_receiver: None, copy_in_progress: false, + export_receiver: None, + export_in_progress: false, status_message: String::from("Adjust parameters and click Generate."), } } @@ -143,6 +147,94 @@ impl TerrainApp { } } + fn poll_export(&mut self, ctx: &Context) { + let Some(receiver) = &self.export_receiver else { + return; + }; + + match receiver.try_recv() { + Ok(Ok(message)) => { + self.status_message = message; + self.export_receiver = None; + self.export_in_progress = false; + } + Ok(Err(error)) => { + self.status_message = error; + self.export_receiver = None; + self.export_in_progress = false; + } + Err(TryRecvError::Empty) => { + ctx.request_repaint(); + } + Err(TryRecvError::Disconnected) => { + self.status_message = String::from("Export worker disconnected."); + self.export_receiver = None; + self.export_in_progress = false; + } + } + } + + fn start_export(&mut self, preview: PreviewFocus) { + if self.export_in_progress { + self.status_message = String::from("Export already in progress..."); + return; + } + + let Some(generated) = &self.generated else { + self.status_message = String::from("No generated image is available to export."); + return; + }; + + let label = preview_label(preview).to_lowercase().replace(' ', "_"); + let default_name = format!("{}.png", label); + + let files = rfd::FileDialog::new() + .add_filter("PNG Image", &["png"]) + .add_filter("JPEG Image", &["jpg", "jpeg"]) + .add_filter("WebP Image", &["webp"]) + .set_file_name(default_name) + .save_file(); + + let Some(path) = files else { + return; + }; + + let generated = generated.clone(); + let (tx, rx) = mpsc::channel(); + self.export_receiver = Some(rx); + self.export_in_progress = true; + self.status_message = format!("Exporting to {}...", path.display()); + + thread::spawn(move || { + let result = (|| -> Result { + let format = match path.extension().and_then(|s| s.to_str()) { + Some("jpg") | Some("jpeg") => image::ImageFormat::Jpeg, + Some("webp") => image::ImageFormat::WebP, + _ => image::ImageFormat::Png, + }; + + match preview { + PreviewFocus::TerrainComposite => generated + .terrain_composite_image + .save_with_format(&path, format), + PreviewFocus::TerrainColor => generated + .terrain_color_image + .save_with_format(&path, format), + PreviewFocus::Heightmap => { + generated.heightmap_image.save_with_format(&path, format) + } + PreviewFocus::Bumpmap => { + generated.bumpmap_image.save_with_format(&path, format) + } + } + .map_err(|e| format!("Failed to save image: {}", e))?; + + Ok(format!("Successfully exported to {}.", path.display())) + })(); + let _ = tx.send(result); + }); + } + fn install_textures(&mut self, ctx: &Context, images: &GeneratedImages) { let heightmap = gray_to_color_image(&images.heightmap_image); let bumpmap = gray_to_color_image(&images.bumpmap_image); @@ -204,15 +296,20 @@ impl TerrainApp { }); } - fn preview_context_menu(response: &Response) -> bool { + fn preview_context_menu(response: &Response) -> (bool, bool) { let mut should_copy = false; + let mut should_export = false; response.context_menu(|ui| { if ui.button("Copy").clicked() { should_copy = true; ui.close(); } + if ui.button("Export").clicked() { + should_export = true; + ui.close(); + } }); - should_copy + (should_copy, should_export) } fn overlay_copy_button(ui: &mut Ui, anchor_rect: egui::Rect, preview: PreviewFocus) -> bool { @@ -252,6 +349,43 @@ impl TerrainApp { clicked } + fn overlay_export_button(ui: &mut Ui, anchor_rect: egui::Rect, preview: PreviewFocus) -> bool { + let button_size = Vec2::new(28.0, 28.0); + let button_rect = Align2::RIGHT_TOP + .align_size_within_rect(button_size, anchor_rect.shrink2(Vec2::new(8.0 + 32.0, 8.0))); + let response = ui.interact( + button_rect, + ui.make_persistent_id(("main_export_button", preview == PreviewFocus::Bumpmap)), + Sense::click(), + ); + + let fill = if response.hovered() { + Color32::from_rgba_premultiplied(70, 90, 120, 220) + } else { + Color32::from_rgba_premultiplied(40, 40, 40, 180) + }; + ui.painter() + .rect_filled(button_rect, CornerRadius::same(6), fill); + ui.painter().rect_stroke( + button_rect, + CornerRadius::same(6), + Stroke::new(1.0, Color32::from_rgba_premultiplied(255, 255, 255, 60)), + StrokeKind::Outside, + ); + ui.painter().text( + button_rect.center(), + Align2::CENTER_CENTER, + "📥", + FontId::proportional(14.0), + Color32::WHITE, + ); + + let clicked = response.clicked(); + response.on_hover_text("Export"); + let _ = preview; + clicked + } + fn tab_button(ui: &mut Ui, current: &mut LeftTab, tab: LeftTab, label: &str) { let selected = *current == tab; if ui.selectable_label(selected, label).clicked() { @@ -302,6 +436,10 @@ impl TerrainApp { ui.add_space(4.0); ui.spinner(); } + if self.export_in_progress { + ui.add_space(4.0); + ui.spinner(); + } ui.add_space(8.0); ui.label(&self.status_message); @@ -386,6 +524,7 @@ impl TerrainApp { fn ui_right_panel(&mut self, ctx: &Context) { let mut pending_copy = None; + let mut pending_export = None; let mut pending_focus = None; let mut pending_hover = None; let preview_focus = self.preview_focus; @@ -418,11 +557,15 @@ impl TerrainApp { if main_response.hovered() { pending_hover = Some(preview_focus); } - if Self::preview_context_menu(&main_response) - || Self::overlay_copy_button(ui, main_response.rect, preview_focus) - { + + let (context_copy, context_export) = Self::preview_context_menu(&main_response); + if context_copy || Self::overlay_copy_button(ui, main_response.rect, preview_focus) { pending_copy = Some(preview_focus); } + if context_export || Self::overlay_export_button(ui, main_response.rect, preview_focus) + { + pending_export = Some(preview_focus); + } ui.add_space(8.0); let thumb_width = (available.x * 0.26).clamp(120.0, 220.0); @@ -455,9 +598,14 @@ impl TerrainApp { if secondary_response.hovered() { pending_hover = Some(secondary_focus); } - if Self::preview_context_menu(&secondary_response) { + let (context_copy, context_export) = + Self::preview_context_menu(&secondary_response); + if context_copy { pending_copy = Some(secondary_focus); } + if context_export { + pending_export = Some(secondary_focus); + } if secondary_response.clicked() { pending_focus = Some(secondary_focus); } @@ -471,6 +619,9 @@ impl TerrainApp { if let Some(preview) = pending_copy { self.copy_preview(preview); } + if let Some(preview) = pending_export { + self.start_export(preview); + } if let Some(preview) = pending_focus { self.preview_focus = preview; } @@ -483,6 +634,7 @@ impl eframe::App for TerrainApp { self.hovered_preview = None; self.poll_generation(ctx); self.poll_copy(ctx); + self.poll_export(ctx); self.ui_left_panel(ctx); self.ui_right_panel(ctx);