617 lines
28 KiB
C#
617 lines
28 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. 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);
|
||
}
|
||
}
|
||
|
||
// 5–6. 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;
|
||
}
|
||
}
|