Skip to main content

dioxus_flow/
ports.rs

1//! Seat-based ports: discrete connection points packed around a node's
2//! rounded rim, and the curves between them.
3//!
4//! This is an alternative to [`crate::Handle`]-based anchoring for editors
5//! where connections may attach anywhere on a node's border: a rim offers a
6//! whole number of *seats* one [`SEAT_PITCH`] apart, [`solve_ports`] packs
7//! every connection end into a seat of its own (deterministically, so the
8//! same graph always renders the same), and [`edge_geometry`] draws the
9//! curve between two seats, arrowheads and label anchor included.
10//!
11//! Everything here is headless — pure geometry over keyed rectangles — so it
12//! can drive custom edge layers, exports and thumbnails alike.
13
14/// Re-exported for convenience: a seat names its face with the shared
15/// [`Side`] type.
16pub use crate::types::Side;
17use crate::types::{Id, Point, Rect};
18use std::collections::BTreeMap;
19use std::f64::consts::{FRAC_PI_2, PI};
20
21/// Which ends of a connection carry an arrowhead. The plane only needs to
22/// know which ends to leave room for; what an arrow means belongs to the
23/// application.
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub struct Arrows {
26    pub start: bool,
27    pub end: bool,
28}
29
30impl Arrows {
31    pub const NONE: Self = Self {
32        start: false,
33        end: false,
34    };
35
36    pub const fn new(start: bool, end: bool) -> Self {
37        Self { start, end }
38    }
39}
40
41/// How far apart the seats on a node's rim are, and so the least room any two
42/// beads can have between them.
43///
44/// This is the document's grid cell on purpose. A node's frame comes to rest on
45/// that grid, so every seat lands on a line the paper already draws: a port is
46/// never at an arbitrary offset, and a connection leaves along structure the
47/// reader can already see.
48pub const SEAT_PITCH: f64 = 12.0;
49/// The bead's own radius, which the surface draws and this reserves room for.
50///
51/// Small enough to read as a joint rather than a knob: where a connection meets
52/// a card is a detail of the connection, not a control competing with it. The
53/// grab target is its own, larger circle, so this answers only to the eye.
54pub const PORT_RADIUS: f64 = 3.6;
55/// How rounded a node's corners are. The stylesheet has to agree with this, or
56/// beads will sit off the rim they are packed onto.
57///
58/// It is exactly one seat, which is what keeps the lattice exact: the corner arc
59/// spans one cell, so the straight runs begin and end on a seat and every
60/// interior seat has an axis-aligned normal.
61pub const CORNER_RADIUS: f64 = SEAT_PITCH;
62/// How near a connection's end a label may be dragged. A label exactly on the end
63/// would sit under the arrowhead and the node both.
64pub const MIN_LABEL_POSITION: f64 = 0.04;
65pub const MAX_LABEL_POSITION: f64 = 0.96;
66/// Where a label rests until it is moved.
67pub const DEFAULT_LABEL_POSITION: f64 = 0.5;
68
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub struct Anchor {
71    pub x: f64,
72    pub y: f64,
73    pub nx: f64,
74    pub ny: f64,
75}
76
77impl Anchor {
78    pub const fn point(self) -> Point {
79        Point::new(self.x, self.y)
80    }
81
82    /// The side of the node this anchor faces, by its normal's dominant axis.
83    /// On a corner arc the normal points diagonally; the steeper component
84    /// wins, matching which run of the rim the seat was counted along.
85    pub fn side(self) -> Side {
86        if self.nx.abs() >= self.ny.abs() {
87            if self.nx >= 0.0 {
88                Side::Right
89            } else {
90                Side::Left
91            }
92        } else if self.ny >= 0.0 {
93            Side::Bottom
94        } else {
95            Side::Top
96        }
97    }
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub enum Endpoint {
102    Start,
103    End,
104}
105
106impl Endpoint {
107    pub const fn other(self) -> Self {
108        match self {
109            Self::Start => Self::End,
110            Self::End => Self::Start,
111        }
112    }
113}
114
115/// One of the discrete places on a node's rim where a bead may rest.
116///
117/// The [`Side`] names which face of the rim the seat is on; `Top` and `Bottom`
118/// count their cells from the left corner, `Left` and `Right` from the top
119/// corner — the common origin that makes a seat survive a resize.
120///
121/// A rim offers a whole number of seats a cell apart, so a port position is an
122/// integer rather than a distance. That is what lets a bead dragged away and
123/// back leave the document byte-identical — and therefore leave no undo step
124/// behind — and what makes two beads sharing a seat a countable fact rather than
125/// a floating-point near-miss.
126#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
128#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
129pub struct PortSeat {
130    pub side: Side,
131    pub cell: u16,
132}
133
134impl PortSeat {
135    pub const fn new(side: Side, cell: u16) -> Self {
136        Self { side, cell }
137    }
138}
139
140#[derive(Clone, Debug, PartialEq)]
141pub enum Terminal {
142    Node(Id),
143    Point(Point),
144}
145
146#[derive(Clone, Debug, PartialEq)]
147pub struct Link {
148    pub id: Id,
149    pub start: Terminal,
150    pub end: Terminal,
151    /// The seat the user pinned this end to. `None` leaves it to the solver,
152    /// which is where every connection starts.
153    pub start_seat: Option<PortSeat>,
154    pub end_seat: Option<PortSeat>,
155}
156
157impl Link {
158    /// A connection with both ends left to the solver.
159    pub fn solved(id: impl Into<Id>, start: Terminal, end: Terminal) -> Self {
160        Self {
161            id: id.into(),
162            start,
163            end,
164            start_seat: None,
165            end_seat: None,
166        }
167    }
168}
169
170#[derive(Clone, Copy, Debug, PartialEq)]
171pub struct EdgeAnchors {
172    pub start: Anchor,
173    pub end: Anchor,
174}
175
176#[derive(Clone, Copy, Debug)]
177struct Ring {
178    x0: f64,
179    y0: f64,
180    x1: f64,
181    y1: f64,
182    radius: f64,
183    across: f64,
184    down: f64,
185    corner: f64,
186    length: f64,
187}
188
189fn ring(frame: Rect) -> Ring {
190    let radius = CORNER_RADIUS
191        .min(frame.width / 2.0)
192        .min(frame.height / 2.0)
193        .max(0.0);
194    let across = (frame.width - radius * 2.0).max(0.0);
195    let down = (frame.height - radius * 2.0).max(0.0);
196    let corner = PI * radius / 2.0;
197    Ring {
198        x0: frame.x + radius,
199        y0: frame.y + radius,
200        x1: frame.x + frame.width - radius,
201        y1: frame.y + frame.height - radius,
202        radius,
203        across,
204        down,
205        corner,
206        length: across * 2.0 + down * 2.0 + corner * 4.0,
207    }
208}
209
210pub fn ring_length(frame: Rect) -> f64 {
211    ring(frame).length
212}
213
214pub fn ring_point(frame: Rect, at: f64) -> Anchor {
215    let ring = ring(frame);
216    if ring.length == 0.0 {
217        return Anchor {
218            x: ring.x0,
219            y: ring.y0,
220            nx: 0.0,
221            ny: -1.0,
222        };
223    }
224    let mut s = at.rem_euclid(ring.length);
225    if s < ring.across {
226        return Anchor {
227            x: ring.x0 + s,
228            y: ring.y0 - ring.radius,
229            nx: 0.0,
230            ny: -1.0,
231        };
232    }
233    s -= ring.across;
234    if s < ring.corner {
235        return arc(ring, ring.x1, ring.y0, -FRAC_PI_2 + s / ring.radius);
236    }
237    s -= ring.corner;
238    if s < ring.down {
239        return Anchor {
240            x: ring.x1 + ring.radius,
241            y: ring.y0 + s,
242            nx: 1.0,
243            ny: 0.0,
244        };
245    }
246    s -= ring.down;
247    if s < ring.corner {
248        return arc(ring, ring.x1, ring.y1, s / ring.radius);
249    }
250    s -= ring.corner;
251    if s < ring.across {
252        return Anchor {
253            x: ring.x1 - s,
254            y: ring.y1 + ring.radius,
255            nx: 0.0,
256            ny: 1.0,
257        };
258    }
259    s -= ring.across;
260    if s < ring.corner {
261        return arc(ring, ring.x0, ring.y1, FRAC_PI_2 + s / ring.radius);
262    }
263    s -= ring.corner;
264    if s < ring.down {
265        return Anchor {
266            x: ring.x0 - ring.radius,
267            y: ring.y1 - s,
268            nx: -1.0,
269            ny: 0.0,
270        };
271    }
272    s -= ring.down;
273    arc(ring, ring.x0, ring.y0, PI + s / ring.radius)
274}
275
276fn arc(ring: Ring, cx: f64, cy: f64, angle: f64) -> Anchor {
277    let nx = angle.cos();
278    let ny = angle.sin();
279    Anchor {
280        x: cx + nx * ring.radius,
281        y: cy + ny * ring.radius,
282        nx,
283        ny,
284    }
285}
286
287pub fn nearest_on_ring(frame: Rect, target: Point) -> f64 {
288    let ring = ring(frame);
289    if ring.length == 0.0 {
290        return 0.0;
291    }
292    let cx = target.x.clamp(ring.x0, ring.x1);
293    let cy = target.y.clamp(ring.y0, ring.y1);
294    let dx = target.x - cx;
295    let dy = target.y - cy;
296    if dx == 0.0 && dy == 0.0 {
297        let gaps = [
298            target.y - frame.y,
299            frame.x + frame.width - target.x,
300            frame.y + frame.height - target.y,
301            target.x - frame.x,
302        ];
303        let nearest = gaps
304            .iter()
305            .enumerate()
306            .min_by(|left, right| left.1.total_cmp(right.1))
307            .map(|(index, _)| index)
308            .unwrap_or(0);
309        return match nearest {
310            0 => (target.x - ring.x0).clamp(0.0, ring.across),
311            1 => ring.across + ring.corner + (target.y - ring.y0).clamp(0.0, ring.down),
312            2 => {
313                ring.across
314                    + ring.corner
315                    + ring.down
316                    + ring.corner
317                    + (ring.x1 - target.x).clamp(0.0, ring.across)
318            }
319            _ => {
320                ring.across * 2.0
321                    + ring.corner * 3.0
322                    + ring.down
323                    + (ring.y1 - target.y).clamp(0.0, ring.down)
324            }
325        };
326    }
327
328    let top_right = ring.across;
329    let right_run = top_right + ring.corner;
330    let bottom_right = right_run + ring.down;
331    let bottom_run = bottom_right + ring.corner;
332    let bottom_left = bottom_run + ring.across;
333    let left_run = bottom_left + ring.corner;
334    let top_left = left_run + ring.down;
335
336    if dx == 0.0 {
337        return if dy < 0.0 {
338            cx - ring.x0
339        } else {
340            bottom_run + ring.x1 - cx
341        };
342    }
343    if dy == 0.0 {
344        return if dx > 0.0 {
345            right_run + cy - ring.y0
346        } else {
347            left_run + ring.y1 - cy
348        };
349    }
350    let angle = dy.atan2(dx);
351    if dx > 0.0 && dy < 0.0 {
352        top_right + (angle + FRAC_PI_2) * ring.radius
353    } else if dx > 0.0 {
354        bottom_right + angle * ring.radius
355    } else if dy > 0.0 {
356        bottom_left + (angle - FRAC_PI_2) * ring.radius
357    } else {
358        top_left + ((if angle < 0.0 { angle + PI * 2.0 } else { angle }) - PI) * ring.radius
359    }
360}
361
362/// How many seats long a frame's horizontal and vertical runs are.
363///
364/// A resting frame is a whole number of cells, so this is exact; during a drag
365/// it rounds, and the seats slide with the edge until the release snaps both
366/// back onto the grid.
367fn seat_counts(frame: Rect) -> (u16, u16) {
368    let count = |length: f64| {
369        let cells = (length / SEAT_PITCH).round();
370        if cells.is_finite() {
371            (cells as i64).clamp(1, 4096) as u16
372        } else {
373            1
374        }
375    };
376    (count(frame.width), count(frame.height))
377}
378
379/// Every seat a frame offers, walking the rim clockwise from the top-left
380/// corner. The four corners belong to the horizontal runs, so no place on the
381/// rim is named twice.
382pub fn seats(frame: Rect) -> Vec<PortSeat> {
383    let (cols, rows) = seat_counts(frame);
384    let mut all = Vec::with_capacity(usize::from(cols) * 2 + usize::from(rows) * 2);
385    all.extend((0..=cols).map(|cell| PortSeat::new(Side::Top, cell)));
386    all.extend((1..rows).map(|cell| PortSeat::new(Side::Right, cell)));
387    all.extend(
388        (0..=cols)
389            .rev()
390            .map(|cell| PortSeat::new(Side::Bottom, cell)),
391    );
392    all.extend((1..rows).rev().map(|cell| PortSeat::new(Side::Left, cell)));
393    all
394}
395
396/// The nearest seat this frame actually has to the one asked for. A node shrunk
397/// past one of its own ports leaves that port at the corner rather than off the
398/// end of the rim.
399pub fn clamp_seat(frame: Rect, seat: PortSeat) -> PortSeat {
400    let (cols, rows) = seat_counts(frame);
401    match seat.side {
402        Side::Top | Side::Bottom => PortSeat::new(seat.side, seat.cell.min(cols)),
403        Side::Left | Side::Right if rows < 2 => PortSeat::new(Side::Top, seat.cell.min(cols)),
404        Side::Left | Side::Right => PortSeat::new(seat.side, seat.cell.clamp(1, rows - 1)),
405    }
406}
407
408/// Where a seat sits on the rim, with the outward normal a connection leaves
409/// along. The seat names a point on the plain rectangle; the rim itself is
410/// rounded, so a corner seat resolves onto its arc.
411pub fn seat_point(frame: Rect, seat: PortSeat) -> Anchor {
412    let seat = clamp_seat(frame, seat);
413    // A seat is a whole number of cells from its own corner, so that is what it
414    // measures — not a fraction of the run it sits on. The two agree exactly on a
415    // resting frame, and only the first of them holds *during* a resize: a frame
416    // mid-drag is any width at all, and a bead on an edge nobody is dragging has
417    // to stay where it is rather than slide along proportionally and jump
418    // whenever the run's cell count rounds to the next whole one.
419    let along = |cell: u16, length: f64| (f64::from(cell) * SEAT_PITCH).min(length.max(0.0));
420    let sharp = match seat.side {
421        Side::Top => Point::new(frame.x + along(seat.cell, frame.width), frame.y),
422        Side::Bottom => Point::new(
423            frame.x + along(seat.cell, frame.width),
424            frame.y + frame.height,
425        ),
426        Side::Left => Point::new(frame.x, frame.y + along(seat.cell, frame.height)),
427        Side::Right => Point::new(
428            frame.x + frame.width,
429            frame.y + along(seat.cell, frame.height),
430        ),
431    };
432    ring_point(frame, nearest_on_ring(frame, sharp))
433}
434
435/// The seat nearest a point that nothing is sitting in yet.
436///
437/// Used when the editor is choosing for the user rather than the other way
438/// round: a band dropped into the middle of a card takes the seat nearest
439/// whatever pulls it, and steps aside if a bead is already there.
440pub fn nearest_free_seat(frame: Rect, target: Point, taken: &[PortSeat]) -> PortSeat {
441    let all = seats(frame);
442    if all.is_empty() {
443        return PortSeat::new(Side::Top, 0);
444    }
445    let wish = nearest_seat(frame, target);
446    let mut occupied = vec![false; all.len()];
447    for seat in taken {
448        let seat = clamp_seat(frame, *seat);
449        if let Some(index) = all.iter().position(|candidate| *candidate == seat) {
450            occupied[index] = true;
451        }
452    }
453    let start = all.iter().position(|seat| *seat == wish).unwrap_or(0);
454    all[nearest_free(&occupied, start)]
455}
456
457/// The seat nearest a point, which is what a dragged bead lands on.
458pub fn nearest_seat(frame: Rect, target: Point) -> PortSeat {
459    seats(frame)
460        .into_iter()
461        .map(|seat| (seat, seat_point(frame, seat).point().distance(target)))
462        .min_by(|left, right| {
463            left.1
464                .total_cmp(&right.1)
465                .then_with(|| left.0.cmp(&right.0))
466        })
467        .map_or(PortSeat::new(Side::Top, 0), |(seat, _)| seat)
468}
469
470#[derive(Clone, Debug)]
471struct Bead {
472    edge_id: Id,
473    endpoint: Endpoint,
474    /// The seat the user pinned this bead to. A pinned bead is furniture: the
475    /// solver packs the free ones around it and never moves it.
476    pinned: Option<PortSeat>,
477    /// What the band is pulling this bead towards.
478    target: Point,
479}
480
481#[derive(Clone, Copy)]
482enum Resolved<'a> {
483    Hooked { frame: Rect, node_id: &'a str },
484    Pinned(Point),
485}
486
487impl<'a> Resolved<'a> {
488    fn node_id(self) -> Option<&'a str> {
489        match self {
490            Self::Hooked { node_id, .. } => Some(node_id),
491            Self::Pinned(_) => None,
492        }
493    }
494}
495
496pub fn solve_ports(frames: &BTreeMap<Id, Rect>, links: &[Link]) -> BTreeMap<Id, EdgeAnchors> {
497    let mut anchors = BTreeMap::new();
498    let mut beads_by_node: BTreeMap<Id, Vec<Bead>> = BTreeMap::new();
499    // Ends held at a pointer rather than hooked on a rim. They are filled in
500    // once the rims have settled, so a held band aims at the bead it will meet.
501    let mut held: Vec<(Id, Endpoint, Point)> = Vec::new();
502
503    for link in links {
504        let Some(start) = resolve(frames, &link.start) else {
505            continue;
506        };
507        let Some(end) = resolve(frames, &link.end) else {
508            continue;
509        };
510        if start.node_id().is_some() && start.node_id() == end.node_id() {
511            continue;
512        }
513        if matches!(start, Resolved::Pinned(_)) && matches!(end, Resolved::Pinned(_)) {
514            continue;
515        }
516        // Where each end pulls the other. A pinned seat and a held pointer are
517        // already somewhere; two free ends aim at each other, which one pass
518        // settles well enough to decide the seat they land on.
519        let far = settle(end, link.end_seat, centre(start));
520        let near = settle(start, link.start_seat, far);
521        let far = settle(end, link.end_seat, near);
522
523        let mut hang = |resolved: Resolved<'_>, endpoint, seat, target| match resolved {
524            Resolved::Hooked { node_id, .. } => {
525                beads_by_node.entry(node_id.into()).or_default().push(Bead {
526                    edge_id: link.id.clone(),
527                    endpoint,
528                    pinned: seat,
529                    target,
530                });
531            }
532            Resolved::Pinned(at) => held.push((link.id.clone(), endpoint, at)),
533        };
534        hang(start, Endpoint::Start, link.start_seat, far);
535        hang(end, Endpoint::End, link.end_seat, near);
536    }
537
538    let mut rings: Vec<RingState> = beads_by_node
539        .into_iter()
540        .filter_map(|(node_id, beads)| Some(seat_beads(*frames.get(&node_id)?, beads)))
541        .collect();
542    for ring in &rings {
543        place_beads(&mut anchors, ring);
544    }
545    untangle(&mut anchors, &mut rings);
546    for (edge_id, endpoint, at) in held {
547        pin(&mut anchors, &edge_id, endpoint, at);
548    }
549    anchors
550}
551
552/// What a link's far end is, before its own rim has been settled.
553fn centre(resolved: Resolved<'_>) -> Point {
554    match resolved {
555        Resolved::Hooked { frame, .. } => frame.center(),
556        Resolved::Pinned(at) => at,
557    }
558}
559
560/// Where one end comes to rest given what is pulling it: a pinned bead does not
561/// move, a held pointer is already where it is, and a free bead slides to the
562/// nearest place on its own rim.
563fn settle(resolved: Resolved<'_>, seat: Option<PortSeat>, toward: Point) -> Point {
564    match (resolved, seat) {
565        (Resolved::Pinned(at), _) => at,
566        (Resolved::Hooked { frame, .. }, Some(seat)) => seat_point(frame, seat).point(),
567        (Resolved::Hooked { frame, .. }, None) => {
568            ring_point(frame, nearest_on_ring(frame, toward)).point()
569        }
570    }
571}
572
573struct RingState {
574    frame: Rect,
575    seats: Vec<PortSeat>,
576    beads: Vec<Bead>,
577    /// The seat index each bead holds, parallel to `beads`.
578    taken: Vec<usize>,
579}
580
581/// Sits every bead on this node's rim in a seat of its own.
582///
583/// Pinned beads go down first and are immovable; the free ones then take the
584/// seat nearest whatever pulls them, and the nearest empty one when that is
585/// already spoken for.
586fn seat_beads(frame: Rect, beads: Vec<Bead>) -> RingState {
587    let all = seats(frame);
588    let index_of: BTreeMap<PortSeat, usize> = all
589        .iter()
590        .enumerate()
591        .map(|(index, seat)| (*seat, index))
592        .collect();
593    let mut occupied = vec![false; all.len()];
594    let mut taken = vec![0usize; beads.len()];
595    let mut wishes: Vec<(usize, usize)> = Vec::new();
596
597    for (index, bead) in beads.iter().enumerate() {
598        match bead.pinned {
599            Some(seat) => {
600                let at = index_of.get(&clamp_seat(frame, seat)).copied().unwrap_or(0);
601                taken[index] = at;
602                occupied[at] = true;
603            }
604            None => {
605                let wish = index_of
606                    .get(&nearest_seat(frame, bead.target))
607                    .copied()
608                    .unwrap_or(0);
609                wishes.push((index, wish));
610            }
611        }
612    }
613
614    // Ordering the wishes — and breaking a tie by identity rather than by
615    // arrival — is what makes the arrangement the same for the same geometry,
616    // whatever order the connections were drawn in, and the same again after a
617    // save, a reload, an undo and a redo.
618    wishes.sort_by(|left, right| {
619        left.1
620            .cmp(&right.1)
621            .then_with(|| beads[left.0].edge_id.cmp(&beads[right.0].edge_id))
622            .then_with(|| order_of(beads[left.0].endpoint).cmp(&order_of(beads[right.0].endpoint)))
623    });
624    for (index, wish) in wishes {
625        let at = nearest_free(&occupied, wish);
626        occupied[at] = true;
627        taken[index] = at;
628    }
629
630    RingState {
631        frame,
632        seats: all,
633        beads,
634        taken,
635    }
636}
637
638/// The empty seat nearest `wish`, searched outwards in both directions so a
639/// crowded rim packs around the wish rather than drifting off one way.
640fn nearest_free(occupied: &[bool], wish: usize) -> usize {
641    let count = occupied.len();
642    if count == 0 {
643        return 0;
644    }
645    let wish = wish.min(count - 1);
646    if !occupied[wish] {
647        return wish;
648    }
649    for step in 1..count {
650        let after = (wish + step) % count;
651        if !occupied[after] {
652            return after;
653        }
654        let before = (wish + count - step) % count;
655        if !occupied[before] {
656            return before;
657        }
658    }
659    // Every seat on the rim is taken. Sharing one is better than losing a bead.
660    wish
661}
662
663const fn order_of(endpoint: Endpoint) -> u8 {
664    match endpoint {
665        Endpoint::Start => 0,
666        Endpoint::End => 1,
667    }
668}
669
670fn place_beads(anchors: &mut BTreeMap<Id, EdgeAnchors>, ring: &RingState) {
671    for (index, bead) in ring.beads.iter().enumerate() {
672        let anchor = seat_point(ring.frame, ring.seats[ring.taken[index]]);
673        let pair = anchors.entry(bead.edge_id.clone()).or_insert(EdgeAnchors {
674            start: anchor,
675            end: anchor,
676        });
677        match bead.endpoint {
678            Endpoint::Start => pair.start = anchor,
679            Endpoint::End => pair.end = anchor,
680        }
681    }
682}
683
684/// Two bands wanting the same pair of seats have nothing to decide which takes
685/// which, and a crossed pair is strictly longer than the same pair uncrossed —
686/// so neighbouring beads trade seats until no trade would shorten them. A pinned
687/// bead never trades: the user placed it.
688fn untangle(anchors: &mut BTreeMap<Id, EdgeAnchors>, rings: &mut [RingState]) {
689    const PASSES: usize = 8;
690    const EPSILON: f64 = 0.01;
691    for _ in 0..PASSES {
692        let mut traded_any = false;
693        for ring in rings.iter_mut() {
694            let mut ring_changed = false;
695            let mut order: Vec<usize> = (0..ring.beads.len()).collect();
696            order.sort_by_key(|index| ring.taken[*index]);
697            for pair in order.windows(2) {
698                let (near, far) = (pair[0], pair[1]);
699                if ring.beads[near].pinned.is_some() || ring.beads[far].pinned.is_some() {
700                    continue;
701                }
702                // Where each band's other end is *now*, not where it was
703                // provisionally guessed to be. Two identical parallel bands guess
704                // identically, so a fixed guess can never tell a crossed pair
705                // from an uncrossed one; the settled far end can.
706                let (Some(near_partner), Some(far_partner)) = (
707                    partner(anchors, &ring.beads[near]),
708                    partner(anchors, &ring.beads[far]),
709                ) else {
710                    continue;
711                };
712                let near_seat = seat_point(ring.frame, ring.seats[ring.taken[near]]).point();
713                let far_seat = seat_point(ring.frame, ring.seats[ring.taken[far]]).point();
714                let held = near_seat.distance(near_partner) + far_seat.distance(far_partner);
715                let swapped = far_seat.distance(near_partner) + near_seat.distance(far_partner);
716                if swapped < held - EPSILON {
717                    ring.taken.swap(near, far);
718                    traded_any = true;
719                    ring_changed = true;
720                }
721            }
722            if ring_changed {
723                place_beads(anchors, ring);
724            }
725        }
726        if !traded_any {
727            return;
728        }
729    }
730}
731
732fn partner(anchors: &BTreeMap<Id, EdgeAnchors>, bead: &Bead) -> Option<Point> {
733    let pair = anchors.get(&bead.edge_id)?;
734    Some(match bead.endpoint {
735        Endpoint::Start => pair.end.point(),
736        Endpoint::End => pair.start.point(),
737    })
738}
739
740fn resolve<'a>(frames: &'a BTreeMap<Id, Rect>, terminal: &'a Terminal) -> Option<Resolved<'a>> {
741    match terminal {
742        Terminal::Point(point) => Some(Resolved::Pinned(*point)),
743        Terminal::Node(node_id) => Some(Resolved::Hooked {
744            frame: *frames.get(node_id)?,
745            node_id,
746        }),
747    }
748}
749
750/// The end a pointer is holding: it is wherever the pointer is, and it aims at
751/// the bead it will meet, so the preview is the geometry the release commits.
752fn pin(anchors: &mut BTreeMap<Id, EdgeAnchors>, edge_id: &Id, endpoint: Endpoint, at: Point) {
753    let Some(pair) = anchors.get_mut(edge_id) else {
754        return;
755    };
756    let bead = match endpoint {
757        Endpoint::Start => pair.end,
758        Endpoint::End => pair.start,
759    };
760    let dx = bead.x - at.x;
761    let dy = bead.y - at.y;
762    let length = dx.hypot(dy).max(f64::EPSILON);
763    let anchor = Anchor {
764        x: at.x,
765        y: at.y,
766        nx: dx / length,
767        ny: dy / length,
768    };
769    match endpoint {
770        Endpoint::Start => pair.start = anchor,
771        Endpoint::End => pair.end = anchor,
772    }
773}
774
775type Cubic = [Point; 4];
776
777#[derive(Clone, Debug, PartialEq)]
778pub struct EdgeGeometry {
779    pub path: String,
780    pub outline: String,
781    pub start_arrow: Option<String>,
782    pub end_arrow: Option<String>,
783    pub label: Point,
784    curve: Cubic,
785}
786
787impl EdgeGeometry {
788    pub fn nearest_label_position(&self, point: Point) -> f64 {
789        nearest_position(self.curve, point)
790    }
791
792    pub fn point_at(&self, t: f64) -> Point {
793        point_at(self.curve, t)
794    }
795}
796
797const ARROW_TIP_INSET: f64 = PORT_RADIUS + 2.0;
798/// How much of the distance a port already faces becomes handle. Two ports
799/// facing each other dead on draw a straight line, which is what a straight
800/// relationship should look like.
801const ALONG_RATIO: f64 = 0.45;
802/// How hard a sideways offset bends the exit. A seated port frequently faces
803/// across the chord rather than along it — two cards side by side joined from
804/// their top edges — and this is what makes that an arch rather than a kink.
805const ACROSS_RATIO: f64 = 0.40;
806/// A port facing away from the far end has to get clear of its own card before
807/// the curve can turn back. `REACH` carries it out along its normal, `SWEEP`
808/// carries it round the side.
809const BEHIND_REACH: f64 = 0.30;
810const BEHIND_SWEEP: f64 = 0.45;
811const MIN_CURVE: f64 = 10.0;
812const MAX_CURVE: f64 = 220.0;
813const ARROW_ASPECT: f64 = 0.58;
814const MIN_ARROW_LENGTH: f64 = 5.0;
815const MIN_ARROW_SPAN: f64 = 7.0;
816const ARROW_SAMPLES: usize = 48;
817
818pub fn edge_geometry(
819    from: Anchor,
820    to: Anchor,
821    arrows: Arrows,
822    weight: u8,
823    label_position: f64,
824    bare: bool,
825) -> EdgeGeometry {
826    let start_inset = if bare {
827        0.0
828    } else if arrows.start {
829        ARROW_TIP_INSET
830    } else {
831        PORT_RADIUS
832    };
833    let end_inset = if bare {
834        0.0
835    } else if arrows.end {
836        ARROW_TIP_INSET
837    } else {
838        PORT_RADIUS
839    };
840    let start = Point::new(
841        from.x + from.nx * start_inset,
842        from.y + from.ny * start_inset,
843    );
844    let end = Point::new(to.x + to.nx * end_inset, to.y + to.ny * end_inset);
845    let dx = end.x - start.x;
846    let dy = end.y - start.y;
847    let span = dx.hypot(dy);
848    let (ux, uy) = if span > f64::EPSILON {
849        (dx / span, dy / span)
850    } else {
851        (1.0, 0.0)
852    };
853    // The side a port facing the wrong way swings out towards. Both ends pick
854    // the same one, so a curve that has to come round the back of a card does it
855    // in a single sweep instead of kinking in the middle.
856    let (px, py) = (-uy, ux);
857    // How much of the way to the far end each port faces. The projection is
858    // signed, not absolute — a port facing away must not be handed a long handle
859    // pointing backwards, which folds the curve past its own endpoint and turns
860    // the arrowhead round with it.
861    let outlook = |anchor: Anchor, toward_x: f64, toward_y: f64| {
862        let along = toward_x * anchor.nx + toward_y * anchor.ny;
863        let across = (toward_x * anchor.ny - toward_y * anchor.nx).abs();
864        (along, across)
865    };
866    let (from_along, from_across) = outlook(from, dx, dy);
867    let (to_along, to_across) = outlook(to, -dx, -dy);
868    // One port facing backwards decides the shape of the whole band: both ends
869    // are carried the same distance to the same side, so the curve travels
870    // clear of the card it has to get around instead of cutting back through it
871    // to meet a far end that stayed on the axis.
872    let sweep = (-from_along).max(-to_along).clamp(0.0, MAX_CURVE) * BEHIND_SWEEP;
873    let control = |anchor: Anchor, at: Point, along: f64, across: f64| {
874        let reach = (along.max(0.0) * ALONG_RATIO
875            + across * ACROSS_RATIO
876            + (-along).max(0.0) * BEHIND_REACH)
877            .clamp(MIN_CURVE, MAX_CURVE);
878        // The sweep runs along the chord's perpendicular, which for a port facing
879        // across the chord points partly back into that port's own card — enough
880        // of it, since the sweep can be larger than the reach, to send the handle
881        // behind its own bead. Only the part of the sweep that runs along this
882        // port's own rail is kept, so the handle always leaves along the outward
883        // normal by exactly `reach`, and both ends still swing to the same side.
884        let behind = px * anchor.nx + py * anchor.ny;
885        let (sx, sy) = (px - anchor.nx * behind, py - anchor.ny * behind);
886        Point::new(
887            at.x + anchor.nx * reach + sx * sweep,
888            at.y + anchor.ny * reach + sy * sweep,
889        )
890    };
891    let curve = [
892        start,
893        control(from, start, from_along, from_across),
894        control(to, end, to_along, to_across),
895        end,
896    ];
897    let nominal: f64 = match weight.clamp(1, 3) {
898        1 => 7.5,
899        2 => 8.5,
900        _ => 9.5,
901    };
902    let head_length = if arrows == Arrows::NONE || span < MIN_ARROW_SPAN {
903        0.0
904    } else {
905        nominal.min(span * 0.42).max(MIN_ARROW_LENGTH)
906    };
907    let mut start_t = 0.0;
908    let mut end_t = 1.0;
909    let mut start_arrow = None;
910    let mut end_arrow = None;
911    if head_length > 0.0 {
912        if arrows.start {
913            start_t = forward_from_start(curve, head_length);
914        }
915        if arrows.end {
916            end_t = back_from_end(curve, head_length);
917        }
918        // Two heads on a short band each reach past the other, and a stroke
919        // trimmed to a crossed pair is drawn backwards — which turns both
920        // arrowheads round, exactly what the signed handle above exists to
921        // prevent. They meet in the middle instead: the band gives up its stroke
922        // rather than its heads, and every head still points out at its own end.
923        if start_t > end_t {
924            let met = (start_t + end_t) / 2.0;
925            start_t = met;
926            end_t = met;
927        }
928        if arrows.start {
929            start_arrow = Some(arrow_path(point_at(curve, start_t), start, head_length));
930        }
931        if arrows.end {
932            end_arrow = Some(arrow_path(point_at(curve, end_t), end, head_length));
933        }
934    }
935    let label_position = label_position.clamp(MIN_LABEL_POSITION, MAX_LABEL_POSITION);
936    EdgeGeometry {
937        path: to_path(slice(curve, start_t, end_t)),
938        outline: to_path(curve),
939        start_arrow,
940        end_arrow,
941        label: point_at(curve, label_position),
942        curve,
943    }
944}
945
946fn point_at([p0, p1, p2, p3]: Cubic, t: f64) -> Point {
947    let a = lerp(p0, p1, t);
948    let b = lerp(p1, p2, t);
949    let c = lerp(p2, p3, t);
950    lerp(lerp(a, b, t), lerp(b, c, t), t)
951}
952
953fn lerp(a: Point, b: Point, t: f64) -> Point {
954    Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
955}
956
957fn nearest_position(curve: Cubic, target: Point) -> f64 {
958    let samples = 40;
959    let mut best = 0.0;
960    let mut best_distance = f64::INFINITY;
961    for index in 0..=samples {
962        let t = index as f64 / samples as f64;
963        let distance = squared(point_at(curve, t), target);
964        if distance < best_distance {
965            best = t;
966            best_distance = distance;
967        }
968    }
969    let mut low = (best - 1.0 / samples as f64).max(0.0);
970    let mut high = (best + 1.0 / samples as f64).min(1.0);
971    for _ in 0..16 {
972        let left = low + (high - low) / 3.0;
973        let right = high - (high - low) / 3.0;
974        if squared(point_at(curve, left), target) < squared(point_at(curve, right), target) {
975            high = right;
976        } else {
977            low = left;
978        }
979    }
980    ((low + high) / 2.0).clamp(MIN_LABEL_POSITION, MAX_LABEL_POSITION)
981}
982
983fn squared(a: Point, b: Point) -> f64 {
984    (a.x - b.x).powi(2) + (a.y - b.y).powi(2)
985}
986
987fn split([p0, p1, p2, p3]: Cubic, t: f64) -> (Cubic, Cubic) {
988    let a = lerp(p0, p1, t);
989    let b = lerp(p1, p2, t);
990    let c = lerp(p2, p3, t);
991    let d = lerp(a, b, t);
992    let e = lerp(b, c, t);
993    let midpoint = lerp(d, e, t);
994    ([p0, a, d, midpoint], [midpoint, e, c, p3])
995}
996
997fn slice(curve: Cubic, start: f64, end: f64) -> Cubic {
998    if start <= 0.0 && end >= 1.0 {
999        return curve;
1000    }
1001    let before_end = if end < 1.0 {
1002        split(curve, end).0
1003    } else {
1004        curve
1005    };
1006    if start <= 0.0 {
1007        before_end
1008    } else {
1009        split(before_end, start / end).1
1010    }
1011}
1012
1013fn back_from_end(curve: Cubic, distance: f64) -> f64 {
1014    let end = curve[3];
1015    for index in 1..=ARROW_SAMPLES {
1016        let t = 1.0 - index as f64 / ARROW_SAMPLES as f64;
1017        if point_at(curve, t).distance(end) >= distance {
1018            return refine(curve, end, t, t + 1.0 / ARROW_SAMPLES as f64, distance);
1019        }
1020    }
1021    0.0
1022}
1023
1024fn forward_from_start(curve: Cubic, distance: f64) -> f64 {
1025    let start = curve[0];
1026    for index in 1..=ARROW_SAMPLES {
1027        let t = index as f64 / ARROW_SAMPLES as f64;
1028        if point_at(curve, t).distance(start) >= distance {
1029            return refine(curve, start, t, t - 1.0 / ARROW_SAMPLES as f64, distance);
1030        }
1031    }
1032    1.0
1033}
1034
1035fn refine(curve: Cubic, anchor: Point, outside: f64, inside: f64, distance: f64) -> f64 {
1036    let mut far = outside;
1037    let mut near = inside;
1038    for _ in 0..12 {
1039        let mid = (far + near) / 2.0;
1040        if point_at(curve, mid).distance(anchor) >= distance {
1041            far = mid;
1042        } else {
1043            near = mid;
1044        }
1045    }
1046    (far + near) / 2.0
1047}
1048
1049fn to_path([p0, p1, p2, p3]: Cubic) -> String {
1050    format!(
1051        "M{},{} C{},{} {},{} {},{}",
1052        round(p0.x),
1053        round(p0.y),
1054        round(p1.x),
1055        round(p1.y),
1056        round(p2.x),
1057        round(p2.y),
1058        round(p3.x),
1059        round(p3.y)
1060    )
1061}
1062
1063fn arrow_path(base: Point, tip: Point, length: f64) -> String {
1064    let axis = Point::new(tip.x - base.x, tip.y - base.y);
1065    let axis_length = axis.x.hypot(axis.y).max(f64::EPSILON);
1066    let half = length * ARROW_ASPECT / 2.0;
1067    let px = -axis.y / axis_length * half;
1068    let py = axis.x / axis_length * half;
1069    format!(
1070        "M{},{} L{},{} L{},{} Z",
1071        round(base.x + px),
1072        round(base.y + py),
1073        round(tip.x),
1074        round(tip.y),
1075        round(base.x - px),
1076        round(base.y - py)
1077    )
1078}
1079
1080fn round(value: f64) -> f64 {
1081    (value * 100.0).round() / 100.0
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use super::*;
1087
1088    fn frame(x: f64, y: f64) -> Rect {
1089        Rect {
1090            x,
1091            y,
1092            width: 216.0,
1093            height: 48.0,
1094        }
1095    }
1096
1097    #[test]
1098    fn ring_walk_round_trips() {
1099        let frame = frame(10.0, 20.0);
1100        let length = ring_length(frame);
1101        for step in 0..128 {
1102            let at = length * step as f64 / 128.0;
1103            let point = ring_point(frame, at);
1104            let found = nearest_on_ring(frame, point.point());
1105            let delta = (found - at).abs().min(length - (found - at).abs());
1106            assert!(delta < 0.001, "{at} vs {found}");
1107            assert!((point.nx.hypot(point.ny) - 1.0).abs() < 0.001);
1108        }
1109    }
1110
1111    fn node(id: &str) -> Terminal {
1112        Terminal::Node(id.into())
1113    }
1114
1115    fn pair() -> BTreeMap<Id, Rect> {
1116        BTreeMap::from([
1117            ("a".into(), frame(0.0, 0.0)),
1118            ("b".into(), frame(360.0, 0.0)),
1119        ])
1120    }
1121
1122    #[test]
1123    fn lone_band_uses_closest_points() {
1124        let frames = BTreeMap::from([
1125            ("a".into(), frame(0.0, 0.0)),
1126            ("b".into(), frame(360.0, 120.0)),
1127        ]);
1128        let result = solve_ports(&frames, &[Link::solved("edge", node("a"), node("b"))]);
1129        let anchors = result["edge"];
1130        assert!(anchors.start.x > 200.0);
1131        assert!(anchors.end.x < 370.0);
1132    }
1133
1134    #[test]
1135    fn crowded_ports_keep_a_seat_each() {
1136        let frames = pair();
1137        let links: Vec<_> = (0..5)
1138            .map(|index| Link::solved(format!("edge-{index}"), node("a"), node("b")))
1139            .collect();
1140        let result = solve_ports(&frames, &links);
1141        let mut seats: Vec<_> = result
1142            .values()
1143            .map(|anchors| nearest_seat(frames["a"], anchors.start.point()))
1144            .collect();
1145        seats.sort();
1146        seats.dedup();
1147        assert_eq!(seats.len(), 5, "five bands must take five distinct seats");
1148        for window in seats.windows(2) {
1149            let gap = seat_point(frames["a"], window[0])
1150                .point()
1151                .distance(seat_point(frames["a"], window[1]).point());
1152            // A cell apart along the straight runs. Across a corner the rim cuts
1153            // the angle, so two seats either side of one are nearer than that —
1154            // still never near enough for their beads to touch.
1155            assert!(gap >= PORT_RADIUS * 2.0, "beads {gap} apart");
1156        }
1157    }
1158
1159    #[test]
1160    fn held_band_uses_the_same_solver() {
1161        let frames = BTreeMap::from([("a".into(), frame(0.0, 0.0))]);
1162        let result = solve_ports(
1163            &frames,
1164            &[Link::solved(
1165                "held",
1166                node("a"),
1167                Terminal::Point(Point::new(400.0, 120.0)),
1168            )],
1169        );
1170        assert_eq!(result["held"].end.point(), Point::new(400.0, 120.0));
1171    }
1172
1173    /// The whole point of a seat: it is where the user put it, whatever the
1174    /// solver would have preferred.
1175    #[test]
1176    fn a_pinned_bead_stays_where_it_was_put() {
1177        let frames = pair();
1178        let seat = PortSeat::new(Side::Left, 2);
1179        let mut link = Link::solved("edge", node("a"), node("b"));
1180        link.start_seat = Some(seat);
1181        let result = solve_ports(&frames, &[link]);
1182        assert_eq!(
1183            result["edge"].start.point(),
1184            seat_point(frames["a"], seat).point(),
1185            "a pinned bead must not be re-solved onto the facing side"
1186        );
1187    }
1188
1189    /// A pinned bead is furniture. Free beads pack around it and never evict it.
1190    #[test]
1191    fn free_beads_make_room_for_a_pinned_one() {
1192        let frames = pair();
1193        let taken = PortSeat::new(Side::Right, 2);
1194        let mut links: Vec<_> = (0..4)
1195            .map(|index| Link::solved(format!("edge-{index}"), node("a"), node("b")))
1196            .collect();
1197        links[0].start_seat = Some(taken);
1198        let result = solve_ports(&frames, &links);
1199        assert_eq!(
1200            nearest_seat(frames["a"], result["edge-0"].start.point()),
1201            taken
1202        );
1203        for index in 1..4 {
1204            let seat = nearest_seat(frames["a"], result[&format!("edge-{index}")].start.point());
1205            assert_ne!(seat, taken, "edge-{index} evicted the pinned bead");
1206        }
1207    }
1208
1209    /// Parallel bands run alongside each other rather than crossing in the gap.
1210    ///
1211    /// This is the case that cannot be settled from where each band *would* rest
1212    /// if it were alone: three identical bands guess identically, so they all
1213    /// want the same seat and the tie is broken by identity. Read down one rim
1214    /// and up the other and the order reverses, which is a crossing — so the
1215    /// trade has to be judged against where each band's far end actually ended
1216    /// up.
1217    #[test]
1218    fn parallel_bands_do_not_cross_in_the_gap() {
1219        let frames = pair();
1220        let links: Vec<_> = (0..3)
1221            .map(|index| Link::solved(format!("edge-{index}"), node("a"), node("b")))
1222            .collect();
1223        let solved = solve_ports(&frames, &links);
1224
1225        let mut by_start: Vec<_> = links
1226            .iter()
1227            .map(|link| {
1228                let anchors = solved[&link.id];
1229                (anchors.start.y, anchors.end.y)
1230            })
1231            .collect();
1232        by_start.sort_by(|left, right| left.0.total_cmp(&right.0));
1233        for window in by_start.windows(2) {
1234            assert!(
1235                window[0].1 < window[1].1,
1236                "bands cross: {:?} then {:?}",
1237                window[0],
1238                window[1]
1239            );
1240        }
1241    }
1242
1243    /// Ports are solved from the frames alone, so the same map must produce the
1244    /// same rim whatever order it was built in — which is what makes a save and
1245    /// reload, an undo and a redo, leave the picture untouched.
1246    #[test]
1247    fn seating_does_not_depend_on_the_order_connections_were_made() {
1248        let frames = pair();
1249        let forwards: Vec<_> = (0..5)
1250            .map(|index| Link::solved(format!("edge-{index}"), node("a"), node("b")))
1251            .collect();
1252        let mut backwards = forwards.clone();
1253        backwards.reverse();
1254        assert_eq!(
1255            solve_ports(&frames, &forwards),
1256            solve_ports(&frames, &backwards)
1257        );
1258    }
1259
1260    #[test]
1261    fn a_seat_survives_a_resize_by_keeping_its_distance_from_its_own_corner() {
1262        let seat = PortSeat::new(Side::Top, 3);
1263        let before = seat_point(frame(0.0, 0.0), seat).point();
1264        // Growing the card to the right moves only the right edge, so a seat
1265        // measured from the left corner has not moved at all.
1266        let after = seat_point(Rect::new(0.0, 0.0, 360.0, 48.0), seat).point();
1267        assert_eq!(before, after);
1268    }
1269
1270    /// And it survives the *drag* that gets there, which is the harder half: a
1271    /// frame between two grid stops is any width at all, and a seat read as a
1272    /// fraction of its run slid along the edge the whole way — jumping several
1273    /// pixels each time the run's cell count rounded to the next whole one.
1274    #[test]
1275    fn a_bead_on_an_edge_nobody_is_dragging_does_not_move_while_another_edge_does() {
1276        let seat = PortSeat::new(Side::Top, 9);
1277        let resting = seat_point(frame(0.0, 0.0), seat).point();
1278        assert_eq!(resting.x, 9.0 * SEAT_PITCH);
1279
1280        let mut width = 216.0;
1281        while width <= 480.0 {
1282            let at = seat_point(Rect::new(0.0, 0.0, width, 48.0), seat).point();
1283            assert!(
1284                (at.x - resting.x).abs() < 1e-9 && (at.y - resting.y).abs() < 1e-9,
1285                "a right-edge resize to {width} moved a top-edge bead to {at:?}"
1286            );
1287            width += 0.25;
1288        }
1289    }
1290
1291    /// A band shorter than the two arrowheads it carries used to be drawn
1292    /// backwards: each head was trimmed off from its own end, the two trims
1293    /// crossed, and the slice between them came out reversed with both
1294    /// arrowheads facing the wrong way.
1295    #[test]
1296    fn two_arrowheads_on_a_short_band_meet_rather_than_reverse_it() {
1297        for gap in [8.0, 12.0, 14.0, 16.0, 17.0, 18.0, 20.0, 24.0, 30.0, 40.0] {
1298            let from = Anchor {
1299                x: 0.0,
1300                y: 0.0,
1301                nx: 1.0,
1302                ny: 0.0,
1303            };
1304            let to = Anchor {
1305                x: gap,
1306                y: 0.0,
1307                nx: -1.0,
1308                ny: 0.0,
1309            };
1310            let geometry = edge_geometry(from, to, Arrows::new(true, true), 1, 0.5, false);
1311            // The untrimmed band runs whichever way its inset ends do; trimming
1312            // the heads off it must not turn that round.
1313            let points = path_points(&geometry.path);
1314            let whole = path_points(&geometry.outline);
1315            let drawn = points[points.len() - 1].x - points[0].x;
1316            let chord = whole[whole.len() - 1].x - whole[0].x;
1317            assert!(
1318                drawn * chord >= 0.0,
1319                "a {gap}px band runs {chord} but is drawn {drawn}: {}",
1320                geometry.path
1321            );
1322            // And each head still points out at the end it belongs to, which is
1323            // the other thing a crossed trim used to reverse.
1324            for (arrow, tip, away) in [
1325                (geometry.start_arrow.as_deref(), from.x, to.x),
1326                (geometry.end_arrow.as_deref(), to.x, from.x),
1327            ] {
1328                let Some(arrow) = arrow else { continue };
1329                let head = path_points(arrow);
1330                let base = (head[0].x + head[2].x) / 2.0;
1331                assert!(
1332                    (base - head[1].x) * (away - tip) >= 0.0,
1333                    "a {gap}px band's arrowhead points backwards: {arrow}"
1334                );
1335            }
1336        }
1337    }
1338
1339    /// Every coordinate pair in a path command string, in order.
1340    fn path_points(path: &str) -> Vec<Point> {
1341        let numbers: Vec<f64> = path
1342            .replace(['M', 'C', 'L', 'Z', ','], " ")
1343            .split_whitespace()
1344            .map(|value| value.parse().expect("a path is made of numbers"))
1345            .collect();
1346        numbers
1347            .chunks_exact(2)
1348            .map(|pair| Point::new(pair[0], pair[1]))
1349            .collect()
1350    }
1351
1352    #[test]
1353    fn a_seat_shrunk_off_the_end_of_its_run_lands_on_the_corner() {
1354        let narrow = Rect::new(0.0, 0.0, 120.0, 48.0);
1355        let clamped = clamp_seat(narrow, PortSeat::new(Side::Top, 40));
1356        assert_eq!(clamped, PortSeat::new(Side::Top, 10));
1357        assert!(seats(narrow).contains(&clamped));
1358    }
1359
1360    /// Every seat names a place on the rim, and no place is named twice.
1361    #[test]
1362    fn the_seats_of_a_frame_are_distinct_and_on_the_rim() {
1363        let frame = frame(0.0, 0.0);
1364        let all = seats(frame);
1365        assert_eq!(all.len(), 2 * 18 + 2 * 4);
1366        let mut seen = std::collections::BTreeSet::new();
1367        for seat in &all {
1368            assert!(seen.insert(*seat), "{seat:?} named twice");
1369            let anchor = seat_point(frame, *seat);
1370            let on_rim = nearest_on_ring(frame, anchor.point());
1371            let back = ring_point(frame, on_rim).point();
1372            assert!(
1373                back.distance(anchor.point()) < 0.001,
1374                "{seat:?} is off the rim"
1375            );
1376            assert!((anchor.nx.hypot(anchor.ny) - 1.0).abs() < 0.001);
1377        }
1378    }
1379
1380    #[test]
1381    fn an_anchor_faces_the_side_its_seat_was_counted_along() {
1382        let frame = frame(0.0, 0.0);
1383        for seat in seats(frame) {
1384            let anchor = seat_point(frame, seat);
1385            // Corner seats belong to the horizontal runs, and their arc
1386            // normals lean no further than the diagonal — so the dominant
1387            // axis never contradicts the seat's own side, except at the exact
1388            // diagonal where either answer is honest.
1389            if anchor.nx.abs() != anchor.ny.abs() {
1390                let expected = match seat.side {
1391                    Side::Top | Side::Bottom if anchor.ny.abs() > anchor.nx.abs() => seat.side,
1392                    Side::Left | Side::Right if anchor.nx.abs() > anchor.ny.abs() => seat.side,
1393                    _ => anchor.side(),
1394                };
1395                assert_eq!(anchor.side(), expected, "{seat:?}");
1396            }
1397        }
1398        // The four plain faces, unambiguously.
1399        assert_eq!(
1400            seat_point(frame, PortSeat::new(Side::Top, 9)).side(),
1401            Side::Top
1402        );
1403        assert_eq!(
1404            seat_point(frame, PortSeat::new(Side::Bottom, 9)).side(),
1405            Side::Bottom
1406        );
1407        assert_eq!(
1408            seat_point(frame, PortSeat::new(Side::Left, 2)).side(),
1409            Side::Left
1410        );
1411        assert_eq!(
1412            seat_point(frame, PortSeat::new(Side::Right, 2)).side(),
1413            Side::Right
1414        );
1415    }
1416
1417    #[test]
1418    fn every_seat_is_the_nearest_seat_to_itself() {
1419        let frame = frame(0.0, 0.0);
1420        for seat in seats(frame) {
1421            assert_eq!(nearest_seat(frame, seat_point(frame, seat).point()), seat);
1422        }
1423    }
1424
1425    /// A port facing away from the far end used to be handed a full-length
1426    /// handle pointing backwards, which folded the curve past its own endpoint
1427    /// and reversed the arrowhead sitting on it.
1428    #[test]
1429    fn a_port_facing_away_does_not_fold_the_curve_back_past_its_end() {
1430        let away = Anchor {
1431            x: 0.0,
1432            y: 0.0,
1433            nx: -1.0,
1434            ny: 0.0,
1435        };
1436        let facing = Anchor {
1437            x: 300.0,
1438            y: 0.0,
1439            nx: -1.0,
1440            ny: 0.0,
1441        };
1442        let geometry = edge_geometry(away, facing, Arrows::new(false, true), 2, 0.5, true);
1443        let mut previous = geometry.point_at(0.0);
1444        let mut travelled = 0.0;
1445        for step in 1..=64 {
1446            let point = geometry.point_at(f64::from(step) / 64.0);
1447            travelled += previous.distance(point);
1448            previous = point;
1449        }
1450        let direct = geometry.point_at(0.0).distance(geometry.point_at(1.0));
1451        assert!(
1452            travelled < direct * 2.0,
1453            "the curve wanders {travelled} over a {direct} span"
1454        );
1455    }
1456
1457    /// A band leaves along its port's own outward normal, whatever the rest of
1458    /// the shape is doing. The sweep that carries a backwards-facing port round
1459    /// its card is a chord perpendicular, and for a port facing *across* the chord
1460    /// that perpendicular points partly back into the card — which sent the handle
1461    /// behind its own bead and drew the first stretch of the band through the card
1462    /// it was leaving.
1463    #[test]
1464    fn every_band_leaves_its_bead_pointing_out_of_its_own_card() {
1465        let a = Rect::new(0.0, 0.0, 216.0, 48.0);
1466        let mut worst = 1.0;
1467        let mut at_worst = String::new();
1468        for offset in [
1469            (60.0, 180.0),
1470            (-300.0, 180.0),
1471            (300.0, -180.0),
1472            (0.0, -240.0),
1473            (480.0, 0.0),
1474            (-40.0, 90.0),
1475        ] {
1476            let b = Rect::new(a.x + offset.0, a.y + offset.1, 216.0, 48.0);
1477            for from in seats(a) {
1478                for to in seats(b) {
1479                    let geometry = edge_geometry(
1480                        seat_point(a, from),
1481                        seat_point(b, to),
1482                        Arrows::NONE,
1483                        1,
1484                        0.5,
1485                        true,
1486                    );
1487                    // The tangent at each end, against that end's own normal.
1488                    for (anchor, near, far) in [
1489                        (seat_point(a, from), 0.0, 0.02),
1490                        (seat_point(b, to), 1.0, 0.98),
1491                    ] {
1492                        let here = geometry.point_at(near);
1493                        let along = geometry.point_at(far);
1494                        let (dx, dy) = (along.x - here.x, along.y - here.y);
1495                        let length = dx.hypot(dy).max(f64::EPSILON);
1496                        let outward = (dx * anchor.nx + dy * anchor.ny) / length;
1497                        if outward < worst {
1498                            worst = outward;
1499                            at_worst = format!("{offset:?} {from:?} -> {to:?}");
1500                        }
1501                    }
1502                }
1503            }
1504        }
1505        assert!(
1506            worst > 0.0,
1507            "a band leaves its bead pointing inward ({worst}) for {at_worst}"
1508        );
1509    }
1510
1511    /// Two seats facing across the chord rather than along it — two cards side
1512    /// by side joined from their top edges — must arch, not kink.
1513    #[test]
1514    fn ports_facing_across_the_chord_actually_curve() {
1515        let up = |x: f64| Anchor {
1516            x,
1517            y: 0.0,
1518            nx: 0.0,
1519            ny: -1.0,
1520        };
1521        let geometry = edge_geometry(up(0.0), up(300.0), Arrows::NONE, 2, 0.5, true);
1522        let middle = geometry.point_at(0.5);
1523        assert!(
1524            middle.y < -40.0,
1525            "the arch only reaches {} above the chord",
1526            -middle.y
1527        );
1528    }
1529}