1500 lines
49 KiB
Rust
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),
|
|
},
|
|
}
|
|
}
|
|
}
|