2026-03-06 11:42:20 -06:00
|
|
|
use std::collections::HashSet;
|
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
2026-03-06 14:06:40 -06:00
|
|
|
use image::{DynamicImage, ImageFormat, RgbaImage, imageops::FilterType};
|
2026-03-06 11:42:20 -06:00
|
|
|
use rfd::FileDialog;
|
|
|
|
|
use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Rect, Stroke, StrokeDash, Transform};
|
|
|
|
|
|
2026-04-14 10:31:26 -05:00
|
|
|
use crate::layout::{AreaMarker, Door, DungeonLayout, Room, Window, WindowSide, corridor_cells};
|
2026-03-06 14:42:08 -06:00
|
|
|
use crate::ui::{ExportFormat, MaskFormat, UiSettings};
|
2026-03-06 11:42:20 -06:00
|
|
|
|
|
|
|
|
const BG: (u8, u8, u8, u8) = (24, 24, 26, 255);
|
|
|
|
|
const GRID: (u8, u8, u8, u8) = (130, 130, 130, 255);
|
|
|
|
|
const ROOM: (u8, u8, u8, u8) = (70, 120, 160, 255);
|
|
|
|
|
const CORRIDOR: (u8, u8, u8, u8) = (210, 190, 120, 255);
|
|
|
|
|
const BLACK: (u8, u8, u8, u8) = (0, 0, 0, 255);
|
2026-03-12 09:11:48 -05:00
|
|
|
const OPEN_DOOR: (u8, u8, u8, u8) = (80, 200, 120, 255);
|
|
|
|
|
const LOCKED_DOOR: (u8, u8, u8, u8) = (220, 70, 70, 255);
|
2026-03-23 12:43:33 -05:00
|
|
|
const SECRET_DOOR: (u8, u8, u8, u8) = (170, 80, 170, 255);
|
2026-03-23 12:22:03 -05:00
|
|
|
const ARCHWAY: (u8, u8, u8, u8) = (230, 140, 60, 255);
|
|
|
|
|
const WINDOW: (u8, u8, u8, u8) = (70, 130, 220, 255);
|
2026-03-06 14:42:08 -06:00
|
|
|
const WHITE: (u8, u8, u8, u8) = (255, 255, 255, 255);
|
2026-04-14 10:31:26 -05:00
|
|
|
const START_MARKER: (u8, u8, u8, u8) = (60, 220, 200, 255);
|
|
|
|
|
const END_MARKER: (u8, u8, u8, u8) = (240, 90, 90, 255);
|
2026-03-06 11:42:20 -06:00
|
|
|
|
2026-03-12 08:59:28 -05:00
|
|
|
pub enum ExportTarget {
|
|
|
|
|
File(PathBuf),
|
|
|
|
|
Folder(PathBuf),
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:42:20 -06:00
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
enum DoorStyle {
|
|
|
|
|
Solid,
|
|
|
|
|
LongDash,
|
|
|
|
|
ShortDash,
|
|
|
|
|
Dotted,
|
2026-03-23 12:22:03 -05:00
|
|
|
DashDot,
|
2026-03-23 12:43:33 -05:00
|
|
|
DashDotDot,
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
struct ExportGeometry {
|
|
|
|
|
cols: usize,
|
|
|
|
|
rows: usize,
|
|
|
|
|
cell: f32,
|
|
|
|
|
pad: f32,
|
|
|
|
|
width: u32,
|
|
|
|
|
height: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ExportGeometry {
|
2026-03-19 13:49:42 -05:00
|
|
|
// Build export geometry with a fixed cell size and padding.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn new(cols: usize, rows: usize) -> Self {
|
|
|
|
|
let cell = 64.0;
|
|
|
|
|
let pad = 40.0;
|
|
|
|
|
let width = (pad * 2.0 + cols as f32 * cell).round() as u32;
|
|
|
|
|
let height = (pad * 2.0 + rows as f32 * cell).round() as u32;
|
|
|
|
|
Self {
|
|
|
|
|
cols,
|
|
|
|
|
rows,
|
|
|
|
|
cell,
|
|
|
|
|
pad,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Return the left padding offset.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn left(&self) -> f32 {
|
|
|
|
|
self.pad
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Return the top padding offset.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn top(&self) -> f32 {
|
|
|
|
|
self.pad
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Compute the rectangle for a cell in export space.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn cell_rect(&self, col: usize, row: usize) -> Option<Rect> {
|
|
|
|
|
Rect::from_xywh(
|
|
|
|
|
self.left() + col as f32 * self.cell,
|
|
|
|
|
self.top() + row as f32 * self.cell,
|
|
|
|
|
self.cell,
|
|
|
|
|
self.cell,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Compute the center point of a cell in export space.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn cell_center(&self, col: usize, row: usize) -> (f32, f32) {
|
|
|
|
|
(
|
|
|
|
|
self.left() + (col as f32 + 0.5) * self.cell,
|
|
|
|
|
self.top() + (row as f32 + 0.5) * self.cell,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 14:42:08 -06:00
|
|
|
#[derive(Default)]
|
|
|
|
|
struct SceneData {
|
|
|
|
|
corridor_cells: HashSet<(usize, usize)>,
|
|
|
|
|
corridor_edges: HashSet<((usize, usize), (usize, usize))>,
|
|
|
|
|
room_cells: HashSet<(usize, usize)>,
|
2026-03-12 08:25:37 -05:00
|
|
|
room_edges: HashSet<((usize, usize), (usize, usize))>,
|
2026-03-06 14:42:08 -06:00
|
|
|
door_edges: HashSet<((usize, usize), (usize, usize))>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Collect precomputed cell and edge sets needed for rendering.
|
2026-03-12 08:25:37 -05:00
|
|
|
fn collect_scene_data(layout: &DungeonLayout, settings: &UiSettings) -> SceneData {
|
2026-03-06 14:42:08 -06:00
|
|
|
let mut scene = SceneData::default();
|
|
|
|
|
|
|
|
|
|
for door in &layout.doors {
|
2026-03-12 08:25:37 -05:00
|
|
|
for edge in door_edges_for(door, settings.cols, settings.rows) {
|
|
|
|
|
scene.door_edges.insert(edge);
|
|
|
|
|
}
|
2026-03-06 14:42:08 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 08:25:37 -05:00
|
|
|
scene.corridor_cells = corridor_cells(layout, settings.cols, settings.rows);
|
|
|
|
|
scene.corridor_edges = corridor_edges_from_cells(&scene.corridor_cells);
|
2026-03-06 14:42:08 -06:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-12 08:25:37 -05:00
|
|
|
scene.room_edges = room_edges_from_rooms(&layout.rooms);
|
2026-03-06 14:42:08 -06:00
|
|
|
|
|
|
|
|
scene
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Open a dialog to select an export destination.
|
2026-03-12 08:59:28 -05:00
|
|
|
pub fn select_export_target(settings: &UiSettings) -> Result<ExportTarget, String> {
|
2026-03-06 14:42:08 -06:00
|
|
|
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())?;
|
2026-03-12 08:59:28 -05:00
|
|
|
return Ok(ExportTarget::Folder(folder));
|
2026-03-06 14:42:08 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:42:20 -06:00
|
|
|
let ext = settings.export_format.extension();
|
|
|
|
|
let default_name = format!("dungeon_export.{ext}");
|
|
|
|
|
|
|
|
|
|
let mut dialog = FileDialog::new();
|
|
|
|
|
dialog = dialog
|
|
|
|
|
.set_title("Export image")
|
|
|
|
|
.set_file_name(&default_name)
|
|
|
|
|
.add_filter(settings.export_format.label(), &[ext]);
|
|
|
|
|
|
|
|
|
|
let mut path = dialog
|
|
|
|
|
.save_file()
|
|
|
|
|
.ok_or_else(|| "Export canceled".to_string())?;
|
|
|
|
|
|
|
|
|
|
if path.extension().is_none() {
|
|
|
|
|
path.set_extension(ext);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 08:59:28 -05:00
|
|
|
Ok(ExportTarget::File(path))
|
|
|
|
|
}
|
2026-03-06 11:42:20 -06:00
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Export the current layout to the chosen file or folder target.
|
2026-03-12 08:59:28 -05:00
|
|
|
pub fn export_to_target(
|
|
|
|
|
layout: &DungeonLayout,
|
|
|
|
|
settings: &UiSettings,
|
|
|
|
|
target: ExportTarget,
|
|
|
|
|
) -> Result<PathBuf, String> {
|
|
|
|
|
match target {
|
|
|
|
|
ExportTarget::Folder(folder) => {
|
|
|
|
|
export_composite_masks(layout, settings, &folder)?;
|
|
|
|
|
Ok(folder)
|
|
|
|
|
}
|
|
|
|
|
ExportTarget::File(path) => {
|
|
|
|
|
match settings.export_format {
|
|
|
|
|
ExportFormat::Svg => {
|
|
|
|
|
let svg = build_svg(layout, settings);
|
|
|
|
|
std::fs::write(&path, svg).map_err(|e| format!("Failed writing SVG: {e}"))?;
|
|
|
|
|
}
|
|
|
|
|
ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Webp => {
|
|
|
|
|
let pixmap = render_pixmap(layout, settings)?;
|
|
|
|
|
let img = raster_image_from_pixmap(&pixmap, settings)?;
|
|
|
|
|
let format = match settings.export_format {
|
|
|
|
|
ExportFormat::Png => ImageFormat::Png,
|
|
|
|
|
ExportFormat::Jpeg => ImageFormat::Jpeg,
|
|
|
|
|
ExportFormat::Webp => ImageFormat::WebP,
|
|
|
|
|
ExportFormat::Svg | ExportFormat::Folder => unreachable!(),
|
|
|
|
|
};
|
|
|
|
|
img.save_with_format(&path, format)
|
|
|
|
|
.map_err(|e| format!("Failed writing image: {e}"))?;
|
|
|
|
|
}
|
|
|
|
|
ExportFormat::Folder => unreachable!(),
|
|
|
|
|
}
|
|
|
|
|
Ok(path)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Render the dungeon layout to a pixmap.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn render_pixmap(layout: &DungeonLayout, settings: &UiSettings) -> Result<Pixmap, String> {
|
|
|
|
|
let g = ExportGeometry::new(settings.cols, settings.rows);
|
2026-03-12 08:25:37 -05:00
|
|
|
let scene = collect_scene_data(layout, settings);
|
2026-03-06 11:42:20 -06:00
|
|
|
let mut pixmap =
|
|
|
|
|
Pixmap::new(g.width, g.height).ok_or_else(|| "Failed to allocate canvas".to_string())?;
|
|
|
|
|
fill_bg(&mut pixmap, BG);
|
|
|
|
|
|
2026-03-12 08:50:32 -05:00
|
|
|
let wall_w = snap_even_width((g.cell / 5.0).max(1.0));
|
|
|
|
|
let door_w = (g.cell / 10.0).max(1.0).round();
|
2026-03-06 11:42:20 -06:00
|
|
|
|
2026-03-12 08:50:32 -05:00
|
|
|
if settings.export_show_grid {
|
|
|
|
|
draw_grid(&mut pixmap, &g);
|
|
|
|
|
}
|
2026-03-06 11:42:20 -06:00
|
|
|
|
2026-03-06 14:42:08 -06:00
|
|
|
for &(x, y) in &scene.corridor_cells {
|
2026-03-06 11:42:20 -06:00
|
|
|
fill_rect_cell(&mut pixmap, &g, x, y, CORRIDOR);
|
|
|
|
|
if settings.colorblind_mode {
|
|
|
|
|
let (cx, cy) = g.cell_center(x, y);
|
|
|
|
|
draw_dot(&mut pixmap, cx, cy, g.cell * 0.14, BLACK);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for room in &layout.rooms {
|
|
|
|
|
fill_rect_room(&mut pixmap, &g, room, ROOM);
|
|
|
|
|
if settings.colorblind_mode {
|
|
|
|
|
draw_room_crosshatch(&mut pixmap, &g, room, 1.5, BLACK);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
draw_cell_walls(
|
|
|
|
|
&mut pixmap,
|
|
|
|
|
&g,
|
2026-03-06 14:42:08 -06:00
|
|
|
&scene.corridor_cells,
|
|
|
|
|
Some(&scene.corridor_edges),
|
|
|
|
|
&scene.door_edges,
|
2026-03-06 11:42:20 -06:00
|
|
|
wall_w,
|
|
|
|
|
BLACK,
|
|
|
|
|
);
|
|
|
|
|
draw_cell_walls(
|
|
|
|
|
&mut pixmap,
|
|
|
|
|
&g,
|
2026-03-06 14:42:08 -06:00
|
|
|
&scene.room_cells,
|
2026-03-12 08:25:37 -05:00
|
|
|
Some(&scene.room_edges),
|
2026-03-06 14:42:08 -06:00
|
|
|
&scene.door_edges,
|
2026-03-06 11:42:20 -06:00
|
|
|
wall_w,
|
|
|
|
|
BLACK,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for door in &layout.doors {
|
|
|
|
|
let (color, style) = if settings.colorblind_mode {
|
2026-03-23 12:43:33 -05:00
|
|
|
let s = if door.secret {
|
|
|
|
|
DoorStyle::DashDotDot
|
|
|
|
|
} else if door.archway {
|
2026-03-06 11:42:20 -06:00
|
|
|
DoorStyle::Dotted
|
|
|
|
|
} else if door.locked {
|
|
|
|
|
DoorStyle::ShortDash
|
|
|
|
|
} else {
|
|
|
|
|
DoorStyle::LongDash
|
|
|
|
|
};
|
|
|
|
|
(BLACK, s)
|
2026-03-23 12:43:33 -05:00
|
|
|
} else if door.secret {
|
|
|
|
|
(SECRET_DOOR, DoorStyle::Solid)
|
2026-03-06 11:42:20 -06:00
|
|
|
} else if door.archway {
|
|
|
|
|
(ARCHWAY, DoorStyle::Solid)
|
|
|
|
|
} else if door.locked {
|
|
|
|
|
(LOCKED_DOOR, DoorStyle::Solid)
|
|
|
|
|
} else {
|
|
|
|
|
(OPEN_DOOR, DoorStyle::Solid)
|
|
|
|
|
};
|
|
|
|
|
draw_door(&mut pixmap, &g, door, door_w, color, style);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:22:03 -05:00
|
|
|
for window in &layout.windows {
|
|
|
|
|
let style = if settings.colorblind_mode {
|
|
|
|
|
DoorStyle::DashDot
|
|
|
|
|
} else {
|
|
|
|
|
DoorStyle::Solid
|
|
|
|
|
};
|
|
|
|
|
draw_window(&mut pixmap, &g, window, door_w, WINDOW, style);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 11:03:58 -05:00
|
|
|
for marker in &layout.start_markers {
|
2026-04-14 10:31:26 -05:00
|
|
|
draw_area_marker(&mut pixmap, &g, marker, "S", START_MARKER, settings.colorblind_mode);
|
|
|
|
|
}
|
2026-04-14 11:03:58 -05:00
|
|
|
for marker in &layout.end_markers {
|
2026-04-14 10:31:26 -05:00
|
|
|
draw_area_marker(&mut pixmap, &g, marker, "E", END_MARKER, settings.colorblind_mode);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:42:20 -06:00
|
|
|
Ok(pixmap)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Export composite image plus mask layers into a folder.
|
2026-03-06 14:42:08 -06:00
|
|
|
fn export_composite_masks(
|
|
|
|
|
layout: &DungeonLayout,
|
|
|
|
|
settings: &UiSettings,
|
|
|
|
|
folder: &std::path::Path,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
std::fs::create_dir_all(folder).map_err(|e| format!("Failed creating folder: {e}"))?;
|
|
|
|
|
|
|
|
|
|
let g = ExportGeometry::new(settings.cols, settings.rows);
|
2026-03-12 08:25:37 -05:00
|
|
|
let scene = collect_scene_data(layout, settings);
|
2026-03-06 14:42:08 -06:00
|
|
|
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())?;
|
2026-03-12 08:50:32 -05:00
|
|
|
fill_bg(&mut floors, BLACK);
|
2026-03-06 14:42:08 -06:00
|
|
|
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())?;
|
2026-03-12 08:50:32 -05:00
|
|
|
fill_bg(&mut walls, BLACK);
|
2026-03-06 14:42:08 -06:00
|
|
|
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())?;
|
2026-03-12 09:06:22 -05:00
|
|
|
let mut archways = Pixmap::new(g.width, g.height)
|
|
|
|
|
.ok_or_else(|| "Failed to allocate archways mask canvas".to_string())?;
|
|
|
|
|
let mut locked_doors = Pixmap::new(g.width, g.height)
|
|
|
|
|
.ok_or_else(|| "Failed to allocate locked doors mask canvas".to_string())?;
|
2026-03-23 12:43:33 -05:00
|
|
|
let mut secret_doors = Pixmap::new(g.width, g.height)
|
|
|
|
|
.ok_or_else(|| "Failed to allocate secret doors mask canvas".to_string())?;
|
2026-03-23 12:22:03 -05:00
|
|
|
let mut windows = Pixmap::new(g.width, g.height)
|
|
|
|
|
.ok_or_else(|| "Failed to allocate windows mask canvas".to_string())?;
|
2026-04-14 11:21:49 -05:00
|
|
|
let mut start_markers = Pixmap::new(g.width, g.height)
|
|
|
|
|
.ok_or_else(|| "Failed to allocate start markers mask canvas".to_string())?;
|
|
|
|
|
let mut end_markers = Pixmap::new(g.width, g.height)
|
|
|
|
|
.ok_or_else(|| "Failed to allocate end markers mask canvas".to_string())?;
|
2026-03-12 08:50:32 -05:00
|
|
|
fill_bg(&mut doors, BLACK);
|
2026-03-12 09:06:22 -05:00
|
|
|
fill_bg(&mut archways, BLACK);
|
|
|
|
|
fill_bg(&mut locked_doors, BLACK);
|
2026-03-23 12:43:33 -05:00
|
|
|
fill_bg(&mut secret_doors, BLACK);
|
2026-03-23 12:22:03 -05:00
|
|
|
fill_bg(&mut windows, BLACK);
|
2026-04-14 11:21:49 -05:00
|
|
|
fill_bg(&mut start_markers, BLACK);
|
|
|
|
|
fill_bg(&mut end_markers, BLACK);
|
2026-03-06 14:42:08 -06:00
|
|
|
for door in &layout.doors {
|
2026-03-23 12:43:33 -05:00
|
|
|
if door.secret {
|
|
|
|
|
draw_door(&mut secret_doors, &g, door, door_w, WHITE, DoorStyle::Solid);
|
|
|
|
|
} else if door.archway {
|
2026-03-12 09:06:22 -05:00
|
|
|
draw_door(&mut archways, &g, door, door_w, WHITE, DoorStyle::Solid);
|
|
|
|
|
} else if door.locked {
|
|
|
|
|
draw_door(&mut locked_doors, &g, door, door_w, WHITE, DoorStyle::Solid);
|
|
|
|
|
} else {
|
|
|
|
|
draw_door(&mut doors, &g, door, door_w, WHITE, DoorStyle::Solid);
|
|
|
|
|
}
|
2026-03-06 14:42:08 -06:00
|
|
|
}
|
2026-03-23 12:22:03 -05:00
|
|
|
for window in &layout.windows {
|
|
|
|
|
draw_window(&mut windows, &g, window, door_w, WHITE, DoorStyle::Solid);
|
|
|
|
|
}
|
2026-04-14 11:21:49 -05:00
|
|
|
for marker in &layout.start_markers {
|
|
|
|
|
fill_rect_marker(&mut start_markers, &g, marker, WHITE);
|
|
|
|
|
}
|
|
|
|
|
for marker in &layout.end_markers {
|
|
|
|
|
fill_rect_marker(&mut end_markers, &g, marker, WHITE);
|
|
|
|
|
}
|
2026-03-06 14:42:08 -06:00
|
|
|
|
2026-03-12 09:06:22 -05:00
|
|
|
let composite = render_pixmap(layout, settings)?;
|
|
|
|
|
let composite_img = raster_image_from_pixmap(&composite, settings)?;
|
|
|
|
|
let composite_ext = settings.export_format.extension();
|
|
|
|
|
let composite_path = folder.join(format!("composite.{composite_ext}"));
|
|
|
|
|
composite_img
|
|
|
|
|
.save_with_format(&composite_path, export_image_format(settings.export_format))
|
|
|
|
|
.map_err(|e| format!("Failed writing {}: {e}", composite_path.display()))?;
|
|
|
|
|
|
2026-03-06 14:42:08 -06:00
|
|
|
save_mask_image(folder, "floors_mask", &floors, settings)?;
|
|
|
|
|
save_mask_image(folder, "walls_mask", &walls, settings)?;
|
|
|
|
|
save_mask_image(folder, "doors_mask", &doors, settings)?;
|
2026-03-12 09:06:22 -05:00
|
|
|
save_mask_image(folder, "archways_mask", &archways, settings)?;
|
|
|
|
|
save_mask_image(folder, "locked_doors_mask", &locked_doors, settings)?;
|
2026-03-23 12:43:33 -05:00
|
|
|
save_mask_image(folder, "secret_doors_mask", &secret_doors, settings)?;
|
2026-03-23 12:22:03 -05:00
|
|
|
save_mask_image(folder, "windows_mask", &windows, settings)?;
|
2026-04-14 11:21:49 -05:00
|
|
|
save_mask_image(folder, "start_markers_mask", &start_markers, settings)?;
|
|
|
|
|
save_mask_image(folder, "end_markers_mask", &end_markers, settings)?;
|
2026-03-12 08:50:32 -05:00
|
|
|
if settings.export_show_grid {
|
|
|
|
|
let mut grid = Pixmap::new(g.width, g.height)
|
|
|
|
|
.ok_or_else(|| "Failed to allocate grid mask canvas".to_string())?;
|
|
|
|
|
fill_bg(&mut grid, BLACK);
|
|
|
|
|
draw_grid_color(&mut grid, &g, WHITE);
|
|
|
|
|
save_mask_image(folder, "grid_mask", &grid, settings)?;
|
|
|
|
|
}
|
2026-03-06 14:42:08 -06:00
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Write a single mask image to disk.
|
2026-03-06 14:42:08 -06:00
|
|
|
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}"));
|
2026-03-12 08:50:32 -05:00
|
|
|
let img = raster_mask_image_from_pixmap(pixmap, settings)?;
|
2026-03-06 14:42:08 -06:00
|
|
|
img.save_with_format(&path, mask_image_format(settings.mask_format))
|
|
|
|
|
.map_err(|e| format!("Failed writing {}: {e}", path.display()))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Map a mask format to an image encoding.
|
2026-03-06 14:42:08 -06:00
|
|
|
fn mask_image_format(format: MaskFormat) -> ImageFormat {
|
|
|
|
|
match format {
|
|
|
|
|
MaskFormat::Png => ImageFormat::Png,
|
|
|
|
|
MaskFormat::Jpeg => ImageFormat::Jpeg,
|
|
|
|
|
MaskFormat::Webp => ImageFormat::WebP,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Map an export format to an image encoding.
|
2026-03-12 09:06:22 -05:00
|
|
|
fn export_image_format(format: ExportFormat) -> ImageFormat {
|
|
|
|
|
match format {
|
|
|
|
|
ExportFormat::Png => ImageFormat::Png,
|
|
|
|
|
ExportFormat::Jpeg => ImageFormat::Jpeg,
|
|
|
|
|
ExportFormat::Webp => ImageFormat::WebP,
|
|
|
|
|
ExportFormat::Svg | ExportFormat::Folder => ImageFormat::Png,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Convert a pixmap into a raster image at the target size.
|
2026-03-06 14:06:40 -06:00
|
|
|
fn raster_image_from_pixmap(
|
|
|
|
|
pixmap: &Pixmap,
|
|
|
|
|
settings: &UiSettings,
|
|
|
|
|
) -> Result<DynamicImage, String> {
|
|
|
|
|
let rgba = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec())
|
|
|
|
|
.ok_or_else(|| "Failed to build image buffer".to_string())?;
|
|
|
|
|
let mut img = DynamicImage::ImageRgba8(rgba);
|
|
|
|
|
|
|
|
|
|
let target = raster_target_size(settings);
|
|
|
|
|
if img.width() != target.0 || img.height() != target.1 {
|
|
|
|
|
img = img.resize_exact(target.0, target.1, FilterType::Lanczos3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(img)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Convert a pixmap into a thresholded mask image.
|
2026-03-12 08:50:32 -05:00
|
|
|
fn raster_mask_image_from_pixmap(
|
|
|
|
|
pixmap: &Pixmap,
|
|
|
|
|
settings: &UiSettings,
|
|
|
|
|
) -> Result<DynamicImage, String> {
|
|
|
|
|
let rgba = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec())
|
|
|
|
|
.ok_or_else(|| "Failed to build image buffer".to_string())?;
|
|
|
|
|
let mut img = DynamicImage::ImageRgba8(rgba);
|
|
|
|
|
|
|
|
|
|
let target = raster_mask_target_size(settings, pixmap.width(), pixmap.height());
|
|
|
|
|
if img.width() != target.0 || img.height() != target.1 {
|
|
|
|
|
img = img.resize_exact(target.0, target.1, FilterType::Nearest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut out = img.to_rgba8();
|
|
|
|
|
for pixel in out.pixels_mut() {
|
|
|
|
|
let on = pixel[0] > 127 || pixel[1] > 127 || pixel[2] > 127;
|
|
|
|
|
let v = if on { 255 } else { 0 };
|
|
|
|
|
*pixel = image::Rgba([v, v, v, 255]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(DynamicImage::ImageRgba8(out))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Compute the mask target size based on export width scaling.
|
2026-03-12 08:50:32 -05:00
|
|
|
fn raster_mask_target_size(settings: &UiSettings, src_w: u32, src_h: u32) -> (u32, u32) {
|
|
|
|
|
let (target_w, _target_h) = raster_target_size(settings);
|
|
|
|
|
let scale = ((target_w as f32) / (src_w as f32)).round().max(1.0) as u32;
|
|
|
|
|
(src_w.saturating_mul(scale), src_h.saturating_mul(scale))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Compute the output raster size while honoring aspect settings.
|
2026-03-06 14:06:40 -06:00
|
|
|
fn raster_target_size(settings: &UiSettings) -> (u32, u32) {
|
2026-03-20 00:29:51 -05:00
|
|
|
let width = settings.export_width.clamp(1, 10_000);
|
2026-03-06 14:06:40 -06:00
|
|
|
let mut height = settings.export_height.clamp(1, 10_000);
|
|
|
|
|
|
|
|
|
|
if !settings.allow_export_aspect_change {
|
|
|
|
|
let cols = settings.cols.max(1) as f32;
|
|
|
|
|
let rows = settings.rows.max(1) as f32;
|
|
|
|
|
let ratio = cols / rows;
|
|
|
|
|
height = ((width as f32) / ratio).round() as u32;
|
|
|
|
|
height = height.clamp(1, 10_000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(width, height)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Build an SVG document for the current layout.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn build_svg(layout: &DungeonLayout, settings: &UiSettings) -> String {
|
|
|
|
|
let g = ExportGeometry::new(settings.cols, settings.rows);
|
|
|
|
|
let wall_w = (g.cell / 5.0).max(1.0);
|
|
|
|
|
let door_w = (g.cell / 10.0).max(1.0);
|
|
|
|
|
|
|
|
|
|
let mut s = String::new();
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<svg xmlns='http://www.w3.org/2000/svg' width='{}' height='{}' viewBox='0 0 {} {}'>",
|
|
|
|
|
g.width, g.height, g.width, g.height
|
|
|
|
|
);
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<rect width='100%' height='100%' fill='rgb({},{},{})'/>",
|
|
|
|
|
BG.0, BG.1, BG.2
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-12 08:50:32 -05:00
|
|
|
if settings.export_show_grid {
|
|
|
|
|
for c in 0..=g.cols {
|
|
|
|
|
let x = g.left() + c as f32 * g.cell;
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{x}' y1='{}' x2='{x}' y2='{}' stroke='rgb({},{},{})' stroke-width='1'/>",
|
|
|
|
|
g.top(),
|
|
|
|
|
g.top() + g.rows as f32 * g.cell,
|
|
|
|
|
GRID.0,
|
|
|
|
|
GRID.1,
|
|
|
|
|
GRID.2
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
for r in 0..=g.rows {
|
|
|
|
|
let y = g.top() + r as f32 * g.cell;
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{}' y1='{y}' x2='{}' y2='{y}' stroke='rgb({},{},{})' stroke-width='1'/>",
|
|
|
|
|
g.left(),
|
|
|
|
|
g.left() + g.cols as f32 * g.cell,
|
|
|
|
|
GRID.0,
|
|
|
|
|
GRID.1,
|
|
|
|
|
GRID.2
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-12 08:25:37 -05:00
|
|
|
let corridor_cells = corridor_cells(layout, settings.cols, settings.rows);
|
|
|
|
|
let corridor_edges = corridor_edges_from_cells(&corridor_cells);
|
2026-03-06 11:42:20 -06:00
|
|
|
let mut room_cells = HashSet::new();
|
2026-03-12 08:25:37 -05:00
|
|
|
let room_edges = room_edges_from_rooms(&layout.rooms);
|
2026-03-06 11:42:20 -06:00
|
|
|
let mut door_edges = HashSet::new();
|
|
|
|
|
|
|
|
|
|
for door in &layout.doors {
|
2026-03-12 08:25:37 -05:00
|
|
|
for edge in door_edges_for(door, settings.cols, settings.rows) {
|
|
|
|
|
door_edges.insert(edge);
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-12 08:25:37 -05:00
|
|
|
// corridor_cells/corridor_edges already populated with corridor widths
|
2026-03-06 11:42:20 -06:00
|
|
|
for room in &layout.rooms {
|
|
|
|
|
for x in room.x..(room.x + room.width) {
|
|
|
|
|
for y in room.y..(room.y + room.height) {
|
|
|
|
|
room_cells.insert((x, y));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for &(x, y) in &corridor_cells {
|
|
|
|
|
let px = g.left() + x as f32 * g.cell;
|
|
|
|
|
let py = g.top() + y as f32 * g.cell;
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<rect x='{px}' y='{py}' width='{}' height='{}' fill='rgb({},{},{})'/>",
|
|
|
|
|
g.cell, g.cell, CORRIDOR.0, CORRIDOR.1, CORRIDOR.2
|
|
|
|
|
);
|
|
|
|
|
if settings.colorblind_mode {
|
|
|
|
|
let (cx, cy) = g.cell_center(x, y);
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<circle cx='{cx}' cy='{cy}' r='{}' fill='black'/>",
|
|
|
|
|
g.cell * 0.14
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for room in &layout.rooms {
|
|
|
|
|
let px = g.left() + room.x as f32 * g.cell;
|
|
|
|
|
let py = g.top() + room.y as f32 * g.cell;
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<rect x='{px}' y='{py}' width='{}' height='{}' fill='rgb({},{},{})'/>",
|
|
|
|
|
room.width as f32 * g.cell,
|
|
|
|
|
room.height as f32 * g.cell,
|
|
|
|
|
ROOM.0,
|
|
|
|
|
ROOM.1,
|
|
|
|
|
ROOM.2
|
|
|
|
|
);
|
|
|
|
|
if settings.colorblind_mode {
|
|
|
|
|
for x in room.x..(room.x + room.width) {
|
|
|
|
|
for y in room.y..(room.y + room.height) {
|
|
|
|
|
let rx = g.left() + x as f32 * g.cell + 2.0;
|
|
|
|
|
let ry = g.top() + y as f32 * g.cell + 2.0;
|
|
|
|
|
let rr = g.cell - 4.0;
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{rx}' y1='{ry}' x2='{}' y2='{}' stroke='black' stroke-width='1.5'/>",
|
|
|
|
|
rx + rr,
|
|
|
|
|
ry + rr
|
|
|
|
|
);
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{}' y1='{ry}' x2='{rx}' y2='{}' stroke='black' stroke-width='1.5'/>",
|
|
|
|
|
rx + rr,
|
|
|
|
|
ry + rr
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
append_svg_walls(
|
|
|
|
|
&mut s,
|
|
|
|
|
&g,
|
|
|
|
|
&corridor_cells,
|
|
|
|
|
Some(&corridor_edges),
|
|
|
|
|
&door_edges,
|
|
|
|
|
wall_w,
|
|
|
|
|
);
|
2026-03-12 08:25:37 -05:00
|
|
|
append_svg_walls(
|
|
|
|
|
&mut s,
|
|
|
|
|
&g,
|
|
|
|
|
&room_cells,
|
|
|
|
|
Some(&room_edges),
|
|
|
|
|
&door_edges,
|
|
|
|
|
wall_w,
|
|
|
|
|
);
|
2026-03-06 11:42:20 -06:00
|
|
|
|
|
|
|
|
for door in &layout.doors {
|
|
|
|
|
let (color, dash) = if settings.colorblind_mode {
|
2026-03-23 12:43:33 -05:00
|
|
|
if door.secret {
|
|
|
|
|
("black", Some("7,3,1,3,1,4"))
|
|
|
|
|
} else if door.archway {
|
2026-03-06 11:42:20 -06:00
|
|
|
("black", Some("2,4"))
|
|
|
|
|
} else if door.locked {
|
|
|
|
|
("black", Some("6,4"))
|
|
|
|
|
} else {
|
|
|
|
|
("black", Some("14,7"))
|
|
|
|
|
}
|
2026-03-23 12:43:33 -05:00
|
|
|
} else if door.secret {
|
|
|
|
|
("rgb(170,80,170)", None)
|
2026-03-06 11:42:20 -06:00
|
|
|
} else if door.archway {
|
2026-03-23 12:43:33 -05:00
|
|
|
("rgb(230,140,60)", None)
|
2026-03-06 11:42:20 -06:00
|
|
|
} else if door.locked {
|
|
|
|
|
("rgb(220,70,70)", None)
|
2026-03-23 12:43:33 -05:00
|
|
|
} else {
|
|
|
|
|
("rgb(80,200,120)", None)
|
2026-03-06 11:42:20 -06:00
|
|
|
};
|
|
|
|
|
|
2026-03-12 11:36:36 -05:00
|
|
|
if let Some((x1, y1, x2, y2)) =
|
|
|
|
|
door_line_points(&g, door.from, door.to, door_render_width(door))
|
|
|
|
|
{
|
2026-03-06 11:42:20 -06:00
|
|
|
if let Some(pattern) = dash {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{x1}' y1='{y1}' x2='{x2}' y2='{y2}' stroke='{color}' stroke-width='{door_w}' stroke-dasharray='{pattern}'/>"
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{x1}' y1='{y1}' x2='{x2}' y2='{y2}' stroke='{color}' stroke-width='{door_w}'/>"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:22:03 -05:00
|
|
|
for window in &layout.windows {
|
|
|
|
|
let dash = settings.colorblind_mode.then_some("7,3,1,4");
|
|
|
|
|
if let Some((x1, y1, x2, y2)) = window_line_points(&g, window) {
|
|
|
|
|
if let Some(pattern) = dash {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{x1}' y1='{y1}' x2='{x2}' y2='{y2}' stroke='rgb({},{},{})' stroke-width='{door_w}' stroke-dasharray='{pattern}'/>",
|
|
|
|
|
WINDOW.0, WINDOW.1, WINDOW.2
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
s,
|
|
|
|
|
"<line x1='{x1}' y1='{y1}' x2='{x2}' y2='{y2}' stroke='rgb({},{},{})' stroke-width='{door_w}'/>",
|
|
|
|
|
WINDOW.0, WINDOW.1, WINDOW.2
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 11:03:58 -05:00
|
|
|
for marker in &layout.start_markers {
|
2026-04-14 10:31:26 -05:00
|
|
|
append_svg_area_marker(&mut s, &g, marker, "S", START_MARKER, settings.colorblind_mode);
|
|
|
|
|
}
|
2026-04-14 11:03:58 -05:00
|
|
|
for marker in &layout.end_markers {
|
2026-04-14 10:31:26 -05:00
|
|
|
append_svg_area_marker(&mut s, &g, marker, "E", END_MARKER, settings.colorblind_mode);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 11:42:20 -06:00
|
|
|
s.push_str("</svg>\n");
|
|
|
|
|
s
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Fill the entire pixmap with a solid color.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn fill_bg(pixmap: &mut Pixmap, color: (u8, u8, u8, u8)) {
|
|
|
|
|
let mut paint = Paint::default();
|
|
|
|
|
paint.set_color_rgba8(color.0, color.1, color.2, color.3);
|
|
|
|
|
let Some(rect) = Rect::from_xywh(0.0, 0.0, pixmap.width() as f32, pixmap.height() as f32)
|
|
|
|
|
else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw the default grid lines.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn draw_grid(pixmap: &mut Pixmap, g: &ExportGeometry) {
|
2026-03-06 14:42:08 -06:00
|
|
|
draw_grid_color(pixmap, g, GRID);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw grid lines using a specific color.
|
2026-03-06 14:42:08 -06:00
|
|
|
fn draw_grid_color(pixmap: &mut Pixmap, g: &ExportGeometry, color: (u8, u8, u8, u8)) {
|
2026-03-12 08:50:32 -05:00
|
|
|
let left = g.left();
|
|
|
|
|
let top = g.top();
|
|
|
|
|
let width = g.cols as f32 * g.cell;
|
|
|
|
|
let height = g.rows as f32 * g.cell;
|
|
|
|
|
|
|
|
|
|
let mut paint = Paint::default();
|
|
|
|
|
paint.set_color_rgba8(color.0, color.1, color.2, color.3);
|
|
|
|
|
|
2026-03-06 11:42:20 -06:00
|
|
|
for c in 0..=g.cols {
|
2026-03-12 08:50:32 -05:00
|
|
|
let x = (left + c as f32 * g.cell).round();
|
|
|
|
|
let Some(rect) = Rect::from_xywh(x, top, 1.0, height) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
for r in 0..=g.rows {
|
2026-03-12 08:50:32 -05:00
|
|
|
let y = (top + r as f32 * g.cell).round();
|
|
|
|
|
let Some(rect) = Rect::from_xywh(left, y, width, 1.0) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
pixmap.fill_rect(rect, &paint, Transform::identity(), None);
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Fill a single cell rectangle with a color.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn fill_rect_cell(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
col: usize,
|
|
|
|
|
row: usize,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
) {
|
|
|
|
|
let Some(rect) = g.cell_rect(col, row) 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Fill an entire room rectangle with a color.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn fill_rect_room(pixmap: &mut Pixmap, g: &ExportGeometry, room: &Room, color: (u8, u8, u8, u8)) {
|
|
|
|
|
let Some(rect) = Rect::from_xywh(
|
|
|
|
|
g.left() + room.x as f32 * g.cell,
|
|
|
|
|
g.top() + room.y as f32 * g.cell,
|
|
|
|
|
room.width as f32 * g.cell,
|
|
|
|
|
room.height as f32 * g.cell,
|
|
|
|
|
) 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:31:26 -05:00
|
|
|
fn fill_rect_marker(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
marker: &AreaMarker,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
) {
|
|
|
|
|
let Some(rect) = Rect::from_xywh(
|
|
|
|
|
g.left() + marker.cell.0 as f32 * g.cell,
|
|
|
|
|
g.top() + marker.cell.1 as f32 * g.cell,
|
|
|
|
|
marker.size as f32 * g.cell,
|
|
|
|
|
marker.size as f32 * g.cell,
|
|
|
|
|
) 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 draw_area_marker(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
marker: &AreaMarker,
|
|
|
|
|
label: &str,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
colorblind_mode: bool,
|
|
|
|
|
) {
|
|
|
|
|
let overlay = (color.0, color.1, color.2, if colorblind_mode { 56 } else { 90 });
|
|
|
|
|
fill_rect_marker(pixmap, g, marker, overlay);
|
|
|
|
|
|
|
|
|
|
let x = g.left() + marker.cell.0 as f32 * g.cell;
|
|
|
|
|
let y = g.top() + marker.cell.1 as f32 * g.cell;
|
|
|
|
|
let w = marker.size as f32 * g.cell;
|
|
|
|
|
let h = marker.size as f32 * g.cell;
|
|
|
|
|
draw_wall_segment(pixmap, x, y, x + w, y, 3.0, color);
|
|
|
|
|
draw_wall_segment(pixmap, x, y + h, x + w, y + h, 3.0, color);
|
|
|
|
|
draw_wall_segment(pixmap, x, y, x, y + h, 3.0, color);
|
|
|
|
|
draw_wall_segment(pixmap, x + w, y, x + w, y + h, 3.0, color);
|
|
|
|
|
|
|
|
|
|
let symbol = if colorblind_mode { BLACK } else { WHITE };
|
|
|
|
|
if label == "S" {
|
|
|
|
|
draw_line(
|
|
|
|
|
pixmap,
|
|
|
|
|
x + w * 0.25,
|
|
|
|
|
y + h * 0.75,
|
|
|
|
|
x + w * 0.5,
|
|
|
|
|
y + h * 0.25,
|
|
|
|
|
4.0,
|
|
|
|
|
symbol,
|
|
|
|
|
DoorStyle::Solid,
|
|
|
|
|
);
|
|
|
|
|
draw_line(
|
|
|
|
|
pixmap,
|
|
|
|
|
x + w * 0.5,
|
|
|
|
|
y + h * 0.25,
|
|
|
|
|
x + w * 0.75,
|
|
|
|
|
y + h * 0.75,
|
|
|
|
|
4.0,
|
|
|
|
|
symbol,
|
|
|
|
|
DoorStyle::Solid,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
draw_line(
|
|
|
|
|
pixmap,
|
|
|
|
|
x + w * 0.25,
|
|
|
|
|
y + h * 0.25,
|
|
|
|
|
x + w * 0.75,
|
|
|
|
|
y + h * 0.75,
|
|
|
|
|
4.0,
|
|
|
|
|
symbol,
|
|
|
|
|
DoorStyle::Solid,
|
|
|
|
|
);
|
|
|
|
|
draw_line(
|
|
|
|
|
pixmap,
|
|
|
|
|
x + w * 0.75,
|
|
|
|
|
y + h * 0.25,
|
|
|
|
|
x + w * 0.25,
|
|
|
|
|
y + h * 0.75,
|
|
|
|
|
4.0,
|
|
|
|
|
symbol,
|
|
|
|
|
DoorStyle::Solid,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw a filled circle for dot patterns.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn draw_dot(pixmap: &mut Pixmap, cx: f32, cy: f32, radius: f32, color: (u8, u8, u8, u8)) {
|
|
|
|
|
let mut pb = PathBuilder::new();
|
|
|
|
|
pb.push_circle(cx, cy, radius);
|
|
|
|
|
let Some(path) = pb.finish() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let mut paint = Paint::default();
|
|
|
|
|
paint.set_color_rgba8(color.0, color.1, color.2, color.3);
|
|
|
|
|
pixmap.fill_path(
|
|
|
|
|
&path,
|
|
|
|
|
&paint,
|
|
|
|
|
FillRule::Winding,
|
|
|
|
|
Transform::identity(),
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw a crosshatch overlay for a room.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn draw_room_crosshatch(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
room: &Room,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
) {
|
|
|
|
|
for x in room.x..(room.x + room.width) {
|
|
|
|
|
for y in room.y..(room.y + room.height) {
|
|
|
|
|
if let Some(rect) = g.cell_rect(x, y) {
|
|
|
|
|
let pad = 2.0;
|
|
|
|
|
draw_line(
|
|
|
|
|
pixmap,
|
|
|
|
|
rect.left() + pad,
|
|
|
|
|
rect.top() + pad,
|
|
|
|
|
rect.right() - pad,
|
|
|
|
|
rect.bottom() - pad,
|
|
|
|
|
width,
|
|
|
|
|
color,
|
|
|
|
|
DoorStyle::Solid,
|
|
|
|
|
);
|
|
|
|
|
draw_line(
|
|
|
|
|
pixmap,
|
|
|
|
|
rect.right() - pad,
|
|
|
|
|
rect.top() + pad,
|
|
|
|
|
rect.left() + pad,
|
|
|
|
|
rect.bottom() - pad,
|
|
|
|
|
width,
|
|
|
|
|
color,
|
|
|
|
|
DoorStyle::Solid,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw wall segments around occupied cells.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn draw_cell_walls(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
cells: &HashSet<(usize, usize)>,
|
|
|
|
|
connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>,
|
|
|
|
|
door_edges: &HashSet<((usize, usize), (usize, usize))>,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
) {
|
|
|
|
|
for &(col, row) in cells {
|
|
|
|
|
if let Some(rect) = g.cell_rect(col, row) {
|
|
|
|
|
let right = (col + 1, row);
|
|
|
|
|
let bottom = (col, row + 1);
|
|
|
|
|
|
|
|
|
|
let should_left = col == 0
|
|
|
|
|
|| (!cells.contains(&(col - 1, row))
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), (col - 1, row))));
|
|
|
|
|
|
|
|
|
|
let right_connected = connected_edges
|
|
|
|
|
.map(|set| set.contains(&norm_edge((col, row), right)))
|
2026-03-06 14:42:08 -06:00
|
|
|
.unwrap_or(true);
|
2026-03-06 11:42:20 -06:00
|
|
|
let should_right = (!cells.contains(&right) || !right_connected)
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), right));
|
|
|
|
|
|
|
|
|
|
let should_top = row == 0
|
|
|
|
|
|| (!cells.contains(&(col, row - 1))
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), (col, row - 1))));
|
|
|
|
|
|
|
|
|
|
let bottom_connected = connected_edges
|
|
|
|
|
.map(|set| set.contains(&norm_edge((col, row), bottom)))
|
2026-03-06 14:42:08 -06:00
|
|
|
.unwrap_or(true);
|
2026-03-06 11:42:20 -06:00
|
|
|
let should_bottom = (!cells.contains(&bottom) || !bottom_connected)
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), bottom));
|
|
|
|
|
|
|
|
|
|
if should_left {
|
2026-03-06 14:42:08 -06:00
|
|
|
draw_wall_segment(
|
2026-03-06 11:42:20 -06:00
|
|
|
pixmap,
|
|
|
|
|
rect.left(),
|
|
|
|
|
rect.top(),
|
|
|
|
|
rect.left(),
|
|
|
|
|
rect.bottom(),
|
|
|
|
|
width,
|
|
|
|
|
color,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if should_right {
|
2026-03-06 14:42:08 -06:00
|
|
|
draw_wall_segment(
|
2026-03-06 11:42:20 -06:00
|
|
|
pixmap,
|
|
|
|
|
rect.right(),
|
|
|
|
|
rect.top(),
|
|
|
|
|
rect.right(),
|
|
|
|
|
rect.bottom(),
|
|
|
|
|
width,
|
|
|
|
|
color,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if should_top {
|
2026-03-06 14:42:08 -06:00
|
|
|
draw_wall_segment(
|
2026-03-06 11:42:20 -06:00
|
|
|
pixmap,
|
|
|
|
|
rect.left(),
|
|
|
|
|
rect.top(),
|
|
|
|
|
rect.right(),
|
|
|
|
|
rect.top(),
|
|
|
|
|
width,
|
|
|
|
|
color,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if should_bottom {
|
2026-03-06 14:42:08 -06:00
|
|
|
draw_wall_segment(
|
2026-03-06 11:42:20 -06:00
|
|
|
pixmap,
|
|
|
|
|
rect.left(),
|
|
|
|
|
rect.bottom(),
|
|
|
|
|
rect.right(),
|
|
|
|
|
rect.bottom(),
|
|
|
|
|
width,
|
|
|
|
|
color,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw a single wall segment as a filled rectangle.
|
2026-03-06 14:42:08 -06:00
|
|
|
fn draw_wall_segment(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
x1: f32,
|
|
|
|
|
y1: f32,
|
|
|
|
|
x2: f32,
|
|
|
|
|
y2: f32,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
) {
|
2026-03-12 08:50:32 -05:00
|
|
|
let width = width.round().max(1.0);
|
2026-03-06 14:42:08 -06:00
|
|
|
let half = width * 0.5;
|
2026-03-12 08:50:32 -05:00
|
|
|
let x1 = x1.round();
|
|
|
|
|
let y1 = y1.round();
|
|
|
|
|
let x2 = x2.round();
|
|
|
|
|
let y2 = y2.round();
|
2026-03-06 14:42:08 -06:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Round a width to the nearest even pixel size.
|
2026-03-12 08:50:32 -05:00
|
|
|
fn snap_even_width(value: f32) -> f32 {
|
|
|
|
|
let mut w = value.round().max(1.0);
|
|
|
|
|
if (w as u32) % 2 == 1 {
|
|
|
|
|
w += 1.0;
|
|
|
|
|
}
|
|
|
|
|
w
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Append SVG wall rectangles for the given cells.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn append_svg_walls(
|
|
|
|
|
out: &mut String,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
cells: &HashSet<(usize, usize)>,
|
|
|
|
|
connected_edges: Option<&HashSet<((usize, usize), (usize, usize))>>,
|
|
|
|
|
door_edges: &HashSet<((usize, usize), (usize, usize))>,
|
|
|
|
|
width: f32,
|
|
|
|
|
) {
|
|
|
|
|
for &(col, row) in cells {
|
|
|
|
|
let left = g.left() + col as f32 * g.cell;
|
|
|
|
|
let top = g.top() + row as f32 * g.cell;
|
|
|
|
|
let right = left + g.cell;
|
|
|
|
|
let bottom = top + g.cell;
|
|
|
|
|
|
|
|
|
|
let right_cell = (col + 1, row);
|
|
|
|
|
let bottom_cell = (col, row + 1);
|
|
|
|
|
|
|
|
|
|
let left_edge = col == 0
|
|
|
|
|
|| (!cells.contains(&(col - 1, row))
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), (col - 1, row))));
|
|
|
|
|
|
|
|
|
|
let right_connected = connected_edges
|
|
|
|
|
.map(|set| set.contains(&norm_edge((col, row), right_cell)))
|
2026-03-06 14:42:08 -06:00
|
|
|
.unwrap_or(true);
|
2026-03-06 11:42:20 -06:00
|
|
|
let right_edge = (!cells.contains(&right_cell) || !right_connected)
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), right_cell));
|
|
|
|
|
|
|
|
|
|
let top_edge = row == 0
|
|
|
|
|
|| (!cells.contains(&(col, row - 1))
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), (col, row - 1))));
|
|
|
|
|
|
|
|
|
|
let bottom_connected = connected_edges
|
|
|
|
|
.map(|set| set.contains(&norm_edge((col, row), bottom_cell)))
|
2026-03-06 14:42:08 -06:00
|
|
|
.unwrap_or(true);
|
2026-03-06 11:42:20 -06:00
|
|
|
let bottom_edge = (!cells.contains(&bottom_cell) || !bottom_connected)
|
|
|
|
|
&& !door_edges.contains(&norm_edge((col, row), bottom_cell));
|
|
|
|
|
|
|
|
|
|
if left_edge {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
2026-03-06 14:42:08 -06:00
|
|
|
"<rect x='{}' y='{}' width='{width}' height='{}' fill='black'/>",
|
|
|
|
|
left - (width * 0.5),
|
|
|
|
|
top - (width * 0.5),
|
|
|
|
|
(bottom - top) + width
|
2026-03-06 11:42:20 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if right_edge {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
2026-03-06 14:42:08 -06:00
|
|
|
"<rect x='{}' y='{}' width='{width}' height='{}' fill='black'/>",
|
|
|
|
|
right - (width * 0.5),
|
|
|
|
|
top - (width * 0.5),
|
|
|
|
|
(bottom - top) + width
|
2026-03-06 11:42:20 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if top_edge {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
2026-03-06 14:42:08 -06:00
|
|
|
"<rect x='{}' y='{}' width='{}' height='{width}' fill='black'/>",
|
|
|
|
|
left - (width * 0.5),
|
|
|
|
|
top - (width * 0.5),
|
|
|
|
|
(right - left) + width
|
2026-03-06 11:42:20 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if bottom_edge {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
2026-03-06 14:42:08 -06:00
|
|
|
"<rect x='{}' y='{}' width='{}' height='{width}' fill='black'/>",
|
|
|
|
|
left - (width * 0.5),
|
|
|
|
|
bottom - (width * 0.5),
|
|
|
|
|
(right - left) + width
|
2026-03-06 11:42:20 -06:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-14 10:31:26 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn append_svg_area_marker(
|
|
|
|
|
out: &mut String,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
marker: &AreaMarker,
|
|
|
|
|
label: &str,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
colorblind_mode: bool,
|
|
|
|
|
) {
|
|
|
|
|
let x = g.left() + marker.cell.0 as f32 * g.cell;
|
|
|
|
|
let y = g.top() + marker.cell.1 as f32 * g.cell;
|
|
|
|
|
let w = marker.size as f32 * g.cell;
|
|
|
|
|
let h = marker.size as f32 * g.cell;
|
|
|
|
|
let alpha = if colorblind_mode { 0.22 } else { 0.35 };
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
|
|
|
|
"<rect x='{x}' y='{y}' width='{w}' height='{h}' fill='rgb({},{},{})' fill-opacity='{alpha}' stroke='rgb({},{},{})' stroke-width='3'/>",
|
|
|
|
|
color.0, color.1, color.2, color.0, color.1, color.2
|
|
|
|
|
);
|
|
|
|
|
let symbol = if colorblind_mode { "black" } else { "white" };
|
|
|
|
|
if label == "S" {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
|
|
|
|
"<polyline points='{},{} {},{} {},{}' fill='none' stroke='{symbol}' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/>",
|
|
|
|
|
x + w * 0.25,
|
|
|
|
|
y + h * 0.75,
|
|
|
|
|
x + w * 0.5,
|
|
|
|
|
y + h * 0.25,
|
|
|
|
|
x + w * 0.75,
|
|
|
|
|
y + h * 0.75
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
|
|
|
|
"<line x1='{}' y1='{}' x2='{}' y2='{}' stroke='{symbol}' stroke-width='4' stroke-linecap='round'/>",
|
|
|
|
|
x + w * 0.25,
|
|
|
|
|
y + h * 0.25,
|
|
|
|
|
x + w * 0.75,
|
|
|
|
|
y + h * 0.75
|
|
|
|
|
);
|
|
|
|
|
let _ = writeln!(
|
|
|
|
|
out,
|
|
|
|
|
"<line x1='{}' y1='{}' x2='{}' y2='{}' stroke='{symbol}' stroke-width='4' stroke-linecap='round'/>",
|
|
|
|
|
x + w * 0.75,
|
|
|
|
|
y + h * 0.25,
|
|
|
|
|
x + w * 0.25,
|
|
|
|
|
y + h * 0.75
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw a door line onto the pixmap.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn draw_door(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
door: &Door,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
style: DoorStyle,
|
|
|
|
|
) {
|
2026-03-12 11:36:36 -05:00
|
|
|
let Some((x1, y1, x2, y2)) = door_line_points(g, door.from, door.to, door_render_width(door))
|
|
|
|
|
else {
|
2026-03-06 11:42:20 -06:00
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
draw_line(pixmap, x1, y1, x2, y2, width, color, style);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:22:03 -05:00
|
|
|
fn draw_window(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
window: &Window,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
style: DoorStyle,
|
|
|
|
|
) {
|
|
|
|
|
let Some((x1, y1, x2, y2)) = window_line_points(g, window) else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
draw_line(pixmap, x1, y1, x2, y2, width, color, style);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Compute the door line endpoints in export space.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn door_line_points(
|
|
|
|
|
g: &ExportGeometry,
|
|
|
|
|
a: (usize, usize),
|
|
|
|
|
b: (usize, usize),
|
2026-03-12 08:25:37 -05:00
|
|
|
width_cells: usize,
|
2026-03-06 11:42:20 -06:00
|
|
|
) -> Option<(f32, f32, f32, f32)> {
|
|
|
|
|
if a.0.abs_diff(b.0) + a.1.abs_diff(b.1) != 1 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 08:25:37 -05:00
|
|
|
let width_cells = width_cells.max(1) as isize;
|
|
|
|
|
let min_offset = -((width_cells - 1) / 2);
|
|
|
|
|
let max_offset = width_cells / 2;
|
|
|
|
|
|
2026-03-06 11:42:20 -06:00
|
|
|
if a.0 != b.0 {
|
|
|
|
|
let x = g.left() + (a.0.max(b.0) as f32) * g.cell;
|
2026-03-12 08:25:37 -05:00
|
|
|
let row = a.1 as isize;
|
|
|
|
|
let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize);
|
|
|
|
|
let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize);
|
|
|
|
|
let y0 = g.top() + y0_cell as f32 * g.cell;
|
|
|
|
|
let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell;
|
2026-03-06 11:42:20 -06:00
|
|
|
Some((x, y0, x, y1))
|
|
|
|
|
} else {
|
|
|
|
|
let y = g.top() + (a.1.max(b.1) as f32) * g.cell;
|
2026-03-12 08:25:37 -05:00
|
|
|
let col = a.0 as isize;
|
|
|
|
|
let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize);
|
|
|
|
|
let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize);
|
|
|
|
|
let x0 = g.left() + x0_cell as f32 * g.cell;
|
|
|
|
|
let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell;
|
2026-03-06 11:42:20 -06:00
|
|
|
Some((x0, y, x1, y))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 12:22:03 -05:00
|
|
|
fn window_line_points(g: &ExportGeometry, window: &Window) -> Option<(f32, f32, f32, f32)> {
|
|
|
|
|
let width_cells = window_render_width(window).max(1) as isize;
|
|
|
|
|
let min_offset = -((width_cells - 1) / 2);
|
|
|
|
|
let max_offset = width_cells / 2;
|
|
|
|
|
|
|
|
|
|
match window.side {
|
|
|
|
|
WindowSide::Left => {
|
|
|
|
|
let x = g.left() + window.cell.0 as f32 * g.cell;
|
|
|
|
|
let row = window.cell.1 as isize;
|
|
|
|
|
let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize);
|
|
|
|
|
let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize);
|
|
|
|
|
let y0 = g.top() + y0_cell as f32 * g.cell;
|
|
|
|
|
let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell;
|
|
|
|
|
Some((x, y0, x, y1))
|
|
|
|
|
}
|
|
|
|
|
WindowSide::Right => {
|
|
|
|
|
let x = g.left() + (window.cell.0 as f32 + 1.0) * g.cell;
|
|
|
|
|
let row = window.cell.1 as isize;
|
|
|
|
|
let y0_cell = (row + min_offset).clamp(0, g.rows.saturating_sub(1) as isize);
|
|
|
|
|
let y1_cell = (row + max_offset).clamp(0, g.rows.saturating_sub(1) as isize);
|
|
|
|
|
let y0 = g.top() + y0_cell as f32 * g.cell;
|
|
|
|
|
let y1 = g.top() + (y1_cell as f32 + 1.0) * g.cell;
|
|
|
|
|
Some((x, y0, x, y1))
|
|
|
|
|
}
|
|
|
|
|
WindowSide::Top => {
|
|
|
|
|
let y = g.top() + window.cell.1 as f32 * g.cell;
|
|
|
|
|
let col = window.cell.0 as isize;
|
|
|
|
|
let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize);
|
|
|
|
|
let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize);
|
|
|
|
|
let x0 = g.left() + x0_cell as f32 * g.cell;
|
|
|
|
|
let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell;
|
|
|
|
|
Some((x0, y, x1, y))
|
|
|
|
|
}
|
|
|
|
|
WindowSide::Bottom => {
|
|
|
|
|
let y = g.top() + (window.cell.1 as f32 + 1.0) * g.cell;
|
|
|
|
|
let col = window.cell.0 as isize;
|
|
|
|
|
let x0_cell = (col + min_offset).clamp(0, g.cols.saturating_sub(1) as isize);
|
|
|
|
|
let x1_cell = (col + max_offset).clamp(0, g.cols.saturating_sub(1) as isize);
|
|
|
|
|
let x0 = g.left() + x0_cell as f32 * g.cell;
|
|
|
|
|
let x1 = g.left() + (x1_cell as f32 + 1.0) * g.cell;
|
|
|
|
|
Some((x0, y, x1, y))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw a styled line in the pixmap.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn draw_line(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
x1: f32,
|
|
|
|
|
y1: f32,
|
|
|
|
|
x2: f32,
|
|
|
|
|
y2: f32,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
style: DoorStyle,
|
|
|
|
|
) {
|
|
|
|
|
match style {
|
|
|
|
|
DoorStyle::Solid => stroke_path(pixmap, x1, y1, x2, y2, width, color, None),
|
|
|
|
|
DoorStyle::LongDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((14.0, 7.0))),
|
|
|
|
|
DoorStyle::ShortDash => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((6.0, 4.0))),
|
|
|
|
|
DoorStyle::Dotted => dotted_line(pixmap, x1, y1, x2, y2, width, color),
|
2026-03-23 12:22:03 -05:00
|
|
|
DoorStyle::DashDot => stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((7.0, 3.0))),
|
2026-03-23 12:43:33 -05:00
|
|
|
DoorStyle::DashDotDot => {
|
|
|
|
|
stroke_path(pixmap, x1, y1, x2, y2, width, color, Some((7.0, 3.0)))
|
|
|
|
|
}
|
2026-03-06 11:42:20 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Stroke a path with optional dash patterns.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn stroke_path(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
x1: f32,
|
|
|
|
|
y1: f32,
|
|
|
|
|
x2: f32,
|
|
|
|
|
y2: f32,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
dash: Option<(f32, f32)>,
|
|
|
|
|
) {
|
|
|
|
|
let mut pb = PathBuilder::new();
|
|
|
|
|
pb.move_to(x1, y1);
|
|
|
|
|
pb.line_to(x2, y2);
|
|
|
|
|
let Some(path) = pb.finish() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut paint = Paint::default();
|
|
|
|
|
paint.set_color_rgba8(color.0, color.1, color.2, color.3);
|
|
|
|
|
|
|
|
|
|
let mut stroke = Stroke::default();
|
|
|
|
|
stroke.width = width;
|
|
|
|
|
if let Some((dash_len, gap_len)) = dash {
|
|
|
|
|
stroke.dash = StrokeDash::new(vec![dash_len, gap_len], 0.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Draw a dotted line using repeated circles.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn dotted_line(
|
|
|
|
|
pixmap: &mut Pixmap,
|
|
|
|
|
x1: f32,
|
|
|
|
|
y1: f32,
|
|
|
|
|
x2: f32,
|
|
|
|
|
y2: f32,
|
|
|
|
|
width: f32,
|
|
|
|
|
color: (u8, u8, u8, u8),
|
|
|
|
|
) {
|
|
|
|
|
let dx = x2 - x1;
|
|
|
|
|
let dy = y2 - y1;
|
|
|
|
|
let len = (dx * dx + dy * dy).sqrt();
|
|
|
|
|
if len <= 0.0 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let ux = dx / len;
|
|
|
|
|
let uy = dy / len;
|
|
|
|
|
|
|
|
|
|
let mut d = 0.0_f32;
|
|
|
|
|
let step = 6.0;
|
|
|
|
|
let radius = (width * 0.35).max(1.0);
|
|
|
|
|
while d <= len {
|
|
|
|
|
draw_dot(pixmap, x1 + ux * d, y1 + uy * d, radius, color);
|
|
|
|
|
d += step;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Normalize an edge tuple to a stable ordering.
|
2026-03-06 11:42:20 -06:00
|
|
|
fn norm_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
|
|
|
|
|
if a <= b { (a, b) } else { (b, a) }
|
|
|
|
|
}
|
2026-03-12 08:25:37 -05:00
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Build a set of corridor-adjacent edges from corridor cells.
|
2026-03-12 08:25:37 -05:00
|
|
|
fn corridor_edges_from_cells(
|
|
|
|
|
corridor_cells: &HashSet<(usize, usize)>,
|
|
|
|
|
) -> HashSet<((usize, usize), (usize, usize))> {
|
|
|
|
|
let mut edges = HashSet::new();
|
|
|
|
|
for &(col, row) in corridor_cells {
|
|
|
|
|
let right = (col + 1, row);
|
|
|
|
|
let bottom = (col, row + 1);
|
|
|
|
|
if corridor_cells.contains(&right) {
|
|
|
|
|
edges.insert(norm_edge((col, row), right));
|
|
|
|
|
}
|
|
|
|
|
if corridor_cells.contains(&bottom) {
|
|
|
|
|
edges.insert(norm_edge((col, row), bottom));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
edges
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Build a set of internal room edges from room rectangles.
|
2026-03-12 08:25:37 -05:00
|
|
|
fn room_edges_from_rooms(rooms: &[Room]) -> HashSet<((usize, usize), (usize, usize))> {
|
|
|
|
|
let mut edges = HashSet::new();
|
|
|
|
|
for room in rooms {
|
|
|
|
|
let x_end = room.x + room.width;
|
|
|
|
|
let y_end = room.y + room.height;
|
|
|
|
|
for x in room.x..x_end {
|
|
|
|
|
for y in room.y..y_end {
|
|
|
|
|
if x + 1 < x_end {
|
|
|
|
|
edges.insert(norm_edge((x, y), (x + 1, y)));
|
|
|
|
|
}
|
|
|
|
|
if y + 1 < y_end {
|
|
|
|
|
edges.insert(norm_edge((x, y), (x, y + 1)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
edges
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Expand a door into all grid edges it spans based on width.
|
2026-03-12 08:25:37 -05:00
|
|
|
fn door_edges_for(door: &Door, cols: usize, rows: usize) -> Vec<((usize, usize), (usize, usize))> {
|
|
|
|
|
let mut edges = Vec::new();
|
|
|
|
|
if door.from.0.abs_diff(door.to.0) + door.from.1.abs_diff(door.to.1) != 1 {
|
|
|
|
|
return edges;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 11:36:36 -05:00
|
|
|
let width = door_render_width(door).max(1) as isize;
|
2026-03-12 08:25:37 -05:00
|
|
|
let min_offset = -((width - 1) / 2);
|
|
|
|
|
let max_offset = width / 2;
|
|
|
|
|
|
|
|
|
|
if door.from.0 != door.to.0 {
|
|
|
|
|
let y = door.from.1 as isize;
|
|
|
|
|
for dy in min_offset..=max_offset {
|
|
|
|
|
let ny = y + dy;
|
|
|
|
|
if ny < 0 || ny >= rows as isize {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let a = (door.from.0, ny as usize);
|
|
|
|
|
let b = (door.to.0, ny as usize);
|
|
|
|
|
edges.push(norm_edge(a, b));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let x = door.from.0 as isize;
|
|
|
|
|
for dx in min_offset..=max_offset {
|
|
|
|
|
let nx = x + dx;
|
|
|
|
|
if nx < 0 || nx >= cols as isize {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let a = (nx as usize, door.from.1);
|
|
|
|
|
let b = (nx as usize, door.to.1);
|
|
|
|
|
edges.push(norm_edge(a, b));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
edges
|
|
|
|
|
}
|
2026-03-12 11:36:36 -05:00
|
|
|
|
2026-03-19 13:49:42 -05:00
|
|
|
// Determine the rendered door width in cells.
|
2026-03-12 11:36:36 -05:00
|
|
|
fn door_render_width(door: &Door) -> usize {
|
|
|
|
|
if door.span_width {
|
|
|
|
|
door.width.max(1)
|
|
|
|
|
} else {
|
|
|
|
|
1
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-23 12:22:03 -05:00
|
|
|
|
|
|
|
|
fn window_render_width(window: &Window) -> usize {
|
|
|
|
|
if window.span_width {
|
|
|
|
|
window.width.max(1)
|
|
|
|
|
} else {
|
|
|
|
|
1
|
|
|
|
|
}
|
|
|
|
|
}
|