From 58d2ba83ab47724f180036320d28caf228d79b97 Mon Sep 17 00:00:00 2001 From: master <> Date: Thu, 2 Apr 2026 16:30:07 +0300 Subject: [PATCH] Collapse short doglegs: routing-level (gated) + rendering-level (30px) Routing: CollapseShortDoglegs processes one dogleg at a time, accepts only if no entry-angle/node-crossing/shared-lane regressions. Rendering: jog filter increased to 30px to catch 19px+24px doglegs that the routing can't collapse without violations. The filter snaps the next point's axis to prevent diagonals. Sharp corners (r=0) for tight doglegs where both segments < 30px. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../WorkflowRenderSvgRenderer.cs | 2 +- .../ElkEdgePostProcessor.GatewayBoundary.cs | 40 +++++++++++++++++++ ...RouterIterative.WinnerRefinement.Hybrid.cs | 26 ++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs index b4d518bae..b33340197 100644 --- a/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs +++ b/src/Workflow/__Libraries/StellaOps.Workflow.Renderer.Svg/WorkflowRenderSvgRenderer.cs @@ -2052,7 +2052,7 @@ public sealed class WorkflowRenderSvgRenderer var dxIn = Math.Abs(curr.X - prev.X); var dyIn = Math.Abs(curr.Y - prev.Y); var segLen = dxIn + dyIn; - if (segLen < 24d && i < mutablePoints.Count - 1) + if (segLen < 30d && i < mutablePoints.Count - 1) { var next = mutablePoints[i + 1]; if (dxIn < dyIn) diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs index 1279d89ad..a7b6e9ab0 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.GatewayBoundary.cs @@ -629,6 +629,46 @@ internal static partial class ElkEdgePostProcessor return true; } + /// + /// Collapses the FIRST short dogleg found across all edges. + /// Returns the modified array or the original if none found. + /// Call repeatedly to collapse one dogleg at a time. + /// + internal static ElkRoutedEdge[] CollapseShortDoglegs(ElkRoutedEdge[] edges, ElkPositionedNode[] nodes) + { + if (edges.Length == 0) return edges; + for (var i = 0; i < edges.Length; i++) + { + var edge = edges[i]; + var path = ExtractFullPath(edge); + if (path.Count < 4) continue; + var newPath = new List(path); + for (var j = 1; j < newPath.Count - 2; j++) + { + var prev = newPath[j - 1]; + var curr = newPath[j]; + var next = newPath[j + 1]; + var seg1 = Math.Abs(curr.X - prev.X) + Math.Abs(curr.Y - prev.Y); + var seg2 = Math.Abs(next.X - curr.X) + Math.Abs(next.Y - curr.Y); + if (seg1 >= 30d || seg2 >= 30d || seg1 < 1d || seg2 < 1d) continue; + var s1V = Math.Abs(curr.X - prev.X) < 2d; + var s2H = Math.Abs(next.Y - curr.Y) < 2d; + var s1H = Math.Abs(curr.Y - prev.Y) < 2d; + var s2V = Math.Abs(next.X - curr.X) < 2d; + if ((s1V && s2H) || (s1H && s2V)) + { + newPath[j] = new ElkPoint { X = next.X, Y = prev.Y }; + newPath.RemoveAt(j + 1); + var cleaned = RemoveCollinearPoints(newPath); + var result = edges.ToArray(); + result[i] = BuildSingleSectionEdge(edge, cleaned); + return result; // return after first collapse + } + } + } + return edges; + } + private static List RemoveCollinearPoints(List path) { if (path.Count < 3) diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs index a04e17f16..db600fb73 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -272,6 +272,32 @@ internal static partial class ElkEdgeRouterIterative } } + // Collapse short doglegs (two consecutive <30px segments forming an + // L-shape) into single bends. These create visible zigzag steps. + // Collapse short doglegs PER-EDGE: some collapses create entry-angle + // violations. Process one edge at a time and only keep safe ones. + for (var dei = 0; dei < current.Edges.Length; dei++) + { + var singleEdge = ElkEdgePostProcessor.CollapseShortDoglegs( + current.Edges, nodes); + if (ReferenceEquals(singleEdge, current.Edges)) + { + break; // no more doglegs to collapse + } + + var singleScore = ElkEdgeRoutingScoring.ComputeScore(singleEdge, nodes); + if (singleScore.EntryAngleViolations <= current.Score.EntryAngleViolations + && singleScore.NodeCrossings <= current.Score.NodeCrossings + && singleScore.SharedLaneViolations <= current.Score.SharedLaneViolations) + { + current = current with { Score = singleScore, Edges = singleEdge }; + } + else + { + break; // can't collapse more without violations + } + } + // Straighten short diagonal stubs at gateway boundary vertices. var straightened = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(current.Edges, nodes); if (!ReferenceEquals(straightened, current.Edges))