Compose reviewed attachments and State bends with saved Scenario

This commit is contained in:
2026-09-23 00:48:36 -07:00
parent 0b61771d07
commit a9b9c81eab
55 changed files with 5500 additions and 67 deletions
@@ -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<dyn std::error::Error>> {
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(())
}
@@ -0,0 +1,101 @@
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
//!
//! Usage:
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
//!
//! Loads and validates the complete Project, then independently projects/routes
//! every saved State view through core APIs. The complete Project permits exact
//! semantic and presentation comparison; derived geometry permits declaring a
//! segment and insertion position before native actions. No desktop command,
//! planner, gesture, or pin insertion helper is called. Input is never written;
//! output uses create_new and fails rather than overwriting an existing file.
//!
//! Limits: this shares production core routing, so it is not an independent
//! mathematical test of that router, nor evidence of UI picking/gesture success.
//! It does not generate expected mutations or adapt an oracle to observed edits.
//! The acceptance recipe must declare the intended segment/index/point and exact
//! Project mutation before UI execution, and bind this executable/source/input
//! and output hashes externally. Warning strings are diagnostic Debug output,
//! not a stable interchange schema. Missing/hidden/internal transitions have no
//! routed edge; their semantic records remain in the complete Project snapshot.
use oa_core::{persistence, projection, routing, validate_candidate};
use serde_json::json;
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
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 mut views = Vec::new();
for (diagram_id, diagram) in &project.diagrams {
let Some(machine) = diagram.projection.state_machine() else {
continue;
};
let projected = projection::project_diagram(&project, diagram);
let routed = routing::route_diagram(&project, diagram, &projected);
let edges: Vec<_> = routed
.edges
.iter()
.map(|(edge_id, edge)| {
json!({
"edge": edge_id,
"id": edge.id,
"points": edge.points,
"pins": edge.pins,
"pin_fractions": edge.pin_fractions,
"custom_suspended": edge.custom_suspended,
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
"label": {
"text": edge.label.text,
"fraction": edge.label.fraction,
"anchor": edge.label.anchor,
"center": edge.label.center,
"bounds": {
"position": edge.label.bounds.position,
"size": edge.label.bounds.size
}
}
})
})
.collect();
views.push(json!({
"diagram": diagram_id,
"machine": machine,
"projected": projected,
"routed_bounds": {
"position": routed.bounds.position,
"size": routed.bounds.size
},
"routed_edges": edges
}));
}
// Preserve the canonical typed Project serialization (including f32
// formatting). Serializing it through Value directly promotes f32 to f64.
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
let snapshot = json!({
"schema": "state-bend-routing-snapshot/v1",
"project": project_json,
"state_views": views
});
// Finish all reading, validation, routing and serialization before creating
// the new evidence file. A failed load cannot leave an apparent snapshot.
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
bytes.push(b'\n');
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.sync_all()?;
println!(
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
project.identities().len(),
project.diagrams.len(),
views.len()
);
Ok(())
}
+34
View File
@@ -1528,11 +1528,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<EdgeAttachment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_attachment: Option<EdgeAttachment>,
#[serde(default)]
pub waypoints: Vec<Position>,
#[serde(default)]
pub label: Option<EdgeLabel>,
}
/// 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<EdgeAttachment> {
match end {
EdgeEnd::Source => self.source_attachment,
EdgeEnd::Target => self.target_attachment,
}
}
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
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 {
+132 -38
View File
@@ -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<EdgeAttachment>; 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<Id>, view: &ProjectedView) -> Vec<Endpoint> {
fn endpoints(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
port: Option<Id>,
view: &ProjectedView,
attachment: Option<EdgeAttachment>,
) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, 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<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
@@ -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,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(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<f32>,
ys: Vec<f32>,
}
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,
+160
View File
@@ -0,0 +1,160 @@
//! 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<Id> {
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<PortAnchorSide>,
hysteresis: f32,
) -> EdgeAttachment {
let candidate = |side| EdgeAttachment {
side,
offset: projection::port_anchor_offset(node, side, cursor),
};
// Measure side intent at the box, not from a distant overshoot: Euclidean
// distance differences converge outside a small symbol and would otherwise
// let hysteresis retain a perpendicular side indefinitely.
let boundary_cursor = pos(
cursor
.x
.clamp(node.position.x, node.position.x + node.size.width),
cursor
.y
.clamp(node.position.y, node.position.y + node.size.height),
);
let distance = |side| {
length(
projection::port_anchor_position(node, side, candidate(side).offset),
boundary_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 }
}
@@ -601,6 +601,8 @@ fn projection_context_and_architecture_payloads_are_explicitly_separate() {
.routes
.push(crate::EdgeRoute {
edge: f.exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
+18 -1
View File
@@ -93,6 +93,18 @@ fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>
.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 +256,12 @@ fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
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}),
),
);
}
}
}
+14
View File
@@ -644,6 +644,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
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,
+385
View File
@@ -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}, overshoot {overshoot}"
);
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"
);
}
}
@@ -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, &current.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, &current.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"
);
}
@@ -0,0 +1,136 @@
use oa_core::{projection::*, routing::*, *};
#[test]
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
let id = Id::new_v4();
project.elements.insert(
id,
Element {
id,
alias: format!("port_{}", id.simple()),
name: "Status port".into(),
description: "Existing legacy port retained by Item trace".into(),
parent: Some(owner),
body: ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
id
});
let trace = Trace {
id: Id::new_v4(),
alias: "status_correspondence".into(),
name: "Status correspondence".into(),
description: "Preserve this semantic evidence".into(),
oa: ports[0],
sa: ports[1],
kind: TraceKind::Item,
baseline: None,
};
project.traces.insert(trace.id, trace.clone());
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let owners = [
project.place(diagram, oa, Position { x: 100., y: 100. }),
project.place(diagram, sa, Position { x: 800., y: 300. }),
];
validate_candidate(&project).unwrap();
let semantic_elements = project.elements.clone();
let semantic_relations = project.relations.clone();
let semantic_traces = project.traces.clone();
let view = project_diagram(&project, &project.diagrams[&diagram]);
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
assert!(edge.trace);
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
assert_eq!((edge.source_port, edge.target_port), (None, None));
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
.into_iter()
.enumerate()
{
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owners[index])
.unwrap()
.port_anchors
.push(PortAnchor {
port: ports[index],
side,
offset,
});
}
validate_candidate(&project).unwrap();
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
for index in 0..2 {
let marker = moved_view
.ports
.iter()
.find(|port| port.element == ports[index] && port.owner == owners[index])
.unwrap();
let points = &moved.edges[&trace.id].points;
let tip = if index == 0 {
points[0]
} else {
*points.last().unwrap()
};
assert_eq!(tip, marker.position);
let old = &initial.edges[&trace.id].points;
assert_ne!(
tip,
if index == 0 {
old[0]
} else {
*old.last().unwrap()
}
);
}
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: trace.id,
waypoints: vec![],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.9,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.1,
}),
});
validate_candidate(&project).unwrap();
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
assert_eq!(dormant_view, moved_view);
assert_eq!(
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
moved
);
assert_eq!(project.elements, semantic_elements);
assert_eq!(project.relations, semantic_relations);
assert_eq!(project.traces, semantic_traces);
assert_eq!(project.traces[&trace.id], trace);
}
+154
View File
@@ -0,0 +1,154 @@
//! Route the exact native acceptance fixture and intended saved checkpoints.
use oa_core::{projection::project_diagram, routing::route_diagram, *};
const FIXTURE: &str =
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn set(
project: &mut Project,
view: &str,
edge: &str,
end: EdgeEnd,
attachment: Option<(PortAnchorSide, f32)>,
) {
let view = id(project, view);
let edge = id(project, edge);
let route = project
.diagrams
.get_mut(&view)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == edge)
.unwrap();
route.set_attachment(
end,
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
);
}
fn clear(project: &Project, checkpoint: &str) {
validate_candidate(project).unwrap();
for (view, edges) in [
("dispatch_view", &["dispatch_request"][..]),
("readiness_view", &["activate", "initialize", "finish"][..]),
] {
let diagram = &project.diagrams[&id(project, view)];
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
for alias in edges {
let edge = &routed.edges[&id(project, alias)];
assert!(
edge.warnings.is_empty(),
"{checkpoint}/{view}/{alias}: {:?}",
edge.warnings
);
let stored = diagram
.routes
.iter()
.find(|r| r.edge == id(project, alias))
.unwrap();
assert_eq!(
edge.pins, stored.waypoints,
"{checkpoint}/{alias}: manual pins retained"
);
}
}
}
#[test]
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
use EdgeEnd::{Source, Target};
use PortAnchorSide::{Bottom, Top};
let mut project = language::parse(FIXTURE).unwrap();
let baseline = project.clone();
clear(&project, "B");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
Some((Top, 0.25)),
);
clear(&project, "O1");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "O2");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
None,
);
clear(&project, "O3");
set(
&mut project,
"readiness_view",
"activate",
Source,
Some((Top, 0.25)),
);
clear(&project, "S1");
set(
&mut project,
"readiness_view",
"activate",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "S2");
set(&mut project, "readiness_view", "activate", Target, None);
clear(&project, "S3");
set(
&mut project,
"readiness_view",
"initialize",
Source,
Some((Top, 0.5)),
);
clear(&project, "S4");
set(
&mut project,
"readiness_view",
"finish",
Source,
Some((Top, 0.5)),
);
clear(&project, "S5");
assert_eq!(project.elements, baseline.elements);
assert_eq!(project.relations, baseline.relations);
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
let view = id(&project, alias);
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
}
for alias in ["dispatch_view", "readiness_view"] {
let view = id(&project, alias);
assert_eq!(
project.diagrams[&view].nodes,
baseline.diagrams[&view].nodes
);
for route in &project.diagrams[&view].routes {
let original = baseline.diagrams[&view]
.routes
.iter()
.find(|r| r.edge == route.edge)
.unwrap();
assert_eq!(route.waypoints, original.waypoints);
assert_eq!(route.label, original.label);
}
}
}
+290
View File
@@ -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::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(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::<EdgeRoute>(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::<Command>(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(&current, &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
);
}
+2
View File
@@ -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,
},
+4
View File
@@ -71,6 +71,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,
@@ -690,6 +692,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
+2
View File
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: pins,
label,
});
+2
View File
@@ -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()),
});
+16
View File
@@ -302,6 +302,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,
});
@@ -363,11 +365,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,
});
@@ -423,6 +429,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,
});
@@ -457,6 +465,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,
},
@@ -584,6 +594,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,
@@ -653,6 +665,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,
});
@@ -731,6 +745,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,
@@ -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,
@@ -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,
+84 -1
View File
@@ -501,6 +501,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,
@@ -622,6 +623,7 @@ impl App {
camera_epoch: 0,
camera_boundary_epoch: 0,
selected_bend: None,
selected_attachment: None,
exact_strings: false,
document,
page: Page::Diagram,
@@ -1145,6 +1147,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;
}
@@ -2582,6 +2587,15 @@ impl App {
} else if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
} else if self.page == Page::Behavior
&& matches!(
self.behavior.diagram.tool,
behavior::state_edit::Tool::AdjustAttachment { .. }
)
{
// Menu arming invalidates canvas keyboard ownership. Escape
// must also cancel before the next canvas click grants it.
self.behavior.diagram.tool = behavior::state_edit::Tool::Select;
}
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
@@ -4846,7 +4860,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 {
@@ -4874,6 +4888,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));
@@ -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,
@@ -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,
@@ -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,
@@ -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,
},
@@ -0,0 +1,160 @@
//! Production App tree and its actual keyboard subscription, without a canvas click.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
advanced::subscription,
futures::{StreamExt, stream, task::noop_waker_ref},
keyboard::key::Named,
mouse,
};
use std::task::{Context as PollContext, Poll};
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
let input = key(named);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event: input,
status: statuses[0],
};
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
messages.push(message);
}
}
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
async fn armed() -> (App, Mounted, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let mut mounted = Mounted::new(&mut app).await;
app.behavior.diagram.fit = false;
let context = app.state_context().unwrap();
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
anchor: iced::Point::new(450., 400.),
bend: None,
route_segment: None,
},
);
mounted.settle(&mut app).await;
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(crate::model_menu::Event::Invoke(
token,
Box::new(Message::StateDiagram(Event::Choose(
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
))),
)),
);
mounted.settle(&mut app).await;
assert!(app.model_menu.is_none());
assert_eq!(
app.behavior.diagram.tool,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source
}
);
(app, mounted, relation)
}
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, bounds, focused) = mounted
.geometry(app)
.focused
.into_iter()
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
.unwrap();
(bounds, focused)
}
#[tokio::test]
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
let history = (app.document.can_undo(), app.document.can_redo());
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
assert!(!canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(!canvas_focus(&mut mounted, &app).1);
press(&mut mounted, &mut app, Named::Delete).await;
assert!(
!app.delete_pending,
"Escape must not grant canvas Delete ownership"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
}
#[tokio::test]
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
// Existing modal Escape consumes the first key without canceling the tool underneath.
send(&mut app, Message::Delete);
assert!(app.delete_pending && app.delete_target.is_some());
mounted.settle(&mut app).await;
press(&mut mounted, &mut app, Named::Escape).await;
assert!(!app.delete_pending);
assert!(matches!(
app.behavior.diagram.tool,
Tool::AdjustAttachment { .. }
));
// A real canvas click grants ownership; inner-canvas Escape remains live.
let bounds = canvas_focus(&mut mounted, &app).0;
mounted
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
.await;
assert!(canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
);
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
@@ -0,0 +1,480 @@
use super::*;
#[test]
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let next = app.project().relations[&relation].target();
let waypoints = vec![Position { x: 715., y: -425. }];
let delayed = Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
};
send(&mut app, Message::Select(next));
assert_eq!(app.selected, next);
assert_eq!(
app.state_context(),
Some(context),
"same-view selection does not retire the context stamp"
);
let before = app.document.snapshot();
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
event(&mut app, delayed);
assert_eq!(app.selected, next);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
send(&mut app, Message::Select(relation));
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
},
);
let mut expected = before.decoded.project.clone();
let route = expected
.diagrams
.get_mut(&context.diagram)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == relation)
.unwrap();
route.waypoints = waypoints;
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
!app.document.can_undo(),
"live drag remains a single transaction"
);
}
#[test]
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let point = Position { x: 500., y: -400. };
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
anchor: iced::Point::ORIGIN,
bend: Some(1),
route_segment: Some((2, point)),
},
);
let menu = crate::model_menu::popup(&app).unwrap();
let mut add = false;
let mut remove = false;
let mut delete = false;
for entry in menu.entries {
if let crate::context_menu::Entry::Action {
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
..
} = entry
{
match *message {
Message::StateDiagram(Event::AddBend {
relation: id,
segment: Some((2, at)),
..
}) => {
assert_eq!(id, relation);
assert_eq!(at, point);
add = true;
}
Message::StateDiagram(Event::RemoveBend {
relation: id,
index: 1,
..
}) => {
assert_eq!(id, relation);
remove = true;
}
Message::Delete => delete = true,
_ => {}
}
}
}
assert!(add && remove && delete);
}
fn setup() -> (App, Id, Id, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
let second = state_edit::create_view(&project, f.machine).unwrap();
project = prepare_command(&project, &second.command).unwrap().project;
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![
Position { x: 650., y: -400. },
Position { x: 300., y: -400. },
Position { x: 650., y: -400. },
],
label: Some(EdgeLabel {
fraction: 0.35,
offset: Position { x: 9., y: -23. },
}),
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.75,
}),
});
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
(app, diagram, relation, f.a)
}
#[test]
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
let (mut app, diagram, relation, _) = setup();
let baseline = app.document.snapshot();
let points = vec![Position { x: 715., y: 425. }];
let mut expected = app.project().clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points.clone(),
},
);
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
let changed = app.document.snapshot();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points,
},
);
assert!(
Arc::ptr_eq(&changed, &app.document.snapshot()),
"equal bends must not add history"
);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
send(&mut app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &changed.bundle);
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
assert_eq!(app.project(), &expected);
let cleared = app.document.snapshot();
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
}
#[test]
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
let (app, diagram, relation, _) = setup();
let mut project = app.project().clone();
let RelationBody::StateTransition { source, target, .. } =
&mut project.relations.get_mut(&relation).unwrap().body
else {
panic!("State transition fixture")
};
*target = *source;
project.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
oa_core::validate_candidate(&project).unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let baseline = app.document.snapshot();
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
assert!(!geometry.custom_suspended);
assert!(geometry.pins.is_empty());
assert!(
geometry.points.len() >= 4,
"actual external self-loop routing"
);
let segment = 1;
let point = Position {
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
};
// Each operation must alter only the complete Project's waypoint list;
// Undo and Redo must restore exact serialized source, not just geometry.
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
let before = app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
event(app, mutation);
assert_eq!(app.project(), &expected);
assert_source_model_agree(app);
let after = app.document.snapshot();
assert_ne!(after.bundle, before.bundle);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
send(app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after.bundle);
assert_source_model_agree(app);
}
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
vec![point],
);
let moved = Position {
x: point.x + 45.,
y: point.y - 35.,
};
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::SetBends {
context,
relation,
waypoints: vec![moved],
},
vec![moved],
);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::RemoveBend {
context,
relation,
index: 0,
},
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
send(&mut app, Message::Undo);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::ClearBends { context, relation },
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_source_model_agree(&app);
// Clear is a new source-preserving edit and may retain empty-list trivia.
// Exact original bytes are restored by history, not by reauthoring a value.
for _ in 0..3 {
assert!(app.document.can_undo());
send(&mut app, Message::Undo);
}
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
let (app, diagram, relation, _) = setup();
let context = app.state_context().unwrap();
let p = app.project();
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
let original = &p.diagrams[&diagram].routes[0];
let reverse = geometry
.points
.windows(2)
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
.unwrap();
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
let point = Position { x: 500., y: -400. };
let planned = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
)
.unwrap();
let actual = prepare_command(p, &planned.command).unwrap().project;
let mut expected = p.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.insert(expected_index, point);
assert_eq!(actual, expected);
let removed = state_edit::plan(
&actual,
context,
&Event::RemoveBend {
context,
relation,
index: expected_index,
},
)
.unwrap();
assert_eq!(
prepare_command(&actual, &removed.command).unwrap().project,
*p
);
}
let default = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: None,
},
)
.unwrap();
let Command::SetEdgeRoute { route, .. } = default.command else {
panic!("route command")
};
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
assert_eq!(route.source_attachment, original.source_attachment);
assert_eq!(route.target_attachment, original.target_attachment);
assert_eq!(route.label, original.label);
}
#[test]
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
let (mut app, diagram, relation, composite) = setup();
let context = app.state_context().unwrap();
let baseline = app.document.snapshot();
for mutation in [
Event::SetBends {
context: Context {
epoch: context.epoch + 1,
..context
},
relation,
waypoints: vec![],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: f32::NAN, y: 0. }],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: 0., y: 0. }; 129],
},
Event::AddBend {
context,
relation,
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
},
Event::RemoveBend {
context,
relation,
index: usize::MAX,
},
] {
event(&mut app, mutation);
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
}
let mut p = app.project().clone();
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
vec![Position { x: 500., y: -400. }; 128];
assert!(
state_edit::plan(
&p,
context,
&Event::AddBend {
context,
relation,
segment: Some((1, Position { x: 500., y: -400. }))
}
)
.is_err()
);
let mut hidden = app.project().clone();
hidden
.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.hidden_edges
.push(relation);
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
let owner = node(&app, diagram, composite);
let mut folded = app.project().clone();
folded
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owner)
.unwrap()
.collapsed = true;
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
let mut internal = app.project().clone();
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
if let RelationBody::StateTransition {
source,
target,
transition_kind,
..
} = &mut internal.relations.get_mut(&relation).unwrap().body
{
*target = *source;
*transition_kind = TransitionKind::Internal;
}
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
send(
&mut app,
Message::Field("/name".into(), "Unapplied bend owner name".into()),
);
let draft = app.draft.as_ref().unwrap().value.clone();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
assert_eq!(app.draft.as_ref().unwrap().value, draft);
}
@@ -158,10 +158,14 @@ impl App {
object,
placement,
anchor,
bend,
route_segment,
} => {
let mut request = crate::model_menu::Request::object(object, anchor);
request.canvas = Some(context);
request.placement = placement;
request.bend = bend;
request.route_segment = route_segment;
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
}
Event::EditTransition(_, id) => {
@@ -173,6 +177,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();
}
@@ -211,8 +233,37 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetBends { relation, .. } = &mutation
&& *relation != self.selected
{
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 let Command::SetEdgeRoute { diagram, route } = &plan.command
&& crate::route_edit::stored_route(
&self.project().diagrams[diagram],
route.edge,
) == *route
{
return Task::none();
}
if self.execute(plan.command) {
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
@@ -1,5 +1,9 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
#[path = "state_attachment_escape_tests.rs"]
mod attachment_escape;
#[path = "state_bend_tests.rs"]
mod bends;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
@@ -158,6 +158,14 @@ impl Mounted {
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
self.input_with_status(app, events, cursor).0
}
pub fn input_with_status(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> (Vec<Message>, Vec<iced::event::Status>) {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
@@ -165,7 +173,7 @@ impl Mounted {
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let _ = ui.update(
let (_, statuses) = ui.update(
events,
cursor,
&mut self.renderer,
@@ -173,7 +181,7 @@ impl Mounted {
&mut messages,
);
self.cache = ui.into_cache();
messages
(messages, statuses)
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
@@ -0,0 +1,656 @@
// Included in state_canvas::tests: exercise the real canvas event path.
fn bend_fixture(authored: bool) -> (Fixture, Id) {
let mut f = fixture();
let relation = f.project.relate(
RelationKind::StateTransition,
"Standby to stopped",
f.b,
f.c,
);
if authored {
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![Position {
x: 650.25,
y: 340.75,
}],
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
}),
label: Some(EdgeLabel {
fraction: 0.43,
offset: Position { x: 28., y: -16. },
}),
});
}
validate_candidate(&f.project).unwrap();
(f, relation)
}
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
let edge = &scene.routed.edges[&relation];
edge.points
.windows(2)
.enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| {
(
i,
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
)
})
.filter(|(_, p, length)| {
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(i, p, _)| (i, p))
.expect("fixture has an exposed interior route segment")
}
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
motion(canvas, state, at, at);
let event = input(
canvas,
state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"press alone cannot commit"
);
}
#[test]
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
for zoom in [0.5, 1., 2.] {
let (f, relation) = bend_fixture(false);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let (_, midpoint) = bend_segment(&baseline, relation);
let start = screen(&canvas, midpoint);
let destination = Position {
x: ((midpoint.x + 45.) / 5.).round() * 5.,
y: ((midpoint.y + 30.) / 5.).round() * 5.,
};
let release = screen(&canvas, point(destination));
let later_batch_cursor = release + Vector::new(137., 83.);
motion(&canvas, &mut state, start, later_batch_cursor);
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
later_batch_cursor,
);
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
motion(&canvas, &mut state, release, later_batch_cursor);
assert_eq!(
state.bend_preview.as_ref().unwrap().waypoints,
vec![destination]
);
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
vec![destination]
);
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { context, relation: id, waypoints }
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
);
assert!(state.bend_preview.is_none());
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
later_batch_cursor
)
.is_none()
);
let after = prepare_command(
&f.project,
&state_edit::plan(&f.project, canvas.context, &event)
.unwrap()
.command,
)
.unwrap()
.project;
let stored = &after.diagrams[&f.diagram].routes[0];
assert_eq!(stored.waypoints, vec![destination]);
assert!(
stored.source_attachment.is_none()
&& stored.target_attachment.is_none()
&& stored.label.is_none()
);
assert_eq!(after.relations, f.project.relations);
}
}
#[test]
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
for zoom in [0.5, 1., 2.] {
let (mut f, relation) = bend_fixture(true);
let mut other = f.project.diagrams[&f.diagram].clone();
other.id = Id::new_v4();
other.alias = "bend_reference_view".into();
other.nodes = other
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("bend_reference_{}", node.alias);
(node.id, node)
})
.collect();
let other_id = other.id;
f.project.diagrams.insert(other_id, other.clone());
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let start = screen(&canvas, point(original.waypoints[0]));
let destination = Position { x: 690., y: 365. };
let release = screen(&canvas, point(destination));
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let preview = state.bend_preview.as_ref().unwrap();
let mut intended = original.clone();
intended.waypoints[0] = destination;
assert_eq!(preview, &intended);
assert_eq!(
f.project.diagrams[&f.diagram].routes[0], original,
"preview is transient"
);
let preview_scene = canvas.scene(&state).unwrap();
let event = input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
)
.unwrap();
assert!(
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
);
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
let before = document.bundle().clone();
document.execute(plan.command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(document.project().diagrams[&other_id], other);
let committed_view = projection::project_view(document.project(), f.diagram);
let committed = routing::route_diagram(
document.project(),
&document.project().diagrams[&f.diagram],
&committed_view,
);
let preview_edge = &preview_scene.routed.edges[&relation];
let committed_edge = &committed.edges[&relation];
assert_eq!(preview_edge.points, committed_edge.points);
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
assert_eq!(preview_edge.warnings, committed_edge.warnings);
let edited = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &f.project);
assert_eq!(document.bundle(), &before);
assert!(!document.undo(), "one drag adds one history operation");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &edited);
assert!(!document.redo());
}
}
#[test]
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
for authored in [false, true] {
for zoom in [0.5, 1., 2.] {
for travel in [false, true] {
let (f, relation) = bend_fixture(authored);
let mut canvas = canvas(&f, Tool::Select, zoom);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if authored {
point(scene.routed.edges[&relation].pins[0])
} else {
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
bend_press(&canvas, &mut state, start);
if travel {
let away = start + Vector::new(45., 30.) * zoom;
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_some());
motion(&canvas, &mut state, start, start);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
start
)
.is_none()
);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
scene.routed.edges[&relation].points
);
assert_eq!(
f.project.diagrams[&f.diagram].routes.len(),
usize::from(authored)
);
if authored {
assert_eq!(
f.project.diagrams[&f.diagram].routes[0].waypoints,
vec![Position {
x: 650.25,
y: 340.75
}]
);
}
}
}
}
}
#[test]
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
for cancellation in 0..16 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let start = screen(&canvas, point(original.waypoints[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
assert!(state.bend_preview.is_some(), "case {cancellation}");
let mut release_bounds = bounds();
match cancellation {
0 => canvas.zoom = 2.,
1 => canvas.pan.x += 10.,
2 => canvas.context.epoch += 1,
3 => canvas.context.revision += 1,
4 => canvas.context.document = Id::new_v4(),
5 => canvas.tool = Tool::Transition,
6 => canvas.editable = false,
7 | 8 => {
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(if cancellation == 7 {
mouse::Button::Right
} else {
mouse::Button::Middle
}),
release,
);
}
9 => {
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds(),
mouse::Cursor::Available(release),
);
}
10 => {
input(
&canvas,
&mut state,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
release,
);
}
11 => {
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
}
12 => {
motion(&canvas, &mut state, Point::new(-100., -100.), release);
}
13 => release_bounds.x += 25.,
15 => canvas.selected = f.b,
_ => {
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let event = canvas::Program::update(
&canvas,
&mut state,
&escape,
bounds(),
mouse::Cursor::Available(release),
)
.and_then(|action| action.into_inner().0);
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
}
}
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
release_bounds,
);
assert!(
!matches!(event, Some(Event::SetBends { .. })),
"late release committed case {cancellation}"
);
assert!(
state.bend_preview.is_none(),
"stale preview survived case {cancellation}"
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
motion(&canvas, &mut state, pin, pin);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None })
if context == canvas.context && object == relation && anchor == pin)
);
let (index, midpoint) = bend_segment(&scene, relation);
let at = screen(&canvas, midpoint);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)) })
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
);
}
#[test]
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
for internal in [false, true] {
let mut f = fixture();
let relation = if internal {
let id = f
.project
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = TransitionKind::Internal;
}
id
} else {
let id = f.project.relate(
RelationKind::StateTransition,
"Hidden child to standby",
f.leaf,
f.b,
);
let parent = placement(&f, f.a);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
id
};
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let scene = canvas.scene(&state).unwrap();
let world = if internal {
assert!(!scene.routed.edges.contains_key(&relation));
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
internal_rows(&f.project, node)
.into_iter()
.find(|(r, _)| r.id == relation)
.unwrap()
.1
.center()
} else {
assert!(scene.routed.edges[&relation].custom_suspended);
bend_segment(&scene, relation).1
};
let start = screen(&canvas, world);
motion(&canvas, &mut state, start, start);
assert!(matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
start
),
Some(Event::ContextMenu {
bend: None,
route_segment: None,
..
})
));
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(30., 20.);
motion(&canvas, &mut state, away, away);
assert!(state.bend_preview.is_none());
assert!(!matches!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
away
),
Some(Event::SetBends { .. })
));
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
}
}
#[test]
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
for click_pin in [true, false] {
let (mut f, relation) = bend_fixture(true);
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
validate_candidate(&f.project).unwrap();
let original = f.project.diagrams[&f.diagram].routes[0].clone();
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let edge = &baseline.routed.edges[&relation];
let candidates: Vec<_> = edge.points.windows(2).enumerate()
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
.collect();
let (_, insertion_at) = candidates
.iter()
.copied()
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
.expect("exposed segment before both authored pins");
let menu_segment = candidates
.iter()
.copied()
.find(|(_, p)| p.distance(insertion_at) > 30.)
.expect("a distinct committed segment for the menu control");
let start = screen(&canvas, insertion_at);
bend_press(&canvas, &mut state, start);
let away = start + Vector::new(45., 30.);
motion(&canvas, &mut state, away, away);
let preview = state.bend_preview.as_ref().unwrap();
assert_eq!(preview.waypoints.len(), 3);
assert_eq!(
&preview.waypoints[1..],
original.waypoints.as_slice(),
"provisional insertion shifts both stored indices"
);
// With the left gesture still active, moving to the last stored pin also
// moves provisional pin0 there. The preview's first hit is therefore
// pin0, but a menu built after cancellation must identify stored pin1.
let menu_world = if click_pin {
point(original.waypoints[1])
} else {
menu_segment.1
};
let menu_window = screen(&canvas, menu_world);
motion(&canvas, &mut state, menu_window, menu_window);
assert!(state.bend_preview.is_some());
let menu = input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Right),
menu_window,
)
.unwrap();
let Event::ContextMenu {
context,
object,
bend,
route_segment,
..
} = menu
else {
panic!("expected context menu after cancelling drag")
};
assert_eq!(context, canvas.context);
assert_eq!(object, relation);
assert!(state.bend_preview.is_none());
assert_eq!(
canvas.scene(&state).unwrap().routed.edges[&relation].points,
edge.points
);
if click_pin {
assert_eq!(
bend,
Some(1),
"menu index must address the stored route, not the provisional route"
);
assert!(route_segment.is_none());
let remove = Event::RemoveBend {
context,
relation,
index: bend.unwrap(),
};
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
let after = prepare_command(&f.project, &plan.command).unwrap().project;
let mut expected = f.project.clone();
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
.waypoints
.remove(1);
assert_eq!(
after, expected,
"remove menu action must delete exactly the addressed original pin"
);
} else {
assert!(
bend.is_none(),
"provisional pin under cursor must not leak into menu"
);
let (segment, at) = route_segment.expect("committed route segment remains actionable");
assert_eq!(segment, menu_segment.0);
assert!(point(at).distance(menu_world) < 0.001);
}
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
menu_window
)
.is_none()
);
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
}
}
#[test]
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
for change in 0..4 {
let (f, relation) = bend_fixture(true);
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = relation;
let mut state = State::default();
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
let release = start + Vector::new(45., 30.);
bend_press(&canvas, &mut state, start);
motion(&canvas, &mut state, release, release);
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_ne!(
displayed_preview.routed.edges[&relation].pins,
baseline.routed.edges[&relation].pins
);
let mut changed_bounds = bounds();
match change {
0 => canvas.zoom = 2.,
1 => canvas.pan.y += 25.,
2 => changed_bounds.x += 35.,
_ => changed_bounds.width -= 90.,
}
// No update event between camera/layout change and this draw-time read.
let shown = canvas
.scene_with_bounds(&state, Some(changed_bounds))
.unwrap();
assert_eq!(
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
"stale draw-time pins for case {change}"
);
assert_eq!(
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
"stale cached preview for case {change}"
);
let event = input_at_bounds(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
release,
changed_bounds,
);
assert!(!matches!(event, Some(Event::SetBends { .. })));
assert!(state.bend_preview.is_none());
}
}
+647 -12
View File
@@ -43,8 +43,12 @@ pub struct State {
press: Option<Press>,
source: Option<Id>,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend: Option<BendGesture>,
bend_preview: Option<EdgeRoute>,
pan_from: Option<(mouse::Button, Point)>,
viewport: Option<Size>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
scene: RefCell<Option<CachedScene>>,
geometry: canvas::Cache,
paint_key: RefCell<Option<PaintKey>>,
@@ -76,6 +80,30 @@ enum Press {
/// Handles require a drag; an explicitly chosen reconnect tool accepts a click.
start: Option<Point>,
},
Attachment {
relation: Id,
end: EdgeEnd,
placement: Id,
start: Point,
},
Bend {
relation: Id,
},
}
struct BendGesture {
start: Point,
route: EdgeRoute,
index: usize,
baseline: Arc<Scene>,
}
#[derive(Clone, Copy, PartialEq)]
struct AttachmentPreview {
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: EdgeAttachment,
}
struct Scene {
@@ -86,12 +114,16 @@ struct Scene {
struct CachedScene {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
scene: Arc<Scene>,
}
#[derive(PartialEq)]
struct PaintKey {
context: Context,
preview: Option<(Id, Position)>,
attachment_preview: Option<AttachmentPreview>,
bend_preview: Option<EdgeRoute>,
selected: Id,
zoom: f32,
pan: Vector,
@@ -104,9 +136,16 @@ enum Hit {
}
impl State {
fn cancel_gesture(&mut self) {
fn cancel_edit(&mut self) {
self.press = None;
self.preview = None;
self.attachment_preview = None;
self.bend = None;
self.bend_preview = None;
self.attachment_viewport = None;
}
fn cancel_gesture(&mut self) {
self.cancel_edit();
self.pan_from = None;
}
fn reset(&mut self) {
@@ -649,20 +688,36 @@ impl StateCanvas<'_> {
&& state_edit::validate_context(self.project, self.context, self.context).is_ok()
}
fn scene(&self, state: &State) -> Option<Arc<Scene>> {
self.scene_with_bounds(state, None)
}
fn scene_with_bounds(&self, state: &State, bounds: Option<Rectangle>) -> Option<Arc<Scene>> {
if !self.valid() {
return None;
}
let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds);
let preview = self
.gesture_active(state)
.then_some(state.preview)
.flatten();
let attachment_preview = route_active.then_some(state.attachment_preview).flatten();
let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten();
if let Some(cached) = state.scene.borrow().as_ref()
&& cached.context == self.context
&& cached.preview == preview
&& cached.attachment_preview == attachment_preview
&& cached.bend_preview == bend_preview
{
return Some(cached.scene.clone());
}
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
if let Some(route) = &bend_preview {
crate::route_edit::with_route(&mut diagram, route.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)
{
@@ -687,7 +742,17 @@ impl StateCanvas<'_> {
}
}
let view = projection::project_diagram(self.project, &diagram);
let routed = routing::route_diagram(self.project, &diagram, &view);
let routed = if let Some(gesture) = state.bend.as_ref().filter(|_| bend_preview.is_some()) {
routing::route_edge_preview(
self.project,
&diagram,
&view,
&gesture.baseline.routed,
gesture.route.edge,
)
} else {
routing::route_diagram(self.project, &diagram, &view)
};
let symbol_labels = symbol_labels(self.project, &view, &routed);
let scene = Arc::new(Scene {
view,
@@ -697,6 +762,8 @@ impl StateCanvas<'_> {
*state.scene.borrow_mut() = Some(CachedScene {
context: self.context,
preview,
attachment_preview,
bend_preview,
scene: scene.clone(),
});
Some(scene)
@@ -828,6 +895,84 @@ impl StateCanvas<'_> {
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(endpoint, _)| (route.id, endpoint))
}
fn bend_at(&self, scene: &Scene, world: Point) -> Option<usize> {
let edge = scene.routed.edges.get(&self.selected)?;
if edge.custom_suspended {
return None;
}
edge.pins
.iter()
.enumerate()
.filter_map(|(index, pin)| {
let distance = world.distance(point(*pin));
(distance <= 8. / self.scale()).then_some((index, distance))
})
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
.map(|(index, _)| index)
}
/// Preserve the rendered traversal index; overlapping runs can have the
/// same world point but different positions among the authored pins.
fn segment_at(&self, scene: &Scene, relation: Id, world: Point) -> Option<(usize, Position)> {
let edge = scene.routed.edges.get(&relation)?;
if edge.custom_suspended {
return None;
}
edge.points
.windows(2)
.enumerate()
.filter(|(index, _)| *index > 0 && index + 2 < edge.points.len())
.filter_map(|(index, span)| {
let projection = routing::project_onto_polyline(span, position(world))?;
(projection.distance <= 7. / self.scale()).then_some((index, projection.distance))
})
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
.map(|(index, _)| (index, position(world)))
}
fn begin_bend(
&self,
state: &mut State,
scene: Arc<Scene>,
window: Point,
route: EdgeRoute,
index: usize,
bounds: Rectangle,
) {
state.press = Some(Press::Bend {
relation: route.edge,
});
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
state.bend = Some(BendGesture {
start: window,
route,
index,
baseline: scene,
});
state.bend_preview = None;
}
fn move_bend_pointer(&self, state: &mut State, window: Point, local: Point) {
let Some(gesture) = &state.bend else {
return;
};
if window.distance(gesture.start) < 3. {
// Also discard a provisional insertion. The exact original route
// comes from the model, so an off-grid pin is never quantized here.
state.bend_preview = None;
return;
}
let world = self.world(local);
if !world.x.is_finite() || !world.y.is_finite() {
state.cancel_gesture();
return;
}
let mut changed = gesture.route.clone();
changed.waypoints[gesture.index] = Position {
x: (world.x / 5.).round() * 5.,
y: (world.y / 5.).round() * 5.,
};
let original =
crate::route_edit::stored_route(&self.project.diagrams[&self.diagram], changed.edge);
state.bend_preview = (changed != original).then_some(changed);
}
fn owner_at(&self, scene: &Scene, world: Point, kind: Kind) -> Option<Id> {
self.node_at(scene, world)
.filter(|n| state_edit::eligible_owner(self.project, self.diagram, n.element, kind))
@@ -856,12 +1001,98 @@ impl StateCanvas<'_> {
}
}
fn gesture_active(&self, state: &State) -> bool {
self.editable && state.context == Some(self.context) && state.tool == Some(self.tool)
self.editable
&& state.context == Some(self.context)
&& state.tool == Some(self.tool)
&& state
.bend
.as_ref()
.is_none_or(|g| g.route.edge == self.selected)
}
fn route_viewport_current(&self, state: &State, bounds: Option<Rectangle>) -> bool {
state
.attachment_viewport
.is_none_or(|(zoom, pan, captured)| {
zoom == self.zoom
&& pan == self.pan
&& bounds.is_none_or(|current| current == captured)
})
}
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<Point>) -> String {
if !self.editable {
return "Pending edits · apply or cancel them before editing the diagram".into();
}
if state.bend.is_some()
&& self.gesture_active(state)
&& self.route_viewport_current(state, None)
{
return "Move bend · release to keep · Esc cancels".into();
}
if self.tool == Tool::Select
&& state.press.is_none()
&& scene
.routed
.edges
.get(&self.selected)
.is_some_and(|edge| !edge.custom_suspended)
{
return "Drag line to add a bend · squares move bends · right-click a square to remove"
.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();
@@ -950,10 +1181,24 @@ impl canvas::Program<Event> for StateCanvas<'_> {
state.tool = Some(self.tool);
}
if !self.editable {
state.press = None;
state.preview = None;
// Pending edits block model changes, but read-only panning remains
// available and must survive successive pointer events.
state.cancel_edit();
state.source = None;
}
if state
.bend
.as_ref()
.is_some_and(|g| g.route.edge != self.selected)
{
state.cancel_gesture();
}
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 {
@@ -963,6 +1208,9 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
canvas::Event::Mouse(mouse::Event::CursorLeft) => {
state.pointer = None;
if state.bend.is_some() {
state.cancel_gesture();
}
cursor
}
canvas::Event::Mouse(
@@ -981,6 +1229,11 @@ impl canvas::Program<Event> 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();
@@ -1053,10 +1306,23 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
if *button == mouse::Button::Right {
state.reset();
let (object, placement) = match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
// A canceled provisional pin shifts indices in its preview.
// Menu commands must target the committed route instead.
let scene = self.scene(state)?;
let bend = self.bend_at(&scene, world);
let (object, placement) = if bend.is_some() {
(self.selected, None)
} else {
match self.hit(&scene, world) {
Some(Hit::Node { element, placement }) => (element, Some(placement)),
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
None => (self.diagram, None),
}
};
let route_segment = if bend.is_none() {
self.segment_at(&scene, object, world)
} else {
None
};
return Some(
Action::publish(Event::ContextMenu {
@@ -1064,6 +1330,8 @@ impl canvas::Program<Event> for StateCanvas<'_> {
object,
placement,
anchor: window,
bend,
route_segment,
})
.and_capture(),
);
@@ -1081,6 +1349,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
});
return Some(Action::capture());
}
if let Some(index) = self.bend_at(&scene, world) {
let route = crate::route_edit::stored_route(
&self.project.diagrams[&self.diagram],
self.selected,
);
self.begin_bend(state, scene.clone(), window, route, index, bounds);
return Some(Action::capture());
}
match self.hit(&scene, world) {
Some(Hit::Node { placement, element }) => {
let original = self
@@ -1101,6 +1377,36 @@ impl canvas::Program<Event> for StateCanvas<'_> {
)
}
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
if id == self.selected
&& let Some(edge) = scene.routed.edges.get(&id)
&& !Rectangle::new(
point(edge.label.bounds.position),
Size::new(
edge.label.bounds.size.width,
edge.label.bounds.size.height,
),
)
.contains(world)
&& let Some((segment, at)) = self.segment_at(&scene, id, world)
{
let mut route = crate::route_edit::stored_route(
&self.project.diagrams[&self.diagram],
id,
);
if let Some(index) = crate::route_edit::insert_pin_on_segment(
&mut route, edge, segment, at,
) {
self.begin_bend(
state,
scene.clone(),
window,
route,
index,
bounds,
);
return Some(Action::capture());
}
}
Some(Action::publish(Event::Select(self.context, id)).and_capture())
}
None => {
@@ -1142,6 +1448,26 @@ impl canvas::Program<Event> 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 +1482,10 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
}
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
self.move_bend_pointer(state, window, local);
}
Some(Action::request_redraw())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
@@ -1176,16 +1506,49 @@ impl canvas::Program<Event> for StateCanvas<'_> {
}
if let Some(window) = window {
self.move_pointer(state, window);
if let Some(local) = local {
self.move_attachment_pointer(state, window, local);
self.move_bend_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 bend_preview = state.bend_preview.take();
state.bend = None;
let world = local.map(|p| self.world(p));
let event = match press {
Press::Bend { relation } if inside => bend_preview
.filter(|route| route.edge == relation)
.map(|route| Event::SetBends {
context: self.context,
relation,
waypoints: route.waypoints,
}),
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
context: self.context,
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.
@@ -1273,15 +1636,19 @@ impl canvas::Program<Event> for StateCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let Some(scene) = self.scene(state) else {
let Some(scene) = self.scene_with_bounds(state, Some(bounds)) else {
return vec![];
};
let route_active =
self.gesture_active(state) && self.route_viewport_current(state, Some(bounds));
let key = PaintKey {
context: self.context,
preview: self
.gesture_active(state)
.then_some(state.preview)
.flatten(),
attachment_preview: route_active.then_some(state.attachment_preview).flatten(),
bend_preview: route_active.then(|| state.bend_preview.clone()).flatten(),
selected: self.selected,
zoom: self.scale(),
pan: self.pan,
@@ -1361,6 +1728,22 @@ impl canvas::Program<Event> for StateCanvas<'_> {
style::MUTED,
);
if active {
if !edge.custom_suspended && self.tool == Tool::Select {
for pin in &edge.pins {
let radius = 4. / self.scale();
let handle = Path::rectangle(
point(*pin) - Vector::new(radius, radius),
Size::new(radius * 2., radius * 2.),
);
frame.fill(&handle, Color::WHITE);
frame.stroke(
&handle,
Stroke::default()
.with_color(style::TEAL)
.with_width(1.6 / self.scale()),
);
}
}
for end in [edge.points.first(), edge.points.last()]
.into_iter()
.flatten()
@@ -1391,6 +1774,20 @@ impl canvas::Program<Event> 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
{
@@ -1761,6 +2158,8 @@ mod tests {
use super::*;
use crate::document::Document;
include!("state_bend_tests.rs");
struct Fixture {
project: Project,
diagram: Id,
@@ -2217,7 +2616,7 @@ mod tests {
motion(&canvas, &mut state, at, at);
assert!(matches!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { context, object, placement: Some(node), anchor })
Some(Event::ContextMenu { context, object, placement: Some(node), anchor, .. })
if context == canvas.context && object == f.b
&& node == placement(&f, f.b) && anchor == at));
let background = Point::new(
@@ -2227,7 +2626,7 @@ mod tests {
motion(&canvas, &mut state, background, background);
assert!(matches!(input(&canvas, &mut state,
mouse::Event::ButtonPressed(mouse::Button::Right), background),
Some(Event::ContextMenu { context, object, placement: None, anchor })
Some(Event::ContextMenu { context, object, placement: None, anchor, .. })
if context == canvas.context && object == f.diagram && anchor == background));
}
@@ -2484,6 +2883,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();
+240 -1
View File
@@ -20,6 +20,7 @@ pub enum Tool {
Region,
Transition,
Reconnect { relation: Id, endpoint: Endpoint },
AdjustAttachment { relation: Id, end: EdgeEnd },
}
#[derive(Debug, Clone)]
@@ -38,6 +39,8 @@ pub enum Event {
object: Id,
placement: Option<Id>,
anchor: iced::Point,
bend: Option<usize>,
route_segment: Option<(usize, Position)>,
},
CreateAt {
context: Context,
@@ -66,6 +69,32 @@ pub enum Event {
endpoint: Endpoint,
vertex: Id,
},
SetAttachment {
context: Context,
relation: Id,
end: EdgeEnd,
placement: Id,
attachment: Option<EdgeAttachment>,
},
SetBends {
context: Context,
relation: Id,
waypoints: Vec<Position>,
},
AddBend {
context: Context,
relation: Id,
segment: Option<(usize, Position)>,
},
RemoveBend {
context: Context,
relation: Id,
index: usize,
},
ClearBends {
context: Context,
relation: Id,
},
}
impl Event {
@@ -84,7 +113,12 @@ 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, .. }
| Self::SetBends { context: c, .. }
| Self::AddBend { context: c, .. }
| Self::RemoveBend { context: c, .. }
| Self::ClearBends { context: c, .. } => Some(*c),
}
}
}
@@ -249,6 +283,54 @@ 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<projection::ProjectedNode> {
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()
}
/// Actual visible State transition geometry, never an internal row or folded proxy.
pub fn bend_route(
project: &Project,
diagram: Id,
relation: Id,
) -> Option<oa_core::routing::RoutedEdge> {
machine(project, diagram).ok()?;
if project.relations.get(&relation)?.kind() != RelationKind::StateTransition {
return None;
}
let stored = project.diagrams.get(&diagram)?;
let view = projection::project_diagram(project, stored);
let geometry = oa_core::routing::route_diagram(project, stored, &view)
.edges
.remove(&relation)?;
(!geometry.custom_suspended).then_some(geometry)
}
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
prepare_command(project, &plan.command)?;
Ok(plan)
@@ -493,6 +575,82 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
relation,
None,
),
Event::SetAttachment {
relation,
end,
placement,
attachment,
..
} => {
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)
}
Event::SetBends { relation, .. }
| Event::AddBend { relation, .. }
| Event::RemoveBend { relation, .. }
| Event::ClearBends { relation, .. } => {
let geometry = bend_route(project, diagram, relation)
.ok_or_else(|| error("Choose an actual visible transition route in this view."))?;
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
match event {
Event::SetBends { waypoints, .. } => {
if waypoints.len() > 128 {
return Err(error("A route supports at most 128 bends."));
}
for &point in waypoints {
finite(point)?;
}
route.waypoints = waypoints.clone();
}
Event::AddBend { segment, .. } => {
let (segment, point) = segment
.or_else(|| {
geometry
.points
.windows(2)
.enumerate()
.filter(|(index, _)| {
*index > 0 && index + 2 < geometry.points.len()
})
.max_by(|(_, a), (_, b)| {
let length = |s: &[Position]| {
(s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs()
};
length(a).total_cmp(&length(b))
})
.map(|(index, span)| {
(
index,
Position {
x: (span[0].x + span[1].x) / 2.,
y: (span[0].y + span[1].y) / 2.,
},
)
})
})
.ok_or_else(|| error("This route has no interior segment for a bend."))?;
crate::route_edit::insert_pin_on_segment(&mut route, &geometry, segment, point)
.ok_or_else(|| {
error("The bend segment is stale, invalid, or at capacity.")
})?;
}
Event::RemoveBend { index, .. } => {
if *index >= route.waypoints.len() {
return Err(error("This bend no longer exists."));
}
route.waypoints.remove(*index);
}
Event::ClearBends { .. } => route.waypoints.clear(),
_ => unreachable!(),
}
(Command::SetEdgeRoute { diagram, route }, relation, None)
}
_ => {
return Err(error(
"Handle this navigation or tool request without a model command.",
@@ -581,6 +739,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();
@@ -764,6 +1001,8 @@ mod tests {
.routes
.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pin],
label: None,
});
@@ -161,6 +161,53 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
);
entries.push(item("Edit transition", edit, true));
if let Some(context) = context {
let bend_geometry = state_edit::bend_route(project, context.diagram, id);
let bend_count = bend_geometry.as_ref().map_or(0, |route| route.pins.len());
let can_add = bend_geometry.as_ref().is_some_and(|route| {
bend_count < 128
&& request.route_segment.map_or_else(
|| route.points.len() >= 4,
|(index, point)| {
index
.checked_add(2)
.is_some_and(|end| end <= route.points.len())
&& point.x.is_finite()
&& point.y.is_finite()
},
)
});
entries.push(item(
if request.route_segment.is_some() {
"Add bend here"
} else {
"Add bend"
},
diagram_event(Event::AddBend {
context,
relation: id,
segment: request.route_segment,
}),
can_add,
));
if let Some(index) = request.bend {
entries.push(item(
"Remove bend",
diagram_event(Event::RemoveBend {
context,
relation: id,
index,
}),
bend_geometry.is_some() && index < bend_count,
));
}
entries.push(item(
"Clear bends",
diagram_event(Event::ClearBends {
context,
relation: id,
}),
bend_geometry.is_some() && bend_count > 0,
));
let visible = projection::project_view(project, context.diagram)
.edges
.iter()
@@ -178,6 +225,44 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
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",
+160 -2
View File
@@ -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<usize>,
pub selected_attachment: Option<EdgeEnd>,
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<Context>,
route_drag: Option<Gesture>,
attachment_viewport: Option<(f32, Vector, Rectangle)>,
connection_press: Option<(Id, Point)>,
connection_hover: Option<Point>,
port_drag: Option<crate::port_edit::Gesture>,
@@ -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<Id> {
let p = self.world(p);
routed
@@ -561,6 +604,24 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
// 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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(
@@ -2140,6 +2286,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2216,6 +2363,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2341,6 +2489,7 @@ mod tests {
selected: p.id,
navigation_focus: None,
selected_bend: None,
selected_attachment: None,
zoom,
pan: Vector::new(20., 30.),
editable: true,
@@ -2478,6 +2627,8 @@ mod tests {
diagram,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 400., y: -100. }],
label: None,
},
@@ -2520,6 +2671,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2597,6 +2749,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2695,6 +2848,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2824,6 +2978,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2879,6 +3034,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2954,6 +3110,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -2988,6 +3145,7 @@ mod tests {
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
@@ -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<Message> {
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::<Vec<_>>();
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
);
}
+5 -1
View File
@@ -78,7 +78,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
}
if state.context.is_none_or(|previous| {
(previous.document, previous.diagram, previous.epoch)
!= (self.context.document, self.context.diagram, self.context.epoch)
!= (
self.context.document,
self.context.diagram,
self.context.epoch,
)
}) || !self.enabled
{
state.focused = false;
@@ -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(),
@@ -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(),
@@ -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,
@@ -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,
+3 -2
View File
@@ -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,8 @@ 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.",
"To shape a selected transition, drag an exposed line segment to add a bend, or drag a square bend handle to move it. Right-click a square and choose Remove bend. The transition's Actions menu also offers Add bend and Clear bends; clearing bends keeps its attachment points and label placement. Bends belong to this saved view. A click alone changes nothing, Escape cancels a drag, and Undo restores the complete edit. Internal transition rows and folded connections do not expose bend editing while their route is hidden.",
"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.",
],
),
+59
View File
@@ -14,6 +14,7 @@ pub struct Request {
pub placement: Option<Id>,
pub bend: Option<usize>,
pub route_point: Option<Position>,
pub route_segment: Option<(usize, Position)>,
pub creation_point: Option<Position>,
pub canvas: Option<route_edit::Context>,
pub source_file: Option<String>,
@@ -26,6 +27,7 @@ impl Request {
placement: None,
bend: None,
route_point: None,
route_segment: None,
creation_point: None,
canvas: None,
source_file: None,
@@ -240,6 +242,33 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
.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 +929,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();
+90 -1
View File
@@ -14,6 +14,9 @@ pub struct Context {
pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
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<EdgeAttachment>,
},
}
#[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,10 +131,40 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
edge,
waypoints: Vec::new(),
label: None,
source_attachment: None,
target_attachment: None,
})
}
/// Insert after the pins preceding the clicked segment along the routed path.
pub fn insert_pin_on_segment(
route: &mut EdgeRoute,
geometry: &RoutedEdge,
segment: usize,
point: Position,
) -> Option<usize> {
if route.edge != geometry.id
|| geometry.custom_suspended
|| route.waypoints != geometry.pins
|| route.waypoints.len() >= 128
|| !point.x.is_finite()
|| !point.y.is_finite()
|| geometry
.points
.get(segment..segment.checked_add(2)?)
.is_none()
{
return None;
}
let index = pin_insertion_index(geometry, segment, point);
if index > route.waypoints.len() {
return None;
}
route.waypoints.insert(index, point);
Some(index)
}
/// Find a segment when an ordinary caller has only a point, not a captured hit.
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
let segment = geometry
.points
+2
View File
@@ -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,
+2
View File
@@ -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,
+6
View File
@@ -479,6 +479,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)
@@ -235,6 +235,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,
+2
View File
@@ -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/scenario_layout.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(&current.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::<Project>(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(&current);
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, &current, commands.clone(), false).await;
assert_eq!(noop.status(), StatusCode::OK);
assert_eq!(noop.headers()["etag"], current.etag());
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
let stale = json!([
Command::Rename(id(&current.model, "first"), "Must not persist".into()),
commands[0].clone()
]);
assert_eq!(
transact(&server, &original, stale, false).await.status(),
StatusCode::PRECONDITION_FAILED
);
assert_unchanged(&server, &current).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(&current);
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::<SourceBundle>().await.unwrap(),
current.source_project
);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_unchanged(&server, &current).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, &current, 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::<Value>().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(&current.model, "view");
let first = id(&current.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(&current.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, &current, commands, false).await;
assert_eq!(
response.status(),
status,
"{field} {bad}: {}",
response.text().await.unwrap()
);
assert_unchanged(&server, &current).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, &current).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;
}
+28 -1
View File
@@ -2,7 +2,7 @@
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="utf-8" />
<meta name="generator" content="Docutils 0.22.2: https://docutils.sourceforge.io/" />
<meta name="generator" content="Docutils 0.23: https://docutils.sourceforge.io/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Arcadia Studio model REST API</title>
<style type="text/css">
@@ -947,6 +947,33 @@ authored files and revision. JSON/source imports may omit <span class="docutils
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
</section>
<section id="diagram-connection-attachments">
<h2>Diagram connection attachments</h2>
<p><span class="docutils literal">EdgeRoute.source_attachment</span> and <span class="docutils literal">target_attachment</span> are optional
<span class="docutils literal"><span class="pre">{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25}</span></span> records. Side and a finite offset in
<span class="docutils literal">[0,1]</span> are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts:</p>
<pre class="literal-block">{&quot;commands&quot;:[{&quot;SetEdgeRoute&quot;:{
&quot;diagram&quot;:&quot;&lt;diagram UUID&gt;&quot;,
&quot;route&quot;:{&quot;edge&quot;:&quot;&lt;relation or trace UUID&gt;&quot;,
&quot;source_attachment&quot;:{&quot;side&quot;:&quot;Bottom&quot;,&quot;offset&quot;:0.25},
&quot;waypoints&quot;:[],&quot;label&quot;:null}
}}]}</pre>
<p><span class="docutils literal">SetEdgeRoute</span> replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. <span class="docutils literal">RemoveEdgeRoute</span> resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.</p>
<p>These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
<span class="docutils literal">language.rst</span> section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.</p>
</section>
<section id="contextual-process-and-chain-topology">
<h2>Contextual process and chain topology</h2>
<p>Operational processes and system functional chains can combine work
+31
View File
@@ -329,6 +329,37 @@ authored files and revision. JSON/source imports may omit ``port_anchors``;
it defaults to ``[]`` and empty lists are omitted on export. The normative
diagram semantics and source spelling are in ``language.rst`` section 5.
Diagram connection attachments
-------------------------------
``EdgeRoute.source_attachment`` and ``target_attachment`` are optional
``{"side":"Bottom","offset":0.25}`` records. Side and a finite offset in
``[0,1]`` are required within a supplied record; unknown fields are rejected.
Absence/null means automatic placement. Omitted fields in older schema-3
models retain that meaning. The normal transaction endpoint accepts::
{"commands":[{"SetEdgeRoute":{
"diagram":"<diagram UUID>",
"route":{"edge":"<relation or trace UUID>",
"source_attachment":{"side":"Bottom","offset":0.25},
"waypoints":[],"label":null}
}}]}
``SetEdgeRoute`` replaces the complete route entry. Clients changing one end
must retain its other attachment, bends and label; omit/null only the intended
attachment to restore that end's automatic position. ``RemoveEdgeRoute`` resets
all presentation for that edge. Both support ETag checking, dry runs, atomic
validation and source-project writeback. Invalid offsets reject the whole
transaction without changing files or revision.
These are per-view presentation preferences, not a semantic reconnect. Fractions
follow the projected shape, including moves/resizes; semantic reconnect retains
them as preferences of the same edge-end slots. Real semantic ports take
precedence, and promoted folded endpoints suspend authored routing. The complete
boundary mapping, lifecycle and generated-view limitations are normative in
``language.rst`` section 5. GET model/source, PUT source, desktop and transactions
share this representation; no separate attachment endpoint exists.
Contextual process and chain topology
-------------------------------------
+45 -2
View File
@@ -1785,14 +1785,16 @@ the Reparent command. Moving, resizing, arranging, or folding an owner retains
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), <span class="docutils literal">label</span>
(default <span class="docutils literal">none</span>), and <span class="docutils literal">source_attachment</span> / <span class="docutils literal">target_attachment</span> (each
default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
need not currently be visible in their diagram.</p>
<pre class="code text literal-block"><code>routes [
{ edge &#64;report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1809,6 +1811,47 @@ are structural errors. A pinned point that obstructs routing remains stored;
geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against <span class="docutils literal">ParseLimits.max_records</span>; pinned
points are bounded by the per-route limit and aggregate input byte limits.</p>
<p>An attachment is an EdgeAttachment record with required <span class="docutils literal">side</span> (Left, Right,
Top, Bottom) and required finite binary32 <span class="docutils literal">offset</span> from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (<span class="docutils literal">invalid_attachment_offset</span> for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.</p>
<p>The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.</p>
<p>For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
<span class="docutils literal">t = 2 * offset - 1</span> and <span class="docutils literal">D</span> be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is <span class="docutils literal">D/2 * (1 - <span class="pre">sqrt(1-t*t))</span></span> for an
ellipse and <span class="docutils literal">D/2 * abs(t)</span> for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset <span class="docutils literal">D * min(offset, <span class="pre">1-offset)</span></span>. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.</p>
<p>An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.</p>
<p><span class="docutils literal">SetEdgeRoute { diagram, route }</span> upserts an entry by edge identity, retaining
the order and contents of unrelated entries. <span class="docutils literal">RemoveEdgeRoute { diagram, edge }</span>
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+49 -2
View File
@@ -1167,8 +1167,9 @@ its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
``port_anchors``; their explicit migration supplies an empty list.
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
(default ``none``). Route records have no separate UUID or alias. An edge occurs
to an existing Relation or Trace, ``waypoints`` (default ``[]``), ``label``
(default ``none``), and ``source_attachment`` / ``target_attachment`` (each
default ``none``). Route records have no separate UUID or alias. An edge occurs
at most once in a diagram's route list; different diagrams MAY store independent
routes for the same edge. List order and explicit empty entries are retained by
source and JSON round trips, although list order has no routing meaning. Edges
@@ -1178,6 +1179,7 @@ need not currently be visible in their diagram.
routes [
{ edge @report_exchange;
source_attachment { side Bottom; offset 0.25; };
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
label { fraction 0.55; offset { x 0; y -16; }; };
}
@@ -1196,6 +1198,51 @@ geometric routing difficulty is reported separately from structural validity.
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
points are bounded by the per-route limit and aggregate input byte limits.
An attachment is an EdgeAttachment record with required ``side`` (Left, Right,
Top, Bottom) and required finite binary32 ``offset`` from zero through one.
Unknown fields, omitted nested fields and out-of-range offsets are structural
errors (``invalid_attachment_offset`` for the latter). Attachments are bounded
inline values, at most two per route, not additional graph objects or records.
JSON uses the same field names, enum strings and number values. Omitted or null
attachments mean automatic positioning; canonical compact source and JSON omit
absent attachments. Earlier schema-3 input hydrates both fields as absent.
The end names designate stored source/target slots, even for undirected edges.
These values MUST NOT change semantic endpoints, ports, ownership, type,
orientation or identity. Each preference belongs to that edge end in that view:
another view remains independent. It follows the current endpoint's projected
boundary under movement, resize or semantic reconnect; reconnect preserves the
preference, as it preserves authored bends. The fraction does not identify the
previous semantic endpoint. Deletion/reidentification of the edge follows the
existing whole-route rules; moving, hiding or replacing its placement retains
the preference. Removing one attachment restores that end's automatic position
without changing bends, labels or the opposite attachment.
For Left/Right, offset gives the Y fraction of the complete projected bounding
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
their width/height; Regions and fork/join bars are rectangles. State circles use
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
``t = 2 * offset - 1`` and ``D`` be width for Left/Right or height for Top/Bottom.
The inset from the selected bounding side is ``D/2 * (1 - sqrt(1-t*t))`` for an
ellipse and ``D/2 * abs(t)`` for a diamond. A Terminate X uses the outermost
painted-stroke intersection, inset ``D * min(offset, 1-offset)``. Zero and one
are valid; different side/fraction pairs can describe the same painted point.
Routing leads extend in the selected cardinal direction beyond the bounding box
before turning. Source/API fractions retain their precision; pointer editing
snaps fractions to hundredths without changing the stored semantic record.
An actual semantic Port remains authoritative, including when projection hides
its port marker. An edge attachment on that end is retained but dormant; use
the port's own placement anchor to move a port. When folding promotes an endpoint
to an ancestor, all authored route data is suspended and resumes on expansion.
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
tips retain their positions with an external loop and a routing warning; the
editor must offer an explicit way to choose either end even when handles overlap.
Internal state transitions have no external connection. Process's generated
topology and Scenario's lifeline/activation/time-derived tips are not consumers
of these diagram route fields; this feature does not redefine their semantics.
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
removes it; removing an absent entry is a no-op for an existing diagram and edge.
+45 -1
View File
@@ -2137,15 +2137,59 @@
],
"additionalProperties": false
},
"EdgeAttachment": {
"type": "object",
"description": "Per-view preference on one stored connection end, not a semantic endpoint. Offset is finite and measured along the complete projected bounding-box side. Real semantic ports take precedence; hidden/promoted geometry retains the preference. Reconnect preserves the preference on its source/target slot.",
"properties": {
"side": {
"$ref": "#/components/schemas/PortAnchorSide"
},
"offset": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Left/Right: top-to-bottom fraction. Top/Bottom: left-to-right fraction. Shape-aware geometry derives the actual contour point."
}
},
"required": [
"side",
"offset"
],
"additionalProperties": false
},
"EdgeRoute": {
"type": "object",
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
"description": "One diagram's authored presentation for an existing Relation or Trace, including currently hidden edges. No separate identity. Stores optional endpoint preferences, pinned points and label placement; explicit empty routes are retained. Removing the route resets all authored presentation.",
"properties": {
"edge": {
"type": "string",
"format": "uuid",
"description": "Existing Relation or Trace identity, never a node placement."
},
"source_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"target_attachment": {
"oneOf": [
{
"$ref": "#/components/schemas/EdgeAttachment"
},
{
"type": "null"
}
],
"default": null,
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
},
"waypoints": {
"type": "array",
"items": {