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>
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user