Add ElkLayoutClearance (thread-static scoped holder) so all 15+ ResolveMinLineClearance call sites in scoring/post-processing use the same NodeSpacing-aware clearance as the iterative optimizer. Formula: max(avgNodeSize/2, nodeSpacing * 1.2) At NodeSpacing=40: max(52.7, 48) = 52.7 (unchanged) At NodeSpacing=60: max(52.7, 72) = 72 (wider corridors) The infrastructure is in place. Wider spacing (50+) still needs routing-level tuning for the different edge convergence patterns that arise from different node arrangements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
174 lines
6.0 KiB
C#
174 lines
6.0 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
|
|
namespace StellaOps.ElkSharp;
|
|
|
|
internal static partial class ElkEdgeRouterIterative
|
|
{
|
|
private static List<ElkPoint>? TryBuildLocalObstacleSkirtPath(
|
|
ElkPoint start,
|
|
ElkPoint end,
|
|
IReadOnlyCollection<ElkPositionedNode> nodes,
|
|
string sourceId,
|
|
string targetId,
|
|
ElkPositionedNode? targetNode,
|
|
double obstaclePadding)
|
|
{
|
|
var obstacles = nodes.Select(node => (
|
|
Left: node.X - obstaclePadding,
|
|
Top: node.Y - obstaclePadding,
|
|
Right: node.X + node.Width + obstaclePadding,
|
|
Bottom: node.Y + node.Height + obstaclePadding,
|
|
Id: node.Id)).ToArray();
|
|
List<ElkPoint>? bestPath = null;
|
|
var bestScore = double.MaxValue;
|
|
|
|
bool SegmentIsClear(ElkPoint from, ElkPoint to) =>
|
|
!ElkEdgePostProcessor.SegmentCrossesObstacle(from, to, obstacles, sourceId, targetId);
|
|
|
|
void ConsiderCandidate(IReadOnlyList<ElkPoint> rawCandidate)
|
|
{
|
|
var candidate = NormalizePolyline(rawCandidate);
|
|
if (candidate.Count < 2)
|
|
{
|
|
return;
|
|
}
|
|
|
|
for (var i = 1; i < candidate.Count; i++)
|
|
{
|
|
if (!SegmentIsClear(candidate[i - 1], candidate[i]))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (targetNode is not null
|
|
&& !ElkShapeBoundaries.IsGatewayShape(targetNode)
|
|
&& HasTargetApproachBacktracking(candidate, targetNode))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var score = ComputePolylineLength(candidate) + (Math.Max(0, candidate.Count - 2) * 4d);
|
|
if (score >= bestScore - 0.5d)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bestScore = score;
|
|
bestPath = candidate;
|
|
}
|
|
|
|
var horizontalDominant = Math.Abs(end.X - start.X) >= Math.Abs(end.Y - start.Y);
|
|
if (horizontalDominant)
|
|
{
|
|
var targetBridgeX = end.X;
|
|
if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode))
|
|
{
|
|
targetBridgeX = ResolveGatewayRoutingApproachPoint(targetNode, start, end).X;
|
|
}
|
|
|
|
var minX = Math.Min(start.X, end.X) + 0.5d;
|
|
var maxX = Math.Max(start.X, end.X) - 0.5d;
|
|
var corridorTop = Math.Min(start.Y, end.Y) - obstaclePadding;
|
|
var corridorBottom = Math.Max(start.Y, end.Y) + obstaclePadding;
|
|
var bypassYCandidates = new List<double> { start.Y, end.Y };
|
|
foreach (var obstacle in obstacles)
|
|
{
|
|
if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal)
|
|
|| string.Equals(obstacle.Id, targetId, StringComparison.Ordinal)
|
|
|| obstacle.Right <= minX
|
|
|| obstacle.Left >= maxX
|
|
|| obstacle.Bottom <= corridorTop
|
|
|| obstacle.Top >= corridorBottom)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
AddUniqueCoordinate(bypassYCandidates, obstacle.Top);
|
|
AddUniqueCoordinate(bypassYCandidates, obstacle.Bottom);
|
|
}
|
|
|
|
foreach (var bypassY in bypassYCandidates)
|
|
{
|
|
ConsiderCandidate(
|
|
[
|
|
start,
|
|
new ElkPoint { X = start.X, Y = bypassY },
|
|
new ElkPoint { X = targetBridgeX, Y = bypassY },
|
|
end,
|
|
]);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var targetBridgeY = end.Y;
|
|
if (targetNode is not null && ElkShapeBoundaries.IsGatewayShape(targetNode))
|
|
{
|
|
targetBridgeY = ResolveGatewayRoutingApproachPoint(targetNode, start, end).Y;
|
|
}
|
|
|
|
var minY = Math.Min(start.Y, end.Y) + 0.5d;
|
|
var maxY = Math.Max(start.Y, end.Y) - 0.5d;
|
|
var corridorLeft = Math.Min(start.X, end.X) - obstaclePadding;
|
|
var corridorRight = Math.Max(start.X, end.X) + obstaclePadding;
|
|
var bypassXCandidates = new List<double> { start.X, end.X };
|
|
foreach (var obstacle in obstacles)
|
|
{
|
|
if (string.Equals(obstacle.Id, sourceId, StringComparison.Ordinal)
|
|
|| string.Equals(obstacle.Id, targetId, StringComparison.Ordinal)
|
|
|| obstacle.Bottom <= minY
|
|
|| obstacle.Top >= maxY
|
|
|| obstacle.Right <= corridorLeft
|
|
|| obstacle.Left >= corridorRight)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
AddUniqueCoordinate(bypassXCandidates, obstacle.Left);
|
|
AddUniqueCoordinate(bypassXCandidates, obstacle.Right);
|
|
}
|
|
|
|
foreach (var bypassX in bypassXCandidates)
|
|
{
|
|
ConsiderCandidate(
|
|
[
|
|
start,
|
|
new ElkPoint { X = bypassX, Y = start.Y },
|
|
new ElkPoint { X = bypassX, Y = targetBridgeY },
|
|
end,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return bestPath;
|
|
}
|
|
|
|
private static double ComputePolylineLength(IReadOnlyList<ElkPoint> points)
|
|
{
|
|
var length = 0d;
|
|
for (var i = 1; i < points.Count; i++)
|
|
{
|
|
length += ElkEdgeRoutingGeometry.ComputeSegmentLength(points[i - 1], points[i]);
|
|
}
|
|
|
|
return length;
|
|
}
|
|
|
|
private static double ResolveMinLineClearance(IReadOnlyCollection<ElkPositionedNode> nodes)
|
|
{
|
|
var overrideClearance = ElkLayoutClearance.Current;
|
|
if (overrideClearance > 0d)
|
|
{
|
|
return overrideClearance;
|
|
}
|
|
|
|
var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray();
|
|
return serviceNodes.Length > 0
|
|
? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d
|
|
: 50d;
|
|
}
|
|
|
|
}
|