M2 part 2: WASM build target for road_parceling_studio

Refactored Studio + impl App into lib.rs (was main.rs). Added a
#[wasm_bindgen] start_in_canvas entry plus index.html/Trunk.toml so
the same code runs in a browser tab via:

  rustup target add wasm32-unknown-unknown
  cargo install trunk
  cd road_parceling_studio && trunk serve

Native still builds with `cargo run -p road_parceling_studio`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dane Sabo 2026-04-26 13:51:06 -04:00
parent 334e6c84ed
commit fb50885e7f
6 changed files with 573 additions and 479 deletions

1
Cargo.lock generated
View File

@ -2577,6 +2577,7 @@ dependencies = [
"glam",
"log",
"road_parceling",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]

View File

@ -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]

View File

@ -0,0 +1,6 @@
[build]
target = "index.html"
[serve]
address = "127.0.0.1"
port = 8080

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>road_parceling_studio</title>
<link data-trunk rel="rust" data-bin="road_parceling_studio" data-wasm-opt="z" />
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #1a1a1a;
color: #ddd;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#studio_canvas {
display: block;
width: 100vw;
height: 100vh;
}
#loading {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #888;
}
</style>
</head>
<body>
<canvas id="studio_canvas"></canvas>
<div id="loading">loading WASM…</div>
<script type="module">
import init, { start_in_canvas } from "./road_parceling_studio.js";
(async () => {
await init();
document.getElementById("loading").remove();
await start_in_canvas("studio_canvas");
})();
</script>
</body>
</html>

View File

@ -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
/// `<canvas id="studio_canvas">` 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::<web_sys::HtmlCanvasElement>()?;
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<SubdivisionStats>,
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<String>,
}
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<NodeId> {
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<egui::Pos2> = 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;
}
});
}
}

View File

@ -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<SubdivisionStats>,
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<String>,
}
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<NodeId> {
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<egui::Pos2> = 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) {}