diff --git a/Cargo.lock b/Cargo.lock index ae95fb7..584e19a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2577,6 +2577,7 @@ dependencies = [ "glam", "log", "road_parceling", + "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] diff --git a/road_parceling_studio/Cargo.toml b/road_parceling_studio/Cargo.toml index 11a962d..322cb1b 100644 --- a/road_parceling_studio/Cargo.toml +++ b/road_parceling_studio/Cargo.toml @@ -6,19 +6,24 @@ authors = ["Dane Sabo"] description = "Interactive test harness for road_parceling — place roads, drag nodes, watch parcels regenerate." license = "MIT OR Apache-2.0" +[lib] +crate-type = ["cdylib", "rlib"] + [dependencies] road_parceling = { path = "../road_parceling" } glam = "0.29" -eframe = { version = "0.28", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } egui = "0.28" log = "0.4" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +eframe = { version = "0.28", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] } env_logger = "0.11" [target.'cfg(target_arch = "wasm32")'.dependencies] +eframe = { version = "0.28", default-features = false, features = ["default_fonts", "glow"] } +wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = "0.3" +web-sys = { version = "0.3", features = ["Document", "Window", "HtmlCanvasElement"] } console_error_panic_hook = "0.1" [lints.rust] diff --git a/road_parceling_studio/Trunk.toml b/road_parceling_studio/Trunk.toml new file mode 100644 index 0000000..9919922 --- /dev/null +++ b/road_parceling_studio/Trunk.toml @@ -0,0 +1,6 @@ +[build] +target = "index.html" + +[serve] +address = "127.0.0.1" +port = 8080 diff --git a/road_parceling_studio/index.html b/road_parceling_studio/index.html new file mode 100644 index 0000000..dd004e0 --- /dev/null +++ b/road_parceling_studio/index.html @@ -0,0 +1,45 @@ + + + + + + road_parceling_studio + + + + + +
loading WASM…
+ + + diff --git a/road_parceling_studio/src/lib.rs b/road_parceling_studio/src/lib.rs new file mode 100644 index 0000000..e42c698 --- /dev/null +++ b/road_parceling_studio/src/lib.rs @@ -0,0 +1,506 @@ +//! Interactive test harness for `road_parceling`. See `Studio` — +//! click empty space to drop nodes, click two nodes to connect them, +//! drag a node to move it. Backed by `eframe`/`egui` so the same +//! `Studio` runs both natively and in the browser via WASM. + +#![forbid(unsafe_code)] + +use eframe::egui; +use glam::DVec2; +use road_parceling::{ + apply_road_edit, subdivide_all_with_stats, NodeId, ParcelSet, RoadEdit, RoadGraph, + SubdivisionParams, SubdivisionStats, +}; + +/// Browser entry point. Mounts the `Studio` app onto an existing +/// `` in the host page. +/// +/// # Errors +/// +/// Returns whatever `eframe::WebRunner::start` returns — either a +/// `JsValue` describing the failure (canvas missing, WebGL init +/// fault) or `Ok(())` once the app is running. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub async fn start_in_canvas(canvas_id: &str) -> Result<(), wasm_bindgen::JsValue> { + use eframe::wasm_bindgen::JsCast as _; + console_error_panic_hook::set_once(); + let document = web_sys::window() + .ok_or_else(|| wasm_bindgen::JsValue::from_str("no window"))? + .document() + .ok_or_else(|| wasm_bindgen::JsValue::from_str("no document"))?; + let canvas = document + .get_element_by_id(canvas_id) + .ok_or_else(|| wasm_bindgen::JsValue::from_str("canvas not found"))? + .dyn_into::()?; + let runner = eframe::WebRunner::new(); + runner + .start( + canvas, + eframe::WebOptions::default(), + Box::new(|_cc| Ok(Box::new(Studio::default()))), + ) + .await +} + +#[derive(Clone, Copy, Debug)] +pub enum Pending { + Idle, + /// User clicked a node; the next click on a (possibly-new) node + /// will close a road from `from` to that node. + RoadFrom(NodeId), + /// User is dragging a node around. We commit the move on release. + DragNode { + node: NodeId, + start_pos: DVec2, + }, +} + +pub struct Studio { + graph: RoadGraph, + params: SubdivisionParams, + parcels: ParcelSet, + stats: Option, + pending: Pending, + /// World-space center of the viewport. + pan: egui::Vec2, + /// Pixels per world-meter. + zoom: f32, + show_parcels: bool, + show_grid: bool, + show_node_ids: bool, + last_error: Option, +} + +impl Default for Studio { + fn default() -> Self { + let mut s = Self { + graph: RoadGraph::new(), + params: SubdivisionParams::default(), + parcels: ParcelSet::default(), + stats: None, + pending: Pending::Idle, + pan: egui::Vec2::ZERO, + zoom: 4.0, + show_parcels: true, + show_grid: true, + show_node_ids: false, + last_error: None, + }; + s.load_preset_rectangle(); + s.recompute(); + s + } +} + +impl Studio { + fn recompute(&mut self) { + if let Err(e) = self.graph.rebuild_topology() { + self.last_error = Some(format!("rebuild_topology: {e}")); + self.parcels = ParcelSet::default(); + self.stats = None; + return; + } + match subdivide_all_with_stats(&self.graph, &self.params) { + Ok((p, s)) => { + self.parcels = p; + self.stats = Some(s); + self.last_error = None; + } + Err(e) => { + self.last_error = Some(format!("subdivide_all: {e}")); + self.parcels = ParcelSet::default(); + self.stats = None; + } + } + } + + fn clear(&mut self) { + self.graph = RoadGraph::new(); + self.pending = Pending::Idle; + self.recompute(); + } + + fn load_preset_rectangle(&mut self) { + self.graph = RoadGraph::new(); + let a = self.graph.add_node(DVec2::new(-100.0, -50.0)); + let b = self.graph.add_node(DVec2::new(100.0, -50.0)); + let c = self.graph.add_node(DVec2::new(100.0, 50.0)); + let d = self.graph.add_node(DVec2::new(-100.0, 50.0)); + let _ = self.graph.add_road(a, b); + let _ = self.graph.add_road(b, c); + let _ = self.graph.add_road(c, d); + let _ = self.graph.add_road(d, a); + self.pending = Pending::Idle; + } + + fn load_preset_y(&mut self) { + self.graph = RoadGraph::new(); + let r = 100.0_f64; + let third = std::f64::consts::TAU / 3.0; + let center = self.graph.add_node(DVec2::new(0.0, 0.0)); + let p1 = self.graph.add_node(DVec2::new(r, 0.0)); + let p2 = self + .graph + .add_node(DVec2::new(r * third.cos(), r * third.sin())); + let p3 = self.graph.add_node(DVec2::new( + r * (2.0 * third).cos(), + r * (2.0 * third).sin(), + )); + let _ = self.graph.add_road(center, p1); + let _ = self.graph.add_road(center, p2); + let _ = self.graph.add_road(center, p3); + let _ = self.graph.add_road(p1, p2); + let _ = self.graph.add_road(p2, p3); + let _ = self.graph.add_road(p3, p1); + self.pending = Pending::Idle; + } + + fn world_to_screen(&self, world: DVec2, rect: egui::Rect) -> egui::Pos2 { + let center = rect.center(); + egui::pos2( + center.x + (world.x as f32) * self.zoom + self.pan.x, + center.y - (world.y as f32) * self.zoom + self.pan.y, + ) + } + + fn screen_to_world(&self, screen: egui::Pos2, rect: egui::Rect) -> DVec2 { + let center = rect.center(); + DVec2::new( + ((screen.x - center.x - self.pan.x) / self.zoom) as f64, + (-(screen.y - center.y - self.pan.y) / self.zoom) as f64, + ) + } + + fn hit_node(&self, world: DVec2, pixel_radius: f32) -> Option { + let r_world = (pixel_radius / self.zoom) as f64; + let r2 = r_world * r_world; + let mut best: Option<(NodeId, f64)> = None; + for (nid, p) in self.graph.nodes() { + let d2 = (p - world).length_squared(); + if d2 <= r2 && best.is_none_or(|(_, bd)| d2 < bd) { + best = Some((nid, d2)); + } + } + best.map(|(id, _)| id) + } +} + +impl eframe::App for Studio { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::SidePanel::left("controls") + .resizable(false) + .default_width(260.0) + .show(ctx, |ui| { + ui.heading("road_parceling_studio"); + ui.separator(); + + ui.collapsing("Presets", |ui| { + if ui.button("Rectangle").clicked() { + self.load_preset_rectangle(); + self.recompute(); + } + if ui.button("Y intersection").clicked() { + self.load_preset_y(); + self.recompute(); + } + if ui.button("Clear").clicked() { + self.clear(); + } + }); + + ui.separator(); + ui.collapsing("Subdivision params", |ui| { + let mut changed = false; + changed |= ui + .add(egui::Slider::new(&mut self.params.frontage_width, 6.0..=60.0).text("frontage R")) + .changed(); + changed |= ui + .add(egui::Slider::new(&mut self.params.frontage_variance, 0.0..=20.0).text("frontage var")) + .changed(); + changed |= ui + .add(egui::Slider::new(&mut self.params.depth, 6.0..=60.0).text("depth d")) + .changed(); + changed |= ui + .add(egui::Slider::new(&mut self.params.depth_variance, 0.0..=20.0).text("depth var")) + .changed(); + changed |= ui + .add(egui::Slider::new(&mut self.params.setback, 0.0..=10.0).text("setback")) + .changed(); + changed |= ui + .add(egui::Slider::new(&mut self.params.min_frontage, 1.0..=20.0).text("min frontage")) + .changed(); + changed |= ui + .add(egui::Slider::new(&mut self.params.min_area, 10.0..=200.0).text("min area")) + .changed(); + changed |= ui + .add(egui::Slider::new(&mut self.params.regularity, 0.0..=1.0).text("regularity ρ")) + .changed(); + if changed { + self.recompute(); + } + }); + + ui.separator(); + ui.collapsing("View", |ui| { + ui.checkbox(&mut self.show_parcels, "Show parcels"); + ui.checkbox(&mut self.show_grid, "Show grid"); + ui.checkbox(&mut self.show_node_ids, "Show node IDs"); + ui.add(egui::Slider::new(&mut self.zoom, 0.5..=20.0).text("zoom")); + if ui.button("Reset view").clicked() { + self.pan = egui::Vec2::ZERO; + self.zoom = 4.0; + } + }); + + ui.separator(); + ui.label("Stats:"); + if let Some(s) = &self.stats { + ui.label(format!("blocks: {}", s.block_count)); + ui.label(format!("parcels: {}", s.parcel_count)); + ui.label(format!("total: {:?}", s.total)); + ui.label(format!("per parcel: {:.2} µs", s.time_per_parcel_us())); + } + if let Some(e) = &self.last_error { + ui.colored_label(egui::Color32::RED, e); + } + + ui.separator(); + ui.label("Controls:"); + ui.label("• Left click empty: place node"); + ui.label("• Left click two nodes: connect"); + ui.label("• Drag node: move it"); + ui.label("• Right-drag: pan"); + ui.label("• Scroll: zoom"); + }); + + egui::CentralPanel::default().show(ctx, |ui| { + let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); + let rect = response.rect; + + // Background. + painter.rect_filled(rect, 0.0, egui::Color32::from_rgb(248, 248, 248)); + + // Grid. + if self.show_grid { + let world_step = if self.zoom >= 6.0 { 10.0 } else if self.zoom >= 2.0 { 50.0 } else { 100.0 }; + let tl_world = self.screen_to_world(rect.left_top(), rect); + let br_world = self.screen_to_world(rect.right_bottom(), rect); + let x0 = (tl_world.x / world_step).floor() * world_step; + let x1 = (br_world.x / world_step).ceil() * world_step; + let y0 = (br_world.y / world_step).floor() * world_step; + let y1 = (tl_world.y / world_step).ceil() * world_step; + let grid_stroke = egui::Stroke::new(0.5, egui::Color32::from_rgb(220, 220, 220)); + let mut x = x0; + while x <= x1 { + let s_a = self.world_to_screen(DVec2::new(x, y0), rect); + let s_b = self.world_to_screen(DVec2::new(x, y1), rect); + painter.line_segment([s_a, s_b], grid_stroke); + x += world_step; + } + let mut y = y0; + while y <= y1 { + let s_a = self.world_to_screen(DVec2::new(x0, y), rect); + let s_b = self.world_to_screen(DVec2::new(x1, y), rect); + painter.line_segment([s_a, s_b], grid_stroke); + y += world_step; + } + // Origin axes. + let axis_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 180, 180)); + let xa = self.world_to_screen(DVec2::new(x0, 0.0), rect); + let xb = self.world_to_screen(DVec2::new(x1, 0.0), rect); + painter.line_segment([xa, xb], axis_stroke); + let ya = self.world_to_screen(DVec2::new(0.0, y0), rect); + let yb = self.world_to_screen(DVec2::new(0.0, y1), rect); + painter.line_segment([ya, yb], axis_stroke); + } + + // Parcels. + if self.show_parcels { + for (_, parcel) in self.parcels.iter() { + let pts: Vec = parcel + .vertices() + .iter() + .map(|v| self.world_to_screen(*v, rect)) + .collect(); + if pts.len() >= 3 { + painter.add(egui::Shape::convex_polygon( + pts.clone(), + egui::Color32::from_rgba_unmultiplied(255, 240, 180, 110), + egui::Stroke::new(0.8, egui::Color32::from_rgb(120, 110, 80)), + )); + } + } + } + + // Roads. + for (_road, a, b) in self.graph.road_endpoints() { + let (Some(pa), Some(pb)) = (self.graph.node_position(a), self.graph.node_position(b)) else { continue }; + let s_a = self.world_to_screen(pa, rect); + let s_b = self.world_to_screen(pb, rect); + painter.line_segment( + [s_a, s_b], + egui::Stroke::new(2.0, egui::Color32::from_rgb(60, 90, 200)), + ); + } + + let hover_world = response.hover_pos().map(|p| self.screen_to_world(p, rect)); + let hover_node = hover_world.and_then(|w| self.hit_node(w, 10.0)); + let nodes_snapshot: Vec<(NodeId, DVec2)> = self.graph.nodes().collect(); + for (nid, p) in &nodes_snapshot { + let s = self.world_to_screen(*p, rect); + let is_hover = hover_node == Some(*nid); + let is_pending = matches!(self.pending, Pending::RoadFrom(n) if n == *nid); + let radius = if is_hover || is_pending { 6.0 } else { 4.0 }; + let color = if is_pending { + egui::Color32::from_rgb(220, 30, 30) + } else if is_hover { + egui::Color32::from_rgb(40, 40, 40) + } else { + egui::Color32::from_rgb(20, 20, 20) + }; + painter.circle_filled(s, radius, color); + if self.show_node_ids { + painter.text( + s + egui::vec2(8.0, -8.0), + egui::Align2::LEFT_BOTTOM, + format!("{nid:?}"), + egui::FontId::proportional(10.0), + egui::Color32::DARK_GRAY, + ); + } + } + + // Pending-road preview line. + if let (Pending::RoadFrom(from), Some(world)) = (self.pending, hover_world) { + if let Some(pa) = self.graph.node_position(from) { + let s_a = self.world_to_screen(pa, rect); + let s_b = self.world_to_screen(world, rect); + painter.line_segment( + [s_a, s_b], + egui::Stroke::new(1.5, egui::Color32::from_rgb(180, 60, 60)), + ); + } + } + + // ---- Input handling ---- + // Right-drag pan. + if response.dragged_by(egui::PointerButton::Secondary) { + self.pan += response.drag_delta(); + } + // Scroll zoom (zoom toward cursor). + let scroll = ctx.input(|i| i.raw_scroll_delta.y); + if scroll.abs() > 0.0 { + if let Some(cursor) = response.hover_pos() { + let world_before = self.screen_to_world(cursor, rect); + let factor = (scroll * 0.005).exp(); + self.zoom = (self.zoom * factor).clamp(0.5, 30.0); + let world_after = self.screen_to_world(cursor, rect); + let delta = world_after - world_before; + self.pan += egui::vec2( + (delta.x as f32) * self.zoom, + -(delta.y as f32) * self.zoom, + ); + } + } + + // Drag start: if cursor is on a node, begin drag. + if response.drag_started_by(egui::PointerButton::Primary) { + if let Some(cursor) = response.interact_pointer_pos() { + let world = self.screen_to_world(cursor, rect); + if let Some(nid) = self.hit_node(world, 10.0) { + if let Some(start) = self.graph.node_position(nid) { + self.pending = Pending::DragNode { + node: nid, + start_pos: start, + }; + } + } + } + } + + // While dragging a node, follow the cursor. + if let Pending::DragNode { node, start_pos } = self.pending { + if response.dragged_by(egui::PointerButton::Primary) { + if let Some(cursor) = response.interact_pointer_pos() { + let world = self.screen_to_world(cursor, rect); + let edit = RoadEdit::MoveNode { node, to: world }; + match apply_road_edit(&mut self.parcels, &mut self.graph, edit, &self.params) { + Ok(_outcome) => { + self.last_error = None; + } + Err(_e) => { + // Drag move rejected (would create + // self-intersection or planarity + // violation). Snap back to the start + // and abort the drag. + let _ = apply_road_edit( + &mut self.parcels, + &mut self.graph, + RoadEdit::MoveNode { + node, + to: start_pos, + }, + &self.params, + ); + self.pending = Pending::Idle; + } + } + // Stats need a fresh subdivide_all_with_stats + // since apply_road_edit doesn't return them. + if let Ok((_, s)) = subdivide_all_with_stats(&self.graph, &self.params) { + self.stats = Some(s); + } + } + } + if response.drag_stopped_by(egui::PointerButton::Primary) { + self.pending = Pending::Idle; + } + } else if response.clicked_by(egui::PointerButton::Primary) { + if let Some(cursor) = response.interact_pointer_pos() { + let world = self.screen_to_world(cursor, rect); + let hit = self.hit_node(world, 10.0); + match (self.pending, hit) { + (Pending::RoadFrom(from), Some(to)) if from != to => { + match self.graph.add_road(from, to) { + Ok(_) => self.last_error = None, + Err(e) => self.last_error = Some(format!("add_road: {e}")), + } + self.pending = Pending::Idle; + self.recompute(); + } + (Pending::RoadFrom(from), None) => { + let new_node = self.graph.add_node(world); + match self.graph.add_road(from, new_node) { + Ok(_) => self.last_error = None, + Err(e) => self.last_error = Some(format!("add_road: {e}")), + } + self.pending = Pending::Idle; + self.recompute(); + } + (Pending::Idle, Some(node)) => { + self.pending = Pending::RoadFrom(node); + } + (Pending::Idle, None) => { + let new_node = self.graph.add_node(world); + self.pending = Pending::RoadFrom(new_node); + self.recompute(); + } + _ => {} + } + } + } + + // Right-click: cancel pending road. + if response.clicked_by(egui::PointerButton::Secondary) { + self.pending = Pending::Idle; + } + + // ESC also cancels. + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + self.pending = Pending::Idle; + } + }); + } +} + diff --git a/road_parceling_studio/src/main.rs b/road_parceling_studio/src/main.rs index 07bb210..71fc949 100644 --- a/road_parceling_studio/src/main.rs +++ b/road_parceling_studio/src/main.rs @@ -1,492 +1,23 @@ -//! Interactive test harness for `road_parceling`. Click empty space -//! to drop nodes; click two nodes to connect them with a road; drag -//! a node to move it (re-runs the subdivision live). Side panel -//! exposes the [`SubdivisionParams`] knobs. +//! Native entry point. The actual UI lives in `lib.rs` so the same +//! `Studio` is reused for the WASM build. #![forbid(unsafe_code)] -use eframe::egui; -use glam::DVec2; -use road_parceling::{ - apply_road_edit, subdivide_all_with_stats, NodeId, ParcelSet, RoadEdit, RoadGraph, RoadId, - SubdivisionParams, SubdivisionStats, -}; - +#[cfg(not(target_arch = "wasm32"))] fn main() -> eframe::Result { env_logger::init(); let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]), + viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]), ..Default::default() }; eframe::run_native( "road_parceling_studio", options, - Box::new(|_cc| Ok(Box::new(Studio::default()))), + Box::new(|_cc| Ok(Box::new(road_parceling_studio::Studio::default()))), ) } -#[derive(Clone, Copy, Debug)] -enum Pending { - Idle, - /// User clicked a node; the next click on a (possibly-new) node - /// will close a road from `from` to that node. - RoadFrom(NodeId), - /// User is dragging a node around. We commit the move on release. - DragNode { - node: NodeId, - start_pos: DVec2, - }, +#[cfg(target_arch = "wasm32")] +fn main() { + // No-op on wasm32; the browser entrypoint lives in `lib.rs`. } - -struct Studio { - graph: RoadGraph, - params: SubdivisionParams, - parcels: ParcelSet, - stats: Option, - pending: Pending, - /// World-space center of the viewport. - pan: egui::Vec2, - /// Pixels per world-meter. - zoom: f32, - show_parcels: bool, - show_grid: bool, - show_node_ids: bool, - last_error: Option, -} - -impl Default for Studio { - fn default() -> Self { - let mut s = Self { - graph: RoadGraph::new(), - params: SubdivisionParams::default(), - parcels: ParcelSet::default(), - stats: None, - pending: Pending::Idle, - pan: egui::Vec2::ZERO, - zoom: 4.0, - show_parcels: true, - show_grid: true, - show_node_ids: false, - last_error: None, - }; - s.load_preset_rectangle(); - s.recompute(); - s - } -} - -impl Studio { - fn recompute(&mut self) { - if let Err(e) = self.graph.rebuild_topology() { - self.last_error = Some(format!("rebuild_topology: {e}")); - self.parcels = ParcelSet::default(); - self.stats = None; - return; - } - match subdivide_all_with_stats(&self.graph, &self.params) { - Ok((p, s)) => { - self.parcels = p; - self.stats = Some(s); - self.last_error = None; - } - Err(e) => { - self.last_error = Some(format!("subdivide_all: {e}")); - self.parcels = ParcelSet::default(); - self.stats = None; - } - } - } - - fn clear(&mut self) { - self.graph = RoadGraph::new(); - self.pending = Pending::Idle; - self.recompute(); - } - - fn load_preset_rectangle(&mut self) { - self.graph = RoadGraph::new(); - let a = self.graph.add_node(DVec2::new(-100.0, -50.0)); - let b = self.graph.add_node(DVec2::new(100.0, -50.0)); - let c = self.graph.add_node(DVec2::new(100.0, 50.0)); - let d = self.graph.add_node(DVec2::new(-100.0, 50.0)); - let _ = self.graph.add_road(a, b); - let _ = self.graph.add_road(b, c); - let _ = self.graph.add_road(c, d); - let _ = self.graph.add_road(d, a); - self.pending = Pending::Idle; - } - - fn load_preset_y(&mut self) { - self.graph = RoadGraph::new(); - let r = 100.0_f64; - let third = std::f64::consts::TAU / 3.0; - let center = self.graph.add_node(DVec2::new(0.0, 0.0)); - let p1 = self.graph.add_node(DVec2::new(r, 0.0)); - let p2 = self - .graph - .add_node(DVec2::new(r * third.cos(), r * third.sin())); - let p3 = self.graph.add_node(DVec2::new( - r * (2.0 * third).cos(), - r * (2.0 * third).sin(), - )); - let _ = self.graph.add_road(center, p1); - let _ = self.graph.add_road(center, p2); - let _ = self.graph.add_road(center, p3); - let _ = self.graph.add_road(p1, p2); - let _ = self.graph.add_road(p2, p3); - let _ = self.graph.add_road(p3, p1); - self.pending = Pending::Idle; - } - - fn world_to_screen(&self, world: DVec2, rect: egui::Rect) -> egui::Pos2 { - let center = rect.center(); - egui::pos2( - center.x + (world.x as f32) * self.zoom + self.pan.x, - center.y - (world.y as f32) * self.zoom + self.pan.y, - ) - } - - fn screen_to_world(&self, screen: egui::Pos2, rect: egui::Rect) -> DVec2 { - let center = rect.center(); - DVec2::new( - ((screen.x - center.x - self.pan.x) / self.zoom) as f64, - (-(screen.y - center.y - self.pan.y) / self.zoom) as f64, - ) - } - - fn hit_node(&self, world: DVec2, pixel_radius: f32) -> Option { - let r_world = (pixel_radius / self.zoom) as f64; - let r2 = r_world * r_world; - let mut best: Option<(NodeId, f64)> = None; - for (nid, p) in self.graph.nodes() { - let d2 = (p - world).length_squared(); - if d2 <= r2 && best.is_none_or(|(_, bd)| d2 < bd) { - best = Some((nid, d2)); - } - } - best.map(|(id, _)| id) - } -} - -impl eframe::App for Studio { - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - egui::SidePanel::left("controls") - .resizable(false) - .default_width(260.0) - .show(ctx, |ui| { - ui.heading("road_parceling_studio"); - ui.separator(); - - ui.collapsing("Presets", |ui| { - if ui.button("Rectangle").clicked() { - self.load_preset_rectangle(); - self.recompute(); - } - if ui.button("Y intersection").clicked() { - self.load_preset_y(); - self.recompute(); - } - if ui.button("Clear").clicked() { - self.clear(); - } - }); - - ui.separator(); - ui.collapsing("Subdivision params", |ui| { - let mut changed = false; - changed |= ui - .add(egui::Slider::new(&mut self.params.frontage_width, 6.0..=60.0).text("frontage R")) - .changed(); - changed |= ui - .add(egui::Slider::new(&mut self.params.frontage_variance, 0.0..=20.0).text("frontage var")) - .changed(); - changed |= ui - .add(egui::Slider::new(&mut self.params.depth, 6.0..=60.0).text("depth d")) - .changed(); - changed |= ui - .add(egui::Slider::new(&mut self.params.depth_variance, 0.0..=20.0).text("depth var")) - .changed(); - changed |= ui - .add(egui::Slider::new(&mut self.params.setback, 0.0..=10.0).text("setback")) - .changed(); - changed |= ui - .add(egui::Slider::new(&mut self.params.min_frontage, 1.0..=20.0).text("min frontage")) - .changed(); - changed |= ui - .add(egui::Slider::new(&mut self.params.min_area, 10.0..=200.0).text("min area")) - .changed(); - changed |= ui - .add(egui::Slider::new(&mut self.params.regularity, 0.0..=1.0).text("regularity ρ")) - .changed(); - if changed { - self.recompute(); - } - }); - - ui.separator(); - ui.collapsing("View", |ui| { - ui.checkbox(&mut self.show_parcels, "Show parcels"); - ui.checkbox(&mut self.show_grid, "Show grid"); - ui.checkbox(&mut self.show_node_ids, "Show node IDs"); - ui.add(egui::Slider::new(&mut self.zoom, 0.5..=20.0).text("zoom")); - if ui.button("Reset view").clicked() { - self.pan = egui::Vec2::ZERO; - self.zoom = 4.0; - } - }); - - ui.separator(); - ui.label("Stats:"); - if let Some(s) = &self.stats { - ui.label(format!("blocks: {}", s.block_count)); - ui.label(format!("parcels: {}", s.parcel_count)); - ui.label(format!("total: {:?}", s.total)); - ui.label(format!("per parcel: {:.2} µs", s.time_per_parcel_us())); - } - if let Some(e) = &self.last_error { - ui.colored_label(egui::Color32::RED, e); - } - - ui.separator(); - ui.label("Controls:"); - ui.label("• Left click empty: place node"); - ui.label("• Left click two nodes: connect"); - ui.label("• Drag node: move it"); - ui.label("• Right-drag: pan"); - ui.label("• Scroll: zoom"); - }); - - egui::CentralPanel::default().show(ctx, |ui| { - let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::click_and_drag()); - let rect = response.rect; - - // Background. - painter.rect_filled(rect, 0.0, egui::Color32::from_rgb(248, 248, 248)); - - // Grid. - if self.show_grid { - let world_step = if self.zoom >= 6.0 { 10.0 } else if self.zoom >= 2.0 { 50.0 } else { 100.0 }; - let tl_world = self.screen_to_world(rect.left_top(), rect); - let br_world = self.screen_to_world(rect.right_bottom(), rect); - let x0 = (tl_world.x / world_step).floor() * world_step; - let x1 = (br_world.x / world_step).ceil() * world_step; - let y0 = (br_world.y / world_step).floor() * world_step; - let y1 = (tl_world.y / world_step).ceil() * world_step; - let grid_stroke = egui::Stroke::new(0.5, egui::Color32::from_rgb(220, 220, 220)); - let mut x = x0; - while x <= x1 { - let s_a = self.world_to_screen(DVec2::new(x, y0), rect); - let s_b = self.world_to_screen(DVec2::new(x, y1), rect); - painter.line_segment([s_a, s_b], grid_stroke); - x += world_step; - } - let mut y = y0; - while y <= y1 { - let s_a = self.world_to_screen(DVec2::new(x0, y), rect); - let s_b = self.world_to_screen(DVec2::new(x1, y), rect); - painter.line_segment([s_a, s_b], grid_stroke); - y += world_step; - } - // Origin axes. - let axis_stroke = egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 180, 180)); - let xa = self.world_to_screen(DVec2::new(x0, 0.0), rect); - let xb = self.world_to_screen(DVec2::new(x1, 0.0), rect); - painter.line_segment([xa, xb], axis_stroke); - let ya = self.world_to_screen(DVec2::new(0.0, y0), rect); - let yb = self.world_to_screen(DVec2::new(0.0, y1), rect); - painter.line_segment([ya, yb], axis_stroke); - } - - // Parcels. - if self.show_parcels { - for (_, parcel) in self.parcels.iter() { - let pts: Vec = parcel - .vertices() - .iter() - .map(|v| self.world_to_screen(*v, rect)) - .collect(); - if pts.len() >= 3 { - painter.add(egui::Shape::convex_polygon( - pts.clone(), - egui::Color32::from_rgba_unmultiplied(255, 240, 180, 110), - egui::Stroke::new(0.8, egui::Color32::from_rgb(120, 110, 80)), - )); - } - } - } - - // Roads. - for (_road, a, b) in self.graph.road_endpoints() { - let (Some(pa), Some(pb)) = (self.graph.node_position(a), self.graph.node_position(b)) else { continue }; - let s_a = self.world_to_screen(pa, rect); - let s_b = self.world_to_screen(pb, rect); - painter.line_segment( - [s_a, s_b], - egui::Stroke::new(2.0, egui::Color32::from_rgb(60, 90, 200)), - ); - } - - let hover_world = response.hover_pos().map(|p| self.screen_to_world(p, rect)); - let hover_node = hover_world.and_then(|w| self.hit_node(w, 10.0)); - let nodes_snapshot: Vec<(NodeId, DVec2)> = self.graph.nodes().collect(); - for (nid, p) in &nodes_snapshot { - let s = self.world_to_screen(*p, rect); - let is_hover = hover_node == Some(*nid); - let is_pending = matches!(self.pending, Pending::RoadFrom(n) if n == *nid); - let radius = if is_hover || is_pending { 6.0 } else { 4.0 }; - let color = if is_pending { - egui::Color32::from_rgb(220, 30, 30) - } else if is_hover { - egui::Color32::from_rgb(40, 40, 40) - } else { - egui::Color32::from_rgb(20, 20, 20) - }; - painter.circle_filled(s, radius, color); - if self.show_node_ids { - painter.text( - s + egui::vec2(8.0, -8.0), - egui::Align2::LEFT_BOTTOM, - format!("{nid:?}"), - egui::FontId::proportional(10.0), - egui::Color32::DARK_GRAY, - ); - } - } - - // Pending-road preview line. - if let (Pending::RoadFrom(from), Some(world)) = (self.pending, hover_world) { - if let Some(pa) = self.graph.node_position(from) { - let s_a = self.world_to_screen(pa, rect); - let s_b = self.world_to_screen(world, rect); - painter.line_segment( - [s_a, s_b], - egui::Stroke::new(1.5, egui::Color32::from_rgb(180, 60, 60)), - ); - } - } - - // ---- Input handling ---- - // Right-drag pan. - if response.dragged_by(egui::PointerButton::Secondary) { - self.pan += response.drag_delta(); - } - // Scroll zoom (zoom toward cursor). - let scroll = ctx.input(|i| i.raw_scroll_delta.y); - if scroll.abs() > 0.0 { - if let Some(cursor) = response.hover_pos() { - let world_before = self.screen_to_world(cursor, rect); - let factor = (scroll * 0.005).exp(); - self.zoom = (self.zoom * factor).clamp(0.5, 30.0); - let world_after = self.screen_to_world(cursor, rect); - let delta = world_after - world_before; - self.pan += egui::vec2( - (delta.x as f32) * self.zoom, - -(delta.y as f32) * self.zoom, - ); - } - } - - // Drag start: if cursor is on a node, begin drag. - if response.drag_started_by(egui::PointerButton::Primary) { - if let Some(cursor) = response.interact_pointer_pos() { - let world = self.screen_to_world(cursor, rect); - if let Some(nid) = self.hit_node(world, 10.0) { - if let Some(start) = self.graph.node_position(nid) { - self.pending = Pending::DragNode { - node: nid, - start_pos: start, - }; - } - } - } - } - - // While dragging a node, follow the cursor. - if let Pending::DragNode { node, start_pos } = self.pending { - if response.dragged_by(egui::PointerButton::Primary) { - if let Some(cursor) = response.interact_pointer_pos() { - let world = self.screen_to_world(cursor, rect); - let edit = RoadEdit::MoveNode { node, to: world }; - match apply_road_edit(&mut self.parcels, &mut self.graph, edit, &self.params) { - Ok(_outcome) => { - self.last_error = None; - } - Err(_e) => { - // Drag move rejected (would create - // self-intersection or planarity - // violation). Snap back to the start - // and abort the drag. - let _ = apply_road_edit( - &mut self.parcels, - &mut self.graph, - RoadEdit::MoveNode { - node, - to: start_pos, - }, - &self.params, - ); - self.pending = Pending::Idle; - } - } - // Stats need a fresh subdivide_all_with_stats - // since apply_road_edit doesn't return them. - if let Ok((_, s)) = subdivide_all_with_stats(&self.graph, &self.params) { - self.stats = Some(s); - } - } - } - if response.drag_stopped_by(egui::PointerButton::Primary) { - self.pending = Pending::Idle; - } - } else if response.clicked_by(egui::PointerButton::Primary) { - if let Some(cursor) = response.interact_pointer_pos() { - let world = self.screen_to_world(cursor, rect); - let hit = self.hit_node(world, 10.0); - match (self.pending, hit) { - (Pending::RoadFrom(from), Some(to)) if from != to => { - match self.graph.add_road(from, to) { - Ok(_) => self.last_error = None, - Err(e) => self.last_error = Some(format!("add_road: {e}")), - } - self.pending = Pending::Idle; - self.recompute(); - } - (Pending::RoadFrom(from), None) => { - let new_node = self.graph.add_node(world); - match self.graph.add_road(from, new_node) { - Ok(_) => self.last_error = None, - Err(e) => self.last_error = Some(format!("add_road: {e}")), - } - self.pending = Pending::Idle; - self.recompute(); - } - (Pending::Idle, Some(node)) => { - self.pending = Pending::RoadFrom(node); - } - (Pending::Idle, None) => { - let new_node = self.graph.add_node(world); - self.pending = Pending::RoadFrom(new_node); - self.recompute(); - } - _ => {} - } - } - } - - // Right-click: cancel pending road. - if response.clicked_by(egui::PointerButton::Secondary) { - self.pending = Pending::Idle; - } - - // ESC also cancels. - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - self.pending = Pending::Idle; - } - }); - } -} - -// Silence the dead-code warning for RoadId import (kept for future -// road-deletion mode). -#[allow(dead_code)] -fn _phantom_road(_: RoadId) {}