Experiment with guarded cached label association cues

This commit is contained in:
2026-10-01 18:46:59 -07:00
parent 6ea7518e7b
commit 3e5e094bb1
6 changed files with 590 additions and 0 deletions
+27
View File
@@ -37,6 +37,8 @@ mod port_hit_tests;
#[path = "diagram/view_options_tests.rs"]
mod view_options_tests;
#[path = "diagram/label_cues.rs"]
mod label_cues;
#[path = "diagram/port_labels.rs"]
mod port_labels;
pub use port_labels::PortLabels;
@@ -91,6 +93,7 @@ struct Scene {
work: Option<Arc<crate::app::allocation_drag::Preview>>,
view: ProjectedView,
routed: Arc<RoutedView>,
label_cues: BTreeMap<Id, label_cues::Cue>,
}
struct CachedScene {
context: Context,
@@ -529,6 +532,11 @@ impl DiagramCanvas<'_> {
work: Some(preview.clone()),
view: preview.placement.view.clone(),
routed: preview.routed.clone(),
label_cues: label_cues::derive(
preview.project(),
&preview.placement.view,
&preview.routed,
),
});
*state.work_cache.borrow_mut() = Some(scene.clone());
return scene;
@@ -582,6 +590,7 @@ impl DiagramCanvas<'_> {
);
let scene = Arc::new(Scene {
work: None,
label_cues: label_cues::derive(self.project, &view, &routed),
view,
routed,
});
@@ -1350,6 +1359,20 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
}
if !routed.label.text.is_empty() {
let label = &routed.label;
if let Some(cue) = scene.label_cues.get(&edge.id) {
f.stroke(
&Path::line(point(cue.boundary), point(cue.anchor)),
Stroke {
line_dash: canvas::LineDash {
segments: label_cues::DASH,
offset: 0,
},
..Stroke::default()
.with_color(style::MUTED)
.with_width(label_cues::WIDTH)
},
);
}
f.fill_rectangle(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
@@ -1953,6 +1976,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
}
let view = project_view(p, diagram);
let routed = route_diagram(p, d, &view);
let label_cues = label_cues::derive(p, &view, &routed);
let left = routed.bounds.position.x - 30.;
let top = routed.bounds.position.y - 30.;
let right = routed.bounds.position.x + routed.bounds.size.width + 30.;
@@ -2030,6 +2054,9 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
svg.push_str(&format!("<path data-edge=\"{}\" d=\"{path}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed}{arrow}><title>{}</title></path>",edge.id,escape(p.named(edge.id))));
let label = &route.label;
if !label.text.is_empty() {
if let Some(cue) = label_cues.get(&edge.id) {
svg.push_str(&label_cues::svg(edge.id, *cue));
}
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
if let Some(relation) = p.relations.get(&edge.id) {
@@ -423,6 +423,7 @@ fn coincident_attachment_handles_honor_explicit_end_choice() {
let c = canvas(&app, 1.);
let mut scene = Scene {
work: None,
label_cues: BTreeMap::new(),
view: projection::project_view(c.project, c.diagram),
routed: Arc::new(route_diagram(
c.project,
+403
View File
@@ -0,0 +1,403 @@
//! Experimental, presentation-only label association. Never used for picking.
use oa_core::{
Id, Position, Project,
projection::ProjectedView,
routing::{self, RoutedView, RoutingBounds},
};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct Cue {
pub boundary: Position,
pub anchor: Position,
}
const MIN_GAP: f32 = 12.;
const LANDING_CLEARANCE: f32 = 7.;
pub(super) const DASH: &[f32] = &[2., 3.];
pub(super) const WIDTH: f32 = 1.;
pub(super) fn derive(
project: &Project,
view: &ProjectedView,
routed: &RoutedView,
) -> BTreeMap<Id, Cue> {
let association_labels: Vec<_> = routed
.edges
.values()
.flat_map(|edge| {
project
.relations
.get(&edge.id)
.into_iter()
.flat_map(move |relation| {
routing::association_end_geometry(relation, edge)
.into_iter()
.map(|end| end.label.bounds)
})
})
.collect();
routed
.edges
.values()
.filter_map(|edge| {
if edge.label.text.is_empty() {
return None;
}
let bounds = edge.label.bounds;
let anchor = edge.label.anchor;
// Nearest rectangle boundary, not center displacement (which inflates
// apparent separation for long labels).
let boundary = Position {
x: anchor
.x
.clamp(bounds.position.x, bounds.position.x + bounds.size.width),
y: anchor
.y
.clamp(bounds.position.y, bounds.position.y + bounds.size.height),
};
if (anchor.x - boundary.x).hypot(anchor.y - boundary.y) <= MIN_GAP {
return None;
}
let cue = Cue { boundary, anchor };
for other in routed.edges.values().filter(|other| other.id != edge.id) {
if other.points.windows(2).any(|pair| {
point_distance(anchor, pair[0], pair[1]) <= LANDING_CLEARANCE
|| intersects(cue.boundary, cue.anchor, pair[0], pair[1])
}) {
return None;
}
if !other.label.text.is_empty() && crosses_box(cue, other.label.bounds) {
return None;
}
}
if view.nodes.iter().any(|node| {
crosses_box(
cue,
RoutingBounds {
position: node.position,
size: node.size,
},
)
}) || association_labels
.iter()
.any(|bounds| crosses_box(cue, *bounds))
{
return None;
}
Some((edge.id, cue))
})
.collect()
}
fn point_distance(p: Position, a: Position, b: Position) -> f32 {
let dx = b.x - a.x;
let dy = b.y - a.y;
let length = dx * dx + dy * dy;
let t = if length == 0. {
0.
} else {
(((p.x - a.x) * dx + (p.y - a.y) * dy) / length).clamp(0., 1.)
};
(p.x - a.x - t * dx).hypot(p.y - a.y - t * dy)
}
fn cross(a: Position, b: Position, c: Position) -> f32 {
(b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
}
fn intersects(a: Position, b: Position, c: Position, d: Position) -> bool {
// Closed segments: touching an unrelated path is also ambiguous.
if a.x.max(b.x) < c.x.min(d.x)
|| c.x.max(d.x) < a.x.min(b.x)
|| a.y.max(b.y) < c.y.min(d.y)
|| c.y.max(d.y) < a.y.min(b.y)
{
return false;
}
cross(a, b, c) * cross(a, b, d) <= 0. && cross(c, d, a) * cross(c, d, b) <= 0.
}
fn crosses_box(cue: Cue, bounds: RoutingBounds) -> bool {
let x = bounds.position.x;
let y = bounds.position.y;
let right = x + bounds.size.width;
let bottom = y + bounds.size.height;
let inside = |p: Position| p.x >= x && p.x <= right && p.y >= y && p.y <= bottom;
let corners = [
Position { x, y },
Position { x: right, y },
Position {
x: right,
y: bottom,
},
Position { x, y: bottom },
];
inside(cue.boundary)
|| inside(cue.anchor)
|| (0..4).any(|i| intersects(cue.boundary, cue.anchor, corners[i], corners[(i + 1) % 4]))
}
pub(super) fn svg(edge: Id, cue: Cue) -> String {
format!(
"<path data-label-cue=\"{edge}\" d=\"M {} {} L {} {}\" fill=\"none\" stroke=\"#4b5968\" stroke-width=\"1\" stroke-dasharray=\"2 3\" pointer-events=\"none\"/>",
cue.boundary.x, cue.boundary.y, cue.anchor.x, cue.anchor.y
)
}
#[cfg(test)]
mod tests {
use super::*;
use oa_core::{Diagram, Size};
use serde_json::json;
fn representative() -> (Project, Id, Id) {
let mut project = oa_core::persistence::decode(include_bytes!(
"../../../../examples/release1/model.json"
))
.unwrap();
let id: Id = "00000000-0038-4000-8000-000000000001".parse().unwrap();
let mut nodes = serde_json::Map::new();
for (i, (alias, x, y, width, height)) in [
("oa_assessment_procedure", 314, 40, 243, 101),
("sa_technician", 1180, 18, 265, 143),
("sa_assess", 54, 245, 265, 143),
("sa_control", 736, 245, 265, 143),
("sa_repair", 1083, 343, 266, 143),
("sa_reset", 227, 516, 266, 119),
("oa_triage", 1317, 581, 243, 103),
("sa_confirm", 227, 689, 266, 143),
]
.into_iter()
.enumerate()
{
let element = project
.elements
.values()
.find(|e| e.alias == alias)
.unwrap()
.id;
let node = format!("00000000-0038-4000-8000-{:012}", 101 + i);
nodes.insert(
node.clone(),
json!({"id":node,"alias":format!("issue38_{alias}"),"element":element,
"position":{"x":x,"y":y},"size":{"width":width,"height":height},"collapsed":false}),
);
}
let diagram: Diagram =
serde_json::from_value(json!({"id":id,"alias":"issue38_representative_mixed",
"name":"Representative eight-card view","description":"Not recovered user source",
"layer":"Sa","projection":"Traceability","nodes":nodes,"routes":[]}))
.unwrap();
project.diagrams.insert(id, diagram);
assert!(
!oa_core::validation::validate(&project)
.iter()
.any(|issue| issue.severity == oa_core::validation::Severity::Error)
);
(
project,
id,
"63009b5f-539e-5914-b630-ec6b7fefb4d5".parse().unwrap(),
)
}
#[test]
fn representative_cue_and_svg_preserve_complete_project_and_geometry() {
let (project, diagram, edge) = representative();
let before = project.clone();
let view = oa_core::projection::project_view(&project, diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
let geometry = routed.clone();
assert_eq!(view.edges.len(), 12);
let cues = derive(&project, &view, &routed);
assert_eq!(
cues[&edge],
Cue {
boundary: Position { x: 1180., y: 274.5 },
anchor: Position { x: 1162., y: 274.5 }
}
);
assert_eq!(project, before);
assert_eq!(routed, geometry);
let output = super::super::export_svg(&project, diagram).unwrap();
let cue = output
.split('<')
.find(|part| part.contains(&format!("data-label-cue=\"{edge}\"")))
.unwrap();
assert!(cue.contains("stroke-dasharray=\"2 3\""));
assert!(!cue.contains("marker") && !cue.contains("data-edge="));
assert_eq!(project, before);
assert_eq!(
routing::route_diagram(&project, &project.diagrams[&diagram], &view),
geometry
);
}
#[test]
fn default_gap_and_anchor_inside_label_do_not_add_cues() {
let (project, diagram, edge) = representative();
let view = oa_core::projection::project_view(&project, diagram);
let mut routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
// Geometry controls: center 18 above anchor with a 24-high label leaves
// only a six-unit boundary gap. Large text width must not trigger a cue.
let label = &mut routed.edges.get_mut(&edge).unwrap().label;
label.bounds = RoutingBounds {
position: Position {
x: label.anchor.x - 116.5,
y: label.anchor.y - 30.,
},
size: Size {
width: 233.,
height: 24.,
},
};
assert!(!derive(&project, &view, &routed).contains_key(&edge));
// Same clear horizontal corridor as the representative cue: only the
// boundary gap changes, so these controls isolate the strict threshold.
for (gap, admitted) in [(12., false), (12.25, true)] {
let label = &mut routed.edges.get_mut(&edge).unwrap().label;
label.bounds = RoutingBounds {
position: Position {
x: label.anchor.x + gap,
y: label.anchor.y - 12.,
},
size: Size {
width: 233.,
height: 24.,
},
};
assert_eq!(
derive(&project, &view, &routed).contains_key(&edge),
admitted
);
}
let mut authored = project.clone();
authored
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(oa_core::EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(oa_core::EdgeLabel {
fraction: 0.5,
offset: Position { x: 0., y: 0. },
}),
});
let authored_before = authored.clone();
let authored_routed =
routing::route_diagram(&authored, &authored.diagrams[&diagram], &view);
assert!(!derive(&authored, &view, &authored_routed).contains_key(&edge));
assert_eq!(authored, authored_before);
}
#[test]
fn shared_or_near_landing_and_route_card_label_obstructions_suppress_cue() {
let (project, diagram, edge) = representative();
let view = oa_core::projection::project_view(&project, diagram);
let routed = routing::route_diagram(&project, &project.diagrams[&diagram], &view);
let other = *routed.edges.keys().find(|id| **id != edge).unwrap();
for x in [1162., 1168., 1171.] {
let mut changed = routed.clone();
changed.edges.get_mut(&other).unwrap().points =
vec![Position { x, y: 260. }, Position { x, y: 290. }];
// x1162 shares the landing, x1168 is too near, x1171 crosses the cue.
assert!(!derive(&project, &view, &changed).contains_key(&edge));
}
// These segments lie LEFT of the cue and cannot intersect it. They
// independently exercise landing clearance, including its boundary.
for (x, admitted) in [(1156., false), (1155., false), (1154., true)] {
let mut changed = routed.clone();
changed.edges.get_mut(&other).unwrap().points =
vec![Position { x, y: 260. }, Position { x, y: 290. }];
assert_eq!(
derive(&project, &view, &changed).contains_key(&edge),
admitted
);
}
let mut cards = view.clone();
cards.nodes[0].position = Position { x: 1169., y: 270. };
cards.nodes[0].size = Size {
width: 4.,
height: 10.,
};
assert!(!derive(&project, &cards, &routed).contains_key(&edge));
let mut labels = routed.clone();
let label = &mut labels.edges.get_mut(&other).unwrap().label;
label.text = "Obstruction".into();
label.bounds = RoutingBounds {
position: Position { x: 1169., y: 270. },
size: Size {
width: 4.,
height: 10.,
},
};
assert!(!derive(&project, &view, &labels).contains_key(&edge));
}
#[test]
fn selection_hover_and_camera_reuse_cached_scene_cues() {
let (project, diagram, edge) = representative();
let mut canvas = super::super::DiagramCanvas {
selection: None,
allocation: None,
project: &project,
view_index: None,
diagram,
selected: Id::nil(),
navigation_focus: None,
zoom: 1.,
pan: iced::Vector::ZERO,
context: crate::route_edit::Context {
document: project.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
};
let mut state = super::super::State::default();
let before = canvas.scene(&state);
assert!(before.label_cues.contains_key(&edge));
canvas.selected = edge;
canvas.zoom = 0.52;
canvas.pan = iced::Vector::new(25., 30.);
state.pointer = Some(iced::Point::new(60., 70.));
let after = canvas.scene(&state);
assert!(std::sync::Arc::ptr_eq(&before, &after));
assert_eq!(before.label_cues, after.label_cues);
let mut changed = project.clone();
changed
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(oa_core::EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(oa_core::EdgeLabel {
fraction: 0.5,
offset: Position { x: 0., y: 0. },
}),
});
canvas.project = &changed;
canvas.context.revision += 1;
let rebuilt = canvas.scene(&state);
assert!(!std::sync::Arc::ptr_eq(&after, &rebuilt));
assert!(!rebuilt.label_cues.contains_key(&edge));
assert_eq!(
rebuilt.routed.edges[&edge].label.center,
rebuilt.routed.edges[&edge].label.anchor
);
}
}