Files
git.stella-ops.org/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.FaceConflictRepair.MixedNodeFace.cs
master d04483560b Complete ElkSharp document rendering cleanup and source decomposition
- 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>
2026-04-01 14:16:10 +03:00

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;
}
}