Files
git.stella-ops.org/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.HorizontalRouting.cs
master 55a8d2ff51 Unify minLineClearance across pipeline via ElkLayoutClearance
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>
2026-04-01 16:59:18 +03:00

141 lines
5.3 KiB
C#

namespace StellaOps.ElkSharp;
/// <summary>
/// Y-axis counterpart to <see cref="ElkEdgeChannelGutters.ExpandVerticalCorridorGutters"/>.
/// After edges are routed, detects horizontal segments that pass too close to
/// (or flush with) non-source/target nodes. Inserts horizontal "gutters" by
/// shifting all nodes below the violation Y downward, creating routing space.
///
/// This is the architectural fix for under-node, alongside, and shared-lane
/// violations: instead of patching edge paths after routing, create adequate
/// routing corridors in the node placement so edges route cleanly on the first pass.
/// </summary>
internal static class ElkEdgeHorizontalRoutingGutters
{
internal static bool ExpandHorizontalRoutingGutters(
Dictionary<string, ElkPositionedNode> positionedNodes,
IReadOnlyCollection<ElkRoutedEdge> routedEdges,
IReadOnlyDictionary<string, ElkNode> nodesById,
double nodeSpacing,
ElkLayoutDirection direction)
{
if (direction != ElkLayoutDirection.LeftToRight)
{
return false;
}
var nodes = positionedNodes.Values.ToArray();
if (nodes.Length == 0)
{
return false;
}
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;
// Use layout-wide clearance if available (scales with NodeSpacing).
var overrideClearance = ElkLayoutClearance.Current;
if (overrideClearance > 0d)
{
minClearance = overrideClearance;
}
// Scan routed edges for horizontal segments with under-node or alongside violations.
var gutterRequirements = new Dictionary<double, double>(); // gutterY → requiredShift
foreach (var edge in routedEdges)
{
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge))
{
if (Math.Abs(segment.Start.Y - segment.End.Y) > 2d)
{
continue; // not horizontal
}
var laneY = segment.Start.Y;
var minX = Math.Min(segment.Start.X, segment.End.X);
var maxX = Math.Max(segment.Start.X, segment.End.X);
foreach (var node in nodes)
{
if (string.Equals(node.Id, edge.SourceNodeId, StringComparison.Ordinal)
|| string.Equals(node.Id, edge.TargetNodeId, StringComparison.Ordinal))
{
continue;
}
// Check X overlap
if (maxX <= node.X + 0.5d || minX >= node.X + node.Width - 0.5d)
{
continue;
}
var nodeBottom = node.Y + node.Height;
// Under-node: lane is below node bottom within clearance
var gapBelow = laneY - nodeBottom;
if (gapBelow > -4d && gapBelow < minClearance)
{
var needed = minClearance - gapBelow + 4d;
var gutterY = nodeBottom;
if (!gutterRequirements.TryGetValue(gutterY, out var existing) || needed > existing)
{
gutterRequirements[gutterY] = needed;
}
}
// Above-node: lane is above node top within clearance
var gapAbove = node.Y - laneY;
if (gapAbove > -4d && gapAbove < minClearance)
{
var needed = minClearance - gapAbove + 4d;
var gutterY = laneY - needed;
if (!gutterRequirements.TryGetValue(gutterY, out var existing) || needed > existing)
{
gutterRequirements[gutterY] = needed;
}
}
}
}
}
if (gutterRequirements.Count == 0)
{
return false;
}
// Sort gutters from bottom to top (process bottom-most first to avoid
// cascading shifts that change gutter positions).
var orderedGutters = gutterRequirements
.OrderByDescending(g => g.Key)
.ToArray();
// Apply shifts: for each gutter, shift all nodes whose center Y is below
// the gutter Y downward by the required amount.
foreach (var (gutterY, requiredShift) in orderedGutters)
{
foreach (var nodeId in positionedNodes.Keys.ToArray())
{
var node = positionedNodes[nodeId];
var nodeCenterY = node.Y + (node.Height / 2d);
if (nodeCenterY <= gutterY)
{
continue; // node is above the gutter — don't shift
}
if (!nodesById.TryGetValue(nodeId, out var nodeSpec))
{
continue;
}
positionedNodes[nodeId] = ElkLayoutHelpers.CreatePositionedNode(
nodeSpec, node.X, node.Y + requiredShift, direction);
}
}
return true;
}
}