experiment(layout): diagnose mandatory leads and test bounded flat separation

This commit is contained in:
2026-09-24 20:38:33 -07:00
parent 2850b915d6
commit 2be8c7f292
6 changed files with 898 additions and 41 deletions
+95 -1
View File
@@ -5,6 +5,8 @@ use oa_core::{
};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
#[path = "auto_layout_probe/clearance.rs"]
mod clearance;
#[path = "auto_layout_probe/layered.rs"]
mod layered;
@@ -1002,6 +1004,94 @@ fn import_placement_cli(
"final_projected_ports":projection::project_view(&after,diagram).ports})
);
}
fn lead_diagnostics(project: &Project, diagram: Id) -> Value {
let view = projection::project_view(project, diagram);
let endpoint = |e: &routing::LeadEndpoint| {
json!({"owner":e.owner,"tip":e.tip,"stub":e.stub,
"blockers":e.blockers.iter().map(|b|json!({"id":b.id,"bounds":{"position":b.bounds.position,"size":b.bounds.size}})).collect::<Vec<_>>(),
"stub_containers":e.stub_containers.iter().map(|b|json!({"id":b.id,"bounds":{"position":b.bounds.position,"size":b.bounds.size}})).collect::<Vec<_>>()})
};
json!(
routing::lead_incompatibilities(project, &project.diagrams[&diagram], &view)
.iter()
.map(
|r| json!({"edge":r.edge,"allowed_pair_count":r.allowed_pair_count,
"lead_clear_pair_count":r.lead_clear_pair_count,
"source":r.source.iter().map(&endpoint).collect::<Vec<_>>(),
"target":r.target.iter().map(&endpoint).collect::<Vec<_>>()})
)
.collect::<Vec<_>>()
)
}
fn repaired_candidate(before: &Project, diagram: Id) -> Result<Project, ModelError> {
let geometry = clearance::repair(before, diagram)?;
let view = projection::project_view(&geometry, diagram);
// Reconstruct metadata once from original to final geometry with the same
// audited reset/translation policy used by the placement reference.
import_placement(
before,
diagram,
PlacementPlan {
schema: 1,
nodes: view
.nodes
.iter()
.map(|n| PlacementNode {
id: n.id,
position: n.position,
size: n.size,
})
.collect(),
},
)
}
fn repair_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi::OsStr) {
let before: Project =
serde_json::from_slice(&std::fs::read(project_file).expect("Read Project"))
.expect("Decode Project");
let diagram: Id = diagram_text
.to_str()
.expect("UTF-8 UUID")
.parse()
.expect("Diagram UUID");
validate_candidate(&before).expect("Validate input");
assert!(before.diagrams.contains_key(&diagram), "Missing diagram");
let candidate = match repaired_candidate(&before, diagram) {
Err(error) => json!({"supported":false,"error":error.to_string()}),
Ok(after) => {
let duplicate = repaired_candidate(&before, diagram);
let mut previous = after.clone();
let mut stability = Vec::new();
for pass in 2..=10 {
match repaired_candidate(&previous, diagram) {
Ok(next) => {
stability.push(json!({"pass":pass,"equal_previous":next==previous,"equal_first":next==after}));
previous = next;
}
Err(error) => {
stability.push(json!({"pass":pass,"error":error.to_string()}));
break;
}
}
}
json!({"supported":true,"project":after,"metrics":metrics(&after,diagram),
"lead_incompatibilities":lead_diagnostics(&after,diagram),
"conservation":conservation(&before,&after,diagram),"roundtrip":roundtrip(&after),
"same_input_exact":duplicate.as_ref().is_ok_and(|p|*p==after),
"ten_repair_calls":stability})
}
};
println!(
"{}",
json!({"probe":"placement_clearance_v1","diagram":diagram,
"scope":"Experimental flat monotone separation only; lead clearance is necessary, not sufficient routing or readability acceptance. Unsupported topology and unresolved constraints remain explicit. Existing router and command behavior unchanged.",
"before_project":before,"before":metrics(&before,diagram),
"before_lead_incompatibilities":lead_diagnostics(&before,diagram),"candidate":candidate})
);
}
fn arrange_corpus(path: &std::path::Path) {
let manifest: Value = serde_json::from_slice(&std::fs::read(path).expect("Read manifest"))
.expect("Decode manifest");
@@ -1107,10 +1197,14 @@ fn main() {
);
return;
}
if args.len() == 4 && args[1] == "--repair-placement" {
repair_placement_cli(std::path::Path::new(&args[2]), &args[3]);
return;
}
assert_eq!(
args.len(),
1,
"Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN]"
"Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN | --repair-placement PROJECT UUID]"
);
println!(
"{}",
@@ -0,0 +1,278 @@
//! Bounded flat-placement experiment. Empty lead diagnostics are necessary only,
//! never a claim that routes, labels, or drawing quality are acceptable.
use oa_core::{Id, ModelError, Project, projection, routing};
use std::collections::BTreeMap;
const ROUNDS: usize = 4;
const WORK_LIMIT: usize = 10_000_000;
const MAX_SPAN: f32 = 100_000.0;
const MARGIN: f32 = 1.0;
fn fail(message: &str) -> ModelError {
ModelError(format!("Clearance probe: {message}"))
}
fn spend(work: &mut usize, amount: usize) -> Result<(), ModelError> {
*work = work.saturating_add(amount);
if *work > WORK_LIMIT {
return Err(fail("work limit exceeded"));
}
Ok(())
}
// A boundary constraint means start[to] >= start[from] + extent[from] + gap.
// Adjacent total-order constraints below separately preserve start ordering.
type Constraints = BTreeMap<(usize, usize), f32>;
fn solve(
baseline: &[f32],
extents: &[f32],
order: &[usize],
constraints: &Constraints,
work: &mut usize,
) -> Result<Vec<f32>, ModelError> {
let mut result = baseline.to_vec();
let mut previous = None;
for &to in order {
if let Some(from) = previous {
result[to] = result[to].max(result[from]);
}
for (&(from, target), &gap) in constraints {
spend(work, 1)?;
if target == to {
result[to] = result[to].max(result[from] + extents[from] + gap);
}
}
previous = Some(to);
}
let minimum = baseline.iter().copied().fold(f32::INFINITY, f32::min);
for (i, &value) in result.iter().enumerate() {
if !value.is_finite() || value + extents[i] - minimum > MAX_SPAN {
return Err(fail("coordinate/span bound exceeded"));
}
}
Ok(result)
}
/// Input is an already admitted placement candidate. Never modifies its routes,
/// authored anchors, sizes or semantics. Error drops the private clone.
pub fn repair(project: &Project, diagram: Id) -> Result<Project, ModelError> {
let original = project
.diagrams
.get(&diagram)
.ok_or_else(|| fail("missing diagram"))?;
if original.projection.scenario().is_some() {
return Err(fail("Scenario unsupported"));
}
let baseline = projection::project_diagram(project, original);
if baseline.nodes.len() > 256 || baseline.edges.len() > 1024 {
return Err(fail("graph bound exceeded"));
}
if baseline.nodes.len() != original.nodes.len()
|| baseline
.nodes
.iter()
.any(|n| n.collapsed || n.visual_parent.is_some())
{
return Err(fail("nested, folded or omitted placements unsupported"));
}
let mut nodes = baseline.nodes.clone();
nodes.sort_by_key(|n| n.id);
if nodes.iter().any(|n| {
!n.position.x.is_finite()
|| !n.position.y.is_finite()
|| !n.size.width.is_finite()
|| !n.size.height.is_finite()
|| n.size.width <= 0.0
|| n.size.height <= 0.0
}) {
return Err(fail("invalid initial geometry"));
}
let indices: BTreeMap<_, _> = nodes.iter().enumerate().map(|(i, n)| (n.id, i)).collect();
let coordinates = [
nodes.iter().map(|n| n.position.x).collect::<Vec<_>>(),
nodes.iter().map(|n| n.position.y).collect::<Vec<_>>(),
];
let extents = [
nodes.iter().map(|n| n.size.width).collect::<Vec<_>>(),
nodes.iter().map(|n| n.size.height).collect::<Vec<_>>(),
];
for axis in 0..2 {
let minimum = coordinates[axis]
.iter()
.copied()
.fold(f32::INFINITY, f32::min);
if coordinates[axis]
.iter()
.zip(&extents[axis])
.any(|(&start, &extent)| {
!(start + extent).is_finite() || start + extent - minimum > MAX_SPAN
})
{
return Err(fail("initial coordinate/span bound exceeded"));
}
}
let mut orders = [Vec::new(), Vec::new()];
let mut ranks = [vec![0; nodes.len()], vec![0; nodes.len()]];
for axis in 0..2 {
orders[axis] = (0..nodes.len()).collect();
orders[axis].sort_by(|&a, &b| {
coordinates[axis][a]
.total_cmp(&coordinates[axis][b])
.then(nodes[a].id.cmp(&nodes[b].id))
});
for (rank, &i) in orders[axis].iter().enumerate() {
ranks[axis][i] = rank;
}
}
let mut constraints = [Constraints::new(), Constraints::new()];
let mut work = 0;
// Preserve one existing disjoint axis per pair, X before Y. No initially
// overlapping pair is silently treated as successfully repaired.
for a in 0..nodes.len() {
for b in a + 1..nodes.len() {
spend(&mut work, 1)?;
let mut chosen = None;
for axis in 0..2 {
for (from, to) in [(a, b), (b, a)] {
let gap = coordinates[axis][to] - coordinates[axis][from] - extents[axis][from];
if gap >= 0.0 {
chosen = Some((axis, from, to, gap));
break;
}
}
if chosen.is_some() {
break;
}
}
let (axis, from, to, gap) =
chosen.ok_or_else(|| fail("initial node overlap unsupported"))?;
constraints[axis].insert((from, to), gap);
}
}
let mut result = project.clone();
for round in 0..=ROUNDS {
let current = &result.diagrams[&diagram];
let view = projection::project_diagram(&result, current);
if view.nodes.len() != nodes.len()
|| view.nodes.iter().any(|n| {
indices.get(&n.id).is_none_or(|&i| {
n.size != nodes[i].size || n.collapsed || n.visual_parent.is_some()
})
})
{
return Err(fail("reprojection changed node identity/size/topology"));
}
// Charge a conservative endpoint-pair/obstacle allowance before the
// diagnostic: at most four cardinal candidates at each endpoint.
spend(
&mut work,
view.edges
.len()
.saturating_mul(nodes.len().saturating_add(1))
.saturating_mul(16),
)?;
let failures = routing::lead_incompatibilities(&result, current, &view);
if failures.is_empty() {
return Ok(result);
}
if round == ROUNDS {
return Err(fail("unresolved mandatory leads after four adjustments"));
}
let mut strengthened = false;
for failure in failures {
if failure.allowed_pair_count != 1
|| failure.source.len() != 1
|| failure.target.len() != 1
{
return Err(fail("ambiguous endpoint choices unsupported"));
}
for endpoint in failure.source.iter().chain(&failure.target) {
let owner = *indices
.get(&endpoint.owner)
.ok_or_else(|| fail("unknown endpoint owner"))?;
let dx = endpoint.stub.x - endpoint.tip.x;
let dy = endpoint.stub.y - endpoint.tip.y;
if endpoint.blockers.is_empty() {
continue;
}
let (axis, forward) = match (dx, dy) {
(x, 0.0) if x != 0.0 => (0, x > 0.0),
(0.0, y) if y != 0.0 => (1, y > 0.0),
_ => return Err(fail("noncardinal/degenerate blocked lead")),
};
for blocker in &endpoint.blockers {
spend(&mut work, 1)?;
let other = *indices
.get(&blocker.id)
.ok_or_else(|| fail("unknown blocker"))?;
let (from, to) = if forward {
(owner, other)
} else {
(other, owner)
};
if ranks[axis][from] >= ranks[axis][to] {
return Err(fail("lead constraint opposes frozen spatial order"));
}
let required = routing::PORT_STUB + routing::CLEARANCE + MARGIN;
let old = constraints[axis].entry((from, to)).or_insert(0.0);
if *old < required {
*old = required;
strengthened = true;
}
}
}
}
if !strengthened {
return Err(fail(
"unresolved lead without a stronger monotone constraint",
));
}
let x = solve(
&coordinates[0],
&extents[0],
&orders[0],
&constraints[0],
&mut work,
)?;
let y = solve(
&coordinates[1],
&extents[1],
&orders[1],
&constraints[1],
&mut work,
)?;
let target = result.diagrams.get_mut(&diagram).unwrap();
for (i, node) in nodes.iter().enumerate() {
let placed = target.nodes.get_mut(&node.id).unwrap();
placed.position.x = x[i];
placed.position.y = y[i];
}
}
unreachable!("bounded loop returns on final round")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn separation_propagates_to_neighbor_without_moving_predecessor() {
let constraints = BTreeMap::from([((0, 1), 31.0), ((1, 2), 10.0)]);
let actual = solve(
&[-20.0, 100.0, 210.0],
&[100.0; 3],
&[0, 1, 2],
&constraints,
&mut 0,
)
.unwrap();
assert_eq!(actual, vec![-20.0, 111.0, 221.0]);
}
#[test]
fn extent_bound_rejects_constraint_expansion() {
let constraints = BTreeMap::from([((0, 1), MAX_SPAN)]);
assert!(solve(&[0.0, 20.0], &[10.0; 2], &[0, 1], &constraints, &mut 0).is_err());
}
}
+76 -40
View File
@@ -6,7 +6,9 @@ use crate::{
RelationBody, RelationKind, Size,
};
mod attachments;
mod leads;
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
pub use leads::{LeadBlocker, LeadEndpoint, LeadIncompatibility, lead_incompatibilities};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -420,6 +422,73 @@ fn endpoints(
.collect()
}
fn effective_route<'a>(
project: &Project,
diagram: &Diagram,
edge: &ProjectedEdge,
stored: Option<&'a crate::EdgeRoute>,
) -> (
bool,
Option<&'a crate::EdgeRoute>,
[Option<EdgeAttachment>; 2],
) {
let promoted = projection::edge_endpoint_placements(project, diagram, edge.id)
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
(promoted, route, attachments)
}
#[allow(clippy::too_many_arguments)]
fn edge_endpoints(
project: &Project,
diagram: &Diagram,
edge: &ProjectedEdge,
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
attachments: [Option<EdgeAttachment>; 2],
) -> [Vec<Endpoint>; 2] {
[
endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
]
}
fn allowed_endpoint_pair(edge: &ProjectedEdge, a: Endpoint, b: Endpoint, coincident: bool) -> bool {
!(edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident)
}
fn lead_pair_clear(edge: &ProjectedEdge, a: Endpoint, b: Endpoint, obstacles: &[Rect]) -> bool {
!obstacles
.iter()
.any(|r| r.id != edge.source && r.crosses(a.point, a.stub))
&& !obstacles
.iter()
.any(|r| r.id != edge.target && r.crosses(b.point, b.stub))
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
let mut current = nodes.get(&id).and_then(|n| n.visual_parent);
let mut seen = BTreeSet::new();
@@ -512,16 +581,8 @@ fn route_diagram_impl(
continue;
};
let stored = authored.get(&edge.id).copied();
let promoted = projection::edge_endpoint_placements(project, diagram, edge.id)
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
let (custom_suspended, route, attachments) =
effective_route(project, diagram, edge, stored);
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
@@ -559,24 +620,11 @@ fn route_diagram_impl(
});
}
}
let ends = edge_endpoints(project, diagram, edge, source, target, view, attachments);
let (points, pin_lengths, failure) = route_edge(
edge,
&endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
&ends[0],
&ends[1],
&pins,
&obstacles,
&previous,
@@ -793,13 +841,7 @@ fn route_edge(
for &b in target_ends {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident
{
if !allowed_endpoint_pair(edge, a, b, coincident) {
continue;
}
let estimate = manhattan(a.stub, pins.first().copied().unwrap_or(b.stub))
@@ -823,13 +865,7 @@ fn route_edge(
}) {
continue;
}
if obstacles
.iter()
.any(|r| r.id != edge.source && r.crosses(a.point, a.stub))
|| obstacles
.iter()
.any(|r| r.id != edge.target && r.crosses(b.point, b.stub))
{
if !lead_pair_clear(edge, a, b, obstacles) {
continue;
}
let mut points = vec![a.point, a.stub];
+173
View File
@@ -0,0 +1,173 @@
//! Necessary terminal-lead checks shared with experimental placement consumers.
use super::*;
#[derive(Debug, Clone, PartialEq)]
pub struct LeadBlocker {
pub id: Id,
/// The actual routing obstacle: clearance inflated, ancestor body excluded.
pub bounds: RoutingBounds,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LeadEndpoint {
pub owner: Id,
pub tip: Position,
pub stub: Position,
pub blockers: Vec<LeadBlocker>,
/// Separate information only; not part of the router's early lead guard.
pub stub_containers: Vec<LeadBlocker>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LeadIncompatibility {
pub edge: Id,
pub source: Vec<LeadEndpoint>,
pub target: Vec<LeadEndpoint>,
pub allowed_pair_count: usize,
pub lead_clear_pair_count: usize,
}
fn blocker(r: &Rect) -> LeadBlocker {
LeadBlocker {
id: r.id,
bounds: RoutingBounds {
position: pos(r.left, r.top),
size: Size {
width: r.right - r.left,
height: r.bottom - r.top,
},
},
}
}
fn describe(owner: Id, endpoint: Endpoint, obstacles: &[Rect]) -> LeadEndpoint {
LeadEndpoint {
owner,
tip: endpoint.point,
stub: endpoint.stub,
blockers: obstacles
.iter()
.filter(|r| r.id != owner && r.crosses(endpoint.point, endpoint.stub))
.map(blocker)
.collect(),
stub_containers: obstacles
.iter()
.filter(|r| r.contains(endpoint.stub))
.map(blocker)
.collect(),
}
}
/// Return only edges for which every allowed endpoint pair fails the router's
/// existing mandatory-lead guard. Empty output is NECESSARY ONLY: it does not
/// establish a clear route, clear pins, successful search, or label clearance.
/// Uses the supplied final projection; does not modify the Project or choose
/// different ports/attachments. Stub containment is reported separately and
/// does not change the lead guard or its historical pinned/degenerate behavior.
pub fn lead_incompatibilities(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
) -> Vec<LeadIncompatibility> {
let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect();
let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect();
let authored: BTreeMap<_, _> = diagram.routes.iter().map(|r| (r.edge, r)).collect();
let headers: BTreeMap<_, _> = nodes
.values()
.map(|node| {
(
node.id,
if diagram.projection.state_machine().is_some() && !node.collapsed {
projection::state_header_height(project, node.element)
} else {
HEADER
},
)
})
.collect();
let mut ordered: Vec<_> = view.edges.iter().collect();
ordered.sort_by_key(|e| e.id);
let mut result = Vec::new();
for edge in ordered {
let (Some(source), Some(target)) = (nodes.get(&edge.source), nodes.get(&edge.target))
else {
continue;
};
let (_, _, attachments) =
effective_route(project, diagram, edge, authored.get(&edge.id).copied());
let ends = edge_endpoints(project, diagram, edge, source, target, view, attachments);
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
let coincident = edge.source == edge.target
&& ends[0].len() == 1
&& ends[1].len() == 1
&& ends[0][0].point == ends[1][0].point
&& ends[0][0].stub == ends[1][0].stub;
let mut allowed_pair_count = 0;
let mut lead_clear_pair_count = 0;
for &a in &ends[0] {
for &b in &ends[1] {
if !allowed_endpoint_pair(edge, a, b, coincident) {
continue;
}
allowed_pair_count += 1;
if lead_pair_clear(edge, a, b, &obstacles) {
lead_clear_pair_count += 1;
}
}
}
if lead_clear_pair_count == 0 {
result.push(LeadIncompatibility {
edge: edge.id,
source: ends[0]
.iter()
.map(|&e| describe(edge.source, e, &obstacles))
.collect(),
target: ends[1]
.iter()
.map(|&e| describe(edge.target, e, &obstacles))
.collect(),
allowed_pair_count,
lead_clear_pair_count,
});
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coincident_selfloop_exception_and_stub_containment_remain_separate() {
let owner = Id::from_u128(1);
let edge = ProjectedEdge {
id: Id::from_u128(2),
source: owner,
target: owner,
source_port: None,
target_port: None,
trace: false,
oriented: true,
};
let endpoint = Endpoint {
point: pos(0., 0.),
stub: pos(18., 0.),
};
assert!(!allowed_endpoint_pair(&edge, endpoint, endpoint, false));
assert!(allowed_endpoint_pair(&edge, endpoint, endpoint, true));
let obstacles = [Rect {
id: owner,
left: -1.,
right: 20.,
top: -1.,
bottom: 1.,
}];
assert!(lead_pair_clear(&edge, endpoint, endpoint, &obstacles));
let diagnostic = describe(owner, endpoint, &obstacles);
assert!(diagnostic.blockers.is_empty());
assert_eq!(diagnostic.stub_containers.len(), 1);
// This diagnostic must not be folded into the historical early guard.
assert_eq!(diagnostic.stub_containers[0].id, owner);
}
}
+233
View File
@@ -0,0 +1,233 @@
use oa_core::{projection, routing, *};
struct Fixture {
p: Project,
d: Id,
edge: Id,
source: Id,
target: Id,
blocker: Id,
}
fn fixture(ports: bool, gap: f32) -> Fixture {
let mut p = Project::empty();
let kind = if ports {
Kind::Function
} else {
Kind::DataType
};
let a = p.add_element(kind, Layer::Sa, "Source", None).unwrap();
let b = p.add_element(kind, Layer::Sa, "Target", None).unwrap();
let c = p.add_element(kind, Layer::Sa, "Blocker", None).unwrap();
let edge = p.relate(
if ports {
RelationKind::FunctionalExchange
} else {
RelationKind::DataAssociation
},
"Flow",
a,
b,
);
let d = p.ensure_diagram(Layer::Sa);
let source = p.place(d, a, Position { x: 0., y: 0. });
let target = p.place(
d,
b,
Position {
x: 500.,
y: 180. + gap,
},
);
let blocker = p.place(d, c, Position { x: 500., y: 20. });
for n in p.diagrams.get_mut(&d).unwrap().nodes.values_mut() {
n.size = Size {
width: 224.,
height: 160.,
};
}
if ports {
for (owner, end, side) in [
(source, EdgeEnd::Source, PortAnchorSide::Right),
(target, EdgeEnd::Target, PortAnchorSide::Top),
] {
let port = routing::semantic_port_endpoint(&p, edge, end).unwrap();
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&owner)
.unwrap()
.port_anchors
.push(PortAnchor {
port,
side,
offset: 0.5,
});
}
}
validate_candidate(&p).unwrap();
Fixture {
p,
d,
edge,
source,
target,
blocker,
}
}
fn report(f: &Fixture) -> Vec<routing::LeadIncompatibility> {
routing::lead_incompatibilities(
&f.p,
&f.p.diagrams[&f.d],
&projection::project_view(&f.p, f.d),
)
}
fn attachment_route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.25,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.75,
}),
waypoints: vec![Position { x: 300., y: 400. }],
label: None,
}
}
#[test]
fn exact_twenty_gap_is_blocked_thirty_plus_clears_without_mutation() {
let f = fixture(true, 20.);
let original = f.p.clone();
let r = report(&f);
assert_eq!(r.len(), 1);
assert_eq!(
(
r[0].edge,
r[0].allowed_pair_count,
r[0].lead_clear_pair_count
),
(f.edge, 1, 0)
);
assert_eq!(r[0].source[0].owner, f.source);
let end = &r[0].target[0];
assert_eq!(end.owner, f.target);
assert_eq!(end.tip, Position { x: 612., y: 200. });
assert_eq!(end.stub, Position { x: 612., y: 182. });
assert_eq!(end.blockers.len(), 1);
assert_eq!(end.blockers[0].id, f.blocker);
assert_eq!(
end.blockers[0].bounds,
routing::RoutingBounds {
position: Position { x: 488., y: 8. },
size: Size {
width: 248.,
height: 184.
},
}
);
assert_eq!(end.stub_containers, end.blockers);
assert_eq!(f.p, original);
assert!(report(&fixture(true, 30.)).is_empty());
assert!(report(&fixture(true, 31.)).is_empty());
}
#[test]
fn semantic_manual_ports_override_dormant_route_attachments_and_pins() {
let mut f = fixture(true, 20.);
let expected = report(&f);
f.p.diagrams
.get_mut(&f.d)
.unwrap()
.routes
.push(attachment_route(f.edge));
assert_eq!(report(&f), expected);
let view = projection::project_view(&f.p, f.d);
let routed = routing::route_diagram(&f.p, &f.p.diagrams[&f.d], &view);
assert_eq!(
routed.edges[&f.edge].points.last(),
Some(&expected[0].target[0].tip)
);
assert!(
routed.edges[&f.edge]
.warnings
.contains(&routing::RouteWarning::NoClearPath)
);
}
#[test]
fn unconstrained_nonport_alternative_is_not_hard_blocked() {
let mut f = fixture(false, 20.);
assert!(report(&f).is_empty());
let mut route = attachment_route(f.edge);
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.5,
});
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.5,
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(route);
let r = report(&f);
assert_eq!(r.len(), 1);
assert_eq!(r[0].allowed_pair_count, 1);
assert_eq!(r[0].target[0].blockers[0].id, f.blocker);
}
#[test]
fn nested_endpoint_body_is_traversable_and_promoted_attachments_are_suspended() {
let mut f = fixture(false, 20.);
// DataType containment is not required for this routing-only view witness:
// construct the supplied projected forest explicitly, retaining real owners.
let mut view = projection::project_view(&f.p, f.d);
let parent = Id::new_v4();
let source = view.nodes.iter_mut().find(|n| n.id == f.source).unwrap();
source.visual_parent = Some(parent);
source.position = Position { x: 40., y: 100. };
let element = source.element;
view.nodes.push(projection::ProjectedNode {
id: parent,
element,
position: Position { x: 0., y: 0. },
size: Size {
width: 300.,
height: 300.,
},
visual_parent: None,
depth: 0,
collapsed: false,
allocation_summary: false,
});
let mut route = attachment_route(f.edge);
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.5,
});
route.target_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.5,
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(route);
let r = routing::lead_incompatibilities(&f.p, &f.p.diagrams[&f.d], &view);
assert_eq!(r.len(), 1);
assert!(
r[0].source[0].blockers.is_empty(),
"Ancestor body must not block source lead"
);
// Promotion changes actual projected owner: custom attachments no longer apply.
view.edges
.iter_mut()
.find(|e| e.id == f.edge)
.unwrap()
.source = parent;
let with_dormant = routing::lead_incompatibilities(&f.p, &f.p.diagrams[&f.d], &view);
f.p.diagrams.get_mut(&f.d).unwrap().routes.clear();
assert_eq!(
with_dormant,
routing::lead_incompatibilities(&f.p, &f.p.diagrams[&f.d], &view)
);
}