Skip to main content

blockworx/tools/
route_tool.rs

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