Route diagram edges with shared orthogonal geometry and stable pin previews
This commit is contained in:
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user