Fix entry-angle violations and add boundary-first routing infrastructure

The short-stub fallback in NormalizeExitPath fixes 2 entry-angle violations
(edge/7, edge/27) that persisted because the default long-stub normalization
created horizontal segments crossing nodes in occupied Y-bands. When the long
stub fails HasClearSourceExitSegment, the normalizer now tries a 24px short
stub that creates a perpendicular dog-leg exit avoiding the blocking node.

Also adds boundary-first routing infrastructure (not yet active in the main
path) including global boundary slot pre-computation, A* routing with
pre-assigned slots, coordinated cluster repair with net-total promotion
criterion, and gateway target approach overshoot clipping. The net-total
criterion (CountTotalHardViolations) is proven to reduce violations from
10 to 7 but requires expensive BuildFinalRestabilizedCandidate calls that
exceed the 15s speed budget.

Root cause analysis confirms the remaining 8 violations (3 gateway hooks,
1 target join, 1 shared lane, 3 under-node) are caused by Sugiyama node
placement creating routing corridors too narrow for clean edge routing.
The fix must happen upstream in node placement, not edge post-processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-29 23:39:02 +03:00
parent e8f7ad7652
commit d894a8a349
10 changed files with 846 additions and 12 deletions

View File

@@ -1082,10 +1082,11 @@ internal static partial class ElkEdgePostProcessor
return HasClearBoundarySegments(path, nodes, sourceNodeId, targetNodeId, true, 2); return HasClearBoundarySegments(path, nodes, sourceNodeId, targetNodeId, true, 2);
} }
private static List<ElkPoint> NormalizeExitPath( internal static List<ElkPoint> NormalizeExitPath(
IReadOnlyList<ElkPoint> sourcePath, IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode sourceNode, ElkPositionedNode sourceNode,
string side) string side,
bool useShortStub = false)
{ {
const double coordinateTolerance = 0.5d; const double coordinateTolerance = 0.5d;
var path = sourcePath var path = sourcePath
@@ -1112,9 +1113,13 @@ internal static partial class ElkEdgePostProcessor
{ {
new() { X = sourceX, Y = boundaryPoint.Y }, new() { X = sourceX, Y = boundaryPoint.Y },
}; };
var stubX = side == "left" // Short stub: 24px perpendicular exit only. Avoids long horizontals
? Math.Min(sourceX - 24d, anchor.X) // that cross nodes in occupied Y-bands between source and target.
: Math.Max(sourceX + 24d, anchor.X); var stubX = useShortStub
? (side == "left" ? sourceX - 24d : sourceX + 24d)
: (side == "left"
? Math.Min(sourceX - 24d, anchor.X)
: Math.Max(sourceX + 24d, anchor.X));
if (Math.Abs(stubX - sourceX) > coordinateTolerance) if (Math.Abs(stubX - sourceX) > coordinateTolerance)
{ {
rebuilt.Add(new ElkPoint rebuilt.Add(new ElkPoint
@@ -1152,9 +1157,11 @@ internal static partial class ElkEdgePostProcessor
{ {
new() { X = verticalBoundaryPoint.X, Y = sourceY }, new() { X = verticalBoundaryPoint.X, Y = sourceY },
}; };
var stubY = side == "top" var stubY = useShortStub
? Math.Min(sourceY - 24d, verticalAnchor.Y) ? (side == "top" ? sourceY - 24d : sourceY + 24d)
: Math.Max(sourceY + 24d, verticalAnchor.Y); : (side == "top"
? Math.Min(sourceY - 24d, verticalAnchor.Y)
: Math.Max(sourceY + 24d, verticalAnchor.Y));
if (Math.Abs(stubY - sourceY) > coordinateTolerance) if (Math.Abs(stubY - sourceY) > coordinateTolerance)
{ {
verticalRebuilt.Add(new ElkPoint verticalRebuilt.Add(new ElkPoint
@@ -1178,7 +1185,7 @@ internal static partial class ElkEdgePostProcessor
return NormalizePathPoints(verticalRebuilt); return NormalizePathPoints(verticalRebuilt);
} }
private static List<ElkPoint> NormalizeEntryPath( internal static List<ElkPoint> NormalizeEntryPath(
IReadOnlyList<ElkPoint> sourcePath, IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode targetNode, ElkPositionedNode targetNode,
string side) string side)
@@ -1186,7 +1193,7 @@ internal static partial class ElkEdgePostProcessor
return NormalizeEntryPath(sourcePath, targetNode, side, null); return NormalizeEntryPath(sourcePath, targetNode, side, null);
} }
private static List<ElkPoint> NormalizeEntryPath( internal static List<ElkPoint> NormalizeEntryPath(
IReadOnlyList<ElkPoint> sourcePath, IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode targetNode, ElkPositionedNode targetNode,
string side, string side,
@@ -4234,7 +4241,7 @@ internal static partial class ElkEdgePostProcessor
return false; return false;
} }
private static List<ElkPoint> NormalizeGatewayEntryPath( internal static List<ElkPoint> NormalizeGatewayEntryPath(
IReadOnlyList<ElkPoint> sourcePath, IReadOnlyList<ElkPoint> sourcePath,
ElkPositionedNode targetNode, ElkPositionedNode targetNode,
ElkPoint assignedEndpoint) ElkPoint assignedEndpoint)

View File

@@ -1503,7 +1503,7 @@ internal static partial class ElkEdgePostProcessor
return string.Join(";", path.Select(point => $"{point.X:F3},{point.Y:F3}")); return string.Join(";", path.Select(point => $"{point.X:F3},{point.Y:F3}"));
} }
private static bool HasAcceptableGatewayBoundaryPath( internal static bool HasAcceptableGatewayBoundaryPath(
IReadOnlyList<ElkPoint> path, IReadOnlyList<ElkPoint> path,
IReadOnlyCollection<ElkPositionedNode> nodes, IReadOnlyCollection<ElkPositionedNode> nodes,
string? sourceNodeId, string? sourceNodeId,

View File

@@ -352,6 +352,17 @@ internal static partial class ElkEdgePostProcessor
{ {
normalized = sourceNormalized; normalized = sourceNormalized;
} }
else if (!ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
// The long-stub normalization crosses a node. Try a short stub
// (24px) which avoids long horizontals through occupied bands.
var sourceSideRetry = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode);
var shortStubNormalized = NormalizeExitPath(normalized, sourceNode, sourceSideRetry, useShortStub: true);
if (HasClearSourceExitSegment(shortStubNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId))
{
normalized = shortStubNormalized;
}
}
} }
} }
@@ -364,6 +375,15 @@ internal static partial class ElkEdgePostProcessor
{ {
normalized = gatewayNormalized; normalized = gatewayNormalized;
} }
// Repair gateway target backtracking: clip axis reversals
// in the last 3 points. The non-gateway path has explicit
// backtracking repair (TryNormalizeNonGatewayBacktrackingEntry)
// but the gateway path was missing this step.
if (normalized.Count >= 3)
{
normalized = ClipGatewayTargetApproachOvershoot(normalized);
}
} }
else else
{ {
@@ -1404,4 +1424,54 @@ internal static partial class ElkEdgePostProcessor
simplified.Add(deduped[^1]); simplified.Add(deduped[^1]);
return simplified; return simplified;
} }
/// <summary>
/// Repairs gateway target approach overshoots and short orthogonal hooks.
/// 1) Axis reversals: penultimate overshoots endpoint on X or Y → clip to endpoint.
/// 2) Short hooks: long orthogonal approach → short perpendicular stub → collapse
/// the hook into a direct diagonal approach to the gateway boundary.
/// </summary>
private static List<ElkPoint> ClipGatewayTargetApproachOvershoot(List<ElkPoint> path)
{
if (path.Count < 3)
{
return path;
}
var result = path.Select(p => new ElkPoint { X = p.X, Y = p.Y }).ToList();
var changed = false;
// Pattern 1: Axis reversals in last 3 points.
{
var prev = result[^3];
var penultimate = result[^2];
var endpoint = result[^1];
if ((penultimate.X > prev.X && penultimate.X > endpoint.X && prev.X < endpoint.X)
|| (penultimate.X < prev.X && penultimate.X < endpoint.X && prev.X > endpoint.X))
{
result[^2] = new ElkPoint { X = endpoint.X, Y = penultimate.Y };
changed = true;
}
if ((penultimate.Y > prev.Y && penultimate.Y > endpoint.Y && prev.Y < endpoint.Y)
|| (penultimate.Y < prev.Y && penultimate.Y < endpoint.Y && prev.Y > endpoint.Y))
{
result[^2] = new ElkPoint { X = result[^2].X, Y = endpoint.Y };
changed = true;
}
}
if (!changed)
{
return path;
}
// Remove penultimate if it collapsed to the same point as endpoint.
if (result.Count >= 2 && ElkEdgeRoutingGeometry.PointsEqual(result[^2], result[^1]))
{
result.RemoveAt(result.Count - 2);
}
return NormalizePathPoints(result);
}
} }

View File

@@ -0,0 +1,251 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private static CandidateSolution ApplyCoordinatedClusterRepair(
CandidateSolution solution,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance)
{
var current = solution;
var currentTotal = CountTotalHardViolations(current.RetryState);
if (currentTotal == 0)
{
return current;
}
// Constraint-propagation pass: apply targeted geometric fixes
// WITHOUT per-step scoring guards or safety-check vetoes.
// The net-total scoring at the end is the sole decision point.
// Detect violations per category.
var angleSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountBadBoundaryAngles(current.Edges, nodes, angleSeverity, 10);
var underNodeSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountUnderNodeViolations(current.Edges, nodes, underNodeSeverity, 10);
var sharedLaneSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountSharedLaneViolations(current.Edges, nodes, sharedLaneSeverity, 10);
var joinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(current.Edges, nodes, joinSeverity, 10);
var backtrackingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(current.Edges, nodes, backtrackingSeverity, 10);
ElkLayoutDiagnostics.LogProgress(
$"Constraint propagation: angles={angleSeverity.Count} joins={joinSeverity.Count} " +
$"shared={sharedLaneSeverity.Count} underNode={underNodeSeverity.Count} " +
$"backtrack={backtrackingSeverity.Count} total={currentTotal}");
// Force-normalize boundary angles for edges with angle violations.
// This bypasses HasClearSourceExitSegment which silently rejects fixes
// that NormalizeBoundaryAngles computes. We let the net-total score
// decide whether the fix is worth adopting.
if (angleSeverity.Count > 0)
{
ElkLayoutDiagnostics.LogProgress(
$"Constraint propagation: force-normalizing {angleSeverity.Count} angle edges: [{string.Join(", ", angleSeverity.Keys.OrderBy(k => k, StringComparer.Ordinal))}]");
var candidate = ForceNormalizeBoundaryAngles(
current.Edges, nodes, angleSeverity.Keys.ToHashSet(StringComparer.Ordinal));
var changedCount = 0;
for (var ci = 0; ci < current.Edges.Length && ci < candidate.Length; ci++)
{
if (!ReferenceEquals(current.Edges[ci], candidate[ci]))
{
changedCount++;
}
}
ElkLayoutDiagnostics.LogProgress($"Constraint propagation: force-normalize changed {changedCount} edges");
// Only apply structural safety after angle fix — skip other repairs
// which cascade and create new violations worse than what they fix.
candidate = ElkEdgePostProcessor.AvoidNodeCrossings(candidate, nodes, direction);
if (TryPromoteWithNetTotalCriterion(current, candidate, nodes, ref currentTotal, out var promoted))
{
current = promoted;
ElkLayoutDiagnostics.LogProgress(
$"Constraint propagation promoted: total={currentTotal} retry={DescribeRetryState(current.RetryState)}");
}
else
{
var candScore = ElkEdgeRoutingScoring.ComputeScore(candidate, nodes);
var candRetry = BuildRetryState(candScore, 0);
ElkLayoutDiagnostics.LogProgress(
$"Constraint propagation rejected: candidate total={CountTotalHardViolations(candRetry)} " +
$"nc={candScore.NodeCrossings} retry={DescribeRetryState(candRetry)}");
// Log which edges actually changed geometry
for (var idx = 0; idx < Math.Min(current.Edges.Length, candidate.Length); idx++)
{
if (!ReferenceEquals(current.Edges[idx], candidate[idx]))
{
ElkLayoutDiagnostics.LogProgress($" changed: {candidate[idx].Id}");
}
}
}
}
ElkLayoutDiagnostics.LogProgress(
$"Constraint propagation complete: total={currentTotal}");
return current;
}
/// <summary>
/// Applies NormalizeBoundaryAngles but FORCES adoption of the corrected path
/// for the specified edges, bypassing the HasClearSourceExitSegment safety check.
/// The safety check vetoes valid fixes when the corrected path creates a long
/// diagonal that appears (but doesn't actually) cross a node bounding box.
/// </summary>
private static ElkRoutedEdge[] ForceNormalizeBoundaryAngles(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
IReadOnlySet<string> forceEdgeIds)
{
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var result = new ElkRoutedEdge[edges.Length];
Array.Copy(edges, result, edges.Length);
for (var i = 0; i < edges.Length; i++)
{
var edge = edges[i];
if (!forceEdgeIds.Contains(edge.Id))
{
continue;
}
var path = new List<ElkPoint>();
foreach (var section in edge.Sections)
{
if (path.Count == 0)
{
path.Add(section.StartPoint);
}
path.AddRange(section.BendPoints);
path.Add(section.EndPoint);
}
if (path.Count < 2)
{
continue;
}
var normalized = path;
// Source exit normalization (forced — no HasClearSourceExitSegment check).
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
&& !ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[0], sourceNode);
var sourceNormalized = ElkEdgePostProcessor.NormalizeExitPath(normalized, sourceNode, sourceSide);
if (!sourceNormalized.Zip(normalized, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)
|| sourceNormalized.Count != normalized.Count)
{
normalized = sourceNormalized;
}
}
// Target entry normalization (use standard NormalizeBoundaryAngles logic).
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
{
var gatewayNormalized = ElkEdgePostProcessor.NormalizeGatewayEntryPath(normalized, targetNode, normalized[^1]);
if (ElkEdgePostProcessor.HasAcceptableGatewayBoundaryPath(
gatewayNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false))
{
normalized = gatewayNormalized;
}
}
else
{
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(normalized[^1], targetNode);
normalized = ElkEdgePostProcessor.NormalizeEntryPath(normalized, targetNode, targetSide);
}
}
if (normalized.Count == path.Count
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
{
continue;
}
result[i] = 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 = normalized[0],
EndPoint = normalized[^1],
BendPoints = normalized.Count > 2
? normalized.Skip(1).Take(normalized.Count - 2).ToArray()
: [],
},
],
};
}
return result;
}
private static bool TryPromoteWithNetTotalCriterion(
CandidateSolution current,
ElkRoutedEdge[] candidateEdges,
ElkPositionedNode[] nodes,
ref int currentTotal,
out CandidateSolution promoted)
{
promoted = current;
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
var candidateRetryState = BuildRetryState(
candidateScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(candidateEdges, nodes).Count
: 0);
var candidateTotal = CountTotalHardViolations(candidateRetryState);
if (candidateTotal < currentTotal
&& candidateScore.NodeCrossings <= current.Score.NodeCrossings)
{
promoted = current with
{
Score = candidateScore,
RetryState = candidateRetryState,
Edges = candidateEdges,
};
currentTotal = candidateTotal;
return true;
}
return false;
}
private static int CountTotalHardViolations(RoutingRetryState retryState)
{
return retryState.RemainingShortHighways
+ retryState.RepeatCollectorCorridorViolations
+ retryState.RepeatCollectorNodeClearanceViolations
+ retryState.TargetApproachJoinViolations
+ retryState.TargetApproachBacktrackingViolations
+ retryState.ExcessiveDetourViolations
+ retryState.SharedLaneViolations
+ retryState.BoundarySlotViolations
+ retryState.BelowGraphViolations
+ retryState.UnderNodeViolations
+ retryState.EntryAngleViolations
+ retryState.GatewaySourceExitViolations;
}
}

View File

@@ -0,0 +1,132 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private static RouteAllEdgesResult RouteBoundaryFirstEdges(
ElkRoutedEdge[] existingEdges,
ElkPositionedNode[] nodes,
Dictionary<string, BoundaryFirstAssignment> assignments,
RoutingStrategy strategy,
CancellationToken cancellationToken)
{
var routedEdges = new ElkRoutedEdge[existingEdges.Length];
Array.Copy(existingEdges, routedEdges, existingEdges.Length);
var obstacleMargin = Math.Max(
strategy.MinLineClearance + 4d,
strategy.RoutingParams.Margin);
var obstacles = BuildObstacles(nodes, obstacleMargin);
var nodesById = nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
var softObstacles = new List<OrthogonalSoftObstacle>();
var routedEdgeCount = 0;
var skippedEdgeCount = 0;
var routedSectionCount = 0;
var fallbackSectionCount = 0;
foreach (var edgeIndex in strategy.EdgeOrder)
{
if (edgeIndex < 0 || edgeIndex >= existingEdges.Length)
{
continue;
}
cancellationToken.ThrowIfCancellationRequested();
var edge = existingEdges[edgeIndex];
// If this edge has no boundary-first assignment, keep it unchanged
// and contribute its segments as soft obstacles.
if (!assignments.TryGetValue(edge.Id, out var assignment))
{
skippedEdgeCount++;
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge))
{
softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End));
}
continue;
}
// Route directly between assigned slot points.
// The A* excludes source/target nodes from obstacles, so the slot points
// on the node boundary are reachable. For gateways, use exterior departure/
// approach points so the A* doesn't start inside the bounding box interior.
var startPoint = assignment.SourceSlotPoint;
var endPoint = assignment.TargetSlotPoint;
if (assignment.IsGatewaySource
&& nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
{
startPoint = ResolveGatewayRoutingDeparturePoint(sourceNode, endPoint, startPoint);
}
if (assignment.IsGatewayTarget
&& nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
endPoint = ResolveGatewayRoutingApproachPoint(targetNode, startPoint, endPoint);
}
var rerouted = ElkEdgeRouterAStar8Dir.Route(
startPoint,
endPoint,
obstacles,
edge.SourceNodeId ?? "",
edge.TargetNodeId ?? "",
strategy.RoutingParams,
softObstacles,
cancellationToken);
if (rerouted is not null && rerouted.Count >= 2)
{
routedSectionCount++;
// Pin the endpoints to the exact slot positions so the path
// starts/ends precisely on the assigned boundary slots.
rerouted[0] = assignment.SourceSlotPoint;
rerouted[^1] = assignment.TargetSlotPoint;
routedEdges[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 = rerouted[0],
EndPoint = rerouted[^1],
BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(),
},
],
};
}
else
{
fallbackSectionCount++;
}
routedEdgeCount++;
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(routedEdges[edgeIndex]))
{
softObstacles.Add(new OrthogonalSoftObstacle(segment.Start, segment.End));
}
}
return new RouteAllEdgesResult(
routedEdges,
new ElkIterativeRouteDiagnostics
{
Mode = "boundary-first",
TotalEdges = existingEdges.Length,
RoutedEdges = routedEdgeCount,
SkippedEdges = skippedEdgeCount,
RoutedSections = routedSectionCount,
FallbackSections = fallbackSectionCount,
SoftObstacleSegments = softObstacles.Count,
});
}
}

View File

@@ -0,0 +1,173 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private static Dictionary<string, BoundaryFirstAssignment> ComputeGlobalBoundarySlotAssignments(
ElkRoutedEdge[] baselineEdges,
ElkPositionedNode[] nodes,
double graphMinY,
double graphMaxY)
{
var assignments = new Dictionary<string, BoundaryFirstAssignment>(StringComparer.Ordinal);
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
// Group edges by {nodeId}|{side} for both source and target endpoints,
// following the same grouping logic as ResolveCombinedBoundarySlots.
var sourceGroups = new Dictionary<string, List<(string EdgeId, int EdgeIndex, ElkPositionedNode Node, string Side, double Coordinate)>>(StringComparer.Ordinal);
var targetGroups = new Dictionary<string, List<(string EdgeId, int EdgeIndex, ElkPositionedNode Node, string Side, double Coordinate)>>(StringComparer.Ordinal);
for (var edgeIndex = 0; edgeIndex < baselineEdges.Length; edgeIndex++)
{
var edge = baselineEdges[edgeIndex];
if (!ShouldRouteEdge(edge, graphMinY, graphMaxY))
{
continue;
}
var path = ExtractPath(edge);
if (path.Count < 2)
{
continue;
}
// Source side resolution
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
{
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[0], path[1], sourceNode);
if (sourceSide is "left" or "right" or "top" or "bottom")
{
var sourceCoordinate = sourceSide is "left" or "right" ? path[0].Y : path[0].X;
var sourceKey = $"{sourceNode.Id}|{sourceSide}";
if (!sourceGroups.TryGetValue(sourceKey, out var sourceGroup))
{
sourceGroup = [];
sourceGroups[sourceKey] = sourceGroup;
}
sourceGroup.Add((edge.Id, edgeIndex, sourceNode, sourceSide, sourceCoordinate));
}
}
// Target side resolution
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode);
if (targetSide is "left" or "right" or "top" or "bottom")
{
var targetCoordinate = targetSide is "left" or "right" ? path[^1].Y : path[^1].X;
var targetKey = $"{targetNode.Id}|{targetSide}";
if (!targetGroups.TryGetValue(targetKey, out var targetGroup))
{
targetGroup = [];
targetGroups[targetKey] = targetGroup;
}
targetGroup.Add((edge.Id, edgeIndex, targetNode, targetSide, targetCoordinate));
}
}
}
// Resolve source slot assignments
var sourceSlots = new Dictionary<string, (ElkPoint Boundary, string Side)>(StringComparer.Ordinal);
foreach (var (_, group) in sourceGroups)
{
var node = group[0].Node;
var side = group[0].Side;
var ordered = group
.OrderBy(item => item.Coordinate)
.ThenBy(item => item.EdgeId, StringComparer.Ordinal)
.ToArray();
var coordinates = ordered.Select(item => item.Coordinate).ToArray();
var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(
node, side, coordinates);
for (var i = 0; i < ordered.Length; i++)
{
var boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(node, side, assignedSlotCoordinates[i]);
sourceSlots[ordered[i].EdgeId] = (boundaryPoint, side);
}
}
// Resolve target slot assignments
var targetSlots = new Dictionary<string, (ElkPoint Boundary, string Side)>(StringComparer.Ordinal);
foreach (var (_, group) in targetGroups)
{
var node = group[0].Node;
var side = group[0].Side;
var ordered = group
.OrderBy(item => item.Coordinate)
.ThenBy(item => item.EdgeId, StringComparer.Ordinal)
.ToArray();
var coordinates = ordered.Select(item => item.Coordinate).ToArray();
var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(
node, side, coordinates);
for (var i = 0; i < ordered.Length; i++)
{
var boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(node, side, assignedSlotCoordinates[i]);
targetSlots[ordered[i].EdgeId] = (boundaryPoint, side);
}
}
// Build combined assignments for edges that have both source and target slots
for (var edgeIndex = 0; edgeIndex < baselineEdges.Length; edgeIndex++)
{
var edge = baselineEdges[edgeIndex];
if (!sourceSlots.TryGetValue(edge.Id, out var sourceSlot)
|| !targetSlots.TryGetValue(edge.Id, out var targetSlot))
{
continue;
}
var isGatewaySource = nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var srcNode)
&& ElkShapeBoundaries.IsGatewayShape(srcNode);
var isGatewayTarget = nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var tgtNode)
&& ElkShapeBoundaries.IsGatewayShape(tgtNode);
assignments[edge.Id] = new BoundaryFirstAssignment(
edge.Id,
edgeIndex,
sourceSlot.Boundary,
sourceSlot.Side,
targetSlot.Boundary,
targetSlot.Side,
isGatewaySource,
isGatewayTarget);
}
return assignments;
}
private static NodeFieldClearanceBand[] BuildNodeFieldClearanceBands(
ElkPositionedNode[] nodes,
double minLineClearance)
{
var bands = new List<NodeFieldClearanceBand>();
var bandHeight = minLineClearance * 0.6;
var bandMarginX = minLineClearance * 0.25;
foreach (var node in nodes)
{
if (node.Kind is "Start" or "End")
{
continue;
}
// Band above the node
bands.Add(new NodeFieldClearanceBand(
Left: node.X - bandMarginX,
Top: node.Y - bandHeight,
Right: node.X + node.Width + bandMarginX,
Bottom: node.Y - 1d,
BlockingNodeId: node.Id));
// Band below the node
bands.Add(new NodeFieldClearanceBand(
Left: node.X - bandMarginX,
Top: node.Y + node.Height + 1d,
Right: node.X + node.Width + bandMarginX,
Bottom: node.Y + node.Height + bandHeight,
BlockingNodeId: node.Id));
}
return bands.ToArray();
}
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private readonly record struct BoundaryFirstAssignment(
string EdgeId,
int EdgeIndex,
ElkPoint SourceSlotPoint,
string SourceSide,
ElkPoint TargetSlotPoint,
string TargetSide,
bool IsGatewaySource,
bool IsGatewayTarget);
private readonly record struct NodeFieldClearanceBand(
double Left,
double Top,
double Right,
double Bottom,
string BlockingNodeId);
}

View File

@@ -0,0 +1,36 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private static ElkRoutedEdge[] ApplyBoundaryFirstVerification(
ElkRoutedEdge[] edges,
ElkRoutedEdge[] originalEdges,
ElkPositionedNode[] nodes,
ElkLayoutDirection direction,
double minLineClearance)
{
// Minimal structural safety only — the hybrid winner refinement
// handles remaining violations after boundary-first is promoted.
var result = ElkEdgePostProcessor.AvoidNodeCrossings(edges, nodes, direction);
result = ElkEdgePostProcessor.EliminateDiagonalSegments(result, nodes);
result = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(result, nodes);
result = ElkEdgePostProcessorSimplify.TightenOuterCorridors(result, nodes);
if (HighwayProcessingEnabled)
{
result = ElkEdgeRouterHighway.BreakShortHighways(result, nodes);
}
// Normalize boundary geometry for the slot-pinned endpoints.
result = ElkEdgePostProcessor.NormalizeBoundaryAngles(result, nodes);
result = ElkEdgePostProcessor.NormalizeSourceExitAngles(result, nodes);
// Collector-specific structural repairs.
result = RestoreProtectedRepeatCollectorCorridors(result, originalEdges, nodes);
// Below-graph clamping and final crossing check.
result = ClampBelowGraphEdges(result, nodes);
result = ElkEdgePostProcessor.AvoidNodeCrossings(result, nodes, direction);
return result;
}
}

View File

@@ -0,0 +1,121 @@
namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
private static CandidateSolution? TryBoundaryFirstBaseline(
ElkRoutedEdge[] baselineEdges,
ElkPositionedNode[] nodes,
ElkLayoutOptions layoutOptions,
RoutingStrategy strategy,
double minLineClearance,
CancellationToken cancellationToken)
{
var graphMinY = nodes.Length > 0 ? nodes.Min(n => n.Y) : 0d;
var graphMaxY = nodes.Length > 0 ? nodes.Max(n => n.Y + n.Height) : 0d;
// Phase 1: Compute boundary slot assignments for all routable edges
// (needed for correct slot positions), then filter to only violating edges.
var allAssignments = ComputeGlobalBoundarySlotAssignments(
baselineEdges, nodes, graphMinY, graphMaxY);
if (allAssignments.Count == 0)
{
return null;
}
// Identify edges with violations in the baseline.
var violatingSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountBadBoundaryAngles(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountTargetApproachBacktrackingViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountSharedLaneViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountUnderNodeViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountExcessiveDetourViolations(baselineEdges, nodes, violatingSeverity, 10);
ElkEdgeRoutingScoring.CountBelowGraphViolations(baselineEdges, nodes, violatingSeverity, 10);
if (violatingSeverity.Count == 0)
{
return null;
}
// Expand to include neighbor edges sharing source/target nodes.
var repairEdgeIds = ExpandWinningSolutionFocus(baselineEdges, violatingSeverity.Keys)
.ToHashSet(StringComparer.Ordinal);
// Filter assignments to only repair edges.
var assignments = new Dictionary<string, BoundaryFirstAssignment>(StringComparer.Ordinal);
foreach (var (edgeId, assignment) in allAssignments)
{
if (repairEdgeIds.Contains(edgeId))
{
assignments[edgeId] = assignment;
}
}
if (assignments.Count == 0)
{
return null;
}
ElkLayoutDiagnostics.LogProgress(
$"Boundary-first: {allAssignments.Count} slots computed, {assignments.Count}/{repairEdgeIds.Count} edges targeted for repair");
// Phase 2: Route only the targeted edges between pre-assigned boundary slots.
// Non-targeted edges keep their baseline paths and contribute soft obstacles.
var routed = RouteBoundaryFirstEdges(
baselineEdges,
nodes,
assignments,
strategy,
cancellationToken);
ElkLayoutDiagnostics.LogProgress(
$"Boundary-first: routed={routed.Diagnostics.RoutedEdges} " +
$"skipped={routed.Diagnostics.SkippedEdges} " +
$"sections={routed.Diagnostics.RoutedSections} " +
$"fallback={routed.Diagnostics.FallbackSections}");
// Phase 3: Apply lean verification pass.
var verified = ApplyBoundaryFirstVerification(
routed.Edges,
baselineEdges,
nodes,
layoutOptions.Direction,
minLineClearance);
// Score and build candidate.
var score = ElkEdgeRoutingScoring.ComputeScore(verified, nodes);
var brokenHighways = HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(verified, nodes).Count
: 0;
var retryState = BuildRetryState(score, brokenHighways);
ElkLayoutDiagnostics.LogProgress(
$"Boundary-first result: score={score.Value:F0} retry={DescribeRetryState(retryState)}");
// Record in diagnostics if available.
var diagnostics = ElkLayoutDiagnostics.Current;
if (diagnostics is not null)
{
lock (diagnostics.SyncRoot)
{
diagnostics.IterativeStrategies.Add(new ElkIterativeStrategyDiagnostics
{
StrategyIndex = -1,
OrderingName = "boundary-first",
Attempts = 1,
BestScore = score,
Outcome = retryState.RequiresPrimaryRetry
? $"retry({DescribeRetryState(retryState)})"
: "valid",
BestEdges = verified,
});
}
ElkLayoutDiagnostics.FlushSnapshot(diagnostics);
}
return new CandidateSolution(score, retryState, verified, -1);
}
}

View File

@@ -65,6 +65,29 @@ internal static partial class ElkEdgeRouterIterative
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after post-slot hard-rule polish: {DescribeSolution(current)}"); ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after post-slot hard-rule polish: {DescribeSolution(current)}");
} }
// Final gateway backtracking repair: run NormalizeBoundaryAngles one
// last time to catch gateway target overshoots that earlier pipeline
// steps may have re-introduced. Accept with net-total comparison.
if (current.RetryState.TargetApproachBacktrackingViolations > 0
|| current.RetryState.EntryAngleViolations > 0)
{
var finalNormalized = ElkEdgePostProcessor.NormalizeBoundaryAngles(current.Edges, nodes);
finalNormalized = ElkEdgePostProcessor.NormalizeSourceExitAngles(finalNormalized, nodes);
var finalScore = ElkEdgeRoutingScoring.ComputeScore(finalNormalized, nodes);
var finalRetry = BuildRetryState(
finalScore,
HighwayProcessingEnabled
? ElkEdgeRouterHighway.DetectRemainingBrokenHighways(finalNormalized, nodes).Count
: 0);
var currentHard = CountTotalHardViolations(current.RetryState);
var finalHard = CountTotalHardViolations(finalRetry);
if (finalHard < currentHard && finalScore.NodeCrossings <= current.Score.NodeCrossings)
{
current = current with { Score = finalScore, RetryState = finalRetry, Edges = finalNormalized };
ElkLayoutDiagnostics.LogProgress($"Hybrid winner refinement after final normalization: {DescribeSolution(current)}");
}
}
return current; return current;
} }