Persist diagram routing with atomic edits and grouped movement
This commit is contained in:
@@ -82,6 +82,7 @@ fn diagram(
|
||||
layer,
|
||||
projection,
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
for (index, e) in elements.iter().enumerate() {
|
||||
|
||||
@@ -1241,7 +1241,29 @@ pub struct Diagram {
|
||||
pub projection: ProjectionKind,
|
||||
#[serde(deserialize_with = "unique_id_map")]
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<EdgeRoute>,
|
||||
}
|
||||
/// Authored presentation for one existing relation or trace in one diagram.
|
||||
/// Computed endpoints and automatic routing elbows are never persisted here.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRoute {
|
||||
pub edge: Id,
|
||||
#[serde(default)]
|
||||
pub waypoints: Vec<Position>,
|
||||
#[serde(default)]
|
||||
pub label: Option<EdgeLabel>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeLabel {
|
||||
/// Fraction of the computed polyline's total arc length, from source to target.
|
||||
pub fraction: f32,
|
||||
/// Diagram-space displacement relative to the point at `fraction`.
|
||||
pub offset: Position,
|
||||
}
|
||||
pub const MAX_ROUTE_POINTS: usize = 128;
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NodePlacement {
|
||||
@@ -1613,6 +1635,7 @@ impl Project {
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
id
|
||||
|
||||
@@ -180,6 +180,59 @@ pub fn descendant_nodes(p: &Project, diagram: Id, node: Id) -> Vec<Id> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Actual endpoint-owner placements before folding promotes a hidden endpoint
|
||||
/// to an ancestor. Ports belong to their placed function/participant; they do not
|
||||
/// acquire independent placement identities. Missing placements are not inferred.
|
||||
pub fn edge_endpoint_placements(p: &Project, d: &Diagram, edge: Id) -> Option<(Id, Id)> {
|
||||
let (source, target) = if let Some(r) = p.relations.get(&edge) {
|
||||
(r.source(), r.target())
|
||||
} else {
|
||||
let trace = p.traces.get(&edge)?;
|
||||
(trace.oa, trace.sa)
|
||||
};
|
||||
let placement = |id| {
|
||||
let element = p.elements.get(&id)?;
|
||||
let owner = if element.kind() == Kind::Port {
|
||||
element.parent?
|
||||
} else {
|
||||
id
|
||||
};
|
||||
d.nodes.values().find(|n| n.element == owner).map(|n| n.id)
|
||||
};
|
||||
Some((placement(source)?, placement(target)?))
|
||||
}
|
||||
|
||||
/// Route updates for a simultaneous placement movement. `deltas` maps placement
|
||||
/// identities to diagram-space displacement. Only routes with two actual owners
|
||||
/// moving by the same nonzero delta translate; one-ended moves keep world pins.
|
||||
/// Returns changed records only, for the caller to apply with its node edits in
|
||||
/// one transaction. This pure helper also drives reflow and drag previews.
|
||||
pub fn translated_routes(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
deltas: &BTreeMap<Id, Position>,
|
||||
) -> Vec<EdgeRoute> {
|
||||
d.routes
|
||||
.iter()
|
||||
.filter_map(|route| {
|
||||
let (source, target) = edge_endpoint_placements(p, d, route.edge)?;
|
||||
let source_delta = deltas.get(&source)?;
|
||||
let target_delta = deltas.get(&target)?;
|
||||
if source_delta != target_delta
|
||||
|| (source_delta.x == 0. && source_delta.y == 0.)
|
||||
|| route.waypoints.is_empty()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut translated = route.clone();
|
||||
for point in &mut translated.waypoints {
|
||||
point.x += source_delta.x;
|
||||
point.y += source_delta.y;
|
||||
}
|
||||
Some(translated)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// One projection drives drawing, hit testing, Fit, SVG, and grouped movement.
|
||||
pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
||||
let Some(d) = p.diagrams.get(&diagram) else {
|
||||
|
||||
@@ -181,6 +181,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
for route in &mut d.routes {
|
||||
if route.edge == old {
|
||||
route.edge = new;
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
@@ -339,6 +344,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
|
||||
@@ -36,6 +36,14 @@ pub enum Command {
|
||||
original: Id,
|
||||
node: NodePlacement,
|
||||
},
|
||||
SetEdgeRoute {
|
||||
diagram: Id,
|
||||
route: EdgeRoute,
|
||||
},
|
||||
RemoveEdgeRoute {
|
||||
diagram: Id,
|
||||
edge: Id,
|
||||
},
|
||||
RelocateNode {
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
@@ -559,6 +567,28 @@ fn apply_command(
|
||||
.ok_or_else(missing)? = value.clone();
|
||||
Some(*owner)
|
||||
}
|
||||
Command::SetEdgeRoute { diagram, route } => {
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
*existing = route.clone();
|
||||
} else {
|
||||
d.routes.push(route.clone());
|
||||
}
|
||||
Some(route.edge)
|
||||
}
|
||||
Command::RemoveEdgeRoute { diagram, edge } => {
|
||||
if !p.relations.contains_key(edge) && !p.traces.contains_key(edge) {
|
||||
return Err(ModelError(
|
||||
"Routing requires an existing relation or trace.".into(),
|
||||
));
|
||||
}
|
||||
p.diagrams
|
||||
.get_mut(diagram)
|
||||
.ok_or_else(missing)?
|
||||
.routes
|
||||
.retain(|route| route.edge != *edge);
|
||||
Some(*edge)
|
||||
}
|
||||
Command::MoveNode {
|
||||
diagram,
|
||||
node,
|
||||
@@ -570,12 +600,19 @@ fn apply_command(
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
let n = d.nodes.get_mut(node).ok_or_else(missing)?;
|
||||
let d = p.diagrams.get(diagram).ok_or_else(missing)?;
|
||||
let n = d.nodes.get(node).ok_or_else(missing)?;
|
||||
let delta = Position {
|
||||
x: position.x - n.position.x,
|
||||
y: position.y - n.position.y,
|
||||
};
|
||||
let deltas = std::iter::once(*node)
|
||||
.chain(descendants.iter().copied())
|
||||
.map(|id| (id, delta))
|
||||
.collect();
|
||||
let routes = crate::projection::translated_routes(p, d, &deltas);
|
||||
let d = p.diagrams.get_mut(diagram).unwrap();
|
||||
let n = d.nodes.get_mut(node).unwrap();
|
||||
n.position = *position;
|
||||
for id in descendants {
|
||||
if let Some(child) = d.nodes.get_mut(&id) {
|
||||
@@ -583,6 +620,11 @@ fn apply_command(
|
||||
child.position.y += delta.y;
|
||||
}
|
||||
}
|
||||
for route in routes {
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
*existing = route;
|
||||
}
|
||||
}
|
||||
Some(*node)
|
||||
}
|
||||
Command::Layout(diagram) => {
|
||||
@@ -817,11 +859,30 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
x += sizes[id].width + 240.;
|
||||
row_height = row_height.max(sizes[id].height);
|
||||
}
|
||||
let deltas = positions
|
||||
.iter()
|
||||
.map(|(id, position)| {
|
||||
let original = d.nodes[id].position;
|
||||
(
|
||||
*id,
|
||||
Position {
|
||||
x: position.x - original.x,
|
||||
y: position.y - original.y,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let routes = crate::projection::translated_routes(p, d, &deltas);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
for (id, position) in positions {
|
||||
let n = d.nodes.get_mut(&id).unwrap();
|
||||
n.position = position;
|
||||
n.size = sizes[&id];
|
||||
}
|
||||
for route in routes {
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
*existing = route;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -657,6 +657,78 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
let mut routed_edges = BTreeSet::new();
|
||||
for (index, route) in d.routes.iter().enumerate() {
|
||||
if !routed_edges.insert(route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"duplicate_edge_route",
|
||||
"routes",
|
||||
"Each relation or trace has at most one route per diagram.",
|
||||
);
|
||||
}
|
||||
if !p.relations.contains_key(&route.edge) && !p.traces.contains_key(&route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"route_edge",
|
||||
"routes",
|
||||
"Route edge must reference an existing relation or trace.",
|
||||
);
|
||||
}
|
||||
if route.waypoints.len() > MAX_ROUTE_POINTS {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"route_point_limit",
|
||||
"routes",
|
||||
format!("A route supports at most {MAX_ROUTE_POINTS} pinned points."),
|
||||
);
|
||||
}
|
||||
for (point_index, point) in route.waypoints.iter().enumerate() {
|
||||
for (axis, value) in [("x", point.x), ("y", point.y)] {
|
||||
if !value.is_finite() || value.abs() > MAX_COORDINATE {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_route_bounds",
|
||||
"routes",
|
||||
format!(
|
||||
"Route {} point {} {axis} must be finite and bounded.",
|
||||
index + 1,
|
||||
point_index + 1
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(label) = &route.label {
|
||||
if !label.fraction.is_finite() || !(0. ..=1.).contains(&label.fraction) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_route_fraction",
|
||||
"routes",
|
||||
"Label fraction must be between zero and one.",
|
||||
);
|
||||
}
|
||||
for (axis, value) in [("x", label.offset.x), ("y", label.offset.y)] {
|
||||
if !value.is_finite() || value.abs() > MAX_COORDINATE {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_route_bounds",
|
||||
"routes",
|
||||
format!(
|
||||
"Route {} label offset {axis} must be finite and bounded.",
|
||||
index + 1
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut elements = BTreeSet::new();
|
||||
for (key, n) in &d.nodes {
|
||||
if *key != n.id {
|
||||
|
||||
Reference in New Issue
Block a user