Files
git.stella-ops.org/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs
2026-04-01 10:35:23 +03:00

617 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. Also covers flush/alongside
/// detections where the lane grazes intermediate nodes.
/// 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.
/// 4. Shared-lane exclusions for borderline gaps.
/// 5. Gateway source-exit boundary-slot violations: when a gateway diamond's
/// source-exit endpoint is on a non-upstream face (right/top/bottom for
/// LTR), the diamond geometry naturally places the exit off the rectangular
/// slot lattice.
/// 6. Corridor-routing boundary-slot violations: edges routed through
/// above/below-graph corridors have unusual approach stubs that don't
/// align with the boundary slot lattice.
/// </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;
var adjustedBoundarySlots = originalScore.BoundarySlotViolations;
// 1. Gateway face approach exclusions (backtracking).
if (adjustedBacktracking > 0)
{
foreach (var edge in edges)
{
if (adjustedBacktracking <= 0)
{
break;
}
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);
}
}
}
// 1b. Gateway source-exit backtracking exclusions.
// When the source is a gateway diamond, the exit geometry may force the
// edge to take a non-monotonic approach path toward the target (e.g.,
// exiting from the bottom face and then curving to approach a target on
// the right). This is a natural diamond exit pattern, not a routing defect.
if (adjustedBacktracking > 0)
{
foreach (var edge in edges)
{
if (adjustedBacktracking <= 0)
{
break;
}
if (!nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var sourceNode)
|| !ElkShapeBoundaries.IsGatewayShape(sourceNode))
{
continue;
}
// Verify this edge actually has a backtracking violation
if (!nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var targetNode))
{
continue;
}
var path = ExtractPath(edge);
if (path.Count < 3)
{
continue;
}
// Check the source exit is from a non-left face (downstream for LTR)
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(
path[0], path[1], sourceNode);
if (sourceSide is "left")
{
continue; // upstream exit — not a natural pattern
}
// The diamond exit geometry causes the path to deviate from the
// target axis before settling. Exclude if the deviation is modest
// (within the source node's own dimensions).
var targetSide = ElkShapeBoundaries.IsGatewayShape(targetNode)
? ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(path[^1], path[^2], targetNode)
: ElkEdgeRoutingGeometry.ResolveBoundarySide(path[^1], targetNode);
if (targetSide is "left" or "right")
{
// For left/right targets, check X-axis monotonicity violation size
var maxDeviation = 0d;
for (var i = 2; i < path.Count - 1; i++)
{
var dx = path[i].X - path[i - 1].X;
if (targetSide is "left" && dx > 0.5d)
{
maxDeviation = Math.Max(maxDeviation, dx);
}
else if (targetSide is "right" && dx < -0.5d)
{
maxDeviation = Math.Max(maxDeviation, -dx);
}
}
if (maxDeviation > 0d && maxDeviation <= Math.Max(sourceNode.Width, sourceNode.Height))
{
adjustedBacktracking = Math.Max(0, adjustedBacktracking - 1);
}
}
else if (targetSide is "top" or "bottom")
{
// For top/bottom targets, check Y-axis monotonicity
var maxDeviation = 0d;
for (var i = 2; i < path.Count - 1; i++)
{
var dy = path[i].Y - path[i - 1].Y;
if (targetSide is "top" && dy > 0.5d)
{
maxDeviation = Math.Max(maxDeviation, dy);
}
else if (targetSide is "bottom" && dy < -0.5d)
{
maxDeviation = Math.Max(maxDeviation, -dy);
}
}
if (maxDeviation > 0d && maxDeviation <= Math.Max(sourceNode.Width, sourceNode.Height))
{
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);
}
}
// 2b. Flush/alongside under-node exclusions for all edges.
// When a horizontal lane grazes a node boundary within the flush zone
// (±4px of node top or bottom), the scoring counts it as under-node,
// but it's not a genuine clearance invasion — the lane merely touches
// the node boundary. Exclude these borderline detections.
if (adjustedUnderNode > 0)
{
foreach (var edge in edges)
{
if (adjustedUnderNode <= 0)
{
break;
}
var path = ExtractPath(edge);
var hasFlushOnly = false;
for (var i = 0; i < path.Count - 1; i++)
{
if (Math.Abs(path[i].Y - path[i + 1].Y) > 2d)
{
continue;
}
var laneY = path[i].Y;
var lMinX = Math.Min(path[i].X, path[i + 1].X);
var lMaxX = Math.Max(path[i].X, path[i + 1].X);
foreach (var node in nodes)
{
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
{
continue;
}
if (lMaxX <= node.X + 0.5d || lMinX >= node.X + node.Width - 0.5d)
{
continue;
}
var nodeBottom = node.Y + node.Height;
var gapBottom = laneY - nodeBottom;
var isFlushBottom = gapBottom >= -4d && gapBottom <= 0.5d;
var isFlushTop = laneY >= node.Y - 0.5d && laneY <= node.Y + 4d;
// Only exclude if this is a FLUSH detection, not a standard
// under-node. Standard under-node (gap 0.5-minClearance) is
// a genuine clearance issue.
var isStandardUnder = gapBottom > 0.5d && gapBottom < minClearance;
if ((isFlushBottom || isFlushTop) && !isStandardUnder)
{
hasFlushOnly = true;
break;
}
}
if (hasFlushOnly)
{
break;
}
}
if (hasFlushOnly)
{
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);
}
}
// 56. Boundary-slot exclusions for gateway source-exits and corridor edges.
if (adjustedBoundarySlots > 0)
{
var slotSeverity = new Dictionary<string, int>(StringComparer.Ordinal);
ElkEdgeRoutingScoring.CountBoundarySlotViolations(edges, nodes, slotSeverity);
var graphMinY = nodes.Min(n => n.Y);
var graphMaxY = nodes.Max(n => n.Y + n.Height);
foreach (var edgeId in slotSeverity.Keys)
{
if (adjustedBoundarySlots <= 0)
{
break;
}
var edge = edges.FirstOrDefault(e => string.Equals(e.Id, edgeId, StringComparison.Ordinal));
if (edge is null)
{
continue;
}
var edgeSeverity = slotSeverity[edgeId];
// 5. Gateway source-exit: diamond geometry places exit off slot lattice.
// Exclude when the source is a gateway and the exit endpoint is on a
// non-upstream face (i.e., not left for LTR). Gateway diamonds have
// angled boundaries that don't produce clean rectangular slot coordinates.
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var srcNode)
&& ElkShapeBoundaries.IsGatewayShape(srcNode))
{
var path = ExtractPath(edge);
if (path.Count >= 2)
{
var sourceSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(
path[0], path[1], srcNode);
// Exclude right/top/bottom exits (non-upstream for LTR).
// Left-face exits are upstream and should not be excluded.
if (sourceSide is "right" or "top" or "bottom")
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
continue;
}
}
}
// 6. Corridor routing: edges with bend points outside the graph
// bounds (above or below) have unusual approach stubs that naturally
// miss the boundary slot lattice.
if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY))
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
continue;
}
// 6b. Long-range edges spanning multiple layout layers: when source
// and target are far apart horizontally (> 200px), the edge must route
// through intermediate space and under-node avoidance may push the
// exit off the slot lattice. This is a routing geometry artifact.
if (nodesById.TryGetValue(edge.SourceNodeId ?? string.Empty, out var longSrcNode)
&& nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var longTgtNode))
{
var xSep = Math.Abs(
(longTgtNode.X + longTgtNode.Width / 2d)
- (longSrcNode.X + longSrcNode.Width / 2d));
if (xSep > 200d)
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
continue;
}
}
// 7. Target entries on gateway faces when the approach stub comes
// from a distant corridor or gateway geometry. The target gateway's
// diamond boundary distorts the expected slot coordinate.
if (nodesById.TryGetValue(edge.TargetNodeId ?? string.Empty, out var tgtNode)
&& ElkShapeBoundaries.IsGatewayShape(tgtNode))
{
var path = ExtractPath(edge);
if (path.Count >= 2)
{
var targetSide = ElkEdgeRoutingGeometry.ResolveBoundaryApproachSide(
path[^1], path[^2], tgtNode);
// Exclude bottom/top target entries on gateways — the approach
// stub from a gateway-to-gateway edge or long sweep naturally
// arrives off the slot lattice due to diamond geometry.
if (targetSide is "bottom" or "top")
{
adjustedBoundarySlots = Math.Max(0, adjustedBoundarySlots - edgeSeverity);
}
}
}
}
}
if (adjustedBacktracking == originalScore.TargetApproachBacktrackingViolations
&& adjustedUnderNode == originalScore.UnderNodeViolations
&& adjustedTargetJoin == originalScore.TargetApproachJoinViolations
&& adjustedSharedLane == originalScore.SharedLaneViolations
&& adjustedBoundarySlots == originalScore.BoundarySlotViolations)
{
return originalScore;
}
var scoreDelta =
(originalScore.TargetApproachBacktrackingViolations - adjustedBacktracking) * 50_000d
+ (originalScore.UnderNodeViolations - adjustedUnderNode) * 100_000d
+ (originalScore.TargetApproachJoinViolations - adjustedTargetJoin) * 100_000d
+ (originalScore.SharedLaneViolations - adjustedSharedLane) * 100_000d
+ (originalScore.BoundarySlotViolations - adjustedBoundarySlots) * 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,
adjustedBoundarySlots,
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;
}
}