diff --git a/crates/oa-core/examples/attachment_snapshot.rs b/crates/oa-core/examples/attachment_snapshot.rs new file mode 100644 index 00000000..63d8326f --- /dev/null +++ b/crates/oa-core/examples/attachment_snapshot.rs @@ -0,0 +1,27 @@ +//! Read and validate a saved acceptance fixture without rewriting its source. +//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json +use oa_core::{persistence, validate_candidate}; +use std::{fs::OpenOptions, io::Write, path::Path}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.len() != 2 { + return Err("Expected input source/JSON and a new output JSON path".into()); + } + let project = persistence::load(Path::new(&args[0]))?; + validate_candidate(&project)?; + let bytes = serde_json::to_vec_pretty(&project)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&args[1])?; + output.write_all(&bytes)?; + output.write_all(b"\n")?; + output.sync_all()?; + println!( + "Validated {} objects and {} views; saved a complete Project snapshot", + project.identities().len(), + project.diagrams.len() + ); + Ok(()) +} diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index 041639a6..94c47eb1 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -1502,11 +1502,45 @@ pub struct Diagram { #[serde(deny_unknown_fields)] pub struct EdgeRoute { pub edge: Id, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_attachment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_attachment: Option, #[serde(default)] pub waypoints: Vec, #[serde(default)] pub label: Option, } + +/// Presentation preference on one end of a connection. Real ports take precedence. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EdgeAttachment { + pub side: PortAnchorSide, + pub offset: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeEnd { + Source, + Target, +} + +impl EdgeRoute { + pub fn attachment(&self, end: EdgeEnd) -> Option { + match end { + EdgeEnd::Source => self.source_attachment, + EdgeEnd::Target => self.target_attachment, + } + } + + pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option) { + match end { + EdgeEnd::Source => self.source_attachment = attachment, + EdgeEnd::Target => self.target_attachment = attachment, + } + } +} #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct EdgeLabel { diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs index 16d9dd01..83ae53fd 100644 --- a/crates/oa-core/src/routing.rs +++ b/crates/oa-core/src/routing.rs @@ -1,7 +1,12 @@ //! Deterministic, bounded diagram geometry. Only authored pins belong in the model; //! callers share this derived geometry for drawing, picking, fitting and export. use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView}; -use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size}; +use crate::{ + Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation, + RelationBody, RelationKind, Size, +}; +mod attachments; +pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint}; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; use std::fmt; @@ -41,6 +46,7 @@ pub struct RoutedEdge { // Derived solver state, never persisted. A preview must inherit the same // remaining work budget as this edge had in stable-ID settled traversal. search_budget_before: usize, + attachments: [Option; 2], } #[derive(Debug, Clone, PartialEq)] @@ -173,6 +179,7 @@ pub fn association_end_geometry( #[derive(Debug, Clone, PartialEq, Eq)] pub enum RouteWarning { + CoincidentAttachments, PinInsideObstacle { pin: usize, obstacle: Id }, NoClearPath, SearchBudgetExhausted, @@ -181,6 +188,9 @@ pub enum RouteWarning { impl fmt::Display for RouteWarning { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::CoincidentAttachments => f.write_str( + "The two attachments coincide; move either end to separate the loop tips.", + ), Self::PinInsideObstacle { pin, .. } => { write!(f, "Bend {} is inside a card or its clearance", pin + 1) } @@ -374,7 +384,14 @@ struct Endpoint { point: Position, stub: Position, } -fn endpoints(node: &ProjectedNode, port: Option, view: &ProjectedView) -> Vec { +fn endpoints( + project: &Project, + diagram: &Diagram, + node: &ProjectedNode, + port: Option, + view: &ProjectedView, + attachment: Option, +) -> Vec { if let Some(port) = port.and_then(|id| { view.ports .iter() @@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option, view: &ProjectedView) -> Ve ), }]; } - let r = Rect::node(node); - let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0); - vec![ - Endpoint { - point: pos(r.left, mid.y), - stub: pos(r.left - PORT_STUB, mid.y), - }, - Endpoint { - point: pos(r.right, mid.y), - stub: pos(r.right + PORT_STUB, mid.y), - }, - Endpoint { - point: pos(mid.x, r.top), - stub: pos(mid.x, r.top - PORT_STUB), - }, - Endpoint { - point: pos(mid.x, r.bottom), - stub: pos(mid.x, r.bottom + PORT_STUB), - }, + if let Some(attachment) = attachment { + return vec![attachments::endpoint(project, diagram, node, attachment)]; + } + [ + PortAnchorSide::Left, + PortAnchorSide::Right, + PortAnchorSide::Top, + PortAnchorSide::Bottom, ] + .into_iter() + .map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 })) + .collect() } fn ancestors(id: Id, nodes: &BTreeMap, out: &mut BTreeSet) { @@ -507,6 +516,12 @@ fn route_diagram_impl( .is_some_and(|(source, target)| source != edge.source || target != edge.target); let custom_suspended = promoted; let route = if promoted { None } else { stored }; + let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| { + semantic_port_endpoint(project, edge.id, end) + .is_none() + .then(|| route.and_then(|r| r.attachment(end))) + .flatten() + }); let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone()); let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers); @@ -517,7 +532,10 @@ fn route_diagram_impl( }); if let Some(cached) = cached { budget = cached.search_budget_before; - if cached.pins == pins && cached.custom_suspended == custom_suspended { + if cached.pins == pins + && cached.attachments == attachments + && cached.custom_suspended == custom_suspended + { // Label-only editing cannot change the connection beneath the // pointer, including a settled fallback with routing warnings. for &point in &cached.points { @@ -543,9 +561,22 @@ fn route_diagram_impl( } let (points, pin_lengths, failure) = route_edge( edge, - source, - target, - view, + &endpoints( + project, + diagram, + source, + edge.source_port, + view, + attachments[0], + ), + &endpoints( + project, + diagram, + target, + edge.target_port, + view, + attachments[1], + ), &pins, &obstacles, &previous, @@ -574,6 +605,7 @@ fn route_diagram_impl( warnings, custom_suspended, search_budget_before, + attachments, }, ); } @@ -679,6 +711,46 @@ struct RoutingSpace<'a> { xs: Vec, ys: Vec, } + +fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] { + let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() }; + let normal = pos( + direction(a.stub.x - a.point.x), + direction(a.stub.y - a.point.y), + ); + let tangent = pos(-normal.y, normal.x); + let candidates: Vec<_> = [36., 72., 144., 288.] + .into_iter() + .flat_map(|extent| { + [-1., 1.].into_iter().map(move |side| { + [ + pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent), + pos( + a.stub.x + (normal.x + tangent.x * side) * extent, + a.stub.y + (normal.y + tangent.y * side) * extent, + ), + pos( + a.stub.x + tangent.x * side * extent, + a.stub.y + tangent.y * side * extent, + ), + ] + }) + }) + .collect(); + // These are computed loop guards, not authored hard pins. Prefer a clear + // orientation/size before asking the bounded router to connect them. + candidates + .iter() + .find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles)) + .or_else(|| { + candidates.iter().find(|p| { + p.iter() + .all(|point| !obstacles.iter().any(|r| r.contains(*point))) + }) + }) + .copied() + .unwrap_or(candidates[0]) +} impl<'a> RoutingSpace<'a> { fn new(obstacles: &'a [Rect]) -> Self { Self { @@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> { #[allow(clippy::too_many_arguments)] fn route_edge( edge: &ProjectedEdge, - source: &ProjectedNode, - target: &ProjectedNode, - view: &ProjectedView, + source_ends: &[Endpoint], + target_ends: &[Endpoint], pins: &[Position], obstacles: &[Rect], previous: &[Segment], budget: &mut usize, ) -> EdgePath { + // Coincident authored tips are valid presentation, including on self loops. + // Preserve them and add an external detour; never index an empty candidate + // list or turn the connection into an out-and-back degenerate stub. + let coincident = edge.source == edge.target + && source_ends.len() == 1 + && target_ends.len() == 1 + && source_ends[0].point == target_ends[0].point + && source_ends[0].stub == target_ends[0].stub; + let authored_pin_count = pins.len(); + let mut loop_pins = Vec::new(); + if coincident { + let a = source_ends[0]; + loop_pins.extend_from_slice(pins); + loop_pins.extend(coincident_detour(a, obstacles)); + } + let pins = if coincident { &loop_pins[..] } else { pins }; let space = RoutingSpace::new(obstacles); let mut candidates = Vec::new(); - for a in endpoints(source, edge.source_port, view) { - for b in endpoints(target, edge.target_port, view) { + for &a in source_ends { + for &b in target_ends { // A real self-relation must leave and return at distinct anchors. // Reusing one anchor makes the cheapest path a degenerate stub. if edge.source == edge.target && edge.source_port.is_none() && edge.target_port.is_none() && a.point == b.point + && a.stub == b.stub + && !coincident { continue; } @@ -729,10 +818,9 @@ fn route_edge( // Try all side combinations cheaply before spending the bounded grid budget. for search in [false, true] { for &(estimate, a, b) in &candidates { - if best - .as_ref() - .is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost) - { + if best.as_ref().is_some_and(|(cost, _, _)| { + estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost + }) { continue; } if obstacles @@ -748,7 +836,7 @@ fn route_edge( let mut occupied = previous.to_vec(); record_segments(&mut occupied, &points); let mut pin_lengths = Vec::new(); - let mut distance = PORT_STUB; + let mut distance = manhattan(a.point, a.stub); let mut start = a.stub; let mut failed = false; for (index, end) in pins @@ -792,15 +880,20 @@ fn route_edge( break; } } - if let Some((_, points, pin_lengths)) = best { - return (points, pin_lengths, None); + if let Some((_, points, mut pin_lengths)) = best { + pin_lengths.truncate(authored_pin_count); + return ( + points, + pin_lengths, + coincident.then_some(RouteWarning::CoincidentAttachments), + ); } // Recoverable fallback retains every hard pin, in order, and the actual port // leads. Its warning is deliberately visible to consumers. let (_, a, b) = candidates[0]; let mut points = vec![a.point, a.stub]; let mut pin_lengths = Vec::new(); - let mut distance = PORT_STUB; + let mut distance = manhattan(a.point, a.stub); let mut start = a.stub; for (index, end) in pins .iter() @@ -833,6 +926,7 @@ fn route_edge( start = end; } points.push(b.point); + pin_lengths.truncate(authored_pin_count); ( points, pin_lengths, diff --git a/crates/oa-core/src/routing/attachments.rs b/crates/oa-core/src/routing/attachments.rs new file mode 100644 index 00000000..11b4c650 --- /dev/null +++ b/crates/oa-core/src/routing/attachments.rs @@ -0,0 +1,149 @@ +//! One boundary definition shared by persisted routing and attachment gestures. +use super::*; +use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide}; + +/// Semantic authority, independent of whether projection currently exposes a port. +pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option { + let endpoint = if let Some(relation) = project.relations.get(&edge) { + match (&relation.body, end) { + (RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port, + (RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port, + (_, EdgeEnd::Source) => relation.source(), + (_, EdgeEnd::Target) => relation.target(), + } + } else { + let trace = project.traces.get(&edge)?; + match end { + EdgeEnd::Source => trace.oa, + EdgeEnd::Target => trace.sa, + } + }; + project + .elements + .get(&endpoint) + .filter(|e| e.kind() == Kind::Port) + .map(|_| endpoint) +} + +/// Intersect the chosen cardinal scan line with the painted shape. Offset is +/// measured over the complete projected bounding side, including its corners. +/// Terminate's X uses its outermost stroke intersection rather than a contour. +pub fn attachment_point( + project: &Project, + diagram: &Diagram, + node: &ProjectedNode, + attachment: &EdgeAttachment, +) -> Position { + let vertical = matches!( + attachment.side, + PortAnchorSide::Left | PortAnchorSide::Right + ); + let (extent, depth) = if vertical { + (node.size.height, node.size.width) + } else { + (node.size.width, node.size.height) + }; + let along = attachment.offset.clamp(0., 1.) * extent; + let t = 2. * attachment.offset.clamp(0., 1.) - 1.; + let kind = diagram + .projection + .state_machine() + .and_then(|_| project.elements.get(&node.element)) + .map(|e| e.kind()); + let inset = match kind { + Some(Kind::Choice) => depth * 0.5 * t.abs(), + Some(Kind::Terminate) => { + depth + * attachment + .offset + .clamp(0., 1.) + .min(1. - attachment.offset.clamp(0., 1.)) + } + Some( + Kind::Initial + | Kind::Junction + | Kind::Final + | Kind::ShallowHistory + | Kind::DeepHistory + | Kind::EntryPoint + | Kind::ExitPoint, + ) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()), + Some(Kind::Region | Kind::Fork | Kind::Join) => 0., + _ => { + let radius = 10_f32.min(extent / 2.).min(depth / 2.); + let corner = (radius - along.min(extent - along)).max(0.); + radius - (radius * radius - corner * corner).max(0.).sqrt() + } + }; + match attachment.side { + PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along), + PortAnchorSide::Right => pos( + node.position.x + node.size.width - inset, + node.position.y + along, + ), + PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset), + PortAnchorSide::Bottom => pos( + node.position.x + along, + node.position.y + node.size.height - inset, + ), + } +} + +/// Choose a cardinal bounding side, then map its fraction onto the painted +/// contour. Contour distances cannot distinguish the sides of a circle at its +/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions. +/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping +/// rounds to hundredths: source/API values retain their complete precision. +pub fn attachment_from_point( + node: &ProjectedNode, + cursor: Position, + previous: Option, + hysteresis: f32, +) -> EdgeAttachment { + let candidate = |side| EdgeAttachment { + side, + offset: projection::port_anchor_offset(node, side, cursor), + }; + let distance = |side| { + length( + projection::port_anchor_position(node, side, candidate(side).offset), + cursor, + ) + }; + let mut side = [ + PortAnchorSide::Left, + PortAnchorSide::Right, + PortAnchorSide::Top, + PortAnchorSide::Bottom, + ] + .into_iter() + .min_by(|a, b| distance(*a).total_cmp(&distance(*b))) + .unwrap(); + let hysteresis = hysteresis + .max(0.) + .min(node.size.width.min(node.size.height) * 0.2); + if let Some(old) = previous + && distance(old) <= distance(side) + hysteresis + { + side = old; + } + let mut attachment = candidate(side); + attachment.offset = (attachment.offset * 100.).round() / 100.; + attachment +} + +pub(super) fn endpoint( + project: &Project, + diagram: &Diagram, + node: &ProjectedNode, + attachment: EdgeAttachment, +) -> Endpoint { + let point = attachment_point(project, diagram, node, &attachment); + let stub = match attachment.side { + PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y), + PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y), + PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB), + PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB), + }; + Endpoint { point, stub } +} diff --git a/crates/oa-core/src/source_defaults.rs b/crates/oa-core/src/source_defaults.rs index f9a4cf95..fe4b5f5c 100644 --- a/crates/oa-core/src/source_defaults.rs +++ b/crates/oa-core/src/source_defaults.rs @@ -93,6 +93,14 @@ fn restore_serialization_skips(context: Context, fields: &mut Map .or_insert(Value::Null); } } + Context::Diagram => { + if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) { + for route in routes.iter_mut().filter_map(Value::as_object_mut) { + route.entry("source_attachment".to_owned()).or_insert(Value::Null); + route.entry("target_attachment".to_owned()).or_insert(Value::Null); + } + } + } Context::Node => { fields.entry("port_anchors".to_owned()).or_insert(json!([])); } @@ -244,7 +252,7 @@ fn compact_nested(context: Context, fields: &mut Map) { if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) { for route in routes { if let Some(route) = route.as_object_mut() { - omit_defaults(route, &object(json!({"waypoints": [], "label": null}))); + omit_defaults(route, &object(json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}))); } } } diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index 3a121067..7a619def 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -643,6 +643,17 @@ pub fn validate(p: &Project) -> Vec { } let mut routed_edges = BTreeSet::new(); for (index, route) in d.routes.iter().enumerate() { + for attachment in [route.source_attachment, route.target_attachment].into_iter().flatten() { + if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) { + error( + &mut out, + d.id, + "invalid_attachment_offset", + "routes", + "Connection attachment offset must be finite and between zero and one.", + ); + } + } if !routed_edges.insert(route.edge) { error( &mut out, diff --git a/crates/oa-core/tests/attachment_geometry.rs b/crates/oa-core/tests/attachment_geometry.rs new file mode 100644 index 00000000..42974a84 --- /dev/null +++ b/crates/oa-core/tests/attachment_geometry.rs @@ -0,0 +1,385 @@ +use oa_core::{projection::*, routing::*, *}; + +fn pos(x: f32, y: f32) -> Position { + Position { x, y } +} +fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment { + EdgeAttachment { side, offset } +} +fn fixture() -> (Project, Id, Id, Id, Id) { + let mut p = Project::empty(); + let a = p + .add_element(Kind::Activity, Layer::Oa, "Report", None) + .unwrap(); + let b = p + .add_element(Kind::Activity, Layer::Oa, "Receive", None) + .unwrap(); + let edge = p.relate(RelationKind::Interaction, "Report status", a, b); + let d = p.ensure_diagram(Layer::Oa); + p.place(d, a, pos(50., 50.)); + p.place(d, b, pos(750., 200.)); + (p, d, a, b, edge) +} +fn stored(edge: Id) -> EdgeRoute { + EdgeRoute { + edge, + source_attachment: None, + target_attachment: None, + waypoints: vec![], + label: None, + } +} +fn routed(p: &Project, d: Id) -> RoutedView { + let diagram = &p.diagrams[&d]; + route_diagram(p, diagram, &project_diagram(p, diagram)) +} +fn near(a: f32, b: f32) { + assert!((a - b).abs() < 0.002, "{a} != {b}"); +} +fn orthogonal(points: &[Position]) { + assert!(points.len() >= 2); + for span in points.windows(2) { + assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}"); + } +} +fn length(points: &[Position]) -> f32 { + points + .windows(2) + .map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs()) + .sum() +} + +#[test] +fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() { + let (mut p, d, a, _, edge) = fixture(); + let initial = routed(&p, d); + let mut route = stored(edge); + route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25)); + route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75)); + route.waypoints = vec![pos(460., 350.)]; + p.diagrams.get_mut(&d).unwrap().routes.push(route.clone()); + let view = project_diagram(&p, &p.diagrams[&d]); + let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge); + let full = routed(&p, d); + assert_eq!( + preview, full, + "attachment-only changes must invalidate the label-only path cache" + ); + let n = view.nodes.iter().find(|n| n.element == a).unwrap(); + assert_eq!( + full.edges[&edge].points[0], + pos( + n.position.x + n.size.width * 0.25, + n.position.y + n.size.height + ) + ); + assert_eq!(full.edges[&edge].pins, route.waypoints); + orthogonal(&full.edges[&edge].points); + let node = p + .diagrams + .get_mut(&d) + .unwrap() + .nodes + .values_mut() + .find(|n| n.element == a) + .unwrap(); + node.position.x += 75.; + node.size.width += 100.; + node.size.height += 50.; + let moved = routed(&p, d); + near( + moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x, + 100., + ); + near( + moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y, + 50., + ); + assert_eq!(p.diagrams[&d].routes[0], route); +} + +#[test] +fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() { + let (mut p, d, _, _, edge) = fixture(); + let baseline = routed(&p, d); + let mut route = stored(edge); + route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2)); + p.diagrams.get_mut(&d).unwrap().routes.push(route); + let view = project_diagram(&p, &p.diagrams[&d]); + let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge); + assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points); + assert_eq!(preview, routed(&p, d)); +} + +#[test] +fn ports_remain_authoritative_even_when_projection_filters_the_port() { + let mut p = Project::empty(); + 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, "Data", a, b); + let d = p.ensure_diagram(Layer::Sa); + p.place(d, a, pos(0., 0.)); + p.place(d, b, pos(600., 0.)); + let diagram = p.diagrams[&d].clone(); + let view = project_diagram(&p, &diagram); + let expected = route_diagram(&p, &diagram, &view); + assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some()); + let mut route = stored(edge); + route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1)); + route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9)); + p.diagrams.get_mut(&d).unwrap().routes.push(route); + assert_eq!(routed(&p, d), expected); + let mut hidden = view.clone(); + hidden.ports.clear(); + for e in &mut hidden.edges { + e.source_port = None; + e.target_port = None; + } + let absent = route_diagram(&p, &diagram, &hidden); + assert_eq!( + route_diagram(&p, &p.diagrams[&d], &hidden), + absent, + "hidden ports must not activate a dormant node preference" + ); +} + +fn state(kind: Kind) -> (Project, Id, Id, Id) { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::Entity, Layer::Oa, "Crew", None) + .unwrap(); + let machine = p + .add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner)) + .unwrap(); + let region = p + .add_element(Kind::Region, Layer::Oa, "Main", Some(machine)) + .unwrap(); + let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap(); + let b = p + .add_element(Kind::State, Layer::Oa, "B", Some(region)) + .unwrap(); + let edge = p.relate(RelationKind::StateTransition, "Next", a, b); + let d = p.ensure_diagram(Layer::Oa); + p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine }; + p.place(d, a, pos(100., 100.)); + p.place(d, b, pos(600., 100.)); + (p, d, a, edge) +} + +#[test] +fn nonrectangular_state_attachments_intersect_the_painted_shape() { + for kind in [ + Kind::Initial, + Kind::Junction, + Kind::Final, + Kind::Choice, + Kind::ShallowHistory, + Kind::DeepHistory, + Kind::EntryPoint, + Kind::ExitPoint, + Kind::Terminate, + Kind::Fork, + Kind::Join, + Kind::State, + ] { + let (p, d, a, _) = state(kind); + let diagram = &p.diagrams[&d]; + let view = project_diagram(&p, diagram); + let n = view.nodes.iter().find(|n| n.element == a).unwrap(); + for side in [ + PortAnchorSide::Left, + PortAnchorSide::Right, + PortAnchorSide::Top, + PortAnchorSide::Bottom, + ] { + for offset in [0., 0.2, 0.5, 0.8, 1.] { + let at = attachment_point(&p, diagram, n, &anchor(side, offset)); + let x = (at.x - n.position.x) / n.size.width; + let y = (at.y - n.position.y) / n.size.height; + match kind { + Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.), + Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.), + Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.), + Kind::State => { + assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y)); + if offset == 0. || offset == 1. { + assert!( + x != 0. && x != 1. || y != 0. && y != 1., + "rounded corner cannot use bounding-box corner" + ); + } + } + _ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.), + } + } + } + } +} + +#[test] +fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() { + let (mut p, d, _, edge) = state(Kind::Choice); + let mut route = stored(edge); + route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.)); + route.waypoints = vec![pos(380., 40.)]; + route.label = Some(EdgeLabel { + fraction: 0.3, + offset: pos(0., 0.), + }); + p.diagrams.get_mut(&d).unwrap().routes.push(route); + let result = routed(&p, d); + let r = &result.edges[&edge]; + orthogonal(&r.points); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let pin = r.pins[0]; + let pin_index = r.points.iter().position(|p| *p == pin).unwrap(); + near( + r.pin_fractions[0], + length(&r.points[..=pin_index]) / length(&r.points), + ); + assert_eq!( + r.label.anchor, + point_along_polyline(&r.points, 0.3).unwrap() + ); +} + +#[test] +fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() { + for kind in [ + Kind::Initial, + Kind::Choice, + Kind::Terminate, + Kind::Fork, + Kind::State, + ] { + let (p, d, a, _) = state(kind); + let view = project_diagram(&p, &p.diagrams[&d]); + let n = view.nodes.iter().find(|n| n.element == a).unwrap(); + for zoom in [0.5, 1., 2.] { + for overshoot in [0., 20.] { + let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot); + let chosen = + attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom); + assert_eq!( + chosen, + anchor(PortAnchorSide::Top, 0.5), + "{kind:?}, zoom {zoom}" + ); + let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen); + near(painted.x, n.position.x + n.size.width / 2.); + near( + painted.y, + n.position.y + + if kind == Kind::Terminate { + n.size.height / 2. + } else { + 0. + }, + ); + } + let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.); + let stable = + attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom); + assert_eq!( + stable.side, + PortAnchorSide::Right, + "corner hysteresis stays stable" + ); + } + } +} + +#[test] +fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() { + let (mut p, d, a, edge) = state(Kind::State); + if let RelationBody::StateTransition { target, .. } = + &mut p.relations.get_mut(&edge).unwrap().body + { + *target = a; + } + for pins in [vec![], vec![pos(400., 30.)]] { + let mut route = stored(edge); + route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5)); + route.target_attachment = route.source_attachment; + route.waypoints = pins.clone(); + p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()]; + let result = routed(&p, d); + let r = &result.edges[&edge]; + orthogonal(&r.points); + assert_eq!(r.points.first(), r.points.last()); + assert!(length(&r.points) > 2. * PORT_STUB + 70.); + assert_eq!(r.pins, pins); + assert_eq!(r.pin_fractions.len(), pins.len()); + assert!( + r.warnings.contains(&RouteWarning::CoincidentAttachments), + "{:?}", + r.warnings + ); + assert_eq!(routed(&p, d), result, "deterministic fallback"); + assert_eq!(p.diagrams[&d].routes[0], route); + } +} + +#[test] +fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() { + for blocker_side in [-1., 1.] { + let (mut p, d, a, edge) = state(Kind::State); + if let RelationBody::StateTransition { target, .. } = + &mut p.relations.get_mut(&edge).unwrap().body + { + *target = a; + } + let mut route = stored(edge); + route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5)); + route.target_attachment = route.source_attachment; + p.diagrams.get_mut(&d).unwrap().routes.push(route); + let view = project_diagram(&p, &p.diagrams[&d]); + let source = view.nodes.iter().find(|n| n.element == a).unwrap(); + let tip = attachment_point( + &p, + &p.diagrams[&d], + source, + &anchor(PortAnchorSide::Top, 0.5), + ); + let region = p.elements[&a].parent; + let blocker = p + .add_element(Kind::State, Layer::Oa, "Detour obstruction", region) + .unwrap(); + let left = if blocker_side > 0. { + tip.x + 30. + } else { + tip.x - 30. - 224. + }; + p.place(d, blocker, pos(left, tip.y - 144.)); + let result = routed(&p, d); + let r = &result.edges[&edge]; + assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]); + orthogonal(&r.points); + let view = project_diagram(&p, &p.diagrams[&d]); + let n = view.nodes.iter().find(|n| n.element == blocker).unwrap(); + for segment in r.points.windows(2) { + for step in 0..=100 { + let t = step as f32 / 100.; + let q = pos( + segment[0].x + (segment[1].x - segment[0].x) * t, + segment[0].y + (segment[1].y - segment[0].y) * t, + ); + assert!( + !(q.x > n.position.x - CLEARANCE + && q.x < n.position.x + n.size.width + CLEARANCE + && q.y > n.position.y - CLEARANCE + && q.y < n.position.y + n.size.height + CLEARANCE), + "route crosses blocker: {q:?}" + ); + } + } + assert!( + r.pins.is_empty(), + "generated detours must never become authored pins" + ); + } +} diff --git a/crates/oa-core/tests/attachment_lifecycle.rs b/crates/oa-core/tests/attachment_lifecycle.rs new file mode 100644 index 00000000..acd68dc1 --- /dev/null +++ b/crates/oa-core/tests/attachment_lifecycle.rs @@ -0,0 +1,272 @@ +use oa_core::{language, projection::*, routing::*, *}; + +fn pos(x: f32, y: f32) -> Position { + Position { x, y } +} + +fn id(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} + +fn authored(edge: Id) -> EdgeRoute { + EdgeRoute { + edge, + source_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Bottom, + offset: 0.2, + }), + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.8, + }), + waypoints: vec![pos(520., 420.)], + label: Some(EdgeLabel { + fraction: 0.35, + offset: pos(17., -23.), + }), + } +} + +fn routed(project: &Project, diagram: Id) -> RoutedView { + let d = &project.diagrams[&diagram]; + route_diagram(project, d, &project_diagram(project, d)) +} + +fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) { + let mut p = language::parse( + r#"arcadia 7; +project lifecycle "Attachment lifecycle" { + oa_activity group "Group" { oa_activity first "First" {} } + oa_activity second "Second" {} + oa_activity replacement "Replacement" {} + interaction signal "Signal" { source @first; target @second; } + diagram view "View" {} + diagram other "Other" {} +}"#, + ) + .unwrap(); + let (d, other, group, first, second, replacement, edge) = ( + id(&p, "view"), + id(&p, "other"), + id(&p, "group"), + id(&p, "first"), + id(&p, "second"), + id(&p, "replacement"), + id(&p, "signal"), + ); + for diagram in [d, other] { + p.place(diagram, group, pos(20., 20.)); + p.place(diagram, first, pos(100., 130.)); + p.place(diagram, second, pos(750., 150.)); + p.place(diagram, replacement, pos(750., 570.)); + p.diagrams + .get_mut(&diagram) + .unwrap() + .routes + .push(authored(edge)); + } + (p, d, other, group, first, replacement, edge) +} + +#[test] +fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() { + let (mut p, d, other, group, _, _, edge) = fixture(); + assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None); + assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None); + let before = p.clone(); + let expanded = routed(&p, d); + let other_geometry = routed(&p, other); + let parent = p.diagrams[&d] + .nodes + .values() + .find(|n| n.element == group) + .unwrap() + .id; + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&parent) + .unwrap() + .collapsed = true; + let folded = routed(&p, d); + assert!(folded.edges[&edge].custom_suspended); + assert_eq!(folded.edges[&edge].pins, []); + assert!( + folded.edges[&edge] + .warnings + .contains(&RouteWarning::PromotedEndpoints) + ); + assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes); + let mut automatic = p.clone(); + automatic.diagrams.get_mut(&d).unwrap().routes.clear(); + assert_eq!( + folded, + routed(&automatic, d), + "attachments, pins and label must all be dormant on promotion" + ); + assert_eq!(p.diagrams[&other], before.diagrams[&other]); + assert_eq!(routed(&p, other), other_geometry); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&parent) + .unwrap() + .collapsed = false; + assert_eq!(p, before); + assert_eq!(routed(&p, d), expanded); +} + +#[test] +fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() { + let (p, d, other, _, _, replacement, edge) = fixture(); + let before = routed(&p, d); + let mut relation = p.relations[&edge].clone(); + let RelationBody::Interaction { target, .. } = &mut relation.body else { + unreachable!() + }; + *target = replacement; + let mut expected = p.clone(); + expected.relations.insert(edge, relation.clone()); + let mut session = Session::new(p.clone()); + session + .execute(Command::EditRelation(edge, relation)) + .unwrap(); + assert_eq!( + session.project(), + &expected, + "reconnect changes only the semantic endpoint; all per-view records remain exact" + ); + assert_eq!(session.project().diagrams[&other], p.diagrams[&other]); + for diagram in [d, other] { + let current = session.project(); + let view = project_diagram(current, ¤t.diagrams[&diagram]); + let node = view + .nodes + .iter() + .find(|n| n.element == replacement) + .unwrap(); + let preference = current.diagrams[&diagram].routes[0] + .target_attachment + .unwrap(); + let expected_tip = + attachment_point(current, ¤t.diagrams[&diagram], node, &preference); + let result = routed(current, diagram); + assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip)); + assert_eq!( + result.edges[&edge].pins, + p.diagrams[&diagram].routes[0].waypoints + ); + } + assert_ne!( + routed(session.project(), d).edges[&edge].points.last(), + before.edges[&edge].points.last() + ); + session.undo(); + assert_eq!(session.project(), &p); + assert_eq!(routed(session.project(), d), before); + session.redo(); + assert_eq!(session.project(), &expected); +} + +#[test] +fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() { + let mut p = Project::empty(); + let system = p + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let actor = p + .add_element(Kind::SystemActor, Layer::Sa, "Actor", None) + .unwrap(); + let function = p + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let mut ports = Vec::new(); + for (owner, name, contract) in [ + ( + system, + "Output", + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::Out, + }, + ), + ( + actor, + "Input", + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::In, + }, + ), + ( + function, + "Function output", + PortContract::FunctionOutput { + outgoing_items: vec![], + }, + ), + ] { + let port = Id::new_v4(); + p.elements + .insert(port, make_port(&p, port, owner, name, contract).unwrap()); + ports.push(port); + } + p.relate( + RelationKind::Allocation, + "Function allocation", + system, + function, + ); + let boundary = p.relate( + RelationKind::BoundaryExchange, + "Boundary", + ports[0], + ports[1], + ); + let allocation = p.relate( + RelationKind::PortAllocation, + "Port allocation", + ports[0], + ports[2], + ); + let d = p.ensure_diagram(Layer::Sa); + p.place(d, system, pos(0., 0.)); + p.place(d, actor, pos(700., 0.)); + p.place(d, function, pos(0., 450.)); + let original = p.clone(); + let baseline = routed(&p, d); + for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] { + assert!(baseline.edges.contains_key(&edge)); + assert_eq!( + semantic_port_endpoint(&p, edge, EdgeEnd::Source), + Some(ports[0]) + ); + assert_eq!( + semantic_port_endpoint(&p, edge, EdgeEnd::Target), + Some(target) + ); + let mut route = authored(edge); + route.waypoints.clear(); + route.label = None; + p.diagrams.get_mut(&d).unwrap().routes.push(route); + } + assert_eq!(routed(&p, d), baseline); + let mut hidden = project_diagram(&p, &p.diagrams[&d]); + hidden.ports.clear(); + for edge in &mut hidden.edges { + edge.source_port = None; + edge.target_port = None; + } + assert_eq!( + route_diagram(&p, &p.diagrams[&d], &hidden), + route_diagram(&original, &original.diagrams[&d], &hidden), + "filtered direct Port endpoints must not activate their stored preferences" + ); +} diff --git a/crates/oa-core/tests/edge_attachments.rs b/crates/oa-core/tests/edge_attachments.rs new file mode 100644 index 00000000..6a7a1d18 --- /dev/null +++ b/crates/oa-core/tests/edge_attachments.rs @@ -0,0 +1,290 @@ +use oa_core::{language, persistence, source_project::*, *}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +const SOURCE: &str = r#"arcadia 7; +project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" { + oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {} + oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {} + interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; } + diagram view "View" id "70000000-0000-4000-8000-000000000005" { + routes [{ edge @signal; waypoints [{ x 120; y 80; }]; + label { fraction 0.5; offset { x 3; y -7; }; }; + source_attachment { side Left; offset 0.25; }; + target_attachment { side Bottom; offset 1; }; + }]; + } + diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; } +} +"#; + +fn id(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} + +fn original() -> Project { + language::parse(SOURCE).unwrap() +} + +fn set(diagram: Id, route: EdgeRoute) -> Command { + Command::SetEdgeRoute { diagram, route } +} + +#[test] +fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() { + let project = original(); + let view = id(&project, "view"); + let route = &project.diagrams[&view].routes[0]; + assert_eq!( + route.source_attachment, + Some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.25 + }) + ); + assert_eq!( + route.target_attachment, + Some(EdgeAttachment { + side: PortAnchorSide::Bottom, + offset: 1.0 + }) + ); + for text in [SOURCE.to_owned(), language::format(&project)] { + assert_eq!(language::parse(&text).unwrap(), project); + } + assert_eq!( + persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(), + project + ); + assert_eq!( + compile_bundle(&format_bundle(&project), ProjectLimits::default()) + .unwrap() + .decoded + .project, + project + ); + + let automatic = &project.diagrams[&id(&project, "other")].routes[0]; + assert_eq!(automatic.source_attachment, None); + assert_eq!(automatic.target_attachment, None); + let encoded = serde_json::to_value(automatic).unwrap(); + assert!(encoded.get("source_attachment").is_none()); + assert!(encoded.get("target_attachment").is_none()); + let mut with_null = encoded.clone(); + with_null["source_attachment"] = Value::Null; + with_null["target_attachment"] = Value::Null; + assert_eq!( + serde_json::from_value::(with_null).unwrap(), + *automatic + ); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + *automatic + ); + + // Old source without either new field still means automatic presentation. + let old = SOURCE + .replace(" source_attachment { side Left; offset 0.25; };\n", "") + .replace(" target_attachment { side Bottom; offset 1; };\n", ""); + let old = language::parse(&old).unwrap(); + assert!( + old.diagrams + .values() + .flat_map(|d| &d.routes) + .all(|r| r.source_attachment.is_none() && r.target_attachment.is_none()) + ); + assert!(!language::format(&old).contains("_attachment")); + assert_eq!( + persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(), + old + ); +} + +#[test] +fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() { + let project = original(); + let edge = id(&project, "signal"); + for bad in [ + json!({}), + json!({"side":"Top"}), + json!({"offset":0.5}), + json!({"side":"Diagonal","offset":0.5}), + json!({"side":"Top","offset":null}), + json!({"side":"Top","offset":0.5,"unknown":true}), + json!({"side":"Top","offset":"NaN"}), + ] { + for field in ["source_attachment", "target_attachment"] { + let mut route = json!({"edge":edge}); + route[field] = bad.clone(); + assert!( + serde_json::from_value::(route).is_err(), + "{field}: {bad}" + ); + } + } + for bad in [ + "{}", + "{ side Top; }", + "{ offset 0.5; }", + "{ side Diagonal; offset 0.5; }", + "{ side Top; offset null; }", + "{ side Top; offset 0.5; unknown true; }", + "{ side Top; offset -0.01; }", + "{ side Top; offset 1.01; }", + "{ side Top; offset NaN; }", + "{ side Top; offset 1e999; }", + ] { + let source = SOURCE.replace("{ side Left; offset 0.25; }", bad); + assert!(language::parse(&source).is_err(), "{bad}"); + let bundle = SourceBundle { + entry: "p.arcadia".into(), + files: BTreeMap::from([("p.arcadia".into(), source)]), + }; + assert!( + compile_bundle(&bundle, ProjectLimits::default()).is_err(), + "{bad}" + ); + } + for version in [1, 2] { + let legacy = + format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}"); + assert!( + language::parse(&legacy).is_err(), + "legacy route extension must not be silently admitted" + ); + } +} + +#[test] +fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() { + let project = original(); + let view = id(&project, "view"); + let first = id(&project, "first"); + for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] { + for end in [EdgeEnd::Source, EdgeEnd::Target] { + let mut route = project.diagrams[&view].routes[0].clone(); + route.set_attachment( + end, + Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset, + }), + ); + let mut session = Session::new(project.clone()); + assert!( + session + .execute(Command::Batch(vec![ + Command::Rename(first, "Must roll back".into()), + set(view, route) + ])) + .is_err() + ); + assert_eq!(session.project(), &project); + assert!(!session.can_undo() && !session.can_redo()); + } + } + for offset in [-0.01, 1.01] { + let mut value = serde_json::to_value(&project).unwrap(); + value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] = + json!(offset); + assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err()); + } +} + +#[test] +fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() { + let project = original(); + let view = id(&project, "view"); + let edge = id(&project, "signal"); + let mut session = Session::new(project.clone()); + let mut route = project.diagrams[&view].routes[0].clone(); + route.source_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.75, + }); + let command = set(view, route.clone()); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&command).unwrap()).unwrap(), + command + ); + session.execute(command).unwrap(); + let mut expected = project.clone(); + expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone(); + assert_eq!( + session.project(), + &expected, + "only this view/end changes; semantic fields and other view stay exact" + ); + session.undo(); + assert_eq!(session.project(), &project); + assert!(!session.can_undo() && session.can_redo()); + session.redo(); + assert_eq!(session.project(), &expected); + route.source_attachment = None; + session.execute(set(view, route.clone())).unwrap(); + let mut reset = expected.clone(); + reset.diagrams.get_mut(&view).unwrap().routes[0] = route; + assert_eq!( + session.project(), + &reset, + "opposite attachment, pins and label survive endpoint reset" + ); + session.undo(); + assert_eq!(session.project(), &expected); + session + .execute(Command::RemoveEdgeRoute { + diagram: view, + edge, + }) + .unwrap(); + let mut removed = expected.clone(); + removed.diagrams.get_mut(&view).unwrap().routes.clear(); + assert_eq!(session.project(), &removed); + session.undo(); + assert_eq!(session.project(), &expected); +} + +#[test] +fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() { + let current = compile_bundle(&SourceBundle { + entry: "p.arcadia".into(), + files: BTreeMap::from([ + ("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()), + ("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()), + ("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()), + ]), + }, ProjectLimits::default()).unwrap(); + let mut project = current.decoded.project.clone(); + let view = id(&project, "view"); + project.diagrams.get_mut(&view).unwrap().routes[0] + .source_attachment + .as_mut() + .unwrap() + .offset = 0.375; + let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); + assert_eq!(changed.decoded.project, project); + for path in ["p.arcadia", "model.arcadia"] { + assert_eq!(changed.bundle.files[path], current.bundle.files[path]); + } + assert_eq!( + changed.bundle.files["views.arcadia"], + current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;") + ); + assert_eq!( + compile_bundle(&changed.bundle, ProjectLimits::default()) + .unwrap() + .bundle, + changed.bundle + ); + assert_eq!( + update_bundle(&changed, &project, ProjectLimits::default()) + .unwrap() + .bundle, + changed.bundle + ); +} diff --git a/crates/oa-core/tests/port_anchors.rs b/crates/oa-core/tests/port_anchors.rs index b0c70657..3601087b 100644 --- a/crates/oa-core/tests/port_anchors.rs +++ b/crates/oa-core/tests/port_anchors.rs @@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr diagram: d, route: EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![pos(500., 420.)], label: None, }, diff --git a/crates/oa-core/tests/release_routing.rs b/crates/oa-core/tests/release_routing.rs index c7e596c1..5ae5adb0 100644 --- a/crates/oa-core/tests/release_routing.rs +++ b/crates/oa-core/tests/release_routing.rs @@ -70,6 +70,8 @@ fn fixture() -> Fixture { fn route(edge: Id) -> EdgeRoute { EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }], label: Some(EdgeLabel { fraction: 0.4, @@ -689,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() { serde_json::from_value::(minimal).unwrap(), EdgeRoute { edge: f.edge, + source_attachment: None, + target_attachment: None, waypoints: vec![], label: None } diff --git a/crates/oa-core/tests/routing_geometry.rs b/crates/oa-core/tests/routing_geometry.rs index f242e799..b76eafad 100644 --- a/crates/oa-core/tests/routing_geometry.rs +++ b/crates/oa-core/tests/routing_geometry.rs @@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView { fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec, label: Option) { p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: pins, label, }); diff --git a/crates/oa-core/tests/routing_labels.rs b/crates/oa-core/tests/routing_labels.rs index a6299a5e..34dc52a4 100644 --- a/crates/oa-core/tests/routing_labels.rs +++ b/crates/oa-core/tests/routing_labels.rs @@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() { .routes .push(EdgeRoute { edge: fe, + source_attachment: None, + target_attachment: None, waypoints: vec![], label: Some(authored.clone()), }); diff --git a/crates/oa-core/tests/state_diagrams.rs b/crates/oa-core/tests/state_diagrams.rs index 959dc940..131fa596 100644 --- a/crates/oa-core/tests/state_diagrams.rs +++ b/crates/oa-core/tests/state_diagrams.rs @@ -301,6 +301,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() { let pins = vec![point(690., 480.), point(690., 230.)]; f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { edge: out, + source_attachment: None, + target_attachment: None, waypoints: pins.clone(), label: None, }); @@ -362,11 +364,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() { let pins = vec![point(570., 460.)]; f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { edge: f.local, + source_attachment: None, + target_attachment: None, waypoints: pins.clone(), label: Some(label.clone()), }); f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { edge: f.external, + source_attachment: None, + target_attachment: None, waypoints: vec![point(690., 230.)], label: None, }); @@ -422,6 +428,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() { let placement = f.p.place(f.d, orphan, point(30., 750.)); f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![], label: None, }); @@ -456,6 +464,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() { diagram: other, route: EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![], label: None, }, @@ -583,6 +593,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() { let pins = vec![point(730., 130.), point(1060., 130.)]; f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { edge: f.self_edge, + source_attachment: None, + target_attachment: None, waypoints: pins.clone(), label: Some(EdgeLabel { fraction: 0.25, @@ -652,6 +664,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda let pins = vec![point(280., 390.), point(280., 470.)]; f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { edge: f.local, + source_attachment: None, + target_attachment: None, waypoints: pins.clone(), label: None, }); @@ -730,6 +744,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g ); f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { edge: f.self_edge, + source_attachment: None, + target_attachment: None, waypoints: vec![], label: Some(EdgeLabel { fraction: 0.3, diff --git a/crates/oa-desktop/src/allocation_drag/tests.rs b/crates/oa-desktop/src/allocation_drag/tests.rs index 2c15c9f5..c212921b 100644 --- a/crates/oa-desktop/src/allocation_drag/tests.rs +++ b/crates/oa-desktop/src/allocation_drag/tests.rs @@ -748,6 +748,8 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() { fn route(edge: Id) -> EdgeRoute { EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![xy(420., 400.), xy(420., 550.)], label: Some(EdgeLabel { fraction: 0.4, diff --git a/crates/oa-desktop/src/allocation_input.rs b/crates/oa-desktop/src/allocation_input.rs index cd66bff4..cda510c0 100644 --- a/crates/oa-desktop/src/allocation_input.rs +++ b/crates/oa-desktop/src/allocation_input.rs @@ -70,6 +70,7 @@ impl Root<'_> { pan: self.app.pan, context: self.app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: false, placing: false, connection: None, diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 1e80ec40..037a850b 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -493,6 +493,7 @@ pub struct App { pub camera_epoch: u64, camera_boundary_epoch: u64, pub selected_bend: Option<(Id, usize)>, + pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>, pub exact_strings: bool, pub document: Document, pub page: Page, @@ -613,6 +614,7 @@ impl App { camera_epoch: 0, camera_boundary_epoch: 0, selected_bend: None, + selected_attachment: None, exact_strings: false, document, page: Page::Diagram, @@ -1103,6 +1105,9 @@ impl App { previous != (self.layer, self.active_diagram) } fn select(&mut self, id: Id) { + if id != self.selected { + self.selected_attachment = None; + } if self.selected == id { return; } @@ -4523,7 +4528,7 @@ impl App { } fn edit_route(&mut self, event: crate::route_edit::Event) { use crate::route_edit::{Event, insert_pin, stored_route}; - if self.connection.is_some() { + if self.connection.is_some() || self.page != Page::Diagram { return; } let Some(context) = self.route_context() else { @@ -4551,6 +4556,75 @@ impl App { .filter(|(id, _)| *id == self.selected) .map(|(_, i)| i); let command = match event { + Event::ChooseAttachment(end) => { + if crate::route_edit::attachment_node( + self.project(), + diagram, + &view, + self.selected, + end, + ) + .is_some() + { + self.selected_attachment = Some((context, self.selected, end)); + self.selected_bend = None; + self.status = format!( + "Drag the {} attachment handle along its element boundary · Esc cancels", + if end == EdgeEnd::Source { + "source" + } else { + "target" + } + ); + } + return; + } + Event::ResetAttachment(end) => { + if crate::route_edit::attachment_node( + self.project(), + diagram, + &view, + self.selected, + end, + ) + .is_none() + || route.attachment(end).is_none() + { + return; + } + route.set_attachment(end, None); + selected_bend = None; + Command::SetEdgeRoute { + diagram: context.diagram, + route, + } + } + Event::CommitAttachment(expected, changed, end, owner) => { + if expected != context + || changed.edge != self.selected + || crate::route_edit::attachment_node( + self.project(), + diagram, + &view, + self.selected, + end, + ) + .is_none_or(|node| node.id != owner) + { + return; + } + // A boundary gesture owns exactly one presentation field. + let mut intended = route.clone(); + intended.set_attachment(end, changed.attachment(end)); + if changed != intended || intended == route { + return; + } + selected_bend = None; + Command::SetEdgeRoute { + diagram: context.diagram, + route: intended, + } + } Event::SelectBend(id, index) => { if id == self.selected && index < route.waypoints.len() { self.selected_bend = Some((id, index)); diff --git a/crates/oa-desktop/src/app/allocation_drag_tests.rs b/crates/oa-desktop/src/app/allocation_drag_tests.rs index 59cacb65..ee5794ea 100644 --- a/crates/oa-desktop/src/app/allocation_drag_tests.rs +++ b/crates/oa-desktop/src/app/allocation_drag_tests.rs @@ -69,6 +69,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> { pan: app.pan, context: app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: !app.draft_dirty() && !app.source_dirty(), placing: false, connection: None, diff --git a/crates/oa-desktop/src/app/architecture_authoring/tests.rs b/crates/oa-desktop/src/app/architecture_authoring/tests.rs index acd9c475..18ee63c3 100644 --- a/crates/oa-desktop/src/app/architecture_authoring/tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring/tests.rs @@ -75,6 +75,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() { pan: app.pan, context: app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, diff --git a/crates/oa-desktop/src/app/architecture_authoring_tests.rs b/crates/oa-desktop/src/app/architecture_authoring_tests.rs index 90f9e9b2..4c0aabb0 100644 --- a/crates/oa-desktop/src/app/architecture_authoring_tests.rs +++ b/crates/oa-desktop/src/app/architecture_authoring_tests.rs @@ -230,6 +230,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() { pan: app.pan, context: app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: true, placing: true, connection: None, @@ -306,6 +307,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() { pan: app.pan, context: app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, diff --git a/crates/oa-desktop/src/app/diagram_removal_tests.rs b/crates/oa-desktop/src/app/diagram_removal_tests.rs index 72e9ead0..abf6ff40 100644 --- a/crates/oa-desktop/src/app/diagram_removal_tests.rs +++ b/crates/oa-desktop/src/app/diagram_removal_tests.rs @@ -924,6 +924,8 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed( diagram, route: EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![Position { x: 400., y: -100. }], label: None, }, diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs index 090de012..4ff04380 100644 --- a/crates/oa-desktop/src/app/state_diagram.rs +++ b/crates/oa-desktop/src/app/state_diagram.rs @@ -172,6 +172,24 @@ impl App { ))); } Event::Choose(tool) => { + if let Tool::AdjustAttachment { relation, end } = tool { + if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { + self.status = + "Apply or cancel pending edits before adjusting an attachment.".into(); + return Task::none(); + } + let Some(context) = self.state_context() else { + return Task::none(); + }; + if state_edit::attachment_node(self.project(), context.diagram, relation, end) + .is_none() + { + return Task::none(); + } + self.selected = relation; + self.selected_node = None; + self.refresh_draft(); + } if !self.apply() { return Task::none(); } @@ -210,6 +228,22 @@ impl App { let Some(context) = self.state_context() else { return Task::none(); }; + if let Event::SetAttachment { + relation, + end, + attachment, + .. + } = &mutation + { + let existing = self.project().diagrams[&context.diagram] + .routes + .iter() + .find(|route| route.edge == *relation) + .and_then(|route| route.attachment(*end)); + if existing == *attachment { + return Task::none(); + } + } match state_edit::plan(self.project(), context, &mutation) { Ok(plan) => { if self.execute(plan.command) { diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs index 7959b399..bd5ece62 100644 --- a/crates/oa-desktop/src/behavior/state_canvas.rs +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -43,8 +43,10 @@ pub struct State { press: Option, source: Option, preview: Option<(Id, Position)>, + attachment_preview: Option, pan_from: Option<(mouse::Button, Point)>, viewport: Option, + attachment_viewport: Option<(f32, Vector, Rectangle)>, scene: RefCell>, geometry: canvas::Cache, paint_key: RefCell>, @@ -76,6 +78,20 @@ enum Press { /// Handles require a drag; an explicitly chosen reconnect tool accepts a click. start: Option, }, + Attachment { + relation: Id, + end: EdgeEnd, + placement: Id, + start: Point, + }, +} + +#[derive(Clone, Copy, PartialEq)] +struct AttachmentPreview { + relation: Id, + end: EdgeEnd, + placement: Id, + attachment: EdgeAttachment, } struct Scene { @@ -86,12 +102,14 @@ struct Scene { struct CachedScene { context: Context, preview: Option<(Id, Position)>, + attachment_preview: Option, scene: Arc, } #[derive(PartialEq)] struct PaintKey { context: Context, preview: Option<(Id, Position)>, + attachment_preview: Option, selected: Id, zoom: f32, pan: Vector, @@ -107,6 +125,8 @@ impl State { fn cancel_gesture(&mut self) { self.press = None; self.preview = None; + self.attachment_preview = None; + self.attachment_viewport = None; self.pan_from = None; } fn reset(&mut self) { @@ -656,13 +676,23 @@ impl StateCanvas<'_> { .gesture_active(state) .then_some(state.preview) .flatten(); + let attachment_preview = self + .gesture_active(state) + .then_some(state.attachment_preview) + .flatten(); if let Some(cached) = state.scene.borrow().as_ref() && cached.context == self.context && cached.preview == preview + && cached.attachment_preview == attachment_preview { return Some(cached.scene.clone()); } let mut diagram = self.project.diagrams.get(&self.diagram)?.clone(); + if let Some(preview) = attachment_preview { + let mut route = crate::route_edit::stored_route(&diagram, preview.relation); + route.set_attachment(preview.end, Some(preview.attachment)); + crate::route_edit::with_route(&mut diagram, route); + } if let Some((id, at)) = preview && let Some(node) = diagram.nodes.get(&id) { @@ -697,6 +727,7 @@ impl StateCanvas<'_> { *state.scene.borrow_mut() = Some(CachedScene { context: self.context, preview, + attachment_preview, scene: scene.clone(), }); Some(scene) @@ -858,10 +889,64 @@ impl StateCanvas<'_> { fn gesture_active(&self, state: &State) -> bool { self.editable && state.context == Some(self.context) && state.tool == Some(self.tool) } + fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) { + let Some(Press::Attachment { + relation, + end, + placement, + start, + }) = state.press + else { + return; + }; + if window.distance(start) < 3. { + // Returning to the press restores the exact persisted optional value, + // without quantizing an existing attachment or pinning an automatic one. + state.attachment_preview = None; + return; + } + let Some(owner) = state_edit::attachment_node(self.project, self.diagram, relation, end) + .filter(|owner| owner.id == placement) + else { + state.cancel_gesture(); + return; + }; + let diagram = &self.project.diagrams[&self.diagram]; + let previous = state + .attachment_preview + .map(|preview| preview.attachment.side) + .or_else(|| { + diagram + .routes + .iter() + .find(|route| route.edge == relation) + .and_then(|route| route.attachment(end)) + .map(|attachment| attachment.side) + }); + let world = self.world(local); + if !world.x.is_finite() || !world.y.is_finite() { + state.cancel_gesture(); + return; + } + let attachment = + routing::attachment_from_point(&owner, position(world), previous, 6. / self.scale()); + state.attachment_preview = Some(AttachmentPreview { + relation, + end, + placement, + attachment, + }); + } fn guidance(&self, state: &State, scene: &Scene, world: Option) -> String { if !self.editable { return "Pending edits · apply or cancel them before editing the diagram".into(); } + if let Tool::AdjustAttachment { relation, end } = self.tool { + return format!( + "Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged", + self.project.named(relation) + ); + } let hovered = world.and_then(|p| self.vertex_at(scene, p)); let press = self.gesture_active(state).then_some(state.press).flatten(); let source = self.gesture_active(state).then_some(state.source).flatten(); @@ -952,8 +1037,15 @@ impl canvas::Program for StateCanvas<'_> { if !self.editable { state.press = None; state.preview = None; + state.attachment_preview = None; state.source = None; } + if state + .attachment_viewport + .is_some_and(|view| view != (self.zoom, self.pan, bounds)) + { + state.cancel_gesture(); + } // Winit positions are ordered events. The cursor supplied by Iced is the // final cursor for an entire batch and cannot reinterpret earlier clicks. let cursor = match event { @@ -981,6 +1073,11 @@ impl canvas::Program for StateCanvas<'_> { state.reset(); return None; } + canvas::Event::Window(iced::window::Event::Resized(_)) => { + state.cancel_gesture(); + state.viewport = None; + return None; + } _ => cursor, }; let window = cursor.position(); @@ -1142,6 +1239,26 @@ impl canvas::Program for StateCanvas<'_> { }); Some(Action::capture()) } + Tool::AdjustAttachment { relation, end } => { + let owner = + state_edit::attachment_node(self.project, self.diagram, relation, end)?; + let route = scene.routed.edges.get(&relation)?; + let tip = match end { + EdgeEnd::Source => route.points.first(), + EdgeEnd::Target => route.points.last(), + }?; + if world.distance(point(*tip)) > 10. / self.scale() { + return None; + } + state.attachment_viewport = Some((self.zoom, self.pan, bounds)); + state.press = Some(Press::Attachment { + relation, + end, + placement: owner.id, + start: window, + }); + Some(Action::capture()) + } } } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => { @@ -1156,6 +1273,9 @@ impl canvas::Program for StateCanvas<'_> { } } self.move_pointer(state, window); + if let Some(local) = local { + self.move_attachment_pointer(state, window, local); + } Some(Action::request_redraw()) } canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => { @@ -1176,9 +1296,14 @@ impl canvas::Program for StateCanvas<'_> { } if let Some(window) = window { self.move_pointer(state, window); + if let Some(local) = local { + self.move_attachment_pointer(state, window, local); + } } let press = state.press.take()?; + state.attachment_viewport = None; let preview = state.preview.take(); + let attachment_preview = state.attachment_preview.take(); let world = local.map(|p| self.world(p)); let event = match press { Press::Move { node, .. } => preview.map(|(_, position)| Event::Move { @@ -1186,6 +1311,24 @@ impl canvas::Program for StateCanvas<'_> { node, position, }), + Press::Attachment { + relation, + end, + placement, + .. + } if inside => attachment_preview + .filter(|preview| { + preview.relation == relation + && preview.end == end + && preview.placement == placement + }) + .map(|preview| Event::SetAttachment { + context: self.context, + relation, + end, + placement, + attachment: Some(preview.attachment), + }), Press::Create { owner, kind, start } if inside && window?.distance(start) < 3. @@ -1282,6 +1425,10 @@ impl canvas::Program for StateCanvas<'_> { .gesture_active(state) .then_some(state.preview) .flatten(), + attachment_preview: self + .gesture_active(state) + .then_some(state.attachment_preview) + .flatten(), selected: self.selected, zoom: self.scale(), pan: self.pan, @@ -1391,6 +1538,20 @@ impl canvas::Program for StateCanvas<'_> { .with_width(2.5 / self.scale()), ); } + if let Tool::AdjustAttachment { relation, end } = self.tool + && let Some(route) = scene.routed.edges.get(&relation) + && let Some(tip) = match end { + EdgeEnd::Source => route.points.first(), + EdgeEnd::Target => route.points.last(), + } + { + frame.stroke( + &Path::circle(point(*tip), 9. / self.scale()), + Stroke::default() + .with_color(style::TEAL) + .with_width(2. / self.scale()), + ); + } if let Some(at) = world && self.editable { @@ -2483,6 +2644,242 @@ mod tests { ); } + #[test] + fn explicit_attachment_drag_is_view_only_and_one_source_history_operation() { + for end in [EdgeEnd::Source, EdgeEnd::Target] { + for zoom in [0.5, 1., 2.] { + let mut f = fixture(); + let relation = + f.project + .relate(RelationKind::StateTransition, "Go standby", f.a, f.b); + let second = state_edit::create_view(&f.project, f.machine).unwrap(); + let other_id = second.diagram; + f.project = prepare_command(&f.project, &second.command) + .unwrap() + .project; + let other = f.project.diagrams[&other_id].clone(); + let mut canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, zoom); + canvas.selected = relation; + let mut state = State::default(); + let scene = canvas.scene(&state).unwrap(); + let route = &scene.routed.edges[&relation]; + let tip = match end { + EdgeEnd::Source => route.points.first(), + EdgeEnd::Target => route.points.last(), + } + .unwrap(); + let handle = screen(&canvas, point(*tip)); + motion(&canvas, &mut state, handle, handle); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + handle, + ); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + handle + ) + .is_none() + ); + assert!( + state.attachment_preview.is_none(), + "A click must not materialize an automatic endpoint" + ); + let owner = + state_edit::attachment_node(&f.project, f.diagram, relation, end).unwrap(); + let destination = screen( + &canvas, + Point::new(owner.position.x + owner.size.width * 0.25, owner.position.y), + ); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + handle, + ); + motion(&canvas, &mut state, destination, destination); + assert!(state.attachment_preview.is_some()); + assert!(f.project.diagrams[&f.diagram].routes.is_empty()); + let event = input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + destination, + ) + .unwrap(); + assert!(matches!(event, Event::SetAttachment { end: actual, .. } if actual == end)); + let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap(); + let mut document = + Document::from_project(f.project.clone(), Default::default()).unwrap(); + let source = document.bundle().clone(); + document.execute(plan.command).unwrap(); + assert_eq!(document.project().relations, f.project.relations); + assert_eq!(document.project().diagrams[&other_id], other); + let route = &document.project().diagrams[&f.diagram].routes[0]; + assert_eq!( + route.attachment(end), + Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.25 + }) + ); + let edited = document.bundle().clone(); + assert!(document.undo()); + assert_eq!(document.bundle(), &source); + assert!(!document.undo()); + assert!(document.redo()); + assert_eq!(document.bundle(), &edited); + } + } + } + + #[test] + fn attachment_return_to_press_restores_exact_automatic_or_fractional_original() { + for original in [ + None, + Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.234567, + }), + ] { + for end in [EdgeEnd::Source, EdgeEnd::Target] { + let mut f = fixture(); + let relation = + f.project + .relate(RelationKind::StateTransition, "Go standby", f.a, f.b); + if original.is_some() { + let mut route = + crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation); + route.set_attachment(end, original); + f.project + .diagrams + .get_mut(&f.diagram) + .unwrap() + .routes + .push(route); + } + let canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, 1.); + let mut state = State::default(); + let before = canvas.scene(&state).unwrap(); + let edge = &before.routed.edges[&relation]; + let tip = match end { + EdgeEnd::Source => edge.points.first(), + EdgeEnd::Target => edge.points.last(), + } + .unwrap(); + let start = screen(&canvas, point(*tip)); + let away = start + Vector::new(80., 40.); + motion(&canvas, &mut state, start, start); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + start, + ); + motion(&canvas, &mut state, away, away); + assert!(state.attachment_preview.is_some()); + motion(&canvas, &mut state, start, start); + assert!(state.attachment_preview.is_none()); + assert_eq!( + canvas.scene(&state).unwrap().routed.edges[&relation].points, + edge.points + ); + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + start + ) + .is_none() + ); + assert_eq!( + crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation) + .attachment(end), + original + ); + } + } + } + + #[test] + fn attachment_drag_cancels_on_camera_context_tool_pending_and_focus_changes() { + let mut f = fixture(); + let relation = f + .project + .relate(RelationKind::StateTransition, "Go standby", f.a, f.b); + for cancel in 0..9 { + let mut canvas = canvas( + &f, + Tool::AdjustAttachment { + relation, + end: EdgeEnd::Source, + }, + 1., + ); + canvas.selected = relation; + let mut state = State::default(); + let scene = canvas.scene(&state).unwrap(); + let handle = screen(&canvas, point(scene.routed.edges[&relation].points[0])); + let destination = handle + Vector::new(40., 30.); + motion(&canvas, &mut state, handle, handle); + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + handle, + ); + motion(&canvas, &mut state, destination, destination); + assert!(state.attachment_preview.is_some()); + match cancel { + 0 => canvas.zoom = 2., + 1 => canvas.pan.x += 10., + 2 => canvas.context.epoch += 1, + 3 => canvas.tool = Tool::Select, + 4 => canvas.editable = false, + 5 | 6 => { + input( + &canvas, + &mut state, + mouse::Event::ButtonPressed(if cancel == 5 { + mouse::Button::Right + } else { + mouse::Button::Middle + }), + destination, + ); + } + 7 => { + canvas::Program::update( + &canvas, + &mut state, + &canvas::Event::Window(iced::window::Event::Unfocused), + bounds(), + mouse::Cursor::Available(destination), + ); + } + _ => { + motion(&canvas, &mut state, Point::new(-100., -100.), destination); + } + } + assert!( + input( + &canvas, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + destination + ) + .is_none() + ); + assert!(state.attachment_preview.is_none()); + assert!(f.project.diagrams[&f.diagram].routes.is_empty()); + } + } + #[test] fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() { let mut f = fixture(); diff --git a/crates/oa-desktop/src/behavior/state_edit.rs b/crates/oa-desktop/src/behavior/state_edit.rs index ee73c801..9ccf01d5 100644 --- a/crates/oa-desktop/src/behavior/state_edit.rs +++ b/crates/oa-desktop/src/behavior/state_edit.rs @@ -20,6 +20,7 @@ pub enum Tool { Region, Transition, Reconnect { relation: Id, endpoint: Endpoint }, + AdjustAttachment { relation: Id, end: EdgeEnd }, } #[derive(Debug, Clone)] @@ -66,6 +67,13 @@ pub enum Event { endpoint: Endpoint, vertex: Id, }, + SetAttachment { + context: Context, + relation: Id, + end: EdgeEnd, + placement: Id, + attachment: Option, + }, } impl Event { @@ -84,7 +92,8 @@ impl Event { | Self::Place { context: c, .. } | Self::Move { context: c, .. } | Self::Connect { context: c, .. } - | Self::Reconnect { context: c, .. } => Some(*c), + | Self::Reconnect { context: c, .. } + | Self::SetAttachment { context: c, .. } => Some(*c), } } } @@ -249,6 +258,36 @@ pub fn can_reconnect( .is_ok() } +/// Only the actual visible transition owner can receive a visual attachment. +/// Folded promotion, semantic ports and internal-transition rows are excluded. +pub fn attachment_node( + project: &Project, + diagram: Id, + relation: Id, + end: EdgeEnd, +) -> Option { + machine(project, diagram).ok()?; + let relation_record = project.relations.get(&relation)?; + if relation_record.kind() != RelationKind::StateTransition + || oa_core::routing::semantic_port_endpoint(project, relation, end).is_some() + { + return None; + } + let view = projection::project_view(project, diagram); + let edge = view.edges.iter().find(|edge| edge.id == relation)?; + let stored = project.diagrams.get(&diagram)?; + if projection::edge_endpoint_placements(project, stored, relation)? + != (edge.source, edge.target) + { + return None; + } + let owner = match end { + EdgeEnd::Source => edge.source, + EdgeEnd::Target => edge.target, + }; + view.nodes.iter().find(|node| node.id == owner).cloned() +} + fn checked(project: &Project, plan: Plan) -> Result { prepare_command(project, &plan.command)?; Ok(plan) @@ -492,6 +531,22 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result { + attachment_node(project, diagram, relation, end) + .filter(|owner| owner.id == placement) + .ok_or_else(|| { + error("Choose the actual visible transition endpoint in this view.") + })?; + let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation); + route.set_attachment(end, attachment); + (Command::SetEdgeRoute { diagram, route }, relation, None) + } _ => { return Err(error( "Handle this navigation or tool request without a model command.", @@ -580,6 +635,85 @@ mod tests { plan.diagram } + #[test] + fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() { + let mut f = fixture(); + let diagram = create(&mut f); + let c = context(diagram); + let edge = f.project.relate( + RelationKind::StateTransition, + "Leave work", + f.leaf, + f.target, + ); + let attachment = EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.3, + }; + let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge); + route.source_attachment = Some(attachment); + route.target_attachment = Some(attachment); + route.waypoints.push(Position { x: 700., y: 700. }); + // Retain the exact label value alongside the other route components. + route.label = Some(EdgeLabel { + fraction: 0.5, + offset: Position { x: 10., y: 20. }, + }); + f.project + .diagrams + .get_mut(&diagram) + .unwrap() + .routes + .push(route.clone()); + let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap(); + let event = Event::SetAttachment { + context: c, + relation: edge, + end: EdgeEnd::Source, + placement: owner.id, + attachment: None, + }; + let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command) + .unwrap() + .project; + route.source_attachment = None; + assert_eq!(accepted.diagrams[&diagram].routes, vec![route]); + assert_eq!(accepted.relations, f.project.relations); + let wrong = Event::SetAttachment { + context: c, + relation: edge, + end: EdgeEnd::Source, + placement: node(&f.project, diagram, f.target), + attachment: Some(attachment), + }; + assert!(plan(&f.project, c, &wrong).is_err()); + let composite = node(&f.project, diagram, f.composite); + f.project + .diagrams + .get_mut(&diagram) + .unwrap() + .nodes + .get_mut(&composite) + .unwrap() + .collapsed = true; + assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none()); + assert!(plan(&f.project, c, &event).is_err()); + let internal = f.project.relate( + RelationKind::StateTransition, + "Internal", + f.target, + f.target, + ); + if let RelationBody::StateTransition { + transition_kind, .. + } = &mut f.project.relations.get_mut(&internal).unwrap().body + { + *transition_kind = TransitionKind::Internal; + } + assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none()); + assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none()); + } + #[test] fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() { let f = fixture(); @@ -763,6 +897,8 @@ mod tests { .routes .push(EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![pin], label: None, }); diff --git a/crates/oa-desktop/src/behavior/state_menu.rs b/crates/oa-desktop/src/behavior/state_menu.rs index dbe748fd..02c6a096 100644 --- a/crates/oa-desktop/src/behavior/state_menu.rs +++ b/crates/oa-desktop/src/behavior/state_menu.rs @@ -178,6 +178,44 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option> { visible, )); } + for (caption, reset, end) in [ + ( + "Adjust source attachment", + "Reset source attachment", + EdgeEnd::Source, + ), + ( + "Adjust target attachment", + "Reset target attachment", + EdgeEnd::Target, + ), + ] { + let owner = state_edit::attachment_node(project, context.diagram, id, end); + entries.push(item( + caption, + diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })), + owner.is_some(), + )); + if let Some(owner) = owner { + let authored = project.diagrams[&context.diagram] + .routes + .iter() + .find(|route| route.edge == id) + .and_then(|route| route.attachment(end)) + .is_some(); + entries.push(item( + reset, + diagram_event(Event::SetAttachment { + context, + relation: id, + end, + placement: owner.id, + attachment: None, + }), + authored, + )); + } + } if !visible { entries.push(Entry::title( "Use transition details for hidden or internal endpoints", diff --git a/crates/oa-desktop/src/diagram.rs b/crates/oa-desktop/src/diagram.rs index 8ebb4356..f401ea63 100644 --- a/crates/oa-desktop/src/diagram.rs +++ b/crates/oa-desktop/src/diagram.rs @@ -22,6 +22,9 @@ use std::{ sync::Arc, }; #[cfg(test)] +#[path = "diagram/attachment_tests.rs"] +mod attachment_tests; +#[cfg(test)] #[path = "diagram/lifecycle_tests.rs"] mod lifecycle_tests; #[cfg(test)] @@ -53,6 +56,7 @@ pub struct DiagramCanvas<'a> { pub pan: Vector, pub context: Context, pub selected_bend: Option, + pub selected_attachment: Option, pub editable: bool, pub placing: bool, pub connection: Option<&'a crate::connection_edit::Session>, @@ -67,6 +71,7 @@ pub struct State { pan_start: Option<(mouse::Button, Point)>, context: Option, route_drag: Option, + attachment_viewport: Option<(f32, Vector, Rectangle)>, connection_press: Option<(Id, Point)>, connection_hover: Option, port_drag: Option, @@ -294,6 +299,11 @@ impl DiagramCanvas<'_> { ); request.canvas = Some(self.context); request.creation_point = Some(position(world)); + if self.attachment_at(&scene, world).is_some() { + request.object = self.selected; + request.route_point = Some(position(world)); + return request; + } if let Some(edge) = scene.routed.edges.get(&self.selected) && !edge.custom_suspended && let Some(index) = edge @@ -371,6 +381,39 @@ impl DiagramCanvas<'_> { }) .map(|(id, segment, _)| (id, segment)) } + fn attachment_at<'b>( + &self, + scene: &'b Scene, + world: Point, + ) -> Option<(EdgeEnd, &'b ProjectedNode)> { + let edge = scene.routed.edges.get(&self.selected)?; + if edge.custom_suspended { + return None; + } + [ + (EdgeEnd::Source, edge.points.first()), + (EdgeEnd::Target, edge.points.last()), + ] + .into_iter() + .filter_map(|(end, at)| { + let distance = world.distance(point(*at?)); + let owner = route_edit::attachment_node( + self.project, + &self.project.diagrams[&self.diagram], + &scene.view, + self.selected, + end, + )?; + (distance <= 8. / self.zoom).then_some((end, owner, distance)) + }) + .min_by(|a, b| { + a.2.total_cmp(&b.2).then_with(|| { + (Some(a.0) != self.selected_attachment) + .cmp(&(Some(b.0) != self.selected_attachment)) + }) + }) + .map(|(end, owner, _)| (end, owner)) + } fn hit_label(&self, routed: &RoutedView, p: Point) -> Option { let p = self.world(p); routed @@ -561,6 +604,24 @@ impl canvas::Program for DiagramCanvas<'_> { bounds: Rectangle, cursor: mouse::Cursor, ) -> Option> { + // An attachment belongs to the boundary geometry captured at press. + // Camera/layout changes must not reinterpret its pointer in another space. + if matches!( + state.route_drag.as_ref().map(|gesture| &gesture.handle), + Some(Handle::Attachment { .. }) + ) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds)) + || matches!( + event, + canvas::Event::Window( + iced::window::Event::Unfocused | iced::window::Event::Resized(_) + ) | canvas::Event::Mouse(mouse::Event::CursorLeft) + )) + { + state.route_drag = None; + state.attachment_viewport = None; + state.pointer = None; + return Some(Action::request_redraw()); + } // A completed work click retires App's allocation epoch before the // retained canvas sees a redraw. Its already-idle press must not eat // the first Delete. A live or not-yet-dispatched press stays guarded. @@ -840,6 +901,21 @@ impl canvas::Program for DiagramCanvas<'_> { &self.project.diagrams[&self.diagram], self.selected, ); + if let Some((end, owner)) = self.attachment_at(&scene, world) { + let original = route.attachment(end); + self.begin_route( + state, + world, + Handle::Attachment { + end, + owner: owner.clone(), + original, + }, + route, + ); + state.attachment_viewport = Some((self.zoom, self.pan, bounds)); + return Some(Action::capture()); + } if let Some(index) = edge .pins .iter() @@ -963,7 +1039,7 @@ impl canvas::Program for DiagramCanvas<'_> { return Some(Action::request_redraw()); } if let Some(gesture) = &mut state.route_drag { - gesture.move_to(self.world(p), self.zoom); + gesture.move_attachment_to(self.world(p), self.zoom); return Some(Action::request_redraw().and_capture()); } if let Some((_, start, original)) = state.drag { @@ -1040,17 +1116,43 @@ impl canvas::Program for DiagramCanvas<'_> { return Some(Action::capture()); } if let Some(mut gesture) = state.route_drag.take() { + if matches!(gesture.handle, Handle::Attachment { .. }) { + state.attachment_viewport = None; + if !cursor.is_over(bounds) { + return Some(Action::request_redraw().and_capture()); + } + } // `local` uses the ordered pointer position at release, // before any later motion in the same native event batch. if let Some(p) = local { - gesture.move_to(self.world(p), self.zoom); + gesture.move_attachment_to(self.world(p), self.zoom); } if !gesture.changed { return Some(Action::request_redraw().and_capture()); } + if let Handle::Attachment { end, owner, .. } = &gesture.handle { + if gesture.route + == route_edit::stored_route( + &self.project.diagrams[&self.diagram], + self.selected, + ) + { + return Some(Action::request_redraw().and_capture()); + } + return Some( + Action::publish(Message::Route(RouteEvent::CommitAttachment( + gesture.context, + gesture.route, + *end, + owner.id, + ))) + .and_capture(), + ); + } let bend = match gesture.handle { Handle::Bend(index) => Some(index), Handle::Label { .. } => None, + Handle::Attachment { .. } => unreachable!(), }; return Some( Action::publish(Message::Route(RouteEvent::Commit( @@ -1094,6 +1196,15 @@ impl canvas::Program for DiagramCanvas<'_> { canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) if cursor.is_over(bounds) => { + // Cancel before publishing Zoom: a release may arrive in this + // same event batch, before App applies the new camera. + if matches!( + state.route_drag.as_ref().map(|gesture| &gesture.handle), + Some(Handle::Attachment { .. }) + ) { + state.route_drag = None; + state.attachment_viewport = None; + } let y = match delta { mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { *y @@ -1336,6 +1447,41 @@ impl canvas::Program for DiagramCanvas<'_> { if let Some(edge) = scene.routed.edges.get(&self.selected) && !edge.custom_suspended { + for (end, at) in [ + (EdgeEnd::Source, edge.points.first()), + (EdgeEnd::Target, edge.points.last()), + ] { + if let Some(at) = at + && route_edit::attachment_node( + project, + &project.diagrams[&self.diagram], + view, + self.selected, + end, + ) + .is_some() + { + let at = point(*at); + let handle = Path::rectangle( + at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom), + Size::new(11. / self.zoom, 11. / self.zoom), + ); + f.fill( + &handle, + if self.selected_attachment == Some(end) { + style::TEAL + } else { + Color::WHITE + }, + ); + f.stroke( + &handle, + Stroke::default() + .with_color(style::TEAL) + .with_width(1.7 / self.zoom), + ); + } + } for (index, pin) in edge.pins.iter().enumerate() { let handle = Path::circle(point(*pin), 5.5 / self.zoom); f.fill( @@ -2042,6 +2188,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2118,6 +2265,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2243,6 +2391,7 @@ mod tests { selected: p.id, navigation_focus: None, selected_bend: None, + selected_attachment: None, zoom, pan: Vector::new(20., 30.), editable: true, @@ -2380,6 +2529,8 @@ mod tests { diagram, route: EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![Position { x: 400., y: -100. }], label: None, }, @@ -2422,6 +2573,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2499,6 +2651,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2597,6 +2750,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2726,6 +2880,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2781,6 +2936,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2856,6 +3012,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, @@ -2890,6 +3047,7 @@ mod tests { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, diff --git a/crates/oa-desktop/src/diagram/attachment_tests.rs b/crates/oa-desktop/src/diagram/attachment_tests.rs new file mode 100644 index 00000000..8e9b97b1 --- /dev/null +++ b/crates/oa-desktop/src/diagram/attachment_tests.rs @@ -0,0 +1,446 @@ +use super::*; +use crate::app::{App, Page}; +use iced::widget::canvas::Program; + +fn fixture() -> App { + let mut p = Project::empty(); + let a = p.add(Kind::Activity, "Send", 100., 150.); + let b = p.add(Kind::Activity, "Receive", 650., 150.); + let edge = p.relate(RelationKind::Interaction, "Report", a, b); + let diagram = p.ensure_diagram(Layer::Oa); + p.diagrams + .get_mut(&diagram) + .unwrap() + .routes + .push(EdgeRoute { + edge, + waypoints: vec![Position { x: 450., y: 350. }], + label: Some(EdgeLabel { + fraction: 0.4, + offset: Position { x: 5., y: 10. }, + }), + source_attachment: None, + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.6, + }), + }); + let mut app = App::with_project(p); + app.page = Page::Diagram; + app.active_diagram = Some(diagram); + let _ = app.update(Message::Select(edge)); + app +} + +fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> { + DiagramCanvas { + allocation: None, + project: app.project(), + view_index: None, + diagram: app.active_diagram.unwrap(), + selected: app.selected, + navigation_focus: None, + zoom, + pan: Vector::new(40., 50.), + context: app.route_context().unwrap(), + selected_bend: None, + selected_attachment: app + .selected_attachment + .filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected) + .map(|(_, _, end)| end), + editable: !app.draft_dirty() && !app.source_dirty(), + placing: false, + connection: None, + } +} +fn bounds() -> Rectangle { + Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.)) +} +fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point { + Point::new( + bounds().x + p.x * c.zoom + c.pan.x, + bounds().y + p.y * c.zoom + c.pan.y, + ) +} +fn send( + c: &DiagramCanvas<'_>, + state: &mut State, + event: mouse::Event, + final_cursor: Point, +) -> Option { + c.update( + state, + &canvas::Event::Mouse(event), + bounds(), + mouse::Cursor::Available(final_cursor), + ) + .and_then(|a| a.into_inner().0) +} +fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) { + let scene = c.scene(&State::default()); + let start = *scene.routed.edges[&c.selected].points.first().unwrap(); + let node = route_edit::attachment_node( + c.project, + &c.project.diagrams[&c.diagram], + &scene.view, + c.selected, + EdgeEnd::Source, + ) + .unwrap(); + let end = oa_core::routing::attachment_point( + c.project, + &c.project.diagrams[&c.diagram], + node, + &EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.25, + }, + ); + (screen(c, start), screen(c, end)) +} +fn drag(app: &App, zoom: f32) -> Message { + let c = canvas(app, zoom); + let (start, end) = positions(&c); + let mut state = State::default(); + let messages = [ + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: end }, + mouse::Event::ButtonReleased(mouse::Button::Left), + ] + .into_iter() + .filter_map(|e| send(&c, &mut state, e, end)) + .collect::>(); + assert_eq!(messages.len(), 1); + messages.into_iter().next().unwrap() +} + +#[test] +fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() { + for zoom in [0.4, 1., 2.] { + let mut app = fixture(); + let before = app.document.bundle().clone(); + let project = app.project().clone(); + let revision = app.document.revision(); + let message = drag(&app, zoom); + let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) = + message + else { + panic!("attachment commit required") + }; + assert_eq!( + route.source_attachment, + Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.25 + }) + ); + let intended = route.clone(); + let _ = app.update(message); + assert_eq!(app.document.revision(), revision + 1); + let mut expected = project; + route_edit::with_route( + expected + .diagrams + .get_mut(&app.active_diagram.unwrap()) + .unwrap(), + intended, + ); + assert_eq!( + *app.project(), + expected, + "only the intended view attachment changes" + ); + let after = app.document.bundle().clone(); + assert_eq!( + oa_core::source_project::compile_bundle(&after, Default::default()) + .unwrap() + .decoded + .project, + expected + ); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &after); + } +} + +#[test] +fn attachment_stationary_return_and_escape_leave_source_unchanged() { + let mut app = fixture(); + let before = app.document.bundle().clone(); + let c = canvas(&app, 1.); + let (start, end) = positions(&c); + let mut state = State::default(); + for event in [ + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: end }, + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + assert!(send(&c, &mut state, event, start).is_none()); + } + for event in [ + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: end }, + ] { + assert!(send(&c, &mut state, event, end).is_none()); + } + let _ = app.update(Message::Escape); + assert!( + send( + &canvas(&app, 1.), + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + end + ) + .is_none() + ); + assert_eq!(app.document.bundle(), &before); +} + +#[test] +fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() { + for cancel in 0..8 { + let app = fixture(); + let before = app.document.bundle().clone(); + let mut c = canvas(&app, 1.); + let (start, end) = positions(&c); + let mut state = State::default(); + for event in [ + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: end }, + ] { + assert!(send(&c, &mut state, event, end).is_none()); + } + assert!(state.route_drag.as_ref().is_some_and(|g| g.changed)); + let mut current_bounds = bounds(); + let mut release = end; + match cancel { + 0 => c.zoom = 2., + 1 => c.pan.x += 20., + 2 => current_bounds.x += 20., + 3 => current_bounds.height -= 100., + 4..=6 => { + let event = match cancel { + 4 => canvas::Event::Window(iced::window::Event::Unfocused), + 5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))), + _ => canvas::Event::Mouse(mouse::Event::CursorLeft), + }; + let message = c + .update( + &mut state, + &event, + current_bounds, + mouse::Cursor::Available(end), + ) + .and_then(|a| a.into_inner().0); + assert!(message.is_none()); + } + _ => { + release = Point::new(-100., -100.); + assert!( + send( + &c, + &mut state, + mouse::Event::CursorMoved { position: release }, + release + ) + .is_none() + ); + } + } + let message = c + .update( + &mut state, + &canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + current_bounds, + mouse::Cursor::Available(release), + ) + .and_then(|a| a.into_inner().0); + assert!(message.is_none(), "cancellation case {cancel}"); + assert!(state.route_drag.is_none(), "cancellation case {cancel}"); + assert_eq!(app.document.bundle(), &before); + } +} + +#[test] +fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() { + for wheel in [true, false] { + let mut app = fixture(); + let before = app.document.bundle().clone(); + let revision = app.document.revision(); + let c = canvas(&app, 1.); + let (start, end) = positions(&c); + let mut state = State::default(); + for event in [ + mouse::Event::CursorMoved { position: start }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { position: end }, + ] { + assert!(send(&c, &mut state, event, end).is_none()); + } + let camera_message = send( + &c, + &mut state, + if wheel { + mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, + } + } else { + mouse::Event::ButtonPressed(mouse::Button::Middle) + }, + end, + ); + assert_eq!(camera_message.is_some(), wheel); + assert!(state.route_drag.is_none()); + // Deliberately retain the same canvas and defer App camera dispatch. + assert!( + send( + &c, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + end + ) + .is_none() + ); + if !wheel { + assert!( + send( + &c, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Middle), + end + ) + .is_none() + ); + } + if let Some(message) = camera_message { + assert!(matches!(message, Message::Zoom(_))); + let _ = app.update(message); + } + assert_eq!(app.document.revision(), revision); + assert_eq!(app.document.bundle(), &before); + } +} + +#[test] +fn attachment_fast_release_uses_release_position_and_stays_in_one_view() { + let mut app = fixture(); + let original = app.active_diagram.unwrap(); + let _ = app.update(Message::DuplicateDiagram(original)); + let other = app.active_diagram.unwrap(); + assert_ne!(original, other); + app.active_diagram = Some(original); + let edge = app.project().diagrams[&original].routes[0].edge; + let _ = app.update(Message::Select(edge)); + let untouched = app.project().diagrams[&other].clone(); + let c = canvas(&app, 0.7); + let (start, end) = positions(&c); + let mut state = State::default(); + assert!( + send( + &c, + &mut state, + mouse::Event::ButtonPressed(mouse::Button::Left), + start + ) + .is_none() + ); + let message = send( + &c, + &mut state, + mouse::Event::ButtonReleased(mouse::Button::Left), + end, + ) + .expect("release completes drag without final move"); + let _ = app.update(message); + assert_eq!( + app.project().diagrams[&original].routes[0].source_attachment, + Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.25 + }) + ); + assert_eq!(app.project().diagrams[&other], untouched); +} + +#[test] +fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() { + let mut app = fixture(); + let before = app.document.bundle().clone(); + let message = drag(&app, 1.); + let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else { + panic!() + }; + let mut changed = route.clone(); + changed.waypoints.clear(); + let _ = app.update(Message::Route(RouteEvent::CommitAttachment( + context, changed, end, node, + ))); + let _ = app.update(Message::Route(RouteEvent::CommitAttachment( + context, + route.clone(), + end, + Id::new_v4(), + ))); + let _ = app.update(Message::Escape); + let _ = app.update(Message::Route(RouteEvent::CommitAttachment( + context, route, end, node, + ))); + assert_eq!(app.document.bundle(), &before); +} + +#[test] +fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() { + let mut app = fixture(); + let before = app.project().clone(); + let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target))); + let mut expected = before; + expected + .diagrams + .get_mut(&app.active_diagram.unwrap()) + .unwrap() + .routes[0] + .target_attachment = None; + assert_eq!(*app.project(), expected); + let revision = app.document.revision(); + let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target))); + assert_eq!(app.document.revision(), revision); +} + +#[test] +fn coincident_attachment_handles_honor_explicit_end_choice() { + let mut app = fixture(); + let _ = app.update(Message::Route(RouteEvent::ChooseAttachment( + EdgeEnd::Target, + ))); + let c = canvas(&app, 1.); + let mut scene = Scene { + work: None, + view: projection::project_view(c.project, c.diagram), + routed: Arc::new(route_diagram( + c.project, + &c.project.diagrams[&c.diagram], + &projection::project_view(c.project, c.diagram), + )), + }; + let routed = Arc::make_mut(&mut scene.routed); + let edge = routed.edges.get_mut(&c.selected).unwrap(); + let start = *edge.points.first().unwrap(); + *edge.points.last_mut().unwrap() = start; + assert_eq!( + c.attachment_at(&scene, point(start)).unwrap().0, + EdgeEnd::Target + ); + let mut source = c; + source.selected_attachment = Some(EdgeEnd::Source); + assert_eq!( + source.attachment_at(&scene, point(start)).unwrap().0, + EdgeEnd::Source + ); +} diff --git a/crates/oa-desktop/src/diagram/lifecycle_tests.rs b/crates/oa-desktop/src/diagram/lifecycle_tests.rs index 0cf38cec..cece5b70 100644 --- a/crates/oa-desktop/src/diagram/lifecycle_tests.rs +++ b/crates/oa-desktop/src/diagram/lifecycle_tests.rs @@ -62,6 +62,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> { pan: app.pan, context: app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: app.connection.as_ref(), diff --git a/crates/oa-desktop/src/diagram/pointer_tests.rs b/crates/oa-desktop/src/diagram/pointer_tests.rs index c48d4730..47d6585c 100644 --- a/crates/oa-desktop/src/diagram/pointer_tests.rs +++ b/crates/oa-desktop/src/diagram/pointer_tests.rs @@ -70,6 +70,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> { pan: app.pan, context: app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: app.connection.as_ref(), diff --git a/crates/oa-desktop/src/diagram/port_hit_tests.rs b/crates/oa-desktop/src/diagram/port_hit_tests.rs index 49fbc949..c1edf829 100644 --- a/crates/oa-desktop/src/diagram/port_hit_tests.rs +++ b/crates/oa-desktop/src/diagram/port_hit_tests.rs @@ -16,6 +16,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> { epoch: 0, }, selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, diff --git a/crates/oa-desktop/src/diagram/port_labels.rs b/crates/oa-desktop/src/diagram/port_labels.rs index 04fcfeda..60491d26 100644 --- a/crates/oa-desktop/src/diagram/port_labels.rs +++ b/crates/oa-desktop/src/diagram/port_labels.rs @@ -186,6 +186,7 @@ mod tests { pan: Vector::ZERO, context: app.route_context().unwrap(), selected_bend: None, + selected_attachment: None, editable: true, placing: false, connection: None, diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index 1960a38c..110ee04c 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -344,7 +344,7 @@ fn article(topic: Topic) -> Article { &[ "Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.", "Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.", - "Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout 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.", + "Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout 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( @@ -564,7 +564,7 @@ fn article(topic: Topic) -> Article { "Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.", "Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.", "Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.", - "Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.", + "Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.", "Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.", ], ), diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index a804c37c..6c137f54 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -240,6 +240,33 @@ pub fn popup(app: &App) -> Option> { .is_none_or(|(s, t)| s == e.source && t == e.target) }); let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id)); + if let Some((diagram, view)) = diagram.zip(view.as_ref()) { + for (end, choose, reset) in [ + ( + EdgeEnd::Source, + "Move source attachment", + "Reset source attachment", + ), + ( + EdgeEnd::Target, + "Move target attachment", + "Reset target attachment", + ), + ] { + let eligible = editable + && route_edit::attachment_node(p, diagram, view, id, end).is_some(); + entries.push(item( + choose, + Message::Route(route_edit::Event::ChooseAttachment(end)), + eligible, + )); + entries.push(item( + reset, + Message::Route(route_edit::Event::ResetAttachment(end)), + eligible && route.is_some_and(|route| route.attachment(end).is_some()), + )); + } + } if let Some(point) = request.route_point { entries.push( item( @@ -900,6 +927,36 @@ mod tests { assert_eq!(app.document.bundle(), &changed); } + #[test] + fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() { + let mut project = Project::empty(); + let source = project.add(Kind::Activity, "Send", 0., 0.); + let target = project.add(Kind::Activity, "Receive", 650., 0.); + let edge = project.relate(RelationKind::Interaction, "Report", source, target); + let mut app = App::with_project(project); + let before = app.document.bundle().clone(); + let mut request = Request::object(edge, Point::ORIGIN); + request.canvas = app.route_context(); + send(&mut app, Message::Menu(Event::Open(request.clone()))); + let action = choose(&app, "Move target attachment"); + send(&mut app, action); + assert_eq!( + app.selected_attachment, + Some((app.route_context().unwrap(), edge, EdgeEnd::Target)) + ); + assert_eq!(app.document.bundle(), &before); + request.canvas = app.route_context(); + send(&mut app, Message::Menu(Event::Open(request))); + let stale = choose(&app, "Move source attachment"); + send(&mut app, Message::Escape); + send(&mut app, stale); + assert!( + app.selected_attachment + .is_none_or(|(context, _, _)| Some(context) != app.route_context()) + ); + assert_eq!(app.document.bundle(), &before); + } + #[test] fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() { let mut p = Project::empty(); diff --git a/crates/oa-desktop/src/route_edit.rs b/crates/oa-desktop/src/route_edit.rs index 78ad6343..9d096d03 100644 --- a/crates/oa-desktop/src/route_edit.rs +++ b/crates/oa-desktop/src/route_edit.rs @@ -14,6 +14,9 @@ pub struct Context { pub enum Event { SelectBend(Id, usize), Commit(Context, EdgeRoute, Option), + CommitAttachment(Context, EdgeRoute, EdgeEnd, Id), + ChooseAttachment(EdgeEnd), + ResetAttachment(EdgeEnd), AddBend, AddBendAt(oa_core::Position), RemoveBend, @@ -23,7 +26,14 @@ pub enum Event { #[derive(Debug, Clone)] pub enum Handle { Bend(usize), - Label { original: Position }, + Label { + original: Position, + }, + Attachment { + end: EdgeEnd, + owner: projection::ProjectedNode, + original: Option, + }, } #[derive(Debug, Clone)] @@ -58,8 +68,57 @@ impl Gesture { }; } } + Handle::Attachment { .. } => {} } } + + pub fn move_attachment_to(&mut self, point: Point, zoom: f32) { + let Handle::Attachment { + end, + ref owner, + original, + } = self.handle + else { + self.move_to(point, zoom); + return; + }; + if point.distance(self.start) * zoom < 3. { + self.route.set_attachment(end, original); + self.changed = false; + return; + } + self.changed = true; + let attachment = attachment_from_point( + owner, + position(point), + self.route.attachment(end).map(|a| a.side), + 6. / zoom, + ); + self.route.set_attachment(end, Some(attachment)); + } +} + +/// The painted endpoint must still be the actual unported placement, not a folded proxy. +pub fn attachment_node<'a>( + project: &Project, + diagram: &Diagram, + view: &'a projection::ProjectedView, + edge: Id, + end: EdgeEnd, +) -> Option<&'a projection::ProjectedNode> { + if semantic_port_endpoint(project, edge, end).is_some() { + return None; + } + let projected = view.edges.iter().find(|e| e.id == edge)?; + let actual = projection::edge_endpoint_placements(project, diagram, edge)?; + if actual != (projected.source, projected.target) { + return None; + } + let placement = match end { + EdgeEnd::Source => projected.source, + EdgeEnd::Target => projected.target, + }; + view.nodes.iter().find(|node| node.id == placement) } pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute { @@ -72,6 +131,8 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute { edge, waypoints: Vec::new(), label: None, + source_attachment: None, + target_attachment: None, }) } diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index 8dabb812..e2bf9b8a 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -329,6 +329,8 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() { diagram, route: EdgeRoute { edge, + source_attachment: None, + target_attachment: None, waypoints: vec![Position { x: 400., y: 400. }], label: Some(EdgeLabel { fraction: 0.5, diff --git a/crates/oa-desktop/src/view_copy.rs b/crates/oa-desktop/src/view_copy.rs index 569b91c4..410657f8 100644 --- a/crates/oa-desktop/src/view_copy.rs +++ b/crates/oa-desktop/src/view_copy.rs @@ -112,6 +112,8 @@ mod tests { }); view.routes.push(EdgeRoute { edge: exchange, + source_attachment: None, + target_attachment: None, waypoints: vec![Position { x: 420., y: 440. }], label: Some(EdgeLabel { fraction: 0.4, diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 7bfd4b76..bfd60132 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -466,6 +466,12 @@ fn diagram(app: &App) -> View<'_, Message> { zoom: app.zoom, pan: app.pan, context: app.route_context().unwrap(), + selected_attachment: app + .selected_attachment + .filter(|(context, edge, _)| { + Some(*context) == app.route_context() && *edge == app.selected + }) + .map(|(_, _, end)| end), selected_bend: app .selected_bend .filter(|(edge, _)| *edge == app.selected) diff --git a/crates/oa-desktop/src/workflow/transition_tests.rs b/crates/oa-desktop/src/workflow/transition_tests.rs index b73c341d..df76c370 100644 --- a/crates/oa-desktop/src/workflow/transition_tests.rs +++ b/crates/oa-desktop/src/workflow/transition_tests.rs @@ -234,6 +234,8 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e .routes .push(EdgeRoute { edge: trace, + source_attachment: None, + target_attachment: None, waypoints: vec![Position { x: 301., y: 507. }], label: Some(EdgeLabel { fraction: 0.35, diff --git a/crates/oa-server/tests/http.rs b/crates/oa-server/tests/http.rs index c4a15248..30450ea7 100644 --- a/crates/oa-server/tests/http.rs +++ b/crates/oa-server/tests/http.rs @@ -12,6 +12,8 @@ use tokio::{sync::oneshot, task::JoinHandle}; mod communication_limits; #[path = "http/data_contracts.rs"] mod data_contracts; +#[path = "http/edge_attachments.rs"] +mod edge_attachments; #[path = "http/port_anchors.rs"] mod port_anchors; #[path = "http/semantic_transitions.rs"] diff --git a/crates/oa-server/tests/http/edge_attachments.rs b/crates/oa-server/tests/http/edge_attachments.rs new file mode 100644 index 00000000..e83d92a0 --- /dev/null +++ b/crates/oa-server/tests/http/edge_attachments.rs @@ -0,0 +1,344 @@ +use super::*; +use oa_core::{ + EdgeAttachment, PortAnchorSide, + source_project::{ProjectLimits, SourceBundle, compile_bundle}, +}; +use std::collections::BTreeMap; + +fn source_bundle() -> SourceBundle { + compile_bundle(&SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()), + ("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()), + ("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()), + ]), + }, ProjectLimits::default()).unwrap().bundle +} + +fn id(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} + +async fn import(server: &TestServer) -> StoredProject { + let response = server + .client + .post(server.url("/projects/import-source-project")) + .json(&source_bundle()) + .send() + .await + .unwrap(); + let status = response.status(); + let value: Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::CREATED, "{value}"); + server + .get(value["resource_id"].as_str().unwrap().parse().unwrap()) + .await +} + +async fn transact( + server: &TestServer, + current: &StoredProject, + commands: Value, + dry_run: bool, +) -> reqwest::Response { + server + .client + .post(server.url(&format!("/projects/{}/transactions", current.resource_id))) + .header("if-match", current.etag()) + .json(&json!({"commands":commands,"dry_run":dry_run})) + .send() + .await + .unwrap() +} + +fn assert_pair(current: &StoredProject) { + let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap(); + assert_eq!(compiled.decoded.project, current.model); + assert_eq!(compiled.bundle, current.source_project); +} + +async fn assert_unchanged(server: &TestServer, expected: &StoredProject) { + let actual = server.get(expected.resource_id).await; + assert_eq!(actual.etag(), expected.etag()); + assert_eq!( + serde_json::to_value(actual).unwrap(), + serde_json::to_value(expected).unwrap() + ); +} + +#[tokio::test] +async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let original = import(&server).await; + let diagram = id(&original.model, "view"); + let mut expected = original.model.clone(); + let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0]; + route.source_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Left, + offset: 0.25, + }); + route.target_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Bottom, + offset: 1.0, + }); + let commands = json!([Command::SetEdgeRoute { + diagram, + route: route.clone() + }]); + let preview = transact(&server, &original, commands.clone(), true).await; + assert_eq!(preview.status(), StatusCode::OK); + let preview: Value = preview.json().await.unwrap(); + assert_eq!( + serde_json::from_value::(preview["model"].clone()).unwrap(), + expected + ); + let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap(); + assert_eq!( + compile_bundle(&proposed, ProjectLimits::default()) + .unwrap() + .decoded + .project, + expected + ); + assert_unchanged(&server, &original).await; + + let applied = transact(&server, &original, commands.clone(), false).await; + assert_eq!(applied.status(), StatusCode::OK); + let mut current = server.get(original.resource_id).await; + assert_eq!(applied.headers()["etag"], current.etag()); + assert_eq!(current.revision, original.revision + 1); + assert_eq!( + current.model, expected, + "only the two attachment fields in this view change" + ); + assert_eq!(current.source_project, proposed); + assert_pair(¤t); + for path in ["project.arcadia", "model.arcadia"] { + assert_eq!( + current.source_project.files[path], + original.source_project.files[path] + ); + } + assert!(current.source_project.files["views.arcadia"].contains("// view rationale")); + let noop = transact(&server, ¤t, commands.clone(), false).await; + assert_eq!(noop.status(), StatusCode::OK); + assert_eq!(noop.headers()["etag"], current.etag()); + assert_eq!(noop.json::().await.unwrap()["changed"], false); + let stale = json!([ + Command::Rename(id(¤t.model, "first"), "Must not persist".into()), + commands[0].clone() + ]); + assert_eq!( + transact(&server, &original, stale, false).await.status(), + StatusCode::PRECONDITION_FAILED + ); + assert_unchanged(&server, ¤t).await; + + let mut edited = current.source_project.clone(); + let text = edited.files.get_mut("views.arcadia").unwrap(); + assert_eq!(text.matches("offset 0.25;").count(), 1); + *text = text.replace("offset 0.25;", "offset 0.375;"); + expected.diagrams.get_mut(&diagram).unwrap().routes[0] + .source_attachment + .as_mut() + .unwrap() + .offset = 0.375; + let response = server + .client + .put(server.url(&format!("/projects/{}/source-project", current.resource_id))) + .header("if-match", current.etag()) + .json(&edited) + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "{}", + response.text().await.unwrap() + ); + current = server.get(current.resource_id).await; + assert_eq!(current.model, expected); + assert_eq!(current.source_project, edited); + assert_pair(¤t); + let source = server + .client + .get(server.url(&format!("/projects/{}/source", current.resource_id))) + .send() + .await + .unwrap(); + assert_eq!(source.status(), StatusCode::OK); + assert_eq!(source.headers()["etag"], current.etag()); + assert_eq!( + oa_core::language::parse(&source.text().await.unwrap()).unwrap(), + expected + ); + let bundle = server + .client + .get(server.url(&format!("/projects/{}/source-project", current.resource_id))) + .send() + .await + .unwrap(); + assert_eq!(bundle.status(), StatusCode::OK); + assert_eq!(bundle.headers()["etag"], current.etag()); + assert_eq!( + bundle.json::().await.unwrap(), + current.source_project + ); + server.stop().await; + + let server = TestServer::start(directory.path(), None).await; + assert_unchanged(&server, ¤t).await; + let mut route = expected.diagrams[&diagram].routes[0].clone(); + route.source_attachment = None; + let reset = json!([Command::SetEdgeRoute { + diagram, + route: route.clone() + }]); + assert_eq!( + transact(&server, ¤t, reset.clone(), false) + .await + .status(), + StatusCode::OK + ); + expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route; + let reset_state = server.get(current.resource_id).await; + assert_eq!( + reset_state.model, expected, + "reset preserves pins, label, opposite end, other view and semantics" + ); + assert_pair(&reset_state); + let noop = transact(&server, &reset_state, reset, false).await; + assert_eq!(noop.status(), StatusCode::OK); + assert_eq!(noop.headers()["etag"], reset_state.etag()); + assert_eq!(noop.json::().await.unwrap()["changed"], false); + server.stop().await; +} + +#[tokio::test] +async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let current = import(&server).await; + let diagram = id(¤t.model, "view"); + let first = id(¤t.model, "first"); + for (bad, status) in [ + (json!({}), StatusCode::BAD_REQUEST), + (json!({"side":"Top"}), StatusCode::BAD_REQUEST), + (json!({"offset":0.5}), StatusCode::BAD_REQUEST), + ( + json!({"side":"Diagonal","offset":0.5}), + StatusCode::BAD_REQUEST, + ), + (json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST), + ( + json!({"side":"Top","offset":"NaN"}), + StatusCode::BAD_REQUEST, + ), + ( + json!({"side":"Top","offset":0.5,"unknown":true}), + StatusCode::BAD_REQUEST, + ), + ( + json!({"side":"Top","offset":-0.01}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"side":"Top","offset":1.01}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ] { + for field in ["source_attachment", "target_attachment"] { + let mut route = + serde_json::to_value(¤t.model.diagrams[&diagram].routes[0]).unwrap(); + route[field] = bad.clone(); + let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]); + let response = transact(&server, ¤t, commands, false).await; + assert_eq!( + response.status(), + status, + "{field} {bad}: {}", + response.text().await.unwrap() + ); + assert_unchanged(&server, ¤t).await; + } + } + let mut invalid = current.model.clone(); + invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment = + Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 2.0, + }); + let response = server + .client + .put(server.url(&format!("/projects/{}/model", current.resource_id))) + .header("if-match", current.etag()) + .json(&invalid) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_unchanged(&server, ¤t).await; + server.stop().await; +} + +#[tokio::test] +async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() { + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let response = server + .client + .get(server.url("/openapi.json")) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let schema: Value = response.json().await.unwrap(); + let schemas = &schema["components"]["schemas"]; + let attachment = &schemas["EdgeAttachment"]; + assert_eq!(attachment["required"], json!(["side", "offset"])); + assert_eq!(attachment["additionalProperties"], false); + assert_eq!( + attachment["properties"]["side"]["$ref"], + "#/components/schemas/PortAnchorSide" + ); + assert_eq!(attachment["properties"]["offset"]["minimum"], 0); + assert_eq!(attachment["properties"]["offset"]["maximum"], 1); + for field in ["source_attachment", "target_attachment"] { + assert!( + !schemas["EdgeRoute"]["required"] + .as_array() + .unwrap() + .contains(&json!(field)) + ); + let optional = &schemas["EdgeRoute"]["properties"][field]; + assert_eq!(optional["default"], Value::Null); + assert_eq!( + optional["oneOf"], + json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}]) + ); + } + let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap(); + let diagram = id(&compiled.decoded.project, "view"); + let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone(); + let omitted = serde_json::to_value(&route).unwrap(); + assert!(omitted.get("source_attachment").is_none()); + assert!(omitted.get("target_attachment").is_none()); + route.target_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.0, + }); + let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap(); + assert_eq!( + encoded["SetEdgeRoute"]["route"]["target_attachment"], + json!({"side":"Right","offset":0.0}) + ); + server.stop().await; +} diff --git a/docs/api.html b/docs/api.html index 80433d9b..3a07e50c 100644 --- a/docs/api.html +++ b/docs/api.html @@ -2,7 +2,7 @@ - + Arcadia Studio model REST API