Skip to main content

dioxus_flow/
path.rs

1//! Edge path construction: bezier, straight, and smooth-step (orthogonal)
2//! paths, plus label anchor points.
3
4use crate::types::{EdgeKind, Point, Rect, Side};
5
6/// Inputs for building an edge path between two handle anchors.
7#[derive(Clone, Copy, PartialEq, Debug)]
8pub struct EdgeGeometry {
9    pub source: Point,
10    pub source_side: Side,
11    pub target: Point,
12    pub target_side: Side,
13    /// Bounds of the source node, when known. Smooth-step routing detours
14    /// around these instead of cutting through the node.
15    pub source_rect: Option<Rect>,
16    /// Bounds of the target node, when known.
17    pub target_rect: Option<Rect>,
18}
19
20impl EdgeGeometry {
21    pub fn new(source: Point, source_side: Side, target: Point, target_side: Side) -> Self {
22        Self {
23            source,
24            source_side,
25            target,
26            target_side,
27            source_rect: None,
28            target_rect: None,
29        }
30    }
31
32    pub fn with_rects(mut self, source_rect: Rect, target_rect: Rect) -> Self {
33        self.source_rect = Some(source_rect);
34        self.target_rect = Some(target_rect);
35        self
36    }
37}
38
39/// A rendered edge path: the SVG `d` attribute and a label anchor point.
40#[derive(Clone, PartialEq, Debug)]
41pub struct EdgePath {
42    pub d: String,
43    pub label: Point,
44}
45
46/// Build the path for the given edge kind.
47pub fn edge_path(kind: EdgeKind, geo: &EdgeGeometry) -> EdgePath {
48    match kind {
49        EdgeKind::Bezier => bezier_path(geo, 0.25),
50        EdgeKind::Straight => straight_path(geo),
51        EdgeKind::SmoothStep => smooth_step_path(geo, 8.0),
52    }
53}
54
55fn fmt(v: f64) -> f64 {
56    // Round to limit path string churn/size; sub-0.01px is invisible.
57    (v * 100.0).round() / 100.0
58}
59
60/// A box the drawn edge is guaranteed to stay inside.
61///
62/// This is what lets edges be grouped into tiles that clip (see
63/// [`crate::tile`]): a bound that merely covered the endpoints would slice the
64/// bulge off every curve. It is deliberately conservative — never tight, never
65/// too small.
66///
67/// A cubic bezier is contained in the convex hull of its control points, so
68/// their bounding box bounds the curve exactly. The step routing only ever
69/// leaves the endpoints' box by its stub, and the straight path not at all.
70pub fn edge_bounds(kind: EdgeKind, geo: &EdgeGeometry) -> Rect {
71    let mut points = vec![geo.source, geo.target];
72    match kind {
73        EdgeKind::Bezier => {
74            points.push(control_point(geo.source, geo.source_side, geo.target, 0.25));
75            points.push(control_point(geo.target, geo.target_side, geo.source, 0.25));
76        }
77        EdgeKind::Straight => {}
78        EdgeKind::SmoothStep => points.extend(step_points(geo, 20.0)),
79    }
80    let mut bounds = Rect::new(points[0].x, points[0].y, 0.0, 0.0);
81    for point in &points[1..] {
82        bounds = bounds.union(&Rect::new(point.x, point.y, 0.0, 0.0));
83    }
84    // `fmt` rounds every coordinate it writes into the path, which can place a
85    // drawn point a rounding step outside the exact hull. Grow by that step so
86    // the bound holds what is drawn, not what was computed.
87    const ROUNDING: f64 = 0.01;
88    Rect::new(
89        bounds.x - ROUNDING,
90        bounds.y - ROUNDING,
91        bounds.width + 2.0 * ROUNDING,
92        bounds.height + 2.0 * ROUNDING,
93    )
94}
95
96/// Distance a bezier control point extends from its anchor. Mirrors
97/// react-flow: half the forward distance, or a curvature-scaled pullback when
98/// the target lies "behind" the anchor.
99fn control_offset(dist: f64, curvature: f64) -> f64 {
100    if dist >= 0.0 {
101        0.5 * dist
102    } else {
103        curvature * 25.0 * (-dist).sqrt()
104    }
105}
106
107fn control_point(p: Point, side: Side, other: Point, curvature: f64) -> Point {
108    match side {
109        Side::Left => Point::new(p.x - control_offset(p.x - other.x, curvature), p.y),
110        Side::Right => Point::new(p.x + control_offset(other.x - p.x, curvature), p.y),
111        Side::Top => Point::new(p.x, p.y - control_offset(p.y - other.y, curvature)),
112        Side::Bottom => Point::new(p.x, p.y + control_offset(other.y - p.y, curvature)),
113    }
114}
115
116/// Cubic bezier between the anchors, curving out of each side.
117pub fn bezier_path(geo: &EdgeGeometry, curvature: f64) -> EdgePath {
118    let s = geo.source;
119    let t = geo.target;
120    let c1 = control_point(s, geo.source_side, t, curvature);
121    let c2 = control_point(t, geo.target_side, s, curvature);
122    let d = format!(
123        "M{},{} C{},{} {},{} {},{}",
124        fmt(s.x),
125        fmt(s.y),
126        fmt(c1.x),
127        fmt(c1.y),
128        fmt(c2.x),
129        fmt(c2.y),
130        fmt(t.x),
131        fmt(t.y)
132    );
133    // Cubic bezier evaluated at t = 0.5.
134    let label = Point::new(
135        (s.x + 3.0 * c1.x + 3.0 * c2.x + t.x) / 8.0,
136        (s.y + 3.0 * c1.y + 3.0 * c2.y + t.y) / 8.0,
137    );
138    EdgePath { d, label }
139}
140
141/// A straight line between the anchors.
142pub fn straight_path(geo: &EdgeGeometry) -> EdgePath {
143    let s = geo.source;
144    let t = geo.target;
145    EdgePath {
146        d: format!("M{},{} L{},{}", fmt(s.x), fmt(s.y), fmt(t.x), fmt(t.y)),
147        label: s.lerp(t, 0.5),
148    }
149}
150
151/// Orthogonal path with rounded corners.
152pub fn smooth_step_path(geo: &EdgeGeometry, radius: f64) -> EdgePath {
153    let points = step_points(geo, 20.0);
154    let label = polyline_midpoint(&points);
155    EdgePath {
156        d: rounded_polyline(&points, radius),
157        label,
158    }
159}
160
161/// Waypoints of the orthogonal route, including both anchors. Routes that
162/// would double back through a node instead detour around its bounds (grown
163/// by the stub length), so back-edges never cut through either endpoint node.
164fn step_points(geo: &EdgeGeometry, stub: f64) -> Vec<Point> {
165    let s = geo.source;
166    let t = geo.target;
167    let ss = geo.source_side;
168    let ts = geo.target_side;
169    // Facing handles share the forward gap. Fixed stubs can cross in a
170    // narrow gap and incorrectly send a forward edge around the nodes.
171    // Back-edges and same/mixed-side connections retain their clearance.
172    let stub = if ss.opposite() == ts {
173        let normal = ss.normal();
174        let gap = (t.x - s.x) * normal.x + (t.y - s.y) * normal.y;
175        if gap >= 0.0 {
176            stub.min(gap / 2.0)
177        } else {
178            stub
179        }
180    } else {
181        stub
182    };
183    let s2 = s + ss.normal() * stub;
184    let t2 = t + ts.normal() * stub;
185    // Clearance boxes: node bounds grown by the stub so corridors keep stub
186    // distance from the nodes. Unknown bounds collapse to the anchor point.
187    let sr = inflate(
188        geo.source_rect
189            .unwrap_or_else(|| Rect::new(s.x, s.y, 0.0, 0.0)),
190        stub,
191    );
192    let tr = inflate(
193        geo.target_rect
194            .unwrap_or_else(|| Rect::new(t.x, t.y, 0.0, 0.0)),
195        stub,
196    );
197
198    let mut pts = vec![s, s2];
199    match (ss, ts) {
200        // Opposite horizontal sides: straight-through when the target lies
201        // ahead of the source anchor, otherwise around via a clear row.
202        (Side::Right, Side::Left) | (Side::Left, Side::Right) => {
203            let forward = if ss == Side::Right {
204                t.x >= s.x
205            } else {
206                t.x <= s.x
207            };
208            if forward {
209                let mid_x = (s2.x + t2.x) / 2.0;
210                pts.push(Point::new(mid_x, s2.y));
211                pts.push(Point::new(mid_x, t2.y));
212            } else {
213                let mid_y = clear_lane(sr.y, sr.max_y(), tr.y, tr.max_y(), s2.y, t2.y);
214                pts.push(Point::new(s2.x, mid_y));
215                pts.push(Point::new(t2.x, mid_y));
216            }
217        }
218        // Opposite vertical sides: mirror of the above.
219        (Side::Bottom, Side::Top) | (Side::Top, Side::Bottom) => {
220            let forward = if ss == Side::Bottom {
221                t.y >= s.y
222            } else {
223                t.y <= s.y
224            };
225            if forward {
226                let mid_y = (s2.y + t2.y) / 2.0;
227                pts.push(Point::new(s2.x, mid_y));
228                pts.push(Point::new(t2.x, mid_y));
229            } else {
230                let mid_x = clear_lane(sr.x, sr.max_x(), tr.x, tr.max_x(), s2.x, t2.x);
231                pts.push(Point::new(mid_x, s2.y));
232                pts.push(Point::new(mid_x, t2.y));
233            }
234        }
235        // Same horizontal side: run along the outer column shared by both.
236        (Side::Right, Side::Right) => {
237            let outer = s2.x.max(t2.x).max(sr.max_x()).max(tr.max_x());
238            pts.push(Point::new(outer, s2.y));
239            pts.push(Point::new(outer, t2.y));
240        }
241        (Side::Left, Side::Left) => {
242            let outer = s2.x.min(t2.x).min(sr.x).min(tr.x);
243            pts.push(Point::new(outer, s2.y));
244            pts.push(Point::new(outer, t2.y));
245        }
246        // Same vertical side.
247        (Side::Bottom, Side::Bottom) => {
248            let outer = s2.y.max(t2.y).max(sr.max_y()).max(tr.max_y());
249            pts.push(Point::new(s2.x, outer));
250            pts.push(Point::new(t2.x, outer));
251        }
252        (Side::Top, Side::Top) => {
253            let outer = s2.y.min(t2.y).min(sr.y).min(tr.y);
254            pts.push(Point::new(s2.x, outer));
255            pts.push(Point::new(t2.x, outer));
256        }
257        // Horizontal source, vertical target: one corner when it bends
258        // forward at both ends, otherwise around the target's clear column.
259        (Side::Right | Side::Left, _) => {
260            let source_ok = if ss == Side::Right {
261                t2.x >= s2.x
262            } else {
263                t2.x <= s2.x
264            };
265            let target_ok = if ts == Side::Top {
266                s2.y <= t2.y
267            } else {
268                s2.y >= t2.y
269            };
270            if source_ok && target_ok {
271                pts.push(Point::new(t2.x, s2.y));
272            } else {
273                let x_out = if ss == Side::Right {
274                    s2.x.max(tr.max_x())
275                } else {
276                    s2.x.min(tr.x)
277                };
278                pts.push(Point::new(x_out, s2.y));
279                pts.push(Point::new(x_out, t2.y));
280            }
281        }
282        // Vertical source, horizontal target: mirror of the above.
283        (_, Side::Right | Side::Left) => {
284            let source_ok = if ss == Side::Bottom {
285                t2.y >= s2.y
286            } else {
287                t2.y <= s2.y
288            };
289            let target_ok = if ts == Side::Left {
290                s2.x <= t2.x
291            } else {
292                s2.x >= t2.x
293            };
294            if source_ok && target_ok {
295                pts.push(Point::new(s2.x, t2.y));
296            } else {
297                let y_out = if ss == Side::Bottom {
298                    s2.y.max(tr.max_y())
299                } else {
300                    s2.y.min(tr.y)
301                };
302                pts.push(Point::new(s2.x, y_out));
303                pts.push(Point::new(t2.x, y_out));
304            }
305        }
306    }
307    pts.push(t2);
308    pts.push(t);
309    simplify_points(pts)
310}
311
312fn inflate(r: Rect, m: f64) -> Rect {
313    Rect::new(r.x - m, r.y - m, r.width + 2.0 * m, r.height + 2.0 * m)
314}
315
316/// A coordinate for a corridor that clears both spans `(a0..a1)` and
317/// `(b0..b1)`: the middle of the gap between them when they are disjoint,
318/// otherwise just past whichever outer edge is closer to the two stubs.
319fn clear_lane(a0: f64, a1: f64, b0: f64, b1: f64, stub_a: f64, stub_b: f64) -> f64 {
320    if a1 < b0 {
321        return (a1 + b0) / 2.0;
322    }
323    if b1 < a0 {
324        return (b1 + a0) / 2.0;
325    }
326    let lo = a0.min(b0);
327    let hi = a1.max(b1);
328    let before = (stub_a - lo) + (stub_b - lo);
329    let after = (hi - stub_a) + (hi - stub_b);
330    if before <= after {
331        lo
332    } else {
333        hi
334    }
335}
336
337/// Drop consecutive duplicates and interior points that continue straight,
338/// so corners in the output are genuine turns.
339fn simplify_points(pts: Vec<Point>) -> Vec<Point> {
340    let mut out: Vec<Point> = Vec::with_capacity(pts.len());
341    for p in pts {
342        if out.last().map(|l| l.distance(p) > 0.01).unwrap_or(true) {
343            out.push(p);
344        }
345    }
346    let mut i = 1;
347    while i + 1 < out.len() {
348        let ab = out[i] - out[i - 1];
349        let bc = out[i + 1] - out[i];
350        let cross = ab.x * bc.y - ab.y * bc.x;
351        let dot = ab.x * bc.x + ab.y * bc.y;
352        if cross.abs() < 1e-6 && dot > 0.0 {
353            out.remove(i);
354        } else {
355            i += 1;
356        }
357    }
358    out
359}
360
361/// Build an SVG path from a polyline, rounding interior corners with
362/// quadratic curves.
363fn rounded_polyline(pts: &[Point], radius: f64) -> String {
364    if pts.is_empty() {
365        return String::new();
366    }
367    let mut d = format!("M{},{}", fmt(pts[0].x), fmt(pts[0].y));
368    for i in 1..pts.len().saturating_sub(1) {
369        let prev = pts[i - 1];
370        let p = pts[i];
371        let next = pts[i + 1];
372        let len_in = prev.distance(p);
373        let len_out = p.distance(next);
374        // A collinear point (straight continuation or 180° reversal) has no
375        // corner to round; rounding it would emit a zero-length curve.
376        let ab = p - prev;
377        let bc = next - p;
378        let collinear = (ab.x * bc.y - ab.y * bc.x).abs() < 1e-6;
379        let r = radius.min(len_in / 2.0).min(len_out / 2.0);
380        if r < 0.1 || collinear {
381            d.push_str(&format!(" L{},{}", fmt(p.x), fmt(p.y)));
382            continue;
383        }
384        let a = p + (prev - p) * (r / len_in);
385        let b = p + (next - p) * (r / len_out);
386        d.push_str(&format!(
387            " L{},{} Q{},{} {},{}",
388            fmt(a.x),
389            fmt(a.y),
390            fmt(p.x),
391            fmt(p.y),
392            fmt(b.x),
393            fmt(b.y)
394        ));
395    }
396    if let Some(last) = pts.last() {
397        if pts.len() > 1 {
398            d.push_str(&format!(" L{},{}", fmt(last.x), fmt(last.y)));
399        }
400    }
401    d
402}
403
404/// The point halfway along a polyline (by arc length).
405fn polyline_midpoint(pts: &[Point]) -> Point {
406    if pts.is_empty() {
407        return Point::ZERO;
408    }
409    let total: f64 = pts.windows(2).map(|w| w[0].distance(w[1])).sum();
410    if total <= f64::EPSILON {
411        return pts[0];
412    }
413    let mut remaining = total / 2.0;
414    for w in pts.windows(2) {
415        let len = w[0].distance(w[1]);
416        if remaining <= len {
417            return w[0].lerp(w[1], remaining / len);
418        }
419        remaining -= len;
420    }
421    *pts.last().unwrap()
422}
423
424/// Path used for the in-progress connection line (a bezier from the source
425/// handle toward the cursor / snap target).
426pub fn connection_path(from: Point, from_side: Side, to: Point, to_side: Option<Side>) -> String {
427    let geo = EdgeGeometry::new(
428        from,
429        from_side,
430        to,
431        to_side.unwrap_or_else(|| from_side.opposite()),
432    );
433    bezier_path(&geo, 0.25).d
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn geo(s: (f64, f64), ss: Side, t: (f64, f64), ts: Side) -> EdgeGeometry {
441        EdgeGeometry::new(s.into(), ss, t.into(), ts)
442    }
443
444    /// Every number in a path's `d`, as the points they describe. All three
445    /// path kinds emit plain `x,y` pairs, so this reads the curve back out of
446    /// what is actually drawn rather than trusting the maths twice.
447    fn drawn_points(d: &str) -> Vec<Point> {
448        let numbers: Vec<f64> = d
449            .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
450            .filter(|s| !s.is_empty())
451            .filter_map(|s| s.parse().ok())
452            .collect();
453        numbers
454            .chunks(2)
455            .filter(|c| c.len() == 2)
456            .map(|c| Point::new(c[0], c[1]))
457            .collect()
458    }
459
460    /// The property that lets edges live in tiles that clip: whatever is
461    /// drawn, the bound contains it. Swept over every kind, every pair of
462    /// sides, and endpoints in every relative direction — including the
463    /// backwards case, where the curve loops out behind its own anchor.
464    #[test]
465    fn the_bound_contains_everything_drawn() {
466        let sides = [Side::Left, Side::Right, Side::Top, Side::Bottom];
467        let kinds = [EdgeKind::Bezier, EdgeKind::Straight, EdgeKind::SmoothStep];
468        let offsets = [
469            (0.0, 0.0),
470            (200.0, 0.0),
471            (-200.0, 0.0),
472            (0.0, 200.0),
473            (0.0, -200.0),
474            (300.0, 180.0),
475            (-300.0, 180.0),
476            (300.0, -180.0),
477            (-300.0, -180.0),
478            (12.0, 5.0),
479            (-7.0, -3.0),
480            (1200.0, -900.0),
481        ];
482        for kind in kinds {
483            for ss in sides {
484                for ts in sides {
485                    for (dx, dy) in offsets {
486                        let g = geo((140.0, 90.0), ss, (140.0 + dx, 90.0 + dy), ts);
487                        let bounds = edge_bounds(kind, &g);
488                        for p in drawn_points(&edge_path(kind, &g).d) {
489                            assert!(
490                                p.x >= bounds.x - 1e-6
491                                    && p.x <= bounds.max_x() + 1e-6
492                                    && p.y >= bounds.y - 1e-6
493                                    && p.y <= bounds.max_y() + 1e-6,
494                                "{kind:?} {ss:?}->{ts:?} d=({dx},{dy}): drawn {p:?} \
495                                 escapes bound {bounds:?}",
496                            );
497                        }
498                    }
499                }
500            }
501        }
502    }
503
504    /// A bound is a box, not a point: it stays usable even when the two
505    /// anchors coincide.
506    #[test]
507    fn a_degenerate_edge_still_has_a_finite_bound() {
508        for kind in [EdgeKind::Bezier, EdgeKind::Straight, EdgeKind::SmoothStep] {
509            let bounds = edge_bounds(
510                kind,
511                &geo((10.0, 10.0), Side::Right, (10.0, 10.0), Side::Left),
512            );
513            assert!(bounds.x.is_finite() && bounds.y.is_finite());
514            assert!(bounds.width.is_finite() && bounds.height.is_finite());
515            assert!(bounds.width >= 0.0 && bounds.height >= 0.0);
516        }
517    }
518
519    fn assert_no_reversals(pts: &[Point]) {
520        for w in pts.windows(3) {
521            let ab = w[1] - w[0];
522            let bc = w[2] - w[1];
523            let cross = ab.x * bc.y - ab.y * bc.x;
524            let dot = ab.x * bc.x + ab.y * bc.y;
525            assert!(
526                cross.abs() > 1e-6 || dot > 0.0,
527                "route doubles back at {:?} in {:?}",
528                w[1],
529                pts
530            );
531        }
532    }
533
534    fn strictly_inside(r: &Rect, p: Point) -> bool {
535        p.x > r.x && p.x < r.max_x() && p.y > r.y && p.y < r.max_y()
536    }
537
538    #[test]
539    fn bezier_endpoints() {
540        let g = geo((0.0, 0.0), Side::Bottom, (100.0, 200.0), Side::Top);
541        let p = bezier_path(&g, 0.25);
542        assert!(p.d.starts_with("M0,0 C"));
543        assert!(p.d.ends_with("100,200"));
544        // Label sits between the endpoints.
545        assert!(p.label.y > 0.0 && p.label.y < 200.0);
546        assert_eq!(p.label.x, 50.0);
547    }
548
549    #[test]
550    fn straight_midpoint() {
551        let g = geo((0.0, 0.0), Side::Right, (10.0, 10.0), Side::Left);
552        let p = straight_path(&g);
553        assert_eq!(p.label, Point::new(5.0, 5.0));
554    }
555
556    #[test]
557    fn smooth_step_valid_path() {
558        let g = geo((0.0, 0.0), Side::Right, (200.0, 100.0), Side::Left);
559        let p = smooth_step_path(&g, 8.0);
560        assert!(p.d.starts_with("M0,0"));
561        assert!(p.d.ends_with("L200,100"));
562        assert!(p.d.contains('Q'), "expected rounded corners: {}", p.d);
563    }
564
565    #[test]
566    fn smooth_step_facing_handles_stay_between_endpoints() {
567        for side in [Side::Right, Side::Left, Side::Bottom, Side::Top] {
568            let normal = side.normal();
569            let lateral = Point::new(-normal.y, normal.x);
570            // The renderer has already trimmed anchors to the handle rims.
571            // Sweep the old two-stub threshold, including coincident rims.
572            for gap in [0.0, 0.5, 30.0, 39.99, 40.0, 40.01, 200.0] {
573                for offset in [-12.0, 0.0, 12.0] {
574                    let source = Point::new(100.0, 100.0);
575                    let target = source + normal * gap + lateral * offset;
576                    let geometry = EdgeGeometry::new(source, side, target, side.opposite());
577                    let source_corner = source - normal * 85.0 - Point::new(80.0, 80.0);
578                    let target_corner = target + normal * 85.0 - Point::new(80.0, 80.0);
579                    let with_rects = geometry.with_rects(
580                        Rect::new(source_corner.x, source_corner.y, 160.0, 160.0),
581                        Rect::new(target_corner.x, target_corner.y, 160.0, 160.0),
582                    );
583                    for geometry in [geometry, with_rects] {
584                        let path = smooth_step_path(&geometry, 8.0);
585                        let points = drawn_points(&path.d);
586                        assert!(points.first().unwrap().distance(source) < 0.01);
587                        assert!(points.last().unwrap().distance(target) < 0.01);
588                        assert_no_reversals(&step_points(&geometry, 20.0));
589                        // Bézier control points and the label must stay in the
590                        // endpoint rectangle, so the drawn curve cannot loop
591                        // above/below the nodes or overshoot either anchor.
592                        for point in points.iter().chain(std::iter::once(&path.label)) {
593                            assert!(
594                                point.x.is_finite()
595                                    && point.y.is_finite()
596                                    && point.x >= source.x.min(target.x) - 0.01
597                                    && point.x <= source.x.max(target.x) + 0.01
598                                    && point.y >= source.y.min(target.y) - 0.01
599                                    && point.y <= source.y.max(target.y) + 0.01,
600                                "{side:?}, gap={gap}, offset={offset}: {point:?} escapes {}",
601                                path.d,
602                            );
603                        }
604                    }
605                }
606            }
607        }
608    }
609
610    #[test]
611    fn smooth_step_mixed_sides() {
612        let g = geo((0.0, 0.0), Side::Bottom, (200.0, 100.0), Side::Left);
613        let p = smooth_step_path(&g, 8.0);
614        assert!(p.d.starts_with("M0,0"));
615        assert!(p.d.ends_with("L200,100"));
616    }
617
618    #[test]
619    fn smooth_step_degenerate_same_point() {
620        let g = geo((50.0, 50.0), Side::Right, (50.0, 50.0), Side::Left);
621        let p = smooth_step_path(&g, 8.0);
622        assert!(p.d.starts_with("M50,50"));
623    }
624
625    #[test]
626    fn smooth_step_back_edge_detours_horizontally() {
627        // Left-to-right layout, feedback edge: target sits behind the source
628        // on the same row. The route must leave through a clear lane above or
629        // below both nodes instead of cutting back through them.
630        let source_rect = Rect::new(400.0, 80.0, 160.0, 56.0);
631        let target_rect = Rect::new(100.0, 80.0, 160.0, 56.0);
632        let g = geo((560.0, 108.0), Side::Right, (100.0, 108.0), Side::Left)
633            .with_rects(source_rect, target_rect);
634        let pts = step_points(&g, 20.0);
635        assert_no_reversals(&pts);
636        for p in &pts {
637            assert!(
638                !strictly_inside(&source_rect, *p) && !strictly_inside(&target_rect, *p),
639                "waypoint {p:?} crosses a node in {pts:?}"
640            );
641        }
642        assert!(
643            pts.iter().any(|p| p.y <= 60.0 || p.y >= 156.0),
644            "no clear lane in {pts:?}"
645        );
646    }
647
648    #[test]
649    fn smooth_step_back_edge_detours_vertically() {
650        // Vertical stack, feedback edge from the lower node's bottom to the
651        // upper node's top: must loop around the side, not overlap the
652        // forward edge's column.
653        let target_rect = Rect::new(160.0, 450.0, 180.0, 56.0);
654        let source_rect = Rect::new(160.0, 600.0, 180.0, 56.0);
655        let g = geo((250.0, 656.0), Side::Bottom, (250.0, 450.0), Side::Top)
656            .with_rects(source_rect, target_rect);
657        let pts = step_points(&g, 20.0);
658        assert_no_reversals(&pts);
659        for p in &pts {
660            assert!(
661                !strictly_inside(&source_rect, *p) && !strictly_inside(&target_rect, *p),
662                "waypoint {p:?} crosses a node in {pts:?}"
663            );
664        }
665        assert!(
666            pts.iter().any(|p| p.x <= 140.0 || p.x >= 360.0),
667            "route never left the forward edge's column: {pts:?}"
668        );
669    }
670
671    #[test]
672    fn smooth_step_same_side_routes_outside_both() {
673        let source_rect = Rect::new(0.0, -20.0, 100.0, 40.0);
674        let target_rect = Rect::new(200.0, 180.0, 100.0, 40.0);
675        let g = geo((100.0, 0.0), Side::Right, (300.0, 200.0), Side::Right)
676            .with_rects(source_rect, target_rect);
677        let pts = step_points(&g, 20.0);
678        assert_no_reversals(&pts);
679        let max_x = pts.iter().fold(f64::MIN, |m, p| m.max(p.x));
680        assert!(max_x >= 320.0, "same-side route stayed inside: {pts:?}");
681        assert_eq!(*pts.last().unwrap(), Point::new(300.0, 200.0));
682    }
683}