Skip to main content

blockworx/widget/
materialize.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//! materialization (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, 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 materialization 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        let candidates = if a.y == b.y {
68            self.by_row.get(&a.y)
69        } else if a.x == b.x {
70            self.by_col.get(&a.x)
71        } else {
72            None
73        };
74        candidates
75            .into_iter()
76            .flatten()
77            .any(|&i| self.rects[i].intersects_edge(a, b))
78    }
79
80    /// Whether the axis-aligned wire `a → b` hugs any obstacle (runs alongside an
81    /// edge within the routing gutter). Unlike [`Self::blocked`], a hugging wire is
82    /// adjacent to — not overlapping — the block, so the row/col occupancy index
83    /// doesn't apply; every rect is tested.
84    pub fn hugs(&self, a: GridPoint, b: GridPoint) -> bool {
85        self.rects.iter().any(|r| r.hugs_wire(a, b, ROUTE_GUTTER))
86    }
87
88    /// Whether `p` lies outside every obstacle (a waypoint inside one is dropped).
89    pub fn accessible(&self, p: GridPoint) -> bool {
90        !self
91            .by_row
92            .get(&p.y)
93            .into_iter()
94            .flatten()
95            .any(|&i| self.rects[i].contains(p.into()))
96    }
97}
98
99fn axis_aligned(a: GridPoint, b: GridPoint) -> bool {
100    a.x == b.x || a.y == b.y
101}
102
103/// A route's resolved (snapped) endpoints for one solve or
104/// materialization pass.
105#[derive(Clone, Copy)]
106pub struct Endpoints {
107    pub start: GridPoint,
108    pub end: GridPoint,
109}
110
111/// Rebuild the edge geometry of a wire whose authored corners are `stored`,
112/// keeping every straight (axis-aligned, unblocked) leg as a single edge and
113/// routing ONLY the legs that are non-colinear or blocked. `start`/`end` are the
114/// resolved grid endpoints. Straight legs never touch the router, so an
115/// unaffected route (all legs straight) is reconstructed byte-identically with
116/// zero pathfinding.
117///
118/// When a leg needs routing, `router` must be `Some` (already built and occupied
119/// by the other routes); the leg's new bends are spliced into the corner list so
120/// the "every corner is a waypoint" invariant is restored. If a leg needs routing
121/// and `router` is `None`, no geometry is written and `None` is returned so the
122/// caller can build the router and retry. Interior waypoints that fall inside an
123/// obstacle are dropped (only on the routing path, where `router` is `Some`).
124///
125/// Returns the corner list the materialization promotes — the polyline the
126/// gesture's commit stores, which the caller pushes as an op. Nothing here
127/// writes the document.
128pub fn materialize_route(
129    stored: &[Waypoint],
130    geometry: &mut RouteGeometry,
131    ends: Endpoints,
132    obstacles: &Obstacles,
133    mut router: Option<&mut ClosedRouter>,
134) -> Option<Vec<Waypoint>> {
135    // Ordered corners: start, each waypoint (drop inaccessible ones when routing),
136    // then end. Waypoint order is path order.
137    let mut corners: Vec<GridPoint> = Vec::with_capacity(stored.len() + 2);
138    corners.push(ends.start);
139    for pos in corner_positions(stored) {
140        if router.is_some() && !obstacles.accessible(pos) {
141            continue;
142        }
143        corners.push(pos);
144    }
145    corners.push(ends.end);
146
147    // Walk the legs, emitting the resulting polyline. Straight legs contribute a
148    // single hop; routed legs contribute their solved sub-path.
149    let mut polyline: Vec<GridPoint> = vec![corners[0]];
150    let mut incoming: Option<Direction> = None;
151    for pair in corners.windows(2) {
152        let (a, b) = (pair[0], pair[1]);
153        if a == b {
154            continue;
155        }
156        if axis_aligned(a, b) && !obstacles.blocked(a, b) && !obstacles.hugs(a, b) {
157            polyline.push(b);
158            incoming = direction_between(a.into(), b.into());
159            continue;
160        }
161        let r = router.as_mut()?;
162        let (subpath, outgoing) = r.route_leg(a.into(), b.into(), incoming);
163        if subpath.len() >= 2 {
164            polyline.extend(subpath[1..].iter().map(|&p| GridPoint::from(p)));
165        } else {
166            polyline.push(b);
167        }
168        incoming = outgoing;
169    }
170
171    let locked = locked_corners(stored);
172    let path = corners_of(&polyline);
173    let promoted = write_geometry(geometry, ends, &path, &locked);
174
175    if let Some(r) = router.as_mut() {
176        add_route_cost(r, geometry.iter_edges().map(|(_, e)| e), WIRE_COST);
177    }
178    Some(promoted)
179}
180
181/// Rebuild a wire's edges from its corner list (start + corners + end) as
182/// axis-aligned segments WITHOUT the router: a straight leg is one edge; a
183/// diagonal leg (its two endpoints share no row or column) becomes an L via a
184/// single bend. It never consults obstacles and never promotes bends to
185/// waypoints, so dragging a segment or corner repositions the wire directly and
186/// never triggers an autoroute — the segment goes exactly where the user puts
187/// it. The corner list is a read, so per-frame recomputation stays
188/// accumulation-free and the route editor's drag keeps owning it.
189///
190/// A *connector* leg — the first leg (from the start anchor to its corner) and
191/// the last (from the last corner to the end anchor) — is bent so the segment
192/// touching the pin runs along the pin's horizontal E/W stub: the first leg goes
193/// horizontal-first (leaving the pin), the last goes vertical-first (so its final
194/// segment enters the pin horizontally). This keeps the ends one cell clear of the
195/// block instead of hugging its gutter. Interior legs keep an incoming-axis bend so
196/// they can't double back.
197pub fn materialize_corners_direct(
198    waypoints: &[GridPoint],
199    geometry: &mut RouteGeometry,
200    ends: Endpoints,
201) {
202    let mut corners: Vec<GridPoint> = Vec::with_capacity(waypoints.len() + 2);
203    corners.push(ends.start);
204    corners.extend_from_slice(waypoints);
205    corners.push(ends.end);
206
207    let last_leg = corners.len().saturating_sub(2);
208    let has_waypoints = corners.len() > 2;
209    let mut polyline: Vec<GridPoint> = vec![corners[0]];
210    let mut incoming: Option<Direction> = None;
211    for (i, pair) in corners.windows(2).enumerate() {
212        let (a, b) = (pair[0], pair[1]);
213        if a == b {
214            continue;
215        }
216        if axis_aligned(a, b) {
217            polyline.push(b);
218        } else {
219            let horizontal_first = if has_waypoints && i == 0 {
220                true // first connector: leave the start pin along its horizontal stub
221            } else if has_waypoints && i == last_leg {
222                false // last connector: enter the end pin along its horizontal stub
223            } else {
224                !matches!(incoming, Some(Direction::North | Direction::South))
225            };
226            let bend = if horizontal_first {
227                GridPoint { x: b.x, y: a.y }
228            } else {
229                GridPoint { x: a.x, y: b.y }
230            };
231            polyline.push(bend);
232            polyline.push(b);
233        }
234        incoming = direction_between(polyline[polyline.len() - 2].into(), b.into());
235    }
236
237    let path = corners_of(&polyline);
238    let mut edges: IdMap<EdgeId, RouteEdge> = IdMap::default();
239    for w in path.windows(2) {
240        edges.insert_value(RouteEdge {
241            start: w[0],
242            end: w[1],
243        });
244    }
245    geometry.edges = edges;
246    geometry.start_pos = ends.start;
247    geometry.end_pos = ends.end;
248}
249
250/// Overwrite the solved geometry from the canonical corner polyline `path`
251/// (`[start, corners…, end]`) and return the waypoint list that polyline
252/// promotes: interior corners in path order, `locked` iff they coincide with
253/// a previously locked corner. The list is the caller's to push as an op —
254/// the solver writes geometry, never the document.
255fn write_geometry(
256    geometry: &mut RouteGeometry,
257    ends: Endpoints,
258    path: &[GridPoint],
259    locked: &HashSet<GridPoint>,
260) -> Vec<Waypoint> {
261    geometry.edges = edges_of(path);
262    geometry.start_pos = ends.start;
263    geometry.end_pos = ends.end;
264    interior_waypoints(path, locked)
265}
266
267/// The polyline's segments as presentation edges.
268fn edges_of(path: &[GridPoint]) -> IdMap<EdgeId, RouteEdge> {
269    let mut edges: IdMap<EdgeId, RouteEdge> = IdMap::default();
270    for w in path.windows(2) {
271        edges.insert_value(RouteEdge {
272            start: w[0],
273            end: w[1],
274        });
275    }
276    edges
277}
278
279/// The corners a canonical polyline `[start, corners…, end]` implies as
280/// waypoints, in path order, `locked` iff one already sat there.
281fn interior_waypoints(path: &[GridPoint], locked: &HashSet<GridPoint>) -> Vec<Waypoint> {
282    if path.len() <= 2 {
283        return Vec::new();
284    }
285    path[1..path.len() - 1]
286        .iter()
287        .map(|&pos| Waypoint {
288            pos,
289            locked: locked.contains(&pos),
290        })
291        .collect()
292}
293
294/// Where the user pinned a corner, so a rebuild can keep it pinned.
295fn locked_corners(waypoints: &[Waypoint]) -> HashSet<GridPoint> {
296    waypoints
297        .iter()
298        .filter(|wp| wp.locked)
299        .map(|wp| wp.pos)
300        .collect()
301}
302
303/// Reduce a polyline to its corners: drop adjacent duplicate points and any
304/// point collinear with its neighbours (a redundant interior point on a straight
305/// run). Keeps the first and last points and only the true direction-change
306/// vertices — the canonical corner set the model stores as waypoints. Idempotent.
307pub fn corners_of(points: &[GridPoint]) -> Vec<GridPoint> {
308    let mut out: Vec<GridPoint> = Vec::with_capacity(points.len());
309    for &p in points {
310        if out.last() == Some(&p) {
311            continue;
312        }
313        if let [.., a, b] = out[..]
314            && collinear(a, b, p)
315        {
316            out.pop();
317        }
318        out.push(p);
319    }
320    out
321}
322
323/// Every corner of the wire's polyline as a waypoint, in path order — the
324/// invariant the explicit-path model relies on. Derives the list from the
325/// route's current edge geometry: each interior bend becomes a waypoint,
326/// preserving the `locked` flag of any existing waypoint already at that position
327/// (so user-pinned corners stay pinned) and marking the rest structural
328/// (`locked: false`). The endpoints are never waypoints. The returned list is
329/// what the gesture's commit stores; the geometry is rebuilt in place.
330pub fn promote_corners_to_waypoints(
331    stored: &[Waypoint],
332    geometry: &mut RouteGeometry,
333) -> Vec<Waypoint> {
334    let corners = geometry_corners(geometry);
335    let locked = locked_corners(stored);
336
337    // Rebuild the edges from the canonical corners as well, not just the
338    // waypoints. `corners_of` collapses a 180° reversal (an overshoot the wire
339    // doubles straight back on), which strict waypoint routing and a literal
340    // route edit can leave in `edges`. Rebuilding only the waypoints would let
341    // that spike linger in the geometry — showing a malformed wire that "fixes
342    // itself" only after a reload rebuilds edges from the clean waypoints — so
343    // keep the two in sync here. Real 90° bends are direction changes, not
344    // collinear, so obstacle-avoidance geometry is preserved untouched.
345    geometry.edges = edges_of(&corners);
346    interior_waypoints(&corners, &locked)
347}
348
349/// The canonical corner polyline of solved geometry, endpoints included.
350pub fn geometry_corners(geometry: &RouteGeometry) -> Vec<GridPoint> {
351    let mut polyline = vec![geometry.start_pos];
352    polyline.extend(geometry.iter_edges().map(|(_, e)| e.end));
353    polyline.push(geometry.end_pos);
354    corners_of(&polyline)
355}
356
357/// Whether `b` lies on the straight line through `a` and `c` (zero cross
358/// product). For the axis-aligned corners we deal with, this means `a`, `b`, `c`
359/// share a row or column.
360fn collinear(a: GridPoint, b: GridPoint, c: GridPoint) -> bool {
361    (b.x - a.x) as i64 * (c.y - a.y) as i64 == (b.y - a.y) as i64 * (c.x - a.x) as i64
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    use crate::edit::geometry::{backtracking_ordinals, pruned_waypoints};
369
370    fn g(x: i32, y: i32) -> GridPoint {
371        GridPoint { x, y }
372    }
373
374    fn wp(x: i32, y: i32) -> Waypoint {
375        Waypoint {
376            pos: GridPoint { x, y },
377            locked: false,
378        }
379    }
380
381    /// The authored corners of a wire as a materialization pass reads them —
382    /// the post-load state: corner waypoints present, edges empty.
383    fn loaded_route(waypoints: &[(i32, i32)]) -> Vec<Waypoint> {
384        waypoints.iter().map(|&(x, y)| wp(x, y)).collect()
385    }
386
387    fn edges_double_back(geometry: &RouteGeometry) -> bool {
388        let edges: Vec<_> = geometry
389            .iter_edges()
390            .map(|(_, e)| (e.start, e.end))
391            .collect();
392        edges.windows(2).any(|w| {
393            let (ax, ay) = (w[0].1.x - w[0].0.x, w[0].1.y - w[0].0.y);
394            let (bx, by) = (w[1].1.x - w[1].0.x, w[1].1.y - w[1].0.y);
395            (ay == 0 && by == 0 && (ax > 0) != (bx > 0))
396                || (ax == 0 && bx == 0 && (ay > 0) != (by > 0))
397        })
398    }
399
400    #[test]
401    fn promote_erases_a_backtracking_spike_from_the_edges() {
402        use crate::edit::create::PathOrdinal;
403        use crate::widget::segmentkind::SegmentKind;
404        use crate::widget::waypoint_router::TaggedPoint;
405        use blockworx_router::point::Point;
406
407        let tp = |segment, x, y| TaggedPoint {
408            segment,
409            pos: Point::from(g(x, y)),
410        };
411        // Overshoot: run east to the waypoint at x=10, then straight back west to
412        // x=5 — a 180° reversal split across the two waypoint legs, so the solve
413        // keeps both edges (they never merge across a leg boundary).
414        let w = PathOrdinal::new(0);
415        let path = [
416            tp(SegmentKind::StartToWaypoint(w), 0, 0),
417            tp(SegmentKind::StartToWaypoint(w), 10, 0),
418            tp(SegmentKind::WaypointToEnd(w), 10, 0),
419            tp(SegmentKind::WaypointToEnd(w), 5, 0),
420        ];
421        let stored = loaded_route(&[(10, 0)]);
422        let mut geometry = crate::widget::auto_route::geometry_from_points(&path);
423        assert!(
424            edges_double_back(&geometry),
425            "setup: the solve kept the reversal spike in the edges"
426        );
427
428        let promoted = promote_corners_to_waypoints(&stored, &mut geometry);
429        assert!(
430            !edges_double_back(&geometry),
431            "promote collapsed the spike in the edges"
432        );
433        assert!(promoted.is_empty(), "and dropped the overshoot waypoint");
434    }
435
436    #[test]
437    fn corners_of_drops_collinear_interior_points() {
438        // A run east then a run south: only the bend at (2,0) survives.
439        let pts = [g(0, 0), g(1, 0), g(2, 0), g(2, 1), g(2, 2)];
440        assert_eq!(corners_of(&pts), vec![g(0, 0), g(2, 0), g(2, 2)]);
441    }
442
443    #[test]
444    fn corners_of_drops_adjacent_duplicates() {
445        let pts = [g(0, 0), g(0, 0), g(3, 0), g(3, 0)];
446        assert_eq!(corners_of(&pts), vec![g(0, 0), g(3, 0)]);
447    }
448
449    #[test]
450    fn corners_of_is_idempotent() {
451        let pts = [g(0, 0), g(1, 0), g(2, 0), g(2, 3), g(5, 3), g(5, 3)];
452        let once = corners_of(&pts);
453        assert_eq!(corners_of(&once), once);
454    }
455
456    #[test]
457    fn promote_makes_every_bend_a_waypoint() {
458        use crate::edit::create::PathOrdinal;
459        use crate::widget::segmentkind::SegmentKind;
460        use crate::widget::waypoint_router::TaggedPoint;
461        use blockworx_router::point::Point;
462
463        let tp = |x: i32, y: i32| TaggedPoint {
464            segment: SegmentKind::StartToEnd,
465            pos: Point::from(GridPoint { x, y }),
466        };
467        // Z-shape: east, south, east — two bends at (3,0) and (3,3).
468        let path = [tp(0, 0), tp(3, 0), tp(3, 3), tp(6, 3)];
469        let stored = loaded_route(&[]);
470        let mut geometry = crate::widget::auto_route::geometry_from_points(&path);
471        assert!(stored.is_empty());
472        assert_ne!(
473            PathOrdinal::new(0),
474            PathOrdinal::new(1),
475            "ordinals name distinct corners"
476        );
477
478        let promoted = promote_corners_to_waypoints(&stored, &mut geometry);
479
480        assert_eq!(corner_positions(&promoted), vec![g(3, 0), g(3, 3)]);
481        assert!(promoted.iter().all(|wp| !wp.locked));
482    }
483
484    #[test]
485    fn straight_legs_materialize_without_a_router() {
486        let stored = loaded_route(&[(3, 0)]);
487        let mut geometry = RouteGeometry::default();
488        // (0,0) →H (3,0) →V (3,4): both legs axis-aligned and clear.
489        let done = materialize_route(
490            &stored,
491            &mut geometry,
492            Endpoints {
493                start: g(0, 0),
494                end: g(3, 4),
495            },
496            &Obstacles::new(vec![]),
497            None,
498        );
499        assert!(done.is_some(), "a straight route needs no router");
500        let edges: Vec<(GridPoint, GridPoint)> = geometry
501            .iter_edges()
502            .map(|(_, e)| (e.start, e.end))
503            .collect();
504        assert_eq!(edges, vec![(g(0, 0), g(3, 0)), (g(3, 0), g(3, 4))]);
505    }
506
507    #[test]
508    fn a_blocked_leg_defers_when_no_router() {
509        let stored = loaded_route(&[]);
510        let mut geometry = RouteGeometry::default();
511        // A block straddling the straight run (0,0)→(10,0).
512        let obstacles = Obstacles::new(vec![ObstacleRect {
513            top_left: g(4, -2).into(),
514            bottom_right: g(6, 2).into(),
515        }]);
516        let done = materialize_route(
517            &stored,
518            &mut geometry,
519            Endpoints {
520                start: g(0, 0),
521                end: g(10, 0),
522            },
523            &obstacles,
524            None,
525        );
526        assert!(
527            done.is_none(),
528            "a blocked straight leg must defer to the router"
529        );
530    }
531
532    #[test]
533    fn direct_relay_draws_straight_legs_and_leaves_its_corners() {
534        let corners = [g(0, 5)];
535        let mut geometry = RouteGeometry::default();
536        materialize_corners_direct(
537            &corners,
538            &mut geometry,
539            Endpoints {
540                start: g(0, 0),
541                end: g(5, 5),
542            },
543        );
544        let edges: Vec<(GridPoint, GridPoint)> = geometry
545            .iter_edges()
546            .map(|(_, e)| (e.start, e.end))
547            .collect();
548        assert_eq!(edges, vec![(g(0, 0), g(0, 5)), (g(0, 5), g(5, 5))]);
549        // The direct relay rebuilds edges only; the corner list it was handed
550        // is a read, so the drag still owns it.
551        assert_eq!(corners, [g(0, 5)]);
552    }
553
554    #[test]
555    fn direct_relay_l_bends_a_diagonal_leg_without_a_router() {
556        let mut geometry = RouteGeometry::default();
557        materialize_corners_direct(
558            &[g(3, 4)],
559            &mut geometry,
560            Endpoints {
561                start: g(0, 0),
562                end: g(3, 8),
563            },
564        );
565        assert!(
566            geometry
567                .iter_edges()
568                .all(|(_, e)| e.start.x == e.end.x || e.start.y == e.end.y),
569            "every relayed edge is axis-aligned — an L, never a diagonal"
570        );
571        assert!(
572            geometry.iter_edges().count() >= 2,
573            "the diagonal leg produced a bend"
574        );
575    }
576
577    #[test]
578    fn a_non_colinear_leg_defers_when_no_router() {
579        // Waypoint diagonal from the start: the first leg is not axis-aligned.
580        let stored = loaded_route(&[(3, 4)]);
581        let mut geometry = RouteGeometry::default();
582        let done = materialize_route(
583            &stored,
584            &mut geometry,
585            Endpoints {
586                start: g(0, 0),
587                end: g(3, 8),
588            },
589            &Obstacles::new(vec![]),
590            None,
591        );
592        assert!(
593            done.is_none(),
594            "a non-colinear leg must defer to the router"
595        );
596    }
597
598    /// The backtracking prune the commit pass applies and the preview
599    /// supposes: one policy, [`crate::edit::geometry::backtracking_ordinals`],
600    /// read here through the corner list it prunes.
601    fn pruned(
602        waypoints: &[(i32, i32)],
603        start: GridPoint,
604        end: GridPoint,
605    ) -> (Vec<GridPoint>, bool) {
606        let stored: Vec<Waypoint> = waypoints.iter().map(|&(x, y)| wp(x, y)).collect();
607        let doomed = backtracking_ordinals(&stored, start, end);
608        (
609            corner_positions(&pruned_waypoints(&stored, &doomed)),
610            !doomed.is_empty(),
611        )
612    }
613
614    #[test]
615    fn removes_a_waypoint_the_wire_doubles_back_on() {
616        // start (0,0) → wp (10,0) → end (5,0): the wire runs east to x=10, then
617        // west to x=5 — a 180° reversal at the waypoint, which must be dropped.
618        let (kept, removed) = pruned(&[(10, 0)], g(0, 0), g(5, 0));
619        assert!(removed);
620        assert!(kept.is_empty());
621    }
622
623    #[test]
624    fn keeps_a_real_corner_and_a_straight_through_pin() {
625        // A 90° corner is not a reversal.
626        let (kept, removed) = pruned(&[(10, 0)], g(0, 0), g(10, 5));
627        assert!(!removed);
628        assert_eq!(kept, vec![g(10, 0)]);
629        // A waypoint between its neighbours on a straight run (0°) pins the wire's
630        // row; it is not a reversal, so it survives.
631        let (kept, removed) = pruned(&[(5, 0)], g(0, 0), g(10, 0));
632        assert!(!removed);
633        assert_eq!(kept.len(), 1);
634    }
635
636    #[test]
637    fn cascades_after_a_removal_exposes_a_new_reversal() {
638        // start (0,0) → (10,0) → (3,0) → end (5,0). Dropping the (10,0) overshoot
639        // leaves (0,0) → (3,0) → (5,0), a straight run, so (3,0) stays.
640        let (kept, removed) = pruned(&[(10, 0), (3, 0)], g(0, 0), g(5, 0));
641        assert!(removed);
642        assert_eq!(kept, vec![g(3, 0)]);
643    }
644}