Files
ARCADIA-Studio/crates/oa-desktop/src/diagram/label_layout_experiment.rs
T

768 lines
27 KiB
Rust

//! 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<Profile> = 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<Line>,
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<graphics_text::Version> {
(profile != Profile::Baseline).then(|| {
graphics_text::font_system()
.read()
.expect("Read font system")
.version()
})
}
fn measured(profile: Profile) -> Option<Arc<Vec<MeasuredLine>>> {
type Entry = (Profile, graphics_text::Version, Arc<Vec<MeasuredLine>>);
static CACHE: OnceLock<Mutex<Vec<Entry>>> = 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<Layout> {
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<Item = Text> + '_ {
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!(
"<g data-label-experiment=\"wrapped\"><title>{}</title><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/>",
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!("<text x=\"{}\" y=\"{}\" font-family=\"{}\" font-size=\"13\" fill=\"#637487\">{}</text>",
line.position.x, line.baseline_y, escape(&line.family), escape(line.text)));
}
out.push_str("</g>");
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::<Vec<_>>()
.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::<Vec<_>>();
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(),
source_attachment: None,
target_attachment: None,
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::<Vec<_>>(),
initial
.lines
.iter()
.map(|line| line.text)
.collect::<Vec<_>>()
);
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.core_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 different_edge_preview_guard_rejection_restores_complete_core_geometry() {
// Exercise the actual gesture capture and core preview, with a pure
// explicit profile. Moving another label onto the wrapped target must
// reject wrapping, leaving the full original one-line target geometry.
// All twelve point arrays and every other frozen label remain exact.
let (p, id) = fixture();
let view = project_view(&p, id);
let core = route_diagram(&p, &p.diagrams[&id], &view);
let foreign = core
.edges
.values()
.find(|edge| edge.id != edge_id() && !edge.label.text.is_empty())
.unwrap();
let original_offset = Position {
x: foreign.label.center.x - foreign.label.anchor.x,
y: foreign.label.center.y - foreign.label.anchor.y,
};
let c = DiagramCanvas {
selected: foreign.id,
..canvas(&p, id)
};
for profile in [Profile::E, Profile::F] {
let mut state = State {
context: Some(c.context),
..State::default()
};
let initial = c.scene_with_profile(&state, profile);
let target = initial
.label_layout
.as_ref()
.expect("Initial witness must wrap");
c.begin_route_with_profile(
&mut state,
point(foreign.label.center),
Handle::Label {
original: original_offset,
},
EdgeRoute {
edge: foreign.id,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: foreign.label.fraction,
offset: original_offset,
}),
},
profile,
);
state
.route_drag
.as_mut()
.unwrap()
.move_to(point(target.geometry.center), 1.);
let diagram = c.preview_diagram(&state);
let expected = routing::route_edge_preview(&p, &diagram, &view, &core, foreign.id);
let mut guard_probe = expected.clone();
assert!(
apply(&p, &diagram, &view, &mut guard_probe, profile).is_none(),
"Foreign label now overlaps target: wrapping must be refused"
);
assert_eq!(guard_probe, expected);
let actual = c.scene_with_profile(&state, profile);
assert!(actual.label_layout.is_none());
for (edge, before) in &core.edges {
assert_eq!(
actual.routed.edges[edge].points, before.points,
"Label drag must not reroute"
);
if *edge != foreign.id && *edge != edge_id() {
assert_eq!(
actual.routed.edges[edge].label, before.label,
"Other labels remain frozen"
);
}
}
assert_eq!(
actual.routed.edges[&edge_id()].label,
core.edges[&edge_id()].label,
"No Layout means original complete one-line target geometry, never stale wrapped hit bounds"
);
assert_eq!(
*actual.routed, expected,
"Fallback is the complete core preview result"
);
}
}
#[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!(Arc::ptr_eq(&plain.core_routed, &plain.routed));
assert_eq!(
*plain.routed,
route_diagram(&p, &p.diagrams[&id], &plain.view)
);
}
}