- 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>
337 lines
16 KiB
C#
337 lines
16 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgePostProcessor
|
|
{
|
|
internal static ElkRoutedEdge[] SeparateMixedNodeFaceLaneConflicts(
|
|
ElkRoutedEdge[] edges,
|
|
ElkPositionedNode[] nodes,
|
|
double minLineClearance,
|
|
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
|
{
|
|
if (edges.Length < 2 || nodes.Length == 0)
|
|
{
|
|
return edges;
|
|
}
|
|
|
|
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 restrictedSet = restrictedEdgeIds is null
|
|
? null
|
|
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
|
var result = edges.ToArray();
|
|
var entries = new List<(int Index, ElkRoutedEdge Edge, IReadOnlyList<ElkPoint> Path, ElkPositionedNode Node, string Side, bool IsOutgoing, ElkPoint Boundary, double BoundaryCoordinate, double AxisValue)>();
|
|
|
|
for (var index = 0; index < result.Length; index++)
|
|
{
|
|
var edge = result[index];
|
|
var path = ExtractFullPath(edge);
|
|
if (path.Count < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|
|
&& (ShouldSpreadSourceDeparture(edge, graphMinY, graphMaxY)
|
|
|| ElkShapeBoundaries.IsGatewayShape(sourceNode)))
|
|
{
|
|
var side = ResolveSourceDepartureSide(path, sourceNode);
|
|
var axisValue = TryExtractSourceDepartureRun(path, side, out _, out var runEndIndex)
|
|
? side is "left" or "right"
|
|
? path[runEndIndex].X
|
|
: path[runEndIndex].Y
|
|
: ResolveDefaultSourceDepartureAxis(sourceNode, side);
|
|
entries.Add((
|
|
index,
|
|
edge,
|
|
path,
|
|
sourceNode,
|
|
side,
|
|
true,
|
|
path[0],
|
|
side is "left" or "right" ? path[0].Y : path[0].X,
|
|
axisValue));
|
|
}
|
|
|
|
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)
|
|
&& ShouldSpreadTargetApproach(edge, graphMinY, graphMaxY))
|
|
{
|
|
var side = ResolveTargetApproachSide(path, targetNode);
|
|
var axisValue = ResolveTargetApproachAxisValue(path, side);
|
|
if (double.IsNaN(axisValue))
|
|
{
|
|
axisValue = side is "left" or "right" ? path[^1].Y : path[^1].X;
|
|
}
|
|
|
|
entries.Add((
|
|
index,
|
|
edge,
|
|
path,
|
|
targetNode,
|
|
side,
|
|
false,
|
|
path[^1],
|
|
side is "left" or "right" ? path[^1].Y : path[^1].X,
|
|
axisValue));
|
|
}
|
|
}
|
|
|
|
foreach (var group in entries.GroupBy(
|
|
entry => $"{entry.Node.Id}|{entry.Side}",
|
|
StringComparer.Ordinal))
|
|
{
|
|
var groupEntries = group.ToArray();
|
|
var hasBoundarySlotIssue = groupEntries.Length >= 2
|
|
&& HasBoundarySlotAlignmentIssue(
|
|
groupEntries
|
|
.Select(entry => (entry.Edge.Id, entry.BoundaryCoordinate, entry.IsOutgoing))
|
|
.ToArray(),
|
|
groupEntries[0].Node,
|
|
groupEntries[0].Side,
|
|
minLineClearance);
|
|
if (groupEntries.Length < 2
|
|
|| !groupEntries.Any(entry => entry.IsOutgoing)
|
|
|| !groupEntries.Any(entry => !entry.IsOutgoing)
|
|
|| (!GroupHasMixedNodeFaceLaneConflict(groupEntries, minLineClearance) && !hasBoundarySlotIssue))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (restrictedSet is not null && !groupEntries.Any(entry => restrictedSet.Contains(entry.Edge.Id)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var node = groupEntries[0].Node;
|
|
var side = groupEntries[0].Side;
|
|
var orderedEntries = groupEntries
|
|
.OrderBy(entry => entry.BoundaryCoordinate)
|
|
.ThenBy(entry => entry.IsOutgoing ? 0 : 1)
|
|
.ThenBy(entry => IsRepeatCollectorLabel(entry.Edge.Label) ? 1 : 0)
|
|
.ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal)
|
|
.ToArray();
|
|
var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(
|
|
node,
|
|
side,
|
|
orderedEntries.Select(entry => entry.BoundaryCoordinate).ToArray());
|
|
var desiredCoordinateByEdgeId = new Dictionary<string, double>(StringComparer.Ordinal);
|
|
for (var i = 0; i < orderedEntries.Length; i++)
|
|
{
|
|
desiredCoordinateByEdgeId[orderedEntries[i].Edge.Id] = assignedSlotCoordinates[i];
|
|
}
|
|
var hasAssignedSlotCollision = HasDuplicateBoundarySlotCoordinates(assignedSlotCoordinates);
|
|
|
|
foreach (var entry in groupEntries)
|
|
{
|
|
var forceAlternateGatewayFaceCandidate = hasAssignedSlotCollision
|
|
&& ElkShapeBoundaries.IsGatewayShape(entry.Node);
|
|
if (!desiredCoordinateByEdgeId.TryGetValue(entry.Edge.Id, out var desiredCoordinate)
|
|
|| (!forceAlternateGatewayFaceCandidate
|
|
&& Math.Abs(desiredCoordinate - entry.BoundaryCoordinate) <= 0.5d))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var bestEdge = result[entry.Index];
|
|
var currentGroupEdges = groupEntries
|
|
.Select(item => result[item.Index])
|
|
.ToArray();
|
|
var bestSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(currentGroupEdges, nodes);
|
|
var bestTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(currentGroupEdges, nodes);
|
|
var bestBoundarySlotViolations = ElkEdgeRoutingScoring.CountBoundarySlotViolations(currentGroupEdges, nodes);
|
|
var bestBoundaryAngleViolations = ElkEdgeRoutingScoring.CountBadBoundaryAngles(currentGroupEdges, nodes);
|
|
var bestGatewaySourceExitViolations = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(currentGroupEdges, nodes);
|
|
var bestUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations(currentGroupEdges, nodes);
|
|
var bestPathLength = ComputePathLength(entry.Path);
|
|
var prefersAlternateRepeatFace = !entry.IsOutgoing
|
|
&& !ElkShapeBoundaries.IsGatewayShape(entry.Node)
|
|
&& IsRepeatCollectorLabel(entry.Edge.Label)
|
|
&& groupEntries.Any(other => other.IsOutgoing);
|
|
var candidatePaths = new List<IReadOnlyList<ElkPoint>>();
|
|
var directCandidate = entry.IsOutgoing
|
|
? BuildMixedSourceFaceCandidate(entry.Path, entry.Node, side, desiredCoordinate, entry.AxisValue)
|
|
: BuildMixedTargetFaceCandidate(entry.Path, entry.Node, side, desiredCoordinate, entry.AxisValue);
|
|
AddUniquePathCandidate(candidatePaths, directCandidate);
|
|
var availableSpan = Math.Abs(desiredCoordinate - entry.BoundaryCoordinate);
|
|
if ((forceAlternateGatewayFaceCandidate || prefersAlternateRepeatFace || availableSpan + 0.5d < minLineClearance)
|
|
&& TryBuildAlternateMixedFaceCandidate(entry, nodes, minLineClearance, out var alternateCandidate))
|
|
{
|
|
AddUniquePathCandidate(candidatePaths, alternateCandidate);
|
|
}
|
|
|
|
foreach (var candidate in candidatePaths)
|
|
{
|
|
if (!PathChanged(entry.Path, candidate)
|
|
|| HasNodeObstacleCrossing(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (entry.IsOutgoing)
|
|
{
|
|
if (ElkShapeBoundaries.IsGatewayShape(entry.Node))
|
|
{
|
|
if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, entry.Node, fromStart: true))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
else if (!HasClearBoundarySegments(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, true, 2)
|
|
|| !HasValidBoundaryAngle(candidate[0], candidate[1], entry.Node))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (ElkShapeBoundaries.IsGatewayShape(entry.Node))
|
|
{
|
|
if (!CanAcceptGatewayTargetRepair(candidate, entry.Node)
|
|
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, entry.Node, fromStart: false))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
else if (!HasClearBoundarySegments(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, false, 4)
|
|
|| !HasValidBoundaryAngle(candidate[^1], candidate[^2], entry.Node)
|
|
|| HasTargetApproachBacktracking(candidate, entry.Node))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
var candidateEdge = BuildSingleSectionEdge(entry.Edge, candidate);
|
|
var candidateGroupEdges = groupEntries
|
|
.Select(item => item.Index == entry.Index ? candidateEdge : result[item.Index])
|
|
.ToArray();
|
|
var candidateSharedLaneViolations = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateGroupEdges, nodes);
|
|
var candidateTargetJoinViolations = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidateGroupEdges, nodes);
|
|
var candidateBoundarySlotViolations = ElkEdgeRoutingScoring.CountBoundarySlotViolations(candidateGroupEdges, nodes);
|
|
var candidateBoundaryAngleViolations = ElkEdgeRoutingScoring.CountBadBoundaryAngles(candidateGroupEdges, nodes);
|
|
var candidateGatewaySourceExitViolations = ElkEdgeRoutingScoring.CountGatewaySourceExitViolations(candidateGroupEdges, nodes);
|
|
var candidateUnderNodeViolations = ElkEdgeRoutingScoring.CountUnderNodeViolations(candidateGroupEdges, nodes);
|
|
var candidatePathLength = ComputePathLength(candidate);
|
|
var prefersForcedAlternateGatewayFace = forceAlternateGatewayFaceCandidate
|
|
&& entry.IsOutgoing
|
|
&& ResolveSourceDepartureSide(candidate, entry.Node) != entry.Side
|
|
&& ResolveSourceDepartureSide(ExtractFullPath(bestEdge), entry.Node) == entry.Side
|
|
&& candidateSharedLaneViolations <= bestSharedLaneViolations
|
|
&& candidateTargetJoinViolations <= bestTargetJoinViolations
|
|
&& candidateBoundarySlotViolations <= bestBoundarySlotViolations
|
|
&& candidateBoundaryAngleViolations <= bestBoundaryAngleViolations
|
|
&& candidateGatewaySourceExitViolations <= bestGatewaySourceExitViolations
|
|
&& candidateUnderNodeViolations <= bestUnderNodeViolations;
|
|
|
|
if (prefersForcedAlternateGatewayFace)
|
|
{
|
|
bestEdge = candidateEdge;
|
|
bestSharedLaneViolations = candidateSharedLaneViolations;
|
|
bestTargetJoinViolations = candidateTargetJoinViolations;
|
|
bestBoundarySlotViolations = candidateBoundarySlotViolations;
|
|
bestBoundaryAngleViolations = candidateBoundaryAngleViolations;
|
|
bestGatewaySourceExitViolations = candidateGatewaySourceExitViolations;
|
|
bestUnderNodeViolations = candidateUnderNodeViolations;
|
|
bestPathLength = candidatePathLength;
|
|
continue;
|
|
}
|
|
|
|
if (!IsBetterMixedNodeFaceCandidate(
|
|
candidateSharedLaneViolations,
|
|
candidateTargetJoinViolations,
|
|
candidateBoundarySlotViolations,
|
|
candidateBoundaryAngleViolations,
|
|
candidateGatewaySourceExitViolations,
|
|
candidateUnderNodeViolations,
|
|
candidatePathLength,
|
|
bestSharedLaneViolations,
|
|
bestTargetJoinViolations,
|
|
bestBoundarySlotViolations,
|
|
bestBoundaryAngleViolations,
|
|
bestGatewaySourceExitViolations,
|
|
bestUnderNodeViolations,
|
|
bestPathLength))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bestEdge = candidateEdge;
|
|
bestSharedLaneViolations = candidateSharedLaneViolations;
|
|
bestTargetJoinViolations = candidateTargetJoinViolations;
|
|
bestBoundarySlotViolations = candidateBoundarySlotViolations;
|
|
bestBoundaryAngleViolations = candidateBoundaryAngleViolations;
|
|
bestGatewaySourceExitViolations = candidateGatewaySourceExitViolations;
|
|
bestUnderNodeViolations = candidateUnderNodeViolations;
|
|
bestPathLength = candidatePathLength;
|
|
}
|
|
|
|
result[entry.Index] = bestEdge;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static void AddUniquePathCandidate(
|
|
ICollection<IReadOnlyList<ElkPoint>> candidates,
|
|
IReadOnlyList<ElkPoint> candidate)
|
|
{
|
|
if (candidates.Any(existing =>
|
|
existing.Count == candidate.Count
|
|
&& existing.Zip(candidate, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
candidates.Add(candidate);
|
|
}
|
|
|
|
private static bool IsBetterMixedNodeFaceCandidate(
|
|
int candidateSharedLaneViolations,
|
|
int candidateTargetJoinViolations,
|
|
int candidateBoundarySlotViolations,
|
|
int candidateBoundaryAngleViolations,
|
|
int candidateGatewaySourceExitViolations,
|
|
int candidateUnderNodeViolations,
|
|
double candidatePathLength,
|
|
int currentSharedLaneViolations,
|
|
int currentTargetJoinViolations,
|
|
int currentBoundarySlotViolations,
|
|
int currentBoundaryAngleViolations,
|
|
int currentGatewaySourceExitViolations,
|
|
int currentUnderNodeViolations,
|
|
double currentPathLength)
|
|
{
|
|
if (candidateSharedLaneViolations != currentSharedLaneViolations)
|
|
{
|
|
return candidateSharedLaneViolations < currentSharedLaneViolations;
|
|
}
|
|
|
|
if (candidateTargetJoinViolations != currentTargetJoinViolations)
|
|
{
|
|
return candidateTargetJoinViolations < currentTargetJoinViolations;
|
|
}
|
|
|
|
if (candidateBoundarySlotViolations != currentBoundarySlotViolations)
|
|
{
|
|
return candidateBoundarySlotViolations < currentBoundarySlotViolations;
|
|
}
|
|
|
|
if (candidateBoundaryAngleViolations != currentBoundaryAngleViolations)
|
|
{
|
|
return candidateBoundaryAngleViolations < currentBoundaryAngleViolations;
|
|
}
|
|
|
|
if (candidateGatewaySourceExitViolations != currentGatewaySourceExitViolations)
|
|
{
|
|
return candidateGatewaySourceExitViolations < currentGatewaySourceExitViolations;
|
|
}
|
|
|
|
if (candidateUnderNodeViolations != currentUnderNodeViolations)
|
|
{
|
|
return candidateUnderNodeViolations < currentUnderNodeViolations;
|
|
}
|
|
|
|
return candidatePathLength + 0.5d < currentPathLength;
|
|
}
|
|
}
|