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);
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)}");
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user