elksharp: add post-routing visual quality pipeline
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
namespace StellaOps.ElkSharp;
|
||||
|
||||
internal static partial class ElkEdgePostProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ElkPoint>? TryCollapseBacktrack(IReadOnlyList<ElkPoint> 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<ElkPoint>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user