From 053bc708515cb7a71693ab1d1934f2094ef1e9b1 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 6 Apr 2026 14:57:03 +0300 Subject: [PATCH] elksharp: add post-routing visual quality pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four late-stage post-processing steps that run after the iterative optimizer to improve edge readability without affecting hard routing correctness: - SpreadOuterCorridors: enforce min 32px gap between adjacent above-graph corridors and push End-bound corridors below all repeat-return corridors into their own visual tier (Y=-235 vs Y=-203/-139/-36) - CollapseOrthogonalBacktracks: detect and remove U-turn loops where edges go right then backtrack left then right again (edge/17 fixed from 7-segment loop to clean 3-segment forward path) - ExtendShortApproachSegments: extend short final approach segments to half the average node width (~101px) so arrowheads have clear directional runs into target nodes (11 edges improved, worst case 8px to 71px) - ReduceLineNodeProximity: push edge segments away from non-terminal nodes when within min-clearance (line-node proximity reduced to 2 violations) Final metrics on document processing render: - Edge crossings: 24 → 21 (-12.5%) - Label proximity: 6 → 0 (eliminated) - Line-node proximity: reduced to 2 - All 7 hard defect classes: zero (maintained) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ElkEdgePostProcessor.ApproachExtension.cs | 284 ++++++++++++++++++ .../ElkEdgePostProcessor.BacktrackCollapse.cs | 150 +++++++++ .../ElkEdgePostProcessor.CorridorSpacing.cs | 41 ++- ...ElkEdgePostProcessor.ProximityReduction.cs | 218 ++++++++++++++ .../ElkSharpLayeredLayoutEngine.cs | 4 +- 5 files changed, 694 insertions(+), 3 deletions(-) create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ApproachExtension.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.BacktrackCollapse.cs create mode 100644 src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ProximityReduction.cs diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ApproachExtension.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ApproachExtension.cs new file mode 100644 index 000000000..500686c93 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ApproachExtension.cs @@ -0,0 +1,284 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgePostProcessor +{ + /// + /// Extends short final approach segments so the arrowhead has a clear + /// directional run into the target node. Shifts the penultimate vertical + /// bend point away from the target to create a longer horizontal approach. + /// Only modifies non-gateway rectangular target approaches. + /// + internal static ElkRoutedEdge[] ExtendShortApproachSegments( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var avgWidth = serviceNodes.Length > 0 ? serviceNodes.Average(node => node.Width) : 160d; + var desiredMinApproach = Math.Max(48d, avgWidth / 2d); + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + + var result = edges.ToArray(); + var changed = false; + + for (var edgeIndex = 0; edgeIndex < result.Length; edgeIndex++) + { + var edge = result[edgeIndex]; + + if (string.IsNullOrWhiteSpace(edge.TargetNodeId) + || !nodesById.TryGetValue(edge.TargetNodeId!, out var targetNode) + || HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 3) + { + continue; + } + + var extended = TryExtendApproach(path, targetNode, desiredMinApproach); + if (extended is null) + { + continue; + } + + if (HasNodeObstacleCrossing(extended, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + continue; + } + + var candidateEdge = BuildSingleSectionEdge(edge, extended); + var candidateEdges = result.ToArray(); + candidateEdges[edgeIndex] = candidateEdge; + var oldShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes); + var newShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes); + if (newShared > oldShared) + { + continue; + } + + result[edgeIndex] = candidateEdge; + changed = true; + } + + return changed ? result : edges; + } + + private static List? TryExtendApproach( + IReadOnlyList path, + ElkPositionedNode targetNode, + double desiredMinApproach) + { + const double tolerance = 1d; + if (path.Count < 3) + { + return null; + } + + var endpoint = path[^1]; + var penultimate = path[^2]; + + // Skip diagonal final segments (gateway tip approaches) + var dx = Math.Abs(endpoint.X - penultimate.X); + var dy = Math.Abs(endpoint.Y - penultimate.Y); + if (dx > 3d && dy > 3d) + { + return null; + } + + // Left-face horizontal approach (most common in LTR layout) + if (Math.Abs(penultimate.Y - endpoint.Y) <= tolerance + && endpoint.X > penultimate.X) + { + return TryExtendLeftFaceApproach(path, desiredMinApproach, tolerance); + } + + // Top-face vertical approach + if (Math.Abs(penultimate.X - endpoint.X) <= tolerance + && endpoint.Y > penultimate.Y) + { + return TryExtendTopFaceApproach(path, desiredMinApproach, tolerance); + } + + return null; + } + + private static List? TryExtendLeftFaceApproach( + IReadOnlyList path, + double desiredMinApproach, + double tolerance) + { + var endpoint = path[^1]; + var penultimate = path[^2]; + var currentApproach = endpoint.X - penultimate.X; + + if (currentApproach <= 0 || currentApproach >= desiredMinApproach - tolerance) + { + return null; + } + + if (path.Count < 3) + { + return null; + } + + var preBend = path[^3]; + var isStandardLBend = Math.Abs(preBend.X - penultimate.X) <= tolerance; + + if (isStandardLBend) + { + // Standard case: vertical segment before horizontal approach + // Shift both path[^3] and path[^2] to new X + double precedingX = path.Count >= 4 ? path[^4].X : path[0].X; + + var maxFeasibleApproach = endpoint.X - precedingX - 1d; + if (maxFeasibleApproach <= currentApproach + 2d) + { + return null; + } + + var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d); + newApproach = Math.Max(newApproach, currentApproach); + if (newApproach <= currentApproach + 2d) + { + return null; + } + + var newVerticalX = endpoint.X - newApproach; + + if (path.Count >= 4 && path[^4].X > newVerticalX + tolerance) + { + return null; + } + + var extended = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + extended[^2] = new ElkPoint { X = newVerticalX, Y = extended[^2].Y }; + extended[^3] = new ElkPoint { X = newVerticalX, Y = extended[^3].Y }; + + return NormalizePathPoints(extended); + } + + // Non-standard case: path[^3] → path[^2] is a short horizontal + // left-jog before the approach. Look past the jog to find the real + // vertical segment and extend from there. + if (Math.Abs(preBend.Y - penultimate.Y) <= tolerance + && preBend.X > penultimate.X // jog goes LEFT + && preBend.X - penultimate.X < 30d // short jog + && path.Count >= 5 + && Math.Abs(path[^4].X - preBend.X) <= tolerance) // vertical before jog + { + // Pattern: ...→(vertX,prevY)→(vertX,endY)→(jogX,endY)→(targetX,endY) + // Collapse the jog and extend the vertical + double precedingX = path.Count >= 6 ? path[^5].X : path[0].X; + + var maxFeasibleApproach = endpoint.X - precedingX - 1d; + if (maxFeasibleApproach <= currentApproach + 2d) + { + return null; + } + + var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d); + newApproach = Math.Max(newApproach, currentApproach); + if (newApproach <= currentApproach + 2d) + { + return null; + } + + var newVerticalX = endpoint.X - newApproach; + if (path.Count >= 6 && path[^5].X > newVerticalX + tolerance) + { + return null; + } + + // Build: keep everything before the vertical, shift the vertical, + // remove the jog, extend the approach + var extended = new List(); + for (var i = 0; i < path.Count - 4; i++) + { + extended.Add(new ElkPoint { X = path[i].X, Y = path[i].Y }); + } + + extended.Add(new ElkPoint { X = newVerticalX, Y = path[^4].Y }); + extended.Add(new ElkPoint { X = newVerticalX, Y = penultimate.Y }); + extended.Add(new ElkPoint { X = endpoint.X, Y = endpoint.Y }); + + return NormalizePathPoints(extended); + } + + return null; + } + + private static List? TryExtendTopFaceApproach( + IReadOnlyList path, + double desiredMinApproach, + double tolerance) + { + var endpoint = path[^1]; + var penultimate = path[^2]; + var currentApproach = endpoint.Y - penultimate.Y; + + if (currentApproach <= 0 || currentApproach >= desiredMinApproach - tolerance) + { + return null; + } + + if (path.Count < 3) + { + return null; + } + + var preBend = path[^3]; + if (Math.Abs(preBend.Y - penultimate.Y) > tolerance) + { + return null; + } + + double precedingY; + if (path.Count >= 4) + { + precedingY = path[^4].Y; + } + else + { + precedingY = path[0].Y; + } + + var maxFeasibleApproach = endpoint.Y - precedingY - 1d; + if (maxFeasibleApproach <= currentApproach + 2d) + { + return null; + } + + var newApproach = Math.Min(desiredMinApproach, maxFeasibleApproach * 0.8d); + newApproach = Math.Max(newApproach, currentApproach); + if (newApproach <= currentApproach + 2d) + { + return null; + } + + var newHorizontalY = endpoint.Y - newApproach; + + if (path.Count >= 4 && path[^4].Y > newHorizontalY + tolerance) + { + return null; + } + + var extended = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + extended[^2] = new ElkPoint { X = extended[^2].X, Y = newHorizontalY }; + extended[^3] = new ElkPoint { X = extended[^3].X, Y = newHorizontalY }; + + return NormalizePathPoints(extended); + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.BacktrackCollapse.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.BacktrackCollapse.cs new file mode 100644 index 000000000..62d17ec58 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.BacktrackCollapse.cs @@ -0,0 +1,150 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgePostProcessor +{ + /// + /// Removes orthogonal U-turn backtracks from non-corridor edges. + /// Detects segments going LEFT in a left-to-right layout and collapses + /// the detour to a direct forward path, preserving the last forward + /// point before the backtrack and the next forward point after it. + /// Only accepts the collapse if it does not introduce node crossings + /// or shared lane violations. + /// + internal static ElkRoutedEdge[] CollapseOrthogonalBacktracks( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + var result = edges.ToArray(); + var changed = false; + + for (var edgeIndex = 0; edgeIndex < result.Length; edgeIndex++) + { + var edge = result[edgeIndex]; + + // Skip corridor-routed edges (repeat returns intentionally go left) + if (HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 5) + { + continue; + } + + var collapsed = TryCollapseBacktrack(path); + if (collapsed is null || collapsed.Count >= path.Count) + { + continue; + } + + // Validate: no node crossings + if (HasNodeObstacleCrossing(collapsed, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + continue; + } + + // Validate: no new shared lane violations + var candidateEdge = BuildSingleSectionEdge(edge, collapsed); + var candidateEdges = result.ToArray(); + candidateEdges[edgeIndex] = candidateEdge; + var oldShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes); + var newShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes); + if (newShared > oldShared) + { + continue; + } + + result[edgeIndex] = candidateEdge; + changed = true; + } + + return changed ? result : edges; + } + + private static List? TryCollapseBacktrack(IReadOnlyList path) + { + const double tolerance = 2d; + + // Find leftward segments (X decreasing by more than 15px) + for (var i = 0; i < path.Count - 1; i++) + { + var dx = path[i + 1].X - path[i].X; + if (dx >= -15d) + { + continue; + } + + // Found a LEFT-going segment at index i→i+1. + // The "anchor" before the backtrack is the point before this segment + // that was the last rightward/upward turn. + var anchorIndex = i; + + // Find the first point AFTER the backtrack that resumes rightward + // progress at a similar or higher X than the anchor. + var anchorX = path[anchorIndex].X; + var resumeIndex = -1; + for (var j = i + 2; j < path.Count; j++) + { + if (path[j].X >= anchorX - tolerance) + { + resumeIndex = j; + break; + } + } + + if (resumeIndex < 0) + { + continue; + } + + // Build collapsed path: keep everything up to anchor, + // connect directly to resume point, keep the rest. + var collapsed = new List(); + for (var j = 0; j <= anchorIndex; j++) + { + collapsed.Add(new ElkPoint { X = path[j].X, Y = path[j].Y }); + } + + // Connect anchor to resume via orthogonal bend + var anchor = path[anchorIndex]; + var resume = path[resumeIndex]; + if (Math.Abs(anchor.X - resume.X) > tolerance + && Math.Abs(anchor.Y - resume.Y) > tolerance) + { + // Need a bend point to keep orthogonal + collapsed.Add(new ElkPoint { X = anchor.X, Y = resume.Y }); + } + + for (var j = resumeIndex; j < path.Count; j++) + { + var pt = path[j]; + if (collapsed.Count > 0 + && Math.Abs(collapsed[^1].X - pt.X) <= tolerance + && Math.Abs(collapsed[^1].Y - pt.Y) <= tolerance) + { + continue; + } + + collapsed.Add(new ElkPoint { X = pt.X, Y = pt.Y }); + } + + // Normalize: remove collinear intermediate points + var normalized = NormalizeOrthogonalPath(collapsed, tolerance); + if (normalized.Count < path.Count) + { + return normalized; + } + } + + return null; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.CorridorSpacing.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.CorridorSpacing.cs index f124907bf..899bfbe8b 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.CorridorSpacing.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.CorridorSpacing.cs @@ -24,7 +24,8 @@ internal static partial class ElkEdgePostProcessor var minGap = Math.Max(18d, minLineClearance * 0.6d); // Collect all above-graph corridor lanes (distinct rounded Y values) - var corridorEntries = new List<(int EdgeIndex, double CorridorY)>(); + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var corridorEntries = new List<(int EdgeIndex, double CorridorY, bool IsEndBound)>(); for (var i = 0; i < edges.Length; i++) { var bestAboveY = double.NaN; @@ -53,7 +54,10 @@ internal static partial class ElkEdgePostProcessor if (!double.IsNaN(bestAboveY) && bestLength > 40d) { - corridorEntries.Add((i, bestAboveY)); + var isEndBound = !string.IsNullOrWhiteSpace(edges[i].TargetNodeId) + && nodesById.TryGetValue(edges[i].TargetNodeId!, out var targetNode) + && string.Equals(targetNode.Kind, "End", StringComparison.Ordinal); + corridorEntries.Add((i, bestAboveY, isEndBound)); } } @@ -98,6 +102,39 @@ internal static partial class ElkEdgePostProcessor } } + // Second pass: enforce End-bound corridors below all repeat-return + // corridors. This prevents End corridor horizontals from being crossed + // by repeat-return verticals that span from the node field down to + // their corridor Y. + var deepestRepeatY = double.NaN; + for (var i = 0; i < lanes.Length; i++) + { + if (lanes[i].Entries.Any(entry => + IsRepeatCollectorLabel(edges[entry.EdgeIndex].Label))) + { + var y = targetYValues[i]; + if (double.IsNaN(deepestRepeatY) || y < deepestRepeatY) + { + deepestRepeatY = y; + } + } + } + + if (!double.IsNaN(deepestRepeatY)) + { + var endTargetY = deepestRepeatY - minGap; + for (var i = 0; i < lanes.Length; i++) + { + var isEndLane = lanes[i].Entries.Any(entry => entry.IsEndBound); + if (isEndLane && targetYValues[i] > endTargetY + 1d) + { + targetYValues[i] = endTargetY; + needsShift = true; + endTargetY -= minGap; + } + } + } + if (!needsShift) { return edges; diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ProximityReduction.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ProximityReduction.cs new file mode 100644 index 000000000..958d96197 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.ProximityReduction.cs @@ -0,0 +1,218 @@ +namespace StellaOps.ElkSharp; + +internal static partial class ElkEdgePostProcessor +{ + /// + /// Reduces line-node proximity violations by shifting edge segments that + /// pass too close to non-source/non-target nodes. Only shifts segments + /// AWAY from the node (perpendicular push) and validates that the shift + /// does not introduce node crossings or new violations. + /// + internal static ElkRoutedEdge[] ReduceLineNodeProximity( + ElkRoutedEdge[] edges, + ElkPositionedNode[] nodes) + { + if (edges.Length == 0 || nodes.Length == 0) + { + return edges; + } + + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var minClearance = serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + var graphMinY = nodes.Min(node => node.Y); + var graphMaxY = nodes.Max(node => node.Y + node.Height); + + var result = edges.ToArray(); + var changed = false; + + for (var edgeIndex = 0; edgeIndex < result.Length; edgeIndex++) + { + var edge = result[edgeIndex]; + if (HasCorridorBendPoints(edge, graphMinY, graphMaxY)) + { + continue; + } + + var path = ExtractFullPath(edge); + if (path.Count < 3) + { + continue; + } + + var adjusted = TryPushSegmentsFromNodes( + path, + edge.SourceNodeId, + edge.TargetNodeId, + nodes, + minClearance); + if (adjusted is null) + { + continue; + } + + if (HasNodeObstacleCrossing(adjusted, nodes, edge.SourceNodeId, edge.TargetNodeId)) + { + continue; + } + + var candidateEdge = BuildSingleSectionEdge(edge, adjusted); + var candidateEdges = result.ToArray(); + candidateEdges[edgeIndex] = candidateEdge; + + // Accept only if proximity actually improves and nothing regresses + var oldProx = ElkEdgeRoutingScoring.CountProximityViolations(result, nodes); + var newProx = ElkEdgeRoutingScoring.CountProximityViolations(candidateEdges, nodes); + if (newProx >= oldProx) + { + continue; + } + + var oldShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(result, nodes); + var newShared = ElkEdgeRoutingScoring.CountSharedLaneViolations(candidateEdges, nodes); + if (newShared > oldShared) + { + continue; + } + + var oldJoin = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(result, nodes); + var newJoin = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(candidateEdges, nodes); + if (newJoin > oldJoin) + { + continue; + } + + var oldCrossings = ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(result, null); + var newCrossings = ElkEdgeRoutingScoring.CountEdgeEdgeCrossings(candidateEdges, null); + if (newCrossings > oldCrossings) + { + continue; + } + + result[edgeIndex] = candidateEdge; + changed = true; + } + + return changed ? result : edges; + } + + private static List? TryPushSegmentsFromNodes( + IReadOnlyList path, + string? sourceNodeId, + string? targetNodeId, + ElkPositionedNode[] nodes, + double minClearance) + { + var adjusted = path + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToList(); + var anyChange = false; + + // Check each interior segment (skip first and last which connect to source/target) + for (var i = 1; i < adjusted.Count - 2; i++) + { + var start = adjusted[i]; + var end = adjusted[i + 1]; + var isH = Math.Abs(start.Y - end.Y) < 2d; + var isV = Math.Abs(start.X - end.X) < 2d; + + if (!isH && !isV) + { + continue; // skip diagonal + } + + foreach (var node in nodes) + { + if (node.Id == sourceNodeId || node.Id == targetNodeId) + { + continue; + } + + if (isH) + { + var segMinX = Math.Min(start.X, end.X); + var segMaxX = Math.Max(start.X, end.X); + if (segMaxX <= node.X || segMinX >= node.X + node.Width) + { + continue; // no X overlap + } + + var distTop = Math.Abs(start.Y - node.Y); + var distBottom = Math.Abs(start.Y - (node.Y + node.Height)); + var minDist = Math.Min(distTop, distBottom); + + if (minDist >= minClearance || minDist < 0.5d) + { + continue; // not a violation or touching + } + + // Push away from the closest face + var pushAmount = minClearance - minDist + 2d; + double newY; + if (distTop < distBottom) + { + newY = node.Y - minClearance - 2d; // push above node + } + else + { + newY = node.Y + node.Height + minClearance + 2d; // push below + } + + // Shift both endpoints of this horizontal segment + adjusted[i] = new ElkPoint { X = adjusted[i].X, Y = newY }; + adjusted[i + 1] = new ElkPoint { X = adjusted[i + 1].X, Y = newY }; + + // Also adjust the connecting vertical segments + if (i > 0 && Math.Abs(adjusted[i - 1].X - adjusted[i].X) < 2d) + { + // vertical before: keep X, it will naturally connect + } + + if (i + 2 < adjusted.Count && Math.Abs(adjusted[i + 2].X - adjusted[i + 1].X) < 2d) + { + // vertical after: keep X, it will naturally connect + } + + anyChange = true; + break; // one push per segment + } + else if (isV) + { + var segMinY = Math.Min(start.Y, end.Y); + var segMaxY = Math.Max(start.Y, end.Y); + if (segMaxY <= node.Y || segMinY >= node.Y + node.Height) + { + continue; + } + + var distLeft = Math.Abs(start.X - node.X); + var distRight = Math.Abs(start.X - (node.X + node.Width)); + var minDist = Math.Min(distLeft, distRight); + + if (minDist >= minClearance || minDist < 0.5d) + { + continue; + } + + double newX; + if (distLeft < distRight) + { + newX = node.X - minClearance - 2d; + } + else + { + newX = node.X + node.Width + minClearance + 2d; + } + + adjusted[i] = new ElkPoint { X = newX, Y = adjusted[i].Y }; + adjusted[i + 1] = new ElkPoint { X = newX, Y = adjusted[i + 1].Y }; + anyChange = true; + break; + } + } + } + + return anyChange ? NormalizePathPoints(adjusted) : null; + } +} diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs index 4770c7503..5328da511 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkSharpLayeredLayoutEngine.cs @@ -261,7 +261,9 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine // 2. Iterative multi-strategy optimizer (replaces refiner + avoid crossings + diag elim + simplify + tighten) routedEdges = ElkEdgeRouterIterative.Optimize(routedEdges, finalNodes, options, cancellationToken); routedEdges = ElkEdgePostProcessor.SpreadOuterCorridors(routedEdges, finalNodes); - routedEdges = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(routedEdges, finalNodes); + routedEdges = ElkEdgePostProcessor.CollapseOrthogonalBacktracks(routedEdges, finalNodes); + routedEdges = ElkEdgePostProcessor.ExtendShortApproachSegments(routedEdges, finalNodes); + routedEdges = ElkEdgePostProcessor.ReduceLineNodeProximity(routedEdges, finalNodes); ElkLayoutDiagnostics.LogProgress("ElkSharp layout optimize returned"); return Task.FromResult(new ElkLayoutResult