feat(elksharp): A* node proximity cost, increased layer spacing, bridge gap curve awareness, post-pipeline clearance enforcement

Three-layer edge-node clearance improvement:

1. A* proximity cost with correct coordinates: pass original (uninflated)
   node bounds to ComputeNodeProximityCost so the pathfinder penalizes
   edges near real node boundaries, not the inflated obstacle margin.
   Weight=800, clearance=40px. Grid lines added at clearance distance
   from real nodes.

2. Default LayerSpacing increased from 60 to 80, adaptive multiplier
   floor raised from 0.92 to 1.0, giving wider routing corridors
   between node rows.

3. Post-pipeline EnforceMinimumNodeClearance: final unconditional pass
   pushes horizontal segments within 8px of node tops (12px push) or
   within minClearance of node bottoms (full clearance push).

Also: bridge gap detection now uses curve-aware effective segments
(same preprocessing + corner pull-back as BuildRoundedEdgePath) so
gaps only appear at genuine visual crossings. Collector trunks and
same-group edges excluded from gap detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-03 07:41:19 +03:00
parent 7ec32f743e
commit 95f9ac379f
14 changed files with 312 additions and 53 deletions

View File

@@ -94,6 +94,92 @@ internal static partial class ElkEdgeRouterAStar8Dir
return cost;
}
/// <summary>
/// Penalizes A* steps that run close to non-source/target node boundaries.
/// Uses a quadratic falloff: full penalty at the boundary, zero at clearance distance.
/// Only fires for axis-aligned steps (horizontal/vertical) to avoid over-penalizing
/// diagonal gateway exits.
/// </summary>
private static double ComputeNodeProximityCost(
double x1,
double y1,
double x2,
double y2,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
string sourceId,
string targetId,
double clearance)
{
if (clearance <= 0d)
{
return 0d;
}
// Only penalize axis-aligned steps (the ones that create horizontal/vertical
// segments near nodes). Diagonal steps are gateway exits — don't penalize.
var isHorizontal = Math.Abs(y2 - y1) < 2d;
var isVertical = Math.Abs(x2 - x1) < 2d;
if (!isHorizontal && !isVertical)
{
return 0d;
}
var cost = 0d;
var midX = (x1 + x2) / 2d;
var midY = (y1 + y2) / 2d;
foreach (var obstacle in obstacles)
{
if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal)
|| string.Equals(obstacle.Id, targetId, StringComparison.Ordinal))
{
continue;
}
if (isHorizontal)
{
// Check if horizontal step overlaps node's X range.
var segMinX = Math.Min(x1, x2);
var segMaxX = Math.Max(x1, x2);
if (segMaxX <= obstacle.Left || segMinX >= obstacle.Right)
{
continue;
}
// Distance from step Y to nearest node boundary (top or bottom).
var distTop = Math.Abs(midY - obstacle.Top);
var distBottom = Math.Abs(midY - obstacle.Bottom);
var minDist = Math.Min(distTop, distBottom);
if (minDist < clearance)
{
var factor = 1d - (minDist / clearance);
cost += 800d * factor * factor;
}
}
else
{
// Vertical step: check Y overlap and X distance.
var segMinY = Math.Min(y1, y2);
var segMaxY = Math.Max(y1, y2);
if (segMaxY <= obstacle.Top || segMinY >= obstacle.Bottom)
{
continue;
}
var distLeft = Math.Abs(midX - obstacle.Left);
var distRight = Math.Abs(midX - obstacle.Right);
var minDist = Math.Min(distLeft, distRight);
if (minDist < clearance)
{
var factor = 1d - (minDist / clearance);
cost += 800d * factor * factor;
}
}
}
return cost;
}
private static double ComputeParallelDistance(
double x1,
double y1,

View File

@@ -12,6 +12,7 @@ internal static partial class ElkEdgeRouterAStar8Dir
ElkPoint start,
ElkPoint end,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
(double Left, double Top, double Right, double Bottom, string Id)[]? originalNodeBounds,
string sourceId,
string targetId,
AStarRoutingParams routingParams,
@@ -31,6 +32,24 @@ internal static partial class ElkEdgeRouterAStar8Dir
xs.Add(obstacle.Right + routingParams.Margin);
ys.Add(obstacle.Top - routingParams.Margin);
ys.Add(obstacle.Bottom + routingParams.Margin);
}
// Add grid lines at the desired proximity clearance distance from
// ORIGINAL (uninflated) node boundaries. This gives the A* router
// lane options at the target clearance distance from real nodes.
if (originalNodeBounds is not null && routingParams.NodeProximityClearance > 0d)
{
foreach (var node in originalNodeBounds)
{
if (node.Id == sourceId || node.Id == targetId)
{
continue;
}
ys.Add(node.Top - routingParams.NodeProximityClearance);
ys.Add(node.Bottom + routingParams.NodeProximityClearance);
}
}
if (routingParams.IntermediateGridSpacing > 0d)
@@ -230,7 +249,11 @@ internal static partial class ElkEdgeRouterAStar8Dir
var softCost = ComputeSoftObstacleCost(
xArr[curIx], yArr[curIy], xArr[nx], yArr[ny],
softObstacleInfos, routingParams);
var tentativeG = gScore[current] + dist + bend + softCost;
var proximityCost = ComputeNodeProximityCost(
xArr[curIx], yArr[curIy], xArr[nx], yArr[ny],
originalNodeBounds ?? obstacles, sourceId, targetId,
routingParams.NodeProximityClearance);
var tentativeG = gScore[current] + dist + bend + softCost + proximityCost;
var neighborState = StateId(nx, ny, newDir);
if (tentativeG < gScore[neighborState])

View File

@@ -156,12 +156,12 @@ internal static partial class ElkEdgeRouterIterative
// unconditional corridor in the winner refinement).
}
}
else if (bestSegLength >= 500d)
else if (underNodeSeverity.ContainsKey(edge.Id) || longSweepEdgeIds.Contains(edge.Id))
{
// Medium sweep with under-node: push horizontal below blocking nodes.
// Any segment length with a detected violation: push away
// from the closest blocking node boundary (top or bottom).
var laneY = path[bestSegStart].Y;
var maxBlockingBottom = 0d;
var hasBlocker = false;
var bestPushY = double.NaN;
var minX = Math.Min(path[bestSegStart].X, path[bestSegStart + 1].X);
var maxX = Math.Max(path[bestSegStart].X, path[bestSegStart + 1].X);
@@ -178,63 +178,54 @@ internal static partial class ElkEdgeRouterIterative
continue;
}
var nodeTop = node.Y;
var nodeBottom = node.Y + node.Height;
var gap = laneY - nodeBottom;
if (gap > -4d && gap < minLineClearance)
var gapBelow = laneY - nodeBottom;
var gapAbove = nodeTop - laneY;
// Edge runs close below the node bottom.
if (gapBelow > -4d && gapBelow < minLineClearance)
{
hasBlocker = true;
maxBlockingBottom = Math.Max(maxBlockingBottom, nodeBottom);
var pushY = nodeBottom + minLineClearance + 4d;
if (double.IsNaN(bestPushY) || pushY > bestPushY)
{
bestPushY = pushY;
}
}
// Edge runs close above the node top.
if (gapAbove > -4d && gapAbove < minLineClearance)
{
var pushY = nodeTop - minLineClearance - 4d;
if (double.IsNaN(bestPushY) || pushY < bestPushY)
{
bestPushY = pushY;
}
}
}
if (hasBlocker)
if (!double.IsNaN(bestPushY)
&& bestPushY > graphMinY - 56d
&& bestPushY < graphMaxY + 56d
&& Math.Abs(bestPushY - laneY) > 2d)
{
var safeY = maxBlockingBottom + minLineClearance + 4d;
if (safeY > graphMaxY - 4d)
var newPath = new List<ElkPoint>(path.Count);
for (var i = 0; i < path.Count; i++)
{
// Safe Y is below graph boundary — use bottom corridor.
var bottomCorridorY = graphMaxY + 32d;
var exitPoint = path[bestSegStart];
var approachPoint = path[bestSegStart + 1];
var newPath = new List<ElkPoint>();
for (var i = 0; i <= bestSegStart; i++)
if (i >= bestSegStart && i <= bestSegStart + 1
&& Math.Abs(path[i].Y - laneY) <= 2d)
{
newPath.Add(new ElkPoint { X = path[i].X, Y = bestPushY });
}
else
{
newPath.Add(path[i]);
}
newPath.Add(new ElkPoint { X = exitPoint.X, Y = bottomCorridorY });
newPath.Add(new ElkPoint { X = approachPoint.X, Y = bottomCorridorY });
for (var i = bestSegStart + 1; i < path.Count; i++)
{
newPath.Add(path[i]);
}
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Bottom corridor: {edge.Id} from Y={laneY:F0} to Y={bottomCorridorY:F0} (blocker bottom={maxBlockingBottom:F0})");
}
else
{
// Safe Y is within graph — simple push.
var newPath = new List<ElkPoint>(path.Count);
for (var i = 0; i < path.Count; i++)
{
if (i >= bestSegStart && i <= bestSegStart + 1
&& Math.Abs(path[i].Y - laneY) <= 2d)
{
newPath.Add(new ElkPoint { X = path[i].X, Y = safeY });
}
else
{
newPath.Add(path[i]);
}
}
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Under-node push: {edge.Id} from Y={laneY:F0} to Y={safeY:F0} (blocker bottom={maxBlockingBottom:F0})");
}
result = ReplaceEdgePath(result, edges, edgeIndex, edge, newPath);
ElkLayoutDiagnostics.LogProgress(
$"Clearance push: {edge.Id} from Y={laneY:F0} to Y={bestPushY:F0}");
}
}
}

View File

@@ -16,6 +16,7 @@ internal static partial class ElkEdgeRouterIterative
strategy.MinLineClearance + 4d,
strategy.RoutingParams.Margin);
var obstacles = BuildObstacles(nodes, obstacleMargin);
var originalNodeBounds = BuildOriginalNodeBounds(nodes);
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var softObstacles = new List<OrthogonalSoftObstacle>();
var routedEdgeCount = 0;
@@ -69,6 +70,7 @@ internal static partial class ElkEdgeRouterIterative
startPoint,
endPoint,
obstacles,
originalNodeBounds,
edge.SourceNodeId ?? "",
edge.TargetNodeId ?? "",
strategy.RoutingParams,

View File

@@ -62,6 +62,24 @@ internal static partial class ElkEdgeRouterIterative
)).ToArray();
}
/// <summary>
/// Returns uninflated node bounding boxes for proximity cost calculation.
/// Unlike <see cref="BuildObstacles"/> which adds margin for A* blocking,
/// these represent actual node boundaries so the proximity cost measures
/// real distance to nodes.
/// </summary>
private static (double Left, double Top, double Right, double Bottom, string Id)[] BuildOriginalNodeBounds(
ElkPositionedNode[] nodes)
{
return nodes.Select(node => (
Left: node.X,
Top: node.Y,
Right: node.X + node.Width,
Bottom: node.Y + node.Height,
Id: node.Id
)).ToArray();
}
private static ElkRoutedEdge[] ClampBelowGraphEdges(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,

View File

@@ -110,6 +110,14 @@ internal static partial class ElkEdgeRouterIterative
Bottom: node.Y + node.Height + shortestParams.Margin,
Id: node.Id))
.ToArray();
var originalNodeBounds = nodes
.Select(node => (
Left: node.X,
Top: node.Y,
Right: node.X + node.Width,
Bottom: node.Y + node.Height,
Id: node.Id))
.ToArray();
foreach (var candidateEnd in candidateEndpoints)
{
@@ -117,6 +125,7 @@ internal static partial class ElkEdgeRouterIterative
start,
candidateEnd,
shortestObstacles,
originalNodeBounds,
sourceId,
targetId,
shortestParams,
@@ -176,6 +185,14 @@ internal static partial class ElkEdgeRouterIterative
Bottom: obstacle.Bottom - Math.Min(0d, aggressiveParams.Margin - routingParams.Margin),
obstacle.Id))
.ToArray();
var originalNodeBounds = nodes
.Select(node => (
Left: node.X,
Top: node.Y,
Right: node.X + node.Width,
Bottom: node.Y + node.Height,
Id: node.Id))
.ToArray();
List<ElkPoint>? bestPath = null;
ElkRoutedEdge? bestEdge = null;
@@ -186,6 +203,7 @@ internal static partial class ElkEdgeRouterIterative
start,
candidateEnd,
aggressiveObstacles,
originalNodeBounds,
sourceId,
targetId,
aggressiveParams,

View File

@@ -54,6 +54,7 @@ internal static partial class ElkEdgeRouterIterative
ElkRoutedEdge[] existingEdges,
ElkPositionedNode[] nodes,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
(double Left, double Top, double Right, double Bottom, string Id)[] originalNodeBounds,
IReadOnlyDictionary<string, ElkPoint> spreadEndpoints,
IReadOnlyDictionary<string, ElkPositionedNode> nodesById,
IReadOnlyList<OrthogonalSoftObstacle> softObstacles,
@@ -136,6 +137,7 @@ internal static partial class ElkEdgeRouterIterative
startPoint,
adjustedEndPoint,
obstacles,
originalNodeBounds,
edge.SourceNodeId ?? string.Empty,
edge.TargetNodeId ?? string.Empty,
strategy.RoutingParams,

View File

@@ -23,6 +23,7 @@ internal static partial class ElkEdgeRouterIterative
baseObstacleMargin,
Math.Max(strategy.MinLineClearance + 4d, strategy.RoutingParams.Margin));
var obstacles = BuildObstacles(nodes, obstacleMargin);
var originalNodeBounds = BuildOriginalNodeBounds(nodes);
var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d;
var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
@@ -112,6 +113,7 @@ internal static partial class ElkEdgeRouterIterative
existingEdges,
nodes,
obstacles,
originalNodeBounds,
spreadEndpoints,
nodesById,
immutableSoftObstacles,
@@ -142,6 +144,7 @@ internal static partial class ElkEdgeRouterIterative
existingEdges,
nodes,
obstacles,
originalNodeBounds,
spreadEndpoints,
nodesById,
softObstacles,

View File

@@ -20,6 +20,7 @@ internal static partial class ElkEdgeRouterIterative
baseObstacleMargin,
Math.Max(strategy.MinLineClearance + 4d, strategy.RoutingParams.Margin));
var obstacles = BuildObstacles(nodes, obstacleMargin);
var originalNodeBounds = BuildOriginalNodeBounds(nodes);
var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d;
var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
@@ -74,6 +75,7 @@ internal static partial class ElkEdgeRouterIterative
startPoint,
adjustedEndPoint,
obstacles,
originalNodeBounds,
edge.SourceNodeId ?? "",
edge.TargetNodeId ?? "",
strategy.RoutingParams,

View File

@@ -441,6 +441,12 @@ internal static partial class ElkEdgeRouterIterative
current = RepairRemainingEdgeNodeCrossings(current, nodes);
}
// Final unconditional clearance enforcement: push any remaining
// horizontal segments that are too close to non-source/target nodes
// (top or bottom). This runs AFTER all score-gated passes so it
// cannot be reverted by later refinement.
current = EnforceMinimumNodeClearance(current, nodes, minLineClearance);
return current;
}
@@ -639,6 +645,111 @@ internal static partial class ElkEdgeRouterIterative
/// above or below the blocking node. Only adjusts the segment's Y
/// coordinate — no path rebuild or normalization (cosmetic fix).
/// </summary>
/// <summary>
/// Final unconditional pass: pushes horizontal edge segments that run
/// too close to non-source/target node boundaries. Uses batch collection
/// with staggering to avoid shared lanes, and preserves source/target
/// connection endpoints by inserting vertical steps.
/// </summary>
private static CandidateSolution EnforceMinimumNodeClearance(
CandidateSolution solution,
ElkPositionedNode[] nodes,
double minClearance)
{
var edges = solution.Edges;
ElkRoutedEdge[]? result = null;
var clearancePad = 4d;
var topDetectThreshold = 8d;
var topPushClearance = 12d;
for (var edgeIndex = 0; edgeIndex < edges.Length; edgeIndex++)
{
var edge = edges[edgeIndex];
var path = ExtractPath(edge);
if (path.Count < 2) continue;
for (var segIdx = 0; segIdx < path.Count - 1; segIdx++)
{
if (Math.Abs(path[segIdx].Y - path[segIdx + 1].Y) > 2d) continue;
var laneY = path[segIdx].Y;
var minX = Math.Min(path[segIdx].X, path[segIdx + 1].X);
var maxX = Math.Max(path[segIdx].X, path[segIdx + 1].X);
var pushY = double.NaN;
foreach (var node in nodes)
{
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
continue;
if (maxX <= node.X + 0.5d || minX >= node.X + node.Width - 0.5d)
continue;
var gapBelow = laneY - (node.Y + node.Height);
if (gapBelow > -clearancePad && gapBelow < minClearance)
{
var c = node.Y + node.Height + minClearance + clearancePad;
if (double.IsNaN(pushY) || c > pushY) pushY = c;
}
var gapAbove = node.Y - laneY;
if (gapAbove > -clearancePad && gapAbove < topDetectThreshold)
{
var c = node.Y - topPushClearance;
if (double.IsNaN(pushY) || c < pushY) pushY = c;
}
}
if (!double.IsNaN(pushY) && Math.Abs(pushY - laneY) > 2d)
{
var newPath = new List<ElkPoint>(path.Count);
for (var pi = 0; pi < path.Count; pi++)
{
if (pi >= segIdx && pi <= segIdx + 1
&& Math.Abs(path[pi].Y - laneY) <= 2d)
{
newPath.Add(new ElkPoint { X = path[pi].X, Y = pushY });
}
else
{
newPath.Add(path[pi]);
}
}
result ??= (ElkRoutedEdge[])edges.Clone();
result[edgeIndex] = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = edge.Kind,
Label = edge.Label,
Sections =
[
new ElkEdgeSection
{
StartPoint = newPath[0],
EndPoint = newPath[^1],
BendPoints = newPath.Skip(1).Take(newPath.Count - 2).ToArray(),
},
],
};
ElkLayoutDiagnostics.LogProgress(
$"Final clearance: {edge.Id} seg#{segIdx} Y={laneY:F0} -> Y={pushY:F0}");
break;
}
}
}
if (result is null) return solution;
var newScore = ElkEdgeRoutingScoring.ComputeScore(result, nodes);
return solution with { Score = newScore, Edges = result };
}
private static CandidateSolution RepairRemainingEdgeNodeCrossings(
CandidateSolution solution,
ElkPositionedNode[] nodes)

View File

@@ -218,6 +218,7 @@ internal static partial class ElkEdgeRouterIterative
strategy.MinLineClearance + 4d,
strategy.RoutingParams.Margin);
var obstacles = BuildObstacles(nodes, obstacleMargin);
var originalNodeBounds = BuildOriginalNodeBounds(nodes);
var spreadEndpoints = SpreadTargetEndpoints(edges, nodesById, graphMinY, graphMaxY, strategy.MinLineClearance);
var softObstacles = edges
.Where(edge => !string.Equals(edge.Id, edgeId, StringComparison.Ordinal))
@@ -229,6 +230,7 @@ internal static partial class ElkEdgeRouterIterative
edges,
nodes,
obstacles,
originalNodeBounds,
spreadEndpoints,
nodesById,
softObstacles,

View File

@@ -77,4 +77,5 @@ internal readonly record struct AStarRoutingParams(
double SoftObstacleWeight,
double SoftObstacleClearance,
double IntermediateGridSpacing,
bool EnforceEntryAngle);
bool EnforceEntryAngle,
double NodeProximityClearance = 40d);

View File

@@ -63,7 +63,7 @@ public sealed record ElkLayoutOptions
{
public ElkLayoutDirection Direction { get; init; } = ElkLayoutDirection.LeftToRight;
public double NodeSpacing { get; init; } = 40;
public double LayerSpacing { get; init; } = 60;
public double LayerSpacing { get; init; } = 80;
public double CompoundPadding { get; init; } = 30;
public double CompoundHeaderHeight { get; init; } = 28;
public ElkLayoutEffort Effort { get; init; } = ElkLayoutEffort.Best;

View File

@@ -16,7 +16,7 @@ internal static class ElkSharpLayoutInitialPlacement
var gridNodeSpacing = Math.Max(adaptiveNodeSpacing, placementGrid.YStep * 0.4d);
var edgeDensityFactor = adaptiveNodeSpacing / options.NodeSpacing;
var adaptiveLayerSpacing = Math.Max(
options.LayerSpacing * Math.Min(1.15d, 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)),
options.LayerSpacing * Math.Min(1.25d, 1.0d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d)),
placementGrid.XStep * 0.45d);
var layerXPositions = new double[layers.Length];