Skip to main content

blockworx_editor/widget/
reconstruct.rs

1//! Deriving a route's edge geometry from its ordered corner list.
2//!
3//! Under the explicit-path model a route is fully described by its ordered
4//! corner sequence `[start_pos, waypoint…, end_pos]` — waypoint insertion order
5//! *is* the polyline order. These helpers extract the canonical corner set from
6//! a raw polyline and read a route's ordered corner points; the straight-or-route
7//! reconstruction (which turns those corners back into edges, invoking the
8//! router only for blocked/non-colinear legs) builds on them.
9
10use std::collections::{BTreeMap, HashSet};
11
12use blockworx_doc::geometry::{GridPoint, Waypoint};
13use blockworx_router::block::{Block as ObstacleRect, ROUTE_GUTTER};
14use blockworx_router::{ClosedRouter, Direction, Resolution, WIRE_COST, direction_between};
15
16use crate::presentation::RouteEdge;
17use crate::presentation::RouteGeometry;
18use crate::presentation::store::{EdgeId, IdMap, IdMapExt};
19use crate::widget::waypoint_router::add_route_cost;
20
21/// A route's authored corners in the presentation layer's grid vocabulary —
22/// the one conversion the reconstruction pass makes, so the leg walk below
23/// speaks a single coordinate type.
24fn corner_positions(waypoints: &[Waypoint]) -> Vec<GridPoint> {
25    waypoints.iter().map(|wp| wp.pos).collect()
26}
27
28/// The obstacle rectangles of a block, for the cheap "does a straight wire cross
29/// a block?" test that decides whether a leg can stay straight. Mirrors exactly
30/// the rects `build_closed_router_for_block` feeds to the router as blocks, so a
31/// straight leg judged clear here is the same one the router would leave alone —
32/// but without paying to build the routing graph (the point of the fast load path).
33///
34/// Rects are indexed by the rows and columns they span, so `blocked`/`accessible`
35/// only test the rects that could possibly hit the query line — not all of them.
36/// A linear scan here is quadratic across a whole document's routes (each leg
37/// scans every block), which showed up as ~20s of load overhead at scale.
38pub struct Obstacles {
39    rects: Vec<ObstacleRect>,
40    by_row: BTreeMap<i32, Vec<usize>>,
41    by_col: BTreeMap<i32, Vec<usize>>,
42}
43
44impl Obstacles {
45    pub fn new(rects: Vec<ObstacleRect>) -> Self {
46        let mut by_row: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
47        let mut by_col: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
48        for (i, r) in rects.iter().enumerate() {
49            for y in r.top_left.y.raw()..=r.bottom_right.y.raw() {
50                by_row.entry(y).or_default().push(i);
51            }
52            for x in r.top_left.x.raw()..=r.bottom_right.x.raw() {
53                by_col.entry(x).or_default().push(i);
54            }
55        }
56        Self {
57            rects,
58            by_row,
59            by_col,
60        }
61    }
62
63    /// Whether the axis-aligned wire `a → b` crosses any obstacle. Same result as
64    /// [`ClosedRouter::is_wire_blocked`], but only the rects spanning the wire's
65    /// row (horizontal wire) or column (vertical wire) are tested.
66    pub fn blocked(&self, a: GridPoint, b: GridPoint) -> bool {
67        self.crossings(a, b).next().is_some()
68    }
69
70    /// Each stretch of the axis-aligned wire `a → b` that runs through an
71    /// obstacle — what [`Self::blocked`] refuses, located.
72    pub fn crossings(
73        &self,
74        a: GridPoint,
75        b: GridPoint,
76    ) -> impl Iterator<Item = (GridPoint, GridPoint)> + '_ {
77        let candidates = if a.y == b.y {
78            self.by_row.get(&a.y)
79        } else if a.x == b.x {
80            self.by_col.get(&a.x)
81        } else {
82            None
83        };
84        candidates
85            .into_iter()
86            .flatten()
87            .filter_map(move |&i| self.rects[i].edge_crossing(a, b))
88            .map(|(from, to)| (GridPoint::from(from), GridPoint::from(to)))
89    }
90
91    /// Whether the axis-aligned wire `a → b` hugs any obstacle (runs alongside an
92    /// edge within the routing gutter).
93    ///
94    /// A hugging wire runs *beside* an edge rather than over it, so the index is
95    /// queried a gutter wide either side of the wire: a rect with an edge within
96    /// `ROUTE_GUTTER` of row `y` spans one of the rows `y-gutter ..= y+gutter`,
97    /// and so is in one of their buckets. A rect spanning several of them is
98    /// tested more than once, which costs less than the dedup would.
99    pub fn hugs(&self, a: GridPoint, b: GridPoint) -> bool {
100        let (index, at) = if a.y == b.y {
101            (&self.by_row, a.y)
102        } else if a.x == b.x {
103            (&self.by_col, a.x)
104        } else {
105            return false;
106        };
107        index
108            .range(at - ROUTE_GUTTER..=at + ROUTE_GUTTER)
109            .flat_map(|(_, bucket)| bucket)
110            .any(|&i| self.rects[i].hugs_wire(a, b, ROUTE_GUTTER))
111    }
112
113    /// Whether `p` lies outside every obstacle (a waypoint inside one is dropped).
114    pub fn accessible(&self, p: GridPoint) -> bool {
115        !self
116            .by_row
117            .get(&p.y)
118            .into_iter()
119            .flatten()
120            .any(|&i| self.rects[i].contains(p.into()))
121    }
122}
123
124fn axis_aligned(a: GridPoint, b: GridPoint) -> bool {
125    a.x == b.x || a.y == b.y
126}
127
128/// A route's resolved (snapped) endpoints for one solve or
129/// reconstruction pass.
130#[derive(Clone, Copy)]
131pub struct Endpoints {
132    pub start: GridPoint,
133    pub end: GridPoint,
134}
135
136/// Rebuild the edge geometry of a wire whose authored corners are `stored`,
137/// keeping every straight (axis-aligned, unblocked) leg as a single edge and
138/// routing ONLY the legs that are non-colinear or blocked. `start`/`end` are the
139/// resolved grid endpoints. Straight legs never touch the router, so an
140/// unaffected route (all legs straight) is reconstructed byte-identically with
141/// zero pathfinding.
142///
143/// When a leg needs routing, `router` must be `Some` (already built and occupied
144/// by the other routes); the leg's new bends are spliced into the corner list so
145/// the "every corner is a waypoint" invariant is restored. Interior waypoints
146/// that fall inside an obstacle are dropped (only on the routing path, where
147/// `router` is `Some`). Nothing here writes the document.
148fn reconstruct_legs(
149    stored: &[Waypoint],
150    geometry: &mut RouteGeometry,
151    ends: Endpoints,
152    rules: LegRules<'_>,
153    mut router: Option<&mut ClosedRouter>,
154) -> Reconstruction {
155    let LegRules { obstacles, blocked } = rules;
156    // Ordered corners: start, each waypoint (drop inaccessible ones when
157    // rerouting), then end. Waypoint order is path order.
158    let mut corners: Vec<GridPoint> = Vec::with_capacity(stored.len() + 2);
159    corners.push(ends.start);
160    for pos in corner_positions(stored) {
161        if blocked == BlockedLeg::Reroute && router.is_some() && !obstacles.accessible(pos) {
162            continue;
163        }
164        corners.push(pos);
165    }
166    corners.push(ends.end);
167
168    // Walk the legs, emitting the resulting polyline. Straight legs contribute a
169    // single hop; routed legs contribute their solved sub-path.
170    let mut polyline: Vec<GridPoint> = vec![corners[0]];
171    let mut incoming: Option<Direction> = None;
172    let mut resolution = Resolution::Routed;
173    for pair in corners.windows(2) {
174        let (a, b) = (pair[0], pair[1]);
175        if a == b {
176            continue;
177        }
178        let clear = blocked == BlockedLeg::Keep || !obstacles.blocked(a, b);
179        if axis_aligned(a, b) && clear && !obstacles.hugs(a, b) {
180            polyline.push(b);
181            incoming = direction_between(a.into(), b.into());
182            continue;
183        }
184        let Some(r) = router.as_mut() else {
185            return Reconstruction::NeedsLattice;
186        };
187        let leg = r.route_leg(a.into(), b.into(), incoming);
188        if leg.path.len() >= 2 {
189            polyline.extend(leg.path[1..].iter().map(|&p| GridPoint::from(p)));
190        } else {
191            polyline.push(b);
192        }
193        incoming = leg.outgoing;
194        resolution = resolution.and(leg.resolution);
195    }
196
197    let locked = locked_corners(stored);
198    let path = corners_of(&polyline);
199    let promoted = write_geometry(geometry, ends, &path, &locked);
200
201    if let Some(r) = router.as_mut() {
202        add_route_cost(r, geometry.iter_edges().map(|(_, e)| e), WIRE_COST);
203    }
204    Reconstruction::Laid(Laid {
205        corners: promoted,
206        resolution,
207    })
208}
209
210/// What a leg is judged by: the obstacles, and what a straight leg running
211/// through one of them means to the pass.
212#[derive(Clone, Copy)]
213pub struct LegRules<'a> {
214    obstacles: &'a Obstacles,
215    blocked: BlockedLeg,
216}
217
218impl Obstacles {
219    /// The rider's rules: a leg through a block is routed again.
220    #[must_use]
221    pub fn reroute_blocked(&self) -> LegRules<'_> {
222        LegRules {
223            obstacles: self,
224            blocked: BlockedLeg::Reroute,
225        }
226    }
227
228    /// A reconstruction's rules: a leg through a block is kept as the
229    /// document has it.
230    #[must_use]
231    pub fn keep_blocked(&self) -> LegRules<'_> {
232        LegRules {
233            obstacles: self,
234            blocked: BlockedLeg::Keep,
235        }
236    }
237}
238
239/// What a straight leg that runs through a block means to a pass.
240#[derive(Clone, Copy, PartialEq, Eq, Debug)]
241enum BlockedLeg {
242    /// Something moved onto the wire, so route it again — the rider's reading,
243    /// and the rider's alone: it is the one pass that writes.
244    Reroute,
245    /// The document says the wire runs there, and drawing what the document
246    /// says is all a reconstruction does. The leg is kept, illegal as it is,
247    /// for the draw pass to mark; so is a corner sitting inside a block.
248    Keep,
249}
250
251/// A wire laid through a lattice: the corner list its geometry implies, and
252/// whether every leg found a path. One that did not is its fallback L, which
253/// is still what the wire settles on — the corners are the L's.
254pub struct Laid {
255    pub corners: Vec<Waypoint>,
256    pub resolution: Resolution,
257}
258
259/// Reconstruct a wire without a lattice: the corner list its geometry implies,
260/// or `None` — with nothing written — when a leg needs [`reconstruct_route`]:
261/// one that is non-colinear, or hugs a block, or (under the rider's `rules`)
262/// runs through one.
263pub fn straighten_route(
264    stored: &[Waypoint],
265    geometry: &mut RouteGeometry,
266    ends: Endpoints,
267    rules: LegRules<'_>,
268) -> Option<Vec<Waypoint>> {
269    match reconstruct_legs(stored, geometry, ends, rules, None) {
270        Reconstruction::Laid(laid) => Some(laid.corners),
271        Reconstruction::NeedsLattice => None,
272    }
273}
274
275/// Reconstruct a wire through `lattice`, which is occupied by the wires around
276/// it. A leg the lattice holds no path for falls back to an L that ignores it,
277/// and the [`Laid::resolution`] says so.
278pub fn reconstruct_route(
279    stored: &[Waypoint],
280    geometry: &mut RouteGeometry,
281    ends: Endpoints,
282    rules: LegRules<'_>,
283    lattice: &mut ClosedRouter,
284) -> Laid {
285    match reconstruct_legs(stored, geometry, ends, rules, Some(lattice)) {
286        Reconstruction::Laid(laid) => laid,
287        Reconstruction::NeedsLattice => unreachable!("a lattice was given"),
288    }
289}
290
291/// What reconstructing one wire came to.
292enum Reconstruction {
293    Laid(Laid),
294    NeedsLattice,
295}
296
297/// Rebuild a wire's edges from its corner list (start + corners + end) as
298/// axis-aligned segments WITHOUT the router: a straight leg is one edge; a
299/// diagonal leg (its two endpoints share no row or column) becomes an L via a
300/// single bend. It never consults obstacles and never promotes bends to
301/// waypoints, so dragging a segment or corner repositions the wire directly and
302/// never triggers an autoroute — the segment goes exactly where the user puts
303/// it. The corner list is a read, so per-frame recomputation stays
304/// accumulation-free and the route editor's drag keeps owning it.
305///
306/// A *connector* leg — the first leg (from the start anchor to its corner) and
307/// the last (from the last corner to the end anchor) — is bent so the segment
308/// touching the pin runs along the pin's horizontal E/W stub: the first leg goes
309/// horizontal-first (leaving the pin), the last goes vertical-first (so its final
310/// segment enters the pin horizontally). This keeps the ends one cell clear of the
311/// block instead of hugging its gutter. Interior legs keep an incoming-axis bend so
312/// they can't double back.
313pub fn reconstruct_corners_direct(
314    waypoints: &[GridPoint],
315    geometry: &mut RouteGeometry,
316    ends: Endpoints,
317) {
318    let mut corners: Vec<GridPoint> = Vec::with_capacity(waypoints.len() + 2);
319    corners.push(ends.start);
320    corners.extend_from_slice(waypoints);
321    corners.push(ends.end);
322
323    let last_leg = corners.len().saturating_sub(2);
324    let has_waypoints = corners.len() > 2;
325    let mut polyline: Vec<GridPoint> = vec![corners[0]];
326    let mut incoming: Option<Direction> = None;
327    for (i, pair) in corners.windows(2).enumerate() {
328        let (a, b) = (pair[0], pair[1]);
329        if a == b {
330            continue;
331        }
332        if axis_aligned(a, b) {
333            polyline.push(b);
334        } else {
335            let horizontal_first = if has_waypoints && i == 0 {
336                true // first connector: leave the start pin along its horizontal stub
337            } else if has_waypoints && i == last_leg {
338                false // last connector: enter the end pin along its horizontal stub
339            } else {
340                !matches!(incoming, Some(Direction::North | Direction::South))
341            };
342            let bend = if horizontal_first {
343                GridPoint { x: b.x, y: a.y }
344            } else {
345                GridPoint { x: a.x, y: b.y }
346            };
347            polyline.push(bend);
348            polyline.push(b);
349        }
350        incoming = direction_between(polyline[polyline.len() - 2].into(), b.into());
351    }
352
353    let path = corners_of(&polyline);
354    let mut edges: IdMap<EdgeId, RouteEdge> = IdMap::default();
355    for w in path.windows(2) {
356        edges.insert_value(RouteEdge {
357            start: w[0],
358            end: w[1],
359        });
360    }
361    geometry.edges = edges;
362    geometry.start_pos = ends.start;
363    geometry.end_pos = ends.end;
364}
365
366/// Overwrite the solved geometry from the canonical corner polyline `path`
367/// (`[start, corners…, end]`) and return the waypoint list that polyline
368/// promotes: interior corners in path order, `locked` iff they coincide with
369/// a previously locked corner. The list is the caller's to push as an op —
370/// the solver writes geometry, never the document.
371fn write_geometry(
372    geometry: &mut RouteGeometry,
373    ends: Endpoints,
374    path: &[GridPoint],
375    locked: &HashSet<GridPoint>,
376) -> Vec<Waypoint> {
377    geometry.edges = edges_of(path);
378    geometry.start_pos = ends.start;
379    geometry.end_pos = ends.end;
380    interior_waypoints(path, locked)
381}
382
383/// The polyline's segments as presentation edges.
384fn edges_of(path: &[GridPoint]) -> IdMap<EdgeId, RouteEdge> {
385    let mut edges: IdMap<EdgeId, RouteEdge> = IdMap::default();
386    for w in path.windows(2) {
387        edges.insert_value(RouteEdge {
388            start: w[0],
389            end: w[1],
390        });
391    }
392    edges
393}
394
395/// The corners a canonical polyline `[start, corners…, end]` implies as
396/// waypoints, in path order, `locked` iff one already sat there.
397fn interior_waypoints(path: &[GridPoint], locked: &HashSet<GridPoint>) -> Vec<Waypoint> {
398    if path.len() <= 2 {
399        return Vec::new();
400    }
401    path[1..path.len() - 1]
402        .iter()
403        .map(|&pos| Waypoint {
404            pos,
405            locked: locked.contains(&pos),
406        })
407        .collect()
408}
409
410/// Where the user pinned a corner, so a rebuild can keep it pinned.
411fn locked_corners(waypoints: &[Waypoint]) -> HashSet<GridPoint> {
412    waypoints
413        .iter()
414        .filter(|wp| wp.locked)
415        .map(|wp| wp.pos)
416        .collect()
417}
418
419/// Reduce a polyline to its corners: drop adjacent duplicate points and any
420/// point collinear with its neighbours (a redundant interior point on a straight
421/// run). Keeps the first and last points and only the true direction-change
422/// vertices — the canonical corner set the model stores as waypoints. Idempotent.
423pub fn corners_of(points: &[GridPoint]) -> Vec<GridPoint> {
424    let mut out: Vec<GridPoint> = Vec::with_capacity(points.len());
425    for &p in points {
426        if out.last() == Some(&p) {
427            continue;
428        }
429        if let [.., a, b] = out[..]
430            && collinear(a, b, p)
431        {
432            out.pop();
433        }
434        out.push(p);
435    }
436    out
437}
438
439/// Every corner of the wire's polyline as a waypoint, in path order — the
440/// invariant the explicit-path model relies on. Derives the list from the
441/// route's current edge geometry: each interior bend becomes a waypoint,
442/// preserving the `locked` flag of any existing waypoint already at that position
443/// (so user-pinned corners stay pinned) and marking the rest structural
444/// (`locked: false`). The endpoints are never waypoints. The returned list is
445/// what the gesture's commit stores; the geometry is rebuilt in place.
446pub fn promote_corners_to_waypoints(
447    stored: &[Waypoint],
448    geometry: &mut RouteGeometry,
449) -> Vec<Waypoint> {
450    let corners = geometry_corners(geometry);
451    let locked = locked_corners(stored);
452
453    // Rebuild the edges from the canonical corners as well, not just the
454    // waypoints. `corners_of` collapses a 180° reversal (an overshoot the wire
455    // doubles straight back on), which strict waypoint routing and a literal
456    // route edit can leave in `edges`. Rebuilding only the waypoints would let
457    // that spike linger in the geometry — showing a malformed wire that "fixes
458    // itself" only after a reload rebuilds edges from the clean waypoints — so
459    // keep the two in sync here. Real 90° bends are direction changes, not
460    // collinear, so obstacle-avoidance geometry is preserved untouched.
461    geometry.edges = edges_of(&corners);
462    interior_waypoints(&corners, &locked)
463}
464
465/// The canonical corner polyline of solved geometry, endpoints included.
466pub fn geometry_corners(geometry: &RouteGeometry) -> Vec<GridPoint> {
467    let mut polyline = vec![geometry.start_pos];
468    polyline.extend(geometry.iter_edges().map(|(_, e)| e.end));
469    polyline.push(geometry.end_pos);
470    corners_of(&polyline)
471}
472
473/// Whether `b` lies on the straight line through `a` and `c` (zero cross
474/// product). For the axis-aligned corners we deal with, this means `a`, `b`, `c`
475/// share a row or column.
476fn collinear(a: GridPoint, b: GridPoint, c: GridPoint) -> bool {
477    (b.x - a.x) as i64 * (c.y - a.y) as i64 == (b.y - a.y) as i64 * (c.x - a.x) as i64
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    use crate::edit::geometry::{backtracking_ordinals, pruned_waypoints};
485
486    fn g(x: i32, y: i32) -> GridPoint {
487        GridPoint { x, y }
488    }
489
490    fn wp(x: i32, y: i32) -> Waypoint {
491        Waypoint {
492            pos: GridPoint { x, y },
493            locked: false,
494        }
495    }
496
497    /// The authored corners of a wire as a reconstruction pass reads them —
498    /// the post-load state: corner waypoints present, edges empty.
499    fn loaded_route(waypoints: &[(i32, i32)]) -> Vec<Waypoint> {
500        waypoints.iter().map(|&(x, y)| wp(x, y)).collect()
501    }
502
503    fn edges_double_back(geometry: &RouteGeometry) -> bool {
504        let edges: Vec<_> = geometry
505            .iter_edges()
506            .map(|(_, e)| (e.start, e.end))
507            .collect();
508        edges.windows(2).any(|w| {
509            let (ax, ay) = (w[0].1.x - w[0].0.x, w[0].1.y - w[0].0.y);
510            let (bx, by) = (w[1].1.x - w[1].0.x, w[1].1.y - w[1].0.y);
511            (ay == 0 && by == 0 && (ax > 0) != (bx > 0))
512                || (ax == 0 && bx == 0 && (ay > 0) != (by > 0))
513        })
514    }
515
516    #[test]
517    fn promote_erases_a_backtracking_spike_from_the_edges() {
518        use crate::edit::create::PathOrdinal;
519        use crate::widget::segmentkind::SegmentKind;
520        use crate::widget::waypoint_router::TaggedPoint;
521        use blockworx_router::point::Point;
522
523        let tp = |segment, x, y| TaggedPoint {
524            segment,
525            pos: Point::from(g(x, y)),
526        };
527        // Overshoot: run east to the waypoint at x=10, then straight back west to
528        // x=5 — a 180° reversal split across the two waypoint legs, so the solve
529        // keeps both edges (they never merge across a leg boundary).
530        let w = PathOrdinal::new(0);
531        let path = [
532            tp(SegmentKind::StartToWaypoint(w), 0, 0),
533            tp(SegmentKind::StartToWaypoint(w), 10, 0),
534            tp(SegmentKind::WaypointToEnd(w), 10, 0),
535            tp(SegmentKind::WaypointToEnd(w), 5, 0),
536        ];
537        let stored = loaded_route(&[(10, 0)]);
538        let mut geometry = crate::widget::auto_route::geometry_from_points(&path);
539        assert!(
540            edges_double_back(&geometry),
541            "setup: the solve kept the reversal spike in the edges"
542        );
543
544        let promoted = promote_corners_to_waypoints(&stored, &mut geometry);
545        assert!(
546            !edges_double_back(&geometry),
547            "promote collapsed the spike in the edges"
548        );
549        assert!(promoted.is_empty(), "and dropped the overshoot waypoint");
550    }
551
552    #[test]
553    fn corners_of_drops_collinear_interior_points() {
554        // A run east then a run south: only the bend at (2,0) survives.
555        let pts = [g(0, 0), g(1, 0), g(2, 0), g(2, 1), g(2, 2)];
556        assert_eq!(corners_of(&pts), vec![g(0, 0), g(2, 0), g(2, 2)]);
557    }
558
559    #[test]
560    fn corners_of_drops_adjacent_duplicates() {
561        let pts = [g(0, 0), g(0, 0), g(3, 0), g(3, 0)];
562        assert_eq!(corners_of(&pts), vec![g(0, 0), g(3, 0)]);
563    }
564
565    #[test]
566    fn corners_of_is_idempotent() {
567        let pts = [g(0, 0), g(1, 0), g(2, 0), g(2, 3), g(5, 3), g(5, 3)];
568        let once = corners_of(&pts);
569        assert_eq!(corners_of(&once), once);
570    }
571
572    #[test]
573    fn promote_makes_every_bend_a_waypoint() {
574        use crate::edit::create::PathOrdinal;
575        use crate::widget::segmentkind::SegmentKind;
576        use crate::widget::waypoint_router::TaggedPoint;
577        use blockworx_router::point::Point;
578
579        let tp = |x: i32, y: i32| TaggedPoint {
580            segment: SegmentKind::StartToEnd,
581            pos: Point::from(GridPoint { x, y }),
582        };
583        // Z-shape: east, south, east — two bends at (3,0) and (3,3).
584        let path = [tp(0, 0), tp(3, 0), tp(3, 3), tp(6, 3)];
585        let stored = loaded_route(&[]);
586        let mut geometry = crate::widget::auto_route::geometry_from_points(&path);
587        assert!(stored.is_empty());
588        assert_ne!(
589            PathOrdinal::new(0),
590            PathOrdinal::new(1),
591            "ordinals name distinct corners"
592        );
593
594        let promoted = promote_corners_to_waypoints(&stored, &mut geometry);
595
596        assert_eq!(corner_positions(&promoted), vec![g(3, 0), g(3, 3)]);
597        assert!(promoted.iter().all(|wp| !wp.locked));
598    }
599
600    #[test]
601    fn straight_legs_reconstruct_without_a_router() {
602        let stored = loaded_route(&[(3, 0)]);
603        let mut geometry = RouteGeometry::default();
604        // (0,0) →H (3,0) →V (3,4): both legs axis-aligned and clear.
605        let done = straighten_route(
606            &stored,
607            &mut geometry,
608            Endpoints {
609                start: g(0, 0),
610                end: g(3, 4),
611            },
612            Obstacles::new(vec![]).reroute_blocked(),
613        );
614        assert!(done.is_some(), "a straight route needs no router");
615        let edges: Vec<(GridPoint, GridPoint)> = geometry
616            .iter_edges()
617            .map(|(_, e)| (e.start, e.end))
618            .collect();
619        assert_eq!(edges, vec![(g(0, 0), g(3, 0)), (g(3, 0), g(3, 4))]);
620    }
621
622    #[test]
623    fn a_blocked_leg_defers_when_no_router() {
624        let stored = loaded_route(&[]);
625        let mut geometry = RouteGeometry::default();
626        // A block straddling the straight run (0,0)→(10,0).
627        let obstacles = Obstacles::new(vec![ObstacleRect {
628            top_left: g(4, -2).into(),
629            bottom_right: g(6, 2).into(),
630        }]);
631        let done = straighten_route(
632            &stored,
633            &mut geometry,
634            Endpoints {
635                start: g(0, 0),
636                end: g(10, 0),
637            },
638            obstacles.reroute_blocked(),
639        );
640        assert!(
641            done.is_none(),
642            "a blocked straight leg must defer to the router"
643        );
644    }
645
646    #[test]
647    fn a_blocked_leg_is_kept_as_it_stands_when_the_document_says_so() {
648        let stored = loaded_route(&[]);
649        let mut geometry = RouteGeometry::default();
650        let obstacles = Obstacles::new(vec![ObstacleRect {
651            top_left: g(4, -2).into(),
652            bottom_right: g(6, 2).into(),
653        }]);
654        assert!(
655            obstacles.blocked(g(0, 0), g(10, 0)),
656            "precondition: the leg runs through the block"
657        );
658        let done = straighten_route(
659            &stored,
660            &mut geometry,
661            Endpoints {
662                start: g(0, 0),
663                end: g(10, 0),
664            },
665            obstacles.keep_blocked(),
666        );
667        assert!(done.is_some(), "a kept leg needs no router");
668        let edges: Vec<(GridPoint, GridPoint)> = geometry
669            .iter_edges()
670            .map(|(_, e)| (e.start, e.end))
671            .collect();
672        assert_eq!(edges, vec![(g(0, 0), g(10, 0))]);
673        assert_eq!(
674            obstacles.crossings(g(0, 0), g(10, 0)).collect::<Vec<_>>(),
675            vec![(g(4, 0), g(6, 0))],
676            "the crossing is the stretch inside the block"
677        );
678    }
679
680    #[test]
681    fn direct_relay_draws_straight_legs_and_leaves_its_corners() {
682        let corners = [g(0, 5)];
683        let mut geometry = RouteGeometry::default();
684        reconstruct_corners_direct(
685            &corners,
686            &mut geometry,
687            Endpoints {
688                start: g(0, 0),
689                end: g(5, 5),
690            },
691        );
692        let edges: Vec<(GridPoint, GridPoint)> = geometry
693            .iter_edges()
694            .map(|(_, e)| (e.start, e.end))
695            .collect();
696        assert_eq!(edges, vec![(g(0, 0), g(0, 5)), (g(0, 5), g(5, 5))]);
697        // The direct relay rebuilds edges only; the corner list it was handed
698        // is a read, so the drag still owns it.
699        assert_eq!(corners, [g(0, 5)]);
700    }
701
702    #[test]
703    fn direct_relay_l_bends_a_diagonal_leg_without_a_router() {
704        let mut geometry = RouteGeometry::default();
705        reconstruct_corners_direct(
706            &[g(3, 4)],
707            &mut geometry,
708            Endpoints {
709                start: g(0, 0),
710                end: g(3, 8),
711            },
712        );
713        assert!(
714            geometry
715                .iter_edges()
716                .all(|(_, e)| e.start.x == e.end.x || e.start.y == e.end.y),
717            "every relayed edge is axis-aligned — an L, never a diagonal"
718        );
719        assert!(
720            geometry.iter_edges().count() >= 2,
721            "the diagonal leg produced a bend"
722        );
723    }
724
725    #[test]
726    fn a_non_colinear_leg_defers_when_no_router() {
727        // Waypoint diagonal from the start: the first leg is not axis-aligned.
728        let stored = loaded_route(&[(3, 4)]);
729        let mut geometry = RouteGeometry::default();
730        let done = straighten_route(
731            &stored,
732            &mut geometry,
733            Endpoints {
734                start: g(0, 0),
735                end: g(3, 8),
736            },
737            Obstacles::new(vec![]).reroute_blocked(),
738        );
739        assert!(
740            done.is_none(),
741            "a non-colinear leg must defer to the router"
742        );
743    }
744
745    /// The backtracking prune the commit pass applies and the preview
746    /// previews: one policy, [`crate::edit::geometry::backtracking_ordinals`],
747    /// read here through the corner list it prunes.
748    fn pruned(
749        waypoints: &[(i32, i32)],
750        start: GridPoint,
751        end: GridPoint,
752    ) -> (Vec<GridPoint>, bool) {
753        let stored: Vec<Waypoint> = waypoints.iter().map(|&(x, y)| wp(x, y)).collect();
754        let doomed = backtracking_ordinals(&stored, start, end);
755        (
756            corner_positions(&pruned_waypoints(&stored, &doomed)),
757            !doomed.is_empty(),
758        )
759    }
760
761    #[test]
762    fn removes_a_waypoint_the_wire_doubles_back_on() {
763        // start (0,0) → wp (10,0) → end (5,0): the wire runs east to x=10, then
764        // west to x=5 — a 180° reversal at the waypoint, which must be dropped.
765        let (kept, removed) = pruned(&[(10, 0)], g(0, 0), g(5, 0));
766        assert!(removed);
767        assert!(kept.is_empty());
768    }
769
770    #[test]
771    fn keeps_a_real_corner_and_a_straight_through_pin() {
772        // A 90° corner is not a reversal.
773        let (kept, removed) = pruned(&[(10, 0)], g(0, 0), g(10, 5));
774        assert!(!removed);
775        assert_eq!(kept, vec![g(10, 0)]);
776        // A waypoint between its neighbours on a straight run (0°) pins the wire's
777        // row; it is not a reversal, so it survives.
778        let (kept, removed) = pruned(&[(5, 0)], g(0, 0), g(10, 0));
779        assert!(!removed);
780        assert_eq!(kept.len(), 1);
781    }
782
783    #[test]
784    fn cascades_after_a_removal_exposes_a_new_reversal() {
785        // start (0,0) → (10,0) → (3,0) → end (5,0). Dropping the (10,0) overshoot
786        // leaves (0,0) → (3,0) → (5,0), a straight run, so (3,0) stays.
787        let (kept, removed) = pruned(&[(10, 0), (3, 0)], g(0, 0), g(5, 0));
788        assert!(removed);
789        assert_eq!(kept, vec![g(3, 0)]);
790    }
791}