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) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-02 16:30:07 +03:00
parent 6c70c6bd20
commit 58d2ba83ab
3 changed files with 67 additions and 1 deletions

View File

@@ -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)

View File

@@ -629,6 +629,46 @@ internal static partial class ElkEdgePostProcessor
return true;
}
/// <summary>
/// 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.
/// </summary>
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<ElkPoint>(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<ElkPoint> RemoveCollinearPoints(List<ElkPoint> path)
{
if (path.Count < 3)

View File

@@ -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))