Skip to main content

xpict_core/
arrows.rs

1//! Reaction / network edge shafts in document space.
2//!
3//! Ports the Python `draw/arrows.py` route cleanup + paint helpers so hosts
4//! share one implementation (orthogonal stays axis-aligned; polyline fillets).
5
6use crate::doc::{EdgeArrow, EdgeRouting};
7
8/// Flatten tiny orthogonal/polyline jogs under this length (px).
9pub const KINK_PX: f64 = 10.0;
10/// Corner fillet radius for non-orthogonal (polyline) bends (px).
11pub const TURN_RADIUS: f64 = 36.0;
12
13const DEFAULT_COLOR: &str = "#222";
14const DEFAULT_WIDTH: f64 = 1.6;
15const HEAD: f64 = 9.0;
16const DASH: &str = "6 4";
17const EQ_SEP: f64 = 3.2;
18/// Gap from molecule viewport edge to shaft anchors (document px).
19pub const ANCHOR_GAP: f64 = 6.0;
20
21/// Axis-aligned box around ``(cx,cy)`` clipped toward ``(tx,ty)``.
22pub fn clip_box_edge(
23    cx: f64,
24    cy: f64,
25    tx: f64,
26    ty: f64,
27    w: f64,
28    h: f64,
29    pad: f64,
30) -> Pt {
31    let dx = tx - cx;
32    let dy = ty - cy;
33    if dx.abs() < 1e-9 && dy.abs() < 1e-9 {
34        return (cx, cy);
35    }
36    let hw = w * 0.5 + pad;
37    let hh = h * 0.5 + pad;
38    let sx = if dx.abs() > 1e-9 {
39        hw / dx.abs()
40    } else {
41        f64::INFINITY
42    };
43    let sy = if dy.abs() > 1e-9 {
44        hh / dy.abs()
45    } else {
46        f64::INFINITY
47    };
48    let t = sx.min(sy);
49    (cx + dx * t, cy + dy * t)
50}
51
52/// Viewport-boundary anchors between two molecule boxes.
53///
54/// Each box is ``(x, y, width, height)`` in document space.
55pub fn edge_anchors(
56    src: (f64, f64, f64, f64),
57    tgt: (f64, f64, f64, f64),
58    pad: f64,
59) -> (Pt, Pt) {
60    let (sx, sy, sw, sh) = src;
61    let (tx, ty, tw, th) = tgt;
62    let scx = sx + sw * 0.5;
63    let scy = sy + sh * 0.5;
64    let tcx = tx + tw * 0.5;
65    let tcy = ty + th * 0.5;
66    let p0 = clip_box_edge(scx, scy, tcx, tcy, sw, sh, pad);
67    let p1 = clip_box_edge(tcx, tcy, scx, scy, tw, th, pad);
68    (p0, p1)
69}
70
71/// Document-space point.
72pub type Pt = (f64, f64);
73
74fn hypot(dx: f64, dy: f64) -> f64 {
75    dx.hypot(dy)
76}
77
78fn point_line_distance(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
79    let dx = bx - ax;
80    let dy = by - ay;
81    let l2 = dx * dx + dy * dy;
82    if l2 < 1e-12 {
83        return hypot(px - ax, py - ay);
84    }
85    let t = ((px - ax) * dx + (py - ay) * dy) / l2;
86    let qx = ax + t * dx;
87    let qy = ay + t * dy;
88    hypot(px - qx, py - qy)
89}
90
91/// Drop near-duplicates and flatten kinks under ``kink_px``.
92pub fn simplify_route(pts: &[Pt], kink_px: f64) -> Vec<Pt> {
93    if pts.len() < 2 {
94        return pts.to_vec();
95    }
96    let mut cleaned: Vec<Pt> = Vec::with_capacity(pts.len());
97    for &(x, y) in pts {
98        if cleaned
99            .last()
100            .map(|&(lx, ly)| hypot(x - lx, y - ly) > 1e-6)
101            .unwrap_or(true)
102        {
103            cleaned.push((x, y));
104        }
105    }
106    if cleaned.len() <= 2 {
107        return cleaned;
108    }
109
110    // Collapse short middle segments (classic orthogonal micro-jog).
111    let mut changed = true;
112    while changed && cleaned.len() > 2 {
113        changed = false;
114        let mut out: Vec<Pt> = vec![cleaned[0]];
115        let mut i = 1;
116        while i + 1 < cleaned.len() {
117            let (x0, y0) = *out.last().unwrap();
118            let (x1, y1) = cleaned[i];
119            let (x2, y2) = cleaned[i + 1];
120            let seg = hypot(x1 - x0, y1 - y0);
121            let nxt = hypot(x2 - x1, y2 - y1);
122            if seg < kink_px || nxt < kink_px {
123                i += 1;
124                changed = true;
125                continue;
126            }
127            out.push(cleaned[i]);
128            i += 1;
129        }
130        out.push(*cleaned.last().unwrap());
131        cleaned = out;
132    }
133
134    // Remove vertices nearly collinear with neighbors.
135    changed = true;
136    while changed && cleaned.len() > 2 {
137        changed = false;
138        let mut out: Vec<Pt> = vec![cleaned[0]];
139        for i in 1..cleaned.len() - 1 {
140            let (ax, ay) = *out.last().unwrap();
141            let (px, py) = cleaned[i];
142            let (bx, by) = cleaned[i + 1];
143            if point_line_distance(px, py, ax, ay, bx, by) < kink_px {
144                changed = true;
145                continue;
146            }
147            out.push(cleaned[i]);
148        }
149        out.push(*cleaned.last().unwrap());
150        cleaned = out;
151    }
152    cleaned
153}
154
155/// True when every segment is horizontal or vertical.
156pub fn axis_aligned(pts: &[Pt], tol: f64) -> bool {
157    if pts.len() < 2 {
158        return true;
159    }
160    for i in 1..pts.len() {
161        let dx = (pts[i].0 - pts[i - 1].0).abs();
162        let dy = (pts[i].1 - pts[i - 1].1).abs();
163        if dx > tol && dy > tol {
164            return false;
165        }
166    }
167    true
168}
169
170/// Fillet soft polyline turns; keep orthogonal shafts axis-aligned.
171pub fn should_fillet(pts: &[Pt], routing: Option<EdgeRouting>) -> bool {
172    match routing {
173        Some(EdgeRouting::Orthogonal) => false,
174        Some(EdgeRouting::Polyline | EdgeRouting::Splines) => !axis_aligned(pts, 1e-3),
175        None => pts.len() > 2 && !axis_aligned(pts, 1e-3),
176    }
177}
178
179fn polyline_d(pts: &[Pt]) -> String {
180    if pts.is_empty() {
181        return String::new();
182    }
183    let mut bits = Vec::with_capacity(pts.len());
184    bits.push(format!("M {:.2} {:.2}", pts[0].0, pts[0].1));
185    for &(x, y) in &pts[1..] {
186        bits.push(format!("L {x:.2} {y:.2}"));
187    }
188    bits.join(" ")
189}
190
191/// Polyline with quadratic fillets at corners (larger ``radius`` → softer turns).
192pub fn filleted_path_d(pts: &[Pt], radius: f64) -> String {
193    if pts.len() < 2 {
194        return String::new();
195    }
196    if pts.len() == 2 || radius <= 0.0 {
197        return polyline_d(pts);
198    }
199    let mut bits: Vec<String> = vec![format!("M {:.2} {:.2}", pts[0].0, pts[0].1)];
200    for i in 1..pts.len() - 1 {
201        let (ax, ay) = pts[i - 1];
202        let (bx, by) = pts[i];
203        let (cx, cy) = pts[i + 1];
204        let v1x = ax - bx;
205        let v1y = ay - by;
206        let v2x = cx - bx;
207        let v2y = cy - by;
208        let len1 = hypot(v1x, v1y).max(1e-9);
209        let len2 = hypot(v2x, v2y).max(1e-9);
210        let dot = (v1x * v2x + v1y * v2y) / (len1 * len2);
211        if dot < -0.98 {
212            bits.push(format!("L {bx:.2} {by:.2}"));
213            continue;
214        }
215        let r = radius.min(0.45 * len1).min(0.45 * len2);
216        if r < 1.0 {
217            bits.push(format!("L {bx:.2} {by:.2}"));
218            continue;
219        }
220        let u1x = v1x / len1;
221        let u1y = v1y / len1;
222        let u2x = v2x / len2;
223        let u2y = v2y / len2;
224        let p1x = bx + u1x * r;
225        let p1y = by + u1y * r;
226        let p2x = bx + u2x * r;
227        let p2y = by + u2y * r;
228        bits.push(format!("L {p1x:.2} {p1y:.2}"));
229        bits.push(format!("Q {bx:.2} {by:.2} {p2x:.2} {p2y:.2}"));
230    }
231    let (lx, ly) = *pts.last().unwrap();
232    bits.push(format!("L {lx:.2} {ly:.2}"));
233    bits.join(" ")
234}
235
236/// Path `d` for a shaft: fillet polyline turns; keep orthogonal H/V sharp.
237pub fn shaft_path_d(pts: &[Pt], routing: Option<EdgeRouting>) -> String {
238    if should_fillet(pts, routing) {
239        filleted_path_d(pts, TURN_RADIUS)
240    } else {
241        polyline_d(pts)
242    }
243}
244
245fn unit(dx: f64, dy: f64) -> (f64, f64) {
246    let l = hypot(dx, dy).max(1e-9);
247    (dx / l, dy / l)
248}
249
250fn perp(ux: f64, uy: f64) -> (f64, f64) {
251    (-uy, ux)
252}
253
254fn polyline_length(pts: &[Pt]) -> f64 {
255    let mut total = 0.0;
256    for i in 1..pts.len() {
257        total += hypot(pts[i].0 - pts[i - 1].0, pts[i].1 - pts[i - 1].1);
258    }
259    total
260}
261
262fn shorten_polyline_end(pts: &[Pt], amount: f64) -> (Vec<Pt>, Pt, (f64, f64)) {
263    if pts.len() < 2 {
264        let p = pts.first().copied().unwrap_or((0.0, 0.0));
265        return (vec![p], p, (1.0, 0.0));
266    }
267    let tip = *pts.last().unwrap();
268    let mut pts = pts.to_vec();
269    let mut remaining = amount;
270    while pts.len() >= 2 && remaining > 0.0 {
271        let (x1, y1) = pts[pts.len() - 2];
272        let (x2, y2) = pts[pts.len() - 1];
273        let seg = hypot(x2 - x1, y2 - y1);
274        if seg <= 1e-9 {
275            pts.pop();
276            continue;
277        }
278        if seg > remaining {
279            let (ux, uy) = unit(x2 - x1, y2 - y1);
280            let nx = x2 - ux * remaining;
281            let ny = y2 - uy * remaining;
282            let last = pts.len() - 1;
283            pts[last] = (nx, ny);
284            return (pts, tip, (ux, uy));
285        }
286        remaining -= seg;
287        pts.pop();
288    }
289    let (ux, uy) = if pts.len() >= 2 {
290        unit(
291            pts[pts.len() - 1].0 - pts[pts.len() - 2].0,
292            pts[pts.len() - 1].1 - pts[pts.len() - 2].1,
293        )
294    } else {
295        (1.0, 0.0)
296    };
297    (pts, tip, (ux, uy))
298}
299
300fn filled_arrow_head_d(tip_x: f64, tip_y: f64, ux: f64, uy: f64, size: f64) -> String {
301    let (px, py) = perp(ux, uy);
302    let bx = tip_x - ux * size;
303    let by = tip_y - uy * size;
304    let half = size * 0.55;
305    format!(
306        "M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} Z",
307        bx + px * half,
308        by + py * half,
309        tip_x,
310        tip_y,
311        bx - px * half,
312        by - py * half
313    )
314}
315
316/// Minimal shaft/head paint request (host supplies resolved route points).
317#[derive(Debug, Clone)]
318pub struct EdgePaintIn {
319    pub pts: Vec<Pt>,
320    pub arrow: EdgeArrow,
321    pub routing: Option<EdgeRouting>,
322    pub color: Option<String>,
323    pub stroke_width: Option<f64>,
324    pub dashed: bool,
325    pub label: Option<String>,
326    pub label_pos: Option<String>,
327    pub index: usize,
328}
329
330/// One SVG-ish path or text fragment for overlays.
331#[derive(Debug, Clone, PartialEq)]
332pub enum EdgePrim {
333    Path {
334        d: String,
335        stroke: String,
336        fill: String,
337        stroke_width: f64,
338        stroke_dasharray: Option<String>,
339        class: String,
340    },
341    Text {
342        x: f64,
343        y: f64,
344        text: String,
345        fill: String,
346        font_size: f64,
347        anchor: String,
348        class: String,
349    },
350}
351
352fn path_prim(
353    d: String,
354    stroke: &str,
355    fill: &str,
356    width: f64,
357    dashed: bool,
358    class: &str,
359) -> EdgePrim {
360    EdgePrim::Path {
361        d,
362        stroke: stroke.to_string(),
363        fill: fill.to_string(),
364        stroke_width: width,
365        stroke_dasharray: if dashed {
366            Some(DASH.to_string())
367        } else {
368            None
369        },
370        class: class.to_string(),
371    }
372}
373
374fn label_point(pts: &[Pt]) -> (f64, f64, f64, f64) {
375    if pts.len() < 2 {
376        let (x, y) = pts.first().copied().unwrap_or((0.0, 0.0));
377        return (x, y, 0.0, -1.0);
378    }
379    let mut best_i = 1;
380    let mut best_len = -1.0;
381    for i in 1..pts.len() {
382        let l = hypot(pts[i].0 - pts[i - 1].0, pts[i].1 - pts[i - 1].1);
383        if l > best_len {
384            best_len = l;
385            best_i = i;
386        }
387    }
388    let (x1, y1) = pts[best_i - 1];
389    let (x2, y2) = pts[best_i];
390    let (ux, uy) = unit(x2 - x1, y2 - y1);
391    let (px, py) = perp(ux, uy);
392    ((x1 + x2) * 0.5, (y1 + y2) * 0.5, px, py)
393}
394
395fn offset_polyline(pts: &[Pt], dist: f64) -> Vec<Pt> {
396    if pts.len() < 2 {
397        return pts.to_vec();
398    }
399    let n = pts.len();
400    let mut out = Vec::with_capacity(n);
401    for (i, &(x, y)) in pts.iter().enumerate() {
402        let (px, py) = if i == 0 {
403            let (ux, uy) = unit(pts[1].0 - x, pts[1].1 - y);
404            perp(ux, uy)
405        } else if i + 1 == n {
406            let (ux, uy) = unit(x - pts[i - 1].0, y - pts[i - 1].1);
407            perp(ux, uy)
408        } else {
409            let (u1x, u1y) = unit(x - pts[i - 1].0, y - pts[i - 1].1);
410            let (u2x, u2y) = unit(pts[i + 1].0 - x, pts[i + 1].1 - y);
411            let (p1x, p1y) = perp(u1x, u1y);
412            let (p2x, p2y) = perp(u2x, u2y);
413            let mut px = p1x + p2x;
414            let mut py = p1y + p2y;
415            let l = hypot(px, py).max(1e-9);
416            px /= l;
417            py /= l;
418            (px, py)
419        };
420        out.push((x + px * dist, y + py * dist));
421    }
422    out
423}
424
425fn harpoon_poly(
426    pts: &[Pt],
427    color: &str,
428    width: f64,
429    dashed: bool,
430    head_size: f64,
431    class: &str,
432    routing: Option<EdgeRouting>,
433) -> Vec<EdgePrim> {
434    let (shaft, tip, (ux, uy)) = shorten_polyline_end(pts, head_size);
435    let (px, py) = perp(ux, uy);
436    let barb = head_size * 0.55;
437    let bx = tip.0 - ux * head_size;
438    let by = tip.1 - uy * head_size;
439    let mut out = Vec::new();
440    if shaft.len() >= 2 {
441        out.push(path_prim(
442            shaft_path_d(&shaft, routing),
443            color,
444            "none",
445            width,
446            dashed,
447            class,
448        ));
449    }
450    out.push(path_prim(
451        format!(
452            "M {:.2} {:.2} L {:.2} {:.2}",
453            tip.0,
454            tip.1,
455            bx + px * barb,
456            by + py * barb
457        ),
458        color,
459        "none",
460        width,
461        false,
462        &format!("{class} harpoon"),
463    ));
464    out
465}
466
467/// Build overlay primitives for one edge given a resolved route.
468pub fn edge_primitives(edge: &EdgePaintIn) -> Vec<EdgePrim> {
469    let pts = simplify_route(&edge.pts, KINK_PX);
470    if polyline_length(&pts) < 4.0 {
471        return Vec::new();
472    }
473    let color = edge.color.as_deref().unwrap_or(DEFAULT_COLOR);
474    let width = edge.stroke_width.unwrap_or(DEFAULT_WIDTH);
475    let dashed = edge.dashed;
476    let routing = edge.routing;
477    let cls = format!("edge edge-{}", edge.index);
478    let mut out: Vec<EdgePrim> = Vec::new();
479
480    match edge.arrow {
481        EdgeArrow::Equilibrium => {
482            let fwd = offset_polyline(&pts, EQ_SEP);
483            let mut rev = offset_polyline(&pts, -EQ_SEP);
484            rev.reverse();
485            out.extend(harpoon_poly(
486                &fwd,
487                color,
488                width,
489                dashed,
490                HEAD * 0.85,
491                &format!("{cls} eq-fwd"),
492                routing,
493            ));
494            out.extend(harpoon_poly(
495                &rev,
496                color,
497                width,
498                dashed,
499                HEAD * 0.85,
500                &format!("{cls} eq-rev"),
501                routing,
502            ));
503        }
504        EdgeArrow::Line => {
505            out.push(path_prim(
506                shaft_path_d(&pts, routing),
507                color,
508                "none",
509                width,
510                dashed,
511                &cls,
512            ));
513        }
514        EdgeArrow::Open => {
515            let (shaft, tip, (ux, uy)) = shorten_polyline_end(&pts, HEAD);
516            if shaft.len() >= 2 {
517                out.push(path_prim(
518                    shaft_path_d(&shaft, routing),
519                    color,
520                    "none",
521                    width,
522                    dashed,
523                    &cls,
524                ));
525            }
526            let (px, py) = perp(ux, uy);
527            let bx = tip.0 - ux * HEAD;
528            let by = tip.1 - uy * HEAD;
529            let half = HEAD * 0.55;
530            out.push(path_prim(
531                format!(
532                    "M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2}",
533                    bx + px * half,
534                    by + py * half,
535                    tip.0,
536                    tip.1,
537                    bx - px * half,
538                    by - py * half
539                ),
540                color,
541                "none",
542                width,
543                false,
544                &format!("{cls} head"),
545            ));
546        }
547        EdgeArrow::Forward => {
548            let (shaft, tip, (ux, uy)) = shorten_polyline_end(&pts, HEAD);
549            if shaft.len() >= 2 {
550                out.push(path_prim(
551                    shaft_path_d(&shaft, routing),
552                    color,
553                    "none",
554                    width,
555                    dashed,
556                    &cls,
557                ));
558            }
559            out.push(path_prim(
560                filled_arrow_head_d(tip.0, tip.1, ux, uy, HEAD),
561                "none",
562                color,
563                0.0,
564                false,
565                &format!("{cls} head"),
566            ));
567        }
568    }
569
570    if let Some(ref label) = edge.label {
571        let (mx, my, mut px, mut py) = label_point(&pts);
572        let pos = edge
573            .label_pos
574            .as_deref()
575            .unwrap_or("above")
576            .to_ascii_lowercase();
577        if pos == "below" || pos == "right" {
578            px = -px;
579            py = -py;
580        }
581        out.push(EdgePrim::Text {
582            x: mx + px * 10.0,
583            y: my + py * 10.0 + 4.0,
584            text: label.clone(),
585            fill: color.to_string(),
586            font_size: 11.0,
587            anchor: "middle".into(),
588            class: format!("{cls} label"),
589        });
590    }
591    out
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn paint(
599        pts: Vec<Pt>,
600        arrow: EdgeArrow,
601        routing: Option<EdgeRouting>,
602    ) -> EdgePaintIn {
603        EdgePaintIn {
604            pts,
605            arrow,
606            routing,
607            color: Some("#06c".into()),
608            stroke_width: Some(2.0),
609            dashed: true,
610            label: Some("lab".into()),
611            label_pos: Some("below".into()),
612            index: 1,
613        }
614    }
615
616    #[test]
617    fn simplify_snaps_micro_kink() {
618        let jog = vec![(0.0, 0.0), (100.0, 0.0), (100.0, 5.0), (200.0, 5.0)];
619        let flat = simplify_route(&jog, KINK_PX);
620        assert_eq!(flat.len(), 2);
621        assert_eq!(flat[0], (0.0, 0.0));
622        assert_eq!(flat[1], (200.0, 5.0));
623    }
624
625    #[test]
626    fn simplify_handles_short_and_duplicates() {
627        assert!(simplify_route(&[], KINK_PX).is_empty());
628        assert_eq!(simplify_route(&[(1.0, 2.0)], KINK_PX), vec![(1.0, 2.0)]);
629        let dups = vec![(0.0, 0.0), (0.0, 0.0), (10.0, 0.0)];
630        assert_eq!(simplify_route(&dups, KINK_PX).len(), 2);
631        // Real elbow stays.
632        let elbow = vec![(0.0, 0.0), (80.0, 0.0), (80.0, 60.0), (140.0, 60.0)];
633        assert_eq!(simplify_route(&elbow, KINK_PX).len(), 4);
634    }
635
636    #[test]
637    fn orthogonal_keeps_sharp_corners() {
638        let pts = vec![(0.0, 0.0), (80.0, 0.0), (80.0, 60.0), (140.0, 60.0)];
639        assert!(!should_fillet(&pts, Some(EdgeRouting::Orthogonal)));
640        assert!(!should_fillet(&pts, Some(EdgeRouting::Polyline))); // axis-aligned
641        assert!(!should_fillet(&pts, None));
642        let d = shaft_path_d(&pts, Some(EdgeRouting::Orthogonal));
643        assert!(!d.contains('Q'));
644        assert!(d.matches('L').count() >= 2);
645    }
646
647    #[test]
648    fn polyline_diagonal_gets_fillet() {
649        let pts = vec![(0.0, 0.0), (80.0, 0.0), (120.0, 60.0), (180.0, 60.0)];
650        assert!(should_fillet(&pts, Some(EdgeRouting::Polyline)));
651        assert!(should_fillet(&pts, Some(EdgeRouting::Splines)));
652        assert!(should_fillet(&pts, None));
653        let d = shaft_path_d(&pts, Some(EdgeRouting::Polyline));
654        assert!(d.contains('Q'));
655    }
656
657    #[test]
658    fn filleted_path_edge_cases() {
659        assert!(filleted_path_d(&[], TURN_RADIUS).is_empty());
660        assert!(!filleted_path_d(&[(0.0, 0.0), (10.0, 0.0)], TURN_RADIUS).contains('Q'));
661        assert!(!filleted_path_d(&[(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)], 0.0).contains('Q'));
662        // Near-collinear corner → L, not Q.
663        let almost = vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.1)];
664        let d = filleted_path_d(&almost, TURN_RADIUS);
665        assert!(d.contains('L'));
666        // Tiny arms skip fillet.
667        let tiny = vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)];
668        assert!(!filleted_path_d(&tiny, TURN_RADIUS).contains('Q'));
669    }
670
671    #[test]
672    fn axis_aligned_and_point_line() {
673        assert!(axis_aligned(&[(0.0, 0.0)], 1e-3));
674        assert!(axis_aligned(&[(0.0, 0.0), (1.0, 0.0), (1.0, 2.0)], 1e-3));
675        assert!(!axis_aligned(&[(0.0, 0.0), (1.0, 1.0)], 1e-3));
676        assert!(point_line_distance(0.0, 1.0, 0.0, 0.0, 0.0, 0.0) >= 0.0);
677        assert!((point_line_distance(1.0, 1.0, 0.0, 0.0, 2.0, 0.0) - 1.0).abs() < 1e-9);
678    }
679
680    #[test]
681    fn shorten_polyline_edge_cases() {
682        let (s, tip, _) = shorten_polyline_end(&[], 5.0);
683        assert_eq!(s.len(), 1);
684        assert_eq!(tip, (0.0, 0.0));
685        let (s, tip, dir) = shorten_polyline_end(&[(1.0, 2.0)], 5.0);
686        assert_eq!(tip, (1.0, 2.0));
687        assert_eq!(dir, (1.0, 0.0));
688        assert_eq!(s, vec![(1.0, 2.0)]);
689        // Zero-length segment skipped.
690        let pts = vec![(0.0, 0.0), (0.0, 0.0), (100.0, 0.0)];
691        let (s, _, (ux, _)) = shorten_polyline_end(&pts, 10.0);
692        assert!(s.len() >= 2);
693        assert!(ux > 0.0);
694        // Trim longer than path.
695        let (s, _, _) = shorten_polyline_end(&[(0.0, 0.0), (5.0, 0.0)], 20.0);
696        assert!(s.len() <= 2);
697    }
698
699    #[test]
700    fn forward_edge_emits_shaft_and_head() {
701        let edge = paint(vec![(0.0, 0.0), (100.0, 0.0)], EdgeArrow::Forward, None);
702        let prims = edge_primitives(&edge);
703        assert!(prims.len() >= 2);
704        assert!(prims.iter().any(|p| matches!(p, EdgePrim::Text { .. })));
705        assert!(prims.iter().any(|p| matches!(
706            p,
707            EdgePrim::Path {
708                stroke_dasharray: Some(_),
709                ..
710            }
711        )));
712    }
713
714    #[test]
715    fn line_open_equilibrium_arrows() {
716        let pts = vec![(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (80.0, 40.0)];
717        for arrow in [EdgeArrow::Line, EdgeArrow::Open, EdgeArrow::Equilibrium] {
718            let mut edge = paint(pts.clone(), arrow, Some(EdgeRouting::Orthogonal));
719            edge.label_pos = Some("above".into());
720            let prims = edge_primitives(&edge);
721            assert!(!prims.is_empty(), "{arrow:?}");
722        }
723        // Short path → no paint.
724        let short = paint(vec![(0.0, 0.0), (1.0, 0.0)], EdgeArrow::Line, None);
725        assert!(edge_primitives(&short).is_empty());
726    }
727
728    #[test]
729    fn edge_anchors_on_box_boundary() {
730        let (p0, p1) = edge_anchors((0.0, 0.0, 100.0, 80.0), (200.0, 0.0, 100.0, 80.0), 0.0);
731        assert!((p0.0 - 100.0).abs() < 1e-6);
732        assert!((p1.0 - 200.0).abs() < 1e-6);
733        assert!((p0.1 - 40.0).abs() < 1e-6);
734        let same = clip_box_edge(0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 0.0);
735        assert_eq!(same, (0.0, 0.0));
736    }
737
738    #[test]
739    fn offset_and_label_helpers() {
740        assert_eq!(offset_polyline(&[(0.0, 0.0)], 2.0).len(), 1);
741        let off = offset_polyline(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)], 2.0);
742        assert_eq!(off.len(), 3);
743        let (x, y, _, _) = label_point(&[]);
744        assert_eq!((x, y), (0.0, 0.0));
745        let _ = label_point(&[(5.0, 5.0)]);
746    }
747}