From eac6625c6ee1d83d85c871a630d419ac30d68146 Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 29 Mar 2026 23:52:22 +0300 Subject: [PATCH] Add routing-aware Y-clearance infrastructure (not yet wired) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ElkEdgeVerticalClearance.EnforceEdgeRoutingClearance — the Y-axis counterpart to the existing X-axis gutter expansion. It identifies edge pairs with insufficient vertical clearance (< 12px Y-gap) and adjusts node Y-positions within their layer to create routing-viable corridors. Not wired into the layout pipeline yet: post-placement Y-adjustment disrupts the Sugiyama median-based positioning too much, causing cascading layout changes. The fix must be integrated INTO the Sugiyama placement iterations (inside ElkSharpLayoutInitialPlacement) rather than applied as a post-placement pass. This is tracked for a future sprint focused on routing-aware Sugiyama placement. Root cause analysis confirms all remaining violations (3 gateway hooks, 1 target join, 1 shared lane, 3 under-node) are caused by Y-gaps of 5px, 8px, and 22px between connected nodes — too narrow for clean orthogonal edge routing. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ElkEdgeChannelGutters.VerticalClearance.cs | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.VerticalClearance.cs diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.VerticalClearance.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.VerticalClearance.cs new file mode 100644 index 000000000..a0075c783 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.VerticalClearance.cs @@ -0,0 +1,180 @@ +namespace StellaOps.ElkSharp; + +/// +/// Ensures adequate vertical (Y-axis) clearance between nodes connected by edges. +/// The Sugiyama placement uses median-based positioning that can pack connected nodes +/// too tightly, leaving insufficient Y-space for clean edge routing. This pass shifts +/// nodes within their layer to create routing-viable corridors. +/// +/// This is the Y-axis counterpart to the X-axis gutter expansion in +/// . +/// +internal static class ElkEdgeVerticalClearance +{ + /// + /// Minimum Y-clearance between source and target nodes for edges that need + /// orthogonal routing space. When nodes overlap or are too close vertically, + /// edges must route through their vertical space, causing under-node violations, + /// shared-lane conflicts, and short gateway hooks. + /// + private const double MinEdgeClearanceY = 12d; + + internal static bool EnforceEdgeRoutingClearance( + Dictionary positionedNodes, + IReadOnlyCollection edges, + IReadOnlyDictionary layersByNodeId, + IReadOnlyDictionary nodesById, + double nodeSpacing, + ElkLayoutDirection direction) + { + if (direction != ElkLayoutDirection.LeftToRight) + { + return false; + } + + // Build layer membership for efficient within-layer adjustment. + var nodesByLayer = new Dictionary>(); + foreach (var (nodeId, layer) in layersByNodeId) + { + if (!positionedNodes.ContainsKey(nodeId)) + { + continue; + } + + if (!nodesByLayer.TryGetValue(layer, out var list)) + { + list = []; + nodesByLayer[layer] = list; + } + + list.Add(nodeId); + } + + // Identify edges where source and target nodes have insufficient Y-clearance. + // This covers the root causes: 5px gap (edge/5), 8px gap (edge/25), 22px gap (edge/15), + // and identical Y-positions (edge/32+33 sources). + var adjustments = new Dictionary(StringComparer.Ordinal); + + foreach (var edge in edges) + { + if (!positionedNodes.TryGetValue(edge.SourceNodeId, out var sourceNode) + || !positionedNodes.TryGetValue(edge.TargetNodeId, out var targetNode)) + { + continue; + } + + // Only enforce clearance for edges crossing layers (same-layer edges + // have dedicated intra-layer routing). + var sourceLayer = layersByNodeId.GetValueOrDefault(edge.SourceNodeId, 0); + var targetLayer = layersByNodeId.GetValueOrDefault(edge.TargetNodeId, 0); + if (sourceLayer == targetLayer) + { + continue; + } + + // Compute the Y-gap between the two nodes. + var sourceBottom = sourceNode.Y + sourceNode.Height; + var targetBottom = targetNode.Y + targetNode.Height; + var yGap = sourceNode.Y >= targetBottom + ? sourceNode.Y - targetBottom + : targetNode.Y >= sourceBottom + ? targetNode.Y - sourceBottom + : 0d; // overlapping ranges → gap = 0 + + // Only shift when the gap is extremely tight (< MinEdgeClearanceY). + // Wider gaps have adequate routing space and should not be disturbed. + if (yGap >= MinEdgeClearanceY) + { + continue; + } + + // Determine which node to shift. Prefer shifting the node in the later layer + // to minimize cascade effects on upstream nodes. + var shiftNodeId = targetLayer >= sourceLayer ? edge.TargetNodeId : edge.SourceNodeId; + var anchorNode = targetLayer >= sourceLayer ? sourceNode : targetNode; + var shiftNode = positionedNodes[shiftNodeId]; + + // Compute required shift to create MinEdgeClearanceY gap. + double requiredShift; + if (shiftNode.Y >= anchorNode.Y) + { + // Shift node is below or at same level — push it down. + var currentGap = shiftNode.Y - (anchorNode.Y + anchorNode.Height); + requiredShift = Math.Max(0d, MinEdgeClearanceY - currentGap); + } + else + { + // Shift node is above — push it up. + var currentGap = anchorNode.Y - (shiftNode.Y + shiftNode.Height); + requiredShift = -Math.Max(0d, MinEdgeClearanceY - currentGap); + } + + if (Math.Abs(requiredShift) < 1d) + { + continue; + } + + // Accumulate the largest required shift per node. + var existing = adjustments.GetValueOrDefault(shiftNodeId); + if (Math.Abs(requiredShift) > Math.Abs(existing)) + { + adjustments[shiftNodeId] = requiredShift; + } + } + + if (adjustments.Count == 0) + { + return false; + } + + // Apply shifts individually — no cascade. Only shift if the node + // won't overlap with its layer neighbors after the move. + var applied = false; + foreach (var (shiftNodeId, shift) in adjustments) + { + if (!positionedNodes.TryGetValue(shiftNodeId, out var node) + || !layersByNodeId.TryGetValue(shiftNodeId, out var layer)) + { + continue; + } + + var newY = node.Y + shift; + + // Check that the shifted position doesn't overlap with neighbors. + if (nodesByLayer.TryGetValue(layer, out var layerNodes)) + { + var wouldOverlap = false; + foreach (var neighborId in layerNodes) + { + if (string.Equals(neighborId, shiftNodeId, StringComparison.Ordinal) + || !positionedNodes.TryGetValue(neighborId, out var neighbor)) + { + continue; + } + + var newBottom = newY + node.Height; + var neighborBottom = neighbor.Y + neighbor.Height; + var gap = Math.Min( + Math.Abs(newY - neighborBottom), + Math.Abs(neighbor.Y - newBottom)); + if (newY < neighborBottom && neighbor.Y < newBottom && gap < nodeSpacing * 0.5) + { + wouldOverlap = true; + break; + } + } + + if (wouldOverlap) + { + continue; + } + } + + positionedNodes[shiftNodeId] = ElkLayoutHelpers.CreatePositionedNode( + nodesById[shiftNodeId], node.X, newY, direction); + applied = true; + } + + return applied; + } +}