873 lines
31 KiB
Diff
873 lines
31 KiB
Diff
diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs
|
|
index befb027..2ee820e 100644
|
|
--- a/crates/oa-core/src/projection.rs
|
|
+++ b/crates/oa-core/src/projection.rs
|
|
@@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
mod port_layout;
|
|
pub use port_layout::{port_anchor_offset, port_anchor_position};
|
|
+
|
|
+/// Isolated experiment only: no persisted setting or production default change.
|
|
+#[doc(hidden)]
|
|
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
+pub enum ExperimentalPortPolicy {
|
|
+ Current,
|
|
+ SameSidesAdaptSpacing,
|
|
+ CapacityAware,
|
|
+}
|
|
#[path = "projection/state.rs"]
|
|
mod state;
|
|
pub(crate) use state::node_size as state_node_size;
|
|
@@ -249,7 +258,17 @@ pub fn project_diagram_with_index(
|
|
d: &Diagram,
|
|
index: &crate::view_options::ViewIndex,
|
|
) -> ProjectedView {
|
|
- project_diagram_impl(p, d, None, Some(index))
|
|
+ project_diagram_impl(p, d, None, Some(index), ExperimentalPortPolicy::Current)
|
|
+}
|
|
+/// Compare port policies over the actual projector; not a document/API setting.
|
|
+#[doc(hidden)]
|
|
+pub fn project_diagram_with_experimental_port_policy(
|
|
+ p: &Project,
|
|
+ d: &Diagram,
|
|
+ policy: ExperimentalPortPolicy,
|
|
+) -> ProjectedView {
|
|
+ let index = crate::view_options::ViewIndex::new(p);
|
|
+ project_diagram_impl(p, d, None, Some(&index), policy)
|
|
}
|
|
/// Unfiltered eligibility from the same projection/anchor/fold decisions used
|
|
/// for drawing. Compute once for a view-options editor, then combine with the
|
|
@@ -269,7 +288,13 @@ pub fn edge_availability(
|
|
)
|
|
})
|
|
.collect();
|
|
- project_diagram_impl(p, d, Some(&mut availability), None);
|
|
+ project_diagram_impl(
|
|
+ p,
|
|
+ d,
|
|
+ Some(&mut availability),
|
|
+ None,
|
|
+ ExperimentalPortPolicy::Current,
|
|
+ );
|
|
availability
|
|
}
|
|
fn project_diagram_impl(
|
|
@@ -277,6 +302,7 @@ fn project_diagram_impl(
|
|
d: &Diagram,
|
|
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
|
index: Option<&crate::view_options::ViewIndex>,
|
|
+ port_policy: ExperimentalPortPolicy,
|
|
) -> ProjectedView {
|
|
let parents = parents(p, d);
|
|
let mut nodes = Vec::new();
|
|
@@ -516,7 +542,7 @@ fn project_diagram_impl(
|
|
let ports = if d.projection.state_machine().is_some() {
|
|
Vec::new()
|
|
} else {
|
|
- port_layout::layout(p, d, &mut nodes, &edges)
|
|
+ port_layout::layout(p, d, &mut nodes, &edges, port_policy)
|
|
};
|
|
// Port geometry uses every eligible edge. Presentation filters must never
|
|
// move visible ports when another line is hidden or a category is selected.
|
|
diff --git a/crates/oa-core/src/projection/port_capacity_experiment.rs b/crates/oa-core/src/projection/port_capacity_experiment.rs
|
|
new file mode 100644
|
|
index 0000000..25bdab0
|
|
--- /dev/null
|
|
+++ b/crates/oa-core/src/projection/port_capacity_experiment.rs
|
|
@@ -0,0 +1,221 @@
|
|
+//! Experimental four-side capacity assignment; no model fields or router feedback.
|
|
+use super::*;
|
|
+
|
|
+#[derive(Clone)]
|
|
+pub(super) struct Demand {
|
|
+ pub preferred: PortAnchorSide,
|
|
+ pub semantic: Option<PortAnchorSide>,
|
|
+ pub clear: [bool; 4],
|
|
+}
|
|
+
|
|
+fn index(side: PortAnchorSide) -> usize {
|
|
+ SIDES
|
|
+ .iter()
|
|
+ .position(|candidate| *candidate == side)
|
|
+ .unwrap()
|
|
+}
|
|
+
|
|
+fn capacity(length: f32) -> usize {
|
|
+ if length < 2. * CORNER {
|
|
+ 0
|
|
+ } else {
|
|
+ ((length - 2. * CORNER) / GAP).floor() as usize + 1
|
|
+ }
|
|
+}
|
|
+
|
|
+fn counts(assigned: &[Option<PortAnchorSide>]) -> [usize; 4] {
|
|
+ let mut result = [0; 4];
|
|
+ for side in assigned.iter().flatten() {
|
|
+ result[index(*side)] += 1;
|
|
+ }
|
|
+ result
|
|
+}
|
|
+
|
|
+fn ranked(demand: &Demand, counts: [usize; 4]) -> Vec<PortAnchorSide> {
|
|
+ let mut result = Vec::new();
|
|
+ for side in [Some(demand.preferred), demand.semantic]
|
|
+ .into_iter()
|
|
+ .flatten()
|
|
+ {
|
|
+ if demand.clear[index(side)] && !result.contains(&side) {
|
|
+ result.push(side);
|
|
+ }
|
|
+ }
|
|
+ let mut remaining = SIDES;
|
|
+ remaining.sort_by_key(|side| (counts[index(*side)], index(*side)));
|
|
+ for side in remaining {
|
|
+ if demand.clear[index(side)] && !result.contains(&side) {
|
|
+ result.push(side);
|
|
+ }
|
|
+ }
|
|
+ result
|
|
+}
|
|
+
|
|
+// An alternating reassignment visits each of four side bins at most once.
|
|
+// It is attempted only after every directly free clear side has been considered.
|
|
+fn try_assign(
|
|
+ port: usize,
|
|
+ demands: &[Demand],
|
|
+ capacities: [usize; 4],
|
|
+ assigned: &mut [Option<PortAnchorSide>],
|
|
+ visited: &mut [bool; 4],
|
|
+) -> bool {
|
|
+ let counts = counts(assigned);
|
|
+ let sides = ranked(&demands[port], counts);
|
|
+ for side in &sides {
|
|
+ let slot = index(*side);
|
|
+ if !visited[slot] && counts[slot] < capacities[slot] {
|
|
+ assigned[port] = Some(*side);
|
|
+ return true;
|
|
+ }
|
|
+ }
|
|
+ for side in sides {
|
|
+ let slot = index(side);
|
|
+ if visited[slot] || capacities[slot] == 0 {
|
|
+ continue;
|
|
+ }
|
|
+ visited[slot] = true;
|
|
+ let occupants: Vec<_> = assigned
|
|
+ .iter()
|
|
+ .enumerate()
|
|
+ .filter_map(|(port, current)| (*current == Some(side)).then_some(port))
|
|
+ .collect();
|
|
+ for other in occupants {
|
|
+ if try_assign(other, demands, capacities, assigned, visited) {
|
|
+ assigned[port] = Some(side);
|
|
+ return true;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ false
|
|
+}
|
|
+
|
|
+fn assign_with_capacities(demands: &[Demand], capacities: [usize; 4]) -> Vec<PortAnchorSide> {
|
|
+ let mut assigned = vec![None; demands.len()];
|
|
+ // Demands are in the core's stable semantic port-ID order. Manual anchors
|
|
+ // are deliberately absent: editing one must not resize owners or flip peers.
|
|
+ for port in 0..demands.len() {
|
|
+ try_assign(port, demands, capacities, &mut assigned, &mut [false; 4]);
|
|
+ }
|
|
+ for (port, demand) in demands.iter().enumerate() {
|
|
+ if assigned[port].is_some() {
|
|
+ continue;
|
|
+ }
|
|
+ let counts = counts(&assigned);
|
|
+ // Prefer a crowded clear exit to an available blocked one. The existing
|
|
+ // position allocator adapts spacing; no port or owner geometry is invented.
|
|
+ let side = ranked(demand, counts)
|
|
+ .into_iter()
|
|
+ .next()
|
|
+ .unwrap_or_else(|| {
|
|
+ *SIDES
|
|
+ .iter()
|
|
+ .min_by_key(|side| counts[index(**side)])
|
|
+ .unwrap()
|
|
+ });
|
|
+ assigned[port] = Some(side);
|
|
+ }
|
|
+ assigned.into_iter().map(Option::unwrap).collect()
|
|
+}
|
|
+
|
|
+pub(super) fn assign(node: &ProjectedNode, demands: &[Demand]) -> Vec<PortAnchorSide> {
|
|
+ assign_with_capacities(
|
|
+ demands,
|
|
+ [
|
|
+ capacity(node.size.height),
|
|
+ capacity(node.size.height),
|
|
+ capacity(node.size.width),
|
|
+ capacity(node.size.width),
|
|
+ ],
|
|
+ )
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::*;
|
|
+
|
|
+ fn demand(preferred: PortAnchorSide, sides: &[PortAnchorSide]) -> Demand {
|
|
+ Demand {
|
|
+ preferred,
|
|
+ semantic: None,
|
|
+ clear: SIDES.map(|side| sides.contains(&side)),
|
|
+ }
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn reassignment_recovers_scarce_clear_capacity() {
|
|
+ use PortAnchorSide::{Left, Right, Top};
|
|
+ // Assignment-level falsifier only. Its clear masks are not yet produced
|
|
+ // by a model fixture with actual peer geometry.
|
|
+ let mut demands = vec![demand(Left, &[Left, Top]); 8];
|
|
+ demands.extend(vec![demand(Left, &[Left, Right]); 9]);
|
|
+ demands.extend(vec![demand(Right, &[Right, Top]); 3]);
|
|
+ let capacities = [8, 8, 6, 0];
|
|
+ let result = assign_with_capacities(&demands, capacities);
|
|
+ let totals = counts(&result.iter().copied().map(Some).collect::<Vec<_>>());
|
|
+ for (actual, capacity) in totals.into_iter().zip(capacities) {
|
|
+ assert!(actual <= capacity);
|
|
+ }
|
|
+ for (side, demand) in result.iter().zip(&demands) {
|
|
+ assert!(demand.clear[index(*side)]);
|
|
+ }
|
|
+ assert_eq!(assign_with_capacities(&demands, capacities), result);
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn clear_overflow_does_not_choose_a_blocked_empty_side() {
|
|
+ let demands = vec![demand(PortAnchorSide::Right, &[PortAnchorSide::Right]); 5];
|
|
+ let result = assign_with_capacities(&demands, [10, 1, 10, 10]);
|
|
+ assert!(result.iter().all(|side| *side == PortAnchorSide::Right));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn small_clear_masks_match_an_exhaustive_assignment_oracle() {
|
|
+ let capacities = [1, 1, 1, 0];
|
|
+ // All343 triples of nonempty masks on three sides. This validates the
|
|
+ // alternating assignment itself, not geometric reachability of masks.
|
|
+ for a in 1..8_u8 {
|
|
+ for b in 1..8_u8 {
|
|
+ for c in 1..8_u8 {
|
|
+ let demands: Vec<_> = [a, b, c]
|
|
+ .into_iter()
|
|
+ .map(|mask| {
|
|
+ let sides: Vec<_> = SIDES
|
|
+ .iter()
|
|
+ .copied()
|
|
+ .filter(|side| mask & (1 << index(*side)) != 0)
|
|
+ .collect();
|
|
+ demand(sides[0], &sides)
|
|
+ })
|
|
+ .collect();
|
|
+ let feasible = (0..3).any(|x| {
|
|
+ (0..3).any(|y| {
|
|
+ (0..3).any(|z| {
|
|
+ x != y
|
|
+ && x != z
|
|
+ && y != z
|
|
+ && demands[0].clear[x]
|
|
+ && demands[1].clear[y]
|
|
+ && demands[2].clear[z]
|
|
+ })
|
|
+ })
|
|
+ });
|
|
+ let result = assign_with_capacities(&demands, capacities);
|
|
+ for (side, demand) in result.iter().zip(&demands) {
|
|
+ assert!(demand.clear[index(*side)]);
|
|
+ }
|
|
+ if feasible {
|
|
+ let totals = counts(&result.into_iter().map(Some).collect::<Vec<_>>());
|
|
+ assert!(
|
|
+ totals
|
|
+ .into_iter()
|
|
+ .zip(capacities)
|
|
+ .all(|(n, capacity)| n <= capacity),
|
|
+ "missed feasible masks {a}/{b}/{c}"
|
|
+ );
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/crates/oa-core/src/projection/port_layout.rs b/crates/oa-core/src/projection/port_layout.rs
|
|
index c8ec61d..ba0d868 100644
|
|
--- a/crates/oa-core/src/projection/port_layout.rs
|
|
+++ b/crates/oa-core/src/projection/port_layout.rs
|
|
@@ -1,6 +1,8 @@
|
|
//! Presentation-only port anchors. Automatic choices depend on owner geometry,
|
|
//! never on routed elbows, so routing cannot feed back into layout.
|
|
use super::*;
|
|
+#[path = "port_capacity_experiment.rs"]
|
|
+mod capacity_experiment;
|
|
|
|
const GAP: f32 = 28.;
|
|
const CORNER: f32 = 18.;
|
|
@@ -50,6 +52,7 @@ struct Choice {
|
|
automatic_side: PortAnchorSide,
|
|
manual: Option<f32>,
|
|
peer: Option<Position>,
|
|
+ capacity: Option<capacity_experiment::Demand>,
|
|
}
|
|
|
|
fn facing(node: &ProjectedNode, peer: Position) -> Option<PortAnchorSide> {
|
|
@@ -158,6 +161,7 @@ pub(super) fn layout(
|
|
d: &Diagram,
|
|
nodes: &mut [ProjectedNode],
|
|
edges: &[ProjectedEdge],
|
|
+ policy: ExperimentalPortPolicy,
|
|
) -> Vec<ProjectedPort> {
|
|
let by_id: BTreeMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect();
|
|
let mut peers: BTreeMap<(Id, Id), Peers> = BTreeMap::new();
|
|
@@ -228,34 +232,62 @@ pub(super) fn layout(
|
|
automatic_side: side,
|
|
manual: anchor.map(|a| a.offset),
|
|
peer,
|
|
+ capacity: (policy == ExperimentalPortPolicy::CapacityAware).then(|| {
|
|
+ capacity_experiment::Demand {
|
|
+ preferred: side,
|
|
+ semantic: port_anchor_side(port),
|
|
+ clear: SIDES.map(clear),
|
|
+ }
|
|
+ }),
|
|
});
|
|
}
|
|
}
|
|
- // 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 {
|
|
+ if policy == ExperimentalPortPolicy::CapacityAware {
|
|
+ for node in nodes.iter() {
|
|
+ let Some(choices) = choices.get_mut(&node.id) else {
|
|
continue;
|
|
+ };
|
|
+ let demands: Vec<_> = choices
|
|
+ .iter()
|
|
+ .map(|choice| choice.capacity.clone().unwrap())
|
|
+ .collect();
|
|
+ let sides = capacity_experiment::assign(node, &demands);
|
|
+ for (choice, side) in choices.iter_mut().zip(sides) {
|
|
+ choice.automatic_side = side;
|
|
+ if choice.manual.is_none() {
|
|
+ choice.side = side;
|
|
+ }
|
|
}
|
|
- 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)
|
|
+ }
|
|
+ }
|
|
+ // One capacity pass followed by semantic enclosure. Side choices stay frozen
|
|
+ // throughout this pass; expanded parents cannot cause layout oscillation.
|
|
+ // Experimental alternatives finalize nodes before port-side assignment.
|
|
+ if policy == ExperimentalPortPolicy::Current {
|
|
+ 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;
|
|
}
|
|
- PortAnchorSide::Top | PortAnchorSide::Bottom => {
|
|
- node.size.width = node.size.width.max(minimum)
|
|
+ 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);
|
|
}
|
|
- super::enclose(p, nodes);
|
|
let mut result = Vec::new();
|
|
for node in nodes.iter() {
|
|
let Some(choices) = choices.get(&node.id) else {
|
|
diff --git a/crates/oa-core/tests/experimental_port_policy.rs b/crates/oa-core/tests/experimental_port_policy.rs
|
|
new file mode 100644
|
|
index 0000000..06e3dc3
|
|
--- /dev/null
|
|
+++ b/crates/oa-core/tests/experimental_port_policy.rs
|
|
@@ -0,0 +1,458 @@
|
|
+//! Isolated policy comparisons. Current remains the ordinary projector default.
|
|
+use oa_core::{projection::*, routing::*, *};
|
|
+
|
|
+const ALTERNATIVES: [ExperimentalPortPolicy; 2] = [
|
|
+ ExperimentalPortPolicy::SameSidesAdaptSpacing,
|
|
+ ExperimentalPortPolicy::CapacityAware,
|
|
+];
|
|
+const POLICIES: [ExperimentalPortPolicy; 3] = [
|
|
+ ExperimentalPortPolicy::Current,
|
|
+ ExperimentalPortPolicy::SameSidesAdaptSpacing,
|
|
+ ExperimentalPortPolicy::CapacityAware,
|
|
+];
|
|
+
|
|
+fn pos(x: f32, y: f32) -> Position {
|
|
+ Position { x, y }
|
|
+}
|
|
+fn size(width: f32, height: f32) -> Size {
|
|
+ Size { width, height }
|
|
+}
|
|
+fn standard(p: &mut Project, owner: Id, name: &str) -> Id {
|
|
+ let id = Id::new_v4();
|
|
+ let port = make_port(
|
|
+ p,
|
|
+ id,
|
|
+ owner,
|
|
+ name,
|
|
+ PortContract::Component {
|
|
+ kind: ComponentPortKind::Standard,
|
|
+ orientation: ComponentPortOrientation::Unset,
|
|
+ },
|
|
+ )
|
|
+ .unwrap();
|
|
+ p.elements.insert(id, port);
|
|
+ id
|
|
+}
|
|
+fn place(p: &mut Project, d: Id, e: Id, position: Position, dimensions: Size) -> Id {
|
|
+ let n = p.place(d, e, position);
|
|
+ p.diagrams
|
|
+ .get_mut(&d)
|
|
+ .unwrap()
|
|
+ .nodes
|
|
+ .get_mut(&n)
|
|
+ .unwrap()
|
|
+ .size = dimensions;
|
|
+ n
|
|
+}
|
|
+struct Fixture {
|
|
+ p: Project,
|
|
+ d: Id,
|
|
+ owner: Id,
|
|
+ ports: Vec<Id>,
|
|
+}
|
|
+fn eight_ports() -> Fixture {
|
|
+ let mut p = Project::empty();
|
|
+ let a = p
|
|
+ .add_element(Kind::System, Layer::Sa, "Owner", None)
|
|
+ .unwrap();
|
|
+ let b = p
|
|
+ .add_element(Kind::SystemActor, Layer::Sa, "Above", None)
|
|
+ .unwrap();
|
|
+ let c = p
|
|
+ .add_element(Kind::SystemActor, Layer::Sa, "Below", None)
|
|
+ .unwrap();
|
|
+ let pb = standard(&mut p, b, "Above interface");
|
|
+ let pc = standard(&mut p, c, "Below interface");
|
|
+ let mut ports = Vec::new();
|
|
+ for i in 0..8 {
|
|
+ let pa = standard(&mut p, a, &format!("Interface {i}"));
|
|
+ p.relate(
|
|
+ RelationKind::BoundaryExchange,
|
|
+ &format!("Above {i}"),
|
|
+ pa,
|
|
+ pb,
|
|
+ );
|
|
+ p.relate(
|
|
+ RelationKind::BoundaryExchange,
|
|
+ &format!("Below {i}"),
|
|
+ pa,
|
|
+ pc,
|
|
+ );
|
|
+ ports.push(pa);
|
|
+ }
|
|
+ let d = p.ensure_diagram(Layer::Sa);
|
|
+ let owner = place(&mut p, d, a, pos(0., 0.), size(224., 160.));
|
|
+ place(&mut p, d, b, pos(38., -996.), size(184., 132.));
|
|
+ place(&mut p, d, c, pos(38., 1004.), size(184., 132.));
|
|
+ validate_candidate(&p).unwrap();
|
|
+ Fixture { p, d, owner, ports }
|
|
+}
|
|
+fn hundred_ports() -> Fixture {
|
|
+ let mut p = Project::empty();
|
|
+ let a = p
|
|
+ .add_element(Kind::System, Layer::Sa, "Owner", None)
|
|
+ .unwrap();
|
|
+ let b = p
|
|
+ .add_element(Kind::SystemActor, Layer::Sa, "Peer", None)
|
|
+ .unwrap();
|
|
+ let pb = standard(&mut p, b, "Peer interface");
|
|
+ let mut ports = Vec::new();
|
|
+ for i in 0..100 {
|
|
+ let pa = standard(&mut p, a, &format!("Interface {i}"));
|
|
+ p.relate(
|
|
+ RelationKind::BoundaryExchange,
|
|
+ &format!("Exchange {i}"),
|
|
+ pa,
|
|
+ pb,
|
|
+ );
|
|
+ ports.push(pa);
|
|
+ }
|
|
+ let d = p.ensure_diagram(Layer::Sa);
|
|
+ // Semantic port minima grow this to728x804 before any side choice.
|
|
+ let owner = place(&mut p, d, a, pos(0., 0.), size(224., 160.));
|
|
+ place(&mut p, d, b, pos(1908., 336.), size(184., 132.));
|
|
+ validate_candidate(&p).unwrap();
|
|
+ Fixture { p, d, owner, ports }
|
|
+}
|
|
+fn project(f: &Fixture, policy: ExperimentalPortPolicy) -> ProjectedView {
|
|
+ project_diagram_with_experimental_port_policy(&f.p, &f.p.diagrams[&f.d], policy)
|
|
+}
|
|
+fn node(view: &ProjectedView, id: Id) -> &ProjectedNode {
|
|
+ view.nodes.iter().find(|node| node.id == id).unwrap()
|
|
+}
|
|
+fn port(view: &ProjectedView, id: Id) -> &ProjectedPort {
|
|
+ view.ports.iter().find(|port| port.element == id).unwrap()
|
|
+}
|
|
+fn minimum_gap(view: &ProjectedView, owner: Id) -> f32 {
|
|
+ let mut result = f32::INFINITY;
|
|
+ for side in [
|
|
+ PortAnchorSide::Left,
|
|
+ PortAnchorSide::Right,
|
|
+ PortAnchorSide::Top,
|
|
+ PortAnchorSide::Bottom,
|
|
+ ] {
|
|
+ let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
|
|
+ let mut positions: Vec<_> = view
|
|
+ .ports
|
|
+ .iter()
|
|
+ .filter(|p| p.owner == owner && p.side == side)
|
|
+ .map(|p| {
|
|
+ if horizontal {
|
|
+ p.position.x
|
|
+ } else {
|
|
+ p.position.y
|
|
+ }
|
|
+ })
|
|
+ .collect();
|
|
+ positions.sort_by(f32::total_cmp);
|
|
+ for pair in positions.windows(2) {
|
|
+ result = result.min(pair[1] - pair[0]);
|
|
+ }
|
|
+ }
|
|
+ result
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn experimental_current_is_the_unchanged_default_projector() {
|
|
+ for p in [Project::demo(), eight_ports().p, hundred_ports().p] {
|
|
+ for diagram in p.diagrams.values() {
|
|
+ let expected = project_diagram(&p, diagram);
|
|
+ let actual = project_diagram_with_experimental_port_policy(
|
|
+ &p,
|
|
+ diagram,
|
|
+ ExperimentalPortPolicy::Current,
|
|
+ );
|
|
+ assert_eq!(actual, expected);
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn alternatives_realize_the_three_previously_forbidden_rectangles() {
|
|
+ for policy in POLICIES {
|
|
+ for (position, dimensions) in [
|
|
+ (pos(0., -10.), size(224., 242.)),
|
|
+ (pos(10., 0.), size(214., 232.)),
|
|
+ (pos(0., 0.), size(224., 222.)),
|
|
+ ] {
|
|
+ let mut f = eight_ports();
|
|
+ let original = f.p.diagrams[&f.d].nodes[&f.owner].clone();
|
|
+ let mut edited = original.clone();
|
|
+ edited.position = position;
|
|
+ edited.size = dimensions;
|
|
+ let before = f.p.clone();
|
|
+ f.p = prepare_command(
|
|
+ &f.p,
|
|
+ &Command::EditNode {
|
|
+ diagram: f.d,
|
|
+ original: f.owner,
|
|
+ node: edited,
|
|
+ },
|
|
+ )
|
|
+ .unwrap()
|
|
+ .project;
|
|
+ let view = project(&f, policy);
|
|
+ let actual = node(&view, f.owner);
|
|
+ if policy == ExperimentalPortPolicy::Current {
|
|
+ assert_ne!((actual.position, actual.size), (position, dimensions));
|
|
+ } else {
|
|
+ assert_eq!((actual.position, actual.size), (position, dimensions));
|
|
+ }
|
|
+ let mut restored = f.p.clone();
|
|
+ restored
|
|
+ .diagrams
|
|
+ .get_mut(&f.d)
|
|
+ .unwrap()
|
|
+ .nodes
|
|
+ .insert(f.owner, original);
|
|
+ assert_eq!(restored, before, "Only the selected record may change");
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn all_eight_outward_handles_have_exact_fixed_edges_without_authored_inactive_changes() {
|
|
+ for policy in ALTERNATIVES {
|
|
+ for (left, top, right, bottom) in [
|
|
+ (false, true, false, false),
|
|
+ (false, true, true, false),
|
|
+ (false, false, true, false),
|
|
+ (false, false, true, true),
|
|
+ (false, false, false, true),
|
|
+ (true, false, false, true),
|
|
+ (true, false, false, false),
|
|
+ (true, true, false, false),
|
|
+ ] {
|
|
+ let mut f = eight_ports();
|
|
+ let baseline = project(&f, policy);
|
|
+ let old = node(&baseline, f.owner);
|
|
+ assert_eq!(old.size, size(224., 160.));
|
|
+ let mut edited = f.p.diagrams[&f.d].nodes[&f.owner].clone();
|
|
+ let x = old.position.x - if left { 20. } else { 0. };
|
|
+ let y = old.position.y - if top { 20. } else { 0. };
|
|
+ let r = old.position.x + old.size.width + if right { 20. } else { 0. };
|
|
+ let b = old.position.y + old.size.height + if bottom { 20. } else { 0. };
|
|
+ edited.position = pos(x, y);
|
|
+ edited.size = size(r - x, b - y);
|
|
+ f.p = prepare_command(
|
|
+ &f.p,
|
|
+ &Command::EditNode {
|
|
+ diagram: f.d,
|
|
+ original: f.owner,
|
|
+ node: edited.clone(),
|
|
+ },
|
|
+ )
|
|
+ .unwrap()
|
|
+ .project;
|
|
+ let view = project(&f, policy);
|
|
+ let actual = node(&view, f.owner);
|
|
+ assert_eq!(
|
|
+ (actual.position, actual.size),
|
|
+ (edited.position, edited.size)
|
|
+ );
|
|
+ if !left && !right {
|
|
+ assert_eq!(edited.position.x, old.position.x);
|
|
+ assert_eq!(edited.size.width, old.size.width);
|
|
+ }
|
|
+ if !top && !bottom {
|
|
+ assert_eq!(edited.position.y, old.position.y);
|
|
+ assert_eq!(edited.size.height, old.size.height);
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn capacity_uses_available_sides_where_spacing_only_overlaps_fourteen_unit_glyphs() {
|
|
+ let f = hundred_ports();
|
|
+ let before = f.p.clone();
|
|
+ let current = project(&f, ExperimentalPortPolicy::Current);
|
|
+ let spacing = project(&f, ExperimentalPortPolicy::SameSidesAdaptSpacing);
|
|
+ let capacity = project(&f, ExperimentalPortPolicy::CapacityAware);
|
|
+ assert_eq!(node(¤t, f.owner).size, size(728., 2808.));
|
|
+ assert_eq!(node(&spacing, f.owner).size, size(728., 804.));
|
|
+ assert_eq!(capacity.nodes, spacing.nodes);
|
|
+ assert!(minimum_gap(¤t, f.owner) >= 27.99);
|
|
+ assert!(minimum_gap(&spacing, f.owner) < 14.);
|
|
+ assert!(minimum_gap(&capacity, f.owner) >= 27.99);
|
|
+ assert_eq!(
|
|
+ capacity.ports.iter().filter(|p| p.owner == f.owner).count(),
|
|
+ 100
|
|
+ );
|
|
+ assert_eq!(
|
|
+ f.p, before,
|
|
+ "Projection must not author anchors or new dimensions"
|
|
+ );
|
|
+ println!(
|
|
+ "100 ports: current={:?} spacing={:?} capacity={:?}; gaps={}/{}/{}",
|
|
+ node(¤t, f.owner).size,
|
|
+ node(&spacing, f.owner).size,
|
|
+ node(&capacity, f.owner).size,
|
|
+ minimum_gap(¤t, f.owner),
|
|
+ minimum_gap(&spacing, f.owner),
|
|
+ minimum_gap(&capacity, f.owner)
|
|
+ );
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn manual_anchor_edits_preserve_owner_geometry_and_unrelated_automatic_sides() {
|
|
+ let mut f = hundred_ports();
|
|
+ let before = project(&f, ExperimentalPortPolicy::CapacityAware);
|
|
+ let id = before
|
|
+ .ports
|
|
+ .iter()
|
|
+ .find(|p| p.owner == f.owner && p.side == PortAnchorSide::Right)
|
|
+ .unwrap()
|
|
+ .element;
|
|
+ f.p = prepare_command(
|
|
+ &f.p,
|
|
+ &Command::SetPortAnchor {
|
|
+ diagram: f.d,
|
|
+ node: f.owner,
|
|
+ anchor: PortAnchor {
|
|
+ port: id,
|
|
+ side: PortAnchorSide::Top,
|
|
+ offset: 0.5,
|
|
+ },
|
|
+ },
|
|
+ )
|
|
+ .unwrap()
|
|
+ .project;
|
|
+ let after = project(&f, ExperimentalPortPolicy::CapacityAware);
|
|
+ assert_eq!(after.nodes, before.nodes);
|
|
+ assert_eq!(
|
|
+ port(&after, id).position,
|
|
+ port_anchor_position(node(&after, f.owner), PortAnchorSide::Top, 0.5)
|
|
+ );
|
|
+ for other in after.ports.iter().filter(|p| p.element != id) {
|
|
+ assert_eq!(other.side, port(&before, other.element).side);
|
|
+ }
|
|
+ assert_eq!(language::parse(&language::format(&f.p)).unwrap(), f.p);
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn exact_crowded_manual_fractions_survive_every_alternative_and_reopen() {
|
|
+ for policy in ALTERNATIVES {
|
|
+ let mut f = eight_ports();
|
|
+ let fractions = [0., 0.25, 0.25, 0.5, 0.75, 1., 0.01, 0.99];
|
|
+ f.p.diagrams
|
|
+ .get_mut(&f.d)
|
|
+ .unwrap()
|
|
+ .nodes
|
|
+ .get_mut(&f.owner)
|
|
+ .unwrap()
|
|
+ .port_anchors = f
|
|
+ .ports
|
|
+ .iter()
|
|
+ .zip(fractions)
|
|
+ .map(|(port, offset)| PortAnchor {
|
|
+ port: *port,
|
|
+ side: PortAnchorSide::Right,
|
|
+ offset,
|
|
+ })
|
|
+ .collect();
|
|
+ validate_candidate(&f.p).unwrap();
|
|
+ let original_anchors = f.p.diagrams[&f.d].nodes[&f.owner].port_anchors.clone();
|
|
+ let view = project(&f, policy);
|
|
+ assert_eq!(node(&view, f.owner).size, size(224., 160.));
|
|
+ for anchor in &original_anchors {
|
|
+ let actual = port(&view, anchor.port);
|
|
+ assert_eq!(actual.side, anchor.side);
|
|
+ assert_eq!(
|
|
+ actual.position,
|
|
+ port_anchor_position(node(&view, f.owner), anchor.side, anchor.offset)
|
|
+ );
|
|
+ }
|
|
+ let reopened = language::parse(&language::format(&f.p)).unwrap();
|
|
+ assert_eq!(reopened, f.p);
|
|
+ assert_eq!(
|
|
+ project_diagram_with_experimental_port_policy(
|
|
+ &reopened,
|
|
+ &reopened.diagrams[&f.d],
|
|
+ policy
|
|
+ ),
|
|
+ view
|
|
+ );
|
|
+ assert_eq!(
|
|
+ f.p.diagrams[&f.d].nodes[&f.owner].port_anchors,
|
|
+ original_anchors
|
|
+ );
|
|
+ }
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn narrow_peer_gap_keeps_clear_sides_and_actual_routed_port_endpoints() {
|
|
+ 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);
|
|
+ p.place(d, a, pos(100., 100.));
|
|
+ p.place(d, b, pos(100., 260.));
|
|
+ validate_candidate(&p).unwrap();
|
|
+ for policy in POLICIES {
|
|
+ let view = project_diagram_with_experimental_port_policy(&p, &p.diagrams[&d], policy);
|
|
+ assert_eq!(port(&view, source_port).side, PortAnchorSide::Right);
|
|
+ assert_eq!(port(&view, target_port).side, PortAnchorSide::Left);
|
|
+ let routed = route_diagram(&p, &p.diagrams[&d], &view);
|
|
+ let route = &routed.edges[&edge];
|
|
+ assert!(
|
|
+ route.warnings.is_empty(),
|
|
+ "{policy:?}: {:?}",
|
|
+ route.warnings
|
|
+ );
|
|
+ assert_eq!(
|
|
+ route.points.first(),
|
|
+ Some(&port(&view, source_port).position)
|
|
+ );
|
|
+ assert_eq!(
|
|
+ route.points.last(),
|
|
+ Some(&port(&view, target_port).position)
|
|
+ );
|
|
+ assert!(
|
|
+ route
|
|
+ .points
|
|
+ .windows(2)
|
|
+ .all(|pair| pair[0].x == pair[1].x || pair[0].y == pair[1].y)
|
|
+ );
|
|
+ }
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn candidate_geometry_is_deterministic_and_independent_of_peer_motion_and_names() {
|
|
+ let mut f = eight_ports();
|
|
+ let before = project(&f, ExperimentalPortPolicy::CapacityAware);
|
|
+ assert_eq!(project(&f, ExperimentalPortPolicy::CapacityAware), before);
|
|
+ for element in f.p.elements.values_mut() {
|
|
+ element.alias = format!("renamed_{}", element.id.simple());
|
|
+ }
|
|
+ assert_eq!(project(&f, ExperimentalPortPolicy::CapacityAware), before);
|
|
+ for node in
|
|
+ f.p.diagrams
|
|
+ .get_mut(&f.d)
|
|
+ .unwrap()
|
|
+ .nodes
|
|
+ .values_mut()
|
|
+ .filter(|n| n.id != f.owner)
|
|
+ {
|
|
+ node.position.x += 500.;
|
|
+ }
|
|
+ let moved = project(&f, ExperimentalPortPolicy::CapacityAware);
|
|
+ assert_eq!(
|
|
+ node(&moved, f.owner),
|
|
+ node(&before, f.owner),
|
|
+ "Peer motion must not change owner geometry"
|
|
+ );
|
|
+}
|