Skip to main content

blockworx/tools/
edit_route.rs

1use crate::render::{
2    RouteRenderMode, render_path_with_chamfered_corners, render_route_label_at,
3    render_text_anchors, render_waypoint_handles, route_text_color,
4};
5use crate::theme::Style;
6use blockworx_doc::id::RouteId;
7use blockworx_geom::{Pos2, vec2};
8use blockworx_paint::{Canvas, Event, Interaction};
9
10use crate::{
11    grid::{GRID_SIZE, HIT_RADIUS, PORT_RADIUS, grid_point, px_point, snap_to_grid},
12    presentation::{RouteDirection, RouteEdge},
13    theme::Role,
14    tools::{
15        names::ToolName,
16        tool::{Action, Deletable, Supposing, ToolTrait},
17    },
18    widget::{
19        DrawingPasses,
20        auto_route::{RouteGeometry, hit_waypoint, text_anchors},
21        drawing::Drawing,
22        edge::RouteEdgeExt,
23        routing::RouteEditSession,
24    },
25};
26
27pub enum EditRoute {
28    Idle,
29    Hovered {
30        id: RouteId,
31    },
32    Selected {
33        id: RouteId,
34        /// Where the route was last picked/edited, in world space. The selection
35        /// overlay anchors here so it opens next to the click rather than at the
36        /// route's bounding-box corner.
37        anchor: Pos2,
38    },
39    /// While dragging an edge, `start_cursor`/`end_cursor` accumulate the raw
40    /// (sub-grid) drag deltas. The working corner positions are snapped each
41    /// frame from these cursors, so small per-frame deltas don't get rounded
42    /// to zero before they accumulate past a grid step.
43    DraggingEdge {
44        id: RouteId,
45        direction: RouteDirection,
46        start_cursor: Pos2,
47        end_cursor: Pos2,
48        /// The edit plan for the two boundary corners, built lazily on the
49        /// first `Dragging` frame — after the select→drag tool switch and its
50        /// re-route, which would otherwise invalidate a plan made earlier.
51        /// `None` until then. While it lives, the document is never written:
52        /// the drag previews into derived state and commits once on release.
53        session: Option<RouteEditSession>,
54    },
55    DraggingWaypoint {
56        id: RouteId,
57        cursor: Pos2,
58        /// The edit plan for the dragged corner, built lazily on the first
59        /// `Dragging` frame (see `DraggingEdge::session`). `None` until then.
60        session: Option<RouteEditSession>,
61    },
62}
63
64impl ToolTrait for EditRoute {
65    fn name(&self) -> ToolName {
66        ToolName::EditRoute
67    }
68
69    fn selection(&self) -> Option<Deletable> {
70        match self {
71            EditRoute::Selected { id, .. } => Some(Deletable::Route(*id)),
72            _ => None,
73        }
74    }
75
76    fn overlay_anchor(&self) -> Option<Pos2> {
77        match self {
78            EditRoute::Selected { anchor, .. } => Some(*anchor),
79            _ => None,
80        }
81    }
82
83    fn suppose<C: Canvas>(
84        &mut self,
85        data: &mut Drawing,
86        interaction: &Interaction,
87        _painter: &mut Style<'_, C>,
88        phase: &Supposing,
89    ) {
90        self.preview_drag(data, interaction.event, phase);
91    }
92
93    fn widget<C: Canvas>(
94        &mut self,
95        data: &mut Drawing,
96        interaction: &Interaction,
97        painter: &mut Style<'_, C>,
98    ) -> Option<Action> {
99        self.render(data, interaction, painter);
100        match self {
101            EditRoute::Idle => {
102                if let Some(Event::HoverAt(pos)) = interaction.event {
103                    if let Some(id) = data.route_at_pos(pos) {
104                        *self = EditRoute::Hovered { id };
105                    }
106                } else if let Some(Event::Clicked { pos }) = interaction.event
107                    && let Some(id) = data.route_at_pos(pos)
108                {
109                    *self = EditRoute::Selected { id, anchor: pos };
110                }
111            }
112
113            EditRoute::Hovered { id } => {
114                let id = *id;
115                if let Some(Event::HoverAt(pos)) = interaction.event {
116                    match data.route_at_pos(pos) {
117                        Some(new_id) => *self = EditRoute::Hovered { id: new_id },
118                        None => return Some(Action::default()),
119                    }
120                } else if let Some(Event::Clicked { pos }) = interaction.event {
121                    *self = EditRoute::Selected { id, anchor: pos };
122                }
123            }
124
125            EditRoute::Selected { id, .. } => {
126                let id = *id;
127                if interaction.delete_pressed {
128                    return Some(Action::Delete(Deletable::Route(id)));
129                }
130                if let Some(Event::Clicked { pos }) = interaction.event {
131                    // Click another object to select it directly; empty canvas
132                    // deselects. (Clicking this route resolves back to itself.)
133                    return Some(
134                        crate::tools::select_tool::click_to_select(data, pos, painter)
135                            .unwrap_or_default(),
136                    );
137                } else if let Some(Event::DragStarted { pos }) = interaction.event {
138                    // Dragging this route's edge/waypoint edits it; dragging
139                    // anything else starts moving that object straight away.
140                    match start_drag(data, id, pos) {
141                        Some(new_state) => *self = new_state,
142                        None => {
143                            return Some(crate::tools::select_tool::drag_to_move(
144                                data, pos, painter,
145                            ));
146                        }
147                    }
148                }
149            }
150
151            EditRoute::DraggingEdge {
152                id,
153                start_cursor,
154                end_cursor,
155                session,
156                ..
157            } => {
158                if let Some(Event::DragStopped { .. }) = interaction.event {
159                    // Release: the one document write of the whole edit — the
160                    // working corners land locked (user-placed) and the final
161                    // geometry is promoted to stored waypoints. Skip if the drag
162                    // never realized (no `Dragging` frame occurred).
163                    let (id, start, end) = (*id, *start_cursor, *end_cursor);
164                    if let Some(s) = session {
165                        data.commit_route_edit(id, s, &[start, end]);
166                    }
167                    *self = EditRoute::Selected {
168                        id,
169                        anchor: start + (end - start) * 0.5,
170                    };
171                }
172            }
173
174            EditRoute::DraggingWaypoint {
175                id,
176                cursor,
177                session,
178            } => {
179                if let Some(Event::DragStopped { .. }) = interaction.event {
180                    // Release: commit the working corner (locked, user-placed) and
181                    // promote the final geometry. Skip if the drag never realized.
182                    let (id, cursor) = (*id, *cursor);
183                    if let Some(s) = session {
184                        data.commit_route_edit(id, s, &[cursor]);
185                    }
186                    *self = EditRoute::Selected { id, anchor: cursor };
187                }
188            }
189        }
190        None
191    }
192}
193
194impl EditRoute {
195    /// Begin editing route `id` already mid-drag, as if it had been selected and
196    /// the drag started on it. Lets the select/resize tools hand off a drag that
197    /// lands on a route edge, skipping the separate click-to-select step. Returns
198    /// `None` if `pos` isn't on a draggable part of the route.
199    ///
200    /// Borrows the drawing immutably, which is the whole of the "lazy" in
201    /// lazy edge drag: starting a drag *cannot* stage waypoints, because it
202    /// has no way to write. It used to, and the eager plan was then thrown
203    /// away by the select→drag tool switch's re-route; a test guarded that
204    /// until this signature made it unrepresentable.
205    pub(crate) fn drag_from(data: &Drawing, id: RouteId, pos: Pos2) -> Option<Self> {
206        start_drag(data, id, pos)
207    }
208
209    /// Advance a live drag by this frame's `event` and relay the wire through
210    /// its working corners, into derived state only — no autoroute, so the
211    /// segment goes exactly where the user drags it. The plan is built on the
212    /// first frame that actually drags, after the select→drag tool switch has
213    /// re-routed (which would invalidate a plan staged earlier); an event-less
214    /// repaint re-relays the plan it already has.
215    fn preview_drag(&mut self, data: &mut Drawing<'_>, event: Option<Event>, phase: &Supposing) {
216        let dragged = match event {
217            Some(Event::Dragging { delta, .. }) => Some(delta),
218            _ => None,
219        };
220        match self {
221            EditRoute::DraggingEdge {
222                id,
223                direction,
224                start_cursor,
225                end_cursor,
226                session,
227            } => {
228                if dragged.is_some() && session.is_none() {
229                    *session = data.route_edit_session(*id, &[*start_cursor, *end_cursor]);
230                }
231                let Some(s) = session else { return };
232                if let Some(delta) = dragged {
233                    let constrained = match direction {
234                        RouteDirection::Horizontal => vec2(0.0, delta.y),
235                        RouteDirection::Vertical => vec2(delta.x, 0.0),
236                    };
237                    *start_cursor += constrained;
238                    *end_cursor += constrained;
239                }
240                data.preview_route_edit(phase, *id, s, &[*start_cursor, *end_cursor]);
241            }
242            EditRoute::DraggingWaypoint {
243                id,
244                cursor,
245                session,
246            } => {
247                if dragged.is_some() && session.is_none() {
248                    *session = data.route_edit_session(*id, &[*cursor]);
249                }
250                let Some(s) = session else { return };
251                if let Some(delta) = dragged {
252                    *cursor += delta;
253                }
254                data.preview_route_edit(phase, *id, s, &[*cursor]);
255            }
256            _ => {}
257        }
258    }
259
260    fn render<C: Canvas>(
261        &self,
262        data: &Drawing,
263        interaction: &Interaction,
264        painter: &mut Style<'_, C>,
265    ) {
266        match self {
267            EditRoute::Idle => {
268                crate::widget::display::widget(data, interaction, painter);
269            }
270            EditRoute::Hovered { id } => {
271                let id = *id;
272                DrawingPasses::new(data)
273                    .route_mode(move |rid| {
274                        if rid == id {
275                            RouteRenderMode::Highlighted
276                        } else {
277                            RouteRenderMode::Normal
278                        }
279                    })
280                    .draw(painter);
281            }
282            EditRoute::Selected { id, .. } => {
283                render_selected(data, painter, *id);
284            }
285            EditRoute::DraggingEdge {
286                id,
287                start_cursor,
288                end_cursor,
289                session,
290                ..
291            } => match session {
292                Some(session) => render_dragging(
293                    data,
294                    painter,
295                    DragFrame {
296                        id: *id,
297                        session,
298                        cursors: &[*start_cursor, *end_cursor],
299                        grabbed: None,
300                    },
301                ),
302                None => render_selected(data, painter, *id),
303            },
304            EditRoute::DraggingWaypoint {
305                id,
306                cursor,
307                session,
308            } => match session {
309                Some(session) => render_dragging(
310                    data,
311                    painter,
312                    DragFrame {
313                        id: *id,
314                        session,
315                        cursors: &[*cursor],
316                        grabbed: Some(*cursor),
317                    },
318                ),
319                None => render_selected(data, painter, *id),
320            },
321        }
322    }
323}
324
325fn render_selected<C: Canvas>(data: &Drawing, painter: &mut Style<'_, C>, id: RouteId) {
326    let authoring = data.authoring();
327    DrawingPasses::new(data)
328        .route_mode(move |rid| {
329            if rid == id {
330                RouteRenderMode::Selected { authoring }
331            } else {
332                RouteRenderMode::Normal
333            }
334        })
335        .draw(painter);
336}
337
338/// One drag frame's working state, for the overlay: the plan, the cursors
339/// driving the working corners, and — for a waypoint drag — the grabbed
340/// corner that gets its emphasis ring.
341#[derive(Clone, Copy)]
342struct DragFrame<'a> {
343    id: RouteId,
344    session: &'a RouteEditSession,
345    cursors: &'a [Pos2],
346    grabbed: Option<Pos2>,
347}
348
349/// Draw a drag frame: the edited route in `Editing` mode (halo, no stored
350/// decorations — the document's corner set is stale mid-drag), then the
351/// working state on top: the session's hypothetical handles, the text
352/// anchors on the previewed geometry, each label pinned at its captured
353/// drag-start anchor, and the drag's own emphasis (the projected segment
354/// for an edge drag, the grabbed-corner ring for a waypoint drag). Before
355/// the first `Dragging` frame there is no session and the stored set is
356/// still current, so the route draws as plain `Selected` instead.
357fn render_dragging<C: Canvas>(data: &Drawing, painter: &mut Style<'_, C>, frame: DragFrame) {
358    let DragFrame {
359        id,
360        session,
361        cursors,
362        grabbed,
363    } = frame;
364    let cursors = cursors.to_vec();
365    DrawingPasses::new(data)
366        .route_mode(move |rid| {
367            if rid == id {
368                RouteRenderMode::Editing
369            } else {
370                RouteRenderMode::Normal
371            }
372        })
373        .suppress_route_label(id)
374        .overlay(move |painter| {
375            let (Some(wire), Some(geometry)) = (data.auto_route(id), data.route_geometry(id))
376            else {
377                return;
378            };
379            render_waypoint_handles(
380                painter,
381                session
382                    .corners(&wire.route.waypoints, &cursors)
383                    .into_iter()
384                    .map(|(pos, lock)| (px_point(pos), lock)),
385            );
386            render_text_anchors(painter, text_anchors(&wire.labels, geometry));
387            // Pin each label at its drag-start world anchor: re-project the
388            // anchor onto the previewed geometry, exactly as the commit's
389            // re-anchoring will.
390            let color = route_text_color(wire.route, RouteRenderMode::Editing);
391            for &(_, anchor) in session.anchors() {
392                let at = geometry.map_linear_distance_to_position(geometry.distance_along(anchor));
393                render_route_label_at(painter, wire.route, at, color);
394            }
395            if let Some(cursor) = grabbed {
396                // The grabbed corner's ring, over its handle.
397                painter.circle(
398                    px_point(grid_point(cursor)),
399                    PORT_RADIUS,
400                    Role::WaypointFill,
401                    (1.0, Role::ControlHandleFill),
402                );
403            } else {
404                // The dragged segment's projection.
405                let projected: Vec<Pos2> =
406                    geometry.points().into_iter().map(snap_to_grid).collect();
407                let path = render_path_with_chamfered_corners(&projected);
408                path.render(painter, (1.5, Role::EdgeDragPreview));
409            }
410        })
411        .draw(painter);
412}
413
414/// Classify where `pos` lands on route `id` and return the matching lazy drag
415/// state — reading only, staging no document mutation. The edit plan is built
416/// on the drag's first `Dragging` frame (see [`EditRoute::widget`]), after the
417/// select→drag tool switch has re-routed the wire.
418/// Priority: waypoint → corner → edge center.
419fn start_drag(data: &Drawing, id: RouteId, pos: Pos2) -> Option<EditRoute> {
420    if data.authoring().is_withheld() {
421        return None;
422    }
423    let wire = data.auto_route(id)?;
424    let geometry = data.route_geometry(id)?;
425
426    // 1. Existing waypoint.
427    let waypoints = wire.route.waypoints.clone();
428    if let Some(ordinal) = hit_waypoint(&waypoints, pos, HIT_RADIUS.get()) {
429        let wp_pos = waypoints.get(usize::from(ordinal))?.pos;
430        return Some(EditRoute::DraggingWaypoint {
431            id,
432            cursor: px_point(wp_pos),
433            session: None,
434        });
435    }
436
437    // 2. Corner (junction of two edges).
438    if let Some((edge_1, _)) = geometry.hovered_corner(pos) {
439        let corner_pos: Pos2 = geometry.edge(edge_1).map(|e| px_point(e.end))?;
440        return Some(EditRoute::DraggingWaypoint {
441            id,
442            cursor: corner_pos,
443            session: None,
444        });
445    }
446
447    // 3. Edge center — draggable only if two boundary waypoints fit, each at
448    // least a cell from either anchor (a shorter linear segment is not editable).
449    if let Some(edge_id) = geometry.hovered_edge(pos) {
450        let edge = geometry.edge(edge_id)?.clone();
451        if edge.distance(pos).1 <= HIT_RADIUS.get()
452            && let Some((start_cursor, end_cursor)) = boundary_seeds(geometry, &edge)
453        {
454            return Some(EditRoute::DraggingEdge {
455                id,
456                direction: edge.direction(),
457                start_cursor,
458                end_cursor,
459                session: None,
460            });
461        }
462    }
463
464    None
465}
466
467/// Seed positions for an edge drag's two boundary waypoints, or `None` when the
468/// edge can't host them. Every waypoint a drag adds must sit at least one cell
469/// from either route anchor (one on an anchor would overlap the pin), so an edge
470/// end that lands on an anchor — a straight pin-to-pin segment — is pulled one
471/// cell inward along the edge. A segment shorter than three cells leaves no room
472/// for two such waypoints and is not editable. An interior edge's ends are
473/// existing corners, already clear of the anchors, and are used unchanged.
474fn boundary_seeds(geometry: &RouteGeometry, edge: &RouteEdge) -> Option<(Pos2, Pos2)> {
475    let start: Pos2 = px_point(edge.start);
476    let end: Pos2 = px_point(edge.end);
477    let along = end - start;
478    let len = along.length();
479    if len == 0.0 {
480        return None;
481    }
482    let step = along / len * GRID_SIZE; // one cell along the edge
483    let start_seed = if is_anchor(geometry, edge.start) {
484        start + step
485    } else {
486        start
487    };
488    let end_seed = if is_anchor(geometry, edge.end) {
489        end - step
490    } else {
491        end
492    };
493    (clear_of_anchors(geometry, start_seed)
494        && clear_of_anchors(geometry, end_seed)
495        && (end_seed - start_seed).length() >= GRID_SIZE - 0.5)
496        .then_some((start_seed, end_seed))
497}
498
499/// Whether grid point `p` is one of the route's fixed anchors.
500fn is_anchor(geometry: &RouteGeometry, p: blockworx_doc::geometry::GridPoint) -> bool {
501    p == geometry.start_pos || p == geometry.end_pos
502}
503
504/// Whether `pos` sits at least one cell from both anchors — the invariant every
505/// waypoint added during a drag must satisfy (a closer one would overlap a pin).
506fn clear_of_anchors(geometry: &RouteGeometry, pos: Pos2) -> bool {
507    let cell = GRID_SIZE - 0.5;
508    geometry.start_pos().distance(pos) >= cell && geometry.end_pos().distance(pos) >= cell
509}