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:
parent
334e6c84ed
commit
fb50885e7f
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -2577,6 +2577,7 @@ dependencies = [
|
||||
"glam",
|
||||
"log",
|
||||
"road_parceling",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@ -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]
|
||||
|
||||
6
road_parceling_studio/Trunk.toml
Normal file
6
road_parceling_studio/Trunk.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
|
||||
[serve]
|
||||
address = "127.0.0.1"
|
||||
port = 8080
|
||||
45
road_parceling_studio/index.html
Normal file
45
road_parceling_studio/index.html
Normal 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>
|
||||
506
road_parceling_studio/src/lib.rs
Normal file
506
road_parceling_studio/src/lib.rs
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user