Skip to main content

blockworx_editor/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, Resolution, 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        reconstruct::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.
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    }
320}
321
322/// Non-destructive closed-router reroute: route through the stored waypoint
323/// skeleton minus `excluded` (a trim or prune the preview *previews* without
324/// applying — see `PreviewExclusions` in the routing module), additionally
325/// skipping inaccessible and position-duplicated corners on a working copy.
326/// The authored route is never touched (locks included: they only matter to
327/// commit-time trims, never to routing). Replaces `geometry` wholesale and
328/// applies the new edges' occupancy to the router in place.
329pub fn reroute_preview_excluding(
330    waypoints: &[Waypoint],
331    geometry: &mut RouteGeometry,
332    ends: Endpoints,
333    excluded: &std::collections::HashSet<PathOrdinal>,
334    router: &mut ClosedRouter,
335) -> Resolution {
336    let mut unique_positions = std::collections::HashSet::new();
337    let mut wp_ids: Vec<PathOrdinal> = Vec::new();
338    let mut wp_positions: BTreeMap<PathOrdinal, Point> = BTreeMap::new();
339    for (index, wp) in waypoints.iter().enumerate() {
340        let id = PathOrdinal::new(index);
341        let pos = wp.pos;
342        if excluded.contains(&id)
343            || !router.is_accessible(Point::from(pos))
344            || !unique_positions.insert(pos)
345        {
346            continue;
347        }
348        wp_ids.push(id);
349        wp_positions.insert(id, Point::from(pos));
350    }
351    let laid = RouteRequest {
352        start: ends.start.into(),
353        end: ends.end.into(),
354        wp_ids: &wp_ids,
355        wp_positions: &wp_positions,
356        self_cost: SelfCost::Apply,
357    }
358    .route(router);
359    *geometry = geometry_from_points(&laid.points);
360    geometry.start_pos = ends.start;
361    geometry.end_pos = ends.end;
362    add_route_cost(
363        router,
364        geometry.edges.iter().map(|(_, edge)| edge),
365        WIRE_COST,
366    );
367    laid.resolution
368}
369
370/// Non-destructive drag preview: reroute as if every stored waypoint were
371/// shifted by `waypoint_delta` grid cells, WITHOUT mutating the stored
372/// waypoints (the group drag is uncommitted). Only `geometry`'s edges and
373/// endpoints are rewritten, and the authored route is untouched.
374/// Routes on a frozen [`ClosedRouter`].
375pub fn reroute_preview_closed(
376    waypoints: &[Waypoint],
377    geometry: &mut RouteGeometry,
378    ends: Endpoints,
379    waypoint_delta: GridVec,
380    router: &mut ClosedRouter,
381) -> Resolution {
382    // Route through an OFFSET COPY of the stored waypoints (never the stored
383    // ones); their offset positions were seeded when the graph was built, so
384    // they resolve to nodes. No filtering — the drag is uncommitted.
385    let offset_positions: BTreeMap<PathOrdinal, Point> = waypoints
386        .iter()
387        .enumerate()
388        .map(|(index, wp)| {
389            (
390                PathOrdinal::new(index),
391                Point::from(wp.pos + waypoint_delta),
392            )
393        })
394        .collect();
395    let wp_ids: Vec<PathOrdinal> = (0..waypoints.len()).map(PathOrdinal::new).collect();
396    let laid = RouteRequest {
397        start: ends.start.into(),
398        end: ends.end.into(),
399        wp_ids: &wp_ids,
400        wp_positions: &offset_positions,
401        self_cost: SelfCost::Apply,
402    }
403    .route(router);
404    let preview = geometry_from_points(&laid.points);
405    add_route_cost(
406        router,
407        preview.edges.iter().map(|(_, edge)| edge),
408        WIRE_COST,
409    );
410    geometry.edges = preview.edges;
411    geometry.start_pos = ends.start;
412    geometry.end_pos = ends.end;
413    laid.resolution
414}
415
416/// The hops each of `routes` draws, in the order given. Each crossing point
417/// resolves to a single orientation and every route crossing it in that
418/// orientation gets a matching hop (see [`compute_crossings`]).
419///
420/// The order must be draw order, since a lone crossing hops whichever wire is
421/// drawn later; and the set must hold every wire through any point whose hop
422/// is wanted, since a busier point hops differently from a lone pair. Any set
423/// the spatial index reports for a rectangle holds both for the points inside
424/// it, which is why hops are computed where the wires are drawn rather than
425/// stored beside them.
426pub fn route_hops<'a>(routes: impl IntoIterator<Item = &'a RouteGeometry>) -> Vec<Vec<Crossing>> {
427    let mut h_edges: Vec<HEdge> = Vec::new();
428    let mut v_edges: Vec<VEdge> = Vec::new();
429    let mut num_routes = 0;
430    for (order, geometry) in routes.into_iter().enumerate() {
431        num_routes = order + 1;
432        for (_, edge) in geometry.iter_edges() {
433            let (s, e) = (edge.start, edge.end);
434            if s.y == e.y && s.x != e.x {
435                h_edges.push(HEdge {
436                    order,
437                    y: s.y,
438                    x_lo: s.x.min(e.x),
439                    x_hi: s.x.max(e.x),
440                });
441            } else if s.x == e.x && s.y != e.y {
442                v_edges.push(VEdge {
443                    order,
444                    x: s.x,
445                    y_lo: s.y.min(e.y),
446                    y_hi: s.y.max(e.y),
447                });
448            }
449        }
450    }
451    compute_crossings(&h_edges, &v_edges, num_routes)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::{
457        Crossing, HEdge, RouteGeometry, VEdge, compute_crossings, label_anchors, reanchored,
458    };
459    use crate::presentation::{
460        RouteDirection, RouteEdge,
461        store::{EdgeId, IdMap, IdMapExt},
462    };
463    use blockworx_doc::{
464        fixtures::route_label_id,
465        geometry::{FracVal, GridPoint},
466        id::RouteLabelId,
467    };
468    use blockworx_geom::{Pos2, pos2};
469
470    /// A geometry of one straight edge, as an edit's re-reconstruct would
471    /// leave it.
472    fn single_edge_geometry(start: GridPoint, end: GridPoint) -> RouteGeometry {
473        let mut edges: IdMap<EdgeId, RouteEdge> = IdMap::default();
474        edges.insert_value(RouteEdge { start, end });
475        RouteGeometry {
476            edges,
477            start_pos: start,
478            end_pos: end,
479        }
480    }
481
482    /// One label dropped at `pos` on `geometry` — what the wire-label
483    /// emitter would be handed.
484    fn label_at(
485        geometry: &RouteGeometry,
486        pos: Pos2,
487    ) -> (RouteLabelId, Vec<(RouteLabelId, FracVal)>) {
488        let lid = route_label_id(1);
489        (lid, vec![(lid, geometry.distance_along(pos))])
490    }
491
492    #[test]
493    fn a_horizontal_label_keeps_its_x_when_the_route_is_edited() {
494        let g = crate::grid::GRID_SIZE;
495        let geometry = single_edge_geometry(GridPoint { x: 0, y: 0 }, GridPoint { x: 10, y: 0 });
496        let (lid, labels) = label_at(&geometry, pos2(5.0 * g, 0.0));
497        let before = label_anchors(&labels, &geometry);
498        let old_x = before[0].1.x;
499
500        // Edit: shift the horizontal run down three cells (a perpendicular drag).
501        let geometry = single_edge_geometry(GridPoint { x: 0, y: 3 }, GridPoint { x: 10, y: 3 });
502        let after = reanchored(&geometry, &before);
503
504        assert_eq!(after[0].0, lid, "the same label was re-projected");
505        let loc = geometry.map_linear_distance_to_position(after[0].1);
506        assert!(
507            (loc.location.x - old_x).abs() < 0.5,
508            "a horizontal label keeps its X: {} -> {}",
509            old_x,
510            loc.location.x
511        );
512        assert!(
513            (loc.location.y - 3.0 * g).abs() < 0.5,
514            "its Y follows the moved wire"
515        );
516    }
517
518    #[test]
519    fn a_vertical_label_keeps_its_y_when_the_route_is_edited() {
520        let g = crate::grid::GRID_SIZE;
521        let geometry = single_edge_geometry(GridPoint { x: 0, y: 0 }, GridPoint { x: 0, y: 10 });
522        let (_, labels) = label_at(&geometry, pos2(0.0, 5.0 * g));
523        let before = label_anchors(&labels, &geometry);
524        let old_y = before[0].1.y;
525
526        // Edit: shift the vertical run right three cells.
527        let geometry = single_edge_geometry(GridPoint { x: 3, y: 0 }, GridPoint { x: 3, y: 10 });
528        let after = reanchored(&geometry, &before);
529
530        let loc = geometry.map_linear_distance_to_position(after[0].1);
531        assert!(
532            (loc.location.y - old_y).abs() < 0.5,
533            "a vertical label keeps its Y: {} -> {}",
534            old_y,
535            loc.location.y
536        );
537        assert!(
538            (loc.location.x - 3.0 * g).abs() < 0.5,
539            "its X follows the moved wire"
540        );
541    }
542
543    fn h(order: usize, y: i32, x_lo: i32, x_hi: i32) -> HEdge {
544        HEdge {
545            order,
546            y,
547            x_lo,
548            x_hi,
549        }
550    }
551    fn v(order: usize, x: i32, y_lo: i32, y_hi: i32) -> VEdge {
552        VEdge {
553            order,
554            x,
555            y_lo,
556            y_hi,
557        }
558    }
559
560    #[test]
561    fn later_route_hops_with_correct_orientation() {
562        // Route 0 horizontal at y=10 over x 0..20; route 1 vertical at x=10 over y 0..20.
563        let out = compute_crossings(&[h(0, 10, 0, 20)], &[v(1, 10, 0, 20)], 2);
564        assert!(out[0].is_empty(), "earlier route must not hop");
565        assert_eq!(
566            out[1],
567            vec![Crossing {
568                pos: GridPoint { x: 10, y: 10 },
569                orientation: RouteDirection::Vertical,
570            }],
571            "later (vertical) route hops over the earlier one"
572        );
573    }
574
575    #[test]
576    fn hopping_segment_orientation_follows_the_later_route() {
577        // Now the later route owns the horizontal segment.
578        let out = compute_crossings(&[h(1, 10, 0, 20)], &[v(0, 10, 0, 20)], 2);
579        assert!(out[0].is_empty());
580        assert_eq!(
581            out[1],
582            vec![Crossing {
583                pos: GridPoint { x: 10, y: 10 },
584                orientation: RouteDirection::Horizontal,
585            }]
586        );
587    }
588
589    #[test]
590    fn multi_crossing_hops_every_vertical() {
591        // A horizontal route (order 1) crosses two co-linear vertical routes
592        // (orders 0 and 2) at the same point: a multi-crossing, so it resolves
593        // vertical and *both* verticals hop identically over the horizontal.
594        let hop = |order| {
595            (
596                order,
597                vec![Crossing {
598                    pos: GridPoint { x: 10, y: 10 },
599                    orientation: RouteDirection::Vertical,
600                }],
601            )
602        };
603        let out = compute_crossings(&[h(1, 10, 0, 20)], &[v(0, 10, 0, 20), v(2, 10, 0, 20)], 3);
604        assert_eq!(out[hop(0).0], hop(0).1, "vertical route 0 hops");
605        assert_eq!(out[hop(2).0], hop(2).1, "vertical route 2 hops");
606        assert!(out[1].is_empty(), "the horizontal route is hopped over");
607    }
608
609    #[test]
610    fn t_junction_does_not_hop() {
611        // Vertical segment ends exactly on the horizontal line (y_lo == h.y): not
612        // a strict interior crossing, so no hop.
613        let out = compute_crossings(&[h(0, 10, 0, 20)], &[v(1, 10, 10, 20)], 2);
614        assert!(out.iter().all(std::vec::Vec::is_empty));
615    }
616
617    #[test]
618    fn shared_corner_does_not_hop() {
619        // Segments meet at a shared endpoint (a route bend): no hop.
620        let out = compute_crossings(&[h(0, 10, 0, 10)], &[v(1, 10, 10, 20)], 2);
621        assert!(out.iter().all(std::vec::Vec::is_empty));
622    }
623
624    #[test]
625    fn self_crossing_same_route_is_skipped() {
626        // Same store-order index on both segments => ambiguous self-crossing, skipped.
627        let out = compute_crossings(&[h(0, 10, 0, 20)], &[v(0, 10, 0, 20)], 1);
628        assert!(out[0].is_empty());
629    }
630}