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/// Distance a bezier control point extends from its anchor. Mirrors
61/// react-flow: half the forward distance, or a curvature-scaled pullback when
62/// the target lies "behind" the anchor.
63fn control_offset(dist: f64, curvature: f64) -> f64 {
64    if dist >= 0.0 {
65        0.5 * dist
66    } else {
67        curvature * 25.0 * (-dist).sqrt()
68    }
69}
70
71fn control_point(p: Point, side: Side, other: Point, curvature: f64) -> Point {
72    match side {
73        Side::Left => Point::new(p.x - control_offset(p.x - other.x, curvature), p.y),
74        Side::Right => Point::new(p.x + control_offset(other.x - p.x, curvature), p.y),
75        Side::Top => Point::new(p.x, p.y - control_offset(p.y - other.y, curvature)),
76        Side::Bottom => Point::new(p.x, p.y + control_offset(other.y - p.y, curvature)),
77    }
78}
79
80/// Cubic bezier between the anchors, curving out of each side.
81pub fn bezier_path(geo: &EdgeGeometry, curvature: f64) -> EdgePath {
82    let s = geo.source;
83    let t = geo.target;
84    let c1 = control_point(s, geo.source_side, t, curvature);
85    let c2 = control_point(t, geo.target_side, s, curvature);
86    let d = format!(
87        "M{},{} C{},{} {},{} {},{}",
88        fmt(s.x),
89        fmt(s.y),
90        fmt(c1.x),
91        fmt(c1.y),
92        fmt(c2.x),
93        fmt(c2.y),
94        fmt(t.x),
95        fmt(t.y)
96    );
97    // Cubic bezier evaluated at t = 0.5.
98    let label = Point::new(
99        (s.x + 3.0 * c1.x + 3.0 * c2.x + t.x) / 8.0,
100        (s.y + 3.0 * c1.y + 3.0 * c2.y + t.y) / 8.0,
101    );
102    EdgePath { d, label }
103}
104
105/// A straight line between the anchors.
106pub fn straight_path(geo: &EdgeGeometry) -> EdgePath {
107    let s = geo.source;
108    let t = geo.target;
109    EdgePath {
110        d: format!("M{},{} L{},{}", fmt(s.x), fmt(s.y), fmt(t.x), fmt(t.y)),
111        label: s.lerp(t, 0.5),
112    }
113}
114
115/// Orthogonal path with rounded corners.
116pub fn smooth_step_path(geo: &EdgeGeometry, radius: f64) -> EdgePath {
117    let points = step_points(geo, 20.0);
118    let label = polyline_midpoint(&points);
119    EdgePath {
120        d: rounded_polyline(&points, radius),
121        label,
122    }
123}
124
125/// Waypoints of the orthogonal route, including both anchors. Routes that
126/// would double back through a node instead detour around its bounds (grown
127/// by the stub length), so back-edges never cut through either endpoint node.
128fn step_points(geo: &EdgeGeometry, stub: f64) -> Vec<Point> {
129    let s = geo.source;
130    let t = geo.target;
131    let ss = geo.source_side;
132    let ts = geo.target_side;
133    let s2 = s + ss.normal() * stub;
134    let t2 = t + ts.normal() * stub;
135    // Clearance boxes: node bounds grown by the stub so corridors keep stub
136    // distance from the nodes. Unknown bounds collapse to the anchor point.
137    let sr = inflate(
138        geo.source_rect
139            .unwrap_or_else(|| Rect::new(s.x, s.y, 0.0, 0.0)),
140        stub,
141    );
142    let tr = inflate(
143        geo.target_rect
144            .unwrap_or_else(|| Rect::new(t.x, t.y, 0.0, 0.0)),
145        stub,
146    );
147
148    let mut pts = vec![s, s2];
149    match (ss, ts) {
150        // Opposite horizontal sides: straight-through when the target lies
151        // ahead of the source stub, otherwise around via a clear row.
152        (Side::Right, Side::Left) | (Side::Left, Side::Right) => {
153            let forward = if ss == Side::Right {
154                t2.x >= s2.x
155            } else {
156                t2.x <= s2.x
157            };
158            if forward {
159                let mid_x = (s2.x + t2.x) / 2.0;
160                pts.push(Point::new(mid_x, s2.y));
161                pts.push(Point::new(mid_x, t2.y));
162            } else {
163                let mid_y = clear_lane(sr.y, sr.max_y(), tr.y, tr.max_y(), s2.y, t2.y);
164                pts.push(Point::new(s2.x, mid_y));
165                pts.push(Point::new(t2.x, mid_y));
166            }
167        }
168        // Opposite vertical sides: mirror of the above.
169        (Side::Bottom, Side::Top) | (Side::Top, Side::Bottom) => {
170            let forward = if ss == Side::Bottom {
171                t2.y >= s2.y
172            } else {
173                t2.y <= s2.y
174            };
175            if forward {
176                let mid_y = (s2.y + t2.y) / 2.0;
177                pts.push(Point::new(s2.x, mid_y));
178                pts.push(Point::new(t2.x, mid_y));
179            } else {
180                let mid_x = clear_lane(sr.x, sr.max_x(), tr.x, tr.max_x(), s2.x, t2.x);
181                pts.push(Point::new(mid_x, s2.y));
182                pts.push(Point::new(mid_x, t2.y));
183            }
184        }
185        // Same horizontal side: run along the outer column shared by both.
186        (Side::Right, Side::Right) => {
187            let outer = s2.x.max(t2.x).max(sr.max_x()).max(tr.max_x());
188            pts.push(Point::new(outer, s2.y));
189            pts.push(Point::new(outer, t2.y));
190        }
191        (Side::Left, Side::Left) => {
192            let outer = s2.x.min(t2.x).min(sr.x).min(tr.x);
193            pts.push(Point::new(outer, s2.y));
194            pts.push(Point::new(outer, t2.y));
195        }
196        // Same vertical side.
197        (Side::Bottom, Side::Bottom) => {
198            let outer = s2.y.max(t2.y).max(sr.max_y()).max(tr.max_y());
199            pts.push(Point::new(s2.x, outer));
200            pts.push(Point::new(t2.x, outer));
201        }
202        (Side::Top, Side::Top) => {
203            let outer = s2.y.min(t2.y).min(sr.y).min(tr.y);
204            pts.push(Point::new(s2.x, outer));
205            pts.push(Point::new(t2.x, outer));
206        }
207        // Horizontal source, vertical target: one corner when it bends
208        // forward at both ends, otherwise around the target's clear column.
209        (Side::Right | Side::Left, _) => {
210            let source_ok = if ss == Side::Right {
211                t2.x >= s2.x
212            } else {
213                t2.x <= s2.x
214            };
215            let target_ok = if ts == Side::Top {
216                s2.y <= t2.y
217            } else {
218                s2.y >= t2.y
219            };
220            if source_ok && target_ok {
221                pts.push(Point::new(t2.x, s2.y));
222            } else {
223                let x_out = if ss == Side::Right {
224                    s2.x.max(tr.max_x())
225                } else {
226                    s2.x.min(tr.x)
227                };
228                pts.push(Point::new(x_out, s2.y));
229                pts.push(Point::new(x_out, t2.y));
230            }
231        }
232        // Vertical source, horizontal target: mirror of the above.
233        (_, Side::Right | Side::Left) => {
234            let source_ok = if ss == Side::Bottom {
235                t2.y >= s2.y
236            } else {
237                t2.y <= s2.y
238            };
239            let target_ok = if ts == Side::Left {
240                s2.x <= t2.x
241            } else {
242                s2.x >= t2.x
243            };
244            if source_ok && target_ok {
245                pts.push(Point::new(s2.x, t2.y));
246            } else {
247                let y_out = if ss == Side::Bottom {
248                    s2.y.max(tr.max_y())
249                } else {
250                    s2.y.min(tr.y)
251                };
252                pts.push(Point::new(s2.x, y_out));
253                pts.push(Point::new(t2.x, y_out));
254            }
255        }
256    }
257    pts.push(t2);
258    pts.push(t);
259    simplify_points(pts)
260}
261
262fn inflate(r: Rect, m: f64) -> Rect {
263    Rect::new(r.x - m, r.y - m, r.width + 2.0 * m, r.height + 2.0 * m)
264}
265
266/// A coordinate for a corridor that clears both spans `(a0..a1)` and
267/// `(b0..b1)`: the middle of the gap between them when they are disjoint,
268/// otherwise just past whichever outer edge is closer to the two stubs.
269fn clear_lane(a0: f64, a1: f64, b0: f64, b1: f64, stub_a: f64, stub_b: f64) -> f64 {
270    if a1 < b0 {
271        return (a1 + b0) / 2.0;
272    }
273    if b1 < a0 {
274        return (b1 + a0) / 2.0;
275    }
276    let lo = a0.min(b0);
277    let hi = a1.max(b1);
278    let before = (stub_a - lo) + (stub_b - lo);
279    let after = (hi - stub_a) + (hi - stub_b);
280    if before <= after {
281        lo
282    } else {
283        hi
284    }
285}
286
287/// Drop consecutive duplicates and interior points that continue straight,
288/// so corners in the output are genuine turns.
289fn simplify_points(pts: Vec<Point>) -> Vec<Point> {
290    let mut out: Vec<Point> = Vec::with_capacity(pts.len());
291    for p in pts {
292        if out.last().map(|l| l.distance(p) > 0.01).unwrap_or(true) {
293            out.push(p);
294        }
295    }
296    let mut i = 1;
297    while i + 1 < out.len() {
298        let ab = out[i] - out[i - 1];
299        let bc = out[i + 1] - out[i];
300        let cross = ab.x * bc.y - ab.y * bc.x;
301        let dot = ab.x * bc.x + ab.y * bc.y;
302        if cross.abs() < 1e-6 && dot > 0.0 {
303            out.remove(i);
304        } else {
305            i += 1;
306        }
307    }
308    out
309}
310
311/// Build an SVG path from a polyline, rounding interior corners with
312/// quadratic curves.
313fn rounded_polyline(pts: &[Point], radius: f64) -> String {
314    if pts.is_empty() {
315        return String::new();
316    }
317    let mut d = format!("M{},{}", fmt(pts[0].x), fmt(pts[0].y));
318    for i in 1..pts.len().saturating_sub(1) {
319        let prev = pts[i - 1];
320        let p = pts[i];
321        let next = pts[i + 1];
322        let len_in = prev.distance(p);
323        let len_out = p.distance(next);
324        // A collinear point (straight continuation or 180° reversal) has no
325        // corner to round; rounding it would emit a zero-length curve.
326        let ab = p - prev;
327        let bc = next - p;
328        let collinear = (ab.x * bc.y - ab.y * bc.x).abs() < 1e-6;
329        let r = radius.min(len_in / 2.0).min(len_out / 2.0);
330        if r < 0.1 || collinear {
331            d.push_str(&format!(" L{},{}", fmt(p.x), fmt(p.y)));
332            continue;
333        }
334        let a = p + (prev - p) * (r / len_in);
335        let b = p + (next - p) * (r / len_out);
336        d.push_str(&format!(
337            " L{},{} Q{},{} {},{}",
338            fmt(a.x),
339            fmt(a.y),
340            fmt(p.x),
341            fmt(p.y),
342            fmt(b.x),
343            fmt(b.y)
344        ));
345    }
346    if let Some(last) = pts.last() {
347        if pts.len() > 1 {
348            d.push_str(&format!(" L{},{}", fmt(last.x), fmt(last.y)));
349        }
350    }
351    d
352}
353
354/// The point halfway along a polyline (by arc length).
355fn polyline_midpoint(pts: &[Point]) -> Point {
356    if pts.is_empty() {
357        return Point::ZERO;
358    }
359    let total: f64 = pts.windows(2).map(|w| w[0].distance(w[1])).sum();
360    if total <= f64::EPSILON {
361        return pts[0];
362    }
363    let mut remaining = total / 2.0;
364    for w in pts.windows(2) {
365        let len = w[0].distance(w[1]);
366        if remaining <= len {
367            return w[0].lerp(w[1], remaining / len);
368        }
369        remaining -= len;
370    }
371    *pts.last().unwrap()
372}
373
374/// Path used for the in-progress connection line (a bezier from the source
375/// handle toward the cursor / snap target).
376pub fn connection_path(from: Point, from_side: Side, to: Point, to_side: Option<Side>) -> String {
377    let geo = EdgeGeometry::new(
378        from,
379        from_side,
380        to,
381        to_side.unwrap_or_else(|| from_side.opposite()),
382    );
383    bezier_path(&geo, 0.25).d
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn geo(s: (f64, f64), ss: Side, t: (f64, f64), ts: Side) -> EdgeGeometry {
391        EdgeGeometry::new(s.into(), ss, t.into(), ts)
392    }
393
394    fn assert_no_reversals(pts: &[Point]) {
395        for w in pts.windows(3) {
396            let ab = w[1] - w[0];
397            let bc = w[2] - w[1];
398            let cross = ab.x * bc.y - ab.y * bc.x;
399            let dot = ab.x * bc.x + ab.y * bc.y;
400            assert!(
401                cross.abs() > 1e-6 || dot > 0.0,
402                "route doubles back at {:?} in {:?}",
403                w[1],
404                pts
405            );
406        }
407    }
408
409    fn strictly_inside(r: &Rect, p: Point) -> bool {
410        p.x > r.x && p.x < r.max_x() && p.y > r.y && p.y < r.max_y()
411    }
412
413    #[test]
414    fn bezier_endpoints() {
415        let g = geo((0.0, 0.0), Side::Bottom, (100.0, 200.0), Side::Top);
416        let p = bezier_path(&g, 0.25);
417        assert!(p.d.starts_with("M0,0 C"));
418        assert!(p.d.ends_with("100,200"));
419        // Label sits between the endpoints.
420        assert!(p.label.y > 0.0 && p.label.y < 200.0);
421        assert_eq!(p.label.x, 50.0);
422    }
423
424    #[test]
425    fn straight_midpoint() {
426        let g = geo((0.0, 0.0), Side::Right, (10.0, 10.0), Side::Left);
427        let p = straight_path(&g);
428        assert_eq!(p.label, Point::new(5.0, 5.0));
429    }
430
431    #[test]
432    fn smooth_step_valid_path() {
433        let g = geo((0.0, 0.0), Side::Right, (200.0, 100.0), Side::Left);
434        let p = smooth_step_path(&g, 8.0);
435        assert!(p.d.starts_with("M0,0"));
436        assert!(p.d.ends_with("L200,100"));
437        assert!(p.d.contains('Q'), "expected rounded corners: {}", p.d);
438    }
439
440    #[test]
441    fn smooth_step_mixed_sides() {
442        let g = geo((0.0, 0.0), Side::Bottom, (200.0, 100.0), Side::Left);
443        let p = smooth_step_path(&g, 8.0);
444        assert!(p.d.starts_with("M0,0"));
445        assert!(p.d.ends_with("L200,100"));
446    }
447
448    #[test]
449    fn smooth_step_degenerate_same_point() {
450        let g = geo((50.0, 50.0), Side::Right, (50.0, 50.0), Side::Left);
451        let p = smooth_step_path(&g, 8.0);
452        assert!(p.d.starts_with("M50,50"));
453    }
454
455    #[test]
456    fn smooth_step_back_edge_detours_horizontally() {
457        // Left-to-right layout, feedback edge: target sits behind the source
458        // on the same row. The route must leave through a clear lane above or
459        // below both nodes instead of cutting back through them.
460        let source_rect = Rect::new(400.0, 80.0, 160.0, 56.0);
461        let target_rect = Rect::new(100.0, 80.0, 160.0, 56.0);
462        let g = geo((560.0, 108.0), Side::Right, (100.0, 108.0), Side::Left)
463            .with_rects(source_rect, target_rect);
464        let pts = step_points(&g, 20.0);
465        assert_no_reversals(&pts);
466        for p in &pts {
467            assert!(
468                !strictly_inside(&source_rect, *p) && !strictly_inside(&target_rect, *p),
469                "waypoint {p:?} crosses a node in {pts:?}"
470            );
471        }
472        assert!(
473            pts.iter().any(|p| p.y <= 60.0 || p.y >= 156.0),
474            "no clear lane in {pts:?}"
475        );
476    }
477
478    #[test]
479    fn smooth_step_back_edge_detours_vertically() {
480        // Vertical stack, feedback edge from the lower node's bottom to the
481        // upper node's top: must loop around the side, not overlap the
482        // forward edge's column.
483        let target_rect = Rect::new(160.0, 450.0, 180.0, 56.0);
484        let source_rect = Rect::new(160.0, 600.0, 180.0, 56.0);
485        let g = geo((250.0, 656.0), Side::Bottom, (250.0, 450.0), Side::Top)
486            .with_rects(source_rect, target_rect);
487        let pts = step_points(&g, 20.0);
488        assert_no_reversals(&pts);
489        for p in &pts {
490            assert!(
491                !strictly_inside(&source_rect, *p) && !strictly_inside(&target_rect, *p),
492                "waypoint {p:?} crosses a node in {pts:?}"
493            );
494        }
495        assert!(
496            pts.iter().any(|p| p.x <= 140.0 || p.x >= 360.0),
497            "route never left the forward edge's column: {pts:?}"
498        );
499    }
500
501    #[test]
502    fn smooth_step_same_side_routes_outside_both() {
503        let source_rect = Rect::new(0.0, -20.0, 100.0, 40.0);
504        let target_rect = Rect::new(200.0, 180.0, 100.0, 40.0);
505        let g = geo((100.0, 0.0), Side::Right, (300.0, 200.0), Side::Right)
506            .with_rects(source_rect, target_rect);
507        let pts = step_points(&g, 20.0);
508        assert_no_reversals(&pts);
509        let max_x = pts.iter().fold(f64::MIN, |m, p| m.max(p.x));
510        assert!(max_x >= 320.0, "same-side route stayed inside: {pts:?}");
511        assert_eq!(*pts.last().unwrap(), Point::new(300.0, 200.0));
512    }
513}