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) {}