blockworx_tools/route_tool.rs
1use std::collections::BTreeMap;
2
3use blockworx_doc::geometry::Waypoint;
4use blockworx_doc::id::PinId;
5use blockworx_geom::Pos2;
6use blockworx_router::{ClosedRouter, point::Point};
7
8use crate::render::render_path_with_chamfered_corners;
9use crate::theme::Style;
10use crate::{
11 SelectTool,
12 edit::create::PathOrdinal,
13 grid::{PORT_RADIUS, grid_point, px_point, snap_to_grid},
14 names::ToolName,
15 new_pin::{SlotMarker, draw_route_markers, marker_at, markers_in_scope, nearest_block_markers},
16 route_start,
17 theme::Role,
18 tool::{ToolTrait, Transition},
19 widget::{
20 drawing::Drawing,
21 waypoint_router::{
22 FixedLegs, RouteRequest, SelfCost, TaggedPoint, route_fixed_legs, route_to_head,
23 },
24 },
25};
26use blockworx_paint::{Canvas, Cursor, Event, Interaction};
27/// The in-progress corners as the router speaks them: waypoints are
28/// positional, so a corner's identity is its index in the list.
29fn waypoint_nodes(waypoints: &[Waypoint]) -> (Vec<PathOrdinal>, BTreeMap<PathOrdinal, Point>) {
30 let positions: BTreeMap<PathOrdinal, Point> = waypoints
31 .iter()
32 .enumerate()
33 .map(|(index, wp)| (PathOrdinal::new(index), waypoint_point(wp)))
34 .collect();
35 (positions.keys().copied().collect(), positions)
36}
37
38fn waypoint_point(wp: &Waypoint) -> Point {
39 Point::from(wp.pos)
40}
41
42/// Route an in-progress route (`start` → each waypoint → `end`) against a freshly
43/// built closed router, seeding the endpoints and waypoints so they are graph
44/// nodes. Used at commit time, where the exact final geometry (and the route's own
45/// occupancy) matters. The live preview instead routes against a cached router
46/// (see `RouteTool::preview_route`).
47fn route_in_progress(
48 data: &Drawing,
49 start: Pos2,
50 waypoints: &[Waypoint],
51 end: Pos2,
52) -> Vec<TaggedPoint> {
53 let mut extra: Vec<Point> = Vec::with_capacity(waypoints.len() + 2);
54 extra.push(start.into());
55 extra.push(end.into());
56 extra.extend(waypoints.iter().map(waypoint_point));
57 let mut router = data.scratch_closed_router(&extra);
58 let (wp_ids, wp_positions) = waypoint_nodes(waypoints);
59 RouteRequest {
60 start: start.into(),
61 end: end.into(),
62 wp_ids: &wp_ids,
63 wp_positions: &wp_positions,
64 self_cost: SelfCost::Apply,
65 }
66 .route(&mut router)
67 .points
68}
69
70#[derive(Default)]
71enum RouteToolState {
72 #[default]
73 Idle,
74 PinHeadHovered {
75 anchor: PinId,
76 },
77 InProgress {
78 start: PinId,
79 waypoints: Vec<Waypoint>,
80 head: Pos2,
81 },
82 Proposed {
83 start: PinId,
84 waypoints: Vec<Waypoint>,
85 finish: FinishTarget,
86 },
87}
88
89/// Where an in-progress route proposes to end. An existing pin resolves to its
90/// anchor directly; a free slot's marker names the block and slot where the pin
91/// will be reconstructed at commit time, along with its prospective connection
92/// point.
93#[derive(Clone, Copy)]
94enum FinishTarget {
95 Anchor(PinId),
96 NewPin(SlotMarker),
97}
98
99/// The in-progress route a preview frame lays out: where it starts, the
100/// waypoints committed so far, and the cursor it currently reaches to.
101struct PreviewRequest<'a> {
102 start: PinId,
103 start_pos: Pos2,
104 waypoints: &'a [Waypoint],
105 end: Pos2,
106}
107
108/// Everything cached across frames of one route gesture while only the cursor
109/// moves. The graph (obstacles + existing routes + `start` + waypoints) and the
110/// fixed `start → … → last waypoint` legs are both invariant until a waypoint is
111/// added, so both are computed once. Keyed by `start` + waypoint count.
112struct PreviewCache {
113 start: PinId,
114 waypoint_count: usize,
115 router: ClosedRouter,
116 fixed: FixedLegs,
117}
118
119#[derive(Default)]
120pub struct RouteTool {
121 state: RouteToolState,
122 preview_path: Vec<TaggedPoint>,
123 /// Cached router + fixed legs for the live preview; rebuilt only when a
124 /// waypoint is added or a new route starts, and dropped when the gesture ends
125 /// (which also covers commit changing the set of existing routes). Boxed to
126 /// keep it off the `Tool` enum's stack footprint — it is the widest thing a
127 /// tool owns, and only the route tool ever has one.
128 preview_cache: Option<Box<PreviewCache>>,
129 /// When set, completing or cancelling the route returns to the select tool
130 /// (the route was started from a select-tool hover target) rather than
131 /// staying in the route tool ready for the next route.
132 return_to_select: bool,
133}
134
135impl ToolTrait for RouteTool {
136 fn name(&self) -> ToolName {
137 ToolName::Route
138 }
139
140 fn widget<C: Canvas>(
141 &mut self,
142 data: &mut Drawing,
143 interaction: &Interaction,
144 painter: &mut Style<'_, C>,
145 ) -> Option<Transition> {
146 // Take ownership of the current state, leaving Idle in place.
147 // This avoids borrow-checker issues when transitioning states.
148 let state = std::mem::take(&mut self.state);
149
150 crate::widget::display::widget(data, interaction, painter);
151 painter.set_cursor(Cursor::Crosshair);
152
153 let was_routing = matches!(
154 state,
155 RouteToolState::InProgress { .. } | RouteToolState::Proposed { .. }
156 );
157 // Esc or Backspace cancels an in-progress route, discarding it.
158 let cancelled = was_routing && (interaction.escape_pressed || interaction.delete_pressed);
159
160 self.state = if cancelled {
161 self.preview_path.clear();
162 RouteToolState::Idle
163 } else {
164 match state {
165 RouteToolState::Idle => match interaction.event {
166 Some(Event::DragStarted { pos } | Event::Clicked { pos }) => {
167 if let Some(anchor) = data.anchor_at_pos(pos) {
168 RouteToolState::InProgress {
169 start: anchor,
170 waypoints: Vec::new(),
171 head: pos,
172 }
173 } else {
174 RouteToolState::Idle
175 }
176 }
177 Some(Event::HoverAt(pos)) => {
178 if let Some(anchor) = data.anchor_at_pos(pos) {
179 RouteToolState::PinHeadHovered { anchor }
180 } else {
181 RouteToolState::Idle
182 }
183 }
184 _ => RouteToolState::Idle,
185 },
186
187 RouteToolState::PinHeadHovered { anchor } => match interaction.event {
188 Some(Event::DragStarted { pos } | Event::Clicked { pos }) => {
189 RouteToolState::InProgress {
190 start: anchor,
191 waypoints: Vec::new(),
192 head: pos,
193 }
194 }
195 Some(Event::HoverAt(pos)) => {
196 if let Some(new_anchor) = data.anchor_at_pos(pos) {
197 RouteToolState::PinHeadHovered { anchor: new_anchor }
198 } else {
199 RouteToolState::Idle
200 }
201 }
202 _ => RouteToolState::PinHeadHovered { anchor },
203 },
204
205 RouteToolState::InProgress {
206 start,
207 mut waypoints,
208 mut head,
209 } => match interaction.event {
210 Some(Event::Clicked { pos }) => {
211 waypoints.push(Waypoint {
212 pos: grid_point(pos),
213 locked: true,
214 });
215 RouteToolState::InProgress {
216 start,
217 waypoints,
218 head,
219 }
220 }
221 // A drag away from the start anchor previews the route to the
222 // cursor, proposing a connection once it reaches another anchor or
223 // an armed new-pin target.
224 Some(Event::HoverAt(pos) | Event::Dragging { pos, .. }) => {
225 if let Some(finish) = Self::finish_at(data, start, pos) {
226 RouteToolState::Proposed {
227 start,
228 waypoints,
229 finish,
230 }
231 } else {
232 head = pos;
233 RouteToolState::InProgress {
234 start,
235 waypoints,
236 head,
237 }
238 }
239 }
240 // Releasing on a second anchor (or armed new-pin target) completes
241 // the route; releasing anywhere else leaves the operation in
242 // progress, exactly as a click on the start anchor would.
243 Some(Event::DragStopped { pos }) => {
244 if let Some(finish) = Self::finish_at(data, start, pos) {
245 self.commit(data, start, finish, waypoints)
246 } else {
247 head = pos;
248 RouteToolState::InProgress {
249 start,
250 waypoints,
251 head,
252 }
253 }
254 }
255 _ => RouteToolState::InProgress {
256 start,
257 waypoints,
258 head,
259 },
260 },
261
262 RouteToolState::Proposed {
263 start,
264 waypoints,
265 mut finish,
266 } => match interaction.event {
267 Some(Event::Clicked { .. }) => self.commit(data, start, finish, waypoints),
268 // Releasing on the proposed target completes the route; releasing
269 // off it continues the operation from the start anchor.
270 Some(Event::DragStopped { pos }) => match Self::finish_at(data, start, pos) {
271 Some(finish) => self.commit(data, start, finish, waypoints),
272 None => RouteToolState::InProgress {
273 start,
274 waypoints,
275 head: pos,
276 },
277 },
278 Some(Event::HoverAt(pos) | Event::Dragging { pos, .. }) => {
279 match Self::finish_at(data, start, pos) {
280 Some(new_finish) => {
281 finish = new_finish;
282 RouteToolState::Proposed {
283 start,
284 waypoints,
285 finish,
286 }
287 }
288 None => RouteToolState::InProgress {
289 start,
290 waypoints,
291 head: pos,
292 },
293 }
294 }
295 _ => RouteToolState::Proposed {
296 start,
297 waypoints,
298 finish,
299 },
300 },
301 }
302 };
303
304 self.update_preview(data);
305 self.render(data, painter);
306 // A committed route is inserted after this frame's canvas was already
307 // drawn (display runs at the top of `widget`), so without a nudge nothing
308 // repaints to show it until the next stray input. Request one frame so the
309 // new route — and any pin it created — appears immediately.
310 let committed = was_routing && !cancelled && matches!(self.state, RouteToolState::Idle);
311 if committed {
312 painter.request_repaint();
313 }
314 // A route started from the select tool returns there once it lands in
315 // Idle — whether by completing (commit) or cancelling.
316 if self.return_to_select && was_routing && matches!(self.state, RouteToolState::Idle) {
317 return Some(Transition::SwitchTool(SelectTool.into()));
318 }
319 None
320 }
321}
322
323impl RouteTool {
324 /// Start routing immediately from `start`, with the route head at `head`:
325 /// the caller hands off mid-gesture and the in-progress handling carries
326 /// the same gesture to a connecting anchor.
327 pub fn routing_from(start: PinId, head: Pos2) -> Self {
328 Self {
329 state: RouteToolState::InProgress {
330 start,
331 waypoints: Vec::new(),
332 head,
333 },
334 preview_path: Vec::new(),
335 preview_cache: None,
336 return_to_select: false,
337 }
338 }
339
340 /// Like [`Self::routing_from`], but completing or cancelling the route returns
341 /// to the select tool. Used when a route is started from a select-tool hover
342 /// target.
343 pub fn routing_from_select(start: PinId, head: Pos2) -> Self {
344 Self {
345 return_to_select: true,
346 ..Self::routing_from(start, head)
347 }
348 }
349
350 /// Build and add the route from `start` to `finish` through `waypoints` and
351 /// return to Idle. A [`FinishTarget::NewPin`] finish reconstructs its pin
352 /// first, in this same frame, so the pin and route collapse into one undo
353 /// step (state is snapshotted once per frame). A degenerate (zero-length)
354 /// route is rejected, leaving the proposal in place — and, importantly, no
355 /// pin is created in that case.
356 fn commit(
357 &mut self,
358 data: &mut Drawing,
359 start: PinId,
360 finish: FinishTarget,
361 waypoints: Vec<Waypoint>,
362 ) -> RouteToolState {
363 let Some(start_pos) = data.anchor(start) else {
364 return RouteToolState::Proposed {
365 start,
366 waypoints,
367 finish,
368 };
369 };
370 let Some(end_pos) = Self::finish_pos(data, &finish) else {
371 return RouteToolState::Proposed {
372 start,
373 waypoints,
374 finish,
375 };
376 };
377 if start_pos == end_pos {
378 return RouteToolState::Proposed {
379 start,
380 waypoints,
381 finish,
382 };
383 }
384 // Only now resolve the finish — a destination the wire drew onto a free
385 // slot stamps its pin in the same commit as the wire, so a rejected
386 // (degenerate) route never leaves an orphan pin.
387 let destination = match finish {
388 FinishTarget::Anchor(anchor) => crate::edit::create::RouteEnd::Pin(anchor),
389 FinishTarget::NewPin(SlotMarker { block, loc, .. }) => {
390 // A stale target can still name a block that has since been
391 // locked, and stamping a pin on one is a material edit — so
392 // the scope has to hand out the proof before the wire can
393 // carry a fresh pin at all.
394 let Some(owner) = data.unlocked_scope(block) else {
395 return RouteToolState::Proposed {
396 start,
397 waypoints,
398 finish,
399 };
400 };
401 crate::edit::create::RouteEnd::Fresh(crate::edit::create::NewPin {
402 id: data.mint(),
403 owner,
404 slot: blockworx_doc::geometry::PinSlot {
405 side: loc.side,
406 offset: crate::grid::pin_slot(loc.offset),
407 },
408 })
409 }
410 };
411 let path = route_in_progress(
412 data,
413 snap_to_grid(start_pos),
414 &waypoints,
415 snap_to_grid(end_pos),
416 );
417 // The wire is stored as its corner list, so the solved polyline is
418 // promoted before it lands — the "every corner is a waypoint"
419 // invariant holds from the moment the route exists.
420 let mut geometry = crate::widget::auto_route::geometry_from_points(&path);
421 let corners =
422 crate::widget::reconstruct::promote_corners_to_waypoints(&waypoints, &mut geometry);
423 data.add_route(start, destination, corners);
424 self.preview_path.clear();
425 RouteToolState::Idle
426 }
427
428 /// Resolve a finish target's world position. An existing anchor resolves
429 /// through `data`; a new-pin target reports the slot's prospective connection
430 /// point directly (the pin doesn't exist yet).
431 fn finish_pos(data: &Drawing, finish: &FinishTarget) -> Option<Pos2> {
432 match finish {
433 FinishTarget::Anchor(anchor) => data.anchor(*anchor),
434 FinishTarget::NewPin(marker) => Some(marker.center),
435 }
436 }
437
438 /// Resolve the cursor at `pos` to a finish target during routing: an existing
439 /// anchor (other than `start`) takes priority; failing that, the free slot's
440 /// marker a press there would land on — the same one the "(+)" and the
441 /// add-pin tool commit on, so no two tools disagree about where it is.
442 fn finish_at(data: &Drawing, start: PinId, pos: Pos2) -> Option<FinishTarget> {
443 match data.anchor_at_pos(pos) {
444 Some(anchor) if anchor != start => Some(FinishTarget::Anchor(anchor)),
445 Some(_) => None,
446 None => marker_at(&markers_in_scope(data), pos).map(FinishTarget::NewPin),
447 }
448 }
449
450 fn update_preview(&mut self, data: &mut Drawing) {
451 // Pull the routing request out as owned data first, so the `self.state`
452 // borrow ends before `preview_route` mutably borrows `self` (the cache).
453 // Only InProgress/Proposed route; idle/hover frames do no routing.
454 let req: Option<(PinId, Vec<Waypoint>, Pos2)> = match &self.state {
455 RouteToolState::InProgress {
456 start,
457 waypoints,
458 head,
459 } => Some((*start, waypoints.clone(), *head)),
460 RouteToolState::Proposed {
461 start,
462 waypoints,
463 finish,
464 } => Self::finish_pos(data, finish).map(|end| (*start, waypoints.clone(), end)),
465 _ => None,
466 };
467 self.preview_path = if let Some((start, waypoints, end)) = req {
468 if let Some(start_pos) = data.anchor(start) {
469 self.preview_route(
470 data,
471 &PreviewRequest {
472 start,
473 start_pos: snap_to_grid(start_pos),
474 waypoints: &waypoints,
475 end: snap_to_grid(end),
476 },
477 )
478 } else {
479 self.preview_cache = None;
480 Vec::new()
481 }
482 } else {
483 // Gesture ended (idle/hover) — drop the cache so a new route (or a
484 // route drawn after this one commits) rebuilds against fresh state.
485 self.preview_cache = None;
486 Vec::new()
487 };
488 }
489
490 /// Route the in-progress route `start → waypoints → end` for the live preview,
491 /// reusing the cached router. `start_pos`/`end` are grid-snapped. The router is
492 /// (re)built only when its fixed inputs change — a different `start` (new route)
493 /// or a different waypoint count (a waypoint was added); the moving cursor
494 /// (`end`) does not change the graph and is routed via the existing L-path
495 /// fallback. Routes read-only (`SelfCost::Skip` semantics) so the cache never
496 /// accumulates the in-progress route's own occupancy between frames.
497 fn preview_route(&mut self, data: &Drawing, gesture: &PreviewRequest<'_>) -> Vec<TaggedPoint> {
498 let PreviewRequest {
499 start,
500 start_pos,
501 waypoints,
502 end,
503 } = *gesture;
504 let stale = match &self.preview_cache {
505 Some(c) => c.start != start || c.waypoint_count != waypoints.len(),
506 None => true,
507 };
508 if stale {
509 // Seed the fixed part of the in-progress route (start + waypoints) but
510 // NOT the moving head. Existing routes' occupancy is baked in by
511 // `scratch_closed_router`, so the preview still avoids existing wires.
512 let mut seeds: Vec<Point> = Vec::with_capacity(waypoints.len() + 1);
513 seeds.push(start_pos.into());
514 seeds.extend(waypoints.iter().map(waypoint_point));
515 let mut router = data.scratch_closed_router(&seeds);
516 let (wp_ids, wp_positions) = waypoint_nodes(waypoints);
517 // Route the fixed start→…→last-waypoint legs once; only the head leg
518 // is recomputed per frame below.
519 let fixed = route_fixed_legs(&mut router, &wp_positions, start_pos.into(), &wp_ids);
520 self.preview_cache = Some(Box::new(PreviewCache {
521 start,
522 waypoint_count: waypoints.len(),
523 router,
524 fixed,
525 }));
526 }
527 let Some(cache) = self.preview_cache.as_mut() else {
528 return Vec::new();
529 };
530 route_to_head(&mut cache.router, &cache.fixed, end.into())
531 }
532
533 /// While a route is in progress, surface the nearest block's new-pin markers
534 /// so the route can terminate on a not-yet-existent pin.
535 fn draw_new_pin_markers<C: Canvas>(data: &Drawing, painter: &mut Style<'_, C>) {
536 let Some(pointer) = Self::world_pointer(painter) else {
537 return;
538 };
539 let markers = nearest_block_markers(&markers_in_scope(data), pointer);
540 draw_route_markers(&markers, pointer, painter);
541 }
542
543 fn render<C: Canvas>(&self, data: &Drawing, painter: &mut Style<'_, C>) {
544 if matches!(
545 self.state,
546 RouteToolState::InProgress { .. } | RouteToolState::Proposed { .. }
547 ) {
548 Self::draw_new_pin_markers(data, painter);
549 }
550 match &self.state {
551 // Pre-routing: show the same animated anchor target as the select tool.
552 RouteToolState::Idle | RouteToolState::PinHeadHovered { .. } => {
553 Self::draw_hover_anchor_target(data, painter);
554 }
555 RouteToolState::InProgress {
556 start, waypoints, ..
557 } => {
558 let pts: Vec<Pos2> = self.preview_path.iter().map(|p| p.pos.into()).collect();
559 render_path_with_chamfered_corners(&pts)
560 .render(painter, (0.5, Role::RouteInProgress));
561 for wp in waypoints {
562 painter.circle_filled(px_point(wp.pos), PORT_RADIUS, Role::RouteInProgress);
563 }
564 Self::draw_end_target(data, *start, painter);
565 }
566 RouteToolState::Proposed {
567 start, waypoints, ..
568 } => {
569 let pts: Vec<Pos2> = self.preview_path.iter().map(|p| p.pos.into()).collect();
570 render_path_with_chamfered_corners(&pts)
571 .render(painter, (1.5, Role::RouteInProgress));
572 // Mark where the route started; the end is shown by the grow-out
573 // target below (for an existing anchor) or the new-pin marker.
574 if let Some(pos) = data.anchor(*start) {
575 painter.circle(
576 pos,
577 PORT_RADIUS,
578 Role::RouteProposedEndpoint,
579 (0.5, Role::RouteProposedEndpoint),
580 );
581 }
582 for wp in waypoints {
583 painter.circle_filled(px_point(wp.pos), PORT_RADIUS, Role::RouteInProgress);
584 }
585 Self::draw_end_target(data, *start, painter);
586 }
587 }
588 }
589
590 /// Grow the green route-end target on the anchor nearest the cursor (other than
591 /// `start`), mirroring the select tool's start affordance.
592 fn draw_end_target<C: Canvas>(data: &Drawing, start: PinId, painter: &mut Style<'_, C>) {
593 let Some(pointer) = Self::world_pointer(painter) else {
594 return;
595 };
596 route_start::draw_nearest_anchor_target(data, pointer, Some(start), painter);
597 }
598
599 /// Before a route starts, show the same animated anchor target the select tool's
600 /// route-start does, so hovering the route tool over a pin reads identically.
601 fn draw_hover_anchor_target<C: Canvas>(data: &Drawing, painter: &mut Style<'_, C>) {
602 let Some(pointer) = Self::world_pointer(painter) else {
603 return;
604 };
605 route_start::draw_nearest_anchor_target(data, pointer, None, painter);
606 }
607
608 fn world_pointer<C: Canvas>(painter: &Style<'_, C>) -> Option<Pos2> {
609 // Through the painter's pointer seam, so a scripted session's hover
610 // affordances follow the demo cursor, not the user's mouse.
611 painter.pointer_world()
612 }
613}