Add editable connection routes and shared native diagram geometry

This commit is contained in:
2026-09-05 21:18:42 +03:00
parent 65fa9ab276
commit 23535b4342
10 changed files with 1290 additions and 188 deletions
+148 -21
View File
@@ -93,6 +93,7 @@ pub enum Message {
HideSelected,
ToggleCollapse,
MoveNode(Id, Position),
Route(crate::route_edit::Event),
Zoom(f32),
Pan(iced::Vector),
Fit,
@@ -126,6 +127,9 @@ pub enum Message {
}
pub struct App {
pub canvas_document: Id,
pub canvas_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
pub exact_strings: bool,
pub session: Session,
pub page: Page,
@@ -196,6 +200,9 @@ impl App {
.map(|d| d.id);
let draft = Draft::from_project(&project, selected);
Self {
canvas_document: Id::new_v4(),
canvas_epoch: 0,
selected_bend: None,
exact_strings: false,
session: Session::new(project),
page: Page::Diagram,
@@ -579,6 +586,13 @@ impl App {
{
return Task::none();
}
if matches!(
message,
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
let previous = self.selected;
let task = self.handle(message);
self.normalize_creation();
@@ -593,7 +607,11 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::Escape => self.source_navigation.open = false,
Message::Escape => {
self.source_navigation.open = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
Message::Guide(event) => self.guide.update(event),
Message::ExactStrings(enabled) => {
if let Some(d) = &mut self.draft {
@@ -967,6 +985,7 @@ impl App {
description: String::new(),
layer: self.layer,
projection: ProjectionKind::Allocation,
routes: Vec::new(),
nodes: Default::default(),
};
if self.execute(Command::CreateDiagram(d)) {
@@ -1130,6 +1149,7 @@ impl App {
}
}
}
Message::Route(event) => self.edit_route(event),
Message::MoveNode(node, position) => {
let original = self.selected;
if self.apply()
@@ -1513,6 +1533,7 @@ impl App {
description: String::new(),
layer: self.layer,
projection: preferred,
routes: Vec::new(),
nodes: Default::default(),
}));
diagram_target = Some(view);
@@ -1833,6 +1854,122 @@ impl App {
move |result| Message::Linted(version, result),
)
}
pub fn route_context(&self) -> Option<crate::route_edit::Context> {
Some(crate::route_edit::Context {
document: self.canvas_document,
diagram: self.active_diagram?,
revision: self.session.revision(),
epoch: self.canvas_epoch,
})
}
fn edit_route(&mut self, event: crate::route_edit::Event) {
use crate::route_edit::{Event, insert_pin, stored_route};
let Some(context) = self.route_context() else {
return;
};
if self.source_dirty() || self.draft_dirty() {
self.status = "Apply or revert your draft before editing a connection route.".into();
return;
}
let diagram = &self.project().diagrams[&context.diagram];
let view = project_view(self.project(), context.diagram);
let Some(projected_edge) = view.edges.iter().find(|e| e.id == self.selected) else {
return;
};
if projection::edge_endpoint_placements(self.project(), diagram, self.selected).is_some_and(
|(source, target)| source != projected_edge.source || target != projected_edge.target,
) {
self.status =
"Expand the endpoint containers to edit this connection’s saved route.".into();
return;
}
let mut route = stored_route(diagram, self.selected);
let mut selected_bend = self
.selected_bend
.filter(|(id, _)| *id == self.selected)
.map(|(_, i)| i);
let command = match event {
Event::SelectBend(id, index) => {
if id == self.selected && index < route.waypoints.len() {
self.selected_bend = Some((id, index));
}
return;
}
Event::Commit(expected, changed, bend) => {
if expected != context || changed.edge != self.selected {
self.status =
"Route gesture cancelled because the model or view changed.".into();
return;
}
route = changed;
selected_bend = bend;
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::AddBend => {
if route.waypoints.len() >= 128 {
return;
}
let geometry = oa_core::routing::route_diagram(self.project(), diagram, &view);
let edge = &geometry.edges[&self.selected];
// Mandatory port leads are inside the card's clearance. A pin
// starts beyond those leads, including coincident short stubs.
let segment = edge
.points
.windows(2)
.enumerate()
.filter(|(index, _)| *index > 0 && index + 2 < edge.points.len())
.map(|(_, span)| span)
.max_by(|a, b| {
let len =
|s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
len(a).total_cmp(&len(b))
});
let point = if let Some(segment) = segment {
Position {
x: (segment[0].x + segment[1].x) / 2.,
y: (segment[0].y + segment[1].y) / 2.,
}
} else if let Some(stub) = edge.points.get(1) {
*stub
} else {
return;
};
selected_bend = Some(insert_pin(&mut route, edge, point));
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::RemoveBend => {
let Some(index) = selected_bend.filter(|i| *i < route.waypoints.len()) else {
return;
};
route.waypoints.remove(index);
selected_bend = None;
Command::SetEdgeRoute {
diagram: context.diagram,
route,
}
}
Event::Reset => {
selected_bend = None;
Command::RemoveEdgeRoute {
diagram: context.diagram,
edge: self.selected,
}
}
};
let selected = self.selected;
if self.execute(command) {
self.selected = selected;
self.selected_bend = selected_bend.map(|index| (selected, index));
self.refresh_draft();
self.status = "Connection route updated · Undo restores the previous route".into();
}
}
pub fn fit(&mut self) {
self.fit_to_view = true;
let view = self
@@ -1844,26 +1981,15 @@ impl App {
self.pan = iced::Vector::new(30., 30.);
return;
}
let left = view
.nodes
.iter()
.map(|n| n.position.x)
.fold(f32::INFINITY, f32::min);
let top = view
.nodes
.iter()
.map(|n| n.position.y)
.fold(f32::INFINITY, f32::min);
let right = view
.nodes
.iter()
.map(|n| n.position.x + n.size.width)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = view
.nodes
.iter()
.map(|n| n.position.y + n.size.height)
.fold(f32::NEG_INFINITY, f32::max);
let geometry = oa_core::routing::route_diagram(
self.project(),
&self.project().diagrams[&self.active_diagram.unwrap()],
&view,
);
let left = geometry.bounds.position.x;
let top = geometry.bounds.position.y;
let right = left + geometry.bounds.size.width;
let bottom = top + geometry.bounds.size.height;
let size = self.canvas_size.unwrap_or_else(|| {
iced::Size::new(
self.window_size.width - 230. - 320.,
@@ -1988,6 +2114,7 @@ mod port_view_tests {
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Capabilities,
routes: Vec::new(),
nodes: Default::default(),
},
);
File diff suppressed because it is too large Load Diff
+23
View File
@@ -218,6 +218,10 @@ impl Draft {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else {
json!(Id::nil())
}),
@@ -382,6 +386,7 @@ fn reference(path: &str) -> bool {
| "context"
| "represented"
| "exchange"
| "edge"
| "sender"
| "receiver"
| "action"
@@ -524,6 +529,14 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
.map(Element::layer)
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())));
let field = path.rsplit('/').next().unwrap_or_default();
if field == "edge" {
return project
.relations
.keys()
.chain(project.traces.keys())
.copied()
.collect();
}
let list = path.split('/').rev().nth(1).unwrap_or_default();
let semantic = |id| {
project
@@ -1336,6 +1349,13 @@ fn property<'a>(
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
if path.starts_with("/routes/") && path.ends_with("/label") {
out = out.push(
button(text("Automatic label position").size(11))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
for (key, child) in map {
if key == "nodes" {
continue;
@@ -1414,6 +1434,8 @@ fn property<'a>(
json!(1)
} else if path.ends_with("baseline") {
json!({"oa":{"name":"","description":""},"sa":{"name":"","description":""}})
} else if path.starts_with("/routes/") && path.ends_with("/label") {
json!({"fraction":0.5,"offset":{"x":0.0,"y":-18.0}})
} else {
json!("")
};
@@ -1813,6 +1835,7 @@ mod tests {
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Overview,
routes: Vec::new(),
nodes: BTreeMap::new(),
},
);
+9 -1
View File
@@ -264,7 +264,7 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.",
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize",
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset",
sections: &[
explain(
"Three things to distinguish",
@@ -288,6 +288,14 @@ fn article(topic: Topic) -> Article {
"Read allocation summaries and ports",
"A dashed parent activity/function with an italic name and SUMMARY heading groups leaves assigned to the same performer. It has no direct allocation of its own; its inspector explains the leaf performer. Small ports belong to the boundary of their visible owner and are selectable model elements. Folding a container hides its inner work and ports; summary exchange lines do not invent new ports on the container.",
),
walkthrough(
"Route connections around the model",
&[
"Select an architecture connection. Drag a line segment to add a pinned bend, or choose Add bend; drag its circles to shape a detour. Select a circle and choose Remove bend to release that pin. Drag the connection label independently. Escape cancels a gesture; Undo reverses one completed drag.",
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
"Reset routing clears the selected connection’s pins and label position in this view. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
],
),
explain(
"What nesting does not imply",
"Containment in an allocation projection expresses a performer relationship. It is not a physical deployment statement. Physical deployment is a different architectural concern; do not infer hardware installation from an OA/SA box layout.",
+1
View File
@@ -8,6 +8,7 @@ mod highlight;
mod navigation;
mod port_preview;
mod port_workflow;
mod route_edit;
mod source_navigation;
mod style;
#[cfg(test)]
+1
View File
@@ -677,6 +677,7 @@ mod tests {
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Overview,
routes: Vec::new(),
nodes: Default::default(),
},
);
+108
View File
@@ -0,0 +1,108 @@
//! Transient connection gestures. Only their final route enters model history.
use iced::Point;
use oa_core::{routing::*, *};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
pub document: Id,
pub diagram: Id,
pub revision: u64,
pub epoch: u64,
}
#[derive(Debug, Clone)]
pub enum Event {
SelectBend(Id, usize),
Commit(Context, EdgeRoute, Option<usize>),
AddBend,
RemoveBend,
Reset,
}
#[derive(Debug, Clone)]
pub enum Handle {
Bend(usize),
Label { original: Position },
}
#[derive(Debug, Clone)]
pub struct Gesture {
pub context: Context,
pub start: Point,
pub handle: Handle,
pub route: EdgeRoute,
pub changed: bool,
pub baseline: std::sync::Arc<RoutedView>,
}
impl Gesture {
pub fn move_to(&mut self, point: Point, zoom: f32) {
let delta = point - self.start;
if !self.changed && (delta.x * delta.x + delta.y * delta.y) * zoom * zoom < 9. {
return;
}
self.changed = true;
match self.handle {
Handle::Bend(index) => {
self.route.waypoints[index] = Position {
x: (point.x / 5.).round() * 5.,
y: (point.y / 5.).round() * 5.,
}
}
Handle::Label { original } => {
if let Some(label) = &mut self.route.label {
label.offset = Position {
x: original.x + delta.x,
y: original.y + delta.y,
};
}
}
}
}
}
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
diagram
.routes
.iter()
.find(|r| r.edge == edge)
.cloned()
.unwrap_or(EdgeRoute {
edge,
waypoints: Vec::new(),
label: None,
})
}
/// Insert after the pins preceding the clicked segment along the routed path.
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
let segment = geometry
.points
.windows(2)
.enumerate()
.min_by(|(_, a), (_, b)| {
let distance = |span: &[Position]| {
project_onto_polyline(span, point).map_or(f32::INFINITY, |p| p.distance)
};
distance(a).total_cmp(&distance(b))
})
.map_or(0, |(index, _)| index);
let index = pin_insertion_index(geometry, segment, point);
route.waypoints.insert(index, point);
index
}
pub fn with_route(diagram: &mut Diagram, route: EdgeRoute) {
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
*existing = route;
} else {
diagram.routes.push(route);
}
}
pub fn point(p: Position) -> Point {
Point::new(p.x, p.y)
}
pub fn position(p: Point) -> Position {
Position { x: p.x, y: p.y }
}
+99
View File
@@ -1351,3 +1351,102 @@ fn nested_allocation_insertion_reflows_sibling_subtrees_and_preserves_fold_state
}
}
}
#[test]
fn routing_actions_are_atomic_guarded_and_fit_the_authored_detour() {
use crate::route_edit::Event as Route;
let mut p = Project::empty();
let sender = p.add(Kind::Activity, "Report", 0., 0.);
let target = p.add(Kind::Activity, "Receive", 600., 0.);
let edge = p.relate(RelationKind::Interaction, "Report signal", sender, target);
let diagram = p.ensure_diagram(Layer::Oa);
let mut app = App::with_project(p.clone());
send(&mut app, Message::Select(edge));
send(&mut app, Message::Route(Route::AddBend));
assert_eq!(
app.project().diagrams[&diagram].routes[0].waypoints.len(),
1
);
let context = app.route_context().unwrap();
let mut route = app.project().diagrams[&diagram].routes[0].clone();
route.waypoints[0] = Position { x: 400., y: -700. };
send(
&mut app,
Message::Route(Route::Commit(context, route.clone(), Some(0))),
);
assert_eq!(app.project().diagrams[&diagram].routes[0], route);
send(&mut app, Message::Fit);
let pin = route.waypoints[0];
assert!(pin.y * app.zoom + app.pan.y >= 0.);
send(&mut app, Message::Route(Route::RemoveBend));
assert!(
app.project().diagrams[&diagram].routes[0]
.waypoints
.is_empty()
);
send(&mut app, Message::Undo);
assert_eq!(app.project().diagrams[&diagram].routes[0], route);
let accepted = app.project().clone();
send(
&mut app,
Message::Route(Route::Commit(context, route.clone(), Some(0))),
);
assert_eq!(
app.project(),
&accepted,
"A stale gesture must not overwrite newer routing"
);
let current = app.route_context().unwrap();
send(&mut app, Message::Escape);
send(
&mut app,
Message::Route(Route::Commit(current, route.clone(), Some(0))),
);
assert_eq!(
app.project(),
&accepted,
"Escape invalidates the gesture epoch"
);
send(&mut app, Message::Route(Route::Reset));
assert!(app.project().diagrams[&diagram].routes.is_empty());
send(&mut app, Message::Undo);
assert_eq!(app.project(), &accepted);
let current = app.route_context().unwrap();
source(&mut app, "invalid unsaved source");
send(
&mut app,
Message::Route(Route::Commit(current, route, Some(0))),
);
assert_eq!(app.project(), &accepted);
assert_eq!(app.source_editor.text(), "invalid unsaved source");
}
#[test]
fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
let mut p = Project::empty();
let diagram = p.ensure_diagram(Layer::Sa);
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Status", a, b);
p.place(diagram, a, Position { x: 0., y: 100. });
p.place(diagram, b, Position { x: 260., y: 100. });
let mut app = App::with_project(p);
send(&mut app, Message::Select(edge));
send(&mut app, Message::Route(crate::route_edit::Event::AddBend));
let p = app.project();
let view = projection::project_view(p, diagram);
let routed = routing::route_diagram(p, &p.diagrams[&diagram], &view);
assert!(
routed.edges[&edge].warnings.is_empty(),
"{:?}",
routed.edges[&edge].warnings
);
assert_eq!(
p.diagrams[&diagram].routes[0].waypoints,
vec![Position { x: 242., y: 192. }]
);
}
+64 -8
View File
@@ -304,18 +304,22 @@ pub fn view(app: &App) -> View<'_, Message> {
root,
column![
text(title).size(21),
label(
"Use .arcadia for canonical source, .json for the model, or .svg for diagrams."
),
label(match p.operation {
FileOperation::Export => "Save this diagram as .svg.",
_ => "Use .arcadia for canonical source or .json for the model.",
}),
text_input("File path", &p.path)
.on_input(Message::FilePath)
.on_submit(Message::SubmitFile)
.padding(10)
.size(13),
text(if p.overwrite {
"This file exists. Replace it with the current model?"
} else {
"Your model includes both layers, diagrams and traceability."
text(match (p.operation, p.overwrite) {
(FileOperation::Export, true) =>
"This file exists. Replace it with the current diagram?",
(FileOperation::Export, false) =>
"Includes visible elements and their authored connection routes.",
(_, true) => "This file exists. Replace it with the current model?",
(_, false) => "Your model includes both layers, diagrams and traceability.",
})
.size(12),
row![
@@ -472,6 +476,12 @@ fn diagram(app: &App) -> View<'_, Message> {
selected: app.selected,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: app
.selected_bend
.filter(|(edge, _)| *edge == app.selected)
.map(|(_, i)| i),
editable: !app.draft_dirty() && !app.source_dirty(),
})
.width(Fill)
.height(Fill),
@@ -500,7 +510,53 @@ fn diagram(app: &App) -> View<'_, Message> {
.center(Fill)
.into()
};
column![container(column![toolbar,modes,label("Drag to arrange · scroll to zoom · nesting expresses parent or allocation · select for properties")].spacing(8)).padding(14),canvas].into()
let mut tools = column![toolbar, modes].spacing(8);
if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram);
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
let promoted = edge.is_some_and(|edge| {
oa_core::projection::edge_endpoint_placements(
app.project(),
&app.project().diagrams[&diagram],
edge.id,
)
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
});
let enabled = edge.is_some() && !promoted && !app.draft_dirty() && !app.source_dirty();
let route = app.project().diagrams[&diagram]
.routes
.iter()
.find(|r| r.edge == app.selected);
let bend = app.selected_bend.filter(|(edge, index)| {
*edge == app.selected && route.is_some_and(|r| *index < r.waypoints.len())
});
// Keep canvas origin/size stable when switching between a node and edge.
tools = tools.push(
row![
button("Add bend").padding(8).on_press_maybe(
(enabled && route.is_none_or(|r| r.waypoints.len() < 128))
.then_some(Message::Route(crate::route_edit::Event::AddBend))
),
button("Remove bend").padding(8).on_press_maybe(
(enabled && bend.is_some())
.then_some(Message::Route(crate::route_edit::Event::RemoveBend))
),
button("Reset routing").padding(8).on_press_maybe(
(enabled && route.is_some())
.then_some(Message::Route(crate::route_edit::Event::Reset))
),
]
.spacing(7),
);
tools = tools.push(label(if promoted {
"Expand endpoint containers to edit this connection route"
} else if edge.is_some() {
"Drag a line to add a bend · drag circles or the label · Esc cancels"
} else {
"Drag to arrange · select a connection to edit its route · scroll to zoom"
}));
}
column![container(tools).padding(14), canvas].into()
}
fn inspector(app: &App) -> View<'_, Message> {
+3
View File
@@ -26,3 +26,6 @@
- Git is now explicitly authorized by the user. Establish a source baseline on codex/release1-engineering, then use focused commits at verified milestones. Use Git worktrees for independent writers when their scope can be partitioned; root integrates and owns native UI. Keep target/dist, recovery/store files and credentials outside version control. No remote publication is implied.
- User's hierarchy/port correction supersedes generic graph defaults. Architecture/dataflow/capability content differs by engineering purpose, while containment/allocation and port anchors remain derived from semantic records. The exact correction and remaining port metamodel work are in release-diagram-port-correction.md.
- Adopt `release-port-contract.md`: function input/output ports declare items; Standard/Flow component ports have separate orientation, interface roles and explicit function-port allocations. Required functional endpoints are generated/reused by one atomic planner. Schema3 migration preserves original generic ports as explicit unresolved contracts and generates only previously missing FE endpoints. This avoids guessing component kind, interface role or splitting stable InOut identities during import.
- Source/schema3 is now implemented. The original V2 comment-normalization decision is superseded for the first-release source-project work: validated bundle updates preserve concrete tokens/comments/whitespace, while explicit canonical export remains available. Desktop bundle editing and experimental nested syntax are separate acceptance gates; neither is implied by the pure-core updater.
- Connection routing is per diagram, keyed by an existing relationship/trace ID. Store ordered pinned points and relative label placement; derive orthogonal elbows in a pure Rust core shared by canvas, picking, Fit and SVG. Typed port anchors never move to improve a route. Bounded automatic routing provides an initial path; manual pins express engineering layout intent. Collapse temporarily suspends authored routing when endpoints are promoted, preserving it for expansion. One pointer gesture commits one atomic command; stale/invalid-draft gestures are rejected.