experiment(layout): diagnose mandatory leads and test bounded flat separation
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Placement clearance experiment
|
||||
|
||||
Continuation of issue32 from connection-layout-reference results at2850b915.
|
||||
The sparse external placement leaves20 units between rectangles where its
|
||||
final Rust top port needs18 units of outward lead plus12 units of unrelated
|
||||
obstacle clearance. This experiment addresses that necessary placement
|
||||
condition. It does not adopt either previous placement candidate.
|
||||
|
||||
Use the router's actual endpoint selection, promotion/attachment policy and
|
||||
lead collision predicate in a narrow read-only diagnostic. Preserve existing
|
||||
router guard order, search, costs and fallback. Compare the five-case frozen
|
||||
route output exactly with the released-router control, and exercise targeted
|
||||
lead, alternative-endpoint and folded/ancestor tests.
|
||||
|
||||
First consumer is an isolated example-only flat repair: at most256 nodes,
|
||||
1024 edges and four separation rounds. Freeze axis order and initial coordinate
|
||||
floors; seed separation inequalities from every initially disjoint node pair.
|
||||
Add a31-unit boundary gap (18+12+1 margin) for unambiguous cardinal singleton
|
||||
lead blockers and recompute monotone coordinates. Reproject actual automatic
|
||||
ports each round. Reject overlapping input, unsupported nested/folded/omitted
|
||||
topology, conflicting order, projection size changes, excessive extent/work,
|
||||
or unresolved constraints. No mutation escapes on failure. This narrower
|
||||
first experiment deliberately leaves subtree/compound coordinate assignment
|
||||
for a later planner increment.
|
||||
|
||||
Evaluate all five frozen original and Arrange-reset scenes, all four supported
|
||||
old native candidates, and all three prior ELK geometry transfers, unchanged.
|
||||
Keep unsupported cases and failures explicit. Reconstruct route reset/retained
|
||||
translation once from input to final geometry using the audited import helper.
|
||||
Check semantic/anchor/other-view conservation, round trips, repeat determinism
|
||||
and ten repeated repair calls. Report all existing quality measures, actual
|
||||
port changes, warnings and unrelated-node penetrations. Require the exact sparse
|
||||
terminal conflict to disappear without introducing a new node overlap or routing
|
||||
failure before calling this compatibility increment successful. Lead clearance
|
||||
alone is not complete routability or readability acceptance.
|
||||
|
||||
Stop after one bounded candidate and independent review. If the sparse witness
|
||||
cannot be repaired under frozen orders, retain the counterexample and move
|
||||
constraints into initial coordinate assignment; do not increase iteration
|
||||
counts or tune the corpus. Even success leaves prior shared-run, area,
|
||||
compound/undirected and native usability limitations unresolved.
|
||||
|
||||
Windows only. No Linux build, release change or production Arrange adoption.
|
||||
Reference in New Issue
Block a user