From 73366144931b8efceffe0d2d56a261fb21595419 Mon Sep 17 00:00:00 2001 From: grimsace Date: Fri, 6 Mar 2026 14:42:08 -0600 Subject: [PATCH] added initial mask export --- .gitignore | 2 + src/exporter.rs | 254 ++++++++++++++++++++++++++++++++++++++---------- src/main.rs | 127 ++++++++++++++---------- src/ui.rs | 63 +++++++++++- 4 files changed, 340 insertions(+), 106 deletions(-) diff --git a/.gitignore b/.gitignore index 07f4193..44cb507 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ Cargo.lock # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +#TODO file for my own personal use +TODO # Added by cargo diff --git a/src/exporter.rs b/src/exporter.rs index d3ec929..247cab4 100644 --- a/src/exporter.rs +++ b/src/exporter.rs @@ -7,7 +7,7 @@ use rfd::FileDialog; use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Rect, Stroke, StrokeDash, Transform}; use crate::layout::{Door, DungeonLayout, Room}; -use crate::ui::{ExportFormat, UiSettings}; +use crate::ui::{ExportFormat, MaskFormat, UiSettings}; const BG: (u8, u8, u8, u8) = (24, 24, 26, 255); const GRID: (u8, u8, u8, u8) = (130, 130, 130, 255); @@ -17,6 +17,7 @@ const BLACK: (u8, u8, u8, u8) = (0, 0, 0, 255); const OPEN_DOOR: (u8, u8, u8, u8) = (220, 70, 70, 255); const LOCKED_DOOR: (u8, u8, u8, u8) = (80, 200, 120, 255); const ARCHWAY: (u8, u8, u8, u8) = (70, 130, 220, 255); +const WHITE: (u8, u8, u8, u8) = (255, 255, 255, 255); #[derive(Debug, Clone, Copy)] enum DoorStyle { @@ -77,10 +78,54 @@ impl ExportGeometry { } } +#[derive(Default)] +struct SceneData { + corridor_cells: HashSet<(usize, usize)>, + corridor_edges: HashSet<((usize, usize), (usize, usize))>, + room_cells: HashSet<(usize, usize)>, + door_edges: HashSet<((usize, usize), (usize, usize))>, +} + +fn collect_scene_data(layout: &DungeonLayout) -> SceneData { + let mut scene = SceneData::default(); + + for door in &layout.doors { + scene.door_edges.insert(norm_edge(door.from, door.to)); + } + + for corridor in &layout.corridors { + for &cell in &corridor.path { + scene.corridor_cells.insert(cell); + } + for pair in corridor.path.windows(2) { + scene.corridor_edges.insert(norm_edge(pair[0], pair[1])); + } + } + + for room in &layout.rooms { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + scene.room_cells.insert((x, y)); + } + } + } + + scene +} + pub fn export_with_dialog( layout: &DungeonLayout, settings: &UiSettings, ) -> Result { + if settings.export_format == ExportFormat::Folder { + let folder = FileDialog::new() + .set_title("Export composite masks folder") + .pick_folder() + .ok_or_else(|| "Export canceled".to_string())?; + export_composite_masks(layout, settings, &folder)?; + return Ok(folder); + } + let ext = settings.export_format.extension(); let default_name = format!("dungeon_export.{ext}"); @@ -110,11 +155,12 @@ pub fn export_with_dialog( ExportFormat::Png => ImageFormat::Png, ExportFormat::Jpeg => ImageFormat::Jpeg, ExportFormat::Webp => ImageFormat::WebP, - ExportFormat::Svg => unreachable!(), + ExportFormat::Svg | ExportFormat::Folder => unreachable!(), }; img.save_with_format(&path, format) .map_err(|e| format!("Failed writing image: {e}"))?; } + ExportFormat::Folder => unreachable!(), } Ok(path) @@ -122,6 +168,7 @@ pub fn export_with_dialog( fn render_pixmap(layout: &DungeonLayout, settings: &UiSettings) -> Result { let g = ExportGeometry::new(settings.cols, settings.rows); + let scene = collect_scene_data(layout); let mut pixmap = Pixmap::new(g.width, g.height).ok_or_else(|| "Failed to allocate canvas".to_string())?; fill_bg(&mut pixmap, BG); @@ -131,33 +178,7 @@ fn render_pixmap(layout: &DungeonLayout, settings: &UiSettings) -> Result Result Result Result<(), String> { + std::fs::create_dir_all(folder).map_err(|e| format!("Failed creating folder: {e}"))?; + + let g = ExportGeometry::new(settings.cols, settings.rows); + let scene = collect_scene_data(layout); + let wall_w = (g.cell / 5.0).max(1.0); + let door_w = (g.cell / 10.0).max(1.0); + + let mut floors = Pixmap::new(g.width, g.height) + .ok_or_else(|| "Failed to allocate floors mask canvas".to_string())?; + fill_bg(&mut floors, BG); + for &(x, y) in &scene.corridor_cells { + fill_rect_cell(&mut floors, &g, x, y, WHITE); + } + for room in &layout.rooms { + fill_rect_room(&mut floors, &g, room, WHITE); + } + + let mut walls = Pixmap::new(g.width, g.height) + .ok_or_else(|| "Failed to allocate walls mask canvas".to_string())?; + fill_bg(&mut walls, BG); + draw_cell_walls( + &mut walls, + &g, + &scene.corridor_cells, + Some(&scene.corridor_edges), + &scene.door_edges, + wall_w, + WHITE, + ); + draw_cell_walls( + &mut walls, + &g, + &scene.room_cells, + None, + &scene.door_edges, + wall_w, + WHITE, + ); + + let mut doors = Pixmap::new(g.width, g.height) + .ok_or_else(|| "Failed to allocate doors mask canvas".to_string())?; + fill_bg(&mut doors, BG); + for door in &layout.doors { + draw_door(&mut doors, &g, door, door_w, WHITE, DoorStyle::Solid); + } + + let mut grid = Pixmap::new(g.width, g.height) + .ok_or_else(|| "Failed to allocate grid mask canvas".to_string())?; + fill_bg(&mut grid, BG); + draw_grid_color(&mut grid, &g, WHITE); + + save_mask_image(folder, "floors_mask", &floors, settings)?; + save_mask_image(folder, "walls_mask", &walls, settings)?; + save_mask_image(folder, "doors_mask", &doors, settings)?; + save_mask_image(folder, "grid_mask", &grid, settings)?; + + Ok(()) +} + +fn save_mask_image( + folder: &std::path::Path, + stem: &str, + pixmap: &Pixmap, + settings: &UiSettings, +) -> Result<(), String> { + let ext = settings.mask_format.extension(); + let path = folder.join(format!("{stem}.{ext}")); + let img = raster_image_from_pixmap(pixmap, settings)?; + img.save_with_format(&path, mask_image_format(settings.mask_format)) + .map_err(|e| format!("Failed writing {}: {e}", path.display()))?; + Ok(()) +} + +fn mask_image_format(format: MaskFormat) -> ImageFormat { + match format { + MaskFormat::Png => ImageFormat::Png, + MaskFormat::Jpeg => ImageFormat::Jpeg, + MaskFormat::Webp => ImageFormat::WebP, + } +} + fn raster_image_from_pixmap( pixmap: &Pixmap, settings: &UiSettings, @@ -428,6 +535,10 @@ fn fill_bg(pixmap: &mut Pixmap, color: (u8, u8, u8, u8)) { } fn draw_grid(pixmap: &mut Pixmap, g: &ExportGeometry) { + draw_grid_color(pixmap, g, GRID); +} + +fn draw_grid_color(pixmap: &mut Pixmap, g: &ExportGeometry, color: (u8, u8, u8, u8)) { for c in 0..=g.cols { let x = g.left() + c as f32 * g.cell; draw_line( @@ -437,7 +548,7 @@ fn draw_grid(pixmap: &mut Pixmap, g: &ExportGeometry) { x, g.top() + g.rows as f32 * g.cell, 1.0, - GRID, + color, DoorStyle::Solid, ); } @@ -450,7 +561,7 @@ fn draw_grid(pixmap: &mut Pixmap, g: &ExportGeometry) { g.left() + g.cols as f32 * g.cell, y, 1.0, - GRID, + color, DoorStyle::Solid, ); } @@ -559,7 +670,7 @@ fn draw_cell_walls( let right_connected = connected_edges .map(|set| set.contains(&norm_edge((col, row), right))) - .unwrap_or(false); + .unwrap_or(true); let should_right = (!cells.contains(&right) || !right_connected) && !door_edges.contains(&norm_edge((col, row), right)); @@ -569,12 +680,12 @@ fn draw_cell_walls( let bottom_connected = connected_edges .map(|set| set.contains(&norm_edge((col, row), bottom))) - .unwrap_or(false); + .unwrap_or(true); let should_bottom = (!cells.contains(&bottom) || !bottom_connected) && !door_edges.contains(&norm_edge((col, row), bottom)); if should_left { - draw_line( + draw_wall_segment( pixmap, rect.left(), rect.top(), @@ -582,11 +693,10 @@ fn draw_cell_walls( rect.bottom(), width, color, - DoorStyle::Solid, ); } if should_right { - draw_line( + draw_wall_segment( pixmap, rect.right(), rect.top(), @@ -594,11 +704,10 @@ fn draw_cell_walls( rect.bottom(), width, color, - DoorStyle::Solid, ); } if should_top { - draw_line( + draw_wall_segment( pixmap, rect.left(), rect.top(), @@ -606,11 +715,10 @@ fn draw_cell_walls( rect.top(), width, color, - DoorStyle::Solid, ); } if should_bottom { - draw_line( + draw_wall_segment( pixmap, rect.left(), rect.bottom(), @@ -618,13 +726,43 @@ fn draw_cell_walls( rect.bottom(), width, color, - DoorStyle::Solid, ); } } } } +fn draw_wall_segment( + pixmap: &mut Pixmap, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + width: f32, + color: (u8, u8, u8, u8), +) { + let half = width * 0.5; + let rect = if (x1 - x2).abs() <= f32::EPSILON { + let x = x1.min(x2); + let y0 = y1.min(y2); + let y1 = y1.max(y2); + Rect::from_xywh(x - half, y0 - half, width, (y1 - y0) + width) + } else { + let y = y1.min(y2); + let x0 = x1.min(x2); + let x1 = x1.max(x2); + Rect::from_xywh(x0 - half, y - half, (x1 - x0) + width, width) + }; + + let Some(rect) = rect else { + return; + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + fn append_svg_walls( out: &mut String, g: &ExportGeometry, @@ -648,7 +786,7 @@ fn append_svg_walls( let right_connected = connected_edges .map(|set| set.contains(&norm_edge((col, row), right_cell))) - .unwrap_or(false); + .unwrap_or(true); let right_edge = (!cells.contains(&right_cell) || !right_connected) && !door_edges.contains(&norm_edge((col, row), right_cell)); @@ -658,32 +796,44 @@ fn append_svg_walls( let bottom_connected = connected_edges .map(|set| set.contains(&norm_edge((col, row), bottom_cell))) - .unwrap_or(false); + .unwrap_or(true); let bottom_edge = (!cells.contains(&bottom_cell) || !bottom_connected) && !door_edges.contains(&norm_edge((col, row), bottom_cell)); if left_edge { let _ = writeln!( out, - "" + "", + left - (width * 0.5), + top - (width * 0.5), + (bottom - top) + width ); } if right_edge { let _ = writeln!( out, - "" + "", + right - (width * 0.5), + top - (width * 0.5), + (bottom - top) + width ); } if top_edge { let _ = writeln!( out, - "" + "", + left - (width * 0.5), + top - (width * 0.5), + (right - left) + width ); } if bottom_edge { let _ = writeln!( out, - "" + "", + left - (width * 0.5), + bottom - (width * 0.5), + (right - left) + width ); } } diff --git a/src/main.rs b/src/main.rs index 95f7a32..853a3bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -393,7 +393,7 @@ fn draw_layout( let room_fill = Color32::from_rgb(70, 120, 160); let corridor_fill = Color32::from_rgb(210, 190, 120); - let outline_stroke = Stroke::new(wall_width_px, Color32::BLACK); + let wall_color = Color32::BLACK; let mut corridor_cells_set = HashSet::new(); let mut corridor_edges_set = HashSet::new(); let mut room_cells_set = HashSet::new(); @@ -436,24 +436,24 @@ fn draw_layout( .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) .unwrap_or(true)) { - painter.line_segment( - [ - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.left(), rect.bottom()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.left(), rect.bottom()), + wall_width_px, + wall_color, ); } if (!corridor_cells_set.contains(&right) || !corridor_edges_set.contains(&normalized_edge((col, row), right))) && !door_edges_set.contains(&normalized_edge((col, row), right)) { - painter.line_segment( - [ - egui::pos2(rect.right(), rect.top()), - egui::pos2(rect.right(), rect.bottom()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.right(), rect.top()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, ); } if row == 0 @@ -462,24 +462,24 @@ fn draw_layout( .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) .unwrap_or(true)) { - painter.line_segment( - [ - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.right(), rect.top()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.right(), rect.top()), + wall_width_px, + wall_color, ); } if (!corridor_cells_set.contains(&bottom) || !corridor_edges_set.contains(&normalized_edge((col, row), bottom))) && !door_edges_set.contains(&normalized_edge((col, row), bottom)) { - painter.line_segment( - [ - egui::pos2(rect.left(), rect.bottom()), - egui::pos2(rect.right(), rect.bottom()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.bottom()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, ); } } @@ -516,23 +516,23 @@ fn draw_layout( .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) .unwrap_or(true)) { - painter.line_segment( - [ - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.left(), rect.bottom()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.left(), rect.bottom()), + wall_width_px, + wall_color, ); } if !room_cells_set.contains(&right) && !door_edges_set.contains(&normalized_edge((col, row), right)) { - painter.line_segment( - [ - egui::pos2(rect.right(), rect.top()), - egui::pos2(rect.right(), rect.bottom()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.right(), rect.top()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, ); } if row == 0 @@ -541,23 +541,23 @@ fn draw_layout( .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) .unwrap_or(true)) { - painter.line_segment( - [ - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.right(), rect.top()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.right(), rect.top()), + wall_width_px, + wall_color, ); } if !room_cells_set.contains(&bottom) && !door_edges_set.contains(&normalized_edge((col, row), bottom)) { - painter.line_segment( - [ - egui::pos2(rect.left(), rect.bottom()), - egui::pos2(rect.right(), rect.bottom()), - ], - outline_stroke, + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.bottom()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, ); } } @@ -603,6 +603,35 @@ fn cell_rect(geometry: &GridGeometry, col: usize, row: usize) -> egui::Rect { ) } +fn draw_wall_segment( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + width: f32, + color: Color32, +) { + let half = width * 0.5; + if (from.x - to.x).abs() <= f32::EPSILON { + let x = from.x.min(to.x); + let y0 = from.y.min(to.y); + let y1 = from.y.max(to.y); + let rect = egui::Rect::from_min_max( + egui::pos2(x - half, y0 - half), + egui::pos2(x + half, y1 + half), + ); + painter.rect_filled(rect, 0.0, color); + } else { + let y = from.y.min(to.y); + let x0 = from.x.min(to.x); + let x1 = from.x.max(to.x); + let rect = egui::Rect::from_min_max( + egui::pos2(x0 - half, y - half), + egui::pos2(x1 + half, y + half), + ); + painter.rect_filled(rect, 0.0, color); + } +} + fn normalized_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { if a <= b { (a, b) } else { (b, a) } } diff --git a/src/ui.rs b/src/ui.rs index 5b22b7a..8f9b82e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -21,6 +21,7 @@ pub enum ExportFormat { Jpeg, Webp, Svg, + Folder, } impl Default for ExportFormat { @@ -36,6 +37,7 @@ impl ExportFormat { ExportFormat::Jpeg => "jpeg", ExportFormat::Webp => "webp", ExportFormat::Svg => "svg", + ExportFormat::Folder => "folder", } } @@ -45,14 +47,46 @@ impl ExportFormat { ExportFormat::Jpeg => ".jpeg", ExportFormat::Webp => ".webp", ExportFormat::Svg => ".svg", + ExportFormat::Folder => "Folder (Composite)", } } - pub fn is_raster(self) -> bool { + pub fn supports_resolution(self) -> bool { !matches!(self, ExportFormat::Svg) } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MaskFormat { + Png, + Jpeg, + Webp, +} + +impl Default for MaskFormat { + fn default() -> Self { + Self::Png + } +} + +impl MaskFormat { + pub fn extension(self) -> &'static str { + match self { + MaskFormat::Png => "png", + MaskFormat::Jpeg => "jpeg", + MaskFormat::Webp => "webp", + } + } + + pub fn label(self) -> &'static str { + match self { + MaskFormat::Png => ".png", + MaskFormat::Jpeg => ".jpeg", + MaskFormat::Webp => ".webp", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct UiSettings { @@ -71,6 +105,7 @@ pub struct UiSettings { pub allow_middle_corridor_doors: bool, pub colorblind_mode: bool, pub export_format: ExportFormat, + pub mask_format: MaskFormat, pub export_width: u32, pub export_height: u32, pub allow_export_aspect_change: bool, @@ -95,6 +130,7 @@ impl Default for UiSettings { allow_middle_corridor_doors: false, colorblind_mode: false, export_format: ExportFormat::Png, + mask_format: MaskFormat::Png, export_width: 1920, export_height: 1080, allow_export_aspect_change: false, @@ -211,10 +247,29 @@ fn draw_generate_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut ui.selectable_value(&mut settings.export_format, ExportFormat::Jpeg, ".jpeg"); ui.selectable_value(&mut settings.export_format, ExportFormat::Webp, ".webp"); ui.selectable_value(&mut settings.export_format, ExportFormat::Svg, ".svg"); + ui.selectable_value( + &mut settings.export_format, + ExportFormat::Folder, + "Folder (Composite)", + ); }); }); - if settings.export_format.is_raster() { + if settings.export_format == ExportFormat::Folder { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label("Mask Format"); + egui::ComboBox::from_id_salt("mask_format") + .selected_text(settings.mask_format.label()) + .show_ui(ui, |ui| { + ui.selectable_value(&mut settings.mask_format, MaskFormat::Png, ".png"); + ui.selectable_value(&mut settings.mask_format, MaskFormat::Jpeg, ".jpeg"); + ui.selectable_value(&mut settings.mask_format, MaskFormat::Webp, ".webp"); + }); + }); + } + + if settings.export_format.supports_resolution() { ui.add_space(8.0); ui.checkbox( &mut settings.allow_export_aspect_change, @@ -243,9 +298,7 @@ fn draw_generate_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut ui.label("Resolution Height"); ui.horizontal(|ui| { height_changed |= ui - .add( - egui::Slider::new(&mut settings.export_height, 1..=10_000).show_value(false), - ) + .add(egui::Slider::new(&mut settings.export_height, 1..=10_000).show_value(false)) .changed(); height_changed |= ui .add(