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    let s2 = s + ss.normal() * stub;
170    let t2 = t + ts.normal() * stub;
171    // Clearance boxes: node bounds grown by the stub so corridors keep stub
172    // distance from the nodes. Unknown bounds collapse to the anchor point.
173    let sr = inflate(
174        geo.source_rect
175            .unwrap_or_else(|| Rect::new(s.x, s.y, 0.0, 0.0)),
176        stub,
177    );
178    let tr = inflate(
179        geo.target_rect
180            .unwrap_or_else(|| Rect::new(t.x, t.y, 0.0, 0.0)),
181        stub,
182    );
183
184    let mut pts = vec![s, s2];
185    match (ss, ts) {
186        // Opposite horizontal sides: straight-through when the target lies
187        // ahead of the source stub, otherwise around via a clear row.
188        (Side::Right, Side::Left) | (Side::Left, Side::Right) => {
189            let forward = if ss == Side::Right {
190                t2.x >= s2.x
191            } else {
192                t2.x <= s2.x
193            };
194            if forward {
195                let mid_x = (s2.x + t2.x) / 2.0;
196                pts.push(Point::new(mid_x, s2.y));
197                pts.push(Point::new(mid_x, t2.y));
198            } else {
199                let mid_y = clear_lane(sr.y, sr.max_y(), tr.y, tr.max_y(), s2.y, t2.y);
200                pts.push(Point::new(s2.x, mid_y));
201                pts.push(Point::new(t2.x, mid_y));
202            }
203        }
204        // Opposite vertical sides: mirror of the above.
205        (Side::Bottom, Side::Top) | (Side::Top, Side::Bottom) => {
206            let forward = if ss == Side::Bottom {
207                t2.y >= s2.y
208            } else {
209                t2.y <= s2.y
210            };
211            if forward {
212                let mid_y = (s2.y + t2.y) / 2.0;
213                pts.push(Point::new(s2.x, mid_y));
214                pts.push(Point::new(t2.x, mid_y));
215            } else {
216                let mid_x = clear_lane(sr.x, sr.max_x(), tr.x, tr.max_x(), s2.x, t2.x);
217                pts.push(Point::new(mid_x, s2.y));
218                pts.push(Point::new(mid_x, t2.y));
219            }
220        }
221        // Same horizontal side: run along the outer column shared by both.
222        (Side::Right, Side::Right) => {
223            let outer = s2.x.max(t2.x).max(sr.max_x()).max(tr.max_x());
224            pts.push(Point::new(outer, s2.y));
225            pts.push(Point::new(outer, t2.y));
226        }
227        (Side::Left, Side::Left) => {
228            let outer = s2.x.min(t2.x).min(sr.x).min(tr.x);
229            pts.push(Point::new(outer, s2.y));
230            pts.push(Point::new(outer, t2.y));
231        }
232        // Same vertical side.
233        (Side::Bottom, Side::Bottom) => {
234            let outer = s2.y.max(t2.y).max(sr.max_y()).max(tr.max_y());
235            pts.push(Point::new(s2.x, outer));
236            pts.push(Point::new(t2.x, outer));
237        }
238        (Side::Top, Side::Top) => {
239            let outer = s2.y.min(t2.y).min(sr.y).min(tr.y);
240            pts.push(Point::new(s2.x, outer));
241            pts.push(Point::new(t2.x, outer));
242        }
243        // Horizontal source, vertical target: one corner when it bends
244        // forward at both ends, otherwise around the target's clear column.
245        (Side::Right | Side::Left, _) => {
246            let source_ok = if ss == Side::Right {
247                t2.x >= s2.x
248            } else {
249                t2.x <= s2.x
250            };
251            let target_ok = if ts == Side::Top {
252                s2.y <= t2.y
253            } else {
254                s2.y >= t2.y
255            };
256            if source_ok && target_ok {
257                pts.push(Point::new(t2.x, s2.y));
258            } else {
259                let x_out = if ss == Side::Right {
260                    s2.x.max(tr.max_x())
261                } else {
262                    s2.x.min(tr.x)
263                };
264                pts.push(Point::new(x_out, s2.y));
265                pts.push(Point::new(x_out, t2.y));
266            }
267        }
268        // Vertical source, horizontal target: mirror of the above.
269        (_, Side::Right | Side::Left) => {
270            let source_ok = if ss == Side::Bottom {
271                t2.y >= s2.y
272            } else {
273                t2.y <= s2.y
274            };
275            let target_ok = if ts == Side::Left {
276                s2.x <= t2.x
277            } else {
278                s2.x >= t2.x
279            };
280            if source_ok && target_ok {
281                pts.push(Point::new(s2.x, t2.y));
282            } else {
283                let y_out = if ss == Side::Bottom {
284                    s2.y.max(tr.max_y())
285                } else {
286                    s2.y.min(tr.y)
287                };
288                pts.push(Point::new(s2.x, y_out));
289                pts.push(Point::new(t2.x, y_out));
290            }
291        }
292    }
293    pts.push(t2);
294    pts.push(t);
295    simplify_points(pts)
296}
297
298fn inflate(r: Rect, m: f64) -> Rect {
299    Rect::new(r.x - m, r.y - m, r.width + 2.0 * m, r.height + 2.0 * m)
300}
301
302/// A coordinate for a corridor that clears both spans `(a0..a1)` and
303/// `(b0..b1)`: the middle of the gap between them when they are disjoint,
304/// otherwise just past whichever outer edge is closer to the two stubs.
305fn clear_lane(a0: f64, a1: f64, b0: f64, b1: f64, stub_a: f64, stub_b: f64) -> f64 {
306    if a1 < b0 {
307        return (a1 + b0) / 2.0;
308    }
309    if b1 < a0 {
310        return (b1 + a0) / 2.0;
311    }
312    let lo = a0.min(b0);
313    let hi = a1.max(b1);
314    let before = (stub_a - lo) + (stub_b - lo);
315    let after = (hi - stub_a) + (hi - stub_b);
316    if before <= after {
317        lo
318    } else {
319        hi
320    }
321}
322
323/// Drop consecutive duplicates and interior points that continue straight,
324/// so corners in the output are genuine turns.
325fn simplify_points(pts: Vec<Point>) -> Vec<Point> {
326    let mut out: Vec<Point> = Vec::with_capacity(pts.len());
327    for p in pts {
328        if out.last().map(|l| l.distance(p) > 0.01).unwrap_or(true) {
329            out.push(p);
330        }
331    }
332    let mut i = 1;
333    while i + 1 < out.len() {
334        let ab = out[i] - out[i - 1];
335        let bc = out[i + 1] - out[i];
336        let cross = ab.x * bc.y - ab.y * bc.x;
337        let dot = ab.x * bc.x + ab.y * bc.y;
338        if cross.abs() < 1e-6 && dot > 0.0 {
339            out.remove(i);
340        } else {
341            i += 1;
342        }
343    }
344    out
345}
346
347/// Build an SVG path from a polyline, rounding interior corners with
348/// quadratic curves.
349fn rounded_polyline(pts: &[Point], radius: f64) -> String {
350    if pts.is_empty() {
351        return String::new();
352    }
353    let mut d = format!("M{},{}", fmt(pts[0].x), fmt(pts[0].y));
354    for i in 1..pts.len().saturating_sub(1) {
355        let prev = pts[i - 1];
356        let p = pts[i];
357        let next = pts[i + 1];
358        let len_in = prev.distance(p);
359        let len_out = p.distance(next);
360        // A collinear point (straight continuation or 180° reversal) has no
361        // corner to round; rounding it would emit a zero-length curve.
362        let ab = p - prev;
363        let bc = next - p;
364        let collinear = (ab.x * bc.y - ab.y * bc.x).abs() < 1e-6;
365        let r = radius.min(len_in / 2.0).min(len_out / 2.0);
366        if r < 0.1 || collinear {
367            d.push_str(&format!(" L{},{}", fmt(p.x), fmt(p.y)));
368            continue;
369        }
370        let a = p + (prev - p) * (r / len_in);
371        let b = p + (next - p) * (r / len_out);
372        d.push_str(&format!(
373            " L{},{} Q{},{} {},{}",
374            fmt(a.x),
375            fmt(a.y),
376            fmt(p.x),
377            fmt(p.y),
378            fmt(b.x),
379            fmt(b.y)
380        ));
381    }
382    if let Some(last) = pts.last() {
383        if pts.len() > 1 {
384            d.push_str(&format!(" L{},{}", fmt(last.x), fmt(last.y)));
385        }
386    }
387    d
388}
389
390/// The point halfway along a polyline (by arc length).
391fn polyline_midpoint(pts: &[Point]) -> Point {
392    if pts.is_empty() {
393        return Point::ZERO;
394    }
395    let total: f64 = pts.windows(2).map(|w| w[0].distance(w[1])).sum();
396    if total <= f64::EPSILON {
397        return pts[0];
398    }
399    let mut remaining = total / 2.0;
400    for w in pts.windows(2) {
401        let len = w[0].distance(w[1]);
402        if remaining <= len {
403            return w[0].lerp(w[1], remaining / len);
404        }
405        remaining -= len;
406    }
407    *pts.last().unwrap()
408}
409
410/// Path used for the in-progress connection line (a bezier from the source
411/// handle toward the cursor / snap target).
412pub fn connection_path(from: Point, from_side: Side, to: Point, to_side: Option<Side>) -> String {
413    let geo = EdgeGeometry::new(
414        from,
415        from_side,
416        to,
417        to_side.unwrap_or_else(|| from_side.opposite()),
418    );
419    bezier_path(&geo, 0.25).d
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    fn geo(s: (f64, f64), ss: Side, t: (f64, f64), ts: Side) -> EdgeGeometry {
427        EdgeGeometry::new(s.into(), ss, t.into(), ts)
428    }
429
430    /// Every number in a path's `d`, as the points they describe. All three
431    /// path kinds emit plain `x,y` pairs, so this reads the curve back out of
432    /// what is actually drawn rather than trusting the maths twice.
433    fn drawn_points(d: &str) -> Vec<Point> {
434        let numbers: Vec<f64> = d
435            .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
436            .filter(|s| !s.is_empty())
437            .filter_map(|s| s.parse().ok())
438            .collect();
439        numbers
440            .chunks(2)
441            .filter(|c| c.len() == 2)
442            .map(|c| Point::new(c[0], c[1]))
443            .collect()
444    }
445
446    /// The property that lets edges live in tiles that clip: whatever is
447    /// drawn, the bound contains it. Swept over every kind, every pair of
448    /// sides, and endpoints in every relative direction — including the
449    /// backwards case, where the curve loops out behind its own anchor.
450    #[test]
451    fn the_bound_contains_everything_drawn() {
452        let sides = [Side::Left, Side::Right, Side::Top, Side::Bottom];
453        let kinds = [EdgeKind::Bezier, EdgeKind::Straight, EdgeKind::SmoothStep];
454        let offsets = [
455            (0.0, 0.0),
456            (200.0, 0.0),
457            (-200.0, 0.0),
458            (0.0, 200.0),
459            (0.0, -200.0),
460            (300.0, 180.0),
461            (-300.0, 180.0),
462            (300.0, -180.0),
463            (-300.0, -180.0),
464            (12.0, 5.0),
465            (-7.0, -3.0),
466            (1200.0, -900.0),
467        ];
468        for kind in kinds {
469            for ss in sides {
470                for ts in sides {
471                    for (dx, dy) in offsets {
472                        let g = geo((140.0, 90.0), ss, (140.0 + dx, 90.0 + dy), ts);
473                        let bounds = edge_bounds(kind, &g);
474                        for p in drawn_points(&edge_path(kind, &g).d) {
475                            assert!(
476                                p.x >= bounds.x - 1e-6
477                                    && p.x <= bounds.max_x() + 1e-6
478                                    && p.y >= bounds.y - 1e-6
479                                    && p.y <= bounds.max_y() + 1e-6,
480                                "{kind:?} {ss:?}->{ts:?} d=({dx},{dy}): drawn {p:?} \
481                                 escapes bound {bounds:?}",
482                            );
483                        }
484                    }
485                }
486            }
487        }
488    }
489
490    /// A bound is a box, not a point: it stays usable even when the two
491    /// anchors coincide.
492    #[test]
493    fn a_degenerate_edge_still_has_a_finite_bound() {
494        for kind in [EdgeKind::Bezier, EdgeKind::Straight, EdgeKind::SmoothStep] {
495            let bounds = edge_bounds(
496                kind,
497                &geo((10.0, 10.0), Side::Right, (10.0, 10.0), Side::Left),
498            );
499            assert!(bounds.x.is_finite() && bounds.y.is_finite());
500            assert!(bounds.width.is_finite() && bounds.height.is_finite());
501            assert!(bounds.width >= 0.0 && bounds.height >= 0.0);
502        }
503    }
504
505    fn assert_no_reversals(pts: &[Point]) {
506        for w in pts.windows(3) {
507            let ab = w[1] - w[0];
508            let bc = w[2] - w[1];
509            let cross = ab.x * bc.y - ab.y * bc.x;
510            let dot = ab.x * bc.x + ab.y * bc.y;
511            assert!(
512                cross.abs() > 1e-6 || dot > 0.0,
513                "route doubles back at {:?} in {:?}",
514                w[1],
515                pts
516            );
517        }
518    }
519
520    fn strictly_inside(r: &Rect, p: Point) -> bool {
521        p.x > r.x && p.x < r.max_x() && p.y > r.y && p.y < r.max_y()
522    }
523
524    #[test]
525    fn bezier_endpoints() {
526        let g = geo((0.0, 0.0), Side::Bottom, (100.0, 200.0), Side::Top);
527        let p = bezier_path(&g, 0.25);
528        assert!(p.d.starts_with("M0,0 C"));
529        assert!(p.d.ends_with("100,200"));
530        // Label sits between the endpoints.
531        assert!(p.label.y > 0.0 && p.label.y < 200.0);
532        assert_eq!(p.label.x, 50.0);
533    }
534
535    #[test]
536    fn straight_midpoint() {
537        let g = geo((0.0, 0.0), Side::Right, (10.0, 10.0), Side::Left);
538        let p = straight_path(&g);
539        assert_eq!(p.label, Point::new(5.0, 5.0));
540    }
541
542    #[test]
543    fn smooth_step_valid_path() {
544        let g = geo((0.0, 0.0), Side::Right, (200.0, 100.0), Side::Left);
545        let p = smooth_step_path(&g, 8.0);
546        assert!(p.d.starts_with("M0,0"));
547        assert!(p.d.ends_with("L200,100"));
548        assert!(p.d.contains('Q'), "expected rounded corners: {}", p.d);
549    }
550
551    #[test]
552    fn smooth_step_mixed_sides() {
553        let g = geo((0.0, 0.0), Side::Bottom, (200.0, 100.0), Side::Left);
554        let p = smooth_step_path(&g, 8.0);
555        assert!(p.d.starts_with("M0,0"));
556        assert!(p.d.ends_with("L200,100"));
557    }
558
559    #[test]
560    fn smooth_step_degenerate_same_point() {
561        let g = geo((50.0, 50.0), Side::Right, (50.0, 50.0), Side::Left);
562        let p = smooth_step_path(&g, 8.0);
563        assert!(p.d.starts_with("M50,50"));
564    }
565
566    #[test]
567    fn smooth_step_back_edge_detours_horizontally() {
568        // Left-to-right layout, feedback edge: target sits behind the source
569        // on the same row. The route must leave through a clear lane above or
570        // below both nodes instead of cutting back through them.
571        let source_rect = Rect::new(400.0, 80.0, 160.0, 56.0);
572        let target_rect = Rect::new(100.0, 80.0, 160.0, 56.0);
573        let g = geo((560.0, 108.0), Side::Right, (100.0, 108.0), Side::Left)
574            .with_rects(source_rect, target_rect);
575        let pts = step_points(&g, 20.0);
576        assert_no_reversals(&pts);
577        for p in &pts {
578            assert!(
579                !strictly_inside(&source_rect, *p) && !strictly_inside(&target_rect, *p),
580                "waypoint {p:?} crosses a node in {pts:?}"
581            );
582        }
583        assert!(
584            pts.iter().any(|p| p.y <= 60.0 || p.y >= 156.0),
585            "no clear lane in {pts:?}"
586        );
587    }
588
589    #[test]
590    fn smooth_step_back_edge_detours_vertically() {
591        // Vertical stack, feedback edge from the lower node's bottom to the
592        // upper node's top: must loop around the side, not overlap the
593        // forward edge's column.
594        let target_rect = Rect::new(160.0, 450.0, 180.0, 56.0);
595        let source_rect = Rect::new(160.0, 600.0, 180.0, 56.0);
596        let g = geo((250.0, 656.0), Side::Bottom, (250.0, 450.0), Side::Top)
597            .with_rects(source_rect, target_rect);
598        let pts = step_points(&g, 20.0);
599        assert_no_reversals(&pts);
600        for p in &pts {
601            assert!(
602                !strictly_inside(&source_rect, *p) && !strictly_inside(&target_rect, *p),
603                "waypoint {p:?} crosses a node in {pts:?}"
604            );
605        }
606        assert!(
607            pts.iter().any(|p| p.x <= 140.0 || p.x >= 360.0),
608            "route never left the forward edge's column: {pts:?}"
609        );
610    }
611
612    #[test]
613    fn smooth_step_same_side_routes_outside_both() {
614        let source_rect = Rect::new(0.0, -20.0, 100.0, 40.0);
615        let target_rect = Rect::new(200.0, 180.0, 100.0, 40.0);
616        let g = geo((100.0, 0.0), Side::Right, (300.0, 200.0), Side::Right)
617            .with_rects(source_rect, target_rect);
618        let pts = step_points(&g, 20.0);
619        assert_no_reversals(&pts);
620        let max_x = pts.iter().fold(f64::MIN, |m, p| m.max(p.x));
621        assert!(max_x >= 320.0, "same-side route stayed inside: {pts:?}");
622        assert_eq!(*pts.last().unwrap(), Point::new(300.0, 200.0));
623    }
624}