- 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>
386 lines
19 KiB
C#
386 lines
19 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgePostProcessor
|
|
{
|
|
internal static ElkRoutedEdge[] RepairBoundaryAnglesAndTargetApproaches(
|
|
ElkRoutedEdge[] edges,
|
|
ElkPositionedNode[] nodes,
|
|
double minLineClearance,
|
|
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
|
{
|
|
if (edges.Length == 0 || nodes.Length == 0)
|
|
{
|
|
return edges;
|
|
}
|
|
|
|
var restrictedSet = restrictedEdgeIds is null
|
|
? null
|
|
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
|
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 targetSlots = ResolveTargetApproachSlots(edges, nodesById, graphMinY, graphMaxY, minLineClearance, restrictedSet);
|
|
var result = new ElkRoutedEdge[edges.Length];
|
|
|
|
for (var i = 0; i < edges.Length; i++)
|
|
{
|
|
var edge = edges[i];
|
|
if (restrictedSet is not null && !restrictedSet.Contains(edge.Id))
|
|
{
|
|
result[i] = edge;
|
|
continue;
|
|
}
|
|
|
|
var path = ExtractFullPath(edge);
|
|
if (path.Count < 2)
|
|
{
|
|
result[i] = edge;
|
|
continue;
|
|
}
|
|
|
|
var normalized = path
|
|
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
|
.ToList();
|
|
|
|
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|
|
&& ElkShapeBoundaries.IsGatewayShape(sourceNode))
|
|
{
|
|
var preserveSourceExit = ShouldPreserveSourceExitGeometry(edge, graphMinY, graphMaxY);
|
|
if (!preserveSourceExit)
|
|
{
|
|
var gatewaySourceNormalized = NormalizeGatewayExitPath(normalized, sourceNode, nodes, edge.SourceNodeId, edge.TargetNodeId);
|
|
if (PathStartsAtDecisionVertex(gatewaySourceNormalized, sourceNode))
|
|
{
|
|
gatewaySourceNormalized = ForceDecisionSourceExitOffVertex(
|
|
gatewaySourceNormalized,
|
|
sourceNode,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId);
|
|
}
|
|
if (PathChanged(normalized, gatewaySourceNormalized)
|
|
&& HasAcceptableGatewayBoundaryPath(gatewaySourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, sourceNode, fromStart: true))
|
|
{
|
|
normalized = gatewaySourceNormalized;
|
|
}
|
|
}
|
|
}
|
|
else if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out sourceNode)
|
|
&& !HasValidBoundaryAngle(normalized[0], normalized[1], sourceNode))
|
|
{
|
|
var sourceSide = ResolvePreferredRectSourceExitSide(normalized, sourceNode);
|
|
var sourcePath = normalized
|
|
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
|
.ToList();
|
|
sourcePath[0] = BuildRectBoundaryPointForSide(sourceNode, sourceSide, sourcePath[1]);
|
|
var sourceNormalized = NormalizeExitPath(sourcePath, sourceNode, sourceSide);
|
|
if (HasClearBoundarySegments(sourceNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, true, 3))
|
|
{
|
|
normalized = sourceNormalized;
|
|
}
|
|
}
|
|
|
|
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
|
|
{
|
|
var assignedEndpoint = targetSlots.TryGetValue(edge.Id, out var slot)
|
|
? slot
|
|
: normalized[^1];
|
|
if (ElkShapeBoundaries.IsGatewayShape(targetNode))
|
|
{
|
|
List<ElkPoint>? preferredGatewayTargetNormalized = null;
|
|
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var gatewaySourceNode)
|
|
&& TryBuildPreferredGatewayTargetEntryPath(
|
|
normalized,
|
|
gatewaySourceNode,
|
|
targetNode,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
out var preferredGatewayTargetRepair))
|
|
{
|
|
preferredGatewayTargetNormalized = preferredGatewayTargetRepair;
|
|
}
|
|
|
|
var gatewayTargetNormalized = NormalizeGatewayEntryPath(normalized, targetNode, assignedEndpoint);
|
|
if (gatewayTargetNormalized.Count >= 2
|
|
&& !ElkShapeBoundaries.HasValidGatewayBoundaryAngle(targetNode, gatewayTargetNormalized[^1], gatewayTargetNormalized[^2]))
|
|
{
|
|
var projectedBoundary = ElkShapeBoundaries.ProjectOntoShapeBoundary(targetNode, gatewayTargetNormalized[^2]);
|
|
projectedBoundary = ElkShapeBoundaries.PreferGatewayEdgeInteriorBoundary(targetNode, projectedBoundary, gatewayTargetNormalized[^2]);
|
|
var projectedGatewayTargetNormalized = NormalizeGatewayEntryPath(normalized, targetNode, projectedBoundary);
|
|
if (PathChanged(gatewayTargetNormalized, projectedGatewayTargetNormalized)
|
|
&& HasAcceptableGatewayBoundaryPath(projectedGatewayTargetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false))
|
|
{
|
|
gatewayTargetNormalized = projectedGatewayTargetNormalized;
|
|
}
|
|
}
|
|
|
|
if (preferredGatewayTargetNormalized is not null
|
|
&& (gatewayTargetNormalized.Count < 2
|
|
|| NeedsGatewayTargetBoundaryRepair(gatewayTargetNormalized, targetNode)
|
|
|| !string.Equals(
|
|
ElkEdgeRoutingGeometry.ResolveBoundarySide(gatewayTargetNormalized[^1], targetNode),
|
|
ElkEdgeRoutingGeometry.ResolveBoundarySide(preferredGatewayTargetNormalized[^1], targetNode),
|
|
StringComparison.Ordinal)
|
|
|| ComputePathLength(preferredGatewayTargetNormalized) + 4d < ComputePathLength(gatewayTargetNormalized)
|
|
|| HasTargetApproachBacktracking(gatewayTargetNormalized, targetNode)))
|
|
{
|
|
gatewayTargetNormalized = preferredGatewayTargetNormalized;
|
|
}
|
|
|
|
if (PathChanged(normalized, gatewayTargetNormalized)
|
|
&& HasAcceptableGatewayBoundaryPath(gatewayTargetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, targetNode, fromStart: false))
|
|
{
|
|
normalized = gatewayTargetNormalized;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var shortcutSourceNode)
|
|
&& TryApplyPreferredBoundaryShortcut(
|
|
normalized,
|
|
shortcutSourceNode,
|
|
targetNode,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
requireUnderNodeImprovement: false,
|
|
minLineClearance,
|
|
out var preferredShortcut))
|
|
{
|
|
normalized = preferredShortcut;
|
|
}
|
|
|
|
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundarySide(assignedEndpoint, targetNode);
|
|
if (IsOnWrongSideOfTarget(normalized[^2], targetNode, targetSide, 0.5d)
|
|
&& TryResolveNonGatewayBacktrackingEndpoint(normalized, targetNode, out var correctedSide, out var correctedBoundary))
|
|
{
|
|
targetSide = correctedSide;
|
|
assignedEndpoint = correctedBoundary;
|
|
}
|
|
|
|
if (HasTargetApproachBacktracking(normalized, targetNode)
|
|
&& TryResolveNonGatewayBacktrackingEndpoint(normalized, targetNode, out var preferredSide, out var preferredBoundary))
|
|
{
|
|
targetSide = preferredSide;
|
|
assignedEndpoint = preferredBoundary;
|
|
}
|
|
|
|
if (!HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode))
|
|
{
|
|
var alignedAssignedSideEntry = NormalizeEntryPath(normalized, targetNode, targetSide, assignedEndpoint);
|
|
if (HasClearBoundarySegments(alignedAssignedSideEntry, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)
|
|
&& HasValidBoundaryAngle(alignedAssignedSideEntry[^1], alignedAssignedSideEntry[^2], targetNode))
|
|
{
|
|
normalized = alignedAssignedSideEntry;
|
|
}
|
|
else
|
|
{
|
|
var preferredEntrySide = ResolvePreferredRectTargetEntrySide(normalized, targetNode);
|
|
if (!string.Equals(preferredEntrySide, targetSide, StringComparison.Ordinal))
|
|
{
|
|
targetSide = preferredEntrySide;
|
|
assignedEndpoint = BuildRectBoundaryPointForSide(targetNode, targetSide, normalized[^2]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!ElkEdgeRoutingGeometry.PointsEqual(assignedEndpoint, normalized[^1])
|
|
|| !HasValidBoundaryAngle(normalized[^1], normalized[^2], targetNode))
|
|
{
|
|
var targetNormalized = NormalizeEntryPath(normalized, targetNode, targetSide, assignedEndpoint);
|
|
if (HasClearBoundarySegments(targetNormalized, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3))
|
|
{
|
|
normalized = targetNormalized;
|
|
}
|
|
}
|
|
|
|
var shortenedApproach = TrimTargetApproachBacktracking(normalized, targetNode, targetSide, assignedEndpoint);
|
|
if (PathChanged(normalized, shortenedApproach)
|
|
&& HasClearBoundarySegments(shortenedApproach, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)
|
|
&& HasValidBoundaryAngle(shortenedApproach[^1], shortenedApproach[^2], targetNode))
|
|
{
|
|
normalized = shortenedApproach;
|
|
}
|
|
|
|
if (HasTargetApproachBacktracking(normalized, targetNode)
|
|
&& TryNormalizeNonGatewayBacktrackingEntry(normalized, targetNode, out var backtrackingRepair)
|
|
&& HasClearBoundarySegments(backtrackingRepair, nodes, edge.SourceNodeId, edge.TargetNodeId, false, 3)
|
|
&& HasValidBoundaryAngle(backtrackingRepair[^1], backtrackingRepair[^2], targetNode))
|
|
{
|
|
normalized = backtrackingRepair;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (normalized.Count == path.Count
|
|
&& normalized.Zip(path, ElkEdgeRoutingGeometry.PointsEqual).All(equal => equal))
|
|
{
|
|
result[i] = edge;
|
|
continue;
|
|
}
|
|
|
|
result[i] = BuildSingleSectionEdge(edge, normalized);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
internal static ElkRoutedEdge[] ElevateUnderNodeViolations(
|
|
ElkRoutedEdge[] edges,
|
|
ElkPositionedNode[] nodes,
|
|
double minLineClearance,
|
|
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
|
{
|
|
if (edges.Length == 0 || nodes.Length == 0)
|
|
{
|
|
return edges;
|
|
}
|
|
|
|
var restrictedSet = restrictedEdgeIds is null
|
|
? null
|
|
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
|
var result = edges.ToArray();
|
|
for (var i = 0; i < result.Length; i++)
|
|
{
|
|
var edge = result[i];
|
|
if (restrictedSet is not null && !restrictedSet.Contains(edge.Id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = ExtractFullPath(edge);
|
|
if (path.Count < 2)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (TryResolveUnderNodeWithPreferredShortcut(
|
|
edge,
|
|
path,
|
|
nodes,
|
|
minLineClearance,
|
|
out var directRepair))
|
|
{
|
|
var currentLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(edge, nodes);
|
|
var repairedEdge = BuildSingleSectionEdge(edge, directRepair);
|
|
repairedEdge = ResolveUnderNodePeerTargetConflicts(
|
|
repairedEdge,
|
|
result,
|
|
i,
|
|
nodes,
|
|
minLineClearance);
|
|
var repairedPath = ExtractFullPath(repairedEdge);
|
|
var repairedUnderNodeSegments = CountUnderNodeSegments(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance);
|
|
var currentUnderNodeSegments = CountUnderNodeSegments(path, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance);
|
|
var repairedCrossesNode = HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId);
|
|
var repairedLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(repairedEdge, nodes);
|
|
WriteUnderNodeDebug(
|
|
edge.Id,
|
|
$"accept-check raw current={currentUnderNodeSegments} repaired={repairedUnderNodeSegments} crossing={repairedCrossesNode} local={repairedLocalHardPressure}/{currentLocalHardPressure} repaired={FormatPath(repairedPath)}");
|
|
if (repairedUnderNodeSegments < currentUnderNodeSegments
|
|
&& !repairedCrossesNode
|
|
&& repairedLocalHardPressure <= currentLocalHardPressure)
|
|
{
|
|
WriteUnderNodeDebug(edge.Id, "accept-check raw accepted");
|
|
result[i] = repairedUnderNodeSegments == 0
|
|
? ProtectUnderNodeGeometry(repairedEdge)
|
|
: repairedEdge;
|
|
continue;
|
|
}
|
|
|
|
repairedEdge = RepairBoundaryAnglesAndTargetApproaches(
|
|
[repairedEdge],
|
|
nodes,
|
|
minLineClearance)[0];
|
|
repairedEdge = FinalizeGatewayBoundaryGeometry([repairedEdge], nodes)[0];
|
|
repairedEdge = NormalizeBoundaryAngles([repairedEdge], nodes)[0];
|
|
repairedEdge = NormalizeSourceExitAngles([repairedEdge], nodes)[0];
|
|
repairedEdge = ResolveUnderNodePeerTargetConflicts(
|
|
repairedEdge,
|
|
result,
|
|
i,
|
|
nodes,
|
|
minLineClearance);
|
|
repairedPath = ExtractFullPath(repairedEdge);
|
|
repairedUnderNodeSegments = CountUnderNodeSegments(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance);
|
|
repairedCrossesNode = HasNodeObstacleCrossing(repairedPath, nodes, edge.SourceNodeId, edge.TargetNodeId);
|
|
repairedLocalHardPressure = ComputeUnderNodeRepairLocalHardPressure(repairedEdge, nodes);
|
|
WriteUnderNodeDebug(
|
|
edge.Id,
|
|
$"accept-check normalized current={currentUnderNodeSegments} repaired={repairedUnderNodeSegments} crossing={repairedCrossesNode} local={repairedLocalHardPressure}/{currentLocalHardPressure} repaired={FormatPath(repairedPath)}");
|
|
if (repairedUnderNodeSegments < currentUnderNodeSegments
|
|
&& !repairedCrossesNode
|
|
&& repairedLocalHardPressure <= currentLocalHardPressure)
|
|
{
|
|
WriteUnderNodeDebug(edge.Id, "accept-check normalized accepted");
|
|
result[i] = repairedUnderNodeSegments == 0
|
|
? ProtectUnderNodeGeometry(repairedEdge)
|
|
: repairedEdge;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
var lifted = TryLiftUnderNodeSegments(
|
|
path,
|
|
nodes,
|
|
edge.SourceNodeId,
|
|
edge.TargetNodeId,
|
|
minLineClearance);
|
|
if (!PathChanged(path, lifted))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var liftedEdge = BuildSingleSectionEdge(edge, lifted);
|
|
liftedEdge = NormalizeBoundaryAngles([liftedEdge], nodes)[0];
|
|
liftedEdge = NormalizeSourceExitAngles([liftedEdge], nodes)[0];
|
|
var liftedPath = ExtractFullPath(liftedEdge);
|
|
if (CountUnderNodeSegments(liftedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance)
|
|
< CountUnderNodeSegments(path, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance)
|
|
&& !HasNodeObstacleCrossing(liftedPath, nodes, edge.SourceNodeId, edge.TargetNodeId))
|
|
{
|
|
result[i] = CountUnderNodeSegments(liftedPath, nodes, edge.SourceNodeId, edge.TargetNodeId, minLineClearance) == 0
|
|
? ProtectUnderNodeGeometry(liftedEdge)
|
|
: liftedEdge;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
internal static ElkRoutedEdge[] PolishTargetPeerConflicts(
|
|
ElkRoutedEdge[] edges,
|
|
ElkPositionedNode[] nodes,
|
|
double minLineClearance,
|
|
IReadOnlyCollection<string>? restrictedEdgeIds = null)
|
|
{
|
|
if (edges.Length < 2 || nodes.Length == 0)
|
|
{
|
|
return edges;
|
|
}
|
|
|
|
var restrictedSet = restrictedEdgeIds is null
|
|
? null
|
|
: restrictedEdgeIds.ToHashSet(StringComparer.Ordinal);
|
|
var result = edges.ToArray();
|
|
for (var i = 0; i < result.Length; i++)
|
|
{
|
|
if (restrictedSet is not null && !restrictedSet.Contains(result[i].Id))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
result[i] = ResolveUnderNodePeerTargetConflicts(
|
|
result[i],
|
|
result,
|
|
i,
|
|
nodes,
|
|
minLineClearance);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|