From b0d348c9215c9ec8cd550cc05d64f5d112ec9088 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 30 Mar 2026 11:15:50 +0300 Subject: [PATCH] Add Y-axis gutter expansion for routing-aware node placement The Y-axis counterpart to ExpandVerticalCorridorGutters: after edges are routed, detects horizontal segments with under-node or alongside violations, then inserts horizontal gutters by shifting all nodes below the violation point downward. Re-routes with expanded corridors. This is the architectural fix for the placement-routing disconnect: instead of patching edge paths after routing (corridor reroute, push-down, spread), the gutter expansion creates adequate routing corridors in the node placement so edges route cleanly. Runs after X-gutters and before compact passes, up to 2 iterations. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...ElkEdgeChannelGutters.HorizontalRouting.cs | 134 ++++++++++++++++++ .../ElkSharpLayeredLayoutEngine.cs | 39 +++++ 2 files changed, 173 insertions(+) create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.HorizontalRouting.cs diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.HorizontalRouting.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.HorizontalRouting.cs new file mode 100644 index 000000000..ca4b9b99b --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannelGutters.HorizontalRouting.cs @@ -0,0 +1,134 @@ +namespace StellaOps.ElkSharp; + +/// +/// Y-axis counterpart to . +/// 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. +/// +internal static class ElkEdgeHorizontalRoutingGutters +{ + internal static bool ExpandHorizontalRoutingGutters( + Dictionary positionedNodes, + IReadOnlyCollection routedEdges, + IReadOnlyDictionary 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; + + // Scan routed edges for horizontal segments with under-node or alongside violations. + var gutterRequirements = new Dictionary(); // 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; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs index 59e046eaf..6bd202a14 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -144,6 +144,45 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine .ToArray(); } + // Y-axis gutter expansion: after X-gutters widen inter-layer gaps, + // check for horizontal edge segments that pass too close to nodes. + // Insert horizontal gutters by shifting nodes below the violation + // point downward, then re-route with the expanded corridors. + for (var yGutterPass = 0; yGutterPass < 2; yGutterPass++) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!ElkEdgeHorizontalRoutingGutters.ExpandHorizontalRoutingGutters( + positionedNodes, + routedEdges, + augmentedNodesById, + adaptiveNodeSpacing, + options.Direction)) + { + break; + } + + graphBounds = ElkGraphValidator.ComputeGraphBounds(positionedNodes.Values + .Where(x => !dummyResult.DummyNodeIds.Contains(x.Id)).ToArray()); + layerBoundariesByNodeId = ElkLayoutHelpers.BuildLayerBoundariesByNodeId(positionedNodes, dummyResult.AugmentedLayers); + edgeChannels = ElkEdgeChannels.ComputeEdgeChannels(graph.Edges, positionedNodes, options.Direction, layerBoundariesByNodeId); + reconstructedEdges = ElkEdgeRouter.ReconstructDummyEdges( + graph.Edges, + dummyResult, + positionedNodes, + augmentedNodesById, + options.Direction, + graphBounds, + edgeChannels, + layerBoundariesByNodeId); + routedEdges = graph.Edges + .Select(edge => reconstructedEdges.TryGetValue(edge.Id, out var rerouted) + ? rerouted + : ElkEdgeRouter.RouteEdge(edge, nodesById, positionedNodes, options.Direction, graphBounds, + ElkSharpLayoutHelpers.ResolveSinkOverride(edgeChannels.GetValueOrDefault(edge.Id), edge.Id, dummyResult, edgeChannels, graph.Edges), layerBoundariesByNodeId)) + .ToArray(); + } + for (var compactPass = 0; compactPass < 2; compactPass++) { cancellationToken.ThrowIfCancellationRequested();