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:
Dane Sabo 2026-04-26 13:48:30 -04:00
parent 43df8f3ceb
commit 334e6c84ed
5 changed files with 4772 additions and 0 deletions

4243
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

6
Cargo.toml Normal file
View File

@ -0,0 +1,6 @@
[workspace]
members = [
"road_parceling",
"road_parceling_studio",
]
resolver = "2"

View File

@ -98,6 +98,12 @@ impl RoadGraph {
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.
///
/// # Errors

View 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"

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