diff --git a/crates/oa-core/examples/auto_layout_probe.rs b/crates/oa-core/examples/auto_layout_probe.rs
index d2bf516b..c83e6d0b 100644
--- a/crates/oa-core/examples/auto_layout_probe.rs
+++ b/crates/oa-core/examples/auto_layout_probe.rs
@@ -65,6 +65,69 @@ fn shared_length(a: P, b: P, c: P, d: P) -> f64 {
let s = dot(sub(d, a), u) / len;
(t.max(s).min(len) - t.min(s).max(0.)).max(0.)
}
+// Closed-segment point intersections. A positive collinear interval is not a
+// point: it remains represented by the existing shared-length metric.
+fn point_contact(a: P, b: P, c: P, d: P) -> Option
{
+ let u = sub(b, a);
+ let v = sub(d, c);
+ let on_segment = |at: P, start: P, end: P| {
+ let delta = sub(end, start);
+ let len = length(delta);
+ if len < EPS {
+ return length(sub(at, start)) < EPS;
+ }
+ cross(delta, sub(at, start)).abs() / len <= EPS
+ && dot(sub(at, start), delta) >= -EPS * len
+ && dot(sub(at, end), delta) <= EPS * len
+ };
+ if length(u) < EPS {
+ return on_segment(a, c, d).then_some(a);
+ }
+ if length(v) < EPS {
+ return on_segment(c, a, b).then_some(c);
+ }
+ let denominator = cross(u, v);
+ if denominator.abs() < 1e-9 {
+ if shared_length(a, b, c, d) > EPS {
+ return None;
+ }
+ return [a, b, c, d]
+ .into_iter()
+ .find(|at| on_segment(*at, a, b) && on_segment(*at, c, d));
+ }
+ let t = cross(sub(c, a), v) / denominator;
+ let s = cross(sub(c, a), u) / denominator;
+ (t >= -1e-9 && t <= 1. + 1e-9 && s >= -1e-9 && s <= 1. + 1e-9)
+ .then_some((a.0 + t.clamp(0., 1.) * u.0, a.1 + t.clamp(0., 1.) * u.1))
+}
+fn point_contacts(a: &[P], b: &[P]) -> Vec<(P, bool)> {
+ let mut unique = BTreeMap::new();
+ for ab in a.windows(2) {
+ for cd in b.windows(2) {
+ if let Some(at) = point_contact(ab[0], ab[1], cd[0], cd[1]) {
+ let key = ((at.0 / EPS).round() as i64, (at.1 / EPS).round() as i64);
+ let proper = proper_cross(ab[0], ab[1], cd[0], cd[1]).is_some();
+ let entry = unique.entry(key).or_insert((at, false));
+ entry.1 |= proper;
+ }
+ }
+ }
+ unique.into_values().collect()
+}
+fn contact_role(points: &[P], at: P) -> &'static str {
+ if points
+ .first()
+ .into_iter()
+ .chain(points.last())
+ .any(|tip| length(sub(*tip, at)) < EPS)
+ {
+ "route_endpoint"
+ } else if points.iter().any(|vertex| length(sub(*vertex, at)) < EPS) {
+ "internal_vertex"
+ } else {
+ "segment_interior"
+ }
+}
fn ancestor(view: &ProjectedView, owner: Id, mut node: Id) -> bool {
for _ in 0..view.nodes.len() {
let Some(parent) = view
@@ -142,8 +205,31 @@ fn metrics(project: &Project, diagram: Id) -> Value {
let mut shared = 0.;
let mut excluded_shared = 0.;
let mut excluded_stub_crossings = 0;
+ let mut contacts = Vec::new();
+ let mut common_terminal_contacts = 0;
for (i, a) in edges.iter().enumerate() {
for b in edges.iter().skip(i + 1) {
+ let ap: Vec<_> = a.points.iter().copied().map(p).collect();
+ let bp: Vec<_> = b.points.iter().copied().map(p).collect();
+ let ad = definitions[&a.id];
+ let bd = definitions[&b.id];
+ for (at, proper) in point_contacts(&ap, &bp) {
+ let common_terminal = [(ad.source, ap.first()), (ad.target, ap.last())]
+ .iter()
+ .any(|(owner, tip)| {
+ tip.is_some_and(|tip| length(sub(*tip, at)) < EPS)
+ && [(bd.source, bp.first()), (bd.target, bp.last())]
+ .iter()
+ .any(|(other, tip)| {
+ owner == other
+ && tip.is_some_and(|tip| length(sub(*tip, at)) < EPS)
+ })
+ });
+ common_terminal_contacts += usize::from(common_terminal);
+ contacts.push(json!({"edges":[a.id,b.id],"at":[at.0,at.1],
+ "proper_crossing":proper,"roles":[contact_role(&ap,at),contact_role(&bp,at)],
+ "common_physical_terminal":common_terminal}));
+ }
let mut unique = BTreeSet::new();
for (ai, ab) in a.points.windows(2).enumerate() {
for (bi, cd) in b.points.windows(2).enumerate() {
@@ -282,6 +368,8 @@ fn metrics(project: &Project, diagram: Id) -> Value {
json!({
"nodes":view.nodes.len(),"edges":edges.len(),"ports":view.ports.len(),
"proper_crossings":crossings.len(),"crossing_details":crossings,
+ "point_contacts":contacts.len(),"point_contact_details":contacts,
+ "common_physical_terminal_point_contacts":common_terminal_contacts,
"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,
@@ -297,6 +385,9 @@ fn metrics(project: &Project, diagram: Id) -> Value {
"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,
+ "route_endpoints":edges.iter().map(|e|json!({"id":e.id,
+ "source_owner":definitions[&e.id].source,"target_owner":definitions[&e.id].target,
+ "source":e.points.first(),"target":e.points.last()})).collect::>(),
"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::>()
})
@@ -647,6 +738,29 @@ fn observe(name: &str, f: Builder) -> Value {
}
fn main() {
// Analytic checks validate the observer, not any layout's quality.
+ let horizontal = [(0., 0.), (10., 0.)];
+ assert_eq!(
+ point_contacts(&horizontal, &[(5., -5.), (5., 5.)]),
+ vec![((5., 0.), true)]
+ );
+ assert_eq!(
+ point_contacts(&horizontal, &[(5., 0.), (5., 5.)]),
+ vec![((5., 0.), false)]
+ );
+ let bend = [(5., -5.), (5., 0.), (8., 3.)];
+ assert_eq!(point_contacts(&horizontal, &bend), vec![((5., 0.), false)]);
+ assert_eq!(contact_role(&bend, (5., 0.)), "internal_vertex");
+ assert_eq!(contact_role(&horizontal, (5., 0.)), "segment_interior");
+ assert_eq!(
+ point_contacts(&horizontal, &[(10., 0.), (10., 5.)]),
+ vec![((10., 0.), false)]
+ );
+ assert_eq!(contact_role(&horizontal, (10., 0.)), "route_endpoint");
+ assert_eq!(
+ point_contacts(&horizontal, &[(10., 0.), (20., 0.)]),
+ vec![((10., 0.), false)]
+ );
+ assert!(point_contacts(&horizontal, &[(4., 0.), (12., 0.)]).is_empty());
assert_eq!(
proper_cross((0., 0.), (10., 0.), (5., -5.), (5., 5.)),
Some((5., 0.))
@@ -662,7 +776,8 @@ fn main() {
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,
+ json!({"probe":"auto_layout_probe_v2","reference_source":"715036e132677dd2bc2fb37ed316d93dc9987aec","metric_selfchecks":"passed", "quality_thresholds":null,
+ "point_contact_definition":"Unexempted unique point per edge pair (EPS quantization), closed segments, proper-crossing flag ORed across segment witnesses; roles distinguish route endpoints/internal vertices/interiors. Positive collinear intervals are only in shared-length metric; a noncollinear witness at an overlap boundary is still a point contact. Common physical terminal requires equal owner and equal endpoint coordinate and is classified, never subtracted. Route endpoints emitted for exact comparison against the frozen prior run, not assumed invariant across node layout.",
"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() {
diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs
index 4868d45b..59b0ac0f 100644
--- a/crates/oa-core/src/routing.rs
+++ b/crates/oa-core/src/routing.rs
@@ -846,15 +846,19 @@ fn route_edge(
.enumerate()
{
let incoming = points.iter().rev().find(|&&p| p != start).copied();
- let path = fast_path(start, end, &space, &occupied, incoming).or_else(|| {
- if !search {
- return None;
- }
- let (path, hit_limit) =
- grid_path(start, end, &space, &occupied, incoming, budget);
- exhausted |= hit_limit;
- path
- });
+ // Only the final leg has a known continuation. Intermediate
+ // authored pins must not inherit the target's terminal direction.
+ let outgoing = (index == pins.len()).then_some(b.point);
+ let path =
+ fast_path(start, end, &space, &occupied, incoming, outgoing).or_else(|| {
+ if !search {
+ return None;
+ }
+ let (path, hit_limit) =
+ grid_path(start, end, &space, &occupied, incoming, outgoing, budget);
+ exhausted |= hit_limit;
+ path
+ });
let Some(path) = path else {
failed = true;
break;
@@ -1051,6 +1055,7 @@ fn fast_path(
space: &RoutingSpace<'_>,
previous: &[Segment],
incoming: Option,
+ outgoing: Option,
) -> Option> {
let obstacles = space.obstacles;
if obstacles
@@ -1070,6 +1075,9 @@ fn fast_path(
incoming
.zip(path.get(1))
.map_or(0.0, |(before, &after)| turn_cost(before, start, after))
+ + outgoing
+ .zip(path.len().checked_sub(2).and_then(|i| path.get(i)))
+ .map_or(0.0, |(after, &before)| turn_cost(before, end, after))
};
let estimate = |path: &[Position]| base_cost(path) + join(path);
candidates.sort_by(|a, b| estimate(a).total_cmp(&estimate(b)));
@@ -1141,12 +1149,14 @@ impl Ord for QueueItem {
}
}
+#[allow(clippy::too_many_arguments)]
fn grid_path(
start: Position,
end: Position,
space: &RoutingSpace<'_>,
previous: &[Segment],
incoming: Option,
+ outgoing: Option,
budget: &mut usize,
) -> (Option>, bool) {
let obstacles = space.obstacles;
@@ -1237,6 +1247,10 @@ fn grid_path(
let cost = current.cost
+ manhattan(a, b)
+ before.map_or(0.0, |before| turn_cost(before, a, b))
+ // Charge before queueing the target so competing arrival
+ // directions are compared by their complete terminal cost.
+ + outgoing.filter(|_| next == last)
+ .map_or(0.0, |after| turn_cost(a, b, after))
+ crossing_cost(a, b, previous);
if cost >= costs[state] {
continue;
@@ -1591,3 +1605,146 @@ impl BoundsBuilder {
}
}
}
+
+#[cfg(test)]
+mod terminal_join_tests {
+ use super::*;
+
+ fn complete_cost(
+ path: &[Position],
+ before: Position,
+ after: Position,
+ occupied: &[Segment],
+ ) -> f32 {
+ let mut complete = vec![before];
+ complete.extend_from_slice(path);
+ complete.push(after);
+ path_cost(&complete, occupied)
+ }
+
+ #[test]
+ fn terminal_join_prefers_equal_bend_witness_without_shared_trunk() {
+ // Four orientations prevent an accidental horizontal/right-only fix.
+ for orientation in 0..4 {
+ let transform = |p: Position| match orientation {
+ 0 => p,
+ 1 => pos(-p.x, p.y),
+ 2 => pos(p.y, p.x),
+ _ => pos(-p.y, -p.x),
+ };
+ let start = transform(pos(0.0, 0.0));
+ let end = transform(pos(46.0, 180.0));
+ let before = transform(pos(-18.0, 0.0));
+ let after = transform(pos(64.0, 180.0));
+ let occupied = vec![Segment {
+ a: start,
+ b: transform(pos(46.0, 0.0)),
+ count: 1,
+ }];
+ let space = RoutingSpace::new(&[]);
+ let witness = vec![start, transform(pos(0.0, 180.0)), end];
+ // Length262 + two turns48 =310. The other complete L path has
+ // the same bends/length but pays46*0.12=5.52 in shared-run cost.
+ assert!((complete_cost(&witness, before, after, &occupied) - 310.0).abs() < EPSILON);
+ let fast = fast_path(start, end, &space, &occupied, Some(before), Some(after)).unwrap();
+ assert_eq!(fast, witness);
+ assert!((complete_cost(&fast, before, after, &occupied) - 310.0).abs() < EPSILON);
+ // Exercise grid directly; public routing's valid fast path would
+ // otherwise hide a target-state ordering regression in grid search.
+ let mut budget = VIEW_SEARCH_BUDGET;
+ let (grid, exhausted) = grid_path(
+ start,
+ end,
+ &space,
+ &occupied,
+ Some(before),
+ Some(after),
+ &mut budget,
+ );
+ assert!(!exhausted);
+ let grid = grid.unwrap();
+ assert_eq!(grid, witness);
+ assert!((complete_cost(&grid, before, after, &occupied) - 310.0).abs() < EPSILON);
+ }
+ }
+
+ #[test]
+ fn unspecified_continuation_keeps_intermediate_leg_choice() {
+ let start = pos(0.0, 0.0);
+ let pin = pos(46.0, 180.0);
+ let before = pos(-18.0, 0.0);
+ let occupied = vec![Segment {
+ a: start,
+ b: pos(46.0, 0.0),
+ count: 1,
+ }];
+ let space = RoutingSpace::new(&[]);
+ let witness = vec![start, pos(46.0, 0.0), pin];
+ let fast = fast_path(start, pin, &space, &occupied, Some(before), None).unwrap();
+ assert_eq!(fast, witness);
+ // An intermediate authored pin has no known outgoing continuation:
+ // length226 + one turn24 + shared5.52 =255.52 for this leg.
+ assert!((path_cost(&fast, &occupied) - 255.52).abs() < EPSILON);
+ let mut budget = VIEW_SEARCH_BUDGET;
+ let (grid, exhausted) = grid_path(
+ start,
+ pin,
+ &space,
+ &occupied,
+ Some(before),
+ None,
+ &mut budget,
+ );
+ assert!(!exhausted);
+ assert_eq!(grid.unwrap(), witness);
+ }
+
+ #[test]
+ fn straight_and_degenerate_terminal_legs_remain_exact() {
+ let space = RoutingSpace::new(&[]);
+ let start = pos(0.0, 0.0);
+ let end = pos(46.0, 0.0);
+ for after in [pos(64.0, 0.0), pos(28.0, 0.0)] {
+ let before = pos(-18.0, 0.0);
+ let fast = fast_path(start, end, &space, &[], Some(before), Some(after)).unwrap();
+ let expected = if after.x > end.x { 82.0 } else { 130.0 };
+ assert!((complete_cost(&fast, before, after, &[]) - expected).abs() < EPSILON);
+ let mut budget = VIEW_SEARCH_BUDGET;
+ let (grid, exhausted) = grid_path(
+ start,
+ end,
+ &space,
+ &[],
+ Some(before),
+ Some(after),
+ &mut budget,
+ );
+ assert!(!exhausted);
+ assert_eq!(grid.unwrap(), fast);
+ }
+ assert_eq!(
+ fast_path(
+ start,
+ start,
+ &space,
+ &[],
+ Some(pos(-18.0, 0.0)),
+ Some(pos(18.0, 0.0))
+ )
+ .unwrap(),
+ vec![start]
+ );
+ let mut budget = VIEW_SEARCH_BUDGET;
+ let (grid, exhausted) = grid_path(
+ start,
+ start,
+ &space,
+ &[],
+ Some(pos(-18.0, 0.0)),
+ Some(pos(18.0, 0.0)),
+ &mut budget,
+ );
+ assert!(!exhausted);
+ assert_eq!(grid.unwrap(), vec![start]);
+ }
+}
diff --git a/harness/experiments/auto-layout-20260925/compare_probe.py b/harness/experiments/auto-layout-20260925/compare_probe.py
new file mode 100644
index 00000000..247befef
--- /dev/null
+++ b/harness/experiments/auto-layout-20260925/compare_probe.py
@@ -0,0 +1,56 @@
+"""Compare fixed Project/node/port/endpoint geometry and report routing changes.
+
+Usage: python compare_probe.py OLD_JSONL NEW_JSONL NEW_OUTPUT_JSON
+No layout-quality pass is inferred from equal inputs or successful execution.
+"""
+from pathlib import Path
+import hashlib
+import json
+import sys
+
+old_path, new_path, out = map(Path, sys.argv[1:])
+def read(path):
+ rows = [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines()]
+ return {row['fixture']: row for row in rows if 'fixture' in row}
+old, new = read(old_path), read(new_path)
+assert old.keys() == new.keys()
+def variants(row):
+ yield 'before', row['before_project'], row['before']
+ yield 'baseline', row['after_project'], row['after']
+ for name in ['reset_only_control', 'experimental_candidate', 'manual_planar_order_witness']:
+ if row[name] is not None:
+ yield name, row[name]['project'], row[name]['metrics']
+def endpoints(metrics):
+ return {e['id']: [e['points'][0], e['points'][-1]] for e in metrics['routed_edges']}
+keys = ['proper_crossings', 'pairwise_collinear_shared_length',
+ 'nonterminal_pairwise_shared_length', 'unrelated_node_collisions',
+ 'unrelated_edge_node_penetrations', 'label_node_collisions',
+ 'label_label_collisions', 'unrelated_label_stroke_intersections',
+ 'warning_count', 'route_bends', 'route_length', 'drawing_area']
+results = []
+for name in old:
+ lhs, rhs = list(variants(old[name])), list(variants(new[name]))
+ assert len(lhs) == len(rhs)
+ for (variant, op, om), (other, np, nm) in zip(lhs, rhs):
+ assert variant == other
+ fixed = {'project':op == np, 'nodes':om['projected_nodes'] == nm['projected_nodes'],
+ 'ports':om['projected_ports'] == nm['projected_ports'],
+ 'route_endpoints':endpoints(om) == endpoints(nm)}
+ results.append({'fixture':name, 'variant':variant, 'fixed_geometry':fixed,
+ 'metrics':{key:{'old':om[key], 'new':nm[key]} for key in keys},
+ 'new_point_contacts':nm.get('point_contacts'),
+ 'new_point_contact_details':nm.get('point_contact_details')})
+summary = {'old_sha256':hashlib.sha256(old_path.read_bytes()).hexdigest(),
+ 'new_sha256':hashlib.sha256(new_path.read_bytes()).hexdigest(),
+ 'all_projects_nodes_ports_endpoints_exact':all(all(r['fixed_geometry'].values()) for r in results),
+ 'scope':'Fixed-geometry comparison; quality vector retained, no aggregate quality pass.',
+ 'results':results}
+with out.open('x',encoding='utf-8') as f:
+ json.dump(summary,f,indent=2)
+ f.write('\n')
+print(json.dumps({k:v for k,v in summary.items() if k != 'results'}))
+for row in results:
+ if row['variant'] == 'experimental_candidate':
+ print(json.dumps({'fixture':row['fixture'], 'fixed_geometry':row['fixed_geometry'],
+ 'changed_metrics':{k:v for k,v in row['metrics'].items() if v['old'] != v['new']},
+ 'point_contacts':row['new_point_contacts']}))