diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 5c1df0d0..47a0035d 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -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::(), + ) + }) .sum::(), ) .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); diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index 34cedf92..456050c5 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -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, } #[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); diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index 2d449101..cefe2cef 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -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 { 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, pub ports: Vec, } -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> { @@ -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; + } + } +} diff --git a/crates/oa-core/src/projection/port_layout.rs b/crates/oa-core/src/projection/port_layout.rs new file mode 100644 index 00000000..c8ec61df --- /dev/null +++ b/crates/oa-core/src/projection/port_layout.rs @@ -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, + peer: Option, +} + +fn facing(node: &ProjectedNode, peer: Position) -> Option { + 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, + by_id: &BTreeMap, +) -> 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, +} +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 { + 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> = 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::>::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 { + 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::(); + 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() +} diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index 38035774..c1a6fd95 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -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) { 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| { if v.is_some_and(|id| removed.contains(&id)) { diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs index 87fda464..92fe08b0 100644 --- a/crates/oa-core/src/routing.rs +++ b/crates/oa-core/src/routing.rs @@ -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, 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), }, } } diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index ddb3a41e..5eb2c64a 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -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) { diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index b7efae0e..ca324f48 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -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, } } diff --git a/crates/oa-core/src/transitions.rs b/crates/oa-core/src/transitions.rs index 23b8134a..8d106d5a 100644 --- a/crates/oa-core/src/transitions.rs +++ b/crates/oa-core/src/transitions.rs @@ -981,6 +981,7 @@ pub fn plan_transition( height: 94., }, collapsed: false, + port_anchors: Vec::new(), }; working .diagrams diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index 278240a5..3e46e880 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -731,6 +731,40 @@ pub fn validate(p: &Project) -> Vec { } 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, diff --git a/crates/oa-core/tests/port_anchors.rs b/crates/oa-core/tests/port_anchors.rs new file mode 100644 index 00000000..bb92f098 --- /dev/null +++ b/crates/oa-core/tests/port_anchors.rs @@ -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::>(), + [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::(bad).is_err()); + } + assert!( + serde_json::from_value::( + 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:?}"); + } +} diff --git a/crates/oa-core/tests/release_ports.rs b/crates/oa-core/tests/release_ports.rs index a9c467d4..30194ef6 100644 --- a/crates/oa-core/tests/release_ports.rs +++ b/crates/oa-core/tests/release_ports.rs @@ -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 diff --git a/crates/oa-core/tests/routing_geometry.rs b/crates/oa-core/tests/routing_geometry.rs index a4422deb..f242e799 100644 --- a/crates/oa-core/tests/routing_geometry.rs +++ b/crates/oa-core/tests/routing_geometry.rs @@ -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 ); } }