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
+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,
});