Add direct diagram connections, movable ports, and contextual authoring

This commit is contained in:
2026-09-06 02:13:49 +03:00
parent 35133de650
commit fef2e0dbce
39 changed files with 7076 additions and 499 deletions
+115 -58
View File
@@ -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)]