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:
@@ -1082,10 +1082,11 @@ internal static partial class ElkEdgePostProcessor
|
||||
return HasClearBoundarySegments(path, nodes, sourceNodeId, targetNodeId, true, 2);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizeExitPath(
|
||||
internal static List<ElkPoint> NormalizeExitPath(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode sourceNode,
|
||||
string side)
|
||||
string side,
|
||||
bool useShortStub = false)
|
||||
{
|
||||
const double coordinateTolerance = 0.5d;
|
||||
var path = sourcePath
|
||||
@@ -1112,9 +1113,13 @@ internal static partial class ElkEdgePostProcessor
|
||||
{
|
||||
new() { X = sourceX, Y = boundaryPoint.Y },
|
||||
};
|
||||
var stubX = side == "left"
|
||||
? Math.Min(sourceX - 24d, anchor.X)
|
||||
: Math.Max(sourceX + 24d, anchor.X);
|
||||
// Short stub: 24px perpendicular exit only. Avoids long horizontals
|
||||
// that cross nodes in occupied Y-bands between source and target.
|
||||
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)
|
||||
{
|
||||
rebuilt.Add(new ElkPoint
|
||||
@@ -1152,9 +1157,11 @@ internal static partial class ElkEdgePostProcessor
|
||||
{
|
||||
new() { X = verticalBoundaryPoint.X, Y = sourceY },
|
||||
};
|
||||
var stubY = side == "top"
|
||||
? Math.Min(sourceY - 24d, verticalAnchor.Y)
|
||||
: Math.Max(sourceY + 24d, verticalAnchor.Y);
|
||||
var stubY = useShortStub
|
||||
? (side == "top" ? sourceY - 24d : sourceY + 24d)
|
||||
: (side == "top"
|
||||
? Math.Min(sourceY - 24d, verticalAnchor.Y)
|
||||
: Math.Max(sourceY + 24d, verticalAnchor.Y));
|
||||
if (Math.Abs(stubY - sourceY) > coordinateTolerance)
|
||||
{
|
||||
verticalRebuilt.Add(new ElkPoint
|
||||
@@ -1178,7 +1185,7 @@ internal static partial class ElkEdgePostProcessor
|
||||
return NormalizePathPoints(verticalRebuilt);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizeEntryPath(
|
||||
internal static List<ElkPoint> NormalizeEntryPath(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode targetNode,
|
||||
string side)
|
||||
@@ -1186,7 +1193,7 @@ internal static partial class ElkEdgePostProcessor
|
||||
return NormalizeEntryPath(sourcePath, targetNode, side, null);
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizeEntryPath(
|
||||
internal static List<ElkPoint> NormalizeEntryPath(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode targetNode,
|
||||
string side,
|
||||
@@ -4234,7 +4241,7 @@ internal static partial class ElkEdgePostProcessor
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<ElkPoint> NormalizeGatewayEntryPath(
|
||||
internal static List<ElkPoint> NormalizeGatewayEntryPath(
|
||||
IReadOnlyList<ElkPoint> sourcePath,
|
||||
ElkPositionedNode targetNode,
|
||||
ElkPoint assignedEndpoint)
|
||||
|
||||
@@ -1503,7 +1503,7 @@ internal static partial class ElkEdgePostProcessor
|
||||
return string.Join(";", path.Select(point => $"{point.X:F3},{point.Y:F3}"));
|
||||
}
|
||||
|
||||
private static bool HasAcceptableGatewayBoundaryPath(
|
||||
internal static bool HasAcceptableGatewayBoundaryPath(
|
||||
IReadOnlyList<ElkPoint> path,
|
||||
IReadOnlyCollection<ElkPositionedNode> nodes,
|
||||
string? sourceNodeId,
|
||||
|
||||
@@ -352,6 +352,17 @@ internal static partial class ElkEdgePostProcessor
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -1404,4 +1424,54 @@ internal static partial class ElkEdgePostProcessor
|
||||
simplified.Add(deduped[^1]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,29 @@ internal static partial class ElkEdgeRouterIterative
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user