- 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>
389 lines
15 KiB
C#
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;
|
|
}
|
|
}
|