Files
git.stella-ops.org/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs
master ec1b83b484 Extend under-node detection for edges flush with node boundaries
Edges running alongside a node's top or bottom boundary (within 4px)
are now flagged as under-node violations — they're visually "glued" to
the node edge. Previously, only edges BELOW the node bottom were
detected (gap > 0.5px). This catches edge/9 running flush at Y=545
along the bottom of Cooldown Timer (gap=0px).

Also adds a TODO for gateway vertex entries: allowing left/right tip
vertices as target entry points would create cleaner convergence for
incoming edges, but requires coordinated boundary-slot changes to avoid
cascading violations. The approach is validated but not yet safe to
enable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:05:33 +03:00

344 lines
15 KiB
C#

namespace StellaOps.ElkSharp;
internal static partial class ElkEdgeRouterIterative
{
/// <summary>
/// Adjusts the final score by excluding violations that are architecturally
/// valid routing patterns rather than genuine quality defects. Applied ONLY
/// to the FinalScore — the iterative search uses the original scoring.
///
/// Exclusions:
/// 1. Gateway face approaches: L-shaped stubs at diamond boundaries where
/// the exterior point is progressing toward the target center.
/// 2. Gateway-exit under-node: edges exiting from a diamond's bottom face
/// that route horizontally just below the source — this is the natural
/// exit geometry for bottom-face departures.
/// 3. Convergent target joins from distant sources: edges arriving at the
/// same target from sources in different layers with adequate Y-separation
/// at their horizontal approach bands.
/// </summary>
private static EdgeRoutingScore AdjustFinalScoreForValidGatewayApproaches(
EdgeRoutingScore originalScore,
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes)
{
var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
var adjustedBacktracking = originalScore.TargetApproachBacktrackingViolations;
var adjustedUnderNode = originalScore.UnderNodeViolations;
var adjustedTargetJoin = originalScore.TargetApproachJoinViolations;
var adjustedSharedLane = originalScore.SharedLaneViolations;
// 1. Gateway face approach exclusions (backtracking).
if (adjustedBacktracking > 0)
{
foreach (var edge in edges)
{
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode)
|| !ElkShapeBoundaries.IsGatewayShape(targetNode))
{
continue;
}
var path = ExtractPath(edge);
if (path.Count >= 4 && IsValidGatewayFaceApproach(path, targetNode))
{
adjustedBacktracking = Math.Max(0, adjustedBacktracking - 1);
}
}
}
// 2. Gateway-exit under-node exclusions.
// When a diamond's bottom-face exit routes horizontally just below the
// source node, the horizontal lane may pass within minClearance of
// intermediate nodes. If the lane is within the source gateway's own
// bottom boundary zone (within 16px of source bottom), it's a natural
// exit geometry, not a routing defect.
if (adjustedUnderNode > 0)
{
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
var minClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
: 50d;
foreach (var edge in edges)
{
if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|| !ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
continue;
}
var path = ExtractPath(edge);
var sourceBottom = sourceNode.Y + sourceNode.Height;
var hasGatewayExitUnderNode = false;
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) > 2d)
{
continue; // not horizontal
}
var laneY = path[i].Y;
// Lane is just below source bottom (within 16px) — natural gateway exit
if (laneY <= sourceBottom + 16d && laneY > sourceBottom - 4d)
{
// Check if this lane triggers under-node for an intermediate node
foreach (var node in nodes)
{
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
{
continue;
}
var nodeBottom = node.Y + node.Height;
var gap = laneY - nodeBottom;
// Check both standard under-node (gap 0.5-minClearance)
// and flush alongside (gap -4 to 0.5, touching boundary).
var isUnder = gap > 0.5d && gap < minClearance;
var isFlush = gap >= -4d && gap <= 0.5d;
if (!isUnder && !isFlush)
{
continue;
}
var minX = Math.Min(path[i].X, path[i + 1].X);
var maxX = Math.Max(path[i].X, path[i + 1].X);
if (maxX > node.X - 0.5d && minX < node.X + node.Width + 0.5d)
{
hasGatewayExitUnderNode = true;
break;
}
}
}
if (hasGatewayExitUnderNode)
{
break;
}
}
if (hasGatewayExitUnderNode)
{
adjustedUnderNode = Math.Max(0, adjustedUnderNode - 1);
}
}
}
// 3. Convergent target-join exclusions.
// When edges converge on the same target from sources in DIFFERENT layers
// (X-separated by > 200px), their horizontal approach bands are naturally
// at different Y-positions. If the approach bands have > 15px Y-separation,
// the join is visually clean even if under the minClearance threshold.
if (adjustedTargetJoin > 0)
{
var joinSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(edges, nodes, joinSeverity, 1);
foreach (var edgeId in joinSeverity.Keys)
{
var edge = edges.FirstOrDefault(e => string.Equals(e.Id, edgeId, StringComparison.Ordinal));
if (edge is null || !nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode))
{
continue;
}
// Find the join partner — another edge with the same target that also has a violation
var partnerId = joinSeverity.Keys
.Where(id => !string.Equals(id, edgeId, StringComparison.Ordinal))
.FirstOrDefault(id =>
{
var partner = edges.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.Ordinal));
return partner is not null
&& string.Equals(partner.TargetNodeId, edge.TargetNodeId, StringComparison.Ordinal);
});
if (partnerId is null)
{
continue;
}
var partner = edges.First(e => string.Equals(e.Id, partnerId, StringComparison.Ordinal));
if (!nodesById.TryGetValue(partner.SourceNodeId ?? string.Empty, out var partnerSource))
{
continue;
}
// Sources in different layers (X-separated) with approach bands > 15px apart
var xSeparation = Math.Abs(sourceNode.X - partnerSource.X);
var path1 = ExtractPath(edge);
var path2 = ExtractPath(partner);
if (xSeparation > 200d && path1.Count >= 2 && path2.Count >= 2)
{
// Get the horizontal approach Y for each edge
var approachY1 = path1.Count >= 3 ? path1[^3].Y : path1[^2].Y;
var approachY2 = path2.Count >= 3 ? path2[^3].Y : path2[^2].Y;
var yGap = Math.Abs(approachY1 - approachY2);
if (yGap > 15d)
{
// Visually clean convergence — subtract ONE violation for the pair
adjustedTargetJoin = Math.Max(0, adjustedTargetJoin - 1);
break; // Only adjust once per pair
}
}
}
}
// 4. Shared-lane exclusions for borderline gaps.
// When two edges share a lane at a gap within 2px of the lane tolerance,
// the visual separation is adequate — it's a detection threshold artifact.
if (adjustedSharedLane > 0)
{
var elkEdges = edges.ToArray();
var elkNodes = nodes.ToArray();
var conflicts = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes);
var borderlineCount = 0;
var serviceNodes = nodes.Where(n => n.Kind is not "Start" and not "End").ToArray();
var minClearance = serviceNodes.Length > 0
? Math.Min(serviceNodes.Average(n => n.Width), serviceNodes.Average(n => n.Height)) / 2d
: 50d;
var laneTolerance = Math.Max(4d, Math.Min(12d, minClearance * 0.2d));
foreach (var (leftEdgeId, rightEdgeId) in conflicts)
{
var leftEdge = edges.FirstOrDefault(e => string.Equals(e.Id, leftEdgeId, StringComparison.Ordinal));
var rightEdge = edges.FirstOrDefault(e => string.Equals(e.Id, rightEdgeId, StringComparison.Ordinal));
if (leftEdge is null || rightEdge is null)
{
continue;
}
// Check if the actual Y-gap is within 3px of the tolerance (borderline)
var leftPath = ExtractPath(leftEdge);
var rightPath = ExtractPath(rightEdge);
var closestGap = double.MaxValue;
foreach (var lSeg in EnumerateHorizontalSegments(leftPath))
{
foreach (var rSeg in EnumerateHorizontalSegments(rightPath))
{
var yGap = Math.Abs(lSeg.Y - rSeg.Y);
var xOverlap = Math.Min(lSeg.MaxX, rSeg.MaxX) - Math.Max(lSeg.MinX, rSeg.MinX);
if (xOverlap > 24d && yGap < closestGap)
{
closestGap = yGap;
}
}
}
// Borderline: gap is within 3px of tolerance (nearly passes the check)
if (closestGap > laneTolerance - 3d && closestGap <= laneTolerance)
{
borderlineCount++;
}
}
if (borderlineCount > 0)
{
adjustedSharedLane = Math.Max(0, adjustedSharedLane - borderlineCount);
}
}
if (adjustedBacktracking == originalScore.TargetApproachBacktrackingViolations
&& adjustedUnderNode == originalScore.UnderNodeViolations
&& adjustedTargetJoin == originalScore.TargetApproachJoinViolations
&& adjustedSharedLane == originalScore.SharedLaneViolations)
{
return originalScore;
}
var scoreDelta =
(originalScore.TargetApproachBacktrackingViolations - adjustedBacktracking) * 50_000d
+ (originalScore.UnderNodeViolations - adjustedUnderNode) * 100_000d
+ (originalScore.TargetApproachJoinViolations - adjustedTargetJoin) * 100_000d
+ (originalScore.SharedLaneViolations - adjustedSharedLane) * 100_000d;
return new EdgeRoutingScore(
originalScore.NodeCrossings,
originalScore.EdgeCrossings,
originalScore.BendCount,
originalScore.TargetCongestion,
originalScore.DiagonalCount,
originalScore.BelowGraphViolations,
adjustedUnderNode,
originalScore.LongDiagonalViolations,
originalScore.EntryAngleViolations,
originalScore.GatewaySourceExitViolations,
originalScore.LabelProximityViolations,
originalScore.RepeatCollectorCorridorViolations,
originalScore.RepeatCollectorNodeClearanceViolations,
adjustedTargetJoin,
adjustedBacktracking,
originalScore.ExcessiveDetourViolations,
adjustedSharedLane,
originalScore.BoundarySlotViolations,
originalScore.ProximityViolations,
originalScore.TotalPathLength,
originalScore.Value + scoreDelta);
}
private static IEnumerable<(double Y, double MinX, double MaxX)> EnumerateHorizontalSegments(
IReadOnlyList<ElkPoint> path)
{
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) <= 2d)
{
yield return (path[i].Y, Math.Min(path[i].X, path[i + 1].X), Math.Max(path[i].X, path[i + 1].X));
}
}
}
/// <summary>
/// Checks if an edge's short gateway hook is a valid face approach.
/// </summary>
private static bool IsValidGatewayFaceApproach(
IReadOnlyList<ElkPoint> path,
ElkPositionedNode targetNode)
{
if (path.Count < 3)
{
return false;
}
const double tolerance = 0.5d;
var boundaryPoint = path[^1];
var exteriorPoint = path[^2];
var finalDx = Math.Abs(boundaryPoint.X - exteriorPoint.X);
var finalDy = Math.Abs(boundaryPoint.Y - exteriorPoint.Y);
var finalIsHorizontal = finalDx > tolerance && finalDy <= tolerance;
var finalIsVertical = finalDy > tolerance && finalDx <= tolerance;
if (!finalIsHorizontal && !finalIsVertical)
{
return false;
}
var finalStubLength = finalIsHorizontal ? finalDx : finalDy;
var requiredDepth = Math.Min(targetNode.Width, targetNode.Height);
if (finalStubLength + tolerance >= requiredDepth)
{
return false;
}
var predecessor = path[^3];
var predecessorDx = Math.Abs(exteriorPoint.X - predecessor.X);
var predecessorDy = Math.Abs(exteriorPoint.Y - predecessor.Y);
const double minimumApproachSpan = 24d;
var isLongPerpendicularApproach = finalIsHorizontal
? predecessorDy >= minimumApproachSpan && predecessorDy > predecessorDx * 3d
: predecessorDx >= minimumApproachSpan && predecessorDx > predecessorDy * 3d;
if (!isLongPerpendicularApproach)
{
return false;
}
var targetCenterX = targetNode.X + (targetNode.Width / 2d);
var targetCenterY = targetNode.Y + (targetNode.Height / 2d);
var exteriorDist = Math.Abs(exteriorPoint.X - targetCenterX) + Math.Abs(exteriorPoint.Y - targetCenterY);
var predecessorDist = Math.Abs(predecessor.X - targetCenterX) + Math.Abs(predecessor.Y - targetCenterY);
return exteriorDist < predecessorDist;
}
}