From 68b1efff70ccda5ced79f28b2b330d4743be954f Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 5 Sep 2026 20:34:06 +0300 Subject: [PATCH] Route diagram edges with shared orthogonal geometry and stable pin previews --- crates/oa-core/src/lib.rs | 1 + crates/oa-core/src/routing.rs | 1080 ++++++++++++++++++++++ crates/oa-core/tests/routing_geometry.rs | 741 +++++++++++++++ 3 files changed, 1822 insertions(+) create mode 100644 crates/oa-core/src/routing.rs create mode 100644 crates/oa-core/tests/routing_geometry.rs diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 97a41dcd..8ee2f260 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod persistence; pub mod ports; pub mod projection; pub mod references; +pub mod routing; pub mod session; pub mod source_project; pub mod transitions; diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs new file mode 100644 index 00000000..0c079229 --- /dev/null +++ b/crates/oa-core/src/routing.rs @@ -0,0 +1,1080 @@ +//! 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, PortAnchorSide, ProjectedEdge, ProjectedNode, ProjectedView}; +use crate::{Diagram, Id, Position, Project, 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, + pub bounds: RoutingBounds, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RoutedEdge { + pub id: Id, + pub points: Vec, + pub label: EdgeLabelGeometry, + /// Effective authored points, empty while a folded endpoint suspends routing. + pub pins: Vec, + /// Actual traversal fractions; these preserve order even on a reversing path. + pub pin_fractions: Vec, + pub warnings: Vec, + pub custom_suspended: bool, +} + +#[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, +} + +#[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 { + 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 { + 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, view: &ProjectedView) -> Vec { + if let Some(port) = port.and_then(|id| { + view.ports + .iter() + .find(|p| p.element == id && p.owner == node.id) + }) { + let normal = match port.side { + PortAnchorSide::Left => pos(-1.0, 0.0), + PortAnchorSide::Right => pos(1.0, 0.0), + PortAnchorSide::Bottom => pos(0.0, 1.0), + }; + 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, out: &mut BTreeSet) { + 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 edge +/// against preceding stable IDs, exactly as a complete solve does. Recompute +/// the complete view after commit, or 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 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); + } + if !cached.label.text.is_empty() { + bounds.rect(cached.label.bounds.position, cached.label.bounds.size); + } + 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 mut traversable = BTreeSet::new(); + ancestors(edge.source, &nodes, &mut traversable); + ancestors(edge.target, &nodes, &mut traversable); + let obstacles: Vec<_> = rectangles + .iter() + .map(|r| { + let mut r = *r; + if traversable.contains(&r.id) { + r.bottom = r.bottom.min(r.top + HEADER); + } + r.inflate(CLEARANCE) + }) + .collect(); + 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(); + let label = make_label( + project, + edge.id, + &points, + route.and_then(|r| r.label.as_ref()), + &obstacles, + ); + for &point in &points { + bounds.point(point); + } + if !label.text.is_empty() { + bounds.rect(label.bounds.position, label.bounds.size); + } + record_segments(&mut previous, &points); + edges.insert( + edge.id, + RoutedEdge { + id: edge.id, + points, + label, + pins, + pin_fractions, + warnings, + custom_suspended, + }, + ); + } + RoutedView { + edges, + bounds: bounds.finish(), + } +} + +#[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, 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, Vec, Option); + +struct RoutingSpace<'a> { + obstacles: &'a [Rect], + xs: Vec, + ys: Vec, +} +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) { + let estimate = manhattan(a.stub, pins.first().copied().unwrap_or(b.stub)) + + pins.windows(2).map(|p| manhattan(p[0], p[1])).sum::() + + 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, Vec)> = 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::() + }; + 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) -> Vec { + let mut result: Vec = 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::() +} +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::() +} +fn sorted_coordinates(mut values: Vec) -> Vec { + values.sort_by(f32::total_cmp); + values.dedup(); + values +} + +fn fast_path( + start: Position, + end: Position, + space: &RoutingSpace<'_>, + previous: &[Segment], + incoming: Option, +) -> Option> { + 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)> = 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 { + 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, + budget: &mut usize, +) -> (Option>, 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) +} + +fn label_text(project: &Project, edge: Id) -> String { + let Some(relation) = project.relations.get(&edge) else { + return String::new(); + }; + let text = match relation.kind() { + RelationKind::Interaction + | RelationKind::FunctionalExchange + | RelationKind::BoundaryExchange => 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 make_label( + project: &Project, + edge: Id, + points: &[Position], + authored: Option<&crate::EdgeLabel>, + obstacles: &[Rect], +) -> EdgeLabelGeometry { + let text = 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 offset = authored.map_or(pos(0.0, -18.0), |l| l.offset); + let geometry = |fraction| { + 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); + // Automatic labels prefer the middle, then clear segment centres. An + // explicitly positioned label is exact, even if its author overlaps a card. + if authored.is_none() + && !text.is_empty() + && obstacles.iter().any(|r| r.overlaps(geometry(fraction).2)) + { + let total = total_length(points); + let mut traversed = 0.0; + let mut alternatives = Vec::new(); + 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.sort_by(|a, b| { + (a - 0.5) + .abs() + .total_cmp(&(b - 0.5).abs()) + .then_with(|| a.total_cmp(b)) + }); + if let Some(clear) = alternatives + .into_iter() + .find(|f| !obstacles.iter().any(|r| r.overlaps(geometry(*f).2))) + { + fraction = clear; + } + } + let (anchor, center, bounds) = geometry(fraction); + EdgeLabelGeometry { + text, + fraction, + anchor, + center, + bounds, + } +} + +#[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)); + RoutingBounds { + position: pos(left, top), + size: Size { + width: right - left, + height: bottom - top, + }, + } + } +} diff --git a/crates/oa-core/tests/routing_geometry.rs b/crates/oa-core/tests/routing_geometry.rs new file mode 100644 index 00000000..bcfb443b --- /dev/null +++ b/crates/oa-core/tests/routing_geometry.rs @@ -0,0 +1,741 @@ +use oa_core::{projection::*, routing::*, *}; +use std::time::Instant; + +fn point(x: f32, y: f32) -> Position { + Position { x, y } +} +fn fixture() -> (Project, Id, Id, Id, Id) { + let mut p = Project::empty(); + let a = p + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let b = p + .add_element(Kind::Function, Layer::Sa, "Receive", None) + .unwrap(); + let edge = p.relate(RelationKind::FunctionalExchange, "Status report", a, b); + let d = p.ensure_diagram(Layer::Sa); + p.place(d, a, point(0., 100.)); + p.place(d, b, point(800., 100.)); + (p, d, a, b, edge) +} +fn projected(p: &Project, d: Id) -> ProjectedView { + project_diagram(p, &p.diagrams[&d]) +} +fn route(p: &Project, d: Id) -> RoutedView { + route_diagram(p, &p.diagrams[&d], &projected(p, d)) +} +fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec, label: Option) { + p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute { + edge, + waypoints: pins, + label, + }); +} +fn orthogonal(points: &[Position]) { + assert!(points.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + assert!( + points + .windows(2) + .all(|p| p[0].x == p[1].x || p[0].y == p[1].y), + "{points:?}" + ); +} +fn avoids(points: &[Position], position: Position, size: Size, clearance: f32) { + let (left, right) = (position.x - clearance, position.x + size.width + clearance); + let (top, bottom) = (position.y - clearance, position.y + size.height + clearance); + for segment in points.windows(2) { + let (a, b) = (segment[0], segment[1]); + let crosses = if a.x == b.x { + a.x > left + 0.001 + && a.x < right - 0.001 + && a.y.min(b.y) < bottom - 0.001 + && a.y.max(b.y) > top + 0.001 + } else { + a.y > top + 0.001 + && a.y < bottom - 0.001 + && a.x.min(b.x) < right - 0.001 + && a.x.max(b.x) > left + 0.001 + }; + assert!( + !crosses, + "Segment {a:?} -> {b:?} intersects rectangle {position:?} {size:?}" + ); + } +} + +#[test] +fn functional_endpoints_preserve_real_ports_and_normals_while_detouring() { + let (mut p, d, _, _, edge) = fixture(); + let obstacle = p + .add_element(Kind::Function, Layer::Sa, "Unrelated computation", None) + .unwrap(); + p.place(d, obstacle, point(380., 110.)); + let view = projected(&p, d); + let result = route_diagram(&p, &p.diagrams[&d], &view); + let path = &result.edges[&edge]; + assert!(path.warnings.is_empty(), "{:?}", path.warnings); + orthogonal(&path.points); + let projected_edge = view.edges.iter().find(|e| e.id == edge).unwrap(); + let source = view + .ports + .iter() + .find(|p| Some(p.element) == projected_edge.source_port) + .unwrap(); + let target = view + .ports + .iter() + .find(|p| Some(p.element) == projected_edge.target_port) + .unwrap(); + assert_eq!(path.points[0], source.position); + assert_eq!( + path.points[1], + point(source.position.x + PORT_STUB, source.position.y) + ); + assert_eq!(*path.points.last().unwrap(), target.position); + assert_eq!( + path.points[path.points.len() - 2], + point(target.position.x - PORT_STUB, target.position.y) + ); + let obstacle = view.nodes.iter().find(|n| n.element == obstacle).unwrap(); + avoids(&path.points, obstacle.position, obstacle.size, CLEARANCE); +} + +#[test] +fn bottom_standard_ports_are_approached_from_below() { + let (p, d, _, _, edge) = fixture(); + let mut view = projected(&p, d); + for port in &mut view.ports { + let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap(); + port.side = PortAnchorSide::Bottom; + port.position = point( + owner.position.x + owner.size.width / 2., + owner.position.y + owner.size.height, + ); + } + let result = route_diagram(&p, &p.diagrams[&d], &view); + let points = &result.edges[&edge].points; + assert_eq!(points[1], point(points[0].x, points[0].y + PORT_STUB)); + let n = points.len(); + assert_eq!( + points[n - 2], + point(points[n - 1].x, points[n - 1].y + PORT_STUB) + ); + assert!(result.edges[&edge].warnings.is_empty()); +} + +#[test] +fn nested_endpoint_enclosure_is_traversable_but_its_header_and_siblings_are_not() { + let (mut p, d, a, _, edge) = fixture(); + let container = p + .add_element(Kind::Function, Layer::Sa, "Supervise", None) + .unwrap(); + p.elements.get_mut(&a).unwrap().parent = Some(container); + let parent = p.place(d, container, point(-60., 0.)); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&parent) + .unwrap() + .size = Size { + width: 680., + height: 430., + }; + let sibling = p + .add_element(Kind::Function, Layer::Sa, "Other work", Some(container)) + .unwrap(); + p.place(d, sibling, point(350., 110.)); + let view = projected(&p, d); + let result = route_diagram(&p, &p.diagrams[&d], &view); + let routed = &result.edges[&edge]; + assert!(routed.warnings.is_empty(), "{:?}", routed.warnings); + let parent = view.nodes.iter().find(|n| n.element == container).unwrap(); + avoids( + &routed.points, + parent.position, + Size { + width: parent.size.width, + height: 64., + }, + CLEARANCE, + ); + let sibling = view.nodes.iter().find(|n| n.element == sibling).unwrap(); + avoids(&routed.points, sibling.position, sibling.size, CLEARANCE); + assert!(routed.points.iter().any(|p| p.x > parent.position.x + && p.x < parent.position.x + parent.size.width + && p.y > parent.position.y + 64.)); +} + +#[test] +fn authored_pins_keep_traversal_order_even_when_reversing_and_repeated() { + let (mut p, d, _, _, edge) = fixture(); + let pins = vec![point(650., 400.), point(300., 400.), point(650., 400.)]; + set_route(&mut p, d, edge, pins.clone(), None); + let result = route(&p, d); + let r = &result.edges[&edge]; + assert_eq!(r.pins, pins); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.pin_fractions.windows(2).all(|p| p[0] < p[1])); + for (pin, fraction) in r.pins.iter().zip(&r.pin_fractions) { + assert!(distance(*pin, point_along_polyline(&r.points, *fraction).unwrap()) < 0.01); + } + let first_reverse = r + .points + .windows(2) + .position(|p| p[0] == pins[0] && p[1] == pins[1]) + .unwrap(); + assert_eq!(pin_insertion_index(r, first_reverse, point(500., 400.)), 1); + assert_eq!( + pin_insertion_index(r, first_reverse + 1, point(500., 400.)), + 2 + ); + orthogonal(&r.points); +} + +#[test] +fn single_pin_above_an_oa_interaction_makes_a_detour_without_a_retraced_spur() { + let mut p = Project::empty(); + let a = p + .add_element(Kind::Activity, Layer::Oa, "Observe", None) + .unwrap(); + let b = p + .add_element(Kind::Activity, Layer::Oa, "Decide", None) + .unwrap(); + let d = p.ensure_diagram(Layer::Oa); + p.place(d, a, point(0., 300.)); + p.place(d, b, point(800., 300.)); + let edge = p.relate(RelationKind::Interaction, "Observation", a, b); + let pin = point(500., 100.); + set_route(&mut p, d, edge, vec![pin], None); + let result = route(&p, d); + let r = &result.edges[&edge]; + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.points.contains(&pin)); + for turn in r.points.windows(3) { + let dot = (turn[1].x - turn[0].x) * (turn[2].x - turn[1].x) + + (turn[1].y - turn[0].y) * (turn[2].y - turn[1].y); + assert!( + dot >= 0., + "Adjacent reversal creates an unhelpful pin spur: {:?}", + r.points + ); + } + for (i, a) in r.points.windows(2).enumerate() { + for b in r.points.windows(2).skip(i + 1) { + let overlap = if a[0].y == a[1].y && b[0].y == b[1].y && a[0].y == b[0].y { + a[0].x.max(a[1].x).min(b[0].x.max(b[1].x)) + - a[0].x.min(a[1].x).max(b[0].x.min(b[1].x)) + } else if a[0].x == a[1].x && b[0].x == b[1].x && a[0].x == b[0].x { + a[0].y.max(a[1].y).min(b[0].y.max(b[1].y)) + - a[0].y.min(a[1].y).max(b[0].y.min(b[1].y)) + } else { + 0.0 + }; + assert!(overlap <= 0., "Retraced span: {:?}", r.points); + } + } +} + +#[test] +fn automatic_label_fraction_survives_its_first_authored_drag_without_a_jump() { + let (mut p, d, _, _, edge) = fixture(); + let view = projected(&p, d); + let source_port = view + .edges + .iter() + .find(|e| e.id == edge) + .unwrap() + .source_port; + let y = view + .ports + .iter() + .find(|p| Some(p.element) == source_port) + .unwrap() + .position + .y; + let obstacle = p + .add_element(Kind::Function, Layer::Sa, "Above the line", None) + .unwrap(); + p.place(d, obstacle, point(450., y - 117.)); + set_route(&mut p, d, edge, vec![point(350., y)], None); + let result = route(&p, d); + let label = result.edges[&edge].label.clone(); + assert_ne!( + label.fraction, 0.5, + "Fixture must exercise automatic collision-avoidance relocation" + ); + assert_eq!( + label.anchor, + point_along_polyline(&result.edges[&edge].points, label.fraction).unwrap() + ); + let offset = point( + label.center.x - label.anchor.x + 15., + label.center.y - label.anchor.y - 10., + ); + p.diagrams.get_mut(&d).unwrap().routes[0].label = Some(EdgeLabel { + fraction: label.fraction, + offset, + }); + let moved = route(&p, d).edges[&edge].label.clone(); + assert_eq!( + moved.center, + point(label.center.x + 15., label.center.y - 10.) + ); +} +fn distance(a: Position, b: Position) -> f32 { + (a.x - b.x).hypot(a.y - b.y) +} + +#[test] +fn blocked_pin_is_retained_and_warned_without_nan_or_diagonal_fallback() { + let (mut p, d, _, _, edge) = fixture(); + let pin = point(50., 140.); + set_route(&mut p, d, edge, vec![pin], None); + let result = route(&p, d); + let r = &result.edges[&edge]; + assert_eq!(r.pins, vec![pin]); + assert!(r.points.contains(&pin)); + assert!( + r.warnings + .iter() + .any(|w| matches!(w, RouteWarning::PinInsideObstacle { pin: 0, .. })) + ); + assert!(r.warnings.contains(&RouteWarning::NoClearPath)); + orthogonal(&r.points); +} + +#[test] +fn collapse_suspends_only_promoted_endpoints_and_never_changes_stored_route() { + let (mut p, d, a, _, edge) = fixture(); + let group = p + .add_element(Kind::Function, Layer::Sa, "Group", None) + .unwrap(); + p.elements.get_mut(&a).unwrap().parent = Some(group); + let parent = p.place(d, group, point(-60., 0.)); + set_route( + &mut p, + d, + edge, + vec![point(500., 400.)], + Some(EdgeLabel { + fraction: 0.4, + offset: point(20., 40.), + }), + ); + let stored = p.diagrams[&d].routes.clone(); + assert!(!route(&p, d).edges[&edge].custom_suspended); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&parent) + .unwrap() + .collapsed = true; + let folded = route(&p, d); + assert!(folded.edges[&edge].custom_suspended); + assert!(folded.edges[&edge].pins.is_empty()); + assert!( + folded.edges[&edge] + .warnings + .contains(&RouteWarning::PromotedEndpoints) + ); + assert_eq!(p.diagrams[&d].routes, stored); + p.diagrams.get_mut(&d).unwrap().routes.clear(); + assert!( + route(&p, d).edges[&edge].custom_suspended, + "A promoted endpoint cannot accept its first bend either" + ); + p.diagrams.get_mut(&d).unwrap().routes = stored.clone(); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&parent) + .unwrap() + .collapsed = false; + assert_eq!(route(&p, d).edges[&edge].pins, stored[0].waypoints); + let unrelated = p + .add_element(Kind::Function, Layer::Sa, "Unrelated group", None) + .unwrap(); + let node = p.place(d, unrelated, point(1100., 100.)); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .collapsed = true; + assert!(!route(&p, d).edges[&edge].custom_suspended); +} + +fn maze() -> (Project, Id, Id) { + let (mut p, d, _, _, edge) = fixture(); + let initial = projected(&p, d); + let source_port = initial + .edges + .iter() + .find(|e| e.id == edge) + .unwrap() + .source_port; + let y = initial + .ports + .iter() + .find(|p| Some(p.element) == source_port) + .unwrap() + .position + .y; + for (index, (position, size)) in [ + ( + point(230., y - 143.), + Size { + width: 100., + height: 120., + }, + ), + ( + point(230., y + 27.), + Size { + width: 100., + height: 160., + }, + ), + ( + point(400., y - 73.), + Size { + width: 100., + height: 130., + }, + ), + ] + .into_iter() + .enumerate() + { + let id = p + .add_element(Kind::Function, Layer::Sa, &format!("Wall {index}"), None) + .unwrap(); + let node = p.place(d, id, position); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .size = size; + } + (p, d, edge) +} + +#[test] +fn compressed_grid_search_finds_multi_turn_exit_from_a_narrow_corridor() { + let (p, d, edge) = maze(); + let view = projected(&p, d); + let result = route_diagram(&p, &p.diagrams[&d], &view); + let r = &result.edges[&edge]; + assert!(r.warnings.is_empty(), "{:?}: {:?}", r.warnings, r.points); + orthogonal(&r.points); + assert!( + r.points.len() >= 7, + "The blocked up/down and forward exits require several turns: {:?}", + r.points + ); + for wall in view + .nodes + .iter() + .filter(|n| p.named(n.element).starts_with("Wall")) + { + avoids(&r.points, wall.position, wall.size, CLEARANCE); + } +} + +#[test] +fn excessive_search_grid_falls_back_with_an_explicit_warning_and_preserved_endpoints() { + let (p, d, edge) = maze(); + let mut view = projected(&p, d); + // Distant cards do not alter the local maze, but their distinct coordinates + // deliberately exceed the compressed-grid admission limit. + for i in 0..150 { + view.nodes.push(ProjectedNode { + id: Id::from_u128(i + 1), + element: Id::from_u128(i + 1000), + position: point(10_000. + i as f32 * 333., 10_000. + i as f32 * 377.), + size: Size { + width: 100., + height: 94., + }, + visual_parent: None, + depth: 0, + collapsed: false, + allocation_summary: false, + }); + } + let result = route_diagram(&p, &p.diagrams[&d], &view); + let r = &result.edges[&edge]; + assert!( + r.warnings.contains(&RouteWarning::SearchBudgetExhausted), + "{:?}", + r.warnings + ); + orthogonal(&r.points); + let endpoint = view.edges.iter().find(|e| e.id == edge).unwrap(); + assert_eq!( + r.points[0], + view.ports + .iter() + .find(|p| Some(p.element) == endpoint.source_port) + .unwrap() + .position + ); +} + +#[test] +fn nonport_relations_try_clear_alternate_sides_and_label_only_supported_roles() { + let (mut p, d, a, b, functional) = fixture(); + let include = p.relate(RelationKind::CapabilityInclude, "Hidden custom name", a, b); + let mut view = projected(&p, d); + // The projection is the geometry seam; model validation separately restricts + // the semantic types permitted for each relationship role. + view.edges.retain(|e| e.id == include); + let source = view.nodes.iter().find(|n| n.element == a).unwrap(); + let owner = source.id; + let right_midpoint = point( + source.position.x + source.size.width, + source.position.y + source.size.height / 2., + ); + view.nodes.push(ProjectedNode { + id: Id::from_u128(1), + element: Id::from_u128(2), + position: point(right_midpoint.x + 2., right_midpoint.y - 40.), + size: Size { + width: 90., + height: 94., + }, + visual_parent: None, + depth: 0, + collapsed: false, + allocation_summary: false, + }); + let result = route_diagram(&p, &p.diagrams[&d], &view); + let r = &result.edges[&include]; + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_ne!(r.points[0], right_midpoint); + assert_eq!(r.label.text, "«include»"); + let mut ordinary = view; + ordinary.edges[0].id = Id::from_u128(3); + ordinary.edges[0].source = owner; + let result = route_diagram(&p, &p.diagrams[&d], &ordinary); + assert!(result.edges[&Id::from_u128(3)].label.text.is_empty()); + assert!(!result.edges.contains_key(&functional)); +} + +#[test] +fn label_geometry_and_bounds_are_shared_exact_and_unicode_safe() { + let (mut p, d, _, _, edge) = fixture(); + p.relations.get_mut(&edge).unwrap().name = + "温度と圧力の状態を定期的に送信する長い機能交換名のテストデータ表示です".into(); + let authored = EdgeLabel { + fraction: 0.2, + offset: point(-700., -500.), + }; + set_route( + &mut p, + d, + edge, + vec![point(500., 400.)], + Some(authored.clone()), + ); + let result = route(&p, d); + let r = &result.edges[&edge]; + assert_eq!(r.label.text.chars().count(), 32); + assert!(r.label.text.ends_with('…')); + assert_eq!( + r.label.bounds.size, + Size { + width: 240., + height: 24. + } + ); + assert_eq!( + r.label.anchor, + point_along_polyline(&r.points, authored.fraction).unwrap() + ); + assert_eq!( + r.label.center, + point( + r.label.anchor.x + authored.offset.x, + r.label.anchor.y + authored.offset.y + ) + ); + assert!(result.bounds.position.x <= r.label.bounds.position.x); + assert!(result.bounds.position.y <= r.label.bounds.position.y); + assert!(result.bounds.position.x + result.bounds.size.width >= 1024.); + assert!(result.bounds.position.y + result.bounds.size.height >= 400.); +} + +#[test] +fn deterministic_under_input_and_route_list_permutations() { + let (mut p, d, a, b, edge) = fixture(); + let second = p.relate(RelationKind::FunctionalExchange, "Acknowledgement", b, a); + set_route(&mut p, d, edge, vec![point(450., 300.)], None); + set_route(&mut p, d, second, vec![point(550., 400.)], None); + let view = projected(&p, d); + let original = route_diagram(&p, &p.diagrams[&d], &view); + let mut shuffled = view; + shuffled.edges.reverse(); + shuffled.nodes.reverse(); + shuffled.ports.reverse(); + p.diagrams.get_mut(&d).unwrap().routes.reverse(); + assert_eq!(route_diagram(&p, &p.diagrams[&d], &shuffled), original); +} + +#[test] +fn single_edge_preview_matches_settled_geometry_and_preserves_other_routes() { + let (mut p, d, a, b, first) = fixture(); + let second = p.relate(RelationKind::FunctionalExchange, "Return", b, a); + let view = projected(&p, d); + let baseline = route_diagram(&p, &p.diagrams[&d], &view); + // Choose the later ID to exercise baseline crossing costs; earlier paths + // are unchanged by this edit, exactly as in the complete stable-ID solve. + let changed = first.max(second); + let unchanged = first.min(second); + set_route( + &mut p, + d, + changed, + vec![point(450., 400.)], + Some(EdgeLabel { + fraction: 0.2, + offset: point(-800., -500.), + }), + ); + let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, changed); + let complete = route_diagram(&p, &p.diagrams[&d], &view); + assert_eq!(preview.edges[&changed], complete.edges[&changed]); + assert_eq!(preview.edges[&unchanged], baseline.edges[&unchanged]); + assert_eq!(preview.bounds, complete.bounds); +} + +#[test] +fn interpolation_projection_empty_and_repeated_points_are_well_defined() { + assert_eq!(point_along_polyline(&[], 0.5), None); + assert_eq!(project_onto_polyline(&[], point(1., 2.)), None); + let p = [ + point(0., 0.), + point(0., 0.), + point(100., 0.), + point(100., 100.), + ]; + assert_eq!(point_along_polyline(&p, 0.75), Some(point(100., 50.))); + assert_eq!(point_along_polyline(&p, -1.), Some(point(0., 0.))); + assert_eq!(point_along_polyline(&p, 2.), Some(point(100., 100.))); + let projection = project_onto_polyline(&p, point(110., 50.)).unwrap(); + assert_eq!( + projection, + PolylineProjection { + point: point(100., 50.), + fraction: 0.75, + distance: 10. + } + ); + assert_eq!( + point_along_polyline(&[point(7., 8.); 3], 0.5), + Some(point(7., 8.)) + ); +} + +#[test] +fn routing_100_nodes_150_edges_has_bounded_work_and_reports_actual_time() { + let mut p = Project::empty(); + let d = p.ensure_diagram(Layer::Sa); + let mut elements = Vec::new(); + for i in 0..100 { + let id = p + .add_element(Kind::Function, Layer::Sa, &format!("Function {i}"), None) + .unwrap(); + p.place(d, id, point((i % 10) as f32 * 400., (i / 10) as f32 * 280.)); + elements.push(id); + } + for i in 0..150 { + let source = i % 100; + let target = if i < 90 { + source + 10 + } else { + (source / 10) * 10 + (source + 1) % 10 + }; + p.relate( + RelationKind::FunctionalExchange, + &format!("Flow {i}"), + elements[source], + elements[target], + ); + } + let view = projected(&p, d); + assert_eq!(view.nodes.len(), 100); + assert_eq!(view.edges.len(), 150); + let start = Instant::now(); + let routed = route_diagram(&p, &p.diagrams[&d], &view); + let elapsed = start.elapsed(); + eprintln!( + "100-node/150-edge settled routing: {elapsed:?}; {} warnings", + routed + .edges + .values() + .map(|r| r.warnings.len()) + .sum::() + ); + assert_eq!(routed.edges.len(), 150); + for edge in routed.edges.values() { + orthogonal(&edge.points); + } + // A deliberately loose debug regression guard, not the interactive target. + assert!( + elapsed.as_secs() < 10, + "Unexpected multi-second routing regression: {elapsed:?}" + ); + // A harder admitted model: every edge has eight coincident detour pins. + // Report settled work separately from the interactive single-edge preview. + let edge_ids: Vec<_> = p.relations.keys().copied().collect(); + for &edge in &edge_ids { + set_route( + &mut p, + d, + edge, + (0..8) + .map(|i| point(-100. - (i % 2) as f32 * 100., -100. - i as f32 * 80.)) + .collect(), + None, + ); + } + let started = Instant::now(); + let baseline = route_diagram(&p, &p.diagrams[&d], &view); + let settled = started.elapsed(); + let selected = *edge_ids.last().unwrap(); + let changed = p + .diagrams + .get_mut(&d) + .unwrap() + .routes + .iter_mut() + .find(|r| r.edge == selected) + .unwrap(); + changed.waypoints[7].x -= 35.; + let moved = changed.waypoints[7]; + let started = Instant::now(); + let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, selected); + let preview_time = started.elapsed(); + eprintln!( + "100-node/150-edge/eight-pins settled: {settled:?}; single-edge preview: {preview_time:?}; {} warnings", + baseline + .edges + .values() + .map(|r| r.warnings.len()) + .sum::() + ); + assert!(preview.edges[&selected].points.contains(&moved)); + for &edge in &edge_ids[..edge_ids.len() - 1] { + assert_eq!(preview.edges[&edge], baseline.edges[&edge]); + } + assert!( + preview_time.as_secs() < 1, + "Single route preview must not stall for seconds: {preview_time:?}" + ); +}