Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
+115
-58
@@ -315,9 +315,10 @@ pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Retains other edges from a settled baseline and scores the selected path
|
||||
/// against preceding stable IDs. Labels avoid all currently displayed paths.
|
||||
/// Recompute the complete view after commit: later paths and automatic labels
|
||||
/// may settle again. Also recompute whenever node/port geometry changes.
|
||||
pub fn route_edge_preview(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
@@ -341,7 +342,6 @@ fn route_diagram_impl(
|
||||
ordered.sort_by_key(|e| e.id);
|
||||
let mut edges = BTreeMap::new();
|
||||
let mut previous = Vec::new();
|
||||
let mut previous_labels = Vec::new();
|
||||
let mut budget = VIEW_SEARCH_BUDGET;
|
||||
let mut bounds = BoundsBuilder::default();
|
||||
for node in &view.nodes {
|
||||
@@ -356,7 +356,7 @@ fn route_diagram_impl(
|
||||
},
|
||||
);
|
||||
}
|
||||
for edge in ordered {
|
||||
for edge in &ordered {
|
||||
if let Some(cached) = preview.and_then(|(baseline, changed)| {
|
||||
(edge.id != changed)
|
||||
.then(|| baseline.edges.get(&edge.id))
|
||||
@@ -365,10 +365,6 @@ fn route_diagram_impl(
|
||||
for &point in &cached.points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !cached.label.text.is_empty() {
|
||||
bounds.rect(cached.label.bounds.position, cached.label.bounds.size);
|
||||
previous_labels.push(cached.label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &cached.points);
|
||||
edges.insert(edge.id, cached.clone());
|
||||
continue;
|
||||
@@ -383,19 +379,7 @@ fn route_diagram_impl(
|
||||
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 obstacles = edge_obstacles(edge, &nodes, &rectangles);
|
||||
let cached = preview.and_then(|(baseline, changed)| {
|
||||
(edge.id == changed)
|
||||
.then(|| baseline.edges.get(&edge.id))
|
||||
@@ -406,24 +390,11 @@ fn route_diagram_impl(
|
||||
if cached.pins == pins && cached.custom_suspended == custom_suspended {
|
||||
// Label-only editing cannot change the connection beneath the
|
||||
// pointer, including a settled fallback with routing warnings.
|
||||
let mut updated = cached.clone();
|
||||
updated.label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&updated.points,
|
||||
route.and_then(|r| r.label.as_ref()),
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
);
|
||||
for &point in &updated.points {
|
||||
for &point in &cached.points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !updated.label.text.is_empty() {
|
||||
bounds.rect(updated.label.bounds.position, updated.label.bounds.size);
|
||||
previous_labels.push(updated.label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &updated.points);
|
||||
edges.insert(edge.id, updated);
|
||||
record_segments(&mut previous, &cached.points);
|
||||
edges.insert(edge.id, cached.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -458,28 +429,16 @@ fn route_diagram_impl(
|
||||
.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,
|
||||
&previous_labels,
|
||||
);
|
||||
for &point in &points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !label.text.is_empty() {
|
||||
bounds.rect(label.bounds.position, label.bounds.size);
|
||||
previous_labels.push(label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &points);
|
||||
edges.insert(
|
||||
edge.id,
|
||||
RoutedEdge {
|
||||
id: edge.id,
|
||||
points,
|
||||
label,
|
||||
label: empty_label(),
|
||||
pins,
|
||||
pin_fractions,
|
||||
warnings,
|
||||
@@ -488,12 +447,67 @@ fn route_diagram_impl(
|
||||
},
|
||||
);
|
||||
}
|
||||
// Settle all paths before placing text: a later connection can cross an
|
||||
// earlier label even when the labels themselves do not overlap. This pass
|
||||
// never changes paths or authored metadata. Previews retain other cached
|
||||
// labels and avoid the paths actually shown during the gesture.
|
||||
let mut previous_labels = Vec::new();
|
||||
for edge in ordered {
|
||||
let Some(routed) = edges.get_mut(&edge.id) else {
|
||||
continue;
|
||||
};
|
||||
if preview.is_none_or(|(baseline, changed)| {
|
||||
edge.id == changed || !baseline.edges.contains_key(&edge.id)
|
||||
}) {
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles);
|
||||
let authored_label = (!routed.custom_suspended)
|
||||
.then(|| {
|
||||
authored
|
||||
.get(&edge.id)
|
||||
.and_then(|route| route.label.as_ref())
|
||||
})
|
||||
.flatten();
|
||||
routed.label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&routed.points,
|
||||
authored_label,
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
&previous,
|
||||
);
|
||||
}
|
||||
if !routed.label.text.is_empty() {
|
||||
bounds.rect(routed.label.bounds.position, routed.label.bounds.size);
|
||||
previous_labels.push(routed.label.bounds);
|
||||
}
|
||||
}
|
||||
RoutedView {
|
||||
edges,
|
||||
bounds: bounds.finish(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_obstacles(
|
||||
edge: &ProjectedEdge,
|
||||
nodes: &BTreeMap<Id, &ProjectedNode>,
|
||||
rectangles: &[Rect],
|
||||
) -> Vec<Rect> {
|
||||
let mut traversable = BTreeSet::new();
|
||||
ancestors(edge.source, nodes, &mut traversable);
|
||||
ancestors(edge.target, nodes, &mut traversable);
|
||||
rectangles
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut r = *r;
|
||||
if traversable.contains(&r.id) {
|
||||
r.bottom = r.bottom.min(r.top + HEADER);
|
||||
}
|
||||
r.inflate(CLEARANCE)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Segment {
|
||||
a: Position,
|
||||
@@ -1017,6 +1031,22 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
text.to_owned()
|
||||
}
|
||||
}
|
||||
fn empty_label() -> EdgeLabelGeometry {
|
||||
EdgeLabelGeometry {
|
||||
text: String::new(),
|
||||
fraction: 0.5,
|
||||
anchor: pos(0.0, 0.0),
|
||||
center: pos(0.0, 0.0),
|
||||
bounds: RoutingBounds {
|
||||
position: pos(0.0, 0.0),
|
||||
size: Size {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_label(
|
||||
project: &Project,
|
||||
edge: Id,
|
||||
@@ -1024,6 +1054,7 @@ fn make_label(
|
||||
authored: Option<&crate::EdgeLabel>,
|
||||
obstacles: &[Rect],
|
||||
previous_labels: &[RoutingBounds],
|
||||
strokes: &[Segment],
|
||||
) -> EdgeLabelGeometry {
|
||||
let text = label_text(project, edge);
|
||||
let size = Size {
|
||||
@@ -1048,8 +1079,8 @@ fn make_label(
|
||||
)
|
||||
};
|
||||
let mut fraction = authored.map_or(0.5, |l| l.fraction);
|
||||
// Keep authored placement exact. Automatic labels share the stable-ID
|
||||
// prefix used for route scoring, so previews and settled solves agree.
|
||||
// Keep authored placement exact. Automatic labels avoid all visible
|
||||
// strokes, with earlier stable-ID labels taking precedence.
|
||||
if authored.is_none() && !text.is_empty() {
|
||||
let total = total_length(points);
|
||||
let mut traversed = 0.0;
|
||||
@@ -1093,6 +1124,7 @@ fn make_label(
|
||||
candidate,
|
||||
obstacles,
|
||||
previous_labels,
|
||||
strokes,
|
||||
best.as_ref().map(|(score, _, _)| *score),
|
||||
&mut comparisons,
|
||||
) else {
|
||||
@@ -1101,7 +1133,7 @@ fn make_label(
|
||||
if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
|
||||
best = Some((score, candidate_fraction, candidate_offset));
|
||||
}
|
||||
if score == (0, 0) {
|
||||
if score == (0, 0, 0) {
|
||||
break 'candidate;
|
||||
}
|
||||
}
|
||||
@@ -1125,9 +1157,10 @@ fn label_collisions(
|
||||
bounds: RoutingBounds,
|
||||
obstacles: &[Rect],
|
||||
labels: &[RoutingBounds],
|
||||
best: Option<(usize, usize)>,
|
||||
strokes: &[Segment],
|
||||
best: Option<(usize, usize, usize)>,
|
||||
comparisons: &mut usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
let mut nodes = 0;
|
||||
for obstacle in obstacles {
|
||||
if *comparisons == 0 {
|
||||
@@ -1138,7 +1171,7 @@ fn label_collisions(
|
||||
nodes += 1;
|
||||
}
|
||||
if best.is_some_and(|score| nodes > score.0) {
|
||||
return Some((nodes, usize::MAX));
|
||||
return Some((nodes, usize::MAX, usize::MAX));
|
||||
}
|
||||
}
|
||||
// Once a candidate is worse than the current best, additional collisions
|
||||
@@ -1160,11 +1193,35 @@ fn label_collisions(
|
||||
{
|
||||
collisions += 1;
|
||||
if collisions >= label_limit {
|
||||
return Some((nodes, collisions, usize::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = Rect {
|
||||
id: Id::nil(),
|
||||
left: bounds.position.x,
|
||||
top: bounds.position.y,
|
||||
right: bounds.position.x + bounds.size.width,
|
||||
bottom: bounds.position.y + bounds.size.height,
|
||||
}
|
||||
.inflate(3.0);
|
||||
let stroke_limit = best
|
||||
.filter(|score| (nodes, collisions) == (score.0, score.1))
|
||||
.map_or(usize::MAX, |score| score.2.saturating_add(1));
|
||||
let mut crossings = 0;
|
||||
for stroke in strokes {
|
||||
if *comparisons == 0 {
|
||||
return None;
|
||||
}
|
||||
*comparisons -= 1;
|
||||
if text.crosses(stroke.a, stroke.b) {
|
||||
crossings += 1;
|
||||
if crossings >= stroke_limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((nodes, collisions))
|
||||
Some((nodes, collisions, crossings))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use oa_core::{projection::project_diagram, routing::*, *};
|
||||
|
||||
fn crosses(bounds: RoutingBounds, a: Position, b: Position) -> bool {
|
||||
let left = bounds.position.x;
|
||||
let top = bounds.position.y;
|
||||
let right = left + bounds.size.width;
|
||||
let bottom = top + bounds.size.height;
|
||||
if a.x == b.x {
|
||||
a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top
|
||||
} else {
|
||||
a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(functional_first: bool) -> (Project, Id, Id, Id) {
|
||||
let mut project = Project::demo();
|
||||
let diagram = project
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
// Freeze the observed default arrangement independently of random demo
|
||||
// identities. The two parallel border connections are only 20 units apart.
|
||||
for node in project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
{
|
||||
let (x, y, width, height) = match project.elements[&node.element].alias.as_str() {
|
||||
"vessel_communication_service" => (60., 60., 272., 220.),
|
||||
"rescue_coordination_system" => (572., 60., 272., 220.),
|
||||
"transmit_distress_report" => (84., 124., 224., 132.),
|
||||
"classify_incident_and_alert_operator" => (596., 124., 224., 132.),
|
||||
alias => panic!("Unexpected demo placement {alias}"),
|
||||
};
|
||||
node.position = Position { x, y };
|
||||
node.size = Size { width, height };
|
||||
}
|
||||
let old_fe = project
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.alias == "validated_distress_report")
|
||||
.unwrap()
|
||||
.id;
|
||||
let old_boundary = project
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.alias == "distress_reporting_connection")
|
||||
.unwrap()
|
||||
.id;
|
||||
let fe = Id::from_u128(if functional_first { 1 } else { 2 });
|
||||
let boundary = Id::from_u128(if functional_first { 2 } else { 1 });
|
||||
let project = execute_batch(
|
||||
&project,
|
||||
&[
|
||||
Command::Reidentify(old_fe, fe),
|
||||
Command::Reidentify(old_boundary, boundary),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
(project, diagram, fe, boundary)
|
||||
}
|
||||
|
||||
fn stroke_collisions(project: &Project, routed: &RoutedView) -> Vec<String> {
|
||||
let mut collisions = Vec::new();
|
||||
for edge in routed.edges.values().filter(|e| !e.label.text.is_empty()) {
|
||||
for other in routed.edges.values().filter(|e| e.id != edge.id) {
|
||||
for segment in other.points.windows(2) {
|
||||
if crosses(edge.label.bounds, segment[0], segment[1]) {
|
||||
collisions.push(format!(
|
||||
"{} ({}) label {:?} crosses {} ({}) segment {:?}",
|
||||
edge.label.text,
|
||||
edge.id,
|
||||
edge.label.bounds,
|
||||
project.named(other.id),
|
||||
other.id,
|
||||
segment
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collisions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_architecture_labels_do_not_cover_earlier_or_later_connection_strokes() {
|
||||
for functional_first in [true, false] {
|
||||
let (project, diagram, _, _) = fixture(functional_first);
|
||||
let before = project.clone();
|
||||
let d = &project.diagrams[&diagram];
|
||||
let view = project_diagram(&project, d);
|
||||
let routed = route_diagram(&project, d, &view);
|
||||
let collisions = stroke_collisions(&project, &routed);
|
||||
assert!(collisions.is_empty(), "{collisions:#?}");
|
||||
assert_eq!(project, before);
|
||||
assert_eq!(routed, route_diagram(&project, d, &view));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
|
||||
let (mut project, diagram, fe, _) = fixture(true);
|
||||
let view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let automatic = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
let authored = EdgeLabel {
|
||||
fraction: 0.5,
|
||||
offset: Position { x: 0., y: -18. },
|
||||
};
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: fe,
|
||||
waypoints: vec![],
|
||||
label: Some(authored.clone()),
|
||||
});
|
||||
let authored_snapshot = project.clone();
|
||||
let explicit = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
let label = &explicit.edges[&fe].label;
|
||||
assert_eq!(label.fraction, authored.fraction);
|
||||
assert_eq!(
|
||||
label.center,
|
||||
Position {
|
||||
x: label.anchor.x + authored.offset.x,
|
||||
y: label.anchor.y + authored.offset.y
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
!stroke_collisions(&project, &explicit).is_empty(),
|
||||
"The authored label deliberately retains the native overlap"
|
||||
);
|
||||
assert_eq!(project, authored_snapshot);
|
||||
for (&id, edge) in &automatic.edges {
|
||||
assert_eq!(edge.points, explicit.edges[&id].points);
|
||||
assert_eq!(edge.pins, explicit.edges[&id].pins);
|
||||
assert_eq!(edge.warnings, explicit.edges[&id].warnings);
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes[0].label = None;
|
||||
let reset = route_edge_preview(&project, &project.diagrams[&diagram], &view, &explicit, fe);
|
||||
assert_eq!(reset.edges[&fe], automatic.edges[&fe]);
|
||||
for (&id, edge) in &explicit.edges {
|
||||
if id != fe {
|
||||
assert_eq!(reset.edges[&id], *edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user