1pub use crate::types::Side;
17use crate::types::{Id, Point, Rect};
18use std::collections::BTreeMap;
19use std::f64::consts::{FRAC_PI_2, PI};
20
21#[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
41pub const SEAT_PITCH: f64 = 12.0;
49pub const PORT_RADIUS: f64 = 3.6;
55pub const CORNER_RADIUS: f64 = SEAT_PITCH;
62pub const MIN_LABEL_POSITION: f64 = 0.04;
65pub const MAX_LABEL_POSITION: f64 = 0.96;
66pub 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 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#[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 pub start_seat: Option<PortSeat>,
154 pub end_seat: Option<PortSeat>,
155}
156
157impl Link {
158 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
362fn 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
379pub 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
396pub 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
408pub fn seat_point(frame: Rect, seat: PortSeat) -> Anchor {
412 let seat = clamp_seat(frame, seat);
413 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
435pub 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
457pub 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 pinned: Option<PortSeat>,
477 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 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 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
552fn centre(resolved: Resolved<'_>) -> Point {
554 match resolved {
555 Resolved::Hooked { frame, .. } => frame.center(),
556 Resolved::Pinned(at) => at,
557 }
558}
559
560fn 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 taken: Vec<usize>,
579}
580
581fn 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 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
638fn 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 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
684fn 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 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
750fn 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;
798const ALONG_RATIO: f64 = 0.45;
802const ACROSS_RATIO: f64 = 0.40;
806const 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 let (px, py) = (-uy, ux);
857 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 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 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 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 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 #[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 #[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 #[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 #[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 let after = seat_point(Rect::new(0.0, 0.0, 360.0, 48.0), seat).point();
1267 assert_eq!(before, after);
1268 }
1269
1270 #[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 #[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 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 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 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 #[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 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 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 #[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 #[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 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 #[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}