- 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>
171 lines
5.6 KiB
C#
171 lines
5.6 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgePostProcessor
|
|
{
|
|
internal static ElkRoutedEdge[] PreferShortestBoundaryShortcuts(
|
|
ElkRoutedEdge[] edges,
|
|
ElkPositionedNode[] nodes,
|
|
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
|
{
|
|
if (edges.Length == 0 || nodes.Length == 0)
|
|
{
|
|
return edges;
|
|
}
|
|
|
|
var restrictedSet = restrictedEdgeIds is null
|
|
? null
|
|
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
|
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
|
var graphMinY = nodes.Min(node => node.Y);
|
|
var graphMaxY = nodes.Max(node => node.Y + node.Height);
|
|
var minLineClearance = ResolveMinLineClearance(nodes);
|
|
var result = edges.ToArray();
|
|
|
|
for (var i = 0; i < result.Length; i++)
|
|
{
|
|
var edge = result[i];
|
|
if (restrictedSet is not null && !restrictedSet.Contains(edge.Id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(edge.SourcePortId)
|
|
|| !string.IsNullOrWhiteSpace(edge.TargetPortId)
|
|
|| !nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|
|
|| !nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(edge.Kind)
|
|
&& edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (HasProtectedUnderNodeGeometry(edge)
|
|
&& ElkEdgeRoutingScoring.CountUnderNodeViolations([edge], nodes) > 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (IsRepeatCollectorLabel(edge.Label)
|
|
&& HasCorridorBendPoints(edge, graphMinY, graphMaxY))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = ExtractFullPath(edge);
|
|
if (path.Count < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
List<ElkPoint>? bestShortcut = null;
|
|
var currentLength = ComputePathLength(path);
|
|
|
|
bool IsAcceptableShortcutCandidate(IReadOnlyList<ElkPoint> candidate)
|
|
{
|
|
if (candidate.Count < 2
|
|
|| HasNodeObstacleCrossing(candidate, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (ElkShapeBoundaries.IsGatewayShape(sourceNode))
|
|
{
|
|
if (!HasAcceptableGatewayBoundaryPath(
|
|
candidate,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
sourceNode,
|
|
fromStart: true))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else if (!HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
|
|
{
|
|
return CanAcceptGatewayTargetRepair(candidate, targetNode)
|
|
&& HasAcceptableGatewayBoundaryPath(
|
|
candidate,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
targetNode,
|
|
fromStart: false);
|
|
}
|
|
|
|
return !HasTargetApproachBacktracking(candidate, targetNode)
|
|
&& HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode);
|
|
}
|
|
|
|
void ConsiderShortcutCandidate(List<ElkPoint>? candidate)
|
|
{
|
|
if (candidate is null
|
|
|| ComputePathLength(candidate) + 16d >= currentLength
|
|
|| !IsAcceptableShortcutCandidate(candidate))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (bestShortcut is not null
|
|
&& ComputePathLength(candidate) + 0.5d >= ComputePathLength(bestShortcut))
|
|
{
|
|
return;
|
|
}
|
|
|
|
bestShortcut = candidate;
|
|
}
|
|
|
|
if (TryBuildDominantPreferredBoundaryShortcutPath(
|
|
sourceNode,
|
|
targetNode,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
out var dominantShortcut))
|
|
{
|
|
ConsiderShortcutCandidate(dominantShortcut);
|
|
}
|
|
|
|
if (TryBuildPreferredBoundaryShortcutPath(
|
|
sourceNode,
|
|
targetNode,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
out var preferredShortcut))
|
|
{
|
|
ConsiderShortcutCandidate(preferredShortcut);
|
|
}
|
|
|
|
var localSkirtShortcut = TryBuildLocalObstacleSkirtBoundaryShortcut(
|
|
path,
|
|
path[0],
|
|
path[^1],
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
targetNode,
|
|
minLineClearance);
|
|
ConsiderShortcutCandidate(localSkirtShortcut);
|
|
|
|
if (bestShortcut is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result[i] = BuildSingleSectionEdge(edge, bestShortcut);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|