From 23535b43424c4cc47e59e4198134048e48638846 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 5 Sep 2026 21:18:42 +0300 Subject: [PATCH] Add editable connection routes and shared native diagram geometry --- crates/oa-desktop/src/app.rs | 169 ++++- crates/oa-desktop/src/diagram.rs | 992 +++++++++++++++++++++---- crates/oa-desktop/src/forms.rs | 23 + crates/oa-desktop/src/guide.rs | 10 +- crates/oa-desktop/src/main.rs | 1 + crates/oa-desktop/src/port_workflow.rs | 1 + crates/oa-desktop/src/route_edit.rs | 108 +++ crates/oa-desktop/src/tests.rs | 99 +++ crates/oa-desktop/src/views.rs | 72 +- harness/DECISIONS.md | 3 + 10 files changed, 1290 insertions(+), 188 deletions(-) create mode 100644 crates/oa-desktop/src/route_edit.rs diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index dd331101..fb151417 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -93,6 +93,7 @@ pub enum Message { HideSelected, ToggleCollapse, MoveNode(Id, Position), + Route(crate::route_edit::Event), Zoom(f32), Pan(iced::Vector), Fit, @@ -126,6 +127,9 @@ pub enum Message { } pub struct App { + pub canvas_document: Id, + pub canvas_epoch: u64, + pub selected_bend: Option<(Id, usize)>, pub exact_strings: bool, pub session: Session, pub page: Page, @@ -196,6 +200,9 @@ impl App { .map(|d| d.id); let draft = Draft::from_project(&project, selected); Self { + canvas_document: Id::new_v4(), + canvas_epoch: 0, + selected_bend: None, exact_strings: false, session: Session::new(project), page: Page::Diagram, @@ -579,6 +586,13 @@ impl App { { return Task::none(); } + if matches!( + message, + Message::Page(_) | Message::Layer(_) | Message::Diagram(_) + ) { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.selected_bend = None; + } let previous = self.selected; let task = self.handle(message); self.normalize_creation(); @@ -593,7 +607,11 @@ impl App { } fn handle(&mut self, message: Message) -> Task { match message { - Message::Escape => self.source_navigation.open = false, + Message::Escape => { + self.source_navigation.open = false; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + self.selected_bend = None; + } Message::Guide(event) => self.guide.update(event), Message::ExactStrings(enabled) => { if let Some(d) = &mut self.draft { @@ -967,6 +985,7 @@ impl App { description: String::new(), layer: self.layer, projection: ProjectionKind::Allocation, + routes: Vec::new(), nodes: Default::default(), }; if self.execute(Command::CreateDiagram(d)) { @@ -1130,6 +1149,7 @@ impl App { } } } + Message::Route(event) => self.edit_route(event), Message::MoveNode(node, position) => { let original = self.selected; if self.apply() @@ -1513,6 +1533,7 @@ impl App { description: String::new(), layer: self.layer, projection: preferred, + routes: Vec::new(), nodes: Default::default(), })); diagram_target = Some(view); @@ -1833,6 +1854,122 @@ impl App { move |result| Message::Linted(version, result), ) } + pub fn route_context(&self) -> Option { + Some(crate::route_edit::Context { + document: self.canvas_document, + diagram: self.active_diagram?, + revision: self.session.revision(), + epoch: self.canvas_epoch, + }) + } + fn edit_route(&mut self, event: crate::route_edit::Event) { + use crate::route_edit::{Event, insert_pin, stored_route}; + let Some(context) = self.route_context() else { + return; + }; + if self.source_dirty() || self.draft_dirty() { + self.status = "Apply or revert your draft before editing a connection route.".into(); + return; + } + let diagram = &self.project().diagrams[&context.diagram]; + let view = project_view(self.project(), context.diagram); + let Some(projected_edge) = view.edges.iter().find(|e| e.id == self.selected) else { + return; + }; + if projection::edge_endpoint_placements(self.project(), diagram, self.selected).is_some_and( + |(source, target)| source != projected_edge.source || target != projected_edge.target, + ) { + self.status = + "Expand the endpoint containers to edit this connection’s saved route.".into(); + return; + } + let mut route = stored_route(diagram, self.selected); + let mut selected_bend = self + .selected_bend + .filter(|(id, _)| *id == self.selected) + .map(|(_, i)| i); + let command = match event { + Event::SelectBend(id, index) => { + if id == self.selected && index < route.waypoints.len() { + self.selected_bend = Some((id, index)); + } + return; + } + Event::Commit(expected, changed, bend) => { + if expected != context || changed.edge != self.selected { + self.status = + "Route gesture cancelled because the model or view changed.".into(); + return; + } + route = changed; + selected_bend = bend; + Command::SetEdgeRoute { + diagram: context.diagram, + route, + } + } + Event::AddBend => { + if route.waypoints.len() >= 128 { + return; + } + let geometry = oa_core::routing::route_diagram(self.project(), diagram, &view); + let edge = &geometry.edges[&self.selected]; + // Mandatory port leads are inside the card's clearance. A pin + // starts beyond those leads, including coincident short stubs. + let segment = edge + .points + .windows(2) + .enumerate() + .filter(|(index, _)| *index > 0 && index + 2 < edge.points.len()) + .map(|(_, span)| span) + .max_by(|a, b| { + let len = + |s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs(); + len(a).total_cmp(&len(b)) + }); + let point = if let Some(segment) = segment { + Position { + x: (segment[0].x + segment[1].x) / 2., + y: (segment[0].y + segment[1].y) / 2., + } + } else if let Some(stub) = edge.points.get(1) { + *stub + } else { + return; + }; + selected_bend = Some(insert_pin(&mut route, edge, point)); + Command::SetEdgeRoute { + diagram: context.diagram, + route, + } + } + Event::RemoveBend => { + let Some(index) = selected_bend.filter(|i| *i < route.waypoints.len()) else { + return; + }; + route.waypoints.remove(index); + selected_bend = None; + Command::SetEdgeRoute { + diagram: context.diagram, + route, + } + } + Event::Reset => { + selected_bend = None; + Command::RemoveEdgeRoute { + diagram: context.diagram, + edge: self.selected, + } + } + }; + let selected = self.selected; + if self.execute(command) { + self.selected = selected; + self.selected_bend = selected_bend.map(|index| (selected, index)); + self.refresh_draft(); + self.status = "Connection route updated · Undo restores the previous route".into(); + } + } pub fn fit(&mut self) { self.fit_to_view = true; let view = self @@ -1844,26 +1981,15 @@ impl App { self.pan = iced::Vector::new(30., 30.); return; } - let left = view - .nodes - .iter() - .map(|n| n.position.x) - .fold(f32::INFINITY, f32::min); - let top = view - .nodes - .iter() - .map(|n| n.position.y) - .fold(f32::INFINITY, f32::min); - let right = view - .nodes - .iter() - .map(|n| n.position.x + n.size.width) - .fold(f32::NEG_INFINITY, f32::max); - let bottom = view - .nodes - .iter() - .map(|n| n.position.y + n.size.height) - .fold(f32::NEG_INFINITY, f32::max); + let geometry = oa_core::routing::route_diagram( + self.project(), + &self.project().diagrams[&self.active_diagram.unwrap()], + &view, + ); + let left = geometry.bounds.position.x; + let top = geometry.bounds.position.y; + let right = left + geometry.bounds.size.width; + let bottom = top + geometry.bounds.size.height; let size = self.canvas_size.unwrap_or_else(|| { iced::Size::new( self.window_size.width - 230. - 320., @@ -1988,6 +2114,7 @@ mod port_view_tests { description: String::new(), layer: Layer::Sa, projection: ProjectionKind::Capabilities, + routes: Vec::new(), nodes: Default::default(), }, ); diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index a61800b1..973e1d04 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -1,4 +1,9 @@ -use crate::{app::Message, style, views::short}; +use crate::{ + app::Message, + route_edit::{self, Context, Event as RouteEvent, Gesture, Handle, point, position}, + style, + views::short, +}; use iced::{ Color, Point, Rectangle, Renderer, Size, Theme, Vector, mouse, widget::{ @@ -7,10 +12,15 @@ use iced::{ }, }; use oa_core::{ - projection::{ProjectedEdge, ProjectedNode, ProjectedView, project_view}, + projection::{ProjectedNode, ProjectedView, project_view}, + routing::{RoutedView, route_diagram}, *, }; -use std::collections::BTreeSet; +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; pub struct DiagramCanvas<'a> { pub project: &'a Project, @@ -18,12 +28,28 @@ pub struct DiagramCanvas<'a> { pub selected: Id, pub zoom: f32, pub pan: Vector, + pub context: Context, + pub selected_bend: Option, + pub editable: bool, } #[derive(Default)] pub struct State { drag: Option<(Id, Point, Position)>, preview: Option, pan_start: Option, + context: Option, + route_drag: Option, + cache: RefCell>, +} +struct Scene { + view: ProjectedView, + routed: Arc, +} +struct CachedScene { + context: Context, + preview: Option, + route: Option, + scene: Arc, } /// Make room along the inserted node's containment path, moving only colliding /// later siblings and their descendants. Existing semantic ownership is unchanged. @@ -105,7 +131,22 @@ pub fn insertion_adjustments(p: &Project, diagram_id: Id, element: Id) -> Vec = diagram .nodes .into_values() .map(|mut node| { @@ -118,7 +159,12 @@ pub fn insertion_adjustments(p: &Project, diagram_id: Id, element: Id) -> Vec { fn world(&self, p: Point) -> Point { @@ -146,38 +192,136 @@ impl DiagramCanvas<'_> { }) .map(|n| n.id) } - fn hit_edge(&self, view: &ProjectedView, p: Point) -> Option { + fn hit_edge(&self, routed: &RoutedView, p: Point) -> Option<(Id, usize)> { let p = self.world(p); - view.edges.iter().rev().find_map(|edge| { - let (start, end) = edge_endpoints(view, edge)?; - let tolerance = 7. / self.zoom; - let mut previous = start; - for i in 1..=24 { - let next = curve_point(start, end, i as f32 / 24.); - if distance_to_segment(p, previous, next) <= tolerance { - return Some(edge.id); - } - previous = next; - } - None - }) + routed + .edges + .values() + .flat_map(|edge| { + edge.points.windows(2).enumerate().map(move |(i, span)| { + ( + edge.id, + i, + distance_to_segment(p, point(span[0]), point(span[1])), + ) + }) + }) + .filter(|(_, _, distance)| *distance <= 7. / self.zoom) + .min_by(|a, b| { + a.2.total_cmp(&b.2) + .then((a.0 != self.selected).cmp(&(b.0 != self.selected))) + .then(a.0.cmp(&b.0)) + }) + .map(|(id, segment, _)| (id, segment)) } - fn preview_view(&self, state: &State) -> ProjectedView { + fn hit_label(&self, routed: &RoutedView, p: Point) -> Option { + let p = self.world(p); + routed + .edges + .values() + .filter(|edge| { + !edge.label.text.is_empty() + && Rectangle::new( + point(edge.label.bounds.position), + Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height), + ) + .contains(p) + }) + .min_by(|a, b| { + p.distance(point(a.label.center)) + .total_cmp(&p.distance(point(b.label.center))) + }) + .map(|edge| edge.id) + } + fn preview_diagram(&self, state: &State) -> Diagram { + let mut diagram = self.project.diagrams[&self.diagram].clone(); if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) { - let dx = preview.x - original.x; - let dy = preview.y - original.y; - let mut diagram = self.project.diagrams[&self.diagram].clone(); + let delta = Position { + x: preview.x - original.x, + y: preview.y - original.y, + }; let mut moved = oa_core::projection::descendant_nodes(self.project, self.diagram, id); moved.push(id); + let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect(); + for route in oa_core::projection::translated_routes(self.project, &diagram, &deltas) { + route_edit::with_route(&mut diagram, route); + } for id in moved { if let Some(n) = diagram.nodes.get_mut(&id) { - n.position.x += dx; - n.position.y += dy; + n.position.x += delta.x; + n.position.y += delta.y; } } - return oa_core::projection::project_diagram(self.project, &diagram); } - project_view(self.project, self.diagram) + if let Some(gesture) = &state.route_drag { + route_edit::with_route(&mut diagram, gesture.route.clone()); + } + diagram + } + #[cfg(test)] + fn preview_view(&self, state: &State) -> ProjectedView { + oa_core::projection::project_diagram(self.project, &self.preview_diagram(state)) + } + fn scene(&self, state: &State) -> Arc { + let preview = if state.context == Some(self.context) { + state.preview + } else { + None + }; + let route = state + .route_drag + .as_ref() + .filter(|g| g.context == self.context) + .map(|g| g.route.clone()); + if let Some(cache) = state.cache.borrow().as_ref() + && cache.context == self.context + && cache.preview == preview + && cache.route == route + { + return cache.scene.clone(); + } + let diagram = if state.context == Some(self.context) { + self.preview_diagram(state) + } else { + self.project.diagrams[&self.diagram].clone() + }; + let view = oa_core::projection::project_diagram(self.project, &diagram); + let routed = Arc::new( + if let Some(gesture) = state + .route_drag + .as_ref() + .filter(|g| g.context == self.context) + { + oa_core::routing::route_edge_preview( + self.project, + &diagram, + &view, + &gesture.baseline, + gesture.route.edge, + ) + } else { + route_diagram(self.project, &diagram, &view) + }, + ); + let scene = Arc::new(Scene { view, routed }); + *state.cache.borrow_mut() = Some(CachedScene { + context: self.context, + preview, + route, + scene: scene.clone(), + }); + scene + } + fn begin_route(&self, state: &mut State, world: Point, handle: Handle, route: EdgeRoute) { + let baseline = self.scene(state).routed.clone(); + state.route_drag = Some(Gesture { + context: self.context, + start: world, + handle, + route, + changed: false, + baseline, + }); } } impl canvas::Program for DiagramCanvas<'_> { @@ -189,6 +333,21 @@ impl canvas::Program for DiagramCanvas<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { + if state.context != Some(self.context) { + state.drag = None; + state.preview = None; + state.route_drag = None; + state.pan_start = None; + state.context = Some(self.context); + } + if state + .route_drag + .as_ref() + .is_some_and(|g| g.route.edge != self.selected) + || !self.editable + { + state.route_drag = None; + } let local = cursor .position() .map(|p| Point::new(p.x - bounds.x, p.y - bounds.y)); @@ -197,8 +356,52 @@ impl canvas::Program for DiagramCanvas<'_> { if cursor.is_over(bounds) => { let p = local?; - let view = project_view(self.project, self.diagram); + let scene = self.scene(state); + let view = &scene.view; let world = self.world(p); + if self.editable + && let Some(edge) = scene.routed.edges.get(&self.selected) + && !edge.custom_suspended + { + let mut route = route_edit::stored_route( + &self.project.diagrams[&self.diagram], + self.selected, + ); + if let Some(index) = edge + .pins + .iter() + .position(|pin| world.distance(point(*pin)) <= 8. / self.zoom) + { + self.begin_route(state, world, Handle::Bend(index), route); + return Some( + Action::publish(Message::Route(RouteEvent::SelectBend( + self.selected, + index, + ))) + .and_capture(), + ); + } + let label = &edge.label; + if self.hit(view, p).is_none() + && !label.text.is_empty() + && Rectangle::new( + point(label.bounds.position), + Size::new(label.bounds.size.width, label.bounds.size.height), + ) + .contains(world) + { + let original = route.label.as_ref().map(|l| l.offset).unwrap_or(Position { + x: label.center.x - label.anchor.x, + y: label.center.y - label.anchor.y, + }); + route.label.get_or_insert(EdgeLabel { + fraction: label.fraction, + offset: original, + }); + self.begin_route(state, world, Handle::Label { original }, route); + return Some(Action::capture()); + } + } if let Some(port) = view.ports.iter().rev().find(|port| { let radius = (10. / self.zoom).max(10.); (world.x - port.position.x).abs() <= radius @@ -206,7 +409,7 @@ impl canvas::Program for DiagramCanvas<'_> { }) { return Some(Action::publish(Message::Select(port.element)).and_capture()); } - if let Some(id) = self.hit(&view, p) { + if let Some(id) = self.hit(view, p) { let position = self .project .diagrams @@ -217,8 +420,25 @@ impl canvas::Program for DiagramCanvas<'_> { state.drag = Some((id, p, position)); state.preview = Some(position); Some(Action::publish(Message::SelectNode(id)).and_capture()) - } else if let Some(id) = self.hit_edge(&view, p) { + } else if let Some(id) = self.hit_label(&scene.routed, p) { Some(Action::publish(Message::Select(id)).and_capture()) + } else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) { + if id == self.selected + && self.editable + && let Some(edge) = scene.routed.edges.get(&id) + && !edge.custom_suspended + && edge.pins.len() < 128 + { + let mut route = + route_edit::stored_route(&self.project.diagrams[&self.diagram], id); + let index = + oa_core::routing::pin_insertion_index(edge, segment, position(world)); + route.waypoints.insert(index, position(world)); + self.begin_route(state, world, Handle::Bend(index), route); + Some(Action::capture()) + } else { + Some(Action::publish(Message::Select(id)).and_capture()) + } } else { state.pan_start = Some(p); Some(Action::capture()) @@ -226,6 +446,10 @@ impl canvas::Program for DiagramCanvas<'_> { } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { let p = local?; + if let Some(gesture) = &mut state.route_drag { + gesture.move_to(self.world(p), self.zoom); + return Some(Action::request_redraw().and_capture()); + } if let Some((_, start, original)) = state.drag { let delta = p - start; state.preview = Some(if delta.x * delta.x + delta.y * delta.y > 9. { @@ -246,8 +470,41 @@ impl canvas::Program for DiagramCanvas<'_> { } canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { state.pan_start = None; - if let Some((id, _, original)) = state.drag.take() { - let pos = state.preview.take()?; + if let Some(mut gesture) = state.route_drag.take() { + // A fast native drag can coalesce the final motion event. + // The release cursor is the authoritative final position. + if let Some(p) = local { + gesture.move_to(self.world(p), self.zoom); + } + if !gesture.changed { + return Some(Action::request_redraw().and_capture()); + } + let bend = match gesture.handle { + Handle::Bend(index) => Some(index), + Handle::Label { .. } => None, + }; + return Some( + Action::publish(Message::Route(RouteEvent::Commit( + gesture.context, + gesture.route, + bend, + ))) + .and_capture(), + ); + } + if let Some((id, start, original)) = state.drag.take() { + let preview = state.preview.take()?; + let pos = local.map_or(preview, |p| { + let delta = p - start; + if delta.x * delta.x + delta.y * delta.y > 9. { + Position { + x: original.x + delta.x / self.zoom, + y: original.y + delta.y / self.zoom, + } + } else { + original + } + }); if pos == original { return Some(Action::capture()); } @@ -312,7 +569,8 @@ impl canvas::Program for DiagramCanvas<'_> { } f.translate(self.pan); f.scale(self.zoom); - let view = self.preview_view(state); + let scene = self.scene(state); + let view = &scene.view; let containers = view .nodes .iter() @@ -325,12 +583,22 @@ impl canvas::Program for DiagramCanvas<'_> { } let highlighted = highlighted_exchanges(self.project, self.selected); for edge in &view.edges { - let Some((start, end)) = edge_endpoints(&view, edge) else { + let Some(routed) = scene.routed.edges.get(&edge.id) else { continue; }; + let Some(&end) = routed.points.last() else { + continue; + }; + let end = point(end); let active = edge.id == self.selected || highlighted.contains(&edge.id); let color = if active { style::TEAL + } else if routed + .warnings + .iter() + .any(|w| !matches!(w, oa_core::routing::RouteWarning::PromotedEndpoints)) + { + Color::from_rgb(0.72, 0.47, 0.10) } else if edge.trace { Color::from_rgb8(134, 107, 168) } else { @@ -347,8 +615,8 @@ impl canvas::Program for DiagramCanvas<'_> { offset: 0, }; } - f.stroke(&curve(start, end), stroke); - let previous = curve_point(start, end, 0.96); + f.stroke(&polyline(&routed.points), stroke); + let previous = point(routed.points[routed.points.len().saturating_sub(2)]); let direction = end - previous; let length = (direction.x * direction.x + direction.y * direction.y) .sqrt() @@ -366,34 +634,20 @@ impl canvas::Program for DiagramCanvas<'_> { Stroke::default().with_color(color).with_width(1.7), ); } - if edge.id == self.selected - || capability_label.is_some() - || matches!( - self.project.relations.get(&edge.id).map(|r| r.kind()), - Some( - RelationKind::Interaction - | RelationKind::FunctionalExchange - | RelationKind::BoundaryExchange - ) - ) - { - let center = curve_point(start, end, 0.5); + if !routed.label.text.is_empty() { + let label = &routed.label; f.fill_rectangle( - Point::new(center.x - 112., center.y - 30.), - Size::new(224., 24.), + point(label.bounds.position), + Size::new(label.bounds.size.width, label.bounds.size.height), Color::WHITE, ); f.fill_text(Text { - content: if edge.id == self.selected || capability_label.is_none() { - short(self.project.named(edge.id), 32) - } else { - capability_label.unwrap_or_default().into() - }, - position: Point::new(center.x, center.y - 8.), + 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::Bottom, + align_y: iced::alignment::Vertical::Center, max_width: f32::INFINITY, ..Default::default() }); @@ -481,7 +735,71 @@ impl canvas::Program for DiagramCanvas<'_> { }); } } - vec![f.into_geometry()] + if let Some(edge) = scene.routed.edges.get(&self.selected) + && !edge.custom_suspended + { + for (index, pin) in edge.pins.iter().enumerate() { + let handle = Path::circle(point(*pin), 5.5 / self.zoom); + f.fill( + &handle, + if self.selected_bend == Some(index) { + style::TEAL + } else { + Color::WHITE + }, + ); + f.stroke( + &handle, + Stroke::default() + .with_color(style::TEAL) + .with_width(1.7 / self.zoom), + ); + } + } + let mut overlay = Frame::new(renderer, bounds.size()); + let warning = scene + .routed + .edges + .get(&self.selected) + .and_then(|e| { + if e.custom_suspended { + None // The fixed routing toolbar already explains the folded state. + } else if !e.warnings.is_empty() { + Some(e.warnings[0].to_string()) + } else { + None + } + }) + .or_else(|| { + let count = scene + .routed + .edges + .values() + .filter(|e| { + e.warnings.iter().any(|w| { + !matches!(w, oa_core::routing::RouteWarning::PromotedEndpoints) + }) + }) + .count(); + (count > 0).then(|| { + format!("{count} connection routes need attention · select an amber line") + }) + }); + if let Some(warning) = warning { + overlay.fill_rectangle( + Point::new(8., 8.), + Size::new((bounds.width - 16.).max(0.), 30.), + Color::WHITE, + ); + overlay.fill_text(Text { + content: warning, + position: Point::new(16., 15.), + size: 12.into(), + color: style::MUTED, + ..Default::default() + }); + } + vec![f.into_geometry(), overlay.into_geometry()] } fn mouse_interaction( &self, @@ -489,7 +807,7 @@ impl canvas::Program for DiagramCanvas<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> mouse::Interaction { - if state.drag.is_some() || state.pan_start.is_some() { + if state.drag.is_some() || state.pan_start.is_some() || state.route_drag.is_some() { mouse::Interaction::Grabbing } else if cursor.is_over(bounds) { mouse::Interaction::Grab @@ -613,19 +931,6 @@ fn highlighted_exchanges(p: &Project, selected: Id) -> BTreeSet { }) .collect() } -fn edge_endpoints(view: &ProjectedView, edge: &ProjectedEdge) -> Option<(Point, Point)> { - let a = view.nodes.iter().find(|n| n.id == edge.source)?; - let b = view.nodes.iter().find(|n| n.id == edge.target)?; - let (start, end) = endpoints(a, b); - let port_point = |id: Option| { - id.and_then(|id| view.ports.iter().find(|p| p.element == id)) - .map(|p| Point::new(p.position.x, p.position.y)) - }; - Some(( - port_point(edge.source_port).unwrap_or(start), - port_point(edge.target_port).unwrap_or(end), - )) -} fn relationship_stereotype(p: &Project, id: Id) -> Option<&'static str> { match p.relations.get(&id)?.kind() { RelationKind::CapabilityInclude => Some("«include»"), @@ -638,50 +943,16 @@ fn relationship_stereotype(p: &Project, id: Id) -> Option<&'static str> { _ => None, } } -fn endpoints(a: &ProjectedNode, b: &ProjectedNode) -> (Point, Point) { - let ax = a.position.x + a.size.width / 2.; - let ay = a.position.y + a.size.height / 2.; - let bx = b.position.x + b.size.width / 2.; - let by = b.position.y + b.size.height / 2.; - if (bx - ax).abs() > (by - ay).abs() { - if bx >= ax { - ( - Point::new(a.position.x + a.size.width, ay), - Point::new(b.position.x, by), - ) - } else { - ( - Point::new(a.position.x, ay), - Point::new(b.position.x + b.size.width, by), - ) - } - } else if by >= ay { - ( - Point::new(ax, a.position.y + a.size.height), - Point::new(bx, b.position.y), - ) - } else { - ( - Point::new(ax, a.position.y), - Point::new(bx, b.position.y + b.size.height), - ) - } -} -fn curve(a: Point, b: Point) -> Path { - let mid = (a.x + b.x) / 2.; +fn polyline(points: &[Position]) -> Path { Path::new(|p| { - p.move_to(a); - p.bezier_curve_to(Point::new(mid, a.y), Point::new(mid, b.y), b); + if let Some(first) = points.first() { + p.move_to(point(*first)); + for next in &points[1..] { + p.line_to(point(*next)); + } + } }) } -fn curve_point(a: Point, b: Point, t: f32) -> Point { - let mid = (a.x + b.x) / 2.; - let u = 1. - t; - Point::new( - u * u * u * a.x + 3. * u * u * t * mid + 3. * u * t * t * mid + t * t * t * b.x, - u * u * u * a.y + 3. * u * u * t * a.y + 3. * u * t * t * b.y + t * t * t * b.y, - ) -} pub fn distance_to_segment(p: Point, a: Point, b: Point) -> f32 { let ab = b - a; let ap = p - a; @@ -711,20 +982,14 @@ fn hex(c: Color) -> String { } pub fn export_svg(p: &Project, diagram: Id) -> String { let view = project_view(p, diagram); - let left = view.nodes.iter().map(|n| n.position.x).fold(0., f32::min) - 30.; - let top = view.nodes.iter().map(|n| n.position.y).fold(0., f32::min) - 30.; - let right = view - .nodes - .iter() - .map(|n| n.position.x + n.size.width) - .fold(800., f32::max) - + 30.; - let bottom = view - .nodes - .iter() - .map(|n| n.position.y + n.size.height) - .fold(500., f32::max) - + 30.; + let Some(d) = p.diagrams.get(&diagram) else { + return String::new(); + }; + let routed = route_diagram(p, d, &view); + 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.; + let bottom = routed.bounds.position.y + routed.bounds.size.height + 30.; let mut svg = format!( "{}", right - left, @@ -774,39 +1039,40 @@ pub fn export_svg(p: &Project, diagram: Id) -> String { } } for edge in &view.edges { - if let Some((a, b)) = edge_endpoints(&view, edge) { - let mid = (a.x + b.x) / 2.; - let stereotype = relationship_stereotype(p, edge.id); - let dashed = if matches!(stereotype, Some("«include»" | "«extend»")) { - " stroke-dasharray=\"6 4\"" - } else { - "" - }; - let arrow = if edge.oriented { - " marker-end=\"url(#arrow)\"" - } else { - "" - }; - svg.push_str(&format!("{}",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id)))); - if stereotype.is_some() - || matches!( - p.relations.get(&edge.id).map(|r| r.kind()), - Some( - RelationKind::Interaction - | RelationKind::FunctionalExchange - | RelationKind::BoundaryExchange - ) - ) - { - let label = stereotype.unwrap_or_else(|| p.named(edge.id)); - let center = curve_point(a, b, 0.5); - svg.push_str(&format!("{}",center.x-112.,center.y-30.,center.x,center.y-8.,escape(&short(label,32)))); - } + let Some(route) = routed.edges.get(&edge.id) else { + continue; + }; + let stereotype = relationship_stereotype(p, edge.id); + let dashed = if matches!(stereotype, Some("«include»" | "«extend»")) { + " stroke-dasharray=\"6 4\"" + } else { + "" + }; + let arrow = if edge.oriented { + " marker-end=\"url(#arrow)\"" + } else { + "" + }; + let path = route + .points + .iter() + .enumerate() + .map(|(i, p)| format!("{} {} {}", if i == 0 { "M" } else { "L" }, p.x, p.y)) + .collect::>() + .join(" "); + svg.push_str(&format!("{}",edge.id,escape(p.named(edge.id)))); + let label = &route.label; + 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))); } } for n in &view.nodes { if !containers.contains(&n.id) { svg.push_str(&node_svg(n)); + } else { + let mut header = n.clone(); + header.size.height = 62.; + svg.push_str(&node_svg(&header)); } } for port in &view.ports { @@ -1001,6 +1267,14 @@ mod tests { }; let canvas = DiagramCanvas { project: &p, + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, diagram, selected: p.id, zoom: 1., @@ -1064,6 +1338,14 @@ mod tests { .id; let canvas = DiagramCanvas { project: &p, + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, diagram, selected: p.id, zoom: 0.6, @@ -1107,9 +1389,403 @@ mod tests { assert!(svg.contains(&format!("{}", escape(p.named(port.element))))); } for edge in &view.edges { - let (start, end) = edge_endpoints(&view, edge).unwrap(); - assert!(svg.contains(&format!("M {} {} C", start.x, start.y))); - assert!(svg.contains(&format!("{}, {} {}", end.y, end.x, end.y))); + let routed = route_diagram(&p, &p.diagrams[&diagram], &view); + let points = &routed.edges[&edge.id].points; + let start = points.first().unwrap(); + let end = points.last().unwrap(); + assert!(svg.contains(&format!("M {} {} L", start.x, start.y))); + assert!(svg.contains(&format!("L {} {}", end.x, end.y))); } } + fn route_fixture() -> (Project, Id, Id) { + let mut p = Project::empty(); + let a = p.add(Kind::Activity, "Send", 0., 0.); + let b = p.add(Kind::Activity, "Receive", 650., 0.); + let edge = p.relate(RelationKind::Interaction, "Report", a, b); + let diagram = p.ensure_diagram(Layer::Oa); + let p = execute_batch( + &p, + &[Command::SetEdgeRoute { + diagram, + route: EdgeRoute { + edge, + waypoints: vec![Position { x: 400., y: -100. }], + label: None, + }, + }], + ) + .unwrap(); + (p, diagram, edge) + } + fn pointer( + canvas: &DiagramCanvas<'_>, + state: &mut State, + event: mouse::Event, + cursor: Point, + ) -> Option { + canvas::Program::update( + canvas, + state, + &canvas::Event::Mouse(event), + Rectangle::new(Point::ORIGIN, Size::new(1800., 1200.)), + mouse::Cursor::Available(cursor), + ) + .and_then(|a| a.into_inner().0) + } + #[test] + fn bend_gestures_use_screen_hit_radius_and_commit_one_undo_at_three_zooms() { + for zoom in [0.5, 1., 1.5] { + let (p, diagram, edge) = route_fixture(); + let canvas = DiagramCanvas { + project: &p, + diagram, + selected: edge, + zoom, + pan: Vector::new(150., 300.), + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, + }; + let mut state = State::default(); + let pin = Point::new(400. * zoom + 150., -100. * zoom + 300.); + let selected = pointer( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + pin + Vector::new(6., 0.), + ); + assert!( + matches!(selected,Some(Message::Route(RouteEvent::SelectBend(id,0))) if id==edge) + ); + let target = pin + Vector::new(50. * zoom, 40. * zoom); + pointer( + &canvas, + &mut state, + mouse::Event::CursorMoved { position: target }, + target, + ); + assert_eq!( + p.diagrams[&diagram].routes[0].waypoints[0], + Position { x: 400., y: -100. } + ); + let Some(Message::Route(RouteEvent::Commit(context, route, Some(0)))) = pointer( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + target, + ) else { + panic!("Drag must commit route"); + }; + assert_eq!(context, canvas.context); + assert_eq!(route.waypoints[0], Position { x: 450., y: -60. }); + let mut session = Session::new(p.clone()); + session + .execute(Command::SetEdgeRoute { diagram, route }) + .unwrap(); + session.undo(); + assert_eq!(session.project(), &p); + } + } + #[test] + fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() { + let (p, diagram, edge) = route_fixture(); + let canvas = DiagramCanvas { + project: &p, + diagram, + selected: edge, + zoom: 0.5, + pan: Vector::new(150., 300.), + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, + }; + let mut state = State::default(); + let pin = Point::new(350., 250.); + pointer( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + pin, + ); + let target = pin + Vector::new(30., 20.); + let Some(Message::Route(RouteEvent::Commit(_, route, Some(0)))) = pointer( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + target, + ) else { + panic!("Release must retain the final bend position"); + }; + assert_eq!(route.waypoints[0], Position { x: 460., y: -60. }); + + let scene = canvas.scene(&State::default()); + let center = scene.routed.edges[&edge].label.center; + let label = Point::new(center.x * canvas.zoom, center.y * canvas.zoom) + canvas.pan; + state = State::default(); + pointer( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + label, + ); + let Some(Message::Route(RouteEvent::Commit(_, route, None))) = pointer( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + label + Vector::new(15., 10.), + ) else { + panic!("Release must retain the final label position"); + }; + let geometry = &scene.routed.edges[&edge].label; + assert_eq!( + route.label.unwrap().offset, + Position { + x: geometry.center.x - geometry.anchor.x + 30., + y: geometry.center.y - geometry.anchor.y + 20., + } + ); + + state = State::default(); + let node = &scene.view.nodes[0]; + let start = Point::new( + (node.position.x + 40.) * canvas.zoom, + (node.position.y + 30.) * canvas.zoom, + ) + canvas.pan; + pointer( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + start, + ); + let Some(Message::MoveNode(id, position)) = pointer( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + start + Vector::new(30., 20.), + ) else { + panic!("Release must retain the final node position"); + }; + assert_eq!(id, node.id); + assert_eq!( + position, + Position { + x: node.position.x + 60., + y: node.position.y + 40. + } + ); + } + #[test] + fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() { + let (p, diagram, edge) = route_fixture(); + let mut canvas = DiagramCanvas { + project: &p, + diagram, + selected: edge, + zoom: 1., + pan: Vector::new(150., 300.), + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, + }; + let mut state = State::default(); + let first = canvas.scene(&state); + canvas.zoom = 0.5; + canvas.pan = Vector::new(20., 300.); + assert!( + Arc::ptr_eq(&first, &canvas.scene(&state)), + "Pan and zoom reuse world routing" + ); + let pin = Point::new(220., 250.); + pointer( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + pin, + ); + pointer( + &canvas, + &mut state, + mouse::Event::CursorMoved { + position: pin + Vector::new(40., 30.), + }, + pin + Vector::new(40., 30.), + ); + canvas.context.epoch += 1; + assert!( + pointer( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + pin + ) + .is_none() + ); + assert!(state.route_drag.is_none()); + } + #[test] + fn routed_svg_has_same_polyline_label_and_bounds_as_canvas_scene() { + let (mut p, diagram, edge) = route_fixture(); + p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel { + fraction: 0.3, + offset: Position { x: -500., y: -400. }, + }); + let view = project_view(&p, diagram); + let geometry = route_diagram(&p, &p.diagrams[&diagram], &view); + let svg = export_svg(&p, diagram); + let route = &geometry.edges[&edge]; + let path = route + .points + .iter() + .enumerate() + .map(|(i, p)| format!("{} {} {}", if i == 0 { "M" } else { "L" }, p.x, p.y)) + .collect::>() + .join(" "); + assert!(svg.contains(&format!("d=\"{path}\""))); + assert!(svg.contains(&format!( + "x=\"{}\" y=\"{}\" text-anchor", + route.label.center.x, route.label.center.y + ))); + assert!(geometry.bounds.position.x <= route.label.bounds.position.x); + assert!(geometry.bounds.position.y <= route.label.bounds.position.y); + } + #[test] + fn detached_label_selects_connection_and_occluding_card_owns_its_hit() { + let (mut p, diagram, edge) = route_fixture(); + p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel { + fraction: 0.5, + offset: Position { x: 0., y: -160. }, + }); + let mut canvas = DiagramCanvas { + project: &p, + diagram, + selected: p.id, + zoom: 1., + pan: Vector::new(100., 400.), + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, + }; + let mut state = State::default(); + let scene = canvas.scene(&state); + let center = scene.routed.edges[&edge].label.center; + let screen = point(center) + canvas.pan; + assert!( + matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::Select(id)) if id==edge) + ); + canvas.selected = edge; + pointer( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + screen, + ); + let moved = screen + Vector::new(50., 30.); + pointer( + &canvas, + &mut state, + mouse::Event::CursorMoved { position: moved }, + moved, + ); + let preview = canvas.scene(&state); + assert_eq!( + preview.routed.edges[&edge].label.center, + Position { + x: center.x + 50., + y: center.y + 30. + } + ); + assert_eq!( + preview.routed.edges[&edge].points, scene.routed.edges[&edge].points, + "Label moves do not reroute the connection" + ); + let Some(Message::Route(RouteEvent::Commit(_, route, _))) = pointer( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + moved, + ) else { + panic!("Label drag must commit"); + }; + assert!(route.label.is_some()); + let node = scene.view.nodes[0].clone(); + let cover = Position { + x: node.position.x + 40., + y: node.position.y + 30., + }; + let anchor = scene.routed.edges[&edge].label.anchor; + p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel { + fraction: scene.routed.edges[&edge].label.fraction, + offset: Position { + x: cover.x - anchor.x, + y: cover.y - anchor.y, + }, + }); + let canvas = DiagramCanvas { + project: &p, + diagram, + selected: edge, + zoom: 1., + pan: Vector::new(100., 400.), + context: Context { + document: p.id, + diagram, + revision: 1, + epoch: 0, + }, + selected_bend: None, + editable: true, + }; + assert!( + matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),point(cover)+canvas.pan),Some(Message::SelectNode(id)) if id==node.id) + ); + } + #[test] + fn coincident_line_hit_retains_the_selected_connection() { + let (p, diagram, edge) = route_fixture(); + let view = project_view(&p, diagram); + let mut routed = route_diagram(&p, &p.diagrams[&diagram], &view); + let duplicate = Id::new_v4(); + let mut duplicate_route = routed.edges[&edge].clone(); + duplicate_route.id = duplicate; + routed.edges.insert(duplicate, duplicate_route); + let selected = edge.max(duplicate); // UUID ordering must not override selection. + let canvas = DiagramCanvas { + project: &p, + diagram, + selected, + zoom: 1., + pan: Vector::ZERO, + context: Context { + document: p.id, + diagram, + revision: 0, + epoch: 0, + }, + selected_bend: None, + editable: true, + }; + let span = &routed.edges[&edge].points; + let pick = point(oa_core::routing::point_along_polyline(span, 0.4).unwrap()); + assert_eq!(canvas.hit_edge(&routed, pick).unwrap().0, selected); + } } diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index 8ffaf1b7..9ab7910e 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -218,6 +218,10 @@ impl Draft { json!({"referenced": Id::nil(), "local": Id::nil()}) } else if path.ends_with("values") { json!({"field": Id::nil(), "expression": ""}) + } else if path.ends_with("routes") { + json!({"edge": Id::nil(), "waypoints": [], "label": null}) + } else if path.ends_with("waypoints") { + json!({"x": 0.0, "y": 0.0}) } else { json!(Id::nil()) }), @@ -382,6 +386,7 @@ fn reference(path: &str) -> bool { | "context" | "represented" | "exchange" + | "edge" | "sender" | "receiver" | "action" @@ -524,6 +529,14 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec { .map(Element::layer) .or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source()))); let field = path.rsplit('/').next().unwrap_or_default(); + if field == "edge" { + return project + .relations + .keys() + .chain(project.traces.keys()) + .copied() + .collect(); + } let list = path.split('/').rev().nth(1).unwrap_or_default(); let semantic = |id| { project @@ -1336,6 +1349,13 @@ fn property<'a>( .on_press(Message::FieldShape(path.into(), Value::Null)), ); } + if path.starts_with("/routes/") && path.ends_with("/label") { + out = out.push( + button(text("Automatic label position").size(11)) + .style(style::nav) + .on_press(Message::FieldShape(path.into(), Value::Null)), + ); + } for (key, child) in map { if key == "nodes" { continue; @@ -1414,6 +1434,8 @@ fn property<'a>( json!(1) } else if path.ends_with("baseline") { json!({"oa":{"name":"","description":""},"sa":{"name":"","description":""}}) + } else if path.starts_with("/routes/") && path.ends_with("/label") { + json!({"fraction":0.5,"offset":{"x":0.0,"y":-18.0}}) } else { json!("") }; @@ -1813,6 +1835,7 @@ mod tests { description: String::new(), layer: Layer::Oa, projection: ProjectionKind::Overview, + routes: Vec::new(), nodes: BTreeMap::new(), }, ); diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index c2df6cdb..3ea4499a 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -264,7 +264,7 @@ fn article(topic: Topic) -> Article { const { Article { summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.", - keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize", + keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset", sections: &[ explain( "Three things to distinguish", @@ -288,6 +288,14 @@ fn article(topic: Topic) -> Article { "Read allocation summaries and ports", "A dashed parent activity/function with an italic name and SUMMARY heading groups leaves assigned to the same performer. It has no direct allocation of its own; its inspector explains the leaf performer. Small ports belong to the boundary of their visible owner and are selectable model elements. Folding a container hides its inner work and ports; summary exchange lines do not invent new ports on the container.", ), + walkthrough( + "Route connections around the model", + &[ + "Select an architecture connection. Drag a line segment to add a pinned bend, or choose Add bend; drag its circles to shape a detour. Select a circle and choose Remove bend to release that pin. Drag the connection label independently. Escape cancels a gesture; Undo reverses one completed drag.", + "Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.", + "Reset routing clears the selected connection’s pins and label position in this view. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.", + ], + ), explain( "What nesting does not imply", "Containment in an allocation projection expresses a performer relationship. It is not a physical deployment statement. Physical deployment is a different architectural concern; do not infer hardware installation from an OA/SA box layout.", diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index e996cdc5..3fc2db17 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -8,6 +8,7 @@ mod highlight; mod navigation; mod port_preview; mod port_workflow; +mod route_edit; mod source_navigation; mod style; #[cfg(test)] diff --git a/crates/oa-desktop/src/port_workflow.rs b/crates/oa-desktop/src/port_workflow.rs index c59b23f8..8998652b 100644 --- a/crates/oa-desktop/src/port_workflow.rs +++ b/crates/oa-desktop/src/port_workflow.rs @@ -677,6 +677,7 @@ mod tests { description: String::new(), layer: Layer::Sa, projection: ProjectionKind::Overview, + routes: Vec::new(), nodes: Default::default(), }, ); diff --git a/crates/oa-desktop/src/route_edit.rs b/crates/oa-desktop/src/route_edit.rs new file mode 100644 index 00000000..689d87d1 --- /dev/null +++ b/crates/oa-desktop/src/route_edit.rs @@ -0,0 +1,108 @@ +//! Transient connection gestures. Only their final route enters model history. +use iced::Point; +use oa_core::{routing::*, *}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Context { + pub document: Id, + pub diagram: Id, + pub revision: u64, + pub epoch: u64, +} + +#[derive(Debug, Clone)] +pub enum Event { + SelectBend(Id, usize), + Commit(Context, EdgeRoute, Option), + AddBend, + RemoveBend, + Reset, +} + +#[derive(Debug, Clone)] +pub enum Handle { + Bend(usize), + Label { original: Position }, +} + +#[derive(Debug, Clone)] +pub struct Gesture { + pub context: Context, + pub start: Point, + pub handle: Handle, + pub route: EdgeRoute, + pub changed: bool, + pub baseline: std::sync::Arc, +} + +impl Gesture { + pub fn move_to(&mut self, point: Point, zoom: f32) { + let delta = point - self.start; + if !self.changed && (delta.x * delta.x + delta.y * delta.y) * zoom * zoom < 9. { + return; + } + self.changed = true; + match self.handle { + Handle::Bend(index) => { + self.route.waypoints[index] = Position { + x: (point.x / 5.).round() * 5., + y: (point.y / 5.).round() * 5., + } + } + Handle::Label { original } => { + if let Some(label) = &mut self.route.label { + label.offset = Position { + x: original.x + delta.x, + y: original.y + delta.y, + }; + } + } + } + } +} + +pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute { + diagram + .routes + .iter() + .find(|r| r.edge == edge) + .cloned() + .unwrap_or(EdgeRoute { + edge, + waypoints: Vec::new(), + label: None, + }) +} + +/// Insert after the pins preceding the clicked segment along the routed path. +pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize { + let segment = geometry + .points + .windows(2) + .enumerate() + .min_by(|(_, a), (_, b)| { + let distance = |span: &[Position]| { + project_onto_polyline(span, point).map_or(f32::INFINITY, |p| p.distance) + }; + distance(a).total_cmp(&distance(b)) + }) + .map_or(0, |(index, _)| index); + let index = pin_insertion_index(geometry, segment, point); + route.waypoints.insert(index, point); + index +} + +pub fn with_route(diagram: &mut Diagram, route: EdgeRoute) { + if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) { + *existing = route; + } else { + diagram.routes.push(route); + } +} + +pub fn point(p: Position) -> Point { + Point::new(p.x, p.y) +} +pub fn position(p: Point) -> Position { + Position { x: p.x, y: p.y } +} diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index 0d15e3ce..0182379f 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -1351,3 +1351,102 @@ fn nested_allocation_insertion_reflows_sibling_subtrees_and_preserves_fold_state } } } + +#[test] +fn routing_actions_are_atomic_guarded_and_fit_the_authored_detour() { + use crate::route_edit::Event as Route; + let mut p = Project::empty(); + let sender = p.add(Kind::Activity, "Report", 0., 0.); + let target = p.add(Kind::Activity, "Receive", 600., 0.); + let edge = p.relate(RelationKind::Interaction, "Report signal", sender, target); + let diagram = p.ensure_diagram(Layer::Oa); + let mut app = App::with_project(p.clone()); + send(&mut app, Message::Select(edge)); + send(&mut app, Message::Route(Route::AddBend)); + assert_eq!( + app.project().diagrams[&diagram].routes[0].waypoints.len(), + 1 + ); + let context = app.route_context().unwrap(); + let mut route = app.project().diagrams[&diagram].routes[0].clone(); + route.waypoints[0] = Position { x: 400., y: -700. }; + send( + &mut app, + Message::Route(Route::Commit(context, route.clone(), Some(0))), + ); + assert_eq!(app.project().diagrams[&diagram].routes[0], route); + send(&mut app, Message::Fit); + let pin = route.waypoints[0]; + assert!(pin.y * app.zoom + app.pan.y >= 0.); + send(&mut app, Message::Route(Route::RemoveBend)); + assert!( + app.project().diagrams[&diagram].routes[0] + .waypoints + .is_empty() + ); + send(&mut app, Message::Undo); + assert_eq!(app.project().diagrams[&diagram].routes[0], route); + let accepted = app.project().clone(); + send( + &mut app, + Message::Route(Route::Commit(context, route.clone(), Some(0))), + ); + assert_eq!( + app.project(), + &accepted, + "A stale gesture must not overwrite newer routing" + ); + let current = app.route_context().unwrap(); + send(&mut app, Message::Escape); + send( + &mut app, + Message::Route(Route::Commit(current, route.clone(), Some(0))), + ); + assert_eq!( + app.project(), + &accepted, + "Escape invalidates the gesture epoch" + ); + send(&mut app, Message::Route(Route::Reset)); + assert!(app.project().diagrams[&diagram].routes.is_empty()); + send(&mut app, Message::Undo); + assert_eq!(app.project(), &accepted); + let current = app.route_context().unwrap(); + source(&mut app, "invalid unsaved source"); + send( + &mut app, + Message::Route(Route::Commit(current, route, Some(0))), + ); + assert_eq!(app.project(), &accepted); + assert_eq!(app.source_editor.text(), "invalid unsaved source"); +} + +#[test] +fn adding_bend_between_close_ports_stays_outside_owner_clearance() { + let mut p = Project::empty(); + let diagram = p.ensure_diagram(Layer::Sa); + let a = p + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let b = p + .add_element(Kind::Function, Layer::Sa, "Receive", None) + .unwrap(); + let edge = p.relate(RelationKind::FunctionalExchange, "Status", a, b); + p.place(diagram, a, Position { x: 0., y: 100. }); + p.place(diagram, b, Position { x: 260., y: 100. }); + let mut app = App::with_project(p); + send(&mut app, Message::Select(edge)); + send(&mut app, Message::Route(crate::route_edit::Event::AddBend)); + let p = app.project(); + let view = projection::project_view(p, diagram); + let routed = routing::route_diagram(p, &p.diagrams[&diagram], &view); + assert!( + routed.edges[&edge].warnings.is_empty(), + "{:?}", + routed.edges[&edge].warnings + ); + assert_eq!( + p.diagrams[&diagram].routes[0].waypoints, + vec![Position { x: 242., y: 192. }] + ); +} diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index b9eaaeaa..2d0c5898 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -304,18 +304,22 @@ pub fn view(app: &App) -> View<'_, Message> { root, column![ text(title).size(21), - label( - "Use .arcadia for canonical source, .json for the model, or .svg for diagrams." - ), + label(match p.operation { + FileOperation::Export => "Save this diagram as .svg.", + _ => "Use .arcadia for canonical source or .json for the model.", + }), text_input("File path", &p.path) .on_input(Message::FilePath) .on_submit(Message::SubmitFile) .padding(10) .size(13), - text(if p.overwrite { - "This file exists. Replace it with the current model?" - } else { - "Your model includes both layers, diagrams and traceability." + text(match (p.operation, p.overwrite) { + (FileOperation::Export, true) => + "This file exists. Replace it with the current diagram?", + (FileOperation::Export, false) => + "Includes visible elements and their authored connection routes.", + (_, true) => "This file exists. Replace it with the current model?", + (_, false) => "Your model includes both layers, diagrams and traceability.", }) .size(12), row![ @@ -472,6 +476,12 @@ fn diagram(app: &App) -> View<'_, Message> { selected: app.selected, zoom: app.zoom, pan: app.pan, + context: app.route_context().unwrap(), + selected_bend: app + .selected_bend + .filter(|(edge, _)| *edge == app.selected) + .map(|(_, i)| i), + editable: !app.draft_dirty() && !app.source_dirty(), }) .width(Fill) .height(Fill), @@ -500,7 +510,53 @@ fn diagram(app: &App) -> View<'_, Message> { .center(Fill) .into() }; - column![container(column![toolbar,modes,label("Drag to arrange · scroll to zoom · nesting expresses parent or allocation · select for properties")].spacing(8)).padding(14),canvas].into() + let mut tools = column![toolbar, modes].spacing(8); + if let Some(diagram) = app.active_diagram { + let view = oa_core::projection::project_view(app.project(), diagram); + let edge = view.edges.iter().find(|edge| edge.id == app.selected); + let promoted = edge.is_some_and(|edge| { + oa_core::projection::edge_endpoint_placements( + app.project(), + &app.project().diagrams[&diagram], + edge.id, + ) + .is_some_and(|(source, target)| source != edge.source || target != edge.target) + }); + let enabled = edge.is_some() && !promoted && !app.draft_dirty() && !app.source_dirty(); + let route = app.project().diagrams[&diagram] + .routes + .iter() + .find(|r| r.edge == app.selected); + let bend = app.selected_bend.filter(|(edge, index)| { + *edge == app.selected && route.is_some_and(|r| *index < r.waypoints.len()) + }); + // Keep canvas origin/size stable when switching between a node and edge. + tools = tools.push( + row![ + button("Add bend").padding(8).on_press_maybe( + (enabled && route.is_none_or(|r| r.waypoints.len() < 128)) + .then_some(Message::Route(crate::route_edit::Event::AddBend)) + ), + button("Remove bend").padding(8).on_press_maybe( + (enabled && bend.is_some()) + .then_some(Message::Route(crate::route_edit::Event::RemoveBend)) + ), + button("Reset routing").padding(8).on_press_maybe( + (enabled && route.is_some()) + .then_some(Message::Route(crate::route_edit::Event::Reset)) + ), + ] + .spacing(7), + ); + tools = tools.push(label(if promoted { + "Expand endpoint containers to edit this connection route" + } else if edge.is_some() { + "Drag a line to add a bend · drag circles or the label · Esc cancels" + } else { + "Drag to arrange · select a connection to edit its route · scroll to zoom" + })); + } + column![container(tools).padding(14), canvas].into() } fn inspector(app: &App) -> View<'_, Message> { diff --git a/harness/DECISIONS.md b/harness/DECISIONS.md index 152aa6e9..6f718c6b 100644 --- a/harness/DECISIONS.md +++ b/harness/DECISIONS.md @@ -26,3 +26,6 @@ - Git is now explicitly authorized by the user. Establish a source baseline on codex/release1-engineering, then use focused commits at verified milestones. Use Git worktrees for independent writers when their scope can be partitioned; root integrates and owns native UI. Keep target/dist, recovery/store files and credentials outside version control. No remote publication is implied. - User's hierarchy/port correction supersedes generic graph defaults. Architecture/dataflow/capability content differs by engineering purpose, while containment/allocation and port anchors remain derived from semantic records. The exact correction and remaining port metamodel work are in release-diagram-port-correction.md. - Adopt `release-port-contract.md`: function input/output ports declare items; Standard/Flow component ports have separate orientation, interface roles and explicit function-port allocations. Required functional endpoints are generated/reused by one atomic planner. Schema3 migration preserves original generic ports as explicit unresolved contracts and generates only previously missing FE endpoints. This avoids guessing component kind, interface role or splitting stable InOut identities during import. + +- Source/schema3 is now implemented. The original V2 comment-normalization decision is superseded for the first-release source-project work: validated bundle updates preserve concrete tokens/comments/whitespace, while explicit canonical export remains available. Desktop bundle editing and experimental nested syntax are separate acceptance gates; neither is implied by the pure-core updater. +- Connection routing is per diagram, keyed by an existing relationship/trace ID. Store ordered pinned points and relative label placement; derive orthogonal elbows in a pure Rust core shared by canvas, picking, Fit and SVG. Typed port anchors never move to improve a route. Bounded automatic routing provides an initial path; manual pins express engineering layout intent. Collapse temporarily suspends authored routing when endpoints are promoted, preserving it for expansion. One pointer gesture commits one atomic command; stale/invalid-draft gestures are rejected.