Files
git.stella-ops.org/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.FaceConflictRepair.SourceDeparture.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

389 lines
15 KiB
C#

namespace StellaOps.ElkSharp;
internal static partial class ElkEdgePostProcessor
{
internal static ElkRoutedEdge[] SpreadSourceDepartureJoins(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
double minLineClearance,
IReadOnlyCollection<string>? restrictedEdgeIds = null)
{
if (edges.Length == 0 || 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 groups = result
.Select((edge, index) => new
{
Edge = edge,
Index = index,
Path = ExtractFullPath(edge),
})
.Where(item => item.Path.Count >= 2
&& nodesById.TryGetValue(item.Edge.SourceNodeId ?? string.Empty, out _)
&& ShouldSpreadSourceDeparture(item.Edge, graphMinY, graphMaxY))
.GroupBy(
item =>
{
var sourceNode = nodesById[item.Edge.SourceNodeId ?? string.Empty];
var side = ResolveSourceDepartureSide(item.Path, sourceNode);
return $"{sourceNode.Id}|{side}";
},
StringComparer.Ordinal);
foreach (var group in groups)
{
var entries = group
.Select(item =>
{
var sourceNode = nodesById[item.Edge.SourceNodeId ?? string.Empty];
var side = ResolveSourceDepartureSide(item.Path, sourceNode);
return new
{
item.Edge,
item.Index,
item.Path,
SourceNode = sourceNode,
Side = side,
Boundary = item.Path[0],
TargetReference = side is "left" or "right"
? item.Path[^1].Y
: item.Path[^1].X,
PathLength = ComputePathLength(item.Path),
};
})
.ToArray();
if (entries.Length < 2)
{
continue;
}
if (restrictedSet is not null && !entries.Any(entry => restrictedSet.Contains(entry.Edge.Id)))
{
continue;
}
var sourceNode = entries[0].SourceNode;
var side = entries[0].Side;
var sourceBoundaryEntries = entries
.Select(entry => (
entry.Edge.Id,
Coordinate: side is "left" or "right" ? entry.Boundary.Y : entry.Boundary.X,
IsOutgoing: true))
.ToArray();
var joinEntries = entries
.Select(entry => (Path: (IReadOnlyList<ElkPoint>)entry.Path, Side: entry.Side))
.ToArray();
var hasDepartureJoin = GroupHasSourceDepartureJoin(joinEntries, minLineClearance);
var hasBoundarySlotIssue = HasBoundarySlotAlignmentIssue(
sourceBoundaryEntries,
sourceNode,
side,
minLineClearance);
if (!hasDepartureJoin && !hasBoundarySlotIssue)
{
continue;
}
var isGatewaySource = ElkShapeBoundaries.IsGatewayShape(sourceNode);
var slotOnlyRepair = !hasDepartureJoin && hasBoundarySlotIssue;
var boundaryCoordinate = side is "left" or "right"
? entries[0].Boundary.Y
: entries[0].Boundary.X;
var anchor = entries
.OrderBy(entry => Math.Abs(entry.TargetReference - boundaryCoordinate))
.ThenBy(entry => entry.PathLength)
.ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal)
.First();
var sorted = entries
.OrderBy(entry => entry.TargetReference)
.ThenBy(entry => entry.PathLength)
.ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal)
.ToArray();
var assignedSlotCoordinates = ElkBoundarySlots.BuildAssignedBoundarySlotCoordinates(
sourceNode,
side,
sorted.Select(entry => entry.TargetReference).ToArray());
var desiredCoordinateByEdgeId = new Dictionary<string, double>(StringComparer.Ordinal);
var anchorDepartureAxis = TryExtractSourceDepartureRun(anchor.Path, side, out _, out var anchorRunEndIndex)
? side is "left" or "right"
? anchor.Path[anchorRunEndIndex].X
: anchor.Path[anchorRunEndIndex].Y
: side switch
{
"left" => sourceNode.X - 24d,
"right" => sourceNode.X + sourceNode.Width + 24d,
"top" => sourceNode.Y - 24d,
"bottom" => sourceNode.Y + sourceNode.Height + 24d,
_ => 0d,
};
var desiredAxisByEdgeId = new Dictionary<string, double>(StringComparer.Ordinal);
for (var i = 0; i < sorted.Length; i++)
{
desiredCoordinateByEdgeId[sorted[i].Edge.Id] = assignedSlotCoordinates[i];
desiredAxisByEdgeId[sorted[i].Edge.Id] = slotOnlyRepair
? TryExtractSourceDepartureRun(sorted[i].Path, side, out _, out var sortedRunEndIndex)
? side is "left" or "right"
? sorted[i].Path[sortedRunEndIndex].X
: sorted[i].Path[sortedRunEndIndex].Y
: anchorDepartureAxis
: anchorDepartureAxis;
}
foreach (var entry in entries)
{
if (!desiredCoordinateByEdgeId.TryGetValue(entry.Edge.Id, out var slotCoordinate))
{
continue;
}
if (!desiredAxisByEdgeId.TryGetValue(entry.Edge.Id, out var desiredAxis))
{
continue;
}
var originalCoordinate = side is "left" or "right"
? entry.Boundary.Y
: entry.Boundary.X;
var originalAxis = TryExtractSourceDepartureRun(entry.Path, side, out _, out var runEndIndex)
? side is "left" or "right"
? entry.Path[runEndIndex].X
: entry.Path[runEndIndex].Y
: desiredAxis;
if (Math.Abs(originalCoordinate - slotCoordinate) <= 0.5d
&& Math.Abs(originalAxis - desiredAxis) <= 0.5d)
{
continue;
}
var boundaryPoint = ElkBoundarySlots.BuildBoundarySlotPoint(sourceNode, side, slotCoordinate);
if (isGatewaySource)
{
var continuation = entry.Path.Count > 1 ? entry.Path[1] : entry.Path[0];
boundaryPoint = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(sourceNode, boundaryPoint, continuation);
}
var candidate = BuildSourceDepartureCandidatePath(
entry.Path,
sourceNode,
side,
boundaryPoint,
desiredAxis,
nodes,
entry.Edge.SourceNodeId,
entry.Edge.TargetNodeId);
if (!PathChanged(entry.Path, candidate))
{
continue;
}
if (isGatewaySource)
{
if (!HasAcceptableGatewayBoundaryPath(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, sourceNode, fromStart: true))
{
continue;
}
}
else
{
if (!HasClearBoundarySegments(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId, true, 2)
|| !HasValidBoundaryAngle(candidate[0], candidate[1], sourceNode)
|| HasNodeObstacleCrossing(candidate, nodes, entry.Edge.SourceNodeId, entry.Edge.TargetNodeId))
{
continue;
}
}
result[entry.Index] = BuildSingleSectionEdge(entry.Edge, candidate);
}
}
return result;
}
internal static ElkRoutedEdge[] SpreadRectTargetApproachFeederBands(
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 restrictedSet = restrictedEdgeIds is null
? null
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
var result = edges.ToArray();
var groups = result
.Select((edge, index) => new
{
Edge = edge,
Index = index,
Path = ExtractFullPath(edge),
})
.Where(item => item.Path.Count >= 3
&& nodesById.TryGetValue(item.Edge.TargetNodeId ?? string.Empty, out var targetNode)
&& !ElkShapeBoundaries.IsGatewayShape(targetNode))
.GroupBy(
item =>
{
var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty];
var side = ResolveTargetApproachSide(item.Path, targetNode);
return $"{targetNode.Id}|{side}";
},
StringComparer.Ordinal);
foreach (var group in groups)
{
var entries = group
.Select(item =>
{
var targetNode = nodesById[item.Edge.TargetNodeId ?? string.Empty];
var side = ResolveTargetApproachSide(item.Path, targetNode);
return TryExtractTargetApproachFeeder(item.Path, side, out var feeder)
? new
{
item.Edge,
item.Index,
item.Path,
TargetNode = targetNode,
Side = side,
Feeder = feeder,
}
: null;
})
.Where(entry => entry is not null)
.Select(entry => entry!)
.ToArray();
if (entries.Length < 2)
{
continue;
}
if (restrictedSet is not null && !entries.Any(entry => restrictedSet.Contains(entry.Edge.Id)))
{
continue;
}
var conflictNeighbors = new List<int>[entries.Length];
for (var i = 0; i < entries.Length; i++)
{
conflictNeighbors[i] = [];
}
var hasConflict = false;
for (var i = 0; i < entries.Length; i++)
{
for (var j = i + 1; j < entries.Length; j++)
{
if (ElkEdgeRoutingGeometry.AreParallelAndClose(
entries[i].Feeder.Start,
entries[i].Feeder.End,
entries[j].Feeder.Start,
entries[j].Feeder.End,
minLineClearance))
{
hasConflict = true;
conflictNeighbors[i].Add(j);
conflictNeighbors[j].Add(i);
}
}
}
if (!hasConflict)
{
continue;
}
var visited = new bool[entries.Length];
for (var componentStart = 0; componentStart < entries.Length; componentStart++)
{
if (visited[componentStart] || conflictNeighbors[componentStart].Count == 0)
{
continue;
}
var queue = new Queue<int>();
var componentIndices = new List<int>();
queue.Enqueue(componentStart);
visited[componentStart] = true;
while (queue.Count > 0)
{
var current = queue.Dequeue();
componentIndices.Add(current);
foreach (var neighbor in conflictNeighbors[current])
{
if (visited[neighbor])
{
continue;
}
visited[neighbor] = true;
queue.Enqueue(neighbor);
}
}
if (componentIndices.Count < 2)
{
continue;
}
var componentEntries = componentIndices
.Select(index => entries[index])
.ToArray();
if (restrictedSet is not null && !componentEntries.Any(entry => restrictedSet.Contains(entry.Edge.Id)))
{
continue;
}
var spacing = Math.Max(12d, minLineClearance + 4d);
var sorted = componentEntries
.OrderBy(entry => entry.Feeder.BandCoordinate)
.ThenBy(entry => entry.Edge.Id, StringComparer.Ordinal)
.ToArray();
var baseBand = sorted[0].Side is "left" or "top"
? sorted.Max(entry => entry.Feeder.BandCoordinate)
: sorted.Min(entry => entry.Feeder.BandCoordinate);
for (var i = 0; i < sorted.Length; i++)
{
var desiredBand = ResolveDesiredTargetApproachAxis(
sorted[i].TargetNode,
sorted[i].Side,
baseBand,
spacing,
i,
forceOutwardFromBoundary: true);
if (Math.Abs(sorted[i].Feeder.BandCoordinate - desiredBand) <= 0.5d)
{
continue;
}
var candidate = RewriteTargetApproachFeederBand(sorted[i].Path, sorted[i].Side, desiredBand);
if (!PathChanged(sorted[i].Path, candidate)
|| !HasClearBoundarySegments(candidate, nodes, sorted[i].Edge.SourceNodeId, sorted[i].Edge.TargetNodeId, false, 4)
|| HasNodeObstacleCrossing(candidate, nodes, sorted[i].Edge.SourceNodeId, sorted[i].Edge.TargetNodeId))
{
continue;
}
result[sorted[i].Index] = BuildSingleSectionEdge(sorted[i].Edge, candidate);
}
}
}
return result;
}
}