Avoid automatic diagram label collisions with stable bounded placement
This commit is contained in:
+102
-15
@@ -345,6 +345,7 @@ fn route_diagram_impl(
|
|||||||
ordered.sort_by_key(|e| e.id);
|
ordered.sort_by_key(|e| e.id);
|
||||||
let mut edges = BTreeMap::new();
|
let mut edges = BTreeMap::new();
|
||||||
let mut previous = Vec::new();
|
let mut previous = Vec::new();
|
||||||
|
let mut previous_labels = Vec::new();
|
||||||
let mut budget = VIEW_SEARCH_BUDGET;
|
let mut budget = VIEW_SEARCH_BUDGET;
|
||||||
let mut bounds = BoundsBuilder::default();
|
let mut bounds = BoundsBuilder::default();
|
||||||
for node in &view.nodes {
|
for node in &view.nodes {
|
||||||
@@ -370,6 +371,7 @@ fn route_diagram_impl(
|
|||||||
}
|
}
|
||||||
if !cached.label.text.is_empty() {
|
if !cached.label.text.is_empty() {
|
||||||
bounds.rect(cached.label.bounds.position, cached.label.bounds.size);
|
bounds.rect(cached.label.bounds.position, cached.label.bounds.size);
|
||||||
|
previous_labels.push(cached.label.bounds);
|
||||||
}
|
}
|
||||||
record_segments(&mut previous, &cached.points);
|
record_segments(&mut previous, &cached.points);
|
||||||
edges.insert(edge.id, cached.clone());
|
edges.insert(edge.id, cached.clone());
|
||||||
@@ -415,12 +417,14 @@ fn route_diagram_impl(
|
|||||||
&updated.points,
|
&updated.points,
|
||||||
route.and_then(|r| r.label.as_ref()),
|
route.and_then(|r| r.label.as_ref()),
|
||||||
&obstacles,
|
&obstacles,
|
||||||
|
&previous_labels,
|
||||||
);
|
);
|
||||||
for &point in &updated.points {
|
for &point in &updated.points {
|
||||||
bounds.point(point);
|
bounds.point(point);
|
||||||
}
|
}
|
||||||
if !updated.label.text.is_empty() {
|
if !updated.label.text.is_empty() {
|
||||||
bounds.rect(updated.label.bounds.position, updated.label.bounds.size);
|
bounds.rect(updated.label.bounds.position, updated.label.bounds.size);
|
||||||
|
previous_labels.push(updated.label.bounds);
|
||||||
}
|
}
|
||||||
record_segments(&mut previous, &updated.points);
|
record_segments(&mut previous, &updated.points);
|
||||||
edges.insert(edge.id, updated);
|
edges.insert(edge.id, updated);
|
||||||
@@ -464,12 +468,14 @@ fn route_diagram_impl(
|
|||||||
&points,
|
&points,
|
||||||
route.and_then(|r| r.label.as_ref()),
|
route.and_then(|r| r.label.as_ref()),
|
||||||
&obstacles,
|
&obstacles,
|
||||||
|
&previous_labels,
|
||||||
);
|
);
|
||||||
for &point in &points {
|
for &point in &points {
|
||||||
bounds.point(point);
|
bounds.point(point);
|
||||||
}
|
}
|
||||||
if !label.text.is_empty() {
|
if !label.text.is_empty() {
|
||||||
bounds.rect(label.bounds.position, label.bounds.size);
|
bounds.rect(label.bounds.position, label.bounds.size);
|
||||||
|
previous_labels.push(label.bounds);
|
||||||
}
|
}
|
||||||
record_segments(&mut previous, &points);
|
record_segments(&mut previous, &points);
|
||||||
edges.insert(
|
edges.insert(
|
||||||
@@ -1021,6 +1027,7 @@ fn make_label(
|
|||||||
points: &[Position],
|
points: &[Position],
|
||||||
authored: Option<&crate::EdgeLabel>,
|
authored: Option<&crate::EdgeLabel>,
|
||||||
obstacles: &[Rect],
|
obstacles: &[Rect],
|
||||||
|
previous_labels: &[RoutingBounds],
|
||||||
) -> EdgeLabelGeometry {
|
) -> EdgeLabelGeometry {
|
||||||
let text = label_text(project, edge);
|
let text = label_text(project, edge);
|
||||||
let size = Size {
|
let size = Size {
|
||||||
@@ -1031,8 +1038,8 @@ fn make_label(
|
|||||||
},
|
},
|
||||||
height: if text.is_empty() { 0.0 } else { 24.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 mut offset = authored.map_or(pos(0.0, -18.0), |l| l.offset);
|
||||||
let geometry = |fraction| {
|
let geometry = |fraction, offset: Position| {
|
||||||
let anchor = point_along_polyline(points, fraction).unwrap_or(pos(0.0, 0.0));
|
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);
|
let center = pos(anchor.x + offset.x, anchor.y + offset.y);
|
||||||
(
|
(
|
||||||
@@ -1045,21 +1052,19 @@ fn make_label(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
let mut fraction = authored.map_or(0.5, |l| l.fraction);
|
let mut fraction = authored.map_or(0.5, |l| l.fraction);
|
||||||
// Automatic labels prefer the middle, then clear segment centres. An
|
// Keep authored placement exact. Automatic labels share the stable-ID
|
||||||
// explicitly positioned label is exact, even if its author overlaps a card.
|
// prefix used for route scoring, so previews and settled solves agree.
|
||||||
if authored.is_none()
|
if authored.is_none() && !text.is_empty() {
|
||||||
&& !text.is_empty()
|
|
||||||
&& obstacles.iter().any(|r| r.overlaps(geometry(fraction).2))
|
|
||||||
{
|
|
||||||
let total = total_length(points);
|
let total = total_length(points);
|
||||||
let mut traversed = 0.0;
|
let mut traversed = 0.0;
|
||||||
let mut alternatives = Vec::new();
|
let mut alternatives = vec![0.5];
|
||||||
if total > 0.0 {
|
if total > 0.0 {
|
||||||
for pair in points.windows(2) {
|
for pair in points.windows(2) {
|
||||||
let len = length(pair[0], pair[1]);
|
let len = length(pair[0], pair[1]);
|
||||||
alternatives.push((traversed + len / 2.0) / total);
|
alternatives.push((traversed + len / 2.0) / total);
|
||||||
traversed += len;
|
traversed += len;
|
||||||
}
|
}
|
||||||
|
alternatives.extend((1..20).map(|i| i as f32 / 20.0));
|
||||||
}
|
}
|
||||||
alternatives.sort_by(|a, b| {
|
alternatives.sort_by(|a, b| {
|
||||||
(a - 0.5)
|
(a - 0.5)
|
||||||
@@ -1067,14 +1072,50 @@ fn make_label(
|
|||||||
.total_cmp(&(b - 0.5).abs())
|
.total_cmp(&(b - 0.5).abs())
|
||||||
.then_with(|| a.total_cmp(b))
|
.then_with(|| a.total_cmp(b))
|
||||||
});
|
});
|
||||||
if let Some(clear) = alternatives
|
alternatives.dedup();
|
||||||
.into_iter()
|
alternatives.truncate(32);
|
||||||
.find(|f| !obstacles.iter().any(|r| r.overlaps(geometry(*f).2)))
|
// Prefer another position on the existing path at the normal offset.
|
||||||
{
|
// Only then try a small bounded set of offsets, never move the route.
|
||||||
fraction = clear;
|
let offsets = [
|
||||||
|
offset,
|
||||||
|
pos(0.0, 18.0),
|
||||||
|
pos(0.0, -46.0),
|
||||||
|
pos(0.0, 46.0),
|
||||||
|
pos(0.0, -74.0),
|
||||||
|
pos(0.0, 74.0),
|
||||||
|
pos(size.width / 2.0 + 18.0, 0.0),
|
||||||
|
pos(-size.width / 2.0 - 18.0, 0.0),
|
||||||
|
];
|
||||||
|
let mut best = None;
|
||||||
|
// Bound work independently per label, including pathological coincident
|
||||||
|
// edges. A preview sees the same stable prefix and the same budget.
|
||||||
|
let mut comparisons = 8_192;
|
||||||
|
'candidate: for candidate_offset in offsets {
|
||||||
|
for &candidate_fraction in &alternatives {
|
||||||
|
let candidate = geometry(candidate_fraction, candidate_offset).2;
|
||||||
|
let Some(score) = label_collisions(
|
||||||
|
candidate,
|
||||||
|
obstacles,
|
||||||
|
previous_labels,
|
||||||
|
best.as_ref().map(|(score, _, _)| *score),
|
||||||
|
&mut comparisons,
|
||||||
|
) else {
|
||||||
|
break 'candidate;
|
||||||
|
};
|
||||||
|
if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
|
||||||
|
best = Some((score, candidate_fraction, candidate_offset));
|
||||||
|
}
|
||||||
|
if score == (0, 0) {
|
||||||
|
break 'candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((_, chosen_fraction, chosen_offset)) = best {
|
||||||
|
fraction = chosen_fraction;
|
||||||
|
offset = chosen_offset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (anchor, center, bounds) = geometry(fraction);
|
let (anchor, center, bounds) = geometry(fraction, offset);
|
||||||
EdgeLabelGeometry {
|
EdgeLabelGeometry {
|
||||||
text,
|
text,
|
||||||
fraction,
|
fraction,
|
||||||
@@ -1084,6 +1125,52 @@ fn make_label(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn label_collisions(
|
||||||
|
bounds: RoutingBounds,
|
||||||
|
obstacles: &[Rect],
|
||||||
|
labels: &[RoutingBounds],
|
||||||
|
best: Option<(usize, usize)>,
|
||||||
|
comparisons: &mut usize,
|
||||||
|
) -> Option<(usize, usize)> {
|
||||||
|
let mut nodes = 0;
|
||||||
|
for obstacle in obstacles {
|
||||||
|
if *comparisons == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
*comparisons -= 1;
|
||||||
|
if obstacle.overlaps(bounds) {
|
||||||
|
nodes += 1;
|
||||||
|
}
|
||||||
|
if best.is_some_and(|score| nodes > score.0) {
|
||||||
|
return Some((nodes, usize::MAX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Once a candidate is worse than the current best, additional collisions
|
||||||
|
// cannot change the decision. This bounds dense coincident-label work.
|
||||||
|
let label_limit = best
|
||||||
|
.filter(|score| nodes == score.0)
|
||||||
|
.map_or(usize::MAX, |score| score.1.saturating_add(1));
|
||||||
|
let mut collisions = 0;
|
||||||
|
for other in labels {
|
||||||
|
if *comparisons == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
*comparisons -= 1;
|
||||||
|
// Four diagram units between text boxes also keeps hit regions legible.
|
||||||
|
if bounds.position.x < other.position.x + other.size.width + 4.0
|
||||||
|
&& bounds.position.x + bounds.size.width > other.position.x - 4.0
|
||||||
|
&& bounds.position.y < other.position.y + other.size.height + 4.0
|
||||||
|
&& bounds.position.y + bounds.size.height > other.position.y - 4.0
|
||||||
|
{
|
||||||
|
collisions += 1;
|
||||||
|
if collisions >= label_limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((nodes, collisions))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct BoundsBuilder {
|
struct BoundsBuilder {
|
||||||
extents: Option<(f32, f32, f32, f32)>,
|
extents: Option<(f32, f32, f32, f32)>,
|
||||||
|
|||||||
@@ -282,6 +282,144 @@ fn automatic_label_fraction_survives_its_first_authored_drag_without_a_jump() {
|
|||||||
point(label.center.x + 15., label.center.y - 10.)
|
point(label.center.x + 15., label.center.y - 10.)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn label_overlap(a: RoutingBounds, b: RoutingBounds) -> bool {
|
||||||
|
a.position.x < b.position.x + b.size.width
|
||||||
|
&& a.position.x + a.size.width > b.position.x
|
||||||
|
&& a.position.y < b.position.y + b.size.height
|
||||||
|
&& a.position.y + a.size.height > b.position.y
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workshop_automatic_labels_are_readable_without_mutating_authored_routes() {
|
||||||
|
let p = language::parse(include_str!("../../../examples/routing-workshop.arcadia")).unwrap();
|
||||||
|
let d = p
|
||||||
|
.diagrams
|
||||||
|
.values()
|
||||||
|
.find(|d| d.alias == "sa_system_boundary_and_functions")
|
||||||
|
.unwrap();
|
||||||
|
let view = project_diagram(&p, d);
|
||||||
|
let routed = route_diagram(&p, d, &view);
|
||||||
|
assert_eq!(routed.edges.len(), 12);
|
||||||
|
let labels: Vec<_> = routed
|
||||||
|
.edges
|
||||||
|
.values()
|
||||||
|
.filter(|e| !e.label.text.is_empty())
|
||||||
|
.collect();
|
||||||
|
let mut overlaps = Vec::new();
|
||||||
|
for (i, a) in labels.iter().enumerate() {
|
||||||
|
for b in labels.iter().skip(i + 1) {
|
||||||
|
if label_overlap(a.label.bounds, b.label.bounds) {
|
||||||
|
overlaps.push(format!(
|
||||||
|
"{} at {:?} / {} at {:?}",
|
||||||
|
a.label.text, a.label.center, b.label.text, b.label.center
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
overlaps.is_empty(),
|
||||||
|
"Overlapping automatic workshop labels: {overlaps:#?}"
|
||||||
|
);
|
||||||
|
for edge in &view.edges {
|
||||||
|
let label = &routed.edges[&edge.id].label;
|
||||||
|
let mut ancestors = std::collections::BTreeSet::new();
|
||||||
|
for endpoint in [edge.source, edge.target] {
|
||||||
|
let mut parent = view
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.id == endpoint)
|
||||||
|
.unwrap()
|
||||||
|
.visual_parent;
|
||||||
|
while let Some(id) = parent {
|
||||||
|
ancestors.insert(id);
|
||||||
|
parent = view
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.id == id)
|
||||||
|
.and_then(|n| n.visual_parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for node in &view.nodes {
|
||||||
|
let size = if ancestors.contains(&node.id) {
|
||||||
|
Size {
|
||||||
|
height: node.size.height.min(64.),
|
||||||
|
..node.size
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node.size
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!label_overlap(
|
||||||
|
label.bounds,
|
||||||
|
RoutingBounds {
|
||||||
|
position: node.position,
|
||||||
|
size
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{} overlaps {}",
|
||||||
|
label.text,
|
||||||
|
p.named(node.element)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offset_count = labels
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.label.center != point(e.label.anchor.x, e.label.anchor.y - 18.))
|
||||||
|
.count();
|
||||||
|
eprintln!("Workshop labels: 12 clear labels; {offset_count} bounded offset placements");
|
||||||
|
|
||||||
|
// Explicit placement stays exact even when deliberately overlapping another
|
||||||
|
// label. Preview updates only the selected label; its path cannot jump.
|
||||||
|
let selected = *routed.edges.keys().last().unwrap();
|
||||||
|
let baseline = &routed.edges[&selected];
|
||||||
|
let destination = labels
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.id != selected)
|
||||||
|
.unwrap()
|
||||||
|
.label
|
||||||
|
.center;
|
||||||
|
let mut edited = p.clone();
|
||||||
|
set_route(
|
||||||
|
&mut edited,
|
||||||
|
d.id,
|
||||||
|
selected,
|
||||||
|
vec![],
|
||||||
|
Some(EdgeLabel {
|
||||||
|
fraction: baseline.label.fraction,
|
||||||
|
offset: point(
|
||||||
|
destination.x - baseline.label.anchor.x,
|
||||||
|
destination.y - baseline.label.anchor.y,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let preview = route_edge_preview(&edited, &edited.diagrams[&d.id], &view, &routed, selected);
|
||||||
|
assert_eq!(preview.edges[&selected].label.center, destination);
|
||||||
|
assert_eq!(preview.edges[&selected].points, baseline.points);
|
||||||
|
assert_eq!(
|
||||||
|
preview.edges[&selected],
|
||||||
|
route_diagram(&edited, &edited.diagrams[&d.id], &view).edges[&selected]
|
||||||
|
);
|
||||||
|
for (&id, edge) in &routed.edges {
|
||||||
|
if id != selected {
|
||||||
|
assert_eq!(preview.edges[&id], *edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resetting a label uses the same earlier-label occupancy as a settled solve.
|
||||||
|
edited.diagrams.get_mut(&d.id).unwrap().routes[0].label = None;
|
||||||
|
let reset = route_edge_preview(&edited, &edited.diagrams[&d.id], &view, &preview, selected);
|
||||||
|
assert_eq!(reset.edges[&selected], *baseline);
|
||||||
|
// An actual bend edit also places the automatic label against that prefix.
|
||||||
|
edited.diagrams.get_mut(&d.id).unwrap().routes[0]
|
||||||
|
.waypoints
|
||||||
|
.push(point(1100., 720.));
|
||||||
|
let pin_preview =
|
||||||
|
route_edge_preview(&edited, &edited.diagrams[&d.id], &view, &routed, selected);
|
||||||
|
assert_eq!(
|
||||||
|
pin_preview.edges[&selected],
|
||||||
|
route_diagram(&edited, &edited.diagrams[&d.id], &view).edges[&selected]
|
||||||
|
);
|
||||||
|
}
|
||||||
fn distance(a: Position, b: Position) -> f32 {
|
fn distance(a: Position, b: Position) -> f32 {
|
||||||
(a.x - b.x).hypot(a.y - b.y)
|
(a.x - b.x).hypot(a.y - b.y)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user