M2 part 1: road_parceling_studio interactive harness (native)
Sibling crate using egui/eframe. Click empty space to drop a node and start a road from it; click a second node to close the road. Drag a node to live-apply MoveNode (with snap-back on rejection). Side panel exposes every SubdivisionParams knob, view toggles, and timing stats. - Workspace Cargo.toml at repo root. - RoadGraph::nodes() iterator added (needed to hit-test isolated just-placed nodes that have no incident road yet). - WASM target list is configured in Cargo.toml but native is the only shipped frontend in this commit — WASM bundling lands next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
43df8f3ceb
commit
334e6c84ed
4243
Cargo.lock
generated
Normal file
4243
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
Cargo.toml
Normal file
6
Cargo.toml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"road_parceling",
|
||||||
|
"road_parceling_studio",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
@ -98,6 +98,12 @@ impl RoadGraph {
|
|||||||
self.nodes.get(id).map(|n| n.pos)
|
self.nodes.get(id).map(|n| n.pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Iterate over every live node (id, position). Includes isolated
|
||||||
|
/// nodes that are not yet connected to any road.
|
||||||
|
pub fn nodes(&self) -> impl Iterator<Item = (NodeId, DVec2)> + '_ {
|
||||||
|
self.nodes.iter().map(|(id, n)| (id, n.pos))
|
||||||
|
}
|
||||||
|
|
||||||
/// Insert a road between two existing nodes.
|
/// Insert a road between two existing nodes.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
25
road_parceling_studio/Cargo.toml
Normal file
25
road_parceling_studio/Cargo.toml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "road_parceling_studio"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Dane Sabo"]
|
||||||
|
description = "Interactive test harness for road_parceling — place roads, drag nodes, watch parcels regenerate."
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[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]
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
web-sys = "0.3"
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "forbid"
|
||||||
492
road_parceling_studio/src/main.rs
Normal file
492
road_parceling_studio/src/main.rs
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
#![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,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> eframe::Result {
|
||||||
|
env_logger::init();
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: 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()))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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