Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b08d8016b |
@@ -66,7 +66,14 @@ impl ParseLimits {
|
||||
project
|
||||
.diagrams
|
||||
.values()
|
||||
.map(|d| d.nodes.len().saturating_add(d.routes.len()))
|
||||
.map(|d| {
|
||||
d.nodes.len().saturating_add(d.routes.len()).saturating_add(
|
||||
d.nodes
|
||||
.values()
|
||||
.map(|n| n.port_anchors.len())
|
||||
.sum::<usize>(),
|
||||
)
|
||||
})
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
@@ -349,6 +356,7 @@ fn reference_field(key: &str) -> bool {
|
||||
| "sa"
|
||||
| "diagram"
|
||||
| "edge"
|
||||
| "port"
|
||||
)
|
||||
}
|
||||
fn enum_field(key: &str) -> bool {
|
||||
@@ -368,6 +376,7 @@ fn enum_field(key: &str) -> bool {
|
||||
| "viewpoint"
|
||||
| "message_kind"
|
||||
| "transition_kind"
|
||||
| "side"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1663,7 +1672,7 @@ pub(crate) fn resolve_declarations(
|
||||
if !fields.contains_key("element") {
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
|
||||
@@ -1264,6 +1264,34 @@ pub struct EdgeLabel {
|
||||
pub offset: Position,
|
||||
}
|
||||
pub const MAX_ROUTE_POINTS: usize = 128;
|
||||
/// Presentation side; independent of the semantic port direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
impl PortAnchorSide {
|
||||
pub fn normal(self) -> Position {
|
||||
match self {
|
||||
Self::Left => Position { x: -1., y: 0. },
|
||||
Self::Right => Position { x: 1., y: 0. },
|
||||
Self::Top => Position { x: 0., y: -1. },
|
||||
Self::Bottom => Position { x: 0., y: 1. },
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Manual position of a semantic child port on one owner placement.
|
||||
/// Absence from the placement's list selects automatic positioning.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PortAnchor {
|
||||
pub port: Id,
|
||||
pub side: PortAnchorSide,
|
||||
/// Fraction of the complete edge, from top (vertical) or left (horizontal).
|
||||
pub offset: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NodePlacement {
|
||||
@@ -1273,6 +1301,8 @@ pub struct NodePlacement {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
pub collapsed: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub port_anchors: Vec<PortAnchor>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ArtifactRelation {
|
||||
@@ -1660,6 +1690,7 @@ impl Project {
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
if let Some(d) = self.diagrams.get_mut(&diagram) {
|
||||
d.nodes.insert(id, node);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub use crate::model::PortAnchorSide;
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod port_layout;
|
||||
pub use port_layout::{port_anchor_offset, port_anchor_position};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProjectedNode {
|
||||
@@ -32,34 +35,31 @@ pub struct ProjectedPort {
|
||||
pub position: Position,
|
||||
pub side: PortAnchorSide,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
/// Semantic fallback for an unconnected port. Neutral ports have no preferred
|
||||
/// side; the projected view resolves their placement using peers and capacity.
|
||||
/// A preferred side never constrains an authored anchor.
|
||||
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
|
||||
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
|
||||
return None;
|
||||
};
|
||||
Some(match contract {
|
||||
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
|
||||
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
|
||||
match contract {
|
||||
PortContract::FunctionInput { .. } => Some(PortAnchorSide::Left),
|
||||
PortContract::FunctionOutput { .. } => Some(PortAnchorSide::Right),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
} => PortAnchorSide::Left,
|
||||
} => Some(PortAnchorSide::Left),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
} => PortAnchorSide::Right,
|
||||
} => Some(PortAnchorSide::Right),
|
||||
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
|
||||
Some(PortDirection::In) => PortAnchorSide::Left,
|
||||
Some(PortDirection::Out) => PortAnchorSide::Right,
|
||||
_ => PortAnchorSide::Bottom,
|
||||
Some(PortDirection::In) => Some(PortAnchorSide::Left),
|
||||
Some(PortDirection::Out) => Some(PortAnchorSide::Right),
|
||||
_ => None,
|
||||
},
|
||||
_ => PortAnchorSide::Bottom,
|
||||
})
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ProjectedView {
|
||||
@@ -67,38 +67,11 @@ pub struct ProjectedView {
|
||||
pub edges: Vec<ProjectedEdge>,
|
||||
pub ports: Vec<ProjectedPort>,
|
||||
}
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
|
||||
.into_iter()
|
||||
.map(|side| {
|
||||
p.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(side))
|
||||
.count()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if count > 0 {
|
||||
size.height = size.height.max(76. + 28. * (count + 1) as f32);
|
||||
}
|
||||
let bottom = p
|
||||
.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
|
||||
.count();
|
||||
if bottom > 0 {
|
||||
size.width = size.width.max(28. * (bottom + 1) as f32);
|
||||
}
|
||||
size
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, size: Size) -> Size {
|
||||
port_layout::minimum_size(p, element, size)
|
||||
}
|
||||
pub(crate) fn port_footer(p: &Project, element: Id) -> f32 {
|
||||
let count = p
|
||||
.children(element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.count();
|
||||
if count == 0 {
|
||||
24.
|
||||
} else {
|
||||
28. * count as f32 + 24.
|
||||
}
|
||||
pub(crate) fn port_footer(_p: &Project, _element: Id) -> f32 {
|
||||
24.
|
||||
}
|
||||
|
||||
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
|
||||
@@ -300,25 +273,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
}
|
||||
nodes.sort_by_key(|n| (n.depth, n.id));
|
||||
for index in (0..nodes.len()).rev() {
|
||||
let child = nodes[index].clone();
|
||||
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 p = &mut nodes[parent];
|
||||
let right =
|
||||
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
|
||||
let bottom =
|
||||
(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.size.width = right - p.position.x;
|
||||
p.size.height = bottom - p.position.y;
|
||||
}
|
||||
}
|
||||
enclose(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| {
|
||||
@@ -341,56 +296,17 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
None
|
||||
};
|
||||
let mut ports = Vec::new();
|
||||
for node in &nodes {
|
||||
let mut children: Vec<_> = p
|
||||
.children(node.element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
children.sort_by_key(|e| (&e.alias, e.id));
|
||||
for anchor_side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let side: Vec<_> = children
|
||||
.iter()
|
||||
.filter(|e| port_anchor_side(e) == Some(anchor_side))
|
||||
.collect();
|
||||
for (index, port) in side.iter().enumerate() {
|
||||
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
|
||||
let y = if p.elements[&node.element].kind().is_performer()
|
||||
|| nodes
|
||||
.iter()
|
||||
.any(|child| child.visual_parent == Some(node.id))
|
||||
{
|
||||
node.position.y + node.size.height - 24. - index as f32 * 28.
|
||||
} else {
|
||||
node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction
|
||||
};
|
||||
let position = match anchor_side {
|
||||
PortAnchorSide::Left => Position {
|
||||
x: node.position.x,
|
||||
y,
|
||||
},
|
||||
PortAnchorSide::Right => Position {
|
||||
x: node.position.x + node.size.width,
|
||||
y,
|
||||
},
|
||||
PortAnchorSide::Bottom => Position {
|
||||
x: node.position.x + node.size.width * fraction,
|
||||
y: node.position.y + node.size.height,
|
||||
},
|
||||
};
|
||||
ports.push(ProjectedPort {
|
||||
element: port.id,
|
||||
owner: node.id,
|
||||
position,
|
||||
side: anchor_side,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let visible_ports: BTreeSet<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.kind() == Kind::Port
|
||||
&& e.parent
|
||||
.and_then(|id| placed.get(&id))
|
||||
.is_some_and(|id| visible.contains(id))
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
let mut edges = Vec::new();
|
||||
for r in p.relations.values() {
|
||||
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
|
||||
@@ -446,8 +362,8 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
..
|
||||
}
|
||||
),
|
||||
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
source_port: source_port.filter(|id| visible_ports.contains(id)),
|
||||
target_port: target_port.filter(|id| visible_ports.contains(id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -468,9 +384,32 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
}
|
||||
}
|
||||
let ports = port_layout::layout(p, d, &mut nodes, &edges);
|
||||
ProjectedView {
|
||||
nodes,
|
||||
edges,
|
||||
ports,
|
||||
}
|
||||
}
|
||||
|
||||
fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
|
||||
for index in (0..nodes.len()).rev() {
|
||||
let child = nodes[index].clone();
|
||||
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 p = &mut nodes[parent];
|
||||
let right =
|
||||
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
|
||||
let bottom =
|
||||
(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.size.width = right - p.position.x;
|
||||
p.size.height = bottom - p.position.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Presentation-only port anchors. Automatic choices depend on owner geometry,
|
||||
//! never on routed elbows, so routing cannot feed back into layout.
|
||||
use super::*;
|
||||
|
||||
const GAP: f32 = 28.;
|
||||
const CORNER: f32 = 18.;
|
||||
const SIDES: [PortAnchorSide; 4] = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
];
|
||||
|
||||
pub fn port_anchor_position(node: &ProjectedNode, side: PortAnchorSide, offset: f32) -> Position {
|
||||
let (x, y) = match side {
|
||||
PortAnchorSide::Left => (0., node.size.height * offset),
|
||||
PortAnchorSide::Right => (node.size.width, node.size.height * offset),
|
||||
PortAnchorSide::Top => (node.size.width * offset, 0.),
|
||||
PortAnchorSide::Bottom => (node.size.width * offset, node.size.height),
|
||||
};
|
||||
Position {
|
||||
x: node.position.x + x,
|
||||
y: node.position.y + y,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn port_anchor_offset(node: &ProjectedNode, side: PortAnchorSide, position: Position) -> f32 {
|
||||
let value = match side {
|
||||
PortAnchorSide::Left | PortAnchorSide::Right => {
|
||||
(position.y - node.position.y) / node.size.height
|
||||
}
|
||||
PortAnchorSide::Top | PortAnchorSide::Bottom => {
|
||||
(position.x - node.position.x) / node.size.width
|
||||
}
|
||||
};
|
||||
value.clamp(0., 1.)
|
||||
}
|
||||
|
||||
fn center(node: &ProjectedNode) -> Position {
|
||||
Position {
|
||||
x: node.position.x + node.size.width / 2.,
|
||||
y: node.position.y + node.size.height / 2.,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Choice {
|
||||
port: Id,
|
||||
side: PortAnchorSide,
|
||||
automatic_side: PortAnchorSide,
|
||||
manual: Option<f32>,
|
||||
peer: Option<Position>,
|
||||
}
|
||||
|
||||
fn facing(node: &ProjectedNode, peer: Position) -> Option<PortAnchorSide> {
|
||||
let c = center(node);
|
||||
let dx = (peer.x - c.x) / node.size.width;
|
||||
let dy = (peer.y - c.y) / node.size.height;
|
||||
if dx.abs().max(dy.abs()) < 0.001 {
|
||||
return None;
|
||||
}
|
||||
if dx.abs() >= dy.abs() {
|
||||
Some(if dx < 0. {
|
||||
PortAnchorSide::Left
|
||||
} else {
|
||||
PortAnchorSide::Right
|
||||
})
|
||||
} else {
|
||||
Some(if dy < 0. {
|
||||
PortAnchorSide::Top
|
||||
} else {
|
||||
PortAnchorSide::Bottom
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_exit(
|
||||
node: &ProjectedNode,
|
||||
side: PortAnchorSide,
|
||||
peers: &BTreeSet<Id>,
|
||||
by_id: &BTreeMap<Id, &ProjectedNode>,
|
||||
) -> bool {
|
||||
let a = port_anchor_position(node, side, 0.5);
|
||||
let normal = side.normal();
|
||||
let b = Position {
|
||||
x: a.x + normal.x * crate::routing::PORT_STUB,
|
||||
y: a.y + normal.y * crate::routing::PORT_STUB,
|
||||
};
|
||||
peers.iter().all(|peer| {
|
||||
let other = by_id[peer];
|
||||
// A containing endpoint's body is traversable, just as in routing.
|
||||
let mut ancestor = node.visual_parent;
|
||||
for _ in 0..by_id.len() {
|
||||
let Some(id) = ancestor else {
|
||||
break;
|
||||
};
|
||||
if id == *peer {
|
||||
return true;
|
||||
}
|
||||
ancestor = by_id.get(&id).and_then(|n| n.visual_parent);
|
||||
}
|
||||
let padding = crate::routing::CLEARANCE;
|
||||
let left = other.position.x - padding;
|
||||
let right = other.position.x + other.size.width + padding;
|
||||
let top = other.position.y - padding;
|
||||
let bottom = other.position.y + other.size.height + padding;
|
||||
if a.x == b.x {
|
||||
!(a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top)
|
||||
} else {
|
||||
!(a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Peers {
|
||||
x: f64,
|
||||
y: f64,
|
||||
count: usize,
|
||||
owners: BTreeSet<Id>,
|
||||
}
|
||||
impl Peers {
|
||||
fn center(&self) -> Position {
|
||||
Position {
|
||||
x: (self.x / self.count as f64) as f32,
|
||||
y: (self.y / self.count as f64) as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn minimum_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
|
||||
let mut neutral = 0;
|
||||
for port in p.children(element).filter(|p| p.kind() == Kind::Port) {
|
||||
if let Some(side) = port_anchor_side(port) {
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
} else {
|
||||
neutral += 1;
|
||||
}
|
||||
}
|
||||
for _ in 0..neutral {
|
||||
let side = *SIDES.iter().min_by_key(|s| counts[s]).unwrap();
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
}
|
||||
let vertical = counts[&PortAnchorSide::Left].max(counts[&PortAnchorSide::Right]);
|
||||
let horizontal = counts[&PortAnchorSide::Top].max(counts[&PortAnchorSide::Bottom]);
|
||||
if vertical > 0 {
|
||||
size.height = size.height.max(76. + GAP * (vertical + 1) as f32);
|
||||
}
|
||||
if horizontal > 0 {
|
||||
size.width = size.width.max(GAP * (horizontal + 1) as f32);
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
pub(super) fn layout(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
nodes: &mut [ProjectedNode],
|
||||
edges: &[ProjectedEdge],
|
||||
) -> Vec<ProjectedPort> {
|
||||
let by_id: BTreeMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let mut peers: BTreeMap<(Id, Id), Peers> = BTreeMap::new();
|
||||
for edge in edges {
|
||||
// Port allocation is implementation traceability, not a communication
|
||||
// peer. It must not turn an external boundary port toward its own work.
|
||||
if !p.relations.get(&edge.id).is_some_and(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
||||
)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for (owner, port, other) in [
|
||||
(edge.source, edge.source_port, edge.target),
|
||||
(edge.target, edge.target_port, edge.source),
|
||||
] {
|
||||
if let (Some(port), Some(other)) = (port, by_id.get(&other)) {
|
||||
let c = center(other);
|
||||
let entry = peers.entry((owner, port)).or_default();
|
||||
entry.x += f64::from(c.x);
|
||||
entry.y += f64::from(c.y);
|
||||
entry.count += 1;
|
||||
entry.owners.insert(other.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut children: BTreeMap<Id, Vec<&Element>> = BTreeMap::new();
|
||||
for port in p.elements.values().filter(|e| e.kind() == Kind::Port) {
|
||||
if let Some(parent) = port.parent {
|
||||
children.entry(parent).or_default().push(port);
|
||||
}
|
||||
}
|
||||
let mut choices = BTreeMap::<Id, Vec<Choice>>::new();
|
||||
for node in nodes.iter() {
|
||||
let anchors: BTreeMap<_, _> = d.nodes[&node.id]
|
||||
.port_anchors
|
||||
.iter()
|
||||
.map(|a| (a.port, a))
|
||||
.collect();
|
||||
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
|
||||
// Compute the automatic capacity independently of manual overrides.
|
||||
// Moving a port must not resize its owner or flip unrelated ports.
|
||||
for port in children.get(&node.element).into_iter().flatten() {
|
||||
let connected = peers.get(&(node.id, port.id));
|
||||
let peer = connected.map(Peers::center);
|
||||
let clear =
|
||||
|side| connected.is_none_or(|peers| clear_exit(node, side, &peers.owners, &by_id));
|
||||
let anchor = anchors.get(&port.id);
|
||||
let side = {
|
||||
peer.and_then(|v| facing(node, v))
|
||||
.filter(|side| clear(*side))
|
||||
}
|
||||
.or_else(|| port_anchor_side(port).filter(|side| clear(*side)))
|
||||
.or_else(|| {
|
||||
SIDES
|
||||
.iter()
|
||||
.filter(|side| clear(**side))
|
||||
.min_by_key(|s| counts[s])
|
||||
.copied()
|
||||
})
|
||||
.unwrap_or_else(|| *SIDES.iter().min_by_key(|s| counts[s]).unwrap());
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
choices.entry(node.id).or_default().push(Choice {
|
||||
port: port.id,
|
||||
side: anchor.map_or(side, |a| a.side),
|
||||
automatic_side: side,
|
||||
manual: anchor.map(|a| a.offset),
|
||||
peer,
|
||||
});
|
||||
}
|
||||
}
|
||||
// One capacity pass followed by semantic enclosure. Side choices stay frozen
|
||||
// throughout this pass; expanded parents cannot cause layout oscillation.
|
||||
for node in nodes.iter_mut() {
|
||||
for side in SIDES {
|
||||
let count = choices
|
||||
.get(&node.id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| c.automatic_side == side)
|
||||
.count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let minimum = 2. * CORNER + GAP * count.saturating_sub(1) as f32;
|
||||
match side {
|
||||
PortAnchorSide::Left | PortAnchorSide::Right => {
|
||||
node.size.height = node.size.height.max(minimum)
|
||||
}
|
||||
PortAnchorSide::Top | PortAnchorSide::Bottom => {
|
||||
node.size.width = node.size.width.max(minimum)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
super::enclose(p, nodes);
|
||||
let mut result = Vec::new();
|
||||
for node in nodes.iter() {
|
||||
let Some(choices) = choices.get(&node.id) else {
|
||||
continue;
|
||||
};
|
||||
for side in SIDES {
|
||||
let mut automatic: Vec<_> = choices
|
||||
.iter()
|
||||
.filter(|c| c.side == side && c.manual.is_none())
|
||||
.collect();
|
||||
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
|
||||
automatic.sort_by(|a, b| {
|
||||
let axis = |c: &Choice| c.peer.map(|p| if horizontal { p.x } else { p.y });
|
||||
match (axis(a), axis(b)) {
|
||||
(Some(a), Some(b)) => a.total_cmp(&b),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
.then_with(|| a.port.cmp(&b.port))
|
||||
});
|
||||
let length = if horizontal {
|
||||
node.size.width
|
||||
} else {
|
||||
node.size.height
|
||||
};
|
||||
let mut fixed = Vec::new();
|
||||
for choice in choices.iter().filter(|c| c.side == side) {
|
||||
if let Some(offset) = choice.manual {
|
||||
fixed.push(offset * length);
|
||||
result.push(ProjectedPort {
|
||||
element: choice.port,
|
||||
owner: node.id,
|
||||
side,
|
||||
position: port_anchor_position(node, side, offset),
|
||||
});
|
||||
}
|
||||
}
|
||||
let positions = automatic_positions(length, &fixed, automatic.len());
|
||||
for (choice, distance) in automatic.into_iter().zip(positions) {
|
||||
result.push(ProjectedPort {
|
||||
element: choice.port,
|
||||
owner: node.id,
|
||||
side,
|
||||
position: port_anchor_position(node, side, distance / length),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Allocate free intervals around authored anchors. If manual anchors are too
|
||||
/// crowded to satisfy the desired gap, retain them and degrade spacing evenly.
|
||||
fn automatic_positions(length: f32, fixed: &[f32], count: usize) -> Vec<f32> {
|
||||
if count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut fixed = fixed.to_vec();
|
||||
fixed.sort_by(f32::total_cmp);
|
||||
let mut gap = GAP;
|
||||
let intervals = loop {
|
||||
let mut start = CORNER.min(length / 2.);
|
||||
let end = (length - CORNER).max(start);
|
||||
let mut free = Vec::new();
|
||||
for &anchor in &fixed {
|
||||
if anchor - gap >= start {
|
||||
free.push((start, (anchor - gap).min(end)));
|
||||
}
|
||||
start = start.max(anchor + gap);
|
||||
if start > end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if start <= end {
|
||||
free.push((start, end));
|
||||
}
|
||||
free.retain(|(a, b)| b >= a);
|
||||
let capacity: usize = free
|
||||
.iter()
|
||||
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
|
||||
.sum();
|
||||
if capacity >= count {
|
||||
break free;
|
||||
}
|
||||
gap /= 2.;
|
||||
if gap < 0.001 {
|
||||
return (0..count)
|
||||
.map(|i| length * (i + 1) as f32 / (count + 1) as f32)
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
let capacities: Vec<_> = intervals
|
||||
.iter()
|
||||
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
|
||||
.collect();
|
||||
let total: usize = capacities.iter().sum();
|
||||
let mut allocated: Vec<_> = capacities
|
||||
.iter()
|
||||
.map(|capacity| (count as u128 * *capacity as u128 / total as u128) as usize)
|
||||
.collect();
|
||||
let mut remainder: Vec<_> = capacities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, capacity)| (count as u128 * *capacity as u128 % total as u128, i))
|
||||
.collect();
|
||||
remainder.sort_by(|(a, i), (b, j)| b.cmp(a).then_with(|| i.cmp(j)));
|
||||
let unassigned = count - allocated.iter().sum::<usize>();
|
||||
for (_, index) in remainder.into_iter().take(unassigned) {
|
||||
allocated[index] += 1;
|
||||
}
|
||||
intervals
|
||||
.into_iter()
|
||||
.zip(allocated)
|
||||
.flat_map(|((a, b), count)| {
|
||||
(0..count).map(move |i| {
|
||||
if count == 1 {
|
||||
(a + b) / 2.
|
||||
} else {
|
||||
a + (b - a) * i as f32 / (count - 1) as f32
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -187,6 +187,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
@@ -345,6 +350,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
node.port_anchors
|
||||
.retain(|anchor| !removed.contains(&anchor.port));
|
||||
}
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 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, PortAnchorSide, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
@@ -264,11 +264,7 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
.iter()
|
||||
.find(|p| p.element == id && p.owner == node.id)
|
||||
}) {
|
||||
let normal = match port.side {
|
||||
PortAnchorSide::Left => pos(-1.0, 0.0),
|
||||
PortAnchorSide::Right => pos(1.0, 0.0),
|
||||
PortAnchorSide::Bottom => pos(0.0, 1.0),
|
||||
};
|
||||
let normal = port.side.normal();
|
||||
return vec![Endpoint {
|
||||
point: port.position,
|
||||
stub: pos(
|
||||
@@ -1190,11 +1186,22 @@ impl BoundsBuilder {
|
||||
}
|
||||
fn finish(self) -> RoutingBounds {
|
||||
let (left, top, right, bottom) = self.extents.unwrap_or((0.0, 0.0, 0.0, 0.0));
|
||||
// Subtraction and subsequent addition can round in opposite directions
|
||||
// when an authored label extends far left/up. Bounds must still enclose
|
||||
// every original point, including the far right and bottom edges.
|
||||
let span = |start: f32, end: f32| {
|
||||
let extent = end - start;
|
||||
if start + extent < end {
|
||||
extent.next_up()
|
||||
} else {
|
||||
extent
|
||||
}
|
||||
};
|
||||
RoutingBounds {
|
||||
position: pos(left, top),
|
||||
size: Size {
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
width: span(left, right),
|
||||
height: span(top, bottom),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ pub enum Command {
|
||||
diagram: Id,
|
||||
edge: Id,
|
||||
},
|
||||
SetPortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
anchor: PortAnchor,
|
||||
},
|
||||
RemovePortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
port: Id,
|
||||
},
|
||||
RelocateNode {
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
@@ -679,6 +689,45 @@ fn apply_command(
|
||||
.ok_or_else(missing)? = value.clone();
|
||||
Some(*owner)
|
||||
}
|
||||
Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if let Some(existing) = n.port_anchors.iter_mut().find(|a| a.port == anchor.port) {
|
||||
*existing = anchor.clone();
|
||||
} else {
|
||||
n.port_anchors.push(anchor.clone());
|
||||
}
|
||||
Some(anchor.port)
|
||||
}
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if !p
|
||||
.elements
|
||||
.get(port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Port positioning requires a direct child port of the owner placement.".into(),
|
||||
));
|
||||
}
|
||||
n.port_anchors.retain(|anchor| anchor.port != *port);
|
||||
Some(*port)
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -1457,15 +1457,17 @@ fn item_key(
|
||||
"literal:{}",
|
||||
&source[value.span.start..value.span.end]
|
||||
)),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge", "port"]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::List(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,6 +981,7 @@ pub fn plan_transition(
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
working
|
||||
.diagrams
|
||||
|
||||
@@ -731,6 +731,40 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut elements = BTreeSet::new();
|
||||
for (key, n) in &d.nodes {
|
||||
let mut ports = BTreeSet::new();
|
||||
for anchor in &n.port_anchors {
|
||||
if !ports.insert(anchor.port) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"duplicate_port_anchor",
|
||||
"port_anchors",
|
||||
"Each port has at most one manual anchor per owner placement.",
|
||||
);
|
||||
}
|
||||
if !p
|
||||
.elements
|
||||
.get(&anchor.port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"port_anchor_owner",
|
||||
"port_anchors",
|
||||
"A manual anchor must reference a direct child port of its owner placement; repair anchors in the same transaction when reparenting.",
|
||||
);
|
||||
}
|
||||
if !anchor.offset.is_finite() || !(0. ..=1.).contains(&anchor.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"invalid_port_offset",
|
||||
"port_anchors",
|
||||
"Port offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if *key != n.id {
|
||||
error(
|
||||
&mut out,
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
use oa_core::{language, persistence, projection::*, routing::*, source_project::*, *};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
|
||||
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, "Information", a, b);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, pos(100., 100.));
|
||||
let nb = p.place(d, b, pos(900., 100.));
|
||||
(p, d, na, nb, source_port, target_port, edge)
|
||||
}
|
||||
fn manual(p: &mut Project, d: Id, n: Id, port: Id, side: PortAnchorSide, offset: f32) {
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prepared.selected, Some(port));
|
||||
*p = prepared.project;
|
||||
}
|
||||
fn port(view: &ProjectedView, id: Id) -> &ProjectedPort {
|
||||
view.ports.iter().find(|p| p.element == id).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_side_uses_exact_final_border_fraction_and_outward_routing_normal() {
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let (mut p, d, na, _, a, _, edge) = fixture();
|
||||
let before = project_diagram(&p, &p.diagrams[&d]);
|
||||
manual(&mut p, d, na, a, side, 0.3);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(
|
||||
view.nodes, before.nodes,
|
||||
"A port drag must not resize or move its owner"
|
||||
);
|
||||
let node = view.nodes.iter().find(|n| n.id == na).unwrap();
|
||||
let anchor = port(&view, a);
|
||||
assert_eq!(anchor.side, side);
|
||||
assert_eq!(anchor.position, port_anchor_position(node, side, 0.3));
|
||||
assert!((port_anchor_offset(node, side, anchor.position) - 0.3).abs() < 0.00001);
|
||||
let route = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
let points = &route.edges[&edge].points;
|
||||
assert_eq!(points[0], anchor.position);
|
||||
let normal = side.normal();
|
||||
let delta = pos(points[1].x - points[0].x, points[1].y - points[0].y);
|
||||
assert!(
|
||||
delta.x * normal.x + delta.y * normal.y >= PORT_STUB - 0.001,
|
||||
"{side:?}: {points:?}"
|
||||
);
|
||||
assert!(
|
||||
points
|
||||
.windows(2)
|
||||
.all(|w| w[0].x == w[1].x || w[0].y == w[1].y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_connected_ports_face_peers_and_unconnected_ports_use_four_sides() {
|
||||
for (peer, expected) in [
|
||||
(pos(-700., 100.), PortAnchorSide::Left),
|
||||
(pos(900., 100.), PortAnchorSide::Right),
|
||||
(pos(100., -700.), PortAnchorSide::Top),
|
||||
(pos(100., 900.), PortAnchorSide::Bottom),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for owner in [a, b] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Boundary",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
ports.push(id);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Connection",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, peer);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, ports[0]).side, expected);
|
||||
let before = view.ports.clone();
|
||||
for element in p.elements.values_mut() {
|
||||
element.alias = format!("renamed_{}", element.id.simple());
|
||||
}
|
||||
assert_eq!(project_diagram(&p, &p.diagrams[&d]).ports, before);
|
||||
p.relations.clear();
|
||||
for i in 0..3 {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
a,
|
||||
&format!("Spare {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let sides: std::collections::BTreeSet<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| p.elements[&port.element].parent == Some(a))
|
||||
.map(|port| port.side)
|
||||
.collect();
|
||||
assert_eq!(sides.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_upsert_reset_and_undo_preserve_unrelated_node_fields_and_list_order() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let extra = Id::new_v4();
|
||||
let spare = make_port(
|
||||
&p,
|
||||
extra,
|
||||
owner,
|
||||
"Spare",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(extra, spare);
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.2);
|
||||
manual(&mut p, d, n, extra, PortAnchorSide::Bottom, 0.7);
|
||||
let before = p.clone();
|
||||
let mut session = Session::new(p);
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port: a,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.45,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
let anchors = &changed.diagrams[&d].nodes[&n].port_anchors;
|
||||
assert_eq!(
|
||||
anchors.iter().map(|a| a.port).collect::<Vec<_>>(),
|
||||
[a, extra]
|
||||
);
|
||||
assert_eq!(anchors[1], before.diagrams[&d].nodes[&n].port_anchors[1]);
|
||||
assert_eq!(
|
||||
changed.diagrams[&d].nodes[&n].position,
|
||||
before.diagrams[&d].nodes[&n].position
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project().diagrams[&d].nodes[&n].port_anchors.len(),
|
||||
1
|
||||
);
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_anchors_and_payloads_fail_atomically_including_duplicate_owner_placements() {
|
||||
let (p, d, n, _, port, other_port, _) = fixture();
|
||||
for (port, offset) in [
|
||||
(port, f32::NAN),
|
||||
(port, f32::INFINITY),
|
||||
(port, -0.1),
|
||||
(port, 1.1),
|
||||
(other_port, 0.5),
|
||||
(Id::new_v4(), 0.5),
|
||||
(p.diagrams[&d].nodes[&n].element, 0.5),
|
||||
] {
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
let anchor = json!({"port": port, "side": "Top", "offset": 0.5});
|
||||
for bad in [
|
||||
json!({"port":port,"side":"Top"}),
|
||||
json!({"port":port,"side":"Diagonal","offset":0.5}),
|
||||
json!({"port":port,"side":"Top","offset":0.5,"owner":n}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<PortAnchor>(bad).is_err());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<Command>(
|
||||
json!({"SetPortAnchor":{"diagram":d,"node":n,"anchor":anchor,"unknown":true}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut duplicate = p.clone();
|
||||
let value = PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.5,
|
||||
};
|
||||
duplicate
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&n)
|
||||
.unwrap()
|
||||
.port_anchors = vec![value.clone(), value];
|
||||
assert!(prepare_command(&p, &Command::Replace(duplicate)).is_err());
|
||||
let mut duplicate = p.diagrams[&d].nodes[&n].clone();
|
||||
duplicate.id = Id::new_v4();
|
||||
duplicate.alias = "duplicate_view".into();
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::CreateNode {
|
||||
diagram: d,
|
||||
node: duplicate
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentification_deletion_reuse_and_owner_repair_preserve_correct_identity() {
|
||||
let (mut p, d, n, target, _, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let id = Id::new_v4();
|
||||
let value = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Spare output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, value.clone());
|
||||
manual(&mut p, d, n, id, PortAnchorSide::Top, 0.25);
|
||||
let new_id = Id::new_v4();
|
||||
let renamed = prepare_command(&p, &Command::Reidentify(id, new_id))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert_eq!(renamed.diagrams[&d].nodes[&n].port_anchors[0].port, new_id);
|
||||
let reused = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![Command::Delete(id), Command::CreateElement(value)]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(reused.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
let parent = Some(p.diagrams[&d].nodes[&target].element);
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::Reparent {
|
||||
element: id,
|
||||
parent
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut edited = p.elements[&id].clone();
|
||||
edited.parent = parent;
|
||||
assert!(prepare_command(&p, &Command::EditElement(id, edited.clone())).is_err());
|
||||
let repaired = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: id,
|
||||
},
|
||||
Command::EditElement(id, edited),
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(repaired.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
assert_eq!(repaired.elements[&id].parent, parent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_views_resize_layout_and_hidden_owners_keep_manual_metadata() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.35);
|
||||
let mut second = p.diagrams[&d].clone();
|
||||
second.id = Id::new_v4();
|
||||
second.alias = "review_view".into();
|
||||
second.nodes.clear();
|
||||
let other_d = second.id;
|
||||
p.diagrams.insert(second.id, second);
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let other_n = p.place(other_d, owner, pos(50., 50.));
|
||||
manual(&mut p, other_d, other_n, a, PortAnchorSide::Bottom, 0.8);
|
||||
let original = p.diagrams[&d].nodes[&n].port_anchors.clone();
|
||||
let mut edited = p.diagrams[&d].nodes[&n].clone();
|
||||
edited.size = Size {
|
||||
width: 600.,
|
||||
height: 450.,
|
||||
};
|
||||
edited.position = pos(200., 250.);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node: edited,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, a).position, pos(410., 250.));
|
||||
p = prepare_command(&p, &Command::Layout(d)).unwrap().project;
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
assert_eq!(
|
||||
p.diagrams[&other_d].nodes[&other_n].port_anchors[0].side,
|
||||
PortAnchorSide::Bottom
|
||||
);
|
||||
let mut diagram = p.diagrams[&d].clone();
|
||||
diagram.projection = ProjectionKind::Capabilities;
|
||||
p = prepare_command(&p, &Command::EditDiagram(d, diagram))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(project_diagram(&p, &p.diagrams[&d]).ports.is_empty());
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometry() {
|
||||
let (mut p, d, na, nb, a, b, edge) = fixture();
|
||||
let parent = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Group", None)
|
||||
.unwrap();
|
||||
for node in [na, nb] {
|
||||
let element = p.diagrams[&d].nodes[&node].element;
|
||||
p.elements.get_mut(&element).unwrap().parent = Some(parent);
|
||||
}
|
||||
let group = p.place(d, parent, pos(50., 0.));
|
||||
let obstacle = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Sibling obstacle", Some(parent))
|
||||
.unwrap();
|
||||
let obstacle_node = p.place(d, obstacle, pos(440., 160.));
|
||||
let group_port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
group_port,
|
||||
parent,
|
||||
"Group input",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(group_port, element);
|
||||
manual(&mut p, d, group, group_port, PortAnchorSide::Top, 0.25);
|
||||
manual(&mut p, d, na, a, PortAnchorSide::Bottom, 0.5);
|
||||
manual(&mut p, d, nb, b, PortAnchorSide::Bottom, 0.5);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::SetEdgeRoute {
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![pos(500., 420.)],
|
||||
label: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let parent_body = view.nodes.iter().find(|n| n.id == group).unwrap();
|
||||
assert!(parent_body.size.width > p.diagrams[&d].nodes[&group].size.width);
|
||||
assert_eq!(
|
||||
port(&view, group_port).position,
|
||||
port_anchor_position(parent_body, PortAnchorSide::Top, 0.25)
|
||||
);
|
||||
let routes = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routes.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routes.edges[&edge]
|
||||
);
|
||||
let obstacle = view.nodes.iter().find(|n| n.id == obstacle_node).unwrap();
|
||||
for pair in routes.edges[&edge].points.windows(2) {
|
||||
let [a, b] = [pair[0], pair[1]];
|
||||
let crosses = if a.x == b.x {
|
||||
a.x > obstacle.position.x
|
||||
&& a.x < obstacle.position.x + obstacle.size.width
|
||||
&& a.y.min(b.y) < obstacle.position.y + obstacle.size.height
|
||||
&& a.y.max(b.y) > obstacle.position.y
|
||||
} else {
|
||||
a.y > obstacle.position.y
|
||||
&& a.y < obstacle.position.y + obstacle.size.height
|
||||
&& a.x.min(b.x) < obstacle.position.x + obstacle.size.width
|
||||
&& a.x.max(b.x) > obstacle.position.x
|
||||
};
|
||||
assert!(!crosses);
|
||||
}
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::MoveNode {
|
||||
diagram: d,
|
||||
node: group,
|
||||
position: pos(190., 80.),
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
let moved = session.project();
|
||||
let after = project_diagram(moved, &moved.diagrams[&d]);
|
||||
for before in &view.ports {
|
||||
assert_eq!(
|
||||
port(&after, before.element).position,
|
||||
pos(before.position.x + 140., before.position.y + 80.)
|
||||
);
|
||||
}
|
||||
for (id, node) in &p.diagrams[&d].nodes {
|
||||
assert_eq!(moved.diagrams[&d].nodes[id].port_anchors, node.port_anchors);
|
||||
}
|
||||
assert_eq!(moved.diagrams[&d].routes[0].waypoints, [pos(640., 500.)]);
|
||||
assert!(
|
||||
route_diagram(moved, &moved.diagrams[&d], &after).edges[&edge]
|
||||
.warnings
|
||||
.is_empty()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_peer_gap_uses_clear_side_instead_of_a_blocked_facing_stub() {
|
||||
let (mut p, d, a, b, output, input, edge) = fixture();
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&b)
|
||||
.unwrap()
|
||||
.position = pos(100., 260.);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.id == a).unwrap();
|
||||
let target = view.nodes.iter().find(|n| n.id == b).unwrap();
|
||||
assert!(target.position.y - source.position.y - source.size.height < PORT_STUB + CLEARANCE);
|
||||
assert_eq!(port(&view, output).side, PortAnchorSide::Right);
|
||||
assert_eq!(port(&view, input).side, PortAnchorSide::Left);
|
||||
let routed = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routed.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routed.edges[&edge]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_neutral_ports_balance_capacity_without_a_giant_bottom_footer() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::System, Layer::Sa, "Many interfaces", None)
|
||||
.unwrap();
|
||||
for i in 0..100 {
|
||||
let id = Id::from_u128(i + 1);
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
&format!("Interface {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let node = p.place(d, owner, pos(0., 0.));
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(view.ports.len(), 100);
|
||||
assert!(view.nodes[0].size.width < 1000. && view.nodes[0].size.height < 1000.);
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let mut offsets: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|p| p.side == side)
|
||||
.map(|p| port_anchor_offset(&view.nodes[0], side, p.position))
|
||||
.collect();
|
||||
assert_eq!(offsets.len(), 25);
|
||||
offsets.sort_by(f32::total_cmp);
|
||||
let length = if matches!(side, PortAnchorSide::Left | PortAnchorSide::Right) {
|
||||
view.nodes[0].size.height
|
||||
} else {
|
||||
view.nodes[0].size.width
|
||||
};
|
||||
assert!(offsets.windows(2).all(|w| (w[1] - w[0]) * length >= 27.99));
|
||||
}
|
||||
let id = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.side == PortAnchorSide::Left)
|
||||
.unwrap()
|
||||
.element;
|
||||
manual(&mut p, d, node, id, PortAnchorSide::Top, 0.5);
|
||||
let after = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(after.nodes, view.nodes);
|
||||
let placed = port(&after, id);
|
||||
for other in after.ports.iter().filter(|p| p.element != id) {
|
||||
assert_eq!(other.side, port(&view, other.element).side);
|
||||
let distance = (other.position.x - placed.position.x).abs()
|
||||
+ (other.position.y - placed.position.y).abs();
|
||||
assert!(
|
||||
distance >= 14.,
|
||||
"An automatic port overlaps the authored anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
project ports "Ports" {
|
||||
sa_function owner "Owner" {}
|
||||
sa_port first "First" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
sa_port second "Second" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
diagram view "View" { layer Sa; }
|
||||
node owner_view { diagram @view; element @owner; port_anchors [
|
||||
// first rationale
|
||||
{ port @first; side Top; offset 0.25; },
|
||||
// second rationale
|
||||
{ port @second; side Bottom; offset 0.75; }
|
||||
]; }
|
||||
}
|
||||
"#;
|
||||
fn find(p: &Project, alias: &str) -> Id {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_json_roundtrip_defaults_and_admission_include_anchor_records() {
|
||||
let p = language::parse(SOURCE).unwrap();
|
||||
let formatted = language::format(&p);
|
||||
assert!(formatted.contains("port @first;") && formatted.contains("side Top;"));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), p);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
|
||||
p
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&p), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
p
|
||||
);
|
||||
// project + function + two ports + diagram + node + two anchors = 8.
|
||||
let limits = language::ParseLimits {
|
||||
max_records: 7,
|
||||
..language::ParseLimits::UNLIMITED
|
||||
};
|
||||
assert!(limits.check_model(&p).is_err());
|
||||
assert!(persistence::decode_limited(&serde_json::to_vec(&p).unwrap(), limits).is_err());
|
||||
assert!(language::parse_limited(SOURCE, limits).is_err());
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&format_bundle(&p),
|
||||
ProjectLimits {
|
||||
model: limits,
|
||||
..ProjectLimits::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut value = serde_json::to_value(&p).unwrap();
|
||||
let d = find(&p, "view");
|
||||
let n = find(&p, "owner_view");
|
||||
value["diagrams"][d.to_string()]["nodes"][n.to_string()]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("port_anchors");
|
||||
let read = persistence::decode(&serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
assert!(read.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_updater_keeps_comment_attachment_through_identity_swap_and_anchor_reordering() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.replace('\n', "\r\n"))]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let d = find(p, "view");
|
||||
let n = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let temp = Id::new_v4();
|
||||
let mut commands = vec![
|
||||
Command::Reidentify(first, temp),
|
||||
Command::Reidentify(second, first),
|
||||
Command::Reidentify(temp, second),
|
||||
Command::SetAlias(second, "revised_first".into()),
|
||||
];
|
||||
let candidate = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.unwrap()
|
||||
.project;
|
||||
let mut node = candidate.diagrams[&d].nodes[&n].clone();
|
||||
node.port_anchors.reverse();
|
||||
node.port_anchors[1].offset = 0.5;
|
||||
commands.push(Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node,
|
||||
});
|
||||
let prepared = prepare_command(p, &Command::Batch(commands)).unwrap();
|
||||
let result = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = &result.bundle.files["project.arcadia"];
|
||||
assert!(
|
||||
text.contains("// first rationale\r\n { port @revised_first; side Top; offset 0.5; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("// second rationale\r\n { port @second; side Bottom; offset 0.75; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(result.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
update_bundle(&result, &prepared.project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_record() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let diagram = find(p, "view");
|
||||
let node = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: first,
|
||||
},
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: second,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let cleared = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cleared.decoded.project, prepared.project);
|
||||
let text = &cleared.bundle.files["project.arcadia"];
|
||||
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
||||
assert!(!text.contains("port_anchors"));
|
||||
let prepared = prepare_command(
|
||||
&cleared.decoded.project,
|
||||
&Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor: PortAnchor {
|
||||
port: second,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let added = update_bundle_with_identity_map(
|
||||
&cleared,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(added.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
added.bundle.files["project.arcadia"]
|
||||
.matches("// second rationale")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_source_anchor_records_have_located_errors() {
|
||||
for bad in [
|
||||
"{ port @first; side Top; }",
|
||||
"{ port @first; side Top; offset -0.1; }",
|
||||
"{ port @first; side Corner; offset 0.5; }",
|
||||
"{ port @owner; side Top; offset 0.5; }",
|
||||
"{ port @first; side Top; offset 0.5; unexpected true; }",
|
||||
"{ port @first; side Top; offset 0.1; }, { port @first; side Right; offset 0.2; }",
|
||||
] {
|
||||
let start = SOURCE.find("port_anchors [").unwrap() + "port_anchors [".len();
|
||||
let end = SOURCE[start..].find("]; }").unwrap() + start;
|
||||
let source = format!("{}\n{}\n{}", &SOURCE[..start], bad, &SOURCE[end..]);
|
||||
let errors = language::parse(&source).expect_err(bad);
|
||||
assert!(errors.iter().any(|e| e.line >= 7), "{bad}: {errors:?}");
|
||||
}
|
||||
}
|
||||
@@ -811,16 +811,19 @@ fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_i
|
||||
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
|
||||
assert!(!edge.oriented);
|
||||
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
|
||||
assert!(
|
||||
view.ports
|
||||
.iter()
|
||||
.filter(|port| [
|
||||
p.relations[&boundary].source(),
|
||||
p.relations[&boundary].target()
|
||||
]
|
||||
.contains(&port.element))
|
||||
.all(|port| port.side == PortAnchorSide::Bottom)
|
||||
);
|
||||
for (element, side) in [
|
||||
(p.relations[&boundary].source(), PortAnchorSide::Right),
|
||||
(p.relations[&boundary].target(), PortAnchorSide::Left),
|
||||
] {
|
||||
assert_eq!(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|port| port.element == element)
|
||||
.unwrap()
|
||||
.side,
|
||||
side
|
||||
);
|
||||
}
|
||||
let (output, input) = endpoints(&p, fe);
|
||||
assert_eq!(
|
||||
view.ports
|
||||
|
||||
@@ -357,9 +357,12 @@ fn workshop_automatic_labels_are_readable_without_mutating_authored_routes() {
|
||||
size
|
||||
}
|
||||
),
|
||||
"{} overlaps {}",
|
||||
"{} overlaps {}: label {:?}, node {:?}, path {:?}",
|
||||
label.text,
|
||||
p.named(node.element)
|
||||
p.named(node.element),
|
||||
label.bounds,
|
||||
node,
|
||||
routed.edges[&edge.id].points
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user