Files
ARCADIA-Studio/crates/oa-core/src/routing.rs
T

1500 lines
49 KiB
Rust

//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
pub const PORT_STUB: f32 = 18.0;
pub const CLEARANCE: f32 = 12.0;
const HEADER: f32 = 64.0;
const BEND_COST: f32 = 24.0;
const MAX_GRID_CELLS: usize = 65_536;
const MAX_SEARCH_STEPS: usize = 8_192;
const VIEW_SEARCH_BUDGET: usize = 65_536;
const EPSILON: f32 = 0.001;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RoutingBounds {
pub position: Position,
pub size: Size,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoutedView {
pub edges: BTreeMap<Id, RoutedEdge>,
pub bounds: RoutingBounds,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoutedEdge {
pub id: Id,
pub points: Vec<Position>,
pub label: EdgeLabelGeometry,
/// Effective authored points, empty while a folded endpoint suspends routing.
pub pins: Vec<Position>,
/// Actual traversal fractions; these preserve order even on a reversing path.
pub pin_fractions: Vec<f32>,
pub warnings: Vec<RouteWarning>,
pub custom_suspended: bool,
// Derived solver state, never persisted. A preview must inherit the same
// remaining work budget as this edge had in stable-ID settled traversal.
search_budget_before: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EdgeLabelGeometry {
/// Empty for edge kinds without a permanently displayed label.
pub text: String,
/// Effective arc-length fraction, including an automatic collision-avoidance
/// choice. Use this when the first label drag becomes authored metadata.
pub fraction: f32,
pub anchor: Position,
pub center: Position,
pub bounds: RoutingBounds,
}
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
pub struct AssociationEndGeometry {
pub source_end: bool,
pub label: EdgeLabelGeometry,
pub tip: Position,
pub inward: Position,
pub navigable: bool,
}
pub fn association_end_geometry(
relation: &Relation,
routed: &RoutedEdge,
) -> Vec<AssociationEndGeometry> {
let RelationBody::DataAssociation {
source_navigable,
target_navigable,
..
} = &relation.body
else {
return Vec::new();
};
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
let Some(tip) = (if source_end {
routed.points.first()
} else {
routed.points.last()
})
.copied() else {
continue;
};
let inward = if source_end {
routed.points.iter().find(|p| **p != tip)
} else {
routed.points.iter().rev().find(|p| **p != tip)
};
let Some(inward) = inward.copied() else {
continue;
};
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
let role = if role.chars().count() > 32 {
role.chars()
.take(31)
.chain(std::iter::once('…'))
.collect::<String>()
} else {
role.to_owned()
};
format!("{role} [{multiplicity}")
} else {
full
};
let size = Size {
width: text.chars().count() as f32 * 7.0 + 12.0,
height: 22.0,
};
let dx = inward.x - tip.x;
let dy = inward.y - tip.y;
let length = dx.hypot(dy);
let direction = pos(dx / length, dy / length);
// One normal sign puts opposite endpoints of a straight edge on
// opposite sides because their inward tangent directions are reversed.
let along =
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
let normal =
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
let mut center = pos(
tip.x + direction.x * along - direction.y * normal,
tip.y + direction.y * along + direction.x * normal,
);
if let Some(other) = ends.first()
&& (center.x - other.label.center.x).abs()
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
&& (center.y - other.label.center.y).abs()
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
{
// Same-side anchors on a self-loop can still be close. Separate
// their complete bounds along this end's normal in one step.
let perpendicular = pos(-direction.y, direction.x);
let distance = (center.x - other.label.center.x) * perpendicular.x
+ (center.y - other.label.center.y) * perpendicular.y;
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
/ 2.0
+ 8.0;
let shift = if distance < 0.0 {
-clearance - distance
} else {
clearance - distance
};
center.x += perpendicular.x * shift;
center.y += perpendicular.y * shift;
}
ends.push(AssociationEndGeometry {
source_end,
label: EdgeLabelGeometry {
text,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
bounds: RoutingBounds {
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
size,
},
},
tip,
inward,
navigable,
});
}
ends
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
PinInsideObstacle { pin: usize, obstacle: Id },
NoClearPath,
SearchBudgetExhausted,
PromotedEndpoints,
}
impl fmt::Display for RouteWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PinInsideObstacle { pin, .. } => {
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
}
Self::NoClearPath => {
f.write_str("No clear route: move a bend or make more space between cards")
}
Self::SearchBudgetExhausted => {
f.write_str("Routing search limit reached: add a bend or make more space")
}
Self::PromotedEndpoints => {
f.write_str("Expand the endpoint to edit routing; stored bends resume on expansion")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PolylineProjection {
pub point: Position,
pub fraction: f32,
pub distance: f32,
}
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn length(a: Position, b: Position) -> f32 {
(b.x - a.x).hypot(b.y - a.y)
}
fn manhattan(a: Position, b: Position) -> f32 {
(b.x - a.x).abs() + (b.y - a.y).abs()
}
fn total_length(points: &[Position]) -> f32 {
points.windows(2).map(|p| length(p[0], p[1])).sum()
}
/// Clamped arc-length interpolation, also defined for zero-length polylines.
pub fn point_along_polyline(points: &[Position], fraction: f32) -> Option<Position> {
let first = *points.first()?;
let mut remaining = total_length(points)
* if fraction.is_finite() {
fraction.clamp(0.0, 1.0)
} else {
0.0
};
for pair in points.windows(2) {
let len = length(pair[0], pair[1]);
if len > 0.0 && remaining <= len {
let t = remaining / len;
return Some(pos(
pair[0].x + (pair[1].x - pair[0].x) * t,
pair[0].y + (pair[1].y - pair[0].y) * t,
));
}
remaining -= len;
}
Some(*points.last().unwrap_or(&first))
}
fn on_segment(a: Position, b: Position, point: Position) -> Position {
let dx = b.x - a.x;
let dy = b.y - a.y;
let squared = dx * dx + dy * dy;
let t = if squared == 0.0 {
0.0
} else {
((point.x - a.x) * dx + (point.y - a.y) * dy) / squared
}
.clamp(0.0, 1.0);
pos(a.x + dx * t, a.y + dy * t)
}
/// Nearest point, breaking equal-distance ties by earliest traversal position.
pub fn project_onto_polyline(points: &[Position], point: Position) -> Option<PolylineProjection> {
let first = *points.first()?;
let total = total_length(points);
let mut best = PolylineProjection {
point: first,
fraction: 0.0,
distance: length(first, point),
};
let mut traversed = 0.0;
for pair in points.windows(2) {
let projected = on_segment(pair[0], pair[1], point);
let distance = length(projected, point);
if distance < best.distance {
best = PolylineProjection {
point: projected,
fraction: if total > 0.0 {
(traversed + length(pair[0], projected)) / total
} else {
0.0
},
distance,
};
}
traversed += length(pair[0], pair[1]);
}
Some(best)
}
/// Insert after pins already traversed on the picked segment. Unlike nearest
/// projection alone, the segment index disambiguates overlapping/reversed runs.
pub fn pin_insertion_index(route: &RoutedEdge, segment_index: usize, point: Position) -> usize {
let Some(pair) = route
.points
.get(segment_index..segment_index.saturating_add(2))
else {
return route.pins.len();
};
let total = total_length(&route.points);
if total == 0.0 {
return route.pins.len();
}
let before = total_length(&route.points[..=segment_index]);
let fraction = (before + length(pair[0], on_segment(pair[0], pair[1], point))) / total;
route
.pin_fractions
.iter()
.take_while(|&&pin| pin <= fraction + f32::EPSILON)
.count()
}
#[derive(Clone, Copy)]
struct Rect {
id: Id,
left: f32,
top: f32,
right: f32,
bottom: f32,
}
impl Rect {
fn node(node: &ProjectedNode) -> Self {
Self {
id: node.id,
left: node.position.x,
top: node.position.y,
right: node.position.x + node.size.width,
bottom: node.position.y + node.size.height,
}
}
fn inflate(self, amount: f32) -> Self {
Self {
left: self.left - amount,
top: self.top - amount,
right: self.right + amount,
bottom: self.bottom + amount,
..self
}
}
fn contains(self, p: Position) -> bool {
p.x > self.left + EPSILON
&& p.x < self.right - EPSILON
&& p.y > self.top + EPSILON
&& p.y < self.bottom - EPSILON
}
fn crosses(self, a: Position, b: Position) -> bool {
if a == b {
return self.contains(a);
}
if (a.x - b.x).abs() < EPSILON {
a.x > self.left + EPSILON
&& a.x < self.right - EPSILON
&& a.y.min(b.y) < self.bottom - EPSILON
&& a.y.max(b.y) > self.top + EPSILON
} else if (a.y - b.y).abs() < EPSILON {
a.y > self.top + EPSILON
&& a.y < self.bottom - EPSILON
&& a.x.min(b.x) < self.right - EPSILON
&& a.x.max(b.x) > self.left + EPSILON
} else {
true
}
}
fn overlaps(self, bounds: RoutingBounds) -> bool {
self.left < bounds.position.x + bounds.size.width
&& self.right > bounds.position.x
&& self.top < bounds.position.y + bounds.size.height
&& self.bottom > bounds.position.y
}
}
fn clear(a: Position, b: Position, obstacles: &[Rect]) -> bool {
!obstacles.iter().any(|r| r.crosses(a, b))
}
fn clear_path(points: &[Position], obstacles: &[Rect]) -> bool {
points.windows(2).all(|p| clear(p[0], p[1], obstacles))
}
#[derive(Clone, Copy)]
struct Endpoint {
point: Position,
stub: Position,
}
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
.find(|p| p.element == id && p.owner == node.id)
}) {
let normal = port.side.normal();
return vec![Endpoint {
point: port.position,
stub: pos(
port.position.x + normal.x * PORT_STUB,
port.position.y + normal.y * PORT_STUB,
),
}];
}
let r = Rect::node(node);
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
vec![
Endpoint {
point: pos(r.left, mid.y),
stub: pos(r.left - PORT_STUB, mid.y),
},
Endpoint {
point: pos(r.right, mid.y),
stub: pos(r.right + PORT_STUB, mid.y),
},
Endpoint {
point: pos(mid.x, r.top),
stub: pos(mid.x, r.top - PORT_STUB),
},
Endpoint {
point: pos(mid.x, r.bottom),
stub: pos(mid.x, r.bottom + PORT_STUB),
},
]
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
let mut current = nodes.get(&id).and_then(|n| n.visual_parent);
let mut seen = BTreeSet::new();
while let Some(id) = current {
if !seen.insert(id) {
break;
}
out.insert(id);
current = nodes.get(&id).and_then(|n| n.visual_parent);
}
}
/// Route stable edge IDs in order so crossing costs never depend on input order.
/// Search caps are presentation limits, reported on the affected edge, never
/// semantic errors or a reason to discard an authored point.
pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView) -> RoutedView {
route_diagram_impl(project, diagram, view, None)
}
/// Transient route/label editing with unchanged projected nodes and ports.
/// Retains other edges from a settled baseline and scores the selected path
/// against preceding stable IDs. Labels avoid all currently displayed paths.
/// Recompute the complete view after commit: later paths and automatic labels
/// may settle again. Also recompute whenever node/port geometry changes.
pub fn route_edge_preview(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
baseline: &RoutedView,
edge: Id,
) -> RoutedView {
route_diagram_impl(project, diagram, view, Some((baseline, edge)))
}
fn route_diagram_impl(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
preview: Option<(&RoutedView, Id)>,
) -> RoutedView {
let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect();
let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect();
let authored: BTreeMap<_, _> = diagram.routes.iter().map(|r| (r.edge, r)).collect();
let headers: BTreeMap<_, _> = nodes
.values()
.map(|node| {
(
node.id,
if diagram.projection.state_machine().is_some() && !node.collapsed {
projection::state_header_height(project, node.element)
} else {
HEADER
},
)
})
.collect();
let mut ordered: Vec<_> = view.edges.iter().collect();
ordered.sort_by_key(|e| e.id);
let mut edges = BTreeMap::new();
let mut previous = Vec::new();
let mut budget = VIEW_SEARCH_BUDGET;
let mut bounds = BoundsBuilder::default();
for node in &view.nodes {
bounds.rect(node.position, node.size);
}
for port in &view.ports {
bounds.rect(
pos(port.position.x - 6.0, port.position.y - 6.0),
Size {
width: 12.0,
height: 12.0,
},
);
}
for edge in &ordered {
if let Some(cached) = preview.and_then(|(baseline, changed)| {
(edge.id != changed)
.then(|| baseline.edges.get(&edge.id))
.flatten()
}) {
for &point in &cached.points {
bounds.point(point);
}
record_segments(&mut previous, &cached.points);
edges.insert(edge.id, cached.clone());
continue;
}
let (Some(source), Some(target)) = (nodes.get(&edge.source), nodes.get(&edge.target))
else {
continue;
};
let stored = authored.get(&edge.id).copied();
let promoted = projection::edge_endpoint_placements(project, diagram, edge.id)
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
let cached = preview.and_then(|(baseline, changed)| {
(edge.id == changed)
.then(|| baseline.edges.get(&edge.id))
.flatten()
});
if let Some(cached) = cached {
budget = cached.search_budget_before;
if cached.pins == pins && cached.custom_suspended == custom_suspended {
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
for &point in &cached.points {
bounds.point(point);
}
record_segments(&mut previous, &cached.points);
edges.insert(edge.id, cached.clone());
continue;
}
}
let search_budget_before = budget;
let mut warnings = Vec::new();
if custom_suspended {
warnings.push(RouteWarning::PromotedEndpoints);
}
for (pin, &point) in pins.iter().enumerate() {
if let Some(obstacle) = obstacles.iter().find(|r| r.contains(point)) {
warnings.push(RouteWarning::PinInsideObstacle {
pin,
obstacle: obstacle.id,
});
}
}
let (points, pin_lengths, failure) = route_edge(
edge,
source,
target,
view,
&pins,
&obstacles,
&previous,
&mut budget,
);
if let Some(warning) = failure {
warnings.push(warning);
}
let total = total_length(&points);
let pin_fractions = pin_lengths
.into_iter()
.map(|v| if total > 0.0 { v / total } else { 0.0 })
.collect();
for &point in &points {
bounds.point(point);
}
record_segments(&mut previous, &points);
edges.insert(
edge.id,
RoutedEdge {
id: edge.id,
points,
label: empty_label(),
pins,
pin_fractions,
warnings,
custom_suspended,
search_budget_before,
},
);
}
// Settle all paths before placing text: a later connection can cross an
// earlier label even when the labels themselves do not overlap. This pass
// never changes paths or authored metadata. Previews retain other cached
// labels and avoid the paths actually shown during the gesture.
let mut previous_labels = Vec::new();
for routed in edges.values() {
if let Some(relation) = project.relations.get(&routed.id) {
for end in association_end_geometry(relation, routed) {
bounds.rect(end.label.bounds.position, end.label.bounds.size);
previous_labels.push(end.label.bounds);
}
}
}
for edge in ordered {
let Some(routed) = edges.get_mut(&edge.id) else {
continue;
};
if preview.is_none_or(|(baseline, changed)| {
edge.id == changed || !baseline.edges.contains_key(&edge.id)
}) {
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
let authored_label = (!routed.custom_suspended)
.then(|| {
authored
.get(&edge.id)
.and_then(|route| route.label.as_ref())
})
.flatten();
routed.label = make_label(
project,
diagram.view_options.exchange_labels,
edge.id,
&routed.points,
authored_label,
&obstacles,
&previous_labels,
&previous,
);
}
if !routed.label.text.is_empty() {
bounds.rect(routed.label.bounds.position, routed.label.bounds.size);
previous_labels.push(routed.label.bounds);
}
}
RoutedView {
edges,
bounds: bounds.finish(),
}
}
fn edge_obstacles(
edge: &ProjectedEdge,
nodes: &BTreeMap<Id, &ProjectedNode>,
rectangles: &[Rect],
headers: &BTreeMap<Id, f32>,
) -> Vec<Rect> {
let mut traversable = BTreeSet::new();
ancestors(edge.source, nodes, &mut traversable);
ancestors(edge.target, nodes, &mut traversable);
rectangles
.iter()
.map(|r| {
let mut r = *r;
if traversable.contains(&r.id) {
r.bottom = r.bottom.min(r.top + headers[&r.id]);
}
r.inflate(CLEARANCE)
})
.collect()
}
#[derive(Clone, Copy)]
struct Segment {
a: Position,
b: Position,
count: u32,
}
// Coincident authored routes can otherwise create thousands of identical
// crossing tests. Keep their exact multiplicity without rescanning duplicates.
fn record_segments(occupied: &mut Vec<Segment>, points: &[Position]) {
for pair in points.windows(2) {
let (a, b) = (pair[0], pair[1]);
if a == b {
continue;
}
if let Some(existing) = occupied
.iter_mut()
.find(|s| (s.a == a && s.b == b) || (s.a == b && s.b == a))
{
existing.count += 1;
} else {
occupied.push(Segment { a, b, count: 1 });
}
}
}
type EdgePath = (Vec<Position>, Vec<f32>, Option<RouteWarning>);
struct RoutingSpace<'a> {
obstacles: &'a [Rect],
xs: Vec<f32>,
ys: Vec<f32>,
}
impl<'a> RoutingSpace<'a> {
fn new(obstacles: &'a [Rect]) -> Self {
Self {
obstacles,
xs: sorted_coordinates(obstacles.iter().flat_map(|r| [r.left, r.right]).collect()),
ys: sorted_coordinates(obstacles.iter().flat_map(|r| [r.top, r.bottom]).collect()),
}
}
}
#[allow(clippy::too_many_arguments)]
fn route_edge(
edge: &ProjectedEdge,
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
pins: &[Position],
obstacles: &[Rect],
previous: &[Segment],
budget: &mut usize,
) -> EdgePath {
let space = RoutingSpace::new(obstacles);
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
{
continue;
}
let estimate = manhattan(a.stub, pins.first().copied().unwrap_or(b.stub))
+ pins.windows(2).map(|p| manhattan(p[0], p[1])).sum::<f32>()
+ pins.last().map_or(0.0, |&p| manhattan(p, b.stub));
candidates.push((estimate, a, b));
}
}
candidates.sort_by(|a, b| {
a.0.total_cmp(&b.0)
.then_with(|| endpoint_order(a.1, b.1))
.then_with(|| endpoint_order(a.2, b.2))
});
let mut best: Option<(f32, Vec<Position>, Vec<f32>)> = None;
let mut exhausted = false;
// Try all side combinations cheaply before spending the bounded grid budget.
for search in [false, true] {
for &(estimate, a, b) in &candidates {
if best
.as_ref()
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
{
continue;
}
if obstacles
.iter()
.any(|r| r.id != edge.source && r.crosses(a.point, a.stub))
|| obstacles
.iter()
.any(|r| r.id != edge.target && r.crosses(b.point, b.stub))
{
continue;
}
let mut points = vec![a.point, a.stub];
let mut occupied = previous.to_vec();
record_segments(&mut occupied, &points);
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut start = a.stub;
let mut failed = false;
for (index, end) in pins
.iter()
.copied()
.chain(std::iter::once(b.stub))
.enumerate()
{
let incoming = points.iter().rev().find(|&&p| p != start).copied();
let path = fast_path(start, end, &space, &occupied, incoming).or_else(|| {
if !search {
return None;
}
let (path, hit_limit) =
grid_path(start, end, &space, &occupied, incoming, budget);
exhausted |= hit_limit;
path
});
let Some(path) = path else {
failed = true;
break;
};
distance += total_length(&path);
record_segments(&mut occupied, &path);
points.extend(path.into_iter().skip(1));
if index < pins.len() {
pin_lengths.push(distance);
}
start = end;
}
if failed {
continue;
}
points.push(b.point);
let cost = path_cost(&points, previous);
if best.as_ref().is_none_or(|(old, _, _)| cost < *old) {
best = Some((cost, points, pin_lengths));
}
}
if best.is_some() {
break;
}
}
if let Some((_, points, pin_lengths)) = best {
return (points, pin_lengths, None);
}
// Recoverable fallback retains every hard pin, in order, and the actual port
// leads. Its warning is deliberately visible to consumers.
let (_, a, b) = candidates[0];
let mut points = vec![a.point, a.stub];
let mut pin_lengths = Vec::new();
let mut distance = PORT_STUB;
let mut start = a.stub;
for (index, end) in pins
.iter()
.copied()
.chain(std::iter::once(b.stub))
.enumerate()
{
let horizontal = compact(vec![start, pos(end.x, start.y), end]);
let vertical = compact(vec![start, pos(start.x, end.y), end]);
let count = |p: &[Position]| {
p.windows(2)
.map(|pair| {
obstacles
.iter()
.filter(|r| r.crosses(pair[0], pair[1]))
.count()
})
.sum::<usize>()
};
let path = if count(&horizontal) <= count(&vertical) {
horizontal
} else {
vertical
};
distance += total_length(&path);
points.extend(path.into_iter().skip(1));
if index < pins.len() {
pin_lengths.push(distance);
}
start = end;
}
points.push(b.point);
(
points,
pin_lengths,
Some(if exhausted {
RouteWarning::SearchBudgetExhausted
} else {
RouteWarning::NoClearPath
}),
)
}
fn endpoint_order(a: Endpoint, b: Endpoint) -> Ordering {
a.point
.x
.total_cmp(&b.point.x)
.then_with(|| a.point.y.total_cmp(&b.point.y))
}
/// Remove redundant elbows only within one pin-to-pin span. Reversals and hard
/// pin/stub boundaries must survive; otherwise arc fractions cease to be true.
fn compact(points: Vec<Position>) -> Vec<Position> {
let mut result: Vec<Position> = Vec::new();
for point in points {
if result.last() == Some(&point) {
continue;
}
while result.len() >= 2 {
let a = result[result.len() - 2];
let b = result[result.len() - 1];
if ((a.x == b.x && b.x == point.x) || (a.y == b.y && b.y == point.y))
&& manhattan(a, point) >= manhattan(a, b) + manhattan(b, point) - EPSILON
{
result.pop();
} else {
break;
}
}
result.push(point);
}
result
}
fn base_cost(points: &[Position]) -> f32 {
total_length(points)
+ points
.windows(3)
.map(|p| turn_cost(p[0], p[1], p[2]))
.sum::<f32>()
}
fn turn_cost(before: Position, at: Position, after: Position) -> f32 {
if before == at || at == after {
return 0.0;
}
let dot = (at.x - before.x) * (after.x - at.x) + (at.y - before.y) * (after.y - at.y);
if dot < 0.0 {
2.0 * BEND_COST
} else if dot == 0.0 {
BEND_COST
} else {
0.0
}
}
fn crossing_cost(a: Position, b: Position, previous: &[Segment]) -> f32 {
let horizontal = a.y == b.y;
previous
.iter()
.map(|segment| {
let (c, d) = (segment.a, segment.b);
let cost = if horizontal == (c.y == d.y) {
let (same, lo, hi) = if horizontal {
(
a.y == c.y,
a.x.min(b.x).max(c.x.min(d.x)),
a.x.max(b.x).min(c.x.max(d.x)),
)
} else {
(
a.x == c.x,
a.y.min(b.y).max(c.y.min(d.y)),
a.y.max(b.y).min(c.y.max(d.y)),
)
};
if same {
((hi - lo).max(0.0) * 0.12).min(80.0)
} else {
0.0
}
} else {
let (h1, h2, v1, v2) = if horizontal {
(a, b, c, d)
} else {
(c, d, a, b)
};
if v1.x > h1.x.min(h2.x)
&& v1.x < h1.x.max(h2.x)
&& h1.y > v1.y.min(v2.y)
&& h1.y < v1.y.max(v2.y)
{
24.0
} else {
0.0
}
};
cost * segment.count as f32
})
.sum()
}
fn path_cost(points: &[Position], previous: &[Segment]) -> f32 {
base_cost(points)
+ points
.windows(2)
.map(|p| crossing_cost(p[0], p[1], previous))
.sum::<f32>()
}
fn sorted_coordinates(mut values: Vec<f32>) -> Vec<f32> {
values.sort_by(f32::total_cmp);
values.dedup();
values
}
fn fast_path(
start: Position,
end: Position,
space: &RoutingSpace<'_>,
previous: &[Segment],
incoming: Option<Position>,
) -> Option<Vec<Position>> {
let obstacles = space.obstacles;
if obstacles
.iter()
.any(|r| r.contains(start) || r.contains(end))
{
return None;
}
if start == end {
return Some(vec![start]);
}
let mut candidates = vec![
compact(vec![start, pos(end.x, start.y), end]),
compact(vec![start, pos(start.x, end.y), end]),
];
let join = |path: &[Position]| {
incoming
.zip(path.get(1))
.map_or(0.0, |(before, &after)| turn_cost(before, start, after))
};
let estimate = |path: &[Position]| base_cost(path) + join(path);
candidates.sort_by(|a, b| estimate(a).total_cmp(&estimate(b)));
let mut best: Option<(f32, Vec<Position>)> = None;
for path in &candidates {
if !clear_path(path, obstacles) {
continue;
}
let cost = path_cost(path, previous) + join(path);
if best.as_ref().is_none_or(|(old, _)| cost < *old) {
best = Some((cost, path.clone()));
}
}
// No two-bend path can beat this admissible lower bound. Most clear spans
// stop here, avoiding obstacle-coordinate candidates and their allocations.
if best
.as_ref()
.is_some_and(|(cost, _)| *cost <= manhattan(start, end) + 2.0 * BEND_COST)
{
return best.map(|(_, path)| path);
}
let xs = std::iter::once((start.x + end.x) / 2.0).chain(space.xs.iter().copied());
let ys = std::iter::once((start.y + end.y) / 2.0).chain(space.ys.iter().copied());
candidates.extend(xs.map(|x| compact(vec![start, pos(x, start.y), pos(x, end.y), end])));
candidates.extend(ys.map(|y| compact(vec![start, pos(start.x, y), pos(end.x, y), end])));
candidates.sort_by(|a, b| estimate(a).total_cmp(&estimate(b)));
for path in candidates {
if best
.as_ref()
.is_some_and(|(cost, _)| estimate(&path) > *cost)
{
break;
}
if !clear_path(&path, obstacles) {
continue;
}
let cost = path_cost(&path, previous) + join(&path);
if best.as_ref().is_none_or(|(old, _)| cost < *old) {
best = Some((cost, path));
}
}
best.map(|(_, path)| path)
}
#[derive(Clone, Copy)]
struct QueueItem {
estimate: f32,
cost: f32,
state: usize,
}
impl PartialEq for QueueItem {
fn eq(&self, other: &Self) -> bool {
self.estimate == other.estimate && self.cost == other.cost && self.state == other.state
}
}
impl Eq for QueueItem {}
impl PartialOrd for QueueItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for QueueItem {
fn cmp(&self, other: &Self) -> Ordering {
other
.estimate
.total_cmp(&self.estimate)
.then_with(|| other.cost.total_cmp(&self.cost))
.then_with(|| other.state.cmp(&self.state))
}
}
fn grid_path(
start: Position,
end: Position,
space: &RoutingSpace<'_>,
previous: &[Segment],
incoming: Option<Position>,
budget: &mut usize,
) -> (Option<Vec<Position>>, bool) {
let obstacles = space.obstacles;
if obstacles
.iter()
.any(|r| r.contains(start) || r.contains(end))
{
return (None, false);
}
let xs = sorted_coordinates(
[start.x, end.x]
.into_iter()
.chain(space.xs.iter().copied())
.collect(),
);
let ys = sorted_coordinates(
[start.y, end.y]
.into_iter()
.chain(space.ys.iter().copied())
.collect(),
);
let cells = xs.len() * ys.len();
if cells > MAX_GRID_CELLS || *budget == 0 {
return (None, true);
}
let index = |p: Position| {
ys.binary_search_by(|v| v.total_cmp(&p.y)).unwrap() * xs.len()
+ xs.binary_search_by(|v| v.total_cmp(&p.x)).unwrap()
};
let position = |cell: usize| pos(xs[cell % xs.len()], ys[cell / xs.len()]);
let first = index(start);
let last = index(end);
let mut costs = vec![f32::INFINITY; cells * 2];
let mut parents = vec![usize::MAX; cells * 2];
let mut queue = BinaryHeap::new();
for direction in 0..2 {
costs[first * 2 + direction] = 0.0;
queue.push(QueueItem {
estimate: manhattan(start, end),
cost: 0.0,
state: first * 2 + direction,
});
}
let mut steps = 0;
while let Some(current) = queue.pop() {
if current.cost > costs[current.state] {
continue;
}
if steps == MAX_SEARCH_STEPS || *budget == 0 {
return (None, true);
}
steps += 1;
*budget -= 1;
let cell = current.state / 2;
if cell == last {
let mut path = Vec::new();
let mut state = current.state;
loop {
path.push(position(state / 2));
if parents[state] == usize::MAX {
break;
}
state = parents[state];
}
path.reverse();
return (Some(compact(path)), false);
}
let x = cell % xs.len();
let y = cell / xs.len();
let neighbours = [
x.checked_sub(1).map(|_| (cell - 1, 0)),
(x + 1 < xs.len()).then_some((cell + 1, 0)),
y.checked_sub(1).map(|_| (cell - xs.len(), 1)),
(y + 1 < ys.len()).then_some((cell + xs.len(), 1)),
];
let a = position(cell);
for (next, direction) in neighbours.into_iter().flatten() {
let b = position(next);
if !clear(a, b, obstacles) {
continue;
}
let state = next * 2 + direction;
let before = if parents[current.state] == usize::MAX {
incoming
} else {
Some(position(parents[current.state] / 2))
};
let cost = current.cost
+ manhattan(a, b)
+ before.map_or(0.0, |before| turn_cost(before, a, b))
+ crossing_cost(a, b, previous);
if cost >= costs[state] {
continue;
}
costs[state] = cost;
parents[state] = current.state;
queue.push(QueueItem {
estimate: cost + manhattan(b, end),
cost,
state,
});
}
}
(None, false)
}
/// Full semantic label shared by state transition routes and internal rows.
/// Expressions remain descriptive; no state-machine execution is implied.
pub fn state_transition_summary(p: &Project, r: &crate::Relation) -> String {
let crate::RelationBody::StateTransition {
transition_kind,
triggers,
trigger,
guard,
effects,
effect,
..
} = &r.body
else {
return r.name.clone();
};
let activation = if !triggers.is_empty() {
triggers
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" OR ")
} else if !trigger.trim().is_empty() {
format!("Unresolved trigger: {trigger}")
} else if p
.elements
.get(&r.source())
.is_some_and(|e| e.kind().is_stable_state())
{
"Completion".into()
} else {
"Transient routing".into()
};
let mut result = format!("{transition_kind:?} \u{b7} {activation}");
if !triggers.is_empty() && !trigger.is_empty() {
result.push_str(&format!(" \u{b7} {trigger}"));
}
if !guard.is_empty() {
result.push_str(&format!(" [{guard}]"));
}
if !effects.is_empty() {
result.push_str(&format!(
" / {}",
effects
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
));
}
if !effect.is_empty() {
result.push_str(&format!(" / {effect}"));
}
result
}
fn label_text(project: &Project, edge: Id) -> String {
let Some(relation) = project.relations.get(&edge) else {
return String::new();
};
if relation.kind() == RelationKind::StateTransition {
let text = state_transition_summary(project, relation);
return if text.chars().count() > 48 {
text.chars()
.take(47)
.chain(std::iter::once('\u{2026}'))
.collect()
} else {
text
};
}
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
| RelationKind::DataAssociation => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
RelationKind::CapabilityGeneralization => "specializes",
RelationKind::MissionCapability => "exploits",
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => "participates",
_ => return String::new(),
};
if text.chars().count() > 32 {
text.chars().take(31).chain(std::iter::once('…')).collect()
} else {
text.to_owned()
}
}
fn empty_label() -> EdgeLabelGeometry {
EdgeLabelGeometry {
text: String::new(),
fraction: 0.5,
anchor: pos(0.0, 0.0),
center: pos(0.0, 0.0),
bounds: RoutingBounds {
position: pos(0.0, 0.0),
size: Size {
width: 0.0,
height: 0.0,
},
},
}
}
// Keep semantic label inputs and the independent collision sets explicit.
#[allow(clippy::too_many_arguments)]
fn make_label(
project: &Project,
labels: crate::view_options::ExchangeLabels,
edge: Id,
points: &[Position],
authored: Option<&crate::EdgeLabel>,
obstacles: &[Rect],
previous_labels: &[RoutingBounds],
strokes: &[Segment],
) -> EdgeLabelGeometry {
let text = if labels != crate::view_options::ExchangeLabels::Name
&& project
.relations
.get(&edge)
.is_some_and(|r| crate::view_options::exchange_family(r.kind()).is_some())
{
let label = labels.label(project, edge);
if label.chars().count() > 48 {
label.chars().take(47).chain(std::iter::once('…')).collect()
} else {
label
}
} else {
label_text(project, edge)
};
let size = Size {
width: if text.is_empty() {
0.0
} else {
text.chars().count() as f32 * 7.0 + 16.0
},
height: if text.is_empty() { 0.0 } else { 24.0 },
};
let mut offset = authored.map_or(pos(0.0, -18.0), |l| l.offset);
let geometry = |fraction, offset: Position| {
let anchor = point_along_polyline(points, fraction).unwrap_or(pos(0.0, 0.0));
let center = pos(anchor.x + offset.x, anchor.y + offset.y);
(
anchor,
center,
RoutingBounds {
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
size,
},
)
};
let mut fraction = authored.map_or(0.5, |l| l.fraction);
// Keep authored placement exact. Automatic labels avoid all visible
// strokes, with earlier stable-ID labels taking precedence.
if authored.is_none() && !text.is_empty() {
let total = total_length(points);
let mut traversed = 0.0;
let mut alternatives = vec![0.5];
if total > 0.0 {
for pair in points.windows(2) {
let len = length(pair[0], pair[1]);
alternatives.push((traversed + len / 2.0) / total);
traversed += len;
}
alternatives.extend((1..20).map(|i| i as f32 / 20.0));
}
alternatives.sort_by(|a, b| {
(a - 0.5)
.abs()
.total_cmp(&(b - 0.5).abs())
.then_with(|| a.total_cmp(b))
});
alternatives.dedup();
alternatives.truncate(32);
// Prefer another position on the existing path at the normal offset.
// Only then try a small bounded set of offsets, never move the route.
let offsets = [
offset,
pos(0.0, 18.0),
pos(0.0, -46.0),
pos(0.0, 46.0),
pos(0.0, -74.0),
pos(0.0, 74.0),
pos(size.width / 2.0 + 18.0, 0.0),
pos(-size.width / 2.0 - 18.0, 0.0),
];
let mut best = None;
// Bound work independently per label, including pathological coincident
// edges. A preview sees the same stable prefix and the same budget.
let mut comparisons = 8_192;
'candidate: for candidate_offset in offsets {
for &candidate_fraction in &alternatives {
let candidate = geometry(candidate_fraction, candidate_offset).2;
let Some(score) = label_collisions(
candidate,
obstacles,
previous_labels,
strokes,
best.as_ref().map(|(score, _, _)| *score),
&mut comparisons,
) else {
break 'candidate;
};
if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
best = Some((score, candidate_fraction, candidate_offset));
}
if score == (0, 0, 0) {
break 'candidate;
}
}
}
if let Some((_, chosen_fraction, chosen_offset)) = best {
fraction = chosen_fraction;
offset = chosen_offset;
}
}
let (anchor, center, bounds) = geometry(fraction, offset);
EdgeLabelGeometry {
text,
fraction,
anchor,
center,
bounds,
}
}
fn label_collisions(
bounds: RoutingBounds,
obstacles: &[Rect],
labels: &[RoutingBounds],
strokes: &[Segment],
best: Option<(usize, usize, usize)>,
comparisons: &mut usize,
) -> Option<(usize, usize, usize)> {
let mut nodes = 0;
for obstacle in obstacles {
if *comparisons == 0 {
return None;
}
*comparisons -= 1;
if obstacle.overlaps(bounds) {
nodes += 1;
}
if best.is_some_and(|score| nodes > score.0) {
return Some((nodes, usize::MAX, usize::MAX));
}
}
// Once a candidate is worse than the current best, additional collisions
// cannot change the decision. This bounds dense coincident-label work.
let label_limit = best
.filter(|score| nodes == score.0)
.map_or(usize::MAX, |score| score.1.saturating_add(1));
let mut collisions = 0;
for other in labels {
if *comparisons == 0 {
return None;
}
*comparisons -= 1;
// Four diagram units between text boxes also keeps hit regions legible.
if bounds.position.x < other.position.x + other.size.width + 4.0
&& bounds.position.x + bounds.size.width > other.position.x - 4.0
&& bounds.position.y < other.position.y + other.size.height + 4.0
&& bounds.position.y + bounds.size.height > other.position.y - 4.0
{
collisions += 1;
if collisions >= label_limit {
return Some((nodes, collisions, usize::MAX));
}
}
}
let text = Rect {
id: Id::nil(),
left: bounds.position.x,
top: bounds.position.y,
right: bounds.position.x + bounds.size.width,
bottom: bounds.position.y + bounds.size.height,
}
.inflate(3.0);
let stroke_limit = best
.filter(|score| (nodes, collisions) == (score.0, score.1))
.map_or(usize::MAX, |score| score.2.saturating_add(1));
let mut crossings = 0;
for stroke in strokes {
if *comparisons == 0 {
return None;
}
*comparisons -= 1;
if text.crosses(stroke.a, stroke.b) {
crossings += 1;
if crossings >= stroke_limit {
break;
}
}
}
Some((nodes, collisions, crossings))
}
#[derive(Default)]
struct BoundsBuilder {
extents: Option<(f32, f32, f32, f32)>,
}
impl BoundsBuilder {
fn point(&mut self, p: Position) {
self.extents = Some(match self.extents {
Some((left, top, right, bottom)) => {
(left.min(p.x), top.min(p.y), right.max(p.x), bottom.max(p.y))
}
None => (p.x, p.y, p.x, p.y),
});
}
fn rect(&mut self, position: Position, size: Size) {
self.point(position);
self.point(pos(position.x + size.width, position.y + size.height));
}
fn finish(self) -> RoutingBounds {
let (left, top, right, bottom) = self.extents.unwrap_or((0.0, 0.0, 0.0, 0.0));
// Subtraction and subsequent addition can round in opposite directions
// when an authored label extends far left/up. Bounds must still enclose
// every original point, including the far right and bottom edges.
let span = |start: f32, end: f32| {
let extent = end - start;
if start + extent < end {
extent.next_up()
} else {
extent
}
};
RoutingBounds {
position: pos(left, top),
size: Size {
width: span(left, right),
height: span(top, bottom),
},
}
}
}