- 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>
299 lines
12 KiB
C#
299 lines
12 KiB
C#
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgePostProcessor
|
|
{
|
|
private static List<ElkPoint> TryBuildGatewaySourceDominantBlockerEscapePath(
|
|
IReadOnlyList<ElkPoint> sourcePath,
|
|
ElkPositionedNode sourceNode,
|
|
IReadOnlyCollection<ElkPositionedNode> nodes,
|
|
string? sourceNodeId,
|
|
string? targetNodeId)
|
|
{
|
|
var path = sourcePath
|
|
.Select(point => new ElkPoint { X = point.X, Y = point.Y })
|
|
.ToList();
|
|
if (sourceNode.Kind != "Decision"
|
|
|| path.Count < 3
|
|
|| !HasGatewaySourceDominantAxisDetour(path, sourceNode))
|
|
{
|
|
return path;
|
|
}
|
|
|
|
var centerX = sourceNode.X + (sourceNode.Width / 2d);
|
|
var centerY = sourceNode.Y + (sourceNode.Height / 2d);
|
|
var desiredDx = path[^1].X - centerX;
|
|
var desiredDy = path[^1].Y - centerY;
|
|
var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0;
|
|
if (!dominantVertical)
|
|
{
|
|
return path;
|
|
}
|
|
|
|
var clearance = 24d;
|
|
var direction = Math.Sign(desiredDy);
|
|
var targetNode = string.IsNullOrWhiteSpace(targetNodeId)
|
|
? null
|
|
: nodes.FirstOrDefault(node => string.Equals(node.Id, targetNodeId, StringComparison.Ordinal));
|
|
var firstExteriorIndex = FindFirstGatewayExteriorPointIndex(path, sourceNode);
|
|
List<ElkPoint>? bestCandidate = null;
|
|
var bestScore = double.PositiveInfinity;
|
|
|
|
foreach (var continuationIndex in EnumerateGatewayDirectRepairContinuationIndices(path, sourceNode, firstExteriorIndex))
|
|
{
|
|
var anchorX = path[continuationIndex].X;
|
|
if (Math.Abs(anchorX - path[0].X) <= 3d)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var dominantReference = new ElkPoint { X = anchorX, Y = path[^1].Y };
|
|
if (!TryResolvePreferredGatewaySourceBoundary(sourceNode, dominantReference, path[^1], out var provisionalBoundary))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var stemBlockers = nodes
|
|
.Where(node =>
|
|
!string.Equals(node.Id, sourceNodeId, StringComparison.Ordinal)
|
|
&& !string.Equals(node.Id, targetNodeId, StringComparison.Ordinal)
|
|
&& provisionalBoundary.X > node.X + 0.5d
|
|
&& provisionalBoundary.X < node.X + node.Width - 0.5d
|
|
&& (direction > 0d
|
|
? node.Y > provisionalBoundary.Y + 0.5d && node.Y < path[^1].Y - 0.5d
|
|
: node.Y + node.Height < provisionalBoundary.Y - 0.5d && node.Y + node.Height > path[^1].Y + 0.5d))
|
|
.OrderBy(node => direction > 0d ? node.Y : -(node.Y + node.Height))
|
|
.ToArray();
|
|
if (stemBlockers.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var blocker in stemBlockers)
|
|
{
|
|
var escapeY = direction > 0d
|
|
? blocker.Y - clearance
|
|
: blocker.Y + blocker.Height + clearance;
|
|
if (direction > 0d)
|
|
{
|
|
if (escapeY <= provisionalBoundary.Y + 8d || escapeY >= blocker.Y - 0.5d)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
else if (escapeY >= provisionalBoundary.Y - 8d || escapeY <= blocker.Y + blocker.Height + 0.5d)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var continuationPoint = new ElkPoint { X = anchorX, Y = escapeY };
|
|
var boundary = provisionalBoundary;
|
|
|
|
var candidate = BuildGatewaySourceRepairPath(
|
|
path,
|
|
sourceNode,
|
|
boundary,
|
|
continuationPoint,
|
|
continuationIndex,
|
|
continuationPoint);
|
|
if (!PathChanged(path, candidate)
|
|
|| !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, sourceNode, fromStart: true)
|
|
|| !HasClearBoundarySegments(candidate, nodes, sourceNodeId, targetNodeId, true, Math.Min(4, candidate.Count - 1))
|
|
|| (targetNode is not null
|
|
&& (ElkShapeBoundaries.IsGatewayShape(targetNode)
|
|
? !HasAcceptableGatewayBoundaryPath(candidate, nodes, sourceNodeId, targetNodeId, targetNode, fromStart: false)
|
|
: candidate.Count < 2 || !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode)))
|
|
|| HasGatewaySourceExitBacktracking(candidate)
|
|
|| HasGatewaySourceExitCurl(candidate)
|
|
|| HasGatewaySourceDominantAxisDetour(candidate, sourceNode)
|
|
|| HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var score = ScoreGatewayDirectRepairCandidate(path, candidate, sourceNode, continuationIndex);
|
|
if (score >= bestScore)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bestScore = score;
|
|
bestCandidate = candidate;
|
|
}
|
|
}
|
|
|
|
if (bestCandidate is null)
|
|
{
|
|
return path;
|
|
}
|
|
|
|
return IsMaterialGatewaySourceRepairImprovement(path, bestCandidate)
|
|
|| IsGatewaySourceGeometryRepairImprovement(path, bestCandidate, sourceNode)
|
|
? bestCandidate
|
|
: path;
|
|
}
|
|
|
|
private static List<ElkPoint>? TryBuildDominantAxisGatewaySourceBypassPath(
|
|
ElkPositionedNode sourceNode,
|
|
ElkPositionedNode targetNode,
|
|
ElkPoint boundary,
|
|
ElkPoint targetEndpoint,
|
|
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
|
|
string? sourceNodeId,
|
|
string? targetNodeId,
|
|
bool dominantHorizontal,
|
|
double desiredDx,
|
|
double desiredDy)
|
|
{
|
|
const double padding = 8d;
|
|
const double coordinateTolerance = 0.5d;
|
|
var sourceId = sourceNodeId ?? string.Empty;
|
|
var targetId = targetNodeId ?? string.Empty;
|
|
List<ElkPoint>? bestCandidate = null;
|
|
var bestScore = double.PositiveInfinity;
|
|
|
|
void ConsiderCandidate(List<ElkPoint> rawCandidate)
|
|
{
|
|
var candidate = NormalizePathPoints(rawCandidate);
|
|
if (candidate.Count < 2
|
|
|| !IsPathClearOfObstacles(candidate, obstacles, sourceId, targetId)
|
|
|| !HasValidBoundaryAngle(candidate[^1], candidate[^2], targetNode)
|
|
|| HasGatewaySourceExitBacktracking(candidate)
|
|
|| HasGatewaySourceExitCurl(candidate)
|
|
|| HasGatewaySourceDominantAxisDetour(candidate, sourceNode)
|
|
|| HasGatewaySourcePreferredFaceMismatch(candidate, sourceNode))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var score = ComputePathLength(candidate) + (Math.Max(0, candidate.Count - 2) * 6d);
|
|
if (score >= bestScore)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bestScore = score;
|
|
bestCandidate = candidate;
|
|
}
|
|
|
|
if (dominantHorizontal)
|
|
{
|
|
var movingRight = desiredDx >= 0d;
|
|
var firstBlocker = obstacles
|
|
.Where(ob => !string.Equals(ob.Id, sourceId, StringComparison.Ordinal)
|
|
&& !string.Equals(ob.Id, targetId, StringComparison.Ordinal)
|
|
&& boundary.Y > ob.Top + coordinateTolerance
|
|
&& boundary.Y < ob.Bottom - coordinateTolerance
|
|
&& (movingRight
|
|
? ob.Left > boundary.X + coordinateTolerance && ob.Left < targetEndpoint.X - coordinateTolerance
|
|
: ob.Right < boundary.X - coordinateTolerance && ob.Right > targetEndpoint.X + coordinateTolerance))
|
|
.OrderBy(ob => movingRight ? ob.Left : -ob.Right)
|
|
.FirstOrDefault();
|
|
if (string.IsNullOrWhiteSpace(firstBlocker.Id))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var axisX = movingRight
|
|
? firstBlocker.Left - padding
|
|
: firstBlocker.Right + padding;
|
|
if (movingRight ? axisX <= boundary.X + 2d : axisX >= boundary.X - 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var bypassYCandidates = new List<double>();
|
|
AddUniqueCoordinate(bypassYCandidates, targetEndpoint.Y);
|
|
AddUniqueCoordinate(bypassYCandidates, firstBlocker.Top - padding);
|
|
AddUniqueCoordinate(bypassYCandidates, firstBlocker.Bottom + padding);
|
|
foreach (var bypassY in bypassYCandidates)
|
|
{
|
|
var diagonalLead = new List<ElkPoint> { boundary };
|
|
var diagonalLeadPoint = new ElkPoint { X = axisX, Y = bypassY };
|
|
if (!ElkEdgeRoutingGeometry.PointsEqual(diagonalLead[^1], diagonalLeadPoint))
|
|
{
|
|
diagonalLead.Add(diagonalLeadPoint);
|
|
}
|
|
|
|
AppendNonGatewayTargetBoundaryApproach(diagonalLead, targetNode, targetEndpoint);
|
|
ConsiderCandidate(diagonalLead);
|
|
|
|
var rebuilt = new List<ElkPoint>
|
|
{
|
|
boundary,
|
|
new() { X = axisX, Y = boundary.Y },
|
|
};
|
|
|
|
if (Math.Abs(rebuilt[^1].Y - bypassY) > coordinateTolerance)
|
|
{
|
|
rebuilt.Add(new ElkPoint { X = axisX, Y = bypassY });
|
|
}
|
|
|
|
AppendNonGatewayTargetBoundaryApproach(rebuilt, targetNode, targetEndpoint);
|
|
|
|
ConsiderCandidate(rebuilt);
|
|
}
|
|
|
|
return bestCandidate;
|
|
}
|
|
|
|
var movingDown = desiredDy >= 0d;
|
|
var verticalBlocker = obstacles
|
|
.Where(ob => !string.Equals(ob.Id, sourceId, StringComparison.Ordinal)
|
|
&& !string.Equals(ob.Id, targetId, StringComparison.Ordinal)
|
|
&& boundary.X > ob.Left + coordinateTolerance
|
|
&& boundary.X < ob.Right - coordinateTolerance
|
|
&& (movingDown
|
|
? ob.Top > boundary.Y + coordinateTolerance && ob.Top < targetEndpoint.Y - coordinateTolerance
|
|
: ob.Bottom < boundary.Y - coordinateTolerance && ob.Bottom > targetEndpoint.Y + coordinateTolerance))
|
|
.OrderBy(ob => movingDown ? ob.Top : -ob.Bottom)
|
|
.FirstOrDefault();
|
|
if (string.IsNullOrWhiteSpace(verticalBlocker.Id))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var axisY = movingDown
|
|
? verticalBlocker.Top - padding
|
|
: verticalBlocker.Bottom + padding;
|
|
if (movingDown ? axisY <= boundary.Y + 2d : axisY >= boundary.Y - 2d)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var bypassXCandidates = new List<double>();
|
|
AddUniqueCoordinate(bypassXCandidates, targetEndpoint.X);
|
|
AddUniqueCoordinate(bypassXCandidates, verticalBlocker.Left - padding);
|
|
AddUniqueCoordinate(bypassXCandidates, verticalBlocker.Right + padding);
|
|
foreach (var bypassX in bypassXCandidates)
|
|
{
|
|
var diagonalLead = new List<ElkPoint> { boundary };
|
|
var diagonalLeadPoint = new ElkPoint { X = bypassX, Y = axisY };
|
|
if (!ElkEdgeRoutingGeometry.PointsEqual(diagonalLead[^1], diagonalLeadPoint))
|
|
{
|
|
diagonalLead.Add(diagonalLeadPoint);
|
|
}
|
|
|
|
AppendNonGatewayTargetBoundaryApproach(diagonalLead, targetNode, targetEndpoint);
|
|
ConsiderCandidate(diagonalLead);
|
|
|
|
var rebuilt = new List<ElkPoint>
|
|
{
|
|
boundary,
|
|
new() { X = boundary.X, Y = axisY },
|
|
};
|
|
|
|
if (Math.Abs(rebuilt[^1].X - bypassX) > coordinateTolerance)
|
|
{
|
|
rebuilt.Add(new ElkPoint { X = bypassX, Y = axisY });
|
|
}
|
|
|
|
AppendNonGatewayTargetBoundaryApproach(rebuilt, targetNode, targetEndpoint);
|
|
|
|
ConsiderCandidate(rebuilt);
|
|
}
|
|
|
|
return bestCandidate;
|
|
}
|
|
|
|
}
|