- Fix target-join (edge/4+edge/17): gateway face overflow redirect to left tip - Fix under-node (edge/14,15,20): push-first corridor reroute instead of top corridor - Fix boundary-slots (4->0): snap after gateway polish reordering - Fix gateway corner diagonals (2->0): post-pipeline straightening pass - Fix gateway interior adjacent: polygon-aware IsInsideNodeShapeInterior - Fix gateway source face mismatch (2->0): per-edge redirect with lenient validation - Fix gateway source scoring (5->0): per-edge scoring candidate application - Fix edge-node crossing (1->0): push horizontal segment above blocking node - Decompose 7 oversized files (~20K lines) into 55+ partials under 400 lines each - Archive sprints 004 (document cleanup), 005 (decomposition), 007 (render speed) All 44+ document-processing artifact assertions pass. Hybrid deterministic mode documented as recommended path for LeftToRight layouts. Tests verified: StraightExit 2/2, BoundarySlotOffenders 2/2, HybridDeterministicMode 3/3, DocumentProcessingWorkflow artifact 1/1. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
319 lines
12 KiB
C#
319 lines
12 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgePostProcessor
|
|
{
|
|
private static List<ElkPoint>? TryBuildGatewaySourceBoundarySlotSkirtCandidate(
|
|
IReadOnlyList<ElkPoint> currentPath,
|
|
ElkPositionedNode sourceNode,
|
|
ElkPoint boundaryPoint,
|
|
IReadOnlyCollection<ElkPositionedNode> nodes,
|
|
string? sourceNodeId,
|
|
string? targetNodeId,
|
|
double obstaclePadding)
|
|
{
|
|
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
|
|
|| currentPath.Count < 3)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(currentPath, sourceNode);
|
|
var preferredContinuationIndex = FindGatewaySourceCurlRecoveryIndex(currentPath, firstExteriorIndex)
|
|
?? FindPreferredGatewayExitContinuationIndex(currentPath, sourceNode, firstExteriorIndex);
|
|
var candidateIndices = new HashSet<int>
|
|
{
|
|
Math.Clamp(currentPath.Count - 2, 1, currentPath.Count - 1),
|
|
Math.Clamp(preferredContinuationIndex, 1, currentPath.Count - 1),
|
|
};
|
|
|
|
List<ElkPoint>? bestCandidate = null;
|
|
List<ElkPoint>? bestLaneRejoinCandidate = null;
|
|
var bestScore = double.PositiveInfinity;
|
|
var bestLaneRejoinScore = double.PositiveInfinity;
|
|
foreach (var continuationIndex in candidateIndices.OrderBy(index => index))
|
|
{
|
|
var continuationPoint = currentPath[continuationIndex];
|
|
var repairCandidates = new List<List<ElkPoint>>();
|
|
var laneRejoinCandidate = TryBuildGatewaySourceBoundarySlotLaneRejoinCandidate(
|
|
currentPath,
|
|
sourceNode,
|
|
boundaryPoint,
|
|
continuationPoint,
|
|
continuationIndex,
|
|
nodes,
|
|
sourceNodeId,
|
|
targetNodeId);
|
|
if (laneRejoinCandidate is not null && PathChanged(currentPath, laneRejoinCandidate))
|
|
{
|
|
var laneRejoinScore = ComputePathLength(laneRejoinCandidate) + (Math.Max(0, laneRejoinCandidate.Count - 2) * 4d);
|
|
if (laneRejoinScore < bestLaneRejoinScore)
|
|
{
|
|
bestLaneRejoinScore = laneRejoinScore;
|
|
bestLaneRejoinCandidate = laneRejoinCandidate;
|
|
}
|
|
}
|
|
|
|
var continuationAnchoredCandidate = BuildGatewaySourceRepairPath(
|
|
currentPath,
|
|
sourceNode,
|
|
boundaryPoint,
|
|
continuationPoint,
|
|
continuationIndex,
|
|
continuationPoint,
|
|
nodes,
|
|
sourceNodeId,
|
|
targetNodeId);
|
|
if (PathChanged(currentPath, continuationAnchoredCandidate))
|
|
{
|
|
repairCandidates.Add(continuationAnchoredCandidate);
|
|
}
|
|
|
|
var prefixCandidate = TryBuildLocalObstacleSkirtBoundaryShortcut(
|
|
currentPath,
|
|
boundaryPoint,
|
|
continuationPoint,
|
|
nodes,
|
|
sourceNodeId,
|
|
targetNodeId,
|
|
targetNode: null,
|
|
obstaclePadding);
|
|
if (prefixCandidate is not null && prefixCandidate.Count >= 2)
|
|
{
|
|
var rebuilt = new List<ElkPoint>(prefixCandidate);
|
|
for (var i = continuationIndex + 1; i < currentPath.Count; i++)
|
|
{
|
|
var point = currentPath[i];
|
|
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], point))
|
|
{
|
|
rebuilt.Add(new ElkPoint { X = point.X, Y = point.Y });
|
|
}
|
|
}
|
|
|
|
var skirtCandidate = NormalizePathPoints(rebuilt);
|
|
if (PathChanged(currentPath, skirtCandidate))
|
|
{
|
|
repairCandidates.Add(skirtCandidate);
|
|
}
|
|
}
|
|
|
|
foreach (var candidate in repairCandidates)
|
|
{
|
|
var score = ComputePathLength(candidate) + (Math.Max(0, candidate.Count - 2) * 4d);
|
|
if (score >= bestScore)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bestScore = score;
|
|
bestCandidate = candidate;
|
|
}
|
|
}
|
|
|
|
return bestLaneRejoinCandidate ?? bestCandidate;
|
|
}
|
|
|
|
private static List<ElkPoint>? TryBuildGatewaySourceBoundarySlotLaneRejoinCandidate(
|
|
IReadOnlyList<ElkPoint> currentPath,
|
|
ElkPositionedNode sourceNode,
|
|
ElkPoint boundaryPoint,
|
|
ElkPoint continuationPoint,
|
|
int continuationIndex,
|
|
IReadOnlyCollection<ElkPositionedNode> nodes,
|
|
string? sourceNodeId,
|
|
string? targetNodeId)
|
|
{
|
|
if (!ElkShapeBoundaries.IsGatewayShape(sourceNode)
|
|
|| currentPath.Count < 3)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var boundarySide = ElkEdgeRoutingGeometry.ResolveBoundarySide(boundaryPoint, sourceNode);
|
|
var padding = 8d;
|
|
ElkPoint rejoinExterior;
|
|
switch (boundarySide)
|
|
{
|
|
case "left":
|
|
rejoinExterior = new ElkPoint { X = sourceNode.X - padding, Y = continuationPoint.Y };
|
|
break;
|
|
case "right":
|
|
rejoinExterior = new ElkPoint { X = sourceNode.X + sourceNode.Width + padding, Y = continuationPoint.Y };
|
|
break;
|
|
case "top":
|
|
rejoinExterior = new ElkPoint { X = continuationPoint.X, Y = sourceNode.Y - padding };
|
|
break;
|
|
case "bottom":
|
|
rejoinExterior = new ElkPoint { X = continuationPoint.X, Y = sourceNode.Y + sourceNode.Height + padding };
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
if (ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior(sourceNode, rejoinExterior)
|
|
|| !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(sourceNode, boundaryPoint, rejoinExterior))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var rebuilt = new List<ElkPoint> { boundaryPoint };
|
|
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], rejoinExterior))
|
|
{
|
|
rebuilt.Add(rejoinExterior);
|
|
}
|
|
|
|
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], continuationPoint))
|
|
{
|
|
rebuilt.Add(continuationPoint);
|
|
}
|
|
|
|
for (var i = continuationIndex + 1; i < currentPath.Count; i++)
|
|
{
|
|
var point = currentPath[i];
|
|
if (!ElkEdgeRoutingGeometry.PointsEqual(rebuilt[^1], point))
|
|
{
|
|
rebuilt.Add(new ElkPoint { X = point.X, Y = point.Y });
|
|
}
|
|
}
|
|
|
|
var candidate = NormalizePathPoints(rebuilt);
|
|
if (!PathChanged(currentPath, candidate)
|
|
|| HasNodeObstacleCrossing(candidate, nodes, sourceNodeId, targetNodeId)
|
|
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)
|
|
|| !HasCleanGatewaySourceBandPath(candidate, sourceNode))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
private static bool TrySelectImprovedBoundarySlotSourceCandidate(
|
|
ElkRoutedEdge[] edges,
|
|
ElkRoutedEdge[] processedEdges,
|
|
int edgeIndex,
|
|
ElkRoutedEdge edge,
|
|
IReadOnlyList<ElkPoint> currentPath,
|
|
IReadOnlyCollection<List<ElkPoint>> candidates,
|
|
IReadOnlyCollection<ElkPositionedNode> nodes,
|
|
out List<ElkPoint> selectedPath)
|
|
{
|
|
selectedPath = [];
|
|
if (candidates.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var baselineLayout = BuildBoundarySlotEvaluationLayout(
|
|
edges,
|
|
processedEdges,
|
|
edgeIndex,
|
|
BuildSingleSectionEdge(edge, currentPath));
|
|
var baselineScore = ElkEdgeRoutingScoring.ComputeScore(baselineLayout, nodes);
|
|
var bestScore = baselineScore;
|
|
List<ElkPoint>? bestPath = null;
|
|
var seenSignatures = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
foreach (var candidate in candidates)
|
|
{
|
|
var normalizedCandidate = NormalizePathPoints(candidate);
|
|
if (!PathChanged(currentPath, normalizedCandidate)
|
|
|| !seenSignatures.Add(CreatePathSignature(normalizedCandidate)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var candidateLayout = (ElkRoutedEdge[])baselineLayout.Clone();
|
|
candidateLayout[edgeIndex] = BuildSingleSectionEdge(edge, normalizedCandidate);
|
|
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateLayout, nodes);
|
|
if (!IsBetterBoundarySlotSourceCandidate(baselineScore, bestScore, candidateScore))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bestScore = candidateScore;
|
|
bestPath = normalizedCandidate;
|
|
}
|
|
|
|
if (bestPath is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
selectedPath = bestPath;
|
|
return true;
|
|
}
|
|
|
|
private static ElkRoutedEdge[] BuildBoundarySlotEvaluationLayout(
|
|
ElkRoutedEdge[] edges,
|
|
ElkRoutedEdge[] processedEdges,
|
|
int edgeIndex,
|
|
ElkRoutedEdge currentEdge)
|
|
{
|
|
var layout = new ElkRoutedEdge[edges.Length];
|
|
for (var i = 0; i < edges.Length; i++)
|
|
{
|
|
layout[i] = i < edgeIndex ? processedEdges[i] : edges[i];
|
|
}
|
|
|
|
layout[edgeIndex] = currentEdge;
|
|
return layout;
|
|
}
|
|
|
|
private static bool IsBetterBoundarySlotSourceCandidate(
|
|
EdgeRoutingScore baselineScore,
|
|
EdgeRoutingScore currentBestScore,
|
|
EdgeRoutingScore candidateScore)
|
|
{
|
|
if (candidateScore.BoundarySlotViolations >= baselineScore.BoundarySlotViolations
|
|
|| HasBlockingBoundarySlotSourceCandidateRegression(
|
|
baselineScore,
|
|
candidateScore,
|
|
allowTemporarySoftTrade: candidateScore.BoundarySlotViolations < baselineScore.BoundarySlotViolations))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (currentBestScore.BoundarySlotViolations >= baselineScore.BoundarySlotViolations)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (candidateScore.BoundarySlotViolations != currentBestScore.BoundarySlotViolations)
|
|
{
|
|
return candidateScore.BoundarySlotViolations < currentBestScore.BoundarySlotViolations;
|
|
}
|
|
|
|
if (candidateScore.Value > currentBestScore.Value + 0.5d)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (candidateScore.Value + 0.5d < currentBestScore.Value)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return candidateScore.TotalPathLength < currentBestScore.TotalPathLength - 0.5d;
|
|
}
|
|
|
|
private static bool HasBlockingBoundarySlotSourceCandidateRegression(
|
|
EdgeRoutingScore baselineScore,
|
|
EdgeRoutingScore candidateScore,
|
|
bool allowTemporarySoftTrade)
|
|
{
|
|
return candidateScore.NodeCrossings > baselineScore.NodeCrossings
|
|
|| candidateScore.BelowGraphViolations > baselineScore.BelowGraphViolations
|
|
|| candidateScore.UnderNodeViolations > baselineScore.UnderNodeViolations
|
|
|| candidateScore.LongDiagonalViolations > baselineScore.LongDiagonalViolations
|
|
|| candidateScore.EntryAngleViolations > baselineScore.EntryAngleViolations
|
|
|| candidateScore.GatewaySourceExitViolations > baselineScore.GatewaySourceExitViolations
|
|
|| candidateScore.RepeatCollectorCorridorViolations > baselineScore.RepeatCollectorCorridorViolations
|
|
|| candidateScore.RepeatCollectorNodeClearanceViolations > baselineScore.RepeatCollectorNodeClearanceViolations
|
|
|| (!allowTemporarySoftTrade
|
|
&& candidateScore.TargetApproachJoinViolations > baselineScore.TargetApproachJoinViolations)
|
|
|| candidateScore.TargetApproachBacktrackingViolations > baselineScore.TargetApproachBacktrackingViolations
|
|
|| (!allowTemporarySoftTrade
|
|
&& candidateScore.SharedLaneViolations > baselineScore.SharedLaneViolations);
|
|
}
|
|
}
|