Experiment with guarded cached label association cues
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user