From d8ccd6ed49adac799c02a3c52cb9e431214a4963 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 1 Oct 2026 19:21:24 -0700 Subject: [PATCH] Experiment with measured multiline label layout on bounded witness --- crates/oa-desktop/src/app.rs | 8 +- crates/oa-desktop/src/diagram.rs | 115 +- .../src/diagram/attachment_tests.rs | 3 + .../src/diagram/label_layout_experiment.rs | 676 +++ crates/oa-desktop/src/diagram/work_drag.rs | 3 +- crates/oa-desktop/src/diagram_usage.rs | 16 +- .../wrapped-label-20261001/.gitattributes | 1 + .../wrapped-label-20261001/alternatives.md | 70 + .../wrapped-label-20261001/change-summary.md | 81 + .../wrapped-label-20261001/design-review.md | 59 + .../fixture/eight-card.json | 4533 +++++++++++++++++ .../fixture/provenance.json | 7 + .../wrapped-label-20261001/intent.md | 38 + 13 files changed, 5590 insertions(+), 20 deletions(-) create mode 100644 crates/oa-desktop/src/diagram/label_layout_experiment.rs create mode 100644 harness/experiments/wrapped-label-20261001/.gitattributes create mode 100644 harness/experiments/wrapped-label-20261001/alternatives.md create mode 100644 harness/experiments/wrapped-label-20261001/change-summary.md create mode 100644 harness/experiments/wrapped-label-20261001/design-review.md create mode 100644 harness/experiments/wrapped-label-20261001/fixture/eight-card.json create mode 100644 harness/experiments/wrapped-label-20261001/fixture/provenance.json create mode 100644 harness/experiments/wrapped-label-20261001/intent.md diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 7da73652..5b2cdf96 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -5417,11 +5417,17 @@ impl App { self.pan = iced::Vector::new(30., 30.); return; } - let geometry = oa_core::routing::route_diagram( + let mut geometry = oa_core::routing::route_diagram( self.project(), &self.project().diagrams[&self.active_diagram.unwrap()], &view, ); + crate::diagram::apply_label_experiment( + self.project(), + &self.project().diagrams[&self.active_diagram.unwrap()], + &view, + &mut geometry, + ); let left = geometry.bounds.position.x; let top = geometry.bounds.position.y; let right = left + geometry.bounds.size.width; diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 464c085b..fa704632 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -37,6 +37,9 @@ mod port_hit_tests; #[path = "diagram/view_options_tests.rs"] mod view_options_tests; +#[path = "diagram/label_layout_experiment.rs"] +mod label_layout_experiment; + #[path = "diagram/port_labels.rs"] mod port_labels; pub use port_labels::PortLabels; @@ -88,6 +91,9 @@ pub struct State { cache: RefCell>, } struct Scene { + label_layout: Option, + profile: label_layout_experiment::Profile, + font_version: Option, work: Option>, view: ProjectedView, routed: Arc, @@ -514,6 +520,14 @@ impl DiagramCanvas<'_> { } } fn scene(&self, state: &State) -> Arc { + self.scene_with_profile(state, label_layout_experiment::profile()) + } + fn scene_with_profile( + &self, + state: &State, + profile: label_layout_experiment::Profile, + ) -> Arc { + let font_version = label_layout_experiment::font_version(profile); if let Some(preview) = self.work_preview() && state .work @@ -522,13 +536,30 @@ impl DiagramCanvas<'_> { { if let Some(scene) = state.work_cache.borrow().as_ref() && scene.work.as_ref().is_some_and(|p| Arc::ptr_eq(p, preview)) + && scene.profile == profile + && scene.font_version == font_version { return scene.clone(); } + let mut routed = preview.routed.clone(); + let label_layout = if profile == label_layout_experiment::Profile::Baseline { + None + } else { + label_layout_experiment::apply( + preview.project(), + &preview.placement.diagram, + &preview.placement.view, + Arc::make_mut(&mut routed), + profile, + ) + }; let scene = Arc::new(Scene { + label_layout, + profile, + font_version, work: Some(preview.clone()), view: preview.placement.view.clone(), - routed: preview.routed.clone(), + routed, }); *state.work_cache.borrow_mut() = Some(scene.clone()); return scene; @@ -554,6 +585,8 @@ impl DiagramCanvas<'_> { && cache.preview == preview && cache.route == route && cache.port == port + && cache.scene.profile == profile + && cache.scene.font_version == font_version { return cache.scene.clone(); } @@ -563,7 +596,7 @@ impl DiagramCanvas<'_> { self.project.diagrams[&self.diagram].clone() }; let view = self.projected(&diagram); - let routed = Arc::new( + let mut routed = Arc::new( if let Some(gesture) = state .route_drag .as_ref() @@ -580,7 +613,21 @@ impl DiagramCanvas<'_> { route_diagram(self.project, &diagram, &view) }, ); + let label_layout = if profile == label_layout_experiment::Profile::Baseline { + None + } else { + label_layout_experiment::apply( + self.project, + &diagram, + &view, + Arc::make_mut(&mut routed), + profile, + ) + }; let scene = Arc::new(Scene { + label_layout, + profile, + font_version, work: None, view, routed, @@ -1355,16 +1402,27 @@ impl canvas::Program for DiagramCanvas<'_> { Size::new(label.bounds.size.width, label.bounds.size.height), Color::WHITE, ); - f.fill_text(Text { - content: label.text.clone(), - position: point(label.center), - color: if active { style::TEAL } else { style::MUTED }, - size: 13.into(), - align_x: iced::widget::text::Alignment::Center, - align_y: iced::alignment::Vertical::Center, - max_width: f32::INFINITY, - ..Default::default() - }); + if let Some(layout) = scene + .label_layout + .as_ref() + .filter(|_| edge.id == label_layout_experiment::edge_id()) + { + for line in layout.canvas_lines(if active { style::TEAL } else { style::MUTED }) + { + f.fill_text(line); + } + } else { + f.fill_text(Text { + content: label.text.clone(), + position: point(label.center), + color: if active { style::TEAL } else { style::MUTED }, + size: 13.into(), + align_x: iced::widget::text::Alignment::Center, + align_y: iced::alignment::Vertical::Center, + max_width: f32::INFINITY, + ..Default::default() + }); + } } if let Some(relation) = project.relations.get(&edge.id) { for end in routing::association_end_geometry(relation, routed) { @@ -1943,7 +2001,30 @@ impl std::fmt::Display for ExportError { } impl std::error::Error for ExportError {} +/// Private opt-in trial adapter for consumers that already own routed geometry. +pub(crate) fn apply_label_experiment( + p: &Project, + diagram: &Diagram, + view: &ProjectedView, + routed: &mut RoutedView, +) { + let _ = label_layout_experiment::apply( + p, + diagram, + view, + routed, + label_layout_experiment::profile(), + ); +} + pub fn export_svg(p: &Project, diagram: Id) -> Result { + export_svg_with_profile(p, diagram, label_layout_experiment::profile()) +} +fn export_svg_with_profile( + p: &Project, + diagram: Id, + profile: label_layout_experiment::Profile, +) -> Result { let d = p .diagrams .get(&diagram) @@ -1952,7 +2033,8 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result { return Err(ExportError::UnsupportedScenario(diagram)); } let view = project_view(p, diagram); - let routed = route_diagram(p, d, &view); + let mut routed = route_diagram(p, d, &view); + let label_layout = label_layout_experiment::apply(p, d, &view, &mut routed, profile); 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.; @@ -2029,7 +2111,12 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result { .join(" "); svg.push_str(&format!("{}",edge.id,escape(p.named(edge.id)))); let label = &route.label; - if !label.text.is_empty() { + if let Some(layout) = label_layout + .as_ref() + .filter(|_| edge.id == label_layout_experiment::edge_id()) + { + svg.push_str(&layout.svg()); + } else if !label.text.is_empty() { svg.push_str(&format!("{}",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) { diff --git a/crates/oa-desktop/src/diagram/attachment_tests.rs b/crates/oa-desktop/src/diagram/attachment_tests.rs index 974df43f..943ce63a 100644 --- a/crates/oa-desktop/src/diagram/attachment_tests.rs +++ b/crates/oa-desktop/src/diagram/attachment_tests.rs @@ -422,6 +422,9 @@ fn coincident_attachment_handles_honor_explicit_end_choice() { ))); let c = canvas(&app, 1.); let mut scene = Scene { + label_layout: None, + profile: label_layout_experiment::Profile::Baseline, + font_version: None, work: None, view: projection::project_view(c.project, c.diagram), routed: Arc::new(route_diagram( diff --git a/crates/oa-desktop/src/diagram/label_layout_experiment.rs b/crates/oa-desktop/src/diagram/label_layout_experiment.rs new file mode 100644 index 00000000..cc5bdc63 --- /dev/null +++ b/crates/oa-desktop/src/diagram/label_layout_experiment.rs @@ -0,0 +1,676 @@ +//! Explicit, one-fixture visual experiment. No persisted formatting policy. +use super::*; +use iced::advanced::{graphics::text as graphics_text, text as shaped}; +use shaped::Paragraph as _; +use std::sync::{Mutex, OnceLock}; + +const DIAGRAM: u128 = 0x00000000003840008000000000000001; +const EDGE: u128 = 0x63009b5f539e5914b630ec6b7fefb4d5; +pub(super) fn edge_id() -> Id { + Id::from_u128(EDGE) +} + +const NAME: &str = "Technician service conversation"; +const FONT_SIZE: f32 = 13.; +const ADVANCE: f32 = 16.; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum Profile { + #[default] + Baseline, + E, + F, +} +pub(super) fn profile() -> Profile { + static PROFILE: OnceLock = OnceLock::new(); + *PROFILE.get_or_init( + || match std::env::var("ARCADIA_LABEL_EXPERIMENT").as_deref() { + Ok("E") => Profile::E, + Ok("F") => Profile::F, + _ => Profile::Baseline, + }, + ) +} + +#[derive(Clone, Debug, PartialEq)] +struct MeasuredLine { + text: &'static str, + width: f32, + baseline: f32, + family: String, +} +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Line { + pub text: &'static str, + pub position: Position, + pub baseline_y: f32, + pub width: f32, + pub family: String, +} +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Layout { + pub canonical_text: String, + pub lines: Vec, + pub geometry: routing::EdgeLabelGeometry, + /// Authored moves retain their shape even outside the automatic guard. + pub clear: bool, +} + +pub(super) fn font_version(profile: Profile) -> Option { + (profile != Profile::Baseline).then(|| { + graphics_text::font_system() + .read() + .expect("Read font system") + .version() + }) +} + +fn measured(profile: Profile) -> Option>> { + type Entry = (Profile, graphics_text::Version, Arc>); + static CACHE: OnceLock>> = OnceLock::new(); + let version = font_version(profile)?; + let mut cache = CACHE.get_or_init(|| Mutex::new(Vec::new())).lock().ok()?; + if let Some((_, _, lines)) = cache + .iter() + .find(|(p, v, _)| *p == profile && *v == version) + { + return Some(lines.clone()); + } + let strings: &[&'static str] = match profile { + Profile::Baseline => return None, + Profile::E => &["Technician service", "conversation"], + Profile::F => &["Technician", "service", "conversation"], + }; + let mut lines = Vec::new(); + for &content in strings { + let paragraph = graphics_text::Paragraph::with_text(shaped::Text { + content, + bounds: Size::INFINITE, + size: FONT_SIZE.into(), + line_height: shaped::LineHeight::Absolute(ADVANCE.into()), + font: iced::Font::default(), + align_x: shaped::Alignment::Left, + align_y: iced::alignment::Vertical::Top, + shaping: shaped::Shaping::Basic, + wrapping: shaped::Wrapping::None, + }); + let mut runs = paragraph.buffer().layout_runs(); + let run = runs.next()?; + if runs.next().is_some() || run.rtl || run.glyphs.is_empty() { + return None; + } + let width = paragraph.min_width(); + if !width.is_finite() || width <= 0. || paragraph.min_height() > ADVANCE + 0.01 { + return None; + } + // This fixed English witness must resolve to one actual font face. + // Mixed fallback is not silently exported as a generic SVG font. + let font_id = run.glyphs.first()?.font_id; + if run.glyphs.iter().any(|glyph| glyph.font_id != font_id) { + return None; + } + let family = graphics_text::font_system() + .write() + .ok()? + .raw() + .db() + .face(font_id)? + .families + .first()? + .0 + .clone(); + lines.push(MeasuredLine { + text: content, + width, + baseline: run.line_y, + family, + }); + } + let lines = Arc::new(lines); + cache.retain(|(_, v, _)| *v == version); + cache.push((profile, version, lines.clone())); + Some(lines) +} + +/// Runs before any scene consumer; never edits the model or any routed points. +pub(super) fn apply( + project: &Project, + diagram: &Diagram, + view: &ProjectedView, + routed: &mut RoutedView, + profile: Profile, +) -> Option { + if profile == Profile::Baseline || diagram.id.as_u128() != DIAGRAM { + return None; + } + let id = Id::from_u128(EDGE); + let edge = routed.edges.get(&id)?; + if edge.label.text != NAME + || project.relations.get(&id)?.name != NAME + || edge.custom_suspended + || view + .nodes + .iter() + .any(|node| node.visual_parent.is_some() || node.collapsed) + { + return None; + } + let metrics = measured(profile)?; + let width = metrics.iter().map(|line| line.width).fold(0., f32::max) + 16.; + let height = 8. + ADVANCE * metrics.len() as f32; + let authored = diagram + .routes + .iter() + .find(|route| route.edge == id) + .and_then(|route| route.label.as_ref()); + let fraction = authored.map_or(if profile == Profile::E { 0.5 } else { 0.55 }, |label| { + label.fraction + }); + let anchor = routing::point_along_polyline(&edge.points, fraction)?; + let offset = authored.map_or( + Position { + x: if profile == Profile::E { + 0. + } else { + 18. + width / 2. + }, + y: 0., + }, + |label| label.offset, + ); + let center = Position { + x: anchor.x + offset.x, + y: anchor.y + offset.y, + }; + let bounds = routing::RoutingBounds { + position: Position { + x: center.x - width / 2., + y: center.y - height / 2., + }, + size: oa_core::Size { width, height }, + }; + let clear = guarded(project, view, routed, id, bounds, profile); + if authored.is_none() && !clear { + return None; + } + let geometry = routing::EdgeLabelGeometry { + text: edge.label.text.clone(), + fraction, + anchor, + center, + bounds, + }; + let lines = metrics + .iter() + .enumerate() + .map(|(index, line)| { + let top = bounds.position.y + 4. + ADVANCE * index as f32; + Line { + text: line.text, + position: Position { + x: center.x - line.width / 2., + y: top, + }, + baseline_y: top + line.baseline, + width: line.width, + family: line.family.clone(), + } + }) + .collect(); + let layout = Layout { + canonical_text: edge.label.text.clone(), + lines, + geometry, + clear, + }; + routed.edges.get_mut(&id)?.label = layout.geometry.clone(); + // Retain conservative existing bounds; add authored placements outside them. + // The fixed automatic witnesses are inside the unchanged node extents. + let old = routed.bounds; + let left = old.position.x.min(bounds.position.x); + let top = old.position.y.min(bounds.position.y); + let right = (old.position.x + old.size.width).max(bounds.position.x + width); + let bottom = (old.position.y + old.size.height).max(bounds.position.y + height); + routed.bounds = routing::RoutingBounds { + position: Position { x: left, y: top }, + size: oa_core::Size { + width: right - left, + height: bottom - top, + }, + }; + Some(layout) +} + +fn rectangle(bounds: routing::RoutingBounds, padding: f32) -> Rectangle { + Rectangle { + x: bounds.position.x - padding, + y: bounds.position.y - padding, + width: bounds.size.width + 2. * padding, + height: bounds.size.height + 2. * padding, + } +} +fn overlap(a: Rectangle, b: Rectangle) -> bool { + a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y +} +fn crossing(bounds: Rectangle, span: &[Position]) -> bool { + let [a, b] = [span[0], span[1]]; + if a.x == b.x { + a.x >= bounds.x + && a.x <= bounds.x + bounds.width + && a.y.min(b.y) <= bounds.y + bounds.height + && a.y.max(b.y) >= bounds.y + } else if a.y == b.y { + a.y >= bounds.y + && a.y <= bounds.y + bounds.height + && a.x.min(b.x) <= bounds.x + bounds.width + && a.x.max(b.x) >= bounds.x + } else { + // The bounded witness supports orthogonal geometry only. + true + } +} +fn guarded( + project: &Project, + view: &ProjectedView, + routed: &RoutedView, + id: Id, + bounds: routing::RoutingBounds, + profile: Profile, +) -> bool { + let label = rectangle(bounds, 0.); + if view.nodes.iter().any(|node| { + overlap( + label, + Rectangle { + x: node.position.x - 12., + y: node.position.y - 12., + width: node.size.width + 24., + height: node.size.height + 24., + }, + ) + }) { + return false; + } + let stroke_bounds = rectangle(bounds, 3.); + let mut own_crossings = Vec::new(); + for edge in routed.edges.values() { + if edge.id != id + && !edge.label.text.is_empty() + && overlap(label, rectangle(edge.label.bounds, 4.)) + { + return false; + } + if let Some(relation) = project.relations.get(&edge.id) { + for end in routing::association_end_geometry(relation, edge) { + if !end.label.text.is_empty() && overlap(label, rectangle(end.label.bounds, 4.)) { + return false; + } + } + } + for span in edge.points.windows(2) { + if crossing(stroke_bounds, span) { + if edge.id != id || profile == Profile::F { + return false; + } + own_crossings.push(span); + } + } + } + if profile == Profile::E { + let [span] = own_crossings.as_slice() else { + return false; + }; + let [a, b] = [span[0], span[1]]; + // One complete straight crossing, not a bend, lead, tip, or shared run. + (a.x == b.x + && a.y.min(b.y) < stroke_bounds.y + && a.y.max(b.y) > stroke_bounds.y + stroke_bounds.height) + || (a.y == b.y + && a.x.min(b.x) < stroke_bounds.x + && a.x.max(b.x) > stroke_bounds.x + stroke_bounds.width) + } else { + true + } +} + +impl Layout { + pub(super) fn canvas_lines(&self, color: Color) -> impl Iterator + '_ { + self.lines.iter().map(move |line| Text { + content: line.text.into(), + position: point(line.position), + color, + size: FONT_SIZE.into(), + line_height: shaped::LineHeight::Absolute(ADVANCE.into()), + font: iced::Font::default(), + align_x: shaped::Alignment::Left, + align_y: iced::alignment::Vertical::Top, + shaping: shaped::Shaping::Basic, + max_width: f32::INFINITY, + }) + } + pub(super) fn svg(&self) -> String { + let bounds = self.geometry.bounds; + let mut out = format!( + "{}", + escape(&self.canonical_text), + bounds.position.x, + bounds.position.y, + bounds.size.width, + bounds.size.height + ); + for line in &self.lines { + out.push_str(&format!("{}", + line.position.x, line.baseline_y, escape(&line.family), escape(line.text))); + } + out.push_str(""); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (Project, Id) { + let p: Project = serde_json::from_slice(include_bytes!( + "../../../../harness/experiments/wrapped-label-20261001/fixture/eight-card.json" + )) + .unwrap(); + (p, Id::from_u128(DIAGRAM)) + } + fn canvas(p: &Project, diagram: Id) -> DiagramCanvas<'_> { + DiagramCanvas { + selection: None, + allocation: None, + project: p, + view_index: None, + diagram, + selected: edge_id(), + navigation_focus: None, + zoom: 1., + pan: Vector::ZERO, + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + selected_attachment: None, + editable: true, + placing: false, + connection: None, + } + } + #[test] + fn baseline_is_exact_and_wrapping_changes_only_target_derived_geometry() { + let (p, id) = fixture(); + let before = serde_json::to_vec(&p).unwrap(); + let diagram = &p.diagrams[&id]; + let view = project_view(&p, id); + let baseline = route_diagram(&p, diagram, &view); + assert_eq!(baseline.edges.len(), 12); + let mut unchanged = baseline.clone(); + assert!(apply(&p, diagram, &view, &mut unchanged, Profile::Baseline).is_none()); + assert_eq!(unchanged, baseline); + for profile in [Profile::E, Profile::F] { + let mut routed = baseline.clone(); + let layout = + apply(&p, diagram, &view, &mut routed, profile).expect("Measured witness must fit"); + assert!(layout.clear); + assert_eq!(layout.canonical_text, NAME); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.text) + .collect::>() + .join(" "), + NAME + ); + assert_eq!(routed.edges[&edge_id()].label.text, NAME); + for (id, old) in &baseline.edges { + assert_eq!(routed.edges[id].points, old.points); + if *id != edge_id() { + assert_eq!(&routed.edges[id], old); + } + } + let mut restored = routed.clone(); + restored.edges.get_mut(&edge_id()).unwrap().label = + baseline.edges[&edge_id()].label.clone(); + assert_eq!( + restored, baseline, + "No paths, ports, solver state, other labels or scene extents change" + ); + assert_eq!( + layout.geometry.bounds.size.height, + 8. + ADVANCE * layout.lines.len() as f32 + ); + let max_width = layout + .lines + .iter() + .map(|line| line.width) + .fold(0., f32::max); + assert_eq!(layout.geometry.bounds.size.width, max_width + 16.); + assert!( + layout + .lines + .iter() + .all(|line| !line.family.is_empty() && line.width > 0.) + ); + if profile == Profile::F { + assert_eq!(layout.geometry.fraction, 0.55); + assert!( + (layout.geometry.bounds.position.x - layout.geometry.anchor.x - 18.).abs() + < 0.001 + ); + assert!( + layout.geometry.bounds.size.width < 114.5, + "Measured F loses own-is-closer margin" + ); + } else { + assert_eq!(layout.geometry.fraction, 0.5); + assert_eq!(layout.geometry.center, layout.geometry.anchor); + } + println!( + "{profile:?}: bounds={:?}; lines={:?}", + layout.geometry.bounds, layout.lines + ); + } + assert_eq!(serde_json::to_vec(&p).unwrap(), before); + } + #[test] + fn measured_backing_canvas_svg_picking_and_focus_share_geometry() { + let (p, id) = fixture(); + let c = canvas(&p, id); + for profile in [Profile::E, Profile::F] { + let state = State::default(); + let scene = c.scene_with_profile(&state, profile); + let layout = scene.label_layout.as_ref().unwrap(); + let bounds = rectangle(layout.geometry.bounds, 0.); + let texts = layout.canvas_lines(Color::BLACK).collect::>(); + let svg = super::super::export_svg_with_profile(&p, id, profile).unwrap(); + assert!(svg.contains(&layout.svg())); + assert_eq!( + crate::diagram_usage::edge_focus_bounds(&scene.routed.edges[&edge_id()]), + bounds + ); + for (line, text) in layout.lines.iter().zip(texts) { + assert_eq!(text.content, line.text); + assert_eq!(text.position, point(line.position)); + assert_eq!(text.size.0, FONT_SIZE); + assert_eq!( + text.line_height, + shaped::LineHeight::Absolute(ADVANCE.into()) + ); + assert!(line.position.x >= bounds.x + 7.99); + assert!(line.position.x + line.width <= bounds.x + bounds.width - 7.99); + assert_eq!( + c.hit_label( + &scene.routed, + Point::new(line.position.x + line.width / 2., line.position.y + 8.) + ), + Some(edge_id()) + ); + assert!(svg.contains(&format!( + "x=\"{}\" y=\"{}\" font-family=\"{}\"", + line.position.x, + line.baseline_y, + escape(&line.family) + ))); + } + assert_eq!( + c.hit_label(&scene.routed, Point::new(bounds.x + 1., bounds.y + 1.)), + Some(edge_id()) + ); + } + } + #[test] + fn automatic_guard_refuses_foreign_coincident_stroke_and_card() { + let (p, id) = fixture(); + let diagram = &p.diagrams[&id]; + let view = project_view(&p, id); + let baseline = route_diagram(&p, diagram, &view); + for profile in [Profile::E, Profile::F] { + let mut valid = baseline.clone(); + let layout = apply(&p, diagram, &view, &mut valid, profile).unwrap(); + let mut blocked = baseline.clone(); + let foreign = blocked + .edges + .keys() + .copied() + .find(|id| *id != edge_id()) + .unwrap(); + let bounds = layout.geometry.bounds; + blocked.edges.get_mut(&foreign).unwrap().points = vec![ + Position { + x: layout.geometry.center.x, + y: bounds.position.y - 40., + }, + Position { + x: layout.geometry.center.x, + y: bounds.position.y + bounds.size.height + 40., + }, + ]; + let snapshot = blocked.clone(); + assert!(apply(&p, diagram, &view, &mut blocked, profile).is_none()); + assert_eq!(blocked, snapshot); + let mut occluded_view = view.clone(); + occluded_view.nodes[0].position = bounds.position; + let mut routed = baseline.clone(); + assert!(apply(&p, diagram, &occluded_view, &mut routed, profile).is_none()); + assert_eq!(routed, baseline); + } + } + #[test] + fn authored_fraction_offset_keep_shape_even_during_conflicting_move() { + let (mut p, id) = fixture(); + let view = project_view(&p, id); + for profile in [Profile::E, Profile::F] { + let original = route_diagram(&p, &p.diagrams[&id], &view); + let mut automatic = original.clone(); + let initial = apply(&p, &p.diagrams[&id], &view, &mut automatic, profile).unwrap(); + let offset = Position { + x: initial.geometry.center.x - initial.geometry.anchor.x + 17., + y: -9., + }; + p.diagrams.get_mut(&id).unwrap().routes = vec![EdgeRoute { + edge: edge_id(), + waypoints: vec![], + label: Some(EdgeLabel { + fraction: initial.geometry.fraction, + offset, + }), + }]; + let c = canvas(&p, id); + let state = State::default(); + let scene = c.scene_with_profile(&state, profile); + let moved = scene.label_layout.as_ref().unwrap(); + assert_eq!( + moved.lines.iter().map(|line| line.text).collect::>(), + initial + .lines + .iter() + .map(|line| line.text) + .collect::>() + ); + assert_eq!(moved.geometry.fraction, initial.geometry.fraction); + assert!((moved.geometry.center.x - moved.geometry.anchor.x - offset.x).abs() < 0.001); + assert!((moved.geometry.center.y - moved.geometry.anchor.y - offset.y).abs() < 0.001); + let mut preview = State { + context: Some(c.context), + ..State::default() + }; + preview.route_drag = Some(Gesture { + context: c.context, + start: point(moved.geometry.center), + handle: Handle::Label { original: offset }, + route: p.diagrams[&id].routes[0].clone(), + changed: true, + baseline: scene.routed.clone(), + }); + preview + .route_drag + .as_mut() + .unwrap() + .route + .label + .as_mut() + .unwrap() + .offset + .x += 100.; + let dragged = c.scene_with_profile(&preview, profile); + let layout = dragged.label_layout.as_ref().unwrap(); + assert_eq!(layout.lines.len(), moved.lines.len()); + assert!( + (layout.geometry.center.x - layout.geometry.anchor.x - offset.x - 100.).abs() + < 0.001 + ); + for (id, edge) in &scene.routed.edges { + assert_eq!(dragged.routed.edges[id].points, edge.points); + } + p.diagrams.get_mut(&id).unwrap().routes.clear(); + } + } + #[test] + fn scene_and_measurement_cache_reuse_and_geometry_invalidation() { + let (p, id) = fixture(); + let c = canvas(&p, id); + let mut state = State::default(); + let first = c.scene_with_profile(&state, Profile::F); + state.pointer = Some(Point::new(100., 200.)); + let changed_camera = DiagramCanvas { + pan: Vector::new(40., 30.), + zoom: 0.5, + selected: p.id, + ..c + }; + assert!(Arc::ptr_eq( + &first, + &changed_camera.scene_with_profile(&state, Profile::F) + )); + assert!(Arc::ptr_eq( + &measured(Profile::F).unwrap(), + &measured(Profile::F).unwrap() + )); + let other_profile = c.scene_with_profile(&state, Profile::E); + assert!(!Arc::ptr_eq(&first, &other_profile)); + let next_revision = DiagramCanvas { + context: Context { + revision: 1, + ..c.context + }, + ..c + }; + assert!(!Arc::ptr_eq( + &other_profile, + &next_revision.scene_with_profile(&state, Profile::E) + )); + let plain = c.scene_with_profile(&state, Profile::Baseline); + assert!(plain.label_layout.is_none()); + assert_eq!( + *plain.routed, + route_diagram(&p, &p.diagrams[&id], &plain.view) + ); + } +} diff --git a/crates/oa-desktop/src/diagram/work_drag.rs b/crates/oa-desktop/src/diagram/work_drag.rs index 0cc4957d..5c0dfa68 100644 --- a/crates/oa-desktop/src/diagram/work_drag.rs +++ b/crates/oa-desktop/src/diagram/work_drag.rs @@ -6,7 +6,8 @@ impl DiagramCanvas<'_> { pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool { let diagram = &self.project.diagrams[&self.diagram]; let view = self.projected(diagram); - let routed = routing::route_diagram(self.project, diagram, &view); + let mut routed = routing::route_diagram(self.project, diagram, &view); + super::apply_label_experiment(self.project, diagram, &view, &mut routed); self.work_dirty_press( &view, &routed, diff --git a/crates/oa-desktop/src/diagram_usage.rs b/crates/oa-desktop/src/diagram_usage.rs index 08a11a06..78e12a01 100644 --- a/crates/oa-desktop/src/diagram_usage.rs +++ b/crates/oa-desktop/src/diagram_usage.rs @@ -327,7 +327,11 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option