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();