Route diagram edges with shared orthogonal geometry and stable pin previews

This commit is contained in:
2026-09-05 20:34:06 +03:00
parent a7c6f9678c
commit 68b1efff70
3 changed files with 1822 additions and 0 deletions
+1
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+741
View File
@@ -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<Position>, label: Option<EdgeLabel>) {
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::<usize>()
);
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::<usize>()
);
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:?}"
);
}