Add persisted state-machine diagram contexts and shared geometry

This commit is contained in:
2026-09-06 02:23:31 +03:00
parent fef2e0dbce
commit e7a81637dd
9 changed files with 1143 additions and 15 deletions
+1
View File
@@ -357,6 +357,7 @@ fn reference_field(key: &str) -> bool {
| "diagram"
| "edge"
| "port"
| "machine"
)
}
fn enum_field(key: &str) -> bool {
+13
View File
@@ -1190,6 +1190,7 @@ impl RelationBody {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ProjectionKind {
Overview,
Decomposition,
@@ -1197,10 +1198,21 @@ pub enum ProjectionKind {
Traceability,
FunctionalDataflow,
Capabilities,
StateMachine { machine: Id },
}
impl ProjectionKind {
pub fn state_machine(self) -> Option<Id> {
match self {
Self::StateMachine { machine } => Some(machine),
_ => None,
}
}
pub fn includes(self, kind: Kind) -> bool {
match self {
Self::StateMachine { .. } => {
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
}
Self::Allocation => kind.is_performer() || kind.is_work(),
Self::FunctionalDataflow => kind.is_work(),
Self::Capabilities => {
@@ -1221,6 +1233,7 @@ impl ProjectionKind {
impl std::fmt::Display for ProjectionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::StateMachine { .. } => "State machine",
Self::Allocation => "Architecture · allocated work",
Self::FunctionalDataflow => "Functional dataflow",
Self::Capabilities => "Missions & capabilities",
+65 -8
View File
@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod port_layout;
pub use port_layout::{port_anchor_offset, port_anchor_position};
#[path = "projection/state.rs"]
mod state;
pub(crate) use state::node_size as state_node_size;
pub use state::{state_header_height, state_machine_contains, state_symbol_size};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectedNode {
@@ -79,9 +83,13 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
.nodes
.values()
.filter(|n| {
p.elements
.get(&n.element)
.is_some_and(|e| e.kind() != Kind::Port && d.projection.includes(e.kind()))
p.elements.get(&n.element).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& d.projection
.state_machine()
.is_none_or(|machine| state_machine_contains(p, machine, e.id))
})
})
.map(|n| (n.element, n.id))
.collect();
@@ -191,6 +199,11 @@ pub fn translated_routes(
let (source, target) = edge_endpoint_placements(p, d, route.edge)?;
let source_delta = deltas.get(&source)?;
let target_delta = deltas.get(&target)?;
if d.projection.state_machine().is_some()
&& !state::boundary_moves_with(p, d, route.edge, source_delta, deltas)
{
return None;
}
if source_delta != target_delta
|| (source_delta.x == 0. && source_delta.y == 0.)
|| route.waypoints.is_empty()
@@ -221,7 +234,12 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
let Some(e) = p.elements.get(&n.element) else {
continue;
};
if e.kind() == Kind::Port || !d.projection.includes(e.kind()) {
if e.kind() == Kind::Port
|| !d.projection.includes(e.kind())
|| d.projection
.state_machine()
.is_some_and(|machine| !state_machine_contains(p, machine, e.id))
{
continue;
}
if d.projection != ProjectionKind::Traceability && e.layer() != d.layer {
@@ -255,6 +273,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
n.size
},
);
let size = if d.projection.state_machine().is_some() {
state::node_size(p, e, size, n.collapsed)
} else {
size
};
nodes.push(ProjectedNode {
id: n.id,
element: n.element,
@@ -273,7 +296,10 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
}
nodes.sort_by_key(|n| (n.depth, n.id));
enclose(p, &mut nodes);
enclose_with_headers(p, &mut nodes, d.projection.state_machine().is_some());
if d.projection.state_machine().is_some() {
state::snap_boundaries(p, &mut nodes);
}
let visible: BTreeSet<_> = nodes.iter().map(|n| n.id).collect();
let placed: BTreeMap<_, _> = d.nodes.values().map(|n| (n.element, n.id)).collect();
let anchor = |element: Id| {
@@ -313,6 +339,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
continue;
}
let included = match d.projection {
ProjectionKind::StateMachine { machine } => matches!(&r.body,
RelationBody::StateTransition { transition_kind, .. }
if *transition_kind != TransitionKind::Internal
&& state_machine_contains(p, machine, r.source())
&& state_machine_contains(p, machine, r.target())),
ProjectionKind::Allocation => matches!(
r.kind(),
RelationKind::Interaction
@@ -338,7 +369,12 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
continue;
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& source != target
&& (source != target
|| (d.projection.state_machine().is_some()
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| n.id == source && n.element == r.source())))
{
let (source_port, target_port) = match &r.body {
RelationBody::FunctionalExchange {
@@ -384,7 +420,13 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
}
}
let ports = port_layout::layout(p, d, &mut nodes, &edges);
// Dedicated state views contain state symbols, not semantic component ports.
// Port layout would re-enclose already snapped boundary vertices.
let ports = if d.projection.state_machine().is_some() {
Vec::new()
} else {
port_layout::layout(p, d, &mut nodes, &edges)
};
ProjectedView {
nodes,
edges,
@@ -393,13 +435,28 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
enclose_with_headers(p, nodes, false);
}
fn enclose_with_headers(p: &Project, nodes: &mut [ProjectedNode], state_view: bool) {
for index in (0..nodes.len()).rev() {
let child = nodes[index].clone();
if state_view
&& p.elements
.get(&child.element)
.is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint))
{
continue;
}
if let Some(parent) = child
.visual_parent
.and_then(|id| nodes.iter().position(|n| n.id == id))
{
let footer = port_footer(p, nodes[parent].element);
let header = if state_view {
state_header_height(p, nodes[parent].element)
} else {
64.
};
let p = &mut nodes[parent];
let right =
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
@@ -407,7 +464,7 @@ fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
p.position.x = p.position.x.min(child.position.x - 24.);
// Reserve two readable header lines above the first nested card.
p.position.y = p.position.y.min(child.position.y - 64.);
p.position.y = p.position.y.min(child.position.y - header);
p.size.width = right - p.position.x;
p.size.height = bottom - p.position.y;
}
+139
View File
@@ -0,0 +1,139 @@
//! State-specific presentation policies over the shared persisted placements.
use super::*;
/// Membership of a dedicated state view. Other projection kinds may retain
/// unrelated hidden placements and must not acquire this additional restriction.
pub fn state_machine_contains(p: &Project, machine: Id, element: Id) -> bool {
let Some(owner) = p
.elements
.get(&machine)
.filter(|e| e.kind() == Kind::StateMachine)
else {
return false;
};
p.elements.get(&element).is_some_and(|e| {
e.layer() == owner.layer()
&& (matches!(e.kind(), Kind::StateMachine | Kind::Region) || e.kind().is_state())
&& validation::ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
})
}
/// Fixed body bounds for UML-style state symbols. Their authored position is
/// the top-left of this body; labels and larger pointer targets are derived UI.
/// A State or Mode uses its authored rectangular size instead.
pub fn state_symbol_size(kind: Kind) -> Option<Size> {
let (width, height) = match kind {
Kind::Initial => (16., 16.),
Kind::Junction => (10., 10.),
Kind::Fork | Kind::Join => (8., 36.),
Kind::Choice | Kind::ShallowHistory | Kind::DeepHistory => (28., 28.),
Kind::Final | Kind::EntryPoint | Kind::ExitPoint | Kind::Terminate => (24., 24.),
_ => return None,
};
Some(Size { width, height })
}
/// Reserved content before nested Regions. The same boundary is used by layout,
/// routing and the state editor's action/internal-transition rows.
pub fn state_header_height(p: &Project, element: Id) -> f32 {
let Some(e) = p.elements.get(&element) else {
return 64.;
};
if !e.kind().is_stable_state() {
return 64.;
}
let actions = matches!(e.shared(), Some(SharedBody::State { entry, exit, do_activity, .. })
if !entry.is_empty() || !exit.is_empty() || !do_activity.is_empty());
let internal = p
.relations
.values()
.filter(|r| {
matches!(&r.body,
RelationBody::StateTransition { source, transition_kind: TransitionKind::Internal, .. }
if *source == element)
})
.count();
64. + if actions { 28. } else { 0. } + internal as f32 * 56.
}
pub(crate) fn node_size(p: &Project, e: &Element, size: Size, collapsed: bool) -> Size {
if let Some(size) = state_symbol_size(e.kind()) {
return size;
}
Size {
width: size.width.max(if e.kind().is_stable_state() {
184.
} else {
224.
}),
height: size.height.max(if collapsed {
94.
} else {
state_header_height(p, e.id) + 20.
}),
}
}
pub(super) fn snap_boundaries(p: &Project, nodes: &mut [ProjectedNode]) {
let geometry: BTreeMap<_, _> = nodes
.iter()
.map(|n| (n.element, (n.position, n.size)))
.collect();
for node in nodes {
let Some(e) = p.elements.get(&node.element) else {
continue;
};
if !matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint) {
continue;
}
let boundary = e
.parent
.and_then(|id| p.elements.get(&id))
.and_then(|region| region.parent);
let Some((position, size)) = boundary.and_then(|id| geometry.get(&id)) else {
// Partial views may omit the containing boundary. Do not invent a
// placement or silently attach the symbol to a different container.
continue;
};
node.position.x = position.x
+ if e.kind() == Kind::ExitPoint {
size.width
} else {
0.
}
- node.size.width / 2.;
let top = position.y + boundary.map_or(64., |id| state_header_height(p, id));
let bottom = (position.y + size.height - node.size.height - 12.).max(top);
node.position.y = node.position.y.clamp(top, bottom);
}
}
// Entry/Exit positions are attached to a containing State/Machine, not the
// Region that owns them semantically. Moving only that Region changes the
// symbol's authored position but does not translate its snapped boundary.
pub(super) fn boundary_moves_with(
p: &Project,
d: &Diagram,
edge: Id,
delta: &Position,
deltas: &BTreeMap<Id, Position>,
) -> bool {
let Some(r) = p.relations.get(&edge) else {
return true;
};
[r.source(), r.target()].into_iter().all(|id| {
let Some(e) = p.elements.get(&id) else {
return true;
};
if !matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint) {
return true;
}
let boundary = e
.parent
.and_then(|id| p.elements.get(&id))
.and_then(|region| region.parent);
boundary
.and_then(|id| d.nodes.values().find(|n| n.element == id))
.is_none_or(|node| deltas.get(&node.id) == Some(delta))
})
}
+11
View File
@@ -181,6 +181,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
*machine = new;
}
for route in &mut d.routes {
if route.edge == old {
route.edge = new;
@@ -280,6 +285,12 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
}
}
for d in p.diagrams.values() {
if d.projection
.state_machine()
.is_some_and(|machine| removed.contains(&machine))
{
removed.insert(d.id);
}
for n in d.nodes.values() {
if removed.contains(&d.id) || removed.contains(&n.element) {
removed.insert(n.id);
+93 -3
View File
@@ -338,6 +338,19 @@ fn route_diagram_impl(
let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect();
let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect();
let authored: BTreeMap<_, _> = diagram.routes.iter().map(|r| (r.edge, r)).collect();
let headers: BTreeMap<_, _> = nodes
.values()
.map(|node| {
(
node.id,
if diagram.projection.state_machine().is_some() && !node.collapsed {
projection::state_header_height(project, node.element)
} else {
HEADER
},
)
})
.collect();
let mut ordered: Vec<_> = view.edges.iter().collect();
ordered.sort_by_key(|e| e.id);
let mut edges = BTreeMap::new();
@@ -379,7 +392,8 @@ fn route_diagram_impl(
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles);
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
let cached = preview.and_then(|(baseline, changed)| {
(edge.id == changed)
.then(|| baseline.edges.get(&edge.id))
@@ -459,7 +473,7 @@ fn route_diagram_impl(
if preview.is_none_or(|(baseline, changed)| {
edge.id == changed || !baseline.edges.contains_key(&edge.id)
}) {
let obstacles = edge_obstacles(edge, &nodes, &rectangles);
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
let authored_label = (!routed.custom_suspended)
.then(|| {
authored
@@ -492,6 +506,7 @@ fn edge_obstacles(
edge: &ProjectedEdge,
nodes: &BTreeMap<Id, &ProjectedNode>,
rectangles: &[Rect],
headers: &BTreeMap<Id, f32>,
) -> Vec<Rect> {
let mut traversable = BTreeSet::new();
ancestors(edge.source, nodes, &mut traversable);
@@ -501,7 +516,7 @@ fn edge_obstacles(
.map(|r| {
let mut r = *r;
if traversable.contains(&r.id) {
r.bottom = r.bottom.min(r.top + HEADER);
r.bottom = r.bottom.min(r.top + headers[&r.id]);
}
r.inflate(CLEARANCE)
})
@@ -564,6 +579,15 @@ fn route_edge(
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-transition 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
{
continue;
}
let estimate = manhattan(a.stub, pins.first().copied().unwrap_or(b.stub))
+ pins.windows(2).map(|p| manhattan(p[0], p[1])).sum::<f32>()
+ pins.last().map_or(0.0, |&p| manhattan(p, b.stub));
@@ -1010,10 +1034,76 @@ fn grid_path(
(None, false)
}
/// Full semantic label shared by state transition routes and internal rows.
/// Expressions remain descriptive; no state-machine execution is implied.
pub fn state_transition_summary(p: &Project, r: &crate::Relation) -> String {
let crate::RelationBody::StateTransition {
transition_kind,
triggers,
trigger,
guard,
effects,
effect,
..
} = &r.body
else {
return r.name.clone();
};
let activation = if !triggers.is_empty() {
triggers
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(" OR ")
} else if !trigger.trim().is_empty() {
format!("Unresolved trigger: {trigger}")
} else if p
.elements
.get(&r.source())
.is_some_and(|e| e.kind().is_stable_state())
{
"Completion".into()
} else {
"Transient routing".into()
};
let mut result = format!("{transition_kind:?} \u{b7} {activation}");
if !triggers.is_empty() && !trigger.is_empty() {
result.push_str(&format!(" \u{b7} {trigger}"));
}
if !guard.is_empty() {
result.push_str(&format!(" [{guard}]"));
}
if !effects.is_empty() {
result.push_str(&format!(
" / {}",
effects
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
));
}
if !effect.is_empty() {
result.push_str(&format!(" / {effect}"));
}
result
}
fn label_text(project: &Project, edge: Id) -> String {
let Some(relation) = project.relations.get(&edge) else {
return String::new();
};
if relation.kind() == RelationKind::StateTransition {
let text = state_transition_summary(project, relation);
return if text.chars().count() > 48 {
text.chars()
.take(47)
.chain(std::iter::once('\u{2026}'))
.collect()
} else {
text
};
}
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
+37 -4
View File
@@ -908,9 +908,19 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
if !seen.insert(id) {
return Err(ModelError("Diagram containment cycle.".into()));
}
let own = crate::projection::minimum_node_size(p, d.nodes[&id].element, d.nodes[&id].size);
let node = &d.nodes[&id];
let own = crate::projection::minimum_node_size(p, node.element, node.size);
let own = if d.projection.state_machine().is_some() {
crate::projection::state_node_size(p, &p.elements[&node.element], own, false)
} else {
own
};
let mut width = own.width;
let mut height = 64.;
let mut height = if d.projection.state_machine().is_some() {
crate::projection::state_header_height(p, node.element)
} else {
64.
};
let kids = children.get(&Some(id));
if let Some(kids) = kids {
for child in kids {
@@ -935,9 +945,10 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
children: &BTreeMap<Option<Id>, Vec<Id>>,
sizes: &BTreeMap<Id, Size>,
positions: &mut BTreeMap<Id, Position>,
headers: &BTreeMap<Id, f32>,
) {
positions.insert(id, position);
let mut y = position.y + 64.;
let mut y = position.y + headers[&id];
if let Some(kids) = children.get(&Some(id)) {
for child in kids {
place(
@@ -949,6 +960,7 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
children,
sizes,
positions,
headers,
);
y += sizes[child].height + 16.;
}
@@ -1006,6 +1018,20 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
for id in &roots {
measure(p, *id, d, &children, &mut sizes, &mut seen)?;
}
let headers = d
.nodes
.values()
.map(|n| {
(
n.id,
if d.projection.state_machine().is_some() {
crate::projection::state_header_height(p, n.element)
} else {
64.
},
)
})
.collect();
let mut positions = BTreeMap::new();
let mut x = 60.;
let mut y = 60.;
@@ -1016,7 +1042,14 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
y += row_height + 60.;
row_height = 0.;
}
place(*id, Position { x, y }, &children, &sizes, &mut positions);
place(
*id,
Position { x, y },
&children,
&sizes,
&mut positions,
&headers,
);
x += sizes[id].width + 240.;
row_height = row_height.max(sizes[id].height);
}
+40
View File
@@ -658,6 +658,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
if let Some(machine) = d.projection.state_machine()
&& !p
.elements
.get(&machine)
.is_some_and(|e| e.kind() == Kind::StateMachine && e.layer() == d.layer)
{
error(
&mut out,
d.id,
"state_diagram_context",
"projection",
"State diagram context must reference an existing state machine in the diagram layer.",
);
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
if !routed_edges.insert(route.edge) {
@@ -678,6 +692,21 @@ pub fn validate(p: &Project) -> Vec<Issue> {
"Route edge must reference an existing relation or trace.",
);
}
if let Some(machine) = d.projection.state_machine()
&& !p.relations.get(&route.edge).is_some_and(|r| {
r.kind() == RelationKind::StateTransition
&& crate::projection::state_machine_contains(p, machine, r.source())
&& crate::projection::state_machine_contains(p, machine, r.target())
})
{
error(
&mut out,
d.id,
"state_diagram_route",
"routes",
"State diagram routes belong to transitions in its machine. Remove or move incompatible routes in the same transaction.",
);
}
if route.waypoints.len() > MAX_ROUTE_POINTS {
error(
&mut out,
@@ -795,6 +824,17 @@ pub fn validate(p: &Project) -> Vec<Issue> {
"Diagram nodes reference existing elements in the selected layer.",
),
};
if let Some(machine) = d.projection.state_machine()
&& !crate::projection::state_machine_contains(p, machine, n.element)
{
error(
&mut out,
n.id,
"state_diagram_member",
"element",
"State diagrams contain only their machine, its regions and vertices. Remove or relocate incompatible placements in the same transaction.",
);
}
for (value, field) in [
(n.position.x, "x"),
(n.position.y, "y"),