Skip to main content

blockworx/presentation/
route.rs

1//! Solved wire geometry: what the router produced for one route — its
2//! edge polyline, resolved endpoints, and crossing hops. Authored route
3//! state (anchors, waypoints, labels, name, role) stays on the document's
4//! own [`Route`](blockworx_doc::block_model::Route); everything here is
5//! recomputable and never persisted.
6
7use blockworx_doc::geometry::{FracVal, GridPoint};
8use blockworx_geom::{Pos2, Vec2};
9
10use crate::grid::LINE_RADIUS;
11use crate::grid::px_point;
12use crate::presentation::store::{EdgeId, IdMap, IdMapExt};
13use crate::widget::edge::RouteEdgeExt;
14
15/// The orientation of a route segment. Pure data.
16#[derive(Clone, PartialEq, Eq, Hash, Copy, Debug, PartialOrd, Ord)]
17pub enum RouteDirection {
18    Horizontal,
19    Vertical,
20}
21
22/// A single straight (horizontal or vertical) segment of a route, in grid space.
23/// Pure data; the pixel-space helpers (`length`, `distance`) live in the
24/// `RouteEdgeExt` trait in [`crate::widget::edge`].
25#[derive(Clone, PartialEq, Debug)]
26pub struct RouteEdge {
27    pub start: GridPoint,
28    pub end: GridPoint,
29}
30
31impl RouteEdge {
32    /// Whether this segment runs more horizontally or vertically. Pure grid
33    /// arithmetic, so it stays on the data type.
34    pub fn direction(&self) -> RouteDirection {
35        if (self.end.x - self.start.x).abs() > (self.end.y - self.start.y).abs() {
36            RouteDirection::Horizontal
37        } else {
38            RouteDirection::Vertical
39        }
40    }
41}
42
43/// A purely visual decoration marking where this route crosses *over* another
44/// route. The crossing route gets a small semicircular "hop" drawn at `pos`;
45/// `orientation` is the direction of this route's own segment at the crossing
46/// (the bump bulges perpendicular to it). Every route crossing a given point in
47/// the point's chosen orientation carries a matching `Crossing`, so they all hop
48/// identically there. Crossings are recomputed from final geometry after routing
49/// (see [`crate::widget::auto_route::recompute_route_crossings`]) and are
50/// independent of `edges`, so edge-merge simplification never disturbs them.
51#[derive(Clone, Copy, PartialEq, Debug)]
52pub struct Crossing {
53    pub pos: GridPoint,
54    pub orientation: RouteDirection,
55}
56
57/// Where a point on the route sits and which way the wire runs there.
58#[derive(Clone, Copy, Debug)]
59pub struct LocAndDirection {
60    pub location: Pos2,
61    pub direction: RouteDirection,
62}
63
64#[derive(Default, Debug, Clone, PartialEq)]
65pub struct RouteGeometry {
66    pub edges: IdMap<EdgeId, RouteEdge>,
67    pub start_pos: GridPoint,
68    pub end_pos: GridPoint,
69    pub crossings: Vec<Crossing>,
70}
71
72impl RouteGeometry {
73    pub fn edge(&self, edge_index: EdgeId) -> Option<&RouteEdge> {
74        self.edges.get(&edge_index)
75    }
76
77    pub fn iter_edges(&self) -> impl Iterator<Item = (EdgeId, &RouteEdge)> {
78        self.edges.iter().map(|(&k, v)| (k, v))
79    }
80
81    pub fn start_pos(&self) -> Pos2 {
82        px_point(self.start_pos)
83    }
84
85    pub fn end_pos(&self) -> Pos2 {
86        px_point(self.end_pos)
87    }
88
89    /// The polyline's corner sequence: the resolved start, then each
90    /// edge's end (no duplicated interior points).
91    pub fn points(&self) -> Vec<Pos2> {
92        let mut points: Vec<Pos2> = Vec::new();
93        points.push(px_point(self.start_pos));
94        for (_, edge) in self.iter_edges() {
95            points.push(px_point(edge.end));
96        }
97        points
98    }
99
100    pub fn hovered_corner(&self, hover_pos: Pos2) -> Option<(EdgeId, EdgeId)> {
101        self.edges.windows(2).find_map(|edges| {
102            let (edge_id1, edge1) = edges[0];
103            let (edge_id2, edge2) = edges[1];
104            if px_point(edge1.end).distance(hover_pos) <= LINE_RADIUS
105                && edge1.direction() != edge2.direction()
106            {
107                Some((edge_id1, edge_id2))
108            } else {
109                None
110            }
111        })
112    }
113
114    pub fn hovered_edge(&self, hover_pos: Pos2) -> Option<EdgeId> {
115        self.iter_edges().find_map(|(eid, edge)| {
116            if edge.distance(hover_pos).1 <= LINE_RADIUS
117                && px_point(edge.start).distance(hover_pos) > LINE_RADIUS
118                && px_point(edge.end).distance(hover_pos) > LINE_RADIUS
119            {
120                Some(eid)
121            } else {
122                None
123            }
124        })
125    }
126
127    /// Perpendicular distance from `hover_pos` to the nearest point on the
128    /// route, or `None` if the point isn't within the hit radius (or sits
129    /// on a terminal endpoint, which is left to the pin under it). Unlike
130    /// [`hovered_edge`](Self::hovered_edge) this keeps interior corners
131    /// hoverable. Lets a caller pick the *closest* route among several in
132    /// range.
133    pub fn hovered_edge_distance(&self, hover_pos: Pos2) -> Option<f32> {
134        // Only the route's two terminal endpoints are excluded, so a hover near a
135        // pin falls through to it. Interior corners are NOT excluded: the segment
136        // distance already clamps to the bend, so a corner stays hoverable instead
137        // of sitting in a dead zone where both adjacent edges reject it.
138        if self.start_pos().distance(hover_pos) <= LINE_RADIUS
139            || self.end_pos().distance(hover_pos) <= LINE_RADIUS
140        {
141            return None;
142        }
143        self.iter_edges()
144            .filter_map(|(_, edge)| {
145                let perp = edge.distance(hover_pos).1;
146                (perp <= LINE_RADIUS).then_some(perp)
147            })
148            .min_by(f32::total_cmp)
149    }
150
151    // Convert a distance along the route to a point on the route. This is
152    // the inverse of `distance_along`. If the distance is out of range,
153    // take the end anchor.
154    pub fn map_linear_distance_to_position(&self, linear_distance: FracVal) -> LocAndDirection {
155        let mut distance: f32 = linear_distance.into();
156        for (_, edge) in self.iter_edges() {
157            if edge.length() < distance {
158                distance -= edge.length();
159            } else {
160                let frac = distance / edge.length();
161                let start: Pos2 = px_point(edge.start);
162                let end: Pos2 = px_point(edge.end);
163                return LocAndDirection {
164                    location: start + frac * (end - start),
165                    direction: edge.direction(),
166                };
167            }
168        }
169        LocAndDirection {
170            location: px_point(self.end_pos),
171            direction: RouteDirection::Horizontal,
172        }
173    }
174
175    /// Slide an arc-length position along the route by a screen-space delta:
176    /// map to world, offset, re-project onto the nearest point of the wire.
177    pub fn slide_along(&self, from: FracVal, delta: Vec2) -> FracVal {
178        let anchor = self.map_linear_distance_to_position(from).location;
179        self.distance_along(anchor + delta)
180    }
181
182    /// The arc length along the route of the nearest point to `pos`.
183    pub fn distance_along(&self, pos: Pos2) -> FracVal {
184        let mut min_distance = f32::INFINITY;
185        let mut accum_distance: f32 = 0.0;
186        let mut min_distance_along: f32 = 0.0;
187        for (_, edge) in self.iter_edges() {
188            let (distance_along_edge, distance_to_point) = edge.distance(pos);
189            if distance_to_point < min_distance {
190                min_distance = distance_to_point;
191                min_distance_along = accum_distance + distance_along_edge;
192            }
193            accum_distance += edge.length();
194        }
195        FracVal::from(min_distance_along)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::grid::GRID_SIZE;
203    use blockworx_geom::vec2;
204
205    /// One straight horizontal run, ten cells long.
206    fn horizontal_wire() -> RouteGeometry {
207        let start = GridPoint { x: 0, y: 0 };
208        let end = GridPoint { x: 10, y: 0 };
209        let mut edges: IdMap<EdgeId, RouteEdge> = IdMap::default();
210        edges.insert_value(RouteEdge { start, end });
211        RouteGeometry {
212            edges,
213            start_pos: start,
214            end_pos: end,
215            crossings: Vec::new(),
216        }
217    }
218
219    #[test]
220    fn sliding_along_the_wire_advances_by_the_delta() {
221        let wire = horizontal_wire();
222        assert_eq!(wire.iter_edges().count(), 1, "one segment to slide along");
223        assert_eq!(
224            wire.start_pos().y,
225            wire.end_pos().y,
226            "the segment is horizontal"
227        );
228        let span = wire.end_pos().x - wire.start_pos().x;
229        assert!(
230            span > 5.0 * GRID_SIZE,
231            "long enough to slide two cells from three cells in without clamping: {span}"
232        );
233
234        let from = FracVal::from(3.0 * GRID_SIZE);
235        let slid = wire.slide_along(from, vec2(2.0 * GRID_SIZE, 0.0));
236
237        assert!(
238            (f32::from(slid) - 5.0 * GRID_SIZE).abs() < 0.01,
239            "sliding {} advanced to {}, expected {}",
240            2.0 * GRID_SIZE,
241            f32::from(slid),
242            5.0 * GRID_SIZE
243        );
244    }
245
246    #[test]
247    fn a_perpendicular_slide_reprojects_onto_the_wire() {
248        let wire = horizontal_wire();
249        let from = FracVal::from(3.0 * GRID_SIZE);
250        let anchor = wire.map_linear_distance_to_position(from).location;
251        assert!(
252            (anchor.y - wire.start_pos().y).abs() < 0.01,
253            "the starting anchor sits on the wire"
254        );
255
256        let slid = wire.slide_along(from, vec2(0.0, 4.0 * GRID_SIZE));
257        let landed = wire.map_linear_distance_to_position(slid).location;
258
259        assert!(
260            (landed.y - wire.start_pos().y).abs() < 0.01,
261            "an off-wire delta re-projects back onto the wire: {landed:?}"
262        );
263        assert!(
264            (landed.x - anchor.x).abs() < 0.01,
265            "and does not move along it: {} -> {}",
266            anchor.x,
267            landed.x
268        );
269    }
270}