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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,45 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
|
|||||||
.ToArray();
|
.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++)
|
for (var compactPass = 0; compactPass < 2; compactPass++)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|||||||
Reference in New Issue
Block a user