experiment: compare automatic layout against independent geometry metrics
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
//! Experimental observer of Command::Layout; no quality thresholds or candidate algorithm.
|
||||
use oa_core::{
|
||||
projection::{self, ProjectedView},
|
||||
routing, *,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
#[path = "auto_layout_probe/layered.rs"]
|
||||
mod layered;
|
||||
|
||||
type P = (f64, f64);
|
||||
type Rect = (f64, f64, f64, f64);
|
||||
const EPS: f64 = 0.0001;
|
||||
fn p(v: Position) -> P {
|
||||
(v.x as f64, v.y as f64)
|
||||
}
|
||||
fn sub(a: P, b: P) -> P {
|
||||
(a.0 - b.0, a.1 - b.1)
|
||||
}
|
||||
fn dot(a: P, b: P) -> f64 {
|
||||
a.0 * b.0 + a.1 * b.1
|
||||
}
|
||||
fn cross(a: P, b: P) -> f64 {
|
||||
a.0 * b.1 - a.1 * b.0
|
||||
}
|
||||
fn length(a: P) -> f64 {
|
||||
a.0.hypot(a.1)
|
||||
}
|
||||
fn rect(at: Position, size: Size) -> Rect {
|
||||
(
|
||||
at.x as f64,
|
||||
at.y as f64,
|
||||
(at.x + size.width) as f64,
|
||||
(at.y + size.height) as f64,
|
||||
)
|
||||
}
|
||||
fn overlap(a: Rect, b: Rect) -> f64 {
|
||||
let w = a.2.min(b.2) - a.0.max(b.0);
|
||||
let h = a.3.min(b.3) - a.1.max(b.1);
|
||||
if w > EPS && h > EPS { w * h } else { 0. }
|
||||
}
|
||||
fn contains(a: Rect, b: Rect) -> bool {
|
||||
a.0 <= b.0 + EPS && a.1 <= b.1 + EPS && a.2 + EPS >= b.2 && a.3 + EPS >= b.3
|
||||
}
|
||||
fn proper_cross(a: P, b: P, c: P, d: P) -> Option<P> {
|
||||
let u = sub(b, a);
|
||||
let v = sub(d, c);
|
||||
let denominator = cross(u, v);
|
||||
if denominator.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let t = cross(sub(c, a), v) / denominator;
|
||||
let s = cross(sub(c, a), u) / denominator;
|
||||
// Strict interiors: touches, T junctions and intersections at bends are not proper crossings.
|
||||
(t > 1e-9 && t < 1. - 1e-9 && s > 1e-9 && s < 1. - 1e-9)
|
||||
.then_some((a.0 + t * u.0, a.1 + t * u.1))
|
||||
}
|
||||
fn shared_length(a: P, b: P, c: P, d: P) -> f64 {
|
||||
let u = sub(b, a);
|
||||
let len = length(u);
|
||||
if len < EPS || cross(u, sub(c, a)).abs() / len > EPS || cross(u, sub(d, a)).abs() / len > EPS {
|
||||
return 0.;
|
||||
}
|
||||
let t = dot(sub(c, a), u) / len;
|
||||
let s = dot(sub(d, a), u) / len;
|
||||
(t.max(s).min(len) - t.min(s).max(0.)).max(0.)
|
||||
}
|
||||
fn ancestor(view: &ProjectedView, owner: Id, mut node: Id) -> bool {
|
||||
for _ in 0..view.nodes.len() {
|
||||
let Some(parent) = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == node)
|
||||
.and_then(|n| n.visual_parent)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if parent == owner {
|
||||
return true;
|
||||
}
|
||||
node = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
fn terminal_points(
|
||||
edge: &projection::ProjectedEdge,
|
||||
points: &[Position],
|
||||
segment: usize,
|
||||
) -> Vec<(Id, P)> {
|
||||
let mut owners = Vec::new();
|
||||
if segment == 0 {
|
||||
owners.push((edge.source, p(points[0])));
|
||||
}
|
||||
if segment + 2 == points.len() {
|
||||
owners.push((edge.target, p(*points.last().unwrap())));
|
||||
}
|
||||
owners
|
||||
}
|
||||
fn inside_length(a: P, b: P, r: Rect) -> f64 {
|
||||
let delta = sub(b, a);
|
||||
let mut lo = 0_f64;
|
||||
let mut hi = 1_f64;
|
||||
for (origin, direction, min, max) in [
|
||||
(a.0, delta.0, r.0 + EPS, r.2 - EPS),
|
||||
(a.1, delta.1, r.1 + EPS, r.3 - EPS),
|
||||
] {
|
||||
if min >= max {
|
||||
return 0.;
|
||||
}
|
||||
if direction.abs() < 1e-9 {
|
||||
if origin <= min || origin >= max {
|
||||
return 0.;
|
||||
}
|
||||
} else {
|
||||
let t = (min - origin) / direction;
|
||||
let u = (max - origin) / direction;
|
||||
lo = lo.max(t.min(u));
|
||||
hi = hi.min(t.max(u));
|
||||
}
|
||||
}
|
||||
(hi - lo).max(0.) * length(delta)
|
||||
}
|
||||
fn header_height(project: &Project, diagram: Id, node: &projection::ProjectedNode) -> f64 {
|
||||
let h = if project.diagrams[&diagram]
|
||||
.projection
|
||||
.state_machine()
|
||||
.is_some()
|
||||
{
|
||||
projection::state_header_height(project, node.element)
|
||||
} else {
|
||||
64.
|
||||
};
|
||||
h.min(node.size.height) as f64
|
||||
}
|
||||
|
||||
fn metrics(project: &Project, diagram: Id) -> Value {
|
||||
let view = projection::project_view(project, diagram);
|
||||
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
|
||||
let edges: Vec<_> = routed.edges.values().collect();
|
||||
let definitions: BTreeMap<_, _> = view.edges.iter().map(|e| (e.id, e)).collect();
|
||||
let mut crossings = Vec::new();
|
||||
let mut shared = 0.;
|
||||
let mut excluded_shared = 0.;
|
||||
let mut excluded_stub_crossings = 0;
|
||||
for (i, a) in edges.iter().enumerate() {
|
||||
for b in edges.iter().skip(i + 1) {
|
||||
let mut unique = BTreeSet::new();
|
||||
for (ai, ab) in a.points.windows(2).enumerate() {
|
||||
for (bi, cd) in b.points.windows(2).enumerate() {
|
||||
let mut common_tips = Vec::new();
|
||||
for (owner, tip) in terminal_points(definitions[&a.id], &a.points, ai) {
|
||||
if terminal_points(definitions[&b.id], &b.points, bi)
|
||||
.iter()
|
||||
.any(|(other, at)| *other == owner && length(sub(*at, tip)) < EPS)
|
||||
{
|
||||
common_tips.push(tip);
|
||||
}
|
||||
}
|
||||
let crossing = proper_cross(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
|
||||
let overlap = shared_length(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
|
||||
// Raw totals stay primary. Only physically common tips get
|
||||
// bounded PORT_STUB exemptions, never a whole compacted segment.
|
||||
shared += overlap;
|
||||
excluded_shared +=
|
||||
overlap.min(common_tips.len() as f64 * routing::PORT_STUB as f64);
|
||||
if let Some(at) = crossing {
|
||||
if unique.insert(((at.0 / EPS).round() as i64, (at.1 / EPS).round() as i64))
|
||||
{
|
||||
let near_tip = common_tips.iter().any(|tip| {
|
||||
length(sub(at, *tip)) <= routing::PORT_STUB as f64 + EPS
|
||||
});
|
||||
excluded_stub_crossings += usize::from(near_tip);
|
||||
crossings.push(json!({"edges":[a.id,b.id],"at":[at.0,at.1],"within_common_terminal_stub":near_tip}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut node_collisions = Vec::new();
|
||||
let mut label_nodes = Vec::new();
|
||||
let mut label_labels = Vec::new();
|
||||
let mut excluded_ancestor_labels = 0;
|
||||
let mut penetrations = Vec::new();
|
||||
let mut label_strokes = Vec::new();
|
||||
for e in &edges {
|
||||
let def = definitions[&e.id];
|
||||
for n in &view.nodes {
|
||||
if n.id == def.source || n.id == def.target {
|
||||
continue;
|
||||
}
|
||||
let mut obstacle = rect(n.position, n.size);
|
||||
let ancestor_header =
|
||||
ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target);
|
||||
if ancestor_header {
|
||||
obstacle.3 = obstacle.1 + header_height(project, diagram, n);
|
||||
}
|
||||
let length: f64 = e
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| inside_length(p(w[0]), p(w[1]), obstacle))
|
||||
.sum();
|
||||
if length > EPS {
|
||||
penetrations.push(json!({"edge":e.id,"node":n.id,"length":length,"ancestor_header_only":ancestor_header}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, a) in view.nodes.iter().enumerate() {
|
||||
for b in view.nodes.iter().skip(i + 1) {
|
||||
let area = overlap(rect(a.position, a.size), rect(b.position, b.size));
|
||||
if area > 0. && !ancestor(&view, a.id, b.id) && !ancestor(&view, b.id, a.id) {
|
||||
node_collisions.push(json!({"nodes":[a.id,b.id],"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let labels: Vec<_> = edges.iter().filter(|e| !e.label.text.is_empty()).collect();
|
||||
for (i, e) in labels.iter().enumerate() {
|
||||
let lb = rect(e.label.bounds.position, e.label.bounds.size);
|
||||
let def = definitions[&e.id];
|
||||
for n in &view.nodes {
|
||||
let nb = rect(n.position, n.size);
|
||||
let area = overlap(lb, nb);
|
||||
if area == 0. {
|
||||
continue;
|
||||
}
|
||||
let body = (nb.0, nb.1 + header_height(project, diagram, n), nb.2, nb.3);
|
||||
if contains(body, lb)
|
||||
&& (ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target))
|
||||
{
|
||||
excluded_ancestor_labels += 1;
|
||||
} else {
|
||||
label_nodes.push(json!({"edge":e.id,"node":n.id,"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
for other in labels.iter().skip(i + 1) {
|
||||
let area = overlap(
|
||||
lb,
|
||||
rect(other.label.bounds.position, other.label.bounds.size),
|
||||
);
|
||||
if area > 0. {
|
||||
label_labels.push(json!({"edges":[e.id,other.id],"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
for other in edges.iter().filter(|other| other.id != e.id) {
|
||||
let length: f64 = other
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| inside_length(p(w[0]), p(w[1]), lb))
|
||||
.sum();
|
||||
if length > EPS {
|
||||
label_strokes
|
||||
.push(json!({"label_edge":e.id,"stroke_edge":other.id,"length":length}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut total_length = 0.;
|
||||
let mut bends = 0;
|
||||
for e in &edges {
|
||||
total_length += e
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| length(sub(p(w[1]), p(w[0]))))
|
||||
.sum::<f64>();
|
||||
bends += e
|
||||
.points
|
||||
.windows(3)
|
||||
.filter(|w| {
|
||||
let a = sub(p(w[1]), p(w[0]));
|
||||
let b = sub(p(w[2]), p(w[1]));
|
||||
length(a) > EPS && length(b) > EPS && (cross(a, b).abs() > EPS || dot(a, b) < 0.)
|
||||
})
|
||||
.count();
|
||||
}
|
||||
let warnings: Vec<_> = edges
|
||||
.iter()
|
||||
.flat_map(|e| {
|
||||
e.warnings
|
||||
.iter()
|
||||
.map(move |w| json!({"edge":e.id,"warning":format!("{w:?}")}))
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"nodes":view.nodes.len(),"edges":edges.len(),"ports":view.ports.len(),
|
||||
"proper_crossings":crossings.len(),"crossing_details":crossings,
|
||||
"pairwise_collinear_shared_length":shared,
|
||||
"excluded_common_terminal_shared_length":excluded_shared,"excluded_common_terminal_crossings":excluded_stub_crossings,
|
||||
"nonterminal_proper_crossings":crossings.len()-excluded_stub_crossings,
|
||||
"nonterminal_pairwise_shared_length":shared-excluded_shared,
|
||||
"unrelated_edge_node_penetrations":penetrations.len(),"edge_node_details":penetrations,
|
||||
"unrelated_label_stroke_intersections":label_strokes.len(),"label_stroke_details":label_strokes,
|
||||
"unrelated_node_collisions":node_collisions.len(),"node_collision_details":node_collisions,
|
||||
"label_node_collisions":label_nodes.len(),"label_node_details":label_nodes,
|
||||
"excluded_contained_endpoint_ancestor_label_pairs":excluded_ancestor_labels,
|
||||
"label_label_collisions":label_labels.len(),"label_label_details":label_labels,
|
||||
"warning_count":warnings.len(),"warnings":warnings,
|
||||
"drawing_bounds":{"position":routed.bounds.position,"size":routed.bounds.size},
|
||||
"drawing_area":routed.bounds.size.width as f64*routed.bounds.size.height as f64,
|
||||
"route_bends":bends,"route_length":total_length,
|
||||
"projected_nodes":view.nodes,"projected_ports":view.ports,
|
||||
"routed_edges":edges.iter().map(|e|json!({"id":e.id,"points":e.points,"label":{
|
||||
"text":e.label.text,"position":e.label.bounds.position,"size":e.label.bounds.size}})).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
next: u128,
|
||||
witness: Option<Project>,
|
||||
}
|
||||
impl Builder {
|
||||
fn new(name: &str) -> Self {
|
||||
let mut project = Project::empty();
|
||||
project.id = Id::from_u128(90_000);
|
||||
project.alias = "layout_probe".into();
|
||||
project.name = name.into();
|
||||
let old = project.ensure_diagram(Layer::Sa);
|
||||
let diagram = Id::from_u128(90_001);
|
||||
reidentify(&mut project, old, diagram).unwrap();
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
Self {
|
||||
project,
|
||||
diagram,
|
||||
next: 1,
|
||||
witness: None,
|
||||
}
|
||||
}
|
||||
fn stable(&mut self, old: Id) -> Id {
|
||||
let id = Id::from_u128(self.next);
|
||||
self.next += 1;
|
||||
reidentify(&mut self.project, old, id).unwrap();
|
||||
id
|
||||
}
|
||||
fn function(&mut self, name: &str, parent: Option<Id>, x: f32, y: f32) -> Id {
|
||||
let old = self
|
||||
.project
|
||||
.add_element(Kind::Function, Layer::Sa, name, parent)
|
||||
.unwrap();
|
||||
let id = self.stable(old);
|
||||
let old = self.project.place(self.diagram, id, Position { x, y });
|
||||
self.stable(old);
|
||||
id
|
||||
}
|
||||
fn exchange(&mut self, name: &str, a: Id, b: Id) -> Id {
|
||||
let old = self
|
||||
.project
|
||||
.relate(RelationKind::FunctionalExchange, name, a, b);
|
||||
let edge = self.stable(old);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = self.project.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
for (old, prefix) in [(source_port, "out"), (target_port, "in")] {
|
||||
let id = self.stable(old);
|
||||
self.project.elements.get_mut(&id).unwrap().alias =
|
||||
format!("{prefix}_{}", id.as_u128());
|
||||
}
|
||||
edge
|
||||
}
|
||||
fn node(&self, element: Id) -> Id {
|
||||
self.project.diagrams[&self.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == element)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
}
|
||||
fn fixtures() -> Vec<(&'static str, Builder)> {
|
||||
let mut all = Vec::new();
|
||||
for (name, count) in [("flat_chain_4", 4), ("chain_7_row_wrap", 7)] {
|
||||
let mut f = Builder::new(name);
|
||||
let mut nodes = Vec::new();
|
||||
for i in 0..count {
|
||||
nodes.push(f.function(&format!("Function {i}"), None, 40. + 320. * i as f32, 80.));
|
||||
}
|
||||
for i in 0..count - 1 {
|
||||
f.exchange(&format!("Flow {i}"), nodes[i], nodes[i + 1]);
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
for (name, links) in [
|
||||
("diamond", vec![(0, 1), (0, 2), (1, 3), (2, 3)]),
|
||||
("crossed_bipartite", vec![(0, 3), (1, 2), (0, 2), (1, 3)]),
|
||||
] {
|
||||
let mut f = Builder::new(name);
|
||||
let mut nodes = Vec::new();
|
||||
let coordinates = if name == "diamond" {
|
||||
[(40., 240.), (400., 80.), (400., 400.), (760., 240.)]
|
||||
} else {
|
||||
[(40., 80.), (40., 400.), (620., 80.), (620., 400.)]
|
||||
};
|
||||
for (i, (x, y)) in coordinates.into_iter().enumerate() {
|
||||
nodes.push(f.function(&format!("Node {i}"), None, x, y));
|
||||
}
|
||||
for (i, (a, b)) in links.into_iter().enumerate() {
|
||||
f.exchange(&format!("Flow {i}"), nodes[a], nodes[b]);
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
let mut f = Builder::new("nested_cross_boundary");
|
||||
let group = f.function("Parent function", None, 40., 40.);
|
||||
let a = f.function("Child send", Some(group), 100., 130.);
|
||||
let b = f.function("Child receive", Some(group), 100., 330.);
|
||||
let c = f.function("Outside", None, 900., 210.);
|
||||
f.exchange("Internal flow", a, b);
|
||||
f.exchange("Cross boundary", b, c);
|
||||
all.push(("nested_cross_boundary", f));
|
||||
for fixed in [false, true] {
|
||||
let name = if fixed {
|
||||
"fixed_ports"
|
||||
} else {
|
||||
"authored_detour_and_label"
|
||||
};
|
||||
let mut f = Builder::new(name);
|
||||
let a = f.function("Sender", None, 40., 80.);
|
||||
let b = f.function("Receiver", None, 640., 80.);
|
||||
let edge = f.exchange("Preserved information", a, b);
|
||||
if fixed {
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = f.project.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
for (element, port, side, offset) in [
|
||||
(a, source_port, PortAnchorSide::Left, 0.3),
|
||||
(b, target_port, PortAnchorSide::Right, 0.7),
|
||||
] {
|
||||
let node = f.node(element);
|
||||
f.project = prepare_command(
|
||||
&f.project,
|
||||
&Command::SetPortAnchor {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
}
|
||||
} else {
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![
|
||||
Position { x: 400., y: -320. },
|
||||
Position { x: 1000., y: -320. },
|
||||
Position { x: 1000., y: 240. },
|
||||
],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.7,
|
||||
offset: Position { x: 100., y: -180. },
|
||||
}),
|
||||
});
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
let mut f = Builder::new("two_containers_reversed_children");
|
||||
let left = f.function("Left group", None, 40., 40.);
|
||||
let right = f.function("Right group", None, 820., 40.);
|
||||
let la = f.function("Left A", Some(left), 100., 130.);
|
||||
let lb = f.function("Left B", Some(left), 100., 330.);
|
||||
let ra = f.function("Right A", Some(right), 880., 130.);
|
||||
let rb = f.function("Right B", Some(right), 880., 330.);
|
||||
f.exchange("Upper to lower", la, rb);
|
||||
f.exchange("Lower to upper", lb, ra);
|
||||
let mut witness = f.project.clone();
|
||||
for (element, y) in [(ra, 330.), (rb, 130.)] {
|
||||
let node = f.node(element);
|
||||
witness
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.y = y;
|
||||
}
|
||||
f.witness = Some(witness);
|
||||
all.push(("two_containers_reversed_children", f));
|
||||
let mut f = Builder::new("long_ordinary_labels");
|
||||
let a = f.function("Input", None, 40., 100.);
|
||||
let b = f.function("Transform", None, 400., 100.);
|
||||
let c = f.function("Output", None, 760., 100.);
|
||||
f.exchange(
|
||||
"A very long first information exchange label exceeding the ordinary cutoff",
|
||||
a,
|
||||
b,
|
||||
);
|
||||
f.exchange(
|
||||
"A very long second information exchange label exceeding the ordinary cutoff",
|
||||
b,
|
||||
c,
|
||||
);
|
||||
f.exchange(
|
||||
"A very long bypass information exchange label exceeding the ordinary cutoff",
|
||||
a,
|
||||
c,
|
||||
);
|
||||
all.push(("long_ordinary_labels", f));
|
||||
all
|
||||
}
|
||||
|
||||
fn reset_effective_routes(project: &Project, diagram: Id) -> Project {
|
||||
let view = projection::project_view(project, diagram);
|
||||
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
|
||||
let mut reset = project.clone();
|
||||
for route in &mut reset.diagrams.get_mut(&diagram).unwrap().routes {
|
||||
if routed
|
||||
.edges
|
||||
.get(&route.edge)
|
||||
.is_some_and(|edge| !edge.custom_suspended)
|
||||
{
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
}
|
||||
reset
|
||||
}
|
||||
|
||||
fn conservation(before: &Project, after: &Project, diagram: Id) -> Value {
|
||||
let mut semantic = after.clone();
|
||||
semantic.diagrams = before.diagrams.clone();
|
||||
let bd = &before.diagrams[&diagram];
|
||||
let ad = &after.diagrams[&diagram];
|
||||
let mut stable_view = ad.clone();
|
||||
stable_view.nodes = bd.nodes.clone();
|
||||
stable_view.routes = bd.routes.clone();
|
||||
stable_view.layout = bd.layout.clone();
|
||||
let mut other_views = after.diagrams.clone();
|
||||
other_views.insert(diagram, bd.clone());
|
||||
let node_metadata = bd.nodes.len() == ad.nodes.len()
|
||||
&& bd.nodes.iter().all(|(id, node)| {
|
||||
ad.nodes.get(id).is_some_and(|new| {
|
||||
let mut restored = new.clone();
|
||||
restored.position = node.position;
|
||||
restored.size = node.size;
|
||||
restored == *node
|
||||
})
|
||||
});
|
||||
json!({"semantic_records_exact":semantic==*before,"target_view_nongeometry_exact":stable_view==*bd,
|
||||
"other_views_exact":other_views==before.diagrams,"node_identity_visibility_anchors_exact":node_metadata,
|
||||
"authored_routes_exact":ad.routes==bd.routes})
|
||||
}
|
||||
|
||||
fn roundtrip(project: &Project) -> Value {
|
||||
let bytes = serde_json::to_vec(project).unwrap();
|
||||
let json_result = persistence::decode(&bytes);
|
||||
let source = language::format(project);
|
||||
let source_result = language::parse(&source);
|
||||
json!({"json_exact":json_result.as_ref().is_ok_and(|p|p==project),
|
||||
"json_error":json_result.err().map(|e|e.to_string()),
|
||||
"single_source_exact":source_result.as_ref().is_ok_and(|p|p==project),
|
||||
"source_error":source_result.err().map(|e|format!("{e:?}"))})
|
||||
}
|
||||
fn observe(name: &str, f: Builder) -> Value {
|
||||
let witness = f.witness;
|
||||
let before = f.project;
|
||||
let diagram = f.diagram;
|
||||
if let Err(error) = validate_candidate(&before) {
|
||||
return json!({"fixture":name,"fixture_error":error.to_string()});
|
||||
}
|
||||
let mut session = Session::new(before.clone());
|
||||
if let Err(error) = session.execute(Command::Layout(diagram)) {
|
||||
return json!({"fixture":name,"before":metrics(&before,diagram),"layout_error":error.to_string()});
|
||||
}
|
||||
let after = session.project().clone();
|
||||
let changed = after != before;
|
||||
let had_undo = session.can_undo();
|
||||
session.undo();
|
||||
let undo = session.project() == &before;
|
||||
let one_step = !session.can_undo();
|
||||
session.redo();
|
||||
let redo = session.project() == &after;
|
||||
let second = prepare_command(&after, &Command::Layout(diagram));
|
||||
let reset = reset_effective_routes(&after, diagram);
|
||||
let mut repeated = after.clone();
|
||||
let mut stability = Vec::new();
|
||||
for pass in 2..=10 {
|
||||
match prepare_command(&repeated, &Command::Layout(diagram)) {
|
||||
Ok(next) => {
|
||||
stability.push(json!({"pass":pass,"equal_previous":next.project==repeated,"equal_first":next.project==after}));
|
||||
repeated = next.project;
|
||||
}
|
||||
Err(error) => {
|
||||
stability.push(json!({"pass":pass,"error":error.to_string()}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let witness=witness.map(|p|json!({"metrics":metrics(&p,diagram),"project":p,
|
||||
"conservation":conservation(&before,&p,diagram),"validation_error":validate_candidate(&p).err().map(|e|e.to_string())}));
|
||||
let candidate = match layered::candidate(&before, diagram) {
|
||||
Err(error) => json!({"supported":false,"error":error.to_string()}),
|
||||
Ok(candidate) => {
|
||||
if let Err(error) = validate_candidate(&candidate) {
|
||||
json!({"supported":true,"validation_error":error.to_string(),"project":candidate})
|
||||
} else {
|
||||
let again = layered::candidate(&candidate, diagram);
|
||||
let duplicate = layered::candidate(&before, diagram);
|
||||
let mut repeated_candidate = candidate.clone();
|
||||
let mut candidate_stability = Vec::new();
|
||||
for pass in 2..=10 {
|
||||
match layered::candidate(&repeated_candidate, diagram) {
|
||||
Ok(next) => {
|
||||
candidate_stability.push(json!({"pass":pass,"equal_previous":next==repeated_candidate,"equal_first":next==candidate}));
|
||||
repeated_candidate = next;
|
||||
}
|
||||
Err(error) => {
|
||||
candidate_stability
|
||||
.push(json!({"pass":pass,"error":error.to_string()}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"supported":true,"metrics":metrics(&candidate,diagram),"project":candidate,
|
||||
"conservation":conservation(&before,&candidate,diagram),"roundtrip":roundtrip(&candidate),
|
||||
"ten_candidate_calls":candidate_stability,
|
||||
"repeat_same_input_exact":duplicate.as_ref().is_ok_and(|p|*p==candidate),
|
||||
"idempotent":again.as_ref().is_ok_and(|p|*p==candidate),
|
||||
"repeat_error":duplicate.err().map(|e|e.to_string()),"idempotence_error":again.err().map(|e|e.to_string())})
|
||||
}
|
||||
}
|
||||
};
|
||||
json!({"fixture":name,"diagram":diagram,"before":metrics(&before,diagram),"after":metrics(&after,diagram),
|
||||
"before_project":before,"after_project":after,"conservation":conservation(&before,&after,diagram),
|
||||
"reset_only_control":{"metrics":metrics(&reset,diagram),"project":reset,"conservation":conservation(&before,&reset,diagram),"roundtrip":roundtrip(&reset)},
|
||||
"manual_planar_order_witness":witness,"ten_layout_calls":stability,"experimental_candidate":candidate,
|
||||
"checks":{
|
||||
"changed":changed,"history_entry_matches_change":had_undo==changed,"undo_exact":undo,"one_step_undo":one_step,"redo_exact":redo,
|
||||
"second_layout_exact":second.as_ref().is_ok_and(|s|s.project==after),"second_layout_error":second.err().map(|e|e.to_string()),
|
||||
"before_roundtrip":roundtrip(&before),"after_roundtrip":roundtrip(&after)}})
|
||||
}
|
||||
fn main() {
|
||||
// Analytic checks validate the observer, not any layout's quality.
|
||||
assert_eq!(
|
||||
proper_cross((0., 0.), (10., 0.), (5., -5.), (5., 5.)),
|
||||
Some((5., 0.))
|
||||
);
|
||||
assert_eq!(
|
||||
proper_cross((0., 0.), (10., 0.), (10., 0.), (10., 5.)),
|
||||
None
|
||||
);
|
||||
assert!((shared_length((0., 0.), (10., 0.), (4., 0.), (12., 0.)) - 6.).abs() < EPS);
|
||||
assert_eq!(shared_length((0., 0.), (10., 0.), (4., 1.), (12., 1.)), 0.);
|
||||
assert_eq!(overlap((0., 0., 10., 10.), (10., 0., 20., 10.)), 0.);
|
||||
assert!((inside_length((0., 5.), (10., 5.), (2., 2., 8., 8.)) - 6.).abs() < 3. * EPS);
|
||||
assert_eq!(inside_length((0., 2.), (10., 2.), (2., 2., 8., 8.)), 0.);
|
||||
println!(
|
||||
"{}",
|
||||
json!({"probe":"auto_layout_probe_v1","reference_source":"715036e132677dd2bc2fb37ed316d93dc9987aec","metric_selfchecks":"passed", "quality_thresholds":null,
|
||||
"exclusions":"Raw strict-interior crossings and pairwise shared length are primary; separately bound common physical endpoint exclusions to PORT_STUB. Ancestor node containment excluded. Labels wholly inside endpoint-ancestor BODY below header exempt; headers retained. Penetration excludes endpoint nodes and endpoint-ancestor bodies. Label/stroke excludes own edge. Core approximate label bounds, no glyph/port-label metrics."})
|
||||
);
|
||||
for (name, f) in fixtures() {
|
||||
println!("{}", observe(name, f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Isolated #32 experiment, not a production layout engine.
|
||||
//! Uses existing Layout sizing, three deterministic ordering passes, and one
|
||||
//! core-estimated-label spacing pass. Automatic elbows remain derived. Folded/omitted
|
||||
//! node topology is explicitly unsupported; filtered edges retain their metadata.
|
||||
use oa_core::{Command, Id, ModelError, Position, Project, Size, projection, routing};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const WORK_LIMIT: usize = 200_000;
|
||||
const NODE_LIMIT: usize = 256;
|
||||
const EDGE_LIMIT: usize = 1024;
|
||||
|
||||
fn spend(work: &mut usize) -> Result<(), ModelError> {
|
||||
*work += 1;
|
||||
if *work > WORK_LIMIT {
|
||||
return Err(ModelError("Layered probe work limit exceeded.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a new Project; caller owns comparison, history and serialization.
|
||||
pub fn candidate(project: &Project, diagram: Id) -> Result<Project, ModelError> {
|
||||
let original = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| ModelError("Missing probe diagram.".into()))?;
|
||||
let view = projection::project_diagram(project, original);
|
||||
if view.nodes.len() > NODE_LIMIT || view.edges.len() > EDGE_LIMIT {
|
||||
return Err(ModelError("Layered probe graph limit exceeded.".into()));
|
||||
}
|
||||
if view.nodes.len() != original.nodes.len() || view.nodes.iter().any(|n| n.collapsed) {
|
||||
return Err(ModelError(
|
||||
"Layered probe does not support folded/omitted node topology.".into(),
|
||||
));
|
||||
}
|
||||
let routed = routing::route_diagram(project, original, &view);
|
||||
let eligible: BTreeSet<_> = view
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
projection::edge_endpoint_placements(project, original, edge.id)
|
||||
== Some((edge.source, edge.target))
|
||||
&& routed
|
||||
.edges
|
||||
.get(&edge.id)
|
||||
.is_some_and(|route| !route.custom_suspended)
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
.collect();
|
||||
let mut result = oa_core::prepare_command(project, &Command::Layout(diagram))?.project;
|
||||
// Discard baseline route translation: final routes are reconstructed directly
|
||||
// from original geometry, avoiding two translations and preserving list order.
|
||||
result.diagrams.get_mut(&diagram).unwrap().routes = original.routes.clone();
|
||||
for route in &mut result.diagrams.get_mut(&diagram).unwrap().routes {
|
||||
if eligible.contains(&route.edge) {
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
}
|
||||
let baseline = projection::project_view(&result, diagram);
|
||||
let labels = routing::route_diagram(&result, &result.diagrams[&diagram], &baseline);
|
||||
let mut gap_x = 64.0_f32;
|
||||
let mut gap_y = 32.0_f32;
|
||||
for edge in labels.edges.values() {
|
||||
gap_x = gap_x.max(edge.label.bounds.size.width + 24.0);
|
||||
gap_y = gap_y.max(edge.label.bounds.size.height + 24.0);
|
||||
if let Some(relation) = result.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, edge) {
|
||||
gap_x = gap_x.max(end.label.bounds.size.width + 24.0);
|
||||
gap_y = gap_y.max(end.label.bounds.size.height + 24.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gap_x.is_finite() || !gap_y.is_finite() {
|
||||
return Err(ModelError("Nonfinite estimated label bound.".into()));
|
||||
}
|
||||
let parents: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| (n.id, n.visual_parent))
|
||||
.collect();
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for node in &baseline.nodes {
|
||||
children
|
||||
.entry(node.visual_parent)
|
||||
.or_default()
|
||||
.push(node.id);
|
||||
}
|
||||
let names: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| (n.id, (result.elements[&n.element].alias.clone(), n.id)))
|
||||
.collect();
|
||||
for siblings in children.values_mut() {
|
||||
siblings.sort_by_key(|id| names[id].clone());
|
||||
}
|
||||
let minima: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.size)).collect();
|
||||
let headers: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
(
|
||||
n.id,
|
||||
if original.projection.state_machine().is_some() {
|
||||
projection::state_header_height(&result, n.element)
|
||||
} else {
|
||||
64.0
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut positions: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.position)).collect();
|
||||
let mut sizes = minima.clone();
|
||||
let mut work = 0;
|
||||
let mut group_order: Vec<_> = children.keys().copied().collect();
|
||||
group_order.sort_by_key(|parent| {
|
||||
parent.map_or(0, |id| {
|
||||
baseline.nodes.iter().find(|n| n.id == id).unwrap().depth + 1
|
||||
})
|
||||
});
|
||||
let mut groups = BTreeMap::new();
|
||||
for pass in 0..3 {
|
||||
for parent in &group_order {
|
||||
let siblings = &children[parent];
|
||||
let mut arcs = BTreeSet::new();
|
||||
for edge in &baseline.edges {
|
||||
spend(&mut work)?;
|
||||
let a = child_under(edge.source, *parent, &parents, &mut work)?;
|
||||
let b = child_under(edge.target, *parent, &parents, &mut work)?;
|
||||
if edge.oriented
|
||||
&& let (Some(a), Some(b)) = (a, b)
|
||||
&& a != b
|
||||
{
|
||||
arcs.insert((a, b));
|
||||
}
|
||||
}
|
||||
let ranks = ranks(siblings, &arcs, &mut work)?;
|
||||
let mut layers: BTreeMap<usize, Vec<Id>> = BTreeMap::new();
|
||||
for id in siblings {
|
||||
layers.entry(ranks[id]).or_default().push(*id);
|
||||
}
|
||||
// Boundary neighbours participate even though they are not siblings.
|
||||
// Incoming preference anchors the downstream order to its upstream
|
||||
// counterpart instead of swapping both sides simultaneously.
|
||||
let mut scores = BTreeMap::new();
|
||||
for id in siblings {
|
||||
let mut incoming = Vec::new();
|
||||
for edge in &baseline.edges {
|
||||
spend(&mut work)?;
|
||||
let a = child_under(edge.source, *parent, &parents, &mut work)?;
|
||||
let b = child_under(edge.target, *parent, &parents, &mut work)?;
|
||||
if edge.oriented && b == Some(*id) && a != Some(*id) {
|
||||
incoming.push(positions[&edge.source].y + sizes[&edge.source].height / 2.0);
|
||||
}
|
||||
}
|
||||
// Do not also chase outgoing boundary neighbours: simultaneous
|
||||
// swaps in two connected containers can preserve the crossing.
|
||||
let values = &incoming;
|
||||
let score = if values.is_empty() {
|
||||
positions[id].y
|
||||
} else {
|
||||
values.iter().sum::<f32>() / values.len() as f32
|
||||
};
|
||||
scores.insert(*id, score);
|
||||
}
|
||||
for layer in layers.values_mut() {
|
||||
// First pass starts from canonical alias order; subsequent passes
|
||||
// retain a deterministic alias tie break, never hash iteration.
|
||||
if pass > 0 {
|
||||
layer.sort_by(|a, b| {
|
||||
scores[a]
|
||||
.total_cmp(&scores[b])
|
||||
.then_with(|| names[a].cmp(&names[b]))
|
||||
});
|
||||
}
|
||||
}
|
||||
groups.insert(*parent, layers.into_values().collect::<Vec<_>>());
|
||||
}
|
||||
// Recompute from frozen minima, never add spacing to last pass's size.
|
||||
sizes = minima.clone();
|
||||
measure(None, &groups, &headers, &mut sizes, gap_x, gap_y, &mut work)?;
|
||||
place(
|
||||
None,
|
||||
Position { x: 40.0, y: 40.0 },
|
||||
&groups,
|
||||
&headers,
|
||||
&sizes,
|
||||
&mut positions,
|
||||
gap_x,
|
||||
gap_y,
|
||||
&mut work,
|
||||
)?;
|
||||
}
|
||||
let d = result.diagrams.get_mut(&diagram).unwrap();
|
||||
for (id, node) in &mut d.nodes {
|
||||
node.position = positions[id];
|
||||
node.size = sizes[id];
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = original
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(id, node)| {
|
||||
(
|
||||
*id,
|
||||
Position {
|
||||
x: positions[id].x - node.position.x,
|
||||
y: positions[id].y - node.position.y,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let translated: BTreeMap<_, _> = projection::translated_routes(project, original, &deltas)
|
||||
.into_iter()
|
||||
.map(|r| (r.edge, r))
|
||||
.collect();
|
||||
d.routes = original
|
||||
.routes
|
||||
.iter()
|
||||
.map(|route| {
|
||||
let mut route = translated.get(&route.edge).unwrap_or(route).clone();
|
||||
if eligible.contains(&route.edge) {
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
route
|
||||
})
|
||||
.collect();
|
||||
if d.nodes.values().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(ModelError("Invalid layered probe geometry.".into()));
|
||||
}
|
||||
oa_core::validate_candidate(&result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn child_under(
|
||||
mut id: Id,
|
||||
parent: Option<Id>,
|
||||
parents: &BTreeMap<Id, Option<Id>>,
|
||||
work: &mut usize,
|
||||
) -> Result<Option<Id>, ModelError> {
|
||||
for _ in 0..=parents.len() {
|
||||
spend(work)?;
|
||||
let Some(actual) = parents.get(&id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if *actual == parent {
|
||||
return Ok(Some(id));
|
||||
}
|
||||
let Some(next) = actual else { return Ok(None) };
|
||||
id = *next;
|
||||
}
|
||||
Err(ModelError("Layered probe containment cycle.".into()))
|
||||
}
|
||||
|
||||
fn ranks(
|
||||
ids: &[Id],
|
||||
arcs: &BTreeSet<(Id, Id)>,
|
||||
work: &mut usize,
|
||||
) -> Result<BTreeMap<Id, usize>, ModelError> {
|
||||
let mut remaining: BTreeSet<_> = ids.iter().copied().collect();
|
||||
let mut ranks: BTreeMap<_, usize> = BTreeMap::new();
|
||||
while !remaining.is_empty() {
|
||||
let mut ready = None;
|
||||
for id in ids.iter().filter(|id| remaining.contains(*id)) {
|
||||
let mut blocked = false;
|
||||
for (a, b) in arcs {
|
||||
spend(work)?;
|
||||
blocked |= b == id && remaining.contains(a);
|
||||
}
|
||||
if !blocked {
|
||||
ready = Some(*id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Explicit cycle fallback: break the first canonical remaining vertex's
|
||||
// incoming ranking constraints. Actual relation direction is unchanged.
|
||||
let id = ready.unwrap_or_else(|| *ids.iter().find(|id| remaining.contains(*id)).unwrap());
|
||||
let mut rank = 0;
|
||||
for (a, b) in arcs {
|
||||
spend(work)?;
|
||||
if *b == id
|
||||
&& let Some(previous) = ranks.get(a)
|
||||
{
|
||||
rank = rank.max(previous + 1);
|
||||
}
|
||||
}
|
||||
ranks.insert(id, rank);
|
||||
remaining.remove(&id);
|
||||
}
|
||||
Ok(ranks)
|
||||
}
|
||||
|
||||
type Groups = BTreeMap<Option<Id>, Vec<Vec<Id>>>;
|
||||
|
||||
fn measure(
|
||||
parent: Option<Id>,
|
||||
groups: &Groups,
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
sizes: &mut BTreeMap<Id, Size>,
|
||||
gx: f32,
|
||||
gy: f32,
|
||||
work: &mut usize,
|
||||
) -> Result<Size, ModelError> {
|
||||
let Some(layers) = groups.get(&parent) else {
|
||||
return Ok(Size {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
});
|
||||
};
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0_f32;
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
let mut lw = 0.0_f32;
|
||||
let mut lh = 0.0;
|
||||
for (j, id) in layer.iter().enumerate() {
|
||||
spend(work)?;
|
||||
let content = measure(Some(*id), groups, headers, sizes, gx, gy, work)?;
|
||||
let size = sizes.get_mut(id).unwrap();
|
||||
if groups.contains_key(&Some(*id)) {
|
||||
size.width = size.width.max(content.width + 48.0);
|
||||
size.height = size.height.max(content.height + headers[id] + 24.0);
|
||||
}
|
||||
lw = lw.max(size.width);
|
||||
lh += size.height + if j == 0 { 0.0 } else { gy };
|
||||
}
|
||||
width += lw + if i == 0 { 0.0 } else { gx };
|
||||
height = height.max(lh);
|
||||
}
|
||||
Ok(Size { width, height })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place(
|
||||
parent: Option<Id>,
|
||||
origin: Position,
|
||||
groups: &Groups,
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
sizes: &BTreeMap<Id, Size>,
|
||||
positions: &mut BTreeMap<Id, Position>,
|
||||
gx: f32,
|
||||
gy: f32,
|
||||
work: &mut usize,
|
||||
) -> Result<(), ModelError> {
|
||||
let Some(layers) = groups.get(&parent) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut x = origin.x;
|
||||
for layer in layers {
|
||||
let mut y = origin.y;
|
||||
let mut width = 0.0_f32;
|
||||
for id in layer {
|
||||
spend(work)?;
|
||||
positions.insert(*id, Position { x, y });
|
||||
place(
|
||||
Some(*id),
|
||||
Position {
|
||||
x: x + 24.0,
|
||||
y: y + headers[id],
|
||||
},
|
||||
groups,
|
||||
headers,
|
||||
sizes,
|
||||
positions,
|
||||
gx,
|
||||
gy,
|
||||
work,
|
||||
)?;
|
||||
y += sizes[id].height + gy;
|
||||
width = width.max(sizes[id].width);
|
||||
}
|
||||
x += width + gx;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user