Skip to main content

blockworx/widget/
auto_route.rs

1pub use crate::presentation::{Crossing, RouteGeometry};
2/// The derived geometry types, re-exported here for the widget layer. The
3/// routing behaviour that produces a [`RouteGeometry`] lives below; the
4/// authored wire is the document's own [`blockworx_doc::block_model::Route`].
5use blockworx_geom::{Pos2, Rect, vec2};
6use blockworx_paint::Renderer;
7use blockworx_router::{ClosedRouter, WIRE_COST, point::Point};
8
9use std::collections::BTreeMap;
10
11use blockworx_doc::{
12    document::{IndexedDocument, chronological},
13    geometry::{FracVal, GridPoint, GridVec, Waypoint},
14    id::{RouteId, RouteLabelId},
15};
16
17use crate::{
18    edit::create::PathOrdinal,
19    grid::{SHIM, px_point},
20    presentation::{
21        RouteDirection,
22        store::{IdMap, IdMapExt},
23    },
24    theme::Style,
25    widget::{
26        edge::RouteEdge,
27        materialize::Endpoints,
28        segmentkind::SegmentKind,
29        waypoint_router::{RouteRequest, SelfCost, TaggedPoint, add_route_cost},
30    },
31};
32
33/// A horizontal route segment, tagged with the store-order index of the route
34/// that owns it. Used only by `compute_crossings`.
35#[derive(Clone, Copy, Debug)]
36pub struct HEdge {
37    pub order: usize,
38    pub y: i32,
39    pub x_lo: i32,
40    pub x_hi: i32,
41}
42
43/// A vertical route segment, tagged with the store-order index of the route
44/// that owns it. Used only by `compute_crossings`.
45#[derive(Clone, Copy, Debug)]
46pub struct VEdge {
47    pub order: usize,
48    pub x: i32,
49    pub y_lo: i32,
50    pub y_hi: i32,
51}
52
53/// Every route order (store index) passing through one crossing *point* on each
54/// axis. The set sizes both count the routes at the point and identify them, so
55/// a `1`/`1` site is a lone H×V pair and anything busier is a multi-crossing.
56#[derive(Default)]
57struct CrossingSite {
58    h_routes: std::collections::BTreeSet<usize>,
59    v_routes: std::collections::BTreeSet<usize>,
60}
61
62/// Pure geometry core of crossing detection (no `Block`/`Store` dependency so
63/// it can be unit-tested directly). Returns, per route store-order index, the
64/// hops that route should draw. A hop is recorded only for a *strict* interior
65/// crossing of a horizontal and a vertical segment (so shared endpoints,
66/// corners and T-junctions never hop).
67///
68/// A crossing *point* is resolved to a single orientation so the wires there
69/// jump consistently, then *every* route crossing in that orientation draws the
70/// same hop (all the verticals bump identically over the horizontals, say):
71/// a lone H×V pair hops the later route (render order — the one drawn on top),
72/// and any busier point always hops vertically.
73pub fn compute_crossings(
74    h_edges: &[HEdge],
75    v_edges: &[VEdge],
76    num_routes: usize,
77) -> Vec<Vec<Crossing>> {
78    use blockworx_router::event::{Event, EventSense};
79    use std::collections::BTreeMap;
80    use std::ops::Bound;
81
82    // Pass 1: gather the routes meeting at each crossing point, grouped by axis.
83    let mut sites: BTreeMap<(i32, i32), CrossingSite> = BTreeMap::new();
84
85    // Sweep in x using the shared `Event` primitive (the same one the router's
86    // `collect_intersections` uses). A horizontal edge is active over its
87    // `[x_lo, x_hi]` (Enter/Exit carry its index); a vertical edge at `x = v.x`
88    // (Scan carries its index) queries the active horizontals whose row `y` lies
89    // strictly in `(v.y_lo, v.y_hi)`. `Event` orders Enter < Scan < Exit at equal
90    // `x`, so a horizontal touching `v.x` at a boundary is still active during the
91    // scan — but the strict `x`/`y` tests below exclude it, so only strict interior
92    // crossings are reported.
93    let mut events: Vec<Event<i32, usize>> = Vec::with_capacity(h_edges.len() * 2 + v_edges.len());
94    for (i, h) in h_edges.iter().enumerate() {
95        events.push(Event::enter(h.x_lo, i));
96        events.push(Event::exit(h.x_hi, i));
97    }
98    for (j, v) in v_edges.iter().enumerate() {
99        events.push(Event::scan(v.x, j));
100    }
101    events.sort();
102
103    // Active horizontal edges, keyed by row `y` → indices into `h_edges`.
104    let mut active: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
105    for event in events {
106        match event.sense() {
107            EventSense::Enter => {
108                let i = event.cost();
109                active.entry(h_edges[i].y).or_default().push(i);
110            }
111            EventSense::Exit => {
112                let i = event.cost();
113                if let Some(row) = active.get_mut(&h_edges[i].y) {
114                    if let Some(k) = row.iter().position(|&e| e == i) {
115                        row.swap_remove(k);
116                    }
117                    if row.is_empty() {
118                        active.remove(&h_edges[i].y);
119                    }
120                }
121            }
122            EventSense::Scan => {
123                let v = &v_edges[event.cost()];
124                for (_y, row) in active.range((Bound::Excluded(v.y_lo), Bound::Excluded(v.y_hi))) {
125                    for &i in row {
126                        let h = &h_edges[i];
127                        // Equal order == same route self-crossing: skip.
128                        if h.x_lo < v.x && v.x < h.x_hi && h.order != v.order {
129                            let site = sites.entry((v.x, h.y)).or_default();
130                            site.h_routes.insert(h.order);
131                            site.v_routes.insert(v.order);
132                        }
133                    }
134                }
135            }
136        }
137    }
138
139    // Pass 2: pick each point's orientation and hand the hop to every route that
140    // crosses it in that orientation.
141    let mut out = vec![Vec::new(); num_routes];
142    for ((x, y), site) in sites {
143        let pos = GridPoint { x, y };
144        // A lone H×V pair hops whichever route was drawn later; anything busier
145        // always hops the verticals.
146        let lone_pair = site.v_routes.iter().next().zip(site.h_routes.iter().next());
147        let vertical = match lone_pair {
148            Some((v, h)) if site.h_routes.len() == 1 && site.v_routes.len() == 1 => v > h,
149            _ => true,
150        };
151        let (orientation, hoppers) = if vertical {
152            (RouteDirection::Vertical, site.v_routes)
153        } else {
154            (RouteDirection::Horizontal, site.h_routes)
155        };
156        for order in hoppers {
157            out[order].push(Crossing { pos, orientation });
158        }
159    }
160    out
161}
162
163/// A wire as the layers below the document read it: the authored route
164/// with the labels hanging off it, which are entities of their own — the
165/// bundling twin of `BlockShape`'s pins. Resolved once per borrow, so a
166/// renderer, a hit test, and a relayout cannot disagree about which
167/// labels a wire carries.
168pub struct Wire<'a> {
169    pub route: &'a blockworx_doc::block_model::Route,
170    pub labels: Vec<(RouteLabelId, FracVal)>,
171}
172
173/// A wire's name labels as the geometry layer reads them: the entity to
174/// write back to, and the arc length along the solved polyline it sits at.
175/// Resolved through the route index in the document's one draw order, so
176/// hit-testing, rendering, and re-anchoring all walk the same list.
177pub fn route_labels(indexed: &IndexedDocument<'_>, route: RouteId) -> Vec<(RouteLabelId, FracVal)> {
178    let Some(entry) = indexed.index.routes.get(&route) else {
179        return Vec::new();
180    };
181    let held: Vec<(RouteLabelId, &blockworx_doc::block_model::RouteLabel)> = entry
182        .labels
183        .iter()
184        .filter_map(|&id| Some((id, indexed.doc.route_label(&id)?)))
185        .collect();
186    chronological(held.iter().copied())
187        .into_iter()
188        .filter_map(|id| {
189            let label = indexed.doc.route_label(&id)?;
190            Some((id, label.pos))
191        })
192        .collect()
193}
194
195/// Which corner of a route sits within `tolerance` of `pos` — the grab a
196/// route edit starts from. Waypoints are positional, so the answer is the
197/// ordinal the edit session and the emitters both speak.
198pub fn hit_waypoint(waypoints: &[Waypoint], pos: Pos2, tolerance: f32) -> Option<PathOrdinal> {
199    waypoints.iter().enumerate().find_map(|(index, wp)| {
200        (px_point(wp.pos).distance(pos) <= tolerance).then(|| PathOrdinal::new(index))
201    })
202}
203
204/// The label whose drawn box contains `hover_pos`. `name` is the wire's own
205/// name — an unnamed wire measures its faint placeholder instead, so the
206/// prompt stays clickable (matching what the renderer draws there).
207pub fn hit_text_anchor<R: Renderer>(
208    name: &str,
209    labels: &[(RouteLabelId, FracVal)],
210    geometry: &RouteGeometry,
211    hover_pos: Pos2,
212    painter: &Style<'_, R>,
213) -> Option<RouteLabelId> {
214    let text = if name.is_empty() {
215        crate::render::ADD_ROUTE_LABEL_PLACEHOLDER
216    } else {
217        name
218    };
219    let measured = painter.text_size(text, &painter.theme().route_font);
220    labels.iter().find_map(|&(lid, dist)| {
221        let pos_and_direction = geometry.map_linear_distance_to_position(dist);
222        let center_of_label = pos_and_direction.location
223            + match pos_and_direction.direction {
224                RouteDirection::Horizontal => vec2(0.0, -measured.y / 2.0 - SHIM / 4.0),
225                RouteDirection::Vertical => vec2(measured.y / 2.0 + SHIM / 4.0, 0.0),
226            };
227        let label_size = match pos_and_direction.direction {
228            RouteDirection::Horizontal => measured,
229            RouteDirection::Vertical => vec2(measured.y, measured.x),
230        };
231        Rect::from_center_size(center_of_label, label_size)
232            .contains(hover_pos)
233            .then_some(lid)
234    })
235}
236
237/// Where each label sits on the wire — the diamonds a selected route draws.
238pub fn text_anchors(labels: &[(RouteLabelId, FracVal)], geometry: &RouteGeometry) -> Vec<Pos2> {
239    labels
240        .iter()
241        .map(|&(_, dist)| geometry.map_linear_distance_to_position(dist).location)
242        .collect()
243}
244
245/// Each label's current world position, captured before a relayout so
246/// [`reanchored`] can put it back where it was instead of letting it slide
247/// with the arc length.
248pub fn label_anchors(
249    labels: &[(RouteLabelId, FracVal)],
250    geometry: &RouteGeometry,
251) -> Vec<(RouteLabelId, Pos2)> {
252    labels
253        .iter()
254        .map(|&(lid, dist)| (lid, geometry.map_linear_distance_to_position(dist).location))
255        .collect()
256}
257
258/// Each captured anchor re-projected onto the *current* geometry — the
259/// arc lengths a route edit commits. Re-projecting the same screen point
260/// keeps a horizontal label at the same X and a vertical one at the same Y
261/// (the perpendicular foot shares that coordinate).
262pub fn reanchored(
263    geometry: &RouteGeometry,
264    anchors: &[(RouteLabelId, Pos2)],
265) -> Vec<(RouteLabelId, FracVal)> {
266    anchors
267        .iter()
268        .map(|&(lid, pos)| (lid, geometry.distance_along(pos)))
269        .collect()
270}
271
272/// Solve the router's point list into edge geometry: axis-aligned edges
273/// merged while collinear within one segment (two edges from different
274/// waypoint legs never merge, even collinear), endpoints from the first
275/// and last point, crossings empty until the post-routing pass.
276pub fn geometry_from_points(points: &[TaggedPoint]) -> RouteGeometry {
277    let mut edges: Vec<(RouteEdge, SegmentKind)> = Vec::new();
278    for windows in points.windows(2) {
279        let start = windows[0];
280        let end = windows[1];
281        if start.segment != end.segment {
282            continue;
283        }
284        edges.push((
285            RouteEdge {
286                start: start.pos.into(),
287                end: end.pos.into(),
288            },
289            start.segment,
290        ));
291    }
292    let start_pos = points
293        .first()
294        .map_or(GridPoint::default(), |p| GridPoint::from(p.pos));
295    let end_pos = points
296        .last()
297        .map_or(GridPoint::default(), |p| GridPoint::from(p.pos));
298    let mut merged_edges = IdMap::default();
299    let mut current_edge: Option<(RouteEdge, SegmentKind)> = None;
300    for (edge, kind) in edges {
301        if let Some((current, current_kind)) = &mut current_edge {
302            if *current_kind == kind && current.direction() == edge.direction() {
303                current.end = edge.end;
304            } else {
305                merged_edges.insert_value(current.clone());
306                current_edge = Some((edge, kind));
307            }
308        } else {
309            current_edge = Some((edge, kind));
310        }
311    }
312    if let Some((current, _)) = current_edge {
313        merged_edges.insert_value(current);
314    }
315    RouteGeometry {
316        edges: merged_edges,
317        start_pos,
318        end_pos,
319        crossings: Vec::new(),
320    }
321}
322
323/// Non-destructive closed-router reroute: route through the stored waypoint
324/// skeleton minus `excluded` (a trim or prune the preview *supposes* without
325/// applying — see `PreviewExclusions` in the routing module), additionally
326/// skipping inaccessible and position-duplicated corners on a working copy.
327/// The authored route is never touched (locks included: they only matter to
328/// commit-time trims, never to routing). Replaces `geometry` wholesale —
329/// crossings cleared until the post-routing pass — and applies the new edges'
330/// occupancy to the router in place.
331pub fn reroute_preview_excluding(
332    waypoints: &[Waypoint],
333    geometry: &mut RouteGeometry,
334    ends: Endpoints,
335    excluded: &std::collections::HashSet<PathOrdinal>,
336    router: &mut ClosedRouter,
337) {
338    let mut unique_positions = std::collections::HashSet::new();
339    let mut wp_ids: Vec<PathOrdinal> = Vec::new();
340    let mut wp_positions: BTreeMap<PathOrdinal, Point> = BTreeMap::new();
341    for (index, wp) in waypoints.iter().enumerate() {
342        let id = PathOrdinal::new(index);
343        let pos = wp.pos;
344        if excluded.contains(&id)
345            || !router.is_accessible(Point::from(pos))
346            || !unique_positions.insert(pos)
347        {
348            continue;
349        }
350        wp_ids.push(id);
351        wp_positions.insert(id, Point::from(pos));
352    }
353    let path = RouteRequest {
354        start: ends.start.into(),
355        end: ends.end.into(),
356        wp_ids: &wp_ids,
357        wp_positions: &wp_positions,
358        self_cost: SelfCost::Apply,
359    }
360    .route(router);
361    *geometry = geometry_from_points(&path);
362    geometry.start_pos = ends.start;
363    geometry.end_pos = ends.end;
364    add_route_cost(
365        router,
366        geometry.edges.iter().map(|(_, edge)| edge),
367        WIRE_COST,
368    );
369}
370
371/// Non-destructive drag preview: reroute as if every stored waypoint were
372/// shifted by `waypoint_delta` grid cells, WITHOUT mutating the stored
373/// waypoints (the group drag is uncommitted). Only `geometry`'s edges and
374/// endpoints are rewritten; the crossings stay as they were (stale until
375/// the commit pass recomputes them), and the authored route is untouched.
376/// Routes on a frozen [`ClosedRouter`].
377pub fn reroute_preview_closed(
378    waypoints: &[Waypoint],
379    geometry: &mut RouteGeometry,
380    ends: Endpoints,
381    waypoint_delta: GridVec,
382    router: &mut ClosedRouter,
383) {
384    // Route through an OFFSET COPY of the stored waypoints (never the stored
385    // ones); their offset positions were seeded when the graph was built, so
386    // they resolve to nodes. No filtering — the drag is uncommitted.
387    let offset_positions: BTreeMap<PathOrdinal, Point> = waypoints
388        .iter()
389        .enumerate()
390        .map(|(index, wp)| {
391            (
392                PathOrdinal::new(index),
393                Point::from(wp.pos + waypoint_delta),
394            )
395        })
396        .collect();
397    let wp_ids: Vec<PathOrdinal> = (0..waypoints.len()).map(PathOrdinal::new).collect();
398    let path = RouteRequest {
399        start: ends.start.into(),
400        end: ends.end.into(),
401        wp_ids: &wp_ids,
402        wp_positions: &offset_positions,
403        self_cost: SelfCost::Apply,
404    }
405    .route(router);
406    let preview = geometry_from_points(&path);
407    add_route_cost(
408        router,
409        preview.edges.iter().map(|(_, edge)| edge),
410        WIRE_COST,
411    );
412    geometry.edges = preview.edges;
413    geometry.start_pos = ends.start;
414    geometry.end_pos = ends.end;
415}
416
417/// Recompute the visual "hop" decorations for every route in `block` from
418/// the routes' *final* geometry. Must run after routing finalizes all
419/// routes (e.g. at the end of `Drawing::solve_routes`). Each crossing
420/// point resolves to a single orientation and every route crossing it in
421/// that orientation gets a matching hop (see [`compute_crossings`]); every
422/// present route's hop list is rewritten, so stale hops are always
423/// cleared. Routes without a geometry entry contribute nothing but still
424/// hold their store-order slot, so the priority order matches the draw
425/// order.
426pub fn recompute_route_crossings(
427    scope_routes: &[RouteId],
428    geometries: &mut ahash::HashMap<RouteId, RouteGeometry>,
429) {
430    let mut h_edges: Vec<HEdge> = Vec::new();
431    let mut v_edges: Vec<VEdge> = Vec::new();
432    let mut num_routes = 0;
433    for (order, id) in scope_routes.iter().enumerate() {
434        num_routes = order + 1;
435        let Some(geometry) = geometries.get(id) else {
436            continue;
437        };
438        for (_, edge) in geometry.iter_edges() {
439            let (s, e) = (edge.start, edge.end);
440            if s.y == e.y && s.x != e.x {
441                h_edges.push(HEdge {
442                    order,
443                    y: s.y,
444                    x_lo: s.x.min(e.x),
445                    x_hi: s.x.max(e.x),
446                });
447            } else if s.x == e.x && s.y != e.y {
448                v_edges.push(VEdge {
449                    order,
450                    x: s.x,
451                    y_lo: s.y.min(e.y),
452                    y_hi: s.y.max(e.y),
453                });
454            }
455        }
456    }
457    let mut per_route = compute_crossings(&h_edges, &v_edges, num_routes);
458    for (order, id) in scope_routes.iter().enumerate() {
459        if let Some(geometry) = geometries.get_mut(id) {
460            geometry.crossings = std::mem::take(&mut per_route[order]);
461        }
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::{
468        Crossing, HEdge, RouteGeometry, VEdge, compute_crossings, label_anchors, reanchored,
469    };
470    use crate::presentation::{
471        RouteDirection, RouteEdge,
472        store::{EdgeId, IdMap, IdMapExt},
473    };
474    use blockworx_doc::{
475        fixtures::route_label_id,
476        geometry::{FracVal, GridPoint},
477        id::RouteLabelId,
478    };
479    use blockworx_geom::{Pos2, pos2};
480
481    /// A geometry of one straight edge, as an edit's re-materialize would
482    /// leave it.
483    fn single_edge_geometry(start: GridPoint, end: GridPoint) -> RouteGeometry {
484        let mut edges: IdMap<EdgeId, RouteEdge> = IdMap::default();
485        edges.insert_value(RouteEdge { start, end });
486        RouteGeometry {
487            edges,
488            start_pos: start,
489            end_pos: end,
490            crossings: Vec::new(),
491        }
492    }
493
494    /// One label dropped at `pos` on `geometry` — what the wire-label
495    /// emitter would be handed.
496    fn label_at(
497        geometry: &RouteGeometry,
498        pos: Pos2,
499    ) -> (RouteLabelId, Vec<(RouteLabelId, FracVal)>) {
500        let lid = route_label_id(1);
501        (lid, vec![(lid, geometry.distance_along(pos))])
502    }
503
504    #[test]
505    fn a_horizontal_label_keeps_its_x_when_the_route_is_edited() {
506        let g = crate::grid::GRID_SIZE;
507        let geometry = single_edge_geometry(GridPoint { x: 0, y: 0 }, GridPoint { x: 10, y: 0 });
508        let (lid, labels) = label_at(&geometry, pos2(5.0 * g, 0.0));
509        let before = label_anchors(&labels, &geometry);
510        let old_x = before[0].1.x;
511
512        // Edit: shift the horizontal run down three cells (a perpendicular drag).
513        let geometry = single_edge_geometry(GridPoint { x: 0, y: 3 }, GridPoint { x: 10, y: 3 });
514        let after = reanchored(&geometry, &before);
515
516        assert_eq!(after[0].0, lid, "the same label was re-projected");
517        let loc = geometry.map_linear_distance_to_position(after[0].1);
518        assert!(
519            (loc.location.x - old_x).abs() < 0.5,
520            "a horizontal label keeps its X: {} -> {}",
521            old_x,
522            loc.location.x
523        );
524        assert!(
525            (loc.location.y - 3.0 * g).abs() < 0.5,
526            "its Y follows the moved wire"
527        );
528    }
529
530    #[test]
531    fn a_vertical_label_keeps_its_y_when_the_route_is_edited() {
532        let g = crate::grid::GRID_SIZE;
533        let geometry = single_edge_geometry(GridPoint { x: 0, y: 0 }, GridPoint { x: 0, y: 10 });
534        let (_, labels) = label_at(&geometry, pos2(0.0, 5.0 * g));
535        let before = label_anchors(&labels, &geometry);
536        let old_y = before[0].1.y;
537
538        // Edit: shift the vertical run right three cells.
539        let geometry = single_edge_geometry(GridPoint { x: 3, y: 0 }, GridPoint { x: 3, y: 10 });
540        let after = reanchored(&geometry, &before);
541
542        let loc = geometry.map_linear_distance_to_position(after[0].1);
543        assert!(
544            (loc.location.y - old_y).abs() < 0.5,
545            "a vertical label keeps its Y: {} -> {}",
546            old_y,
547            loc.location.y
548        );
549        assert!(
550            (loc.location.x - 3.0 * g).abs() < 0.5,
551            "its X follows the moved wire"
552        );
553    }
554
555    fn h(order: usize, y: i32, x_lo: i32, x_hi: i32) -> HEdge {
556        HEdge {
557            order,
558            y,
559            x_lo,
560            x_hi,
561        }
562    }
563    fn v(order: usize, x: i32, y_lo: i32, y_hi: i32) -> VEdge {
564        VEdge {
565            order,
566            x,
567            y_lo,
568            y_hi,
569        }
570    }
571
572    #[test]
573    fn later_route_hops_with_correct_orientation() {
574        // Route 0 horizontal at y=10 over x 0..20; route 1 vertical at x=10 over y 0..20.
575        let out = compute_crossings(&[h(0, 10, 0, 20)], &[v(1, 10, 0, 20)], 2);
576        assert!(out[0].is_empty(), "earlier route must not hop");
577        assert_eq!(
578            out[1],
579            vec![Crossing {
580                pos: GridPoint { x: 10, y: 10 },
581                orientation: RouteDirection::Vertical,
582            }],
583            "later (vertical) route hops over the earlier one"
584        );
585    }
586
587    #[test]
588    fn hopping_segment_orientation_follows_the_later_route() {
589        // Now the later route owns the horizontal segment.
590        let out = compute_crossings(&[h(1, 10, 0, 20)], &[v(0, 10, 0, 20)], 2);
591        assert!(out[0].is_empty());
592        assert_eq!(
593            out[1],
594            vec![Crossing {
595                pos: GridPoint { x: 10, y: 10 },
596                orientation: RouteDirection::Horizontal,
597            }]
598        );
599    }
600
601    #[test]
602    fn multi_crossing_hops_every_vertical() {
603        // A horizontal route (order 1) crosses two co-linear vertical routes
604        // (orders 0 and 2) at the same point: a multi-crossing, so it resolves
605        // vertical and *both* verticals hop identically over the horizontal.
606        let hop = |order| {
607            (
608                order,
609                vec![Crossing {
610                    pos: GridPoint { x: 10, y: 10 },
611                    orientation: RouteDirection::Vertical,
612                }],
613            )
614        };
615        let out = compute_crossings(&[h(1, 10, 0, 20)], &[v(0, 10, 0, 20), v(2, 10, 0, 20)], 3);
616        assert_eq!(out[hop(0).0], hop(0).1, "vertical route 0 hops");
617        assert_eq!(out[hop(2).0], hop(2).1, "vertical route 2 hops");
618        assert!(out[1].is_empty(), "the horizontal route is hopped over");
619    }
620
621    #[test]
622    fn t_junction_does_not_hop() {
623        // Vertical segment ends exactly on the horizontal line (y_lo == h.y): not
624        // a strict interior crossing, so no hop.
625        let out = compute_crossings(&[h(0, 10, 0, 20)], &[v(1, 10, 10, 20)], 2);
626        assert!(out.iter().all(std::vec::Vec::is_empty));
627    }
628
629    #[test]
630    fn shared_corner_does_not_hop() {
631        // Segments meet at a shared endpoint (a route bend): no hop.
632        let out = compute_crossings(&[h(0, 10, 0, 10)], &[v(1, 10, 10, 20)], 2);
633        assert!(out.iter().all(std::vec::Vec::is_empty));
634    }
635
636    #[test]
637    fn self_crossing_same_route_is_skipped() {
638        // Same store-order index on both segments => ambiguous self-crossing, skipped.
639        let out = compute_crossings(&[h(0, 10, 0, 20)], &[v(0, 10, 0, 20)], 1);
640        assert!(out[0].is_empty());
641    }
642}