- 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>
385 lines
15 KiB
C#
385 lines
15 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgePostProcessor
|
|
{
|
|
private static bool TryPolishGatewayUnderNodeTargetPeerConflicts(
|
|
ElkRoutedEdge candidateEdge,
|
|
IReadOnlyList<ElkRoutedEdge> currentEdges,
|
|
int candidateIndex,
|
|
ElkPositionedNode[] nodes,
|
|
double minLineClearance,
|
|
out ElkRoutedEdge polishedEdge)
|
|
{
|
|
polishedEdge = candidateEdge;
|
|
if (string.IsNullOrWhiteSpace(candidateEdge.TargetNodeId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
|
if (!nodesById.TryGetValue(candidateEdge.TargetNodeId, out var targetNode)
|
|
|| !ElkShapeBoundaries.IsGatewayShape(targetNode))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
nodesById.TryGetValue(candidateEdge.SourceNodeId ?? string.Empty, out var sourceNode);
|
|
var peerEdges = currentEdges
|
|
.Where((edge, index) =>
|
|
index != candidateIndex
|
|
&& string.Equals(edge.TargetNodeId, candidateEdge.TargetNodeId, StringComparison.Ordinal))
|
|
.ToArray();
|
|
if (peerEdges.Length == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var path = ExtractFullPath(candidateEdge);
|
|
if (path.Count < 2)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var sourceNodeId = candidateEdge.SourceNodeId;
|
|
var targetNodeId = candidateEdge.TargetNodeId;
|
|
var currentBundle = peerEdges
|
|
.Append(candidateEdge)
|
|
.ToArray();
|
|
var currentTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(currentBundle, nodes);
|
|
var currentSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(currentBundle, nodes);
|
|
var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, sourceNodeId, targetNodeId, minLineClearance);
|
|
var currentUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations([candidateEdge], nodes);
|
|
var currentLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(candidateEdge, nodes);
|
|
var currentPathLength = ComputePathLength(path);
|
|
if (currentTargetJoinViolations == 0
|
|
&& currentSharedLaneViolations == 0
|
|
&& currentUnderNodeSegments == 0
|
|
&& currentUnderNodeViolations == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var bestEdge = default(ElkRoutedEdge);
|
|
var bestTargetJoinViolations = currentTargetJoinViolations;
|
|
var bestSharedLaneViolations = currentSharedLaneViolations;
|
|
var bestUnderNodeSegments = currentUnderNodeSegments;
|
|
var bestUnderNodeViolations = currentUnderNodeViolations;
|
|
var bestLocalHardPressure = currentLocalHardPressure;
|
|
var bestPathLength = currentPathLength;
|
|
|
|
foreach (var candidatePath in EnumerateGatewayUnderNodePeerConflictCandidates(
|
|
path,
|
|
targetNode,
|
|
sourceNode,
|
|
peerEdges,
|
|
nodes,
|
|
sourceNodeId,
|
|
targetNodeId,
|
|
minLineClearance))
|
|
{
|
|
if (!PathChanged(path, candidatePath)
|
|
|| candidatePath.Count < 2
|
|
|| HasNodeObstacleCrossing(candidatePath, nodes, sourceNodeId, targetNodeId)
|
|
|| !CanAcceptGatewayTargetRepair(candidatePath, targetNode)
|
|
|| !HasAcceptableGatewayBoundaryPath(candidatePath, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var localCandidateEdge = BuildSingleSectionEdge(candidateEdge, candidatePath);
|
|
var localBundle = peerEdges
|
|
.Append(localCandidateEdge)
|
|
.ToArray();
|
|
var candidateTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(localBundle, nodes);
|
|
var candidateSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(localBundle, nodes);
|
|
var candidateUnderNodeSegments = CountUnderNodeSegments(candidatePath, nodes, sourceNodeId, targetNodeId, minLineClearance);
|
|
var candidateUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations([localCandidateEdge], nodes);
|
|
var candidateLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(localCandidateEdge, nodes);
|
|
var candidatePathLength = ComputePathLength(candidatePath);
|
|
|
|
if (!IsBetterGatewayUnderNodePeerConflictCandidate(
|
|
candidateTargetJoinViolations,
|
|
candidateSharedLaneViolations,
|
|
candidateUnderNodeSegments,
|
|
candidateUnderNodeViolations,
|
|
candidateLocalHardPressure,
|
|
candidatePathLength,
|
|
bestTargetJoinViolations,
|
|
bestSharedLaneViolations,
|
|
bestUnderNodeSegments,
|
|
bestUnderNodeViolations,
|
|
bestLocalHardPressure,
|
|
bestPathLength))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bestEdge = localCandidateEdge;
|
|
bestTargetJoinViolations = candidateTargetJoinViolations;
|
|
bestSharedLaneViolations = candidateSharedLaneViolations;
|
|
bestUnderNodeSegments = candidateUnderNodeSegments;
|
|
bestUnderNodeViolations = candidateUnderNodeViolations;
|
|
bestLocalHardPressure = candidateLocalHardPressure;
|
|
bestPathLength = candidatePathLength;
|
|
}
|
|
|
|
if (bestEdge is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
polishedEdge = bestEdge;
|
|
return true;
|
|
}
|
|
|
|
private static IEnumerable<List<ElkPoint>> EnumerateGatewayUnderNodePeerConflictCandidates(
|
|
IReadOnlyList<ElkPoint> path,
|
|
ElkPositionedNode targetNode,
|
|
ElkPositionedNode? sourceNode,
|
|
IReadOnlyCollection<ElkRoutedEdge> peerEdges,
|
|
IReadOnlyCollection<ElkPositionedNode> nodes,
|
|
string? sourceNodeId,
|
|
string? targetNodeId,
|
|
double minLineClearance)
|
|
{
|
|
foreach (var side in EnumerateGatewayUnderNodePeerConflictSides(path, targetNode, peerEdges))
|
|
{
|
|
var slotCoordinates = EnumerateGatewayUnderNodePeerConflictSlotCoordinates(
|
|
path,
|
|
targetNode,
|
|
sourceNode,
|
|
peerEdges,
|
|
side,
|
|
minLineClearance)
|
|
.ToArray();
|
|
if (slotCoordinates.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var slotCoordinate in slotCoordinates)
|
|
{
|
|
if (sourceNode is not null
|
|
&& ElkShapeBoundaries.TryProjectGatewayBoundarySlot(targetNode, side, slotCoordinate, out var bandBoundary)
|
|
&& TryBuildSafeHorizontalBandCandidate(
|
|
sourceNode,
|
|
targetNode,
|
|
nodes,
|
|
sourceNodeId,
|
|
targetNodeId,
|
|
path[0],
|
|
bandBoundary,
|
|
minLineClearance,
|
|
preferredSourceExterior: null,
|
|
out var bandCandidate))
|
|
{
|
|
yield return bandCandidate;
|
|
}
|
|
|
|
foreach (var axis in EnumerateGatewayUnderNodePeerConflictAxes(
|
|
path,
|
|
targetNode,
|
|
side,
|
|
nodes,
|
|
sourceNodeId,
|
|
targetNodeId,
|
|
minLineClearance))
|
|
{
|
|
yield return BuildMixedTargetFaceCandidate(path, targetNode, side, slotCoordinate, axis);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<string> EnumerateGatewayUnderNodePeerConflictSides(
|
|
IReadOnlyList<ElkPoint> path,
|
|
ElkPositionedNode targetNode,
|
|
IReadOnlyCollection<ElkRoutedEdge> peerEdges)
|
|
{
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
var currentSide = ResolveTargetApproachSide(path, targetNode);
|
|
var peerSides = peerEdges
|
|
.Select(edge => ExtractFullPath(edge))
|
|
.Where(peerPath => peerPath.Count >= 2)
|
|
.Select(peerPath => ResolveTargetApproachSide(peerPath, targetNode))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
foreach (var side in new[] { "top", "bottom", "right", "left" })
|
|
{
|
|
if (!string.Equals(side, currentSide, StringComparison.Ordinal)
|
|
&& !peerSides.Contains(side)
|
|
&& seen.Add(side))
|
|
{
|
|
yield return side;
|
|
}
|
|
}
|
|
|
|
if (seen.Add(currentSide))
|
|
{
|
|
yield return currentSide;
|
|
}
|
|
|
|
foreach (var side in new[] { "top", "bottom", "right", "left" })
|
|
{
|
|
if (seen.Add(side))
|
|
{
|
|
yield return side;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<double> EnumerateGatewayUnderNodePeerConflictSlotCoordinates(
|
|
IReadOnlyList<ElkPoint> path,
|
|
ElkPositionedNode targetNode,
|
|
ElkPositionedNode? sourceNode,
|
|
IReadOnlyCollection<ElkRoutedEdge> peerEdges,
|
|
string side,
|
|
double minLineClearance)
|
|
{
|
|
var coordinates = new List<double>();
|
|
var inset = 10d;
|
|
var spacing = Math.Max(14d, minLineClearance + 6d);
|
|
var centerX = targetNode.X + (targetNode.Width / 2d);
|
|
var centerY = targetNode.Y + (targetNode.Height / 2d);
|
|
var slotMinimum = side is "left" or "right" ? targetNode.Y + inset : targetNode.X + inset;
|
|
var slotMaximum = side is "left" or "right"
|
|
? targetNode.Y + targetNode.Height - inset
|
|
: targetNode.X + targetNode.Width - inset;
|
|
|
|
void AddClamped(double value)
|
|
{
|
|
AddUniqueCoordinate(coordinates, Math.Max(slotMinimum, Math.Min(slotMaximum, value)));
|
|
}
|
|
|
|
if (side is "left" or "right")
|
|
{
|
|
AddClamped(path[^1].Y);
|
|
foreach (var peer in peerEdges)
|
|
{
|
|
var peerPath = ExtractFullPath(peer);
|
|
if (peerPath.Count > 0)
|
|
{
|
|
AddClamped(peerPath[^1].Y - spacing);
|
|
AddClamped(peerPath[^1].Y + spacing);
|
|
AddClamped(peerPath[^1].Y);
|
|
}
|
|
}
|
|
|
|
if (sourceNode is not null)
|
|
{
|
|
AddClamped(sourceNode.Y + (sourceNode.Height / 2d));
|
|
}
|
|
|
|
AddClamped(centerY - spacing);
|
|
AddClamped(centerY);
|
|
AddClamped(centerY + spacing);
|
|
}
|
|
else
|
|
{
|
|
AddClamped(path[^1].X);
|
|
foreach (var peer in peerEdges)
|
|
{
|
|
var peerPath = ExtractFullPath(peer);
|
|
if (peerPath.Count > 0)
|
|
{
|
|
AddClamped(peerPath[^1].X - spacing);
|
|
AddClamped(peerPath[^1].X + spacing);
|
|
AddClamped(peerPath[^1].X);
|
|
}
|
|
}
|
|
|
|
if (sourceNode is not null)
|
|
{
|
|
AddClamped(sourceNode.X + (sourceNode.Width / 2d));
|
|
}
|
|
|
|
AddClamped(centerX - spacing);
|
|
AddClamped(centerX);
|
|
AddClamped(centerX + spacing);
|
|
}
|
|
|
|
foreach (var coordinate in coordinates.Take(8))
|
|
{
|
|
yield return coordinate;
|
|
}
|
|
}
|
|
|
|
private static IEnumerable<double> EnumerateGatewayUnderNodePeerConflictAxes(
|
|
IReadOnlyList<ElkPoint> path,
|
|
ElkPositionedNode targetNode,
|
|
string side,
|
|
IReadOnlyCollection<ElkPositionedNode> nodes,
|
|
string? sourceNodeId,
|
|
string? targetNodeId,
|
|
double minLineClearance)
|
|
{
|
|
var coordinates = new List<double>();
|
|
var currentAxis = ResolveTargetApproachAxisValue(path, side);
|
|
if (!double.IsNaN(currentAxis))
|
|
{
|
|
AddUniqueCoordinate(coordinates, currentAxis);
|
|
}
|
|
|
|
AddUniqueCoordinate(coordinates, ResolveDefaultTargetApproachAxis(targetNode, side));
|
|
|
|
var clearance = Math.Max(24d, minLineClearance * 0.6d);
|
|
if (side is "top" or "bottom")
|
|
{
|
|
var minX = Math.Min(path[0].X, targetNode.X);
|
|
var maxX = Math.Max(path[0].X, targetNode.X + targetNode.Width);
|
|
var blockers = nodes
|
|
.Where(node =>
|
|
!string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal)
|
|
&& !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)
|
|
&& maxX > node.X + 0.5d
|
|
&& minX < node.X + node.Width - 0.5d)
|
|
.ToArray();
|
|
if (side == "top")
|
|
{
|
|
var highestBlockerY = blockers.Length > 0
|
|
? blockers.Min(node => node.Y)
|
|
: Math.Min(path[0].Y, targetNode.Y);
|
|
AddUniqueCoordinate(coordinates, Math.Min(targetNode.Y - 8d, highestBlockerY - clearance));
|
|
}
|
|
else
|
|
{
|
|
var lowestBlockerY = blockers.Length > 0
|
|
? blockers.Max(node => node.Y + node.Height)
|
|
: Math.Max(path[0].Y, targetNode.Y + targetNode.Height);
|
|
AddUniqueCoordinate(coordinates, Math.Max(targetNode.Y + targetNode.Height + 8d, lowestBlockerY + clearance));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var minY = Math.Min(path[0].Y, targetNode.Y);
|
|
var maxY = Math.Max(path[0].Y, targetNode.Y + targetNode.Height);
|
|
var blockers = nodes
|
|
.Where(node =>
|
|
!string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal)
|
|
&& !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)
|
|
&& maxY > node.Y + 0.5d
|
|
&& minY < node.Y + node.Height - 0.5d)
|
|
.ToArray();
|
|
if (side == "left")
|
|
{
|
|
var leftmostBlockerX = blockers.Length > 0
|
|
? blockers.Min(node => node.X)
|
|
: Math.Min(path[0].X, targetNode.X);
|
|
AddUniqueCoordinate(coordinates, Math.Min(targetNode.X - 8d, leftmostBlockerX - clearance));
|
|
}
|
|
else
|
|
{
|
|
var rightmostBlockerX = blockers.Length > 0
|
|
? blockers.Max(node => node.X + node.Width)
|
|
: Math.Max(path[0].X, targetNode.X + targetNode.Width);
|
|
AddUniqueCoordinate(coordinates, Math.Max(targetNode.X + targetNode.Width + 8d, rightmostBlockerX + clearance));
|
|
}
|
|
}
|
|
|
|
foreach (var coordinate in coordinates.Take(6))
|
|
{
|
|
yield return coordinate;
|
|
}
|
|
}
|
|
}
|