From 2bc06169f84b75c776f1b7d74a5897af1c07d6ac Mon Sep 17 00:00:00 2001 From: master <> Date: Thu, 26 Mar 2026 14:39:55 +0200 Subject: [PATCH] Fix fake orthogonal target-entry hook detection --- ...23_002_ElkSharp_bounded_edge_refinement.md | 2 + ...ocumentProcessingWorkflowRenderingTests.cs | 1284 ++++++++++++++++- .../ElkEdgePostProcessor.cs | 84 ++ 3 files changed, 1368 insertions(+), 2 deletions(-) diff --git a/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md b/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md index 6ea456274..02f57bc5f 100644 --- a/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md +++ b/docs/implplan/SPRINT_20260323_002_ElkSharp_bounded_edge_refinement.md @@ -174,9 +174,11 @@ Completion criteria: | 2026-03-25 | Reopened ElkSharp follow-up work for the user-reported source-side same-lane conflict between `Internal Notification -> Has Recipients` and `Internal Notification -> Set internalNotificationFailed`. Added source-departure join spreading plus blocking `SharedLaneViolations`, derived placement spacing from an average-node-size placement grid, and verified the focused helper regression with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~SourceDepartureHelpers_WhenOutgoingEdgesShareTheSameDepartureLane_ShouldSpreadOnlyTheConflictingPeer" -v minimal` (1/1). The current document-processing artifact now reports `SharedLaneViolations=0` for the selected route, but still has unresolved late boundary-angle / target-join regressions (`EntryAngleViolations=6`, `TargetApproachJoinViolations=1`), so TASK-010 remains open. | Implementer | | 2026-03-25 | Tightened the iterative local-repair planner so attempt 2+ now selects only currently failing edges plus exact conflict peers instead of padding the repair set with generic ranked edges, and added a lock-aware parallel local builder that computes candidates concurrently but serializes overlapping source/target neighborhoods before merging deterministically. Revalidated with `dotnet build src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln -v minimal` and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (still failing in ~20s on `SharedLaneViolations=1`, `UnderNodeViolations=2`; selected offender cluster remains `edge/15+edge/17`, `edge/9`, `edge/15`). | Implementer | | 2026-03-26 | Cleared the last document-processing handoff by letting gateway target peer-conflict candidates start from slotted feeder paths and reusing focused target-peer conflict polish during transactional final-detour repair. Revalidated with `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~Debug_DumpDocumentProcessingFinalDetourOffenders" -v normal --logger "console;verbosity=normal"` (1/1) and `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1, 2m42s). | Implementer | +| 2026-03-26 | Tightened non-gateway target backtracking so a short orthogonal hook that reaches the boundary for less than one node depth is treated as a forbidden fake side entry, then added a focused `Load Configuration -> Setting configParameters` regression. | Implementer | ## Decisions & Risks - 2026-03-26: The remaining document-processing defect was not another retry-budget issue. Gateway target peer-conflict candidate building needed a slotted feeder so focused peer-conflict polish could separate same-face arrivals without restoring the final excessive detour. +- 2026-03-26 follow-up: the `Load Configuration -> Setting configParameters` edge exposed a different blind spot. The rectangular target-entry rule only validated the final boundary angle and true overshoot, so a `6px` vertical stub into the bottom face still passed as a valid `90`-degree entry. Non-gateway target backtracking now also rejects short orthogonal hooks whose final boundary-depth is less than one node shape depth, which lets the existing backtracking endpoint normalizer move the edge onto the honest side face instead of preserving the fake bottom join. - 2026-03-25 follow-up: the selected document-processing artifact now enforces zero below-graph lanes and zero overlong 45-degree segments, and gateway source exits are no longer allowed to leave from fork/join tip vertices. Gateway target join detection/spreading now groups arrivals by their landed boundary band instead of letting gateway arrivals slip through as highway-like exemptions. Targeted evidence: `dotnet test src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/StellaOps.Workflow.Renderer.Tests.csproj --no-restore --filter "FullyQualifiedName~DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings" -v minimal` (1/1 pass, refreshed 20260325 artifact). That checkpoint still left TASK-010 open; the 2026-03-26 peer-conflict fix closes it. - There was no module-local `AGENTS.md` under `src/__Libraries/StellaOps.ElkSharp/`; this sprint adds one before code changes so the module is no longer undocumented. - Cross-module edits are limited to workflow renderer tests and workflow engine docs because the implementation changes a shared library used by those surfaces. diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs index 78fb81283..f28779705 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.cs @@ -99,6 +99,44 @@ public class DocumentProcessingWorkflowRenderingTests Assert.That(layout.Nodes.Count, Is.EqualTo(24)); Assert.That(layout.Edges.Count, Is.EqualTo(36)); Assert.That(layout.Nodes.All(n => double.IsFinite(n.X) && double.IsFinite(n.Y)), Is.True); + var serviceNodes = graph.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var expectedGridX = Math.Max(64d, Math.Round(serviceNodes.Average(node => node.Width) / 8d) * 8d); + var expectedGridY = Math.Max(48d, Math.Round(serviceNodes.Average(node => node.Height) / 8d) * 8d); + var edgeDensityFactor = Math.Min(1.8d, 1d + (Math.Max(0, graph.Edges.Count - 15) * 0.02d)); + var expectedNodeSpacing = Math.Max(40d * edgeDensityFactor, expectedGridY * 0.4d); + var edgeDensitySpacingFactor = 0.92d + (Math.Max(0d, edgeDensityFactor - 1d) * 0.35d); + var expectedLayerSpacing = Math.Max(60d * Math.Min(1.15d, edgeDensitySpacingFactor), expectedGridX * 0.45d); + var visibleNodes = layout.Nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var distinctLayerXs = visibleNodes + .Select(node => node.X) + .Distinct() + .OrderBy(x => x) + .ToArray(); + var minLayerGap = distinctLayerXs.Zip(distinctLayerXs.Skip(1), (left, right) => right - left).DefaultIfEmpty(double.MaxValue).Min(); + var minInLayerGap = visibleNodes + .GroupBy(node => node.X) + .Select(group => + { + var ordered = group.OrderBy(node => node.Y).ToArray(); + if (ordered.Length < 2) + { + return double.MaxValue; + } + + return ordered + .Zip(ordered.Skip(1), (upper, lower) => lower.Y - (upper.Y + upper.Height)) + .Min(); + }) + .DefaultIfEmpty(double.MaxValue) + .Min(); + Assert.That( + minLayerGap, + Is.GreaterThanOrEqualTo(expectedLayerSpacing - 1d), + $"Layer spacing should honor the placement grid scale (~{expectedGridX:F0}px average width)."); + Assert.That( + minInLayerGap, + Is.GreaterThanOrEqualTo(expectedNodeSpacing - 1d), + $"In-layer node spacing should honor the placement grid scale (~{expectedGridY:F0}px average height)."); } [Test] @@ -121,6 +159,99 @@ public class DocumentProcessingWorkflowRenderingTests "Execute Batch -> Check Result must not overshoot the target side and curl back near the final approach."); } + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldUseHorizontalSidesBetweenConfigParametersAndEvaluateConditions() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/36"); + var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId); + var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId); + var path = FlattenPath(edge); + + Assert.That(path.Count, Is.GreaterThanOrEqualTo(2)); + Assert.That( + ResolveBoundarySide(path[0], sourceNode), + Is.EqualTo("right"), + "Setting configParameters should leave from its east side toward Evaluate Conditions."); + Assert.That( + ResolveBoundarySide(path[^1], targetNode), + Is.EqualTo("left"), + "Evaluate Conditions should be reached from its west side when the horizontal shortcut is clear."); + var shortestDirectLength = 0d; + for (var i = 1; i < path.Count; i++) + { + shortestDirectLength += Math.Abs(path[i].X - path[i - 1].X) + Math.Abs(path[i].Y - path[i - 1].Y); + } + + var boundaryDirectLength = Math.Abs(path[^1].X - path[0].X) + Math.Abs(path[^1].Y - path[0].Y); + Assert.That( + path.Min(point => point.Y), + Is.GreaterThanOrEqualTo(Math.Min(sourceNode.Y, targetNode.Y) - 24d), + "Setting configParameters -> Evaluate Conditions must not detour north when a direct east-to-west shortcut is clear."); + Assert.That( + shortestDirectLength, + Is.LessThanOrEqualTo(boundaryDirectLength + 48d), + "Setting configParameters -> Evaluate Conditions must stay close to the direct boundary-to-boundary shortcut."); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldNotFakeBottomEntryIntoSettingConfigParameters() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var edge = layout.Edges.Single(routedEdge => + routedEdge.SourceNodeId == "start/3" + && routedEdge.TargetNodeId == "start/4/batched"); + var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId); + var path = FlattenPath(edge); + + Assert.That( + HasTargetApproachBacktracking(edge, targetNode), + Is.False, + "Load Configuration -> Setting configParameters must not use a tiny orthogonal hook to fake a bottom-side entry."); + Assert.That(path.Count, Is.GreaterThanOrEqualTo(2)); + Assert.That( + ResolveBoundarySide(path[^1], targetNode), + Is.EqualTo("left"), + "Load Configuration -> Setting configParameters should enter from the west side once short fake bottom hooks are forbidden."); + } + + [Test] + public async Task DocumentProcessingWorkflow_WhenLaidOutWithElkSharp_ShouldKeepLocalRepeatReturnsAboveTheNodeField() + { + var graph = BuildDocumentProcessingWorkflowGraph(); + var engine = new ElkSharpWorkflowRenderLayoutEngine(); + + var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest + { + Direction = WorkflowRenderLayoutDirection.LeftToRight, + }); + + var edge = layout.Edges.Single(routedEdge => routedEdge.Id == "edge/35"); + var sourceNode = layout.Nodes.Single(node => node.Id == edge.SourceNodeId); + var targetNode = layout.Nodes.Single(node => node.Id == edge.TargetNodeId); + var path = FlattenPath(edge); + var maxAllowedY = Math.Max(sourceNode.Y + sourceNode.Height, targetNode.Y + targetNode.Height) + 40d; + + Assert.That( + path.Max(point => point.Y), + Is.LessThanOrEqualTo(maxAllowedY), + "Local repeat-return lanes must not drop into a lower detour band when an upper return is available."); + } + [Test] [Category("RenderingArtifacts")] public async Task DocumentProcessingWorkflow_WhenRenderedWithElkSharp_ShouldProducePngWithZeroNodeCrossings() @@ -139,7 +270,14 @@ public class DocumentProcessingWorkflowRenderingTests File.Delete(progressLogPath); } + var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json"); + if (File.Exists(diagnosticsPath)) + { + File.Delete(diagnosticsPath); + } + diagnosticsCapture.Diagnostics.ProgressLogPath = progressLogPath; + diagnosticsCapture.Diagnostics.SnapshotPath = diagnosticsPath; var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest { Direction = WorkflowRenderLayoutDirection.LeftToRight, @@ -154,7 +292,6 @@ public class DocumentProcessingWorkflowRenderingTests var jsonPath = Path.Combine(outputDir, "elksharp.json"); await File.WriteAllTextAsync(jsonPath, JsonSerializer.Serialize(layout, new JsonSerializerOptions { WriteIndented = true })); - var diagnosticsPath = Path.Combine(outputDir, "elksharp.refinement-diagnostics.json"); await File.WriteAllTextAsync( diagnosticsPath, JsonSerializer.Serialize(diagnosticsCapture.Diagnostics, new JsonSerializerOptions { WriteIndented = true })); @@ -195,7 +332,9 @@ public class DocumentProcessingWorkflowRenderingTests var sc = attemptDiag.Score; var attemptLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} att{attemptDiag.Attempt} [{attemptDiag.Outcome}] " + $"nc={sc.NodeCrossings} ec={sc.EdgeCrossings} bends={sc.BendCount} diag={sc.DiagonalCount} " + + $"bg={sc.BelowGraphViolations} un={sc.UnderNodeViolations} ld={sc.LongDiagonalViolations} " + $"ea={sc.EntryAngleViolations} lbl={sc.LabelProximityViolations} tj={sc.TargetApproachJoinViolations} " + + $"sl={sc.SharedLaneViolations} " + $"tb={sc.TargetApproachBacktrackingViolations} det={sc.ExcessiveDetourViolations} score={sc.Value:F0}"; var attemptSvg = svgRenderer.Render(attemptLayout, attemptLabel); await File.WriteAllTextAsync( @@ -209,7 +348,9 @@ public class DocumentProcessingWorkflowRenderingTests var bestSc = stratDiag.BestScore; var bestLabel = $"S{stratDiag.StrategyIndex} {stratDiag.OrderingName} BEST [{stratDiag.Outcome}] " + $"nc={bestSc?.NodeCrossings} ec={bestSc?.EdgeCrossings} bends={bestSc?.BendCount} diag={bestSc?.DiagonalCount} " + + $"bg={bestSc?.BelowGraphViolations} un={bestSc?.UnderNodeViolations} ld={bestSc?.LongDiagonalViolations} " + $"ea={bestSc?.EntryAngleViolations} lbl={bestSc?.LabelProximityViolations} tj={bestSc?.TargetApproachJoinViolations} " + + $"sl={bestSc?.SharedLaneViolations} " + $"tb={bestSc?.TargetApproachBacktrackingViolations} det={bestSc?.ExcessiveDetourViolations} score={bestSc?.Value:F0}"; var bestSvg = svgRenderer.Render(bestLayout, bestLabel); await File.WriteAllTextAsync( @@ -232,9 +373,106 @@ public class DocumentProcessingWorkflowRenderingTests "Local repair attempts must reroute only the penalized subset of edges."); Assert.That(diagnosticsCapture.Diagnostics.FinalBrokenShortHighwayCount, Is.EqualTo(0), "Final selected layout must not keep broken short highways."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.RepeatCollectorCorridorViolations, Is.EqualTo(0), "Repeat collector outer lanes must remain separated."); + var boundaryAngleOffenders = layout.Edges + .SelectMany(edge => GetBoundaryAngleViolations(edge, layout.Nodes)) + .ToArray(); + TestContext.Out.WriteLine($"Boundary angle offenders: {(boundaryAngleOffenders.Length == 0 ? "" : string.Join(", ", boundaryAngleOffenders))}"); + var targetJoinOffenders = GetTargetApproachJoinOffenders(layout.Edges, layout.Nodes).ToArray(); + TestContext.Out.WriteLine($"Target join offenders: {(targetJoinOffenders.Length == 0 ? "" : string.Join(", ", targetJoinOffenders))}"); + var elkNodes = layout.Nodes.Select(node => new ElkPositionedNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + X = node.X, + Y = node.Y, + Width = node.Width, + Height = node.Height, + }).ToArray(); + var elkEdges = layout.Edges.Select(edge => new ElkRoutedEdge + { + Id = edge.Id, + SourceNodeId = edge.SourceNodeId, + TargetNodeId = edge.TargetNodeId, + Kind = edge.Kind, + Label = edge.Label, + Sections = edge.Sections.Select(section => new ElkEdgeSection + { + StartPoint = new ElkPoint { X = section.StartPoint.X, Y = section.StartPoint.Y }, + EndPoint = new ElkPoint { X = section.EndPoint.X, Y = section.EndPoint.Y }, + BendPoints = section.BendPoints.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(), + }).ToArray(), + }).ToArray(); + var sharedLaneOffenders = ElkEdgeRoutingScoring.DetectSharedLaneConflicts(elkEdges, elkNodes) + .Select(conflict => $"{conflict.LeftEdgeId}+{conflict.RightEdgeId}") + .Distinct(StringComparer.Ordinal) + .ToArray(); + TestContext.Out.WriteLine($"Shared lane offenders: {(sharedLaneOffenders.Length == 0 ? "" : string.Join(", ", sharedLaneOffenders))}"); + var belowGraphOffenders = GetBelowGraphOffenders(layout.Edges, layout.Nodes).ToArray(); + TestContext.Out.WriteLine($"Below-graph offenders: {(belowGraphOffenders.Length == 0 ? "" : string.Join(", ", belowGraphOffenders))}"); + var underNodeOffenders = GetUnderNodeOffenders(layout.Edges, layout.Nodes).ToArray(); + TestContext.Out.WriteLine($"Under-node offenders: {(underNodeOffenders.Length == 0 ? "" : string.Join(", ", underNodeOffenders))}"); + var longDiagonalOffenders = GetLongDiagonalOffenders(layout.Edges, layout.Nodes).ToArray(); + TestContext.Out.WriteLine($"Long-diagonal offenders: {(longDiagonalOffenders.Length == 0 ? "" : string.Join(", ", longDiagonalOffenders))}"); + var gatewaySourceScoringOffenders = layout.Edges + .Where(edge => HasGatewaySourceScoringIssue(edge, layout.Nodes)) + .Select(edge => edge.Id) + .ToArray(); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.EntryAngleViolations, Is.EqualTo(0), "Selected layout must satisfy the node-side entry/exit angle rule."); + Assert.That(targetJoinOffenders, Is.Empty, "Selected layout must not leave visually collapsed target-side approach joins."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachJoinViolations, Is.EqualTo(0), "Selected layout must not keep disallowed target-side joins."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.SharedLaneViolations, Is.EqualTo(0), "Selected layout must not keep same-lane occupancy outside explicit corridor/highway exceptions."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.BelowGraphViolations, Is.EqualTo(0), "Selected layout must not route any lane below the node field."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.UnderNodeViolations, Is.EqualTo(0), "Selected layout must not keep horizontal lanes tucked underneath other nodes."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.LongDiagonalViolations, Is.EqualTo(0), "Selected layout must not keep overlong 45-degree segments."); Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.TargetApproachBacktrackingViolations, Is.EqualTo(0), "Selected layout must not overshoot a target side and curl back near the final approach."); + Assert.That(diagnosticsCapture.Diagnostics.FinalScore?.ExcessiveDetourViolations, Is.EqualTo(0), "Selected layout must not keep shortest-path violations after the retry budget is exhausted."); + var gatewayCornerDiagonalCount = layout.Edges.Count(edge => + HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true) + || HasGatewayCornerDiagonal(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false)); + Assert.That(gatewayCornerDiagonalCount, Is.EqualTo(0), "Gateway diagonal stubs may land on side faces, not on gateway corner vertices."); + var gatewayInteriorAdjacentCount = layout.Edges.Count(edge => + HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), fromSource: true) + || HasGatewayInteriorAdjacentPoint(edge, layout.Nodes.Single(node => node.Id == edge.TargetNodeId), fromSource: false)); + Assert.That(gatewayInteriorAdjacentCount, Is.EqualTo(0), "Gateway joins must use an exterior face-approach point instead of gluing the boundary back to an interior rectangular anchor."); + var gatewaySourceCurlCount = layout.Edges.Count(edge => + HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId))); + var gatewaySourceCurlOffenders = layout.Edges + .Where(edge => HasGatewaySourceExitCurl(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId))) + .Select(edge => edge.Id) + .ToArray(); + Assert.That(gatewaySourceCurlCount, Is.EqualTo(0), "Gateway source exits must leave from the downstream-facing side without curling away and back."); + var gatewaySourceFaceMismatchCount = layout.Edges.Count(edge => + HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes)); + var gatewaySourceFaceMismatchOffenders = layout.Edges + .Where(edge => HasGatewaySourcePreferredFaceMismatch(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes)) + .Select(edge => edge.Id) + .ToArray(); + Assert.That(gatewaySourceFaceMismatchCount, Is.EqualTo(0), "Gateway source exits must leave from the dominant downstream-facing face instead of drifting onto an upper or lower face."); + var gatewaySourceDetourCount = layout.Edges.Count(edge => + HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes)); + var gatewaySourceDetourOffenders = layout.Edges + .Where(edge => HasGatewaySourceDominantAxisDetour(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId), layout.Nodes)) + .Select(edge => edge.Id) + .ToArray(); + Assert.That(gatewaySourceDetourCount, Is.EqualTo(0), "Gateway source exits must not leave on the non-dominant axis when a direct dominant-axis exit is available."); + var gatewaySourceVertexExitCount = layout.Edges.Count(edge => + HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId))); + var gatewaySourceVertexExitOffenders = layout.Edges + .Where(edge => HasGatewaySourceVertexExit(edge, layout.Nodes.Single(node => node.Id == edge.SourceNodeId))) + .Select(edge => edge.Id) + .ToArray(); + Assert.That(gatewaySourceVertexExitCount, Is.EqualTo(0), "Gateway source exits must leave from a face interior, not from a gateway tip/corner."); + Assert.That(gatewaySourceScoringOffenders, Is.Empty, "Gateway source exits must not leave a shorter clean downstream-facing repair opportunity unused."); + var loadConfigurationNode = layout.Nodes.Single(node => node.Id == "start/3"); + var processBatchLoops = layout.Edges + .Where(edge => edge.TargetNodeId == "start/2/branch-1/1" + && edge.Label.StartsWith("repeat while", StringComparison.Ordinal)) + .ToArray(); + Assert.That( + processBatchLoops.All(edge => !HasNearNodeClearanceViolation(edge, loadConfigurationNode, 40d)), + Is.True, + "Repeat-return lanes into Process Batch must stay outside the Load Configuration clearance band."); static WorkflowRenderLayoutResult BuildVariantLayout(WorkflowRenderLayoutResult baseLayout, ElkRoutedEdge[] edges) { @@ -294,7 +532,7 @@ public class DocumentProcessingWorkflowRenderingTests Assert.That(crossings, Is.EqualTo(0), "No edges should cross through node shapes"); } - private static bool HasTargetApproachBacktracking(WorkflowRenderRoutedEdge edge, WorkflowRenderPositionedNode targetNode) + private static List FlattenPath(WorkflowRenderRoutedEdge edge) { var path = new List(); foreach (var section in edge.Sections) @@ -308,11 +546,23 @@ public class DocumentProcessingWorkflowRenderingTests path.Add(section.EndPoint); } + return path; + } + + private static bool HasTargetApproachBacktracking(WorkflowRenderRoutedEdge edge, WorkflowRenderPositionedNode targetNode) + { + var path = FlattenPath(edge); + if (path.Count < 3) { return false; } + if (targetNode.Kind is "Decision" or "Fork" or "Join") + { + return HasGatewayTargetApproachBacktracking(path); + } + var side = ResolveBoundarySide(path[^1], targetNode); if (side is not "left" and not "right" and not "top" and not "bottom") { @@ -320,6 +570,11 @@ public class DocumentProcessingWorkflowRenderingTests } const double tolerance = 0.5d; + if (HasShortOrthogonalTargetHook(path, targetNode, side, tolerance)) + { + return true; + } + var startIndex = Math.Max(0, path.Count - 5); var axisValues = new List(path.Count - startIndex); for (var i = startIndex; i < path.Count; i++) @@ -355,6 +610,999 @@ public class DocumentProcessingWorkflowRenderingTests }; } + private static bool HasShortOrthogonalTargetHook( + IReadOnlyList path, + WorkflowRenderPositionedNode targetNode, + string side, + double tolerance) + { + if (path.Count < 3) + { + return false; + } + + var boundaryPoint = path[^1]; + var runStartIndex = path.Count - 2; + if (side is "left" or "right") + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].Y - boundaryPoint.Y) <= tolerance) + { + runStartIndex--; + } + } + else + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].X - boundaryPoint.X) <= tolerance) + { + runStartIndex--; + } + } + + if (runStartIndex == 0) + { + return false; + } + + var overallDeltaX = path[^1].X - path[0].X; + var overallDeltaY = path[^1].Y - path[0].Y; + var overallAbsDx = Math.Abs(overallDeltaX); + var overallAbsDy = Math.Abs(overallDeltaY); + var sameRowThreshold = Math.Max(24d, targetNode.Height / 3d); + var sameColumnThreshold = Math.Max(24d, targetNode.Width / 3d); + var looksHorizontal = overallAbsDx >= overallAbsDy * 1.15d + && overallAbsDy <= sameRowThreshold + && Math.Sign(overallDeltaX) != 0; + var looksVertical = overallAbsDy >= overallAbsDx * 1.15d + && overallAbsDx <= sameColumnThreshold + && Math.Sign(overallDeltaY) != 0; + var contradictsDominantApproach = side switch + { + "left" or "right" => looksVertical, + "top" or "bottom" => looksHorizontal, + _ => false, + }; + if (!contradictsDominantApproach) + { + return false; + } + + var runStart = path[runStartIndex]; + var boundaryDepth = side is "left" or "right" + ? Math.Abs(boundaryPoint.X - runStart.X) + : Math.Abs(boundaryPoint.Y - runStart.Y); + var requiredDepth = side is "left" or "right" + ? targetNode.Width + : targetNode.Height; + if (boundaryDepth + tolerance >= requiredDepth) + { + return false; + } + + var predecessor = path[runStartIndex - 1]; + var predecessorDx = Math.Abs(runStart.X - predecessor.X); + var predecessorDy = Math.Abs(runStart.Y - predecessor.Y); + return side switch + { + "left" or "right" => predecessorDy > predecessorDx * 3d, + "top" or "bottom" => predecessorDx > predecessorDy * 3d, + _ => false, + }; + } + + private static bool HasGatewayTargetApproachBacktracking(IReadOnlyList path) + { + if (path.Count < 4) + { + return false; + } + + const double tolerance = 0.5d; + var startIndex = Math.Max(0, path.Count - 4); + var nearEnd = path.Skip(startIndex).ToArray(); + if (nearEnd.Length < 3) + { + return false; + } + + var orthStart = nearEnd[^2]; + var orthPrev = nearEnd.Length >= 4 + ? nearEnd[^3] + : nearEnd[0]; + var horizontalApproach = Math.Abs(orthPrev.Y - orthStart.Y) <= tolerance + && Math.Abs(orthPrev.X - orthStart.X) > tolerance; + var verticalApproach = Math.Abs(orthPrev.X - orthStart.X) <= tolerance + && Math.Abs(orthPrev.Y - orthStart.Y) > tolerance; + if (!horizontalApproach && !verticalApproach) + { + return false; + } + + var axisValues = new List(nearEnd.Length); + foreach (var point in nearEnd) + { + var value = horizontalApproach + ? point.X + : point.Y; + if (axisValues.Count == 0 || Math.Abs(axisValues[^1] - value) > tolerance) + { + axisValues.Add(value); + } + } + + if (axisValues.Count < 3) + { + return false; + } + + var targetAxis = horizontalApproach + ? nearEnd[^1].X + : nearEnd[^1].Y; + var previousDistance = Math.Abs(axisValues[0] - targetAxis); + var sawProgress = false; + for (var i = 1; i < axisValues.Count; i++) + { + var currentDistance = Math.Abs(axisValues[i] - targetAxis); + if (currentDistance + tolerance < previousDistance) + { + sawProgress = true; + } + else if (sawProgress && currentDistance > previousDistance + tolerance) + { + return true; + } + + previousDistance = currentDistance; + } + + return false; + } + + private static IEnumerable GetTargetApproachJoinOffenders( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + var minLineClearance = serviceNodes.Length > 0 + ? Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d + : 50d; + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + + foreach (var group in edges.GroupBy(edge => edge.TargetNodeId ?? string.Empty, StringComparer.Ordinal)) + { + if (string.IsNullOrWhiteSpace(group.Key) + || !nodesById.TryGetValue(group.Key, out var targetNode)) + { + continue; + } + + var targetEdges = group.ToArray(); + for (var i = 0; i < targetEdges.Length; i++) + { + var leftPath = ExtractPath(targetEdges[i]); + if (leftPath.Count < 2) + { + continue; + } + + var leftSide = ResolveTargetApproachJoinSide(leftPath, targetNode); + for (var j = i + 1; j < targetEdges.Length; j++) + { + var rightPath = ExtractPath(targetEdges[j]); + if (rightPath.Count < 2) + { + continue; + } + + var rightSide = ResolveTargetApproachJoinSide(rightPath, targetNode); + if (!string.Equals(leftSide, rightSide, StringComparison.Ordinal)) + { + continue; + } + + if (!HasTargetApproachJoin(leftPath, rightPath, minLineClearance, 3)) + { + continue; + } + + yield return $"{targetEdges[i].Id}+{targetEdges[j].Id}@{targetNode.Id}/{leftSide}"; + } + } + } + } + + private static List ExtractPath(WorkflowRenderRoutedEdge edge) + { + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + return path; + } + + private static bool HasTargetApproachJoin( + IReadOnlyList leftPath, + IReadOnlyList rightPath, + double minLineClearance, + int maxSegmentsFromEnd) + { + var leftSegments = FlattenSegmentsNearEnd(leftPath, maxSegmentsFromEnd); + var rightSegments = FlattenSegmentsNearEnd(rightPath, maxSegmentsFromEnd); + + foreach (var leftSegment in leftSegments) + { + foreach (var rightSegment in rightSegments) + { + if (!AreParallelAndClose(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End, minLineClearance)) + { + continue; + } + + var overlap = ComputeSharedSegmentLength(leftSegment.Start, leftSegment.End, rightSegment.Start, rightSegment.End); + if (overlap > 8d) + { + return true; + } + + var leftLength = ComputeSegmentLength(leftSegment.Start, leftSegment.End); + var rightLength = ComputeSegmentLength(rightSegment.Start, rightSegment.End); + if (Math.Min(leftLength, rightLength) > 8d) + { + return true; + } + } + } + + return false; + } + + private static string ResolveTargetApproachJoinSide( + IReadOnlyList path, + WorkflowRenderPositionedNode targetNode) + { + if (path.Count < 2) + { + return ResolveBoundarySide(path[^1], targetNode); + } + + return ResolveBoundaryApproachSide(path[^1], path[^2], targetNode); + } + + private static IReadOnlyList<(WorkflowRenderPoint Start, WorkflowRenderPoint End)> FlattenSegmentsNearEnd( + IReadOnlyList path, + int maxSegmentsFromEnd) + { + if (path.Count < 2 || maxSegmentsFromEnd <= 0) + { + return []; + } + + var startIndex = Math.Max(0, path.Count - (maxSegmentsFromEnd + 1)); + var segments = new List<(WorkflowRenderPoint Start, WorkflowRenderPoint End)>(); + for (var i = startIndex; i < path.Count - 1; i++) + { + segments.Add((path[i], path[i + 1])); + } + + return segments; + } + + private static bool AreParallelAndClose( + WorkflowRenderPoint leftStart, + WorkflowRenderPoint leftEnd, + WorkflowRenderPoint rightStart, + WorkflowRenderPoint rightEnd, + double clearance) + { + const double tolerance = 0.5d; + var leftHorizontal = Math.Abs(leftStart.Y - leftEnd.Y) <= tolerance; + var rightHorizontal = Math.Abs(rightStart.Y - rightEnd.Y) <= tolerance; + if (leftHorizontal && rightHorizontal) + { + return Math.Abs(leftStart.Y - rightStart.Y) <= clearance + && Math.Min(Math.Max(leftStart.X, leftEnd.X), Math.Max(rightStart.X, rightEnd.X)) + - Math.Max(Math.Min(leftStart.X, leftEnd.X), Math.Min(rightStart.X, rightEnd.X)) > 1d; + } + + var leftVertical = Math.Abs(leftStart.X - leftEnd.X) <= tolerance; + var rightVertical = Math.Abs(rightStart.X - rightEnd.X) <= tolerance; + if (leftVertical && rightVertical) + { + return Math.Abs(leftStart.X - rightStart.X) <= clearance + && Math.Min(Math.Max(leftStart.Y, leftEnd.Y), Math.Max(rightStart.Y, rightEnd.Y)) + - Math.Max(Math.Min(leftStart.Y, leftEnd.Y), Math.Min(rightStart.Y, rightEnd.Y)) > 1d; + } + + return false; + } + + private static double ComputeSharedSegmentLength( + WorkflowRenderPoint leftStart, + WorkflowRenderPoint leftEnd, + WorkflowRenderPoint rightStart, + WorkflowRenderPoint rightEnd) + { + const double tolerance = 0.5d; + var leftHorizontal = Math.Abs(leftStart.Y - leftEnd.Y) <= tolerance; + var rightHorizontal = Math.Abs(rightStart.Y - rightEnd.Y) <= tolerance; + if (leftHorizontal && rightHorizontal && Math.Abs(leftStart.Y - rightStart.Y) <= tolerance) + { + return Math.Max(0d, + Math.Min(Math.Max(leftStart.X, leftEnd.X), Math.Max(rightStart.X, rightEnd.X)) + - Math.Max(Math.Min(leftStart.X, leftEnd.X), Math.Min(rightStart.X, rightEnd.X))); + } + + var leftVertical = Math.Abs(leftStart.X - leftEnd.X) <= tolerance; + var rightVertical = Math.Abs(rightStart.X - rightEnd.X) <= tolerance; + if (leftVertical && rightVertical && Math.Abs(leftStart.X - rightStart.X) <= tolerance) + { + return Math.Max(0d, + Math.Min(Math.Max(leftStart.Y, leftEnd.Y), Math.Max(rightStart.Y, rightEnd.Y)) + - Math.Max(Math.Min(leftStart.Y, leftEnd.Y), Math.Min(rightStart.Y, rightEnd.Y))); + } + + return 0d; + } + + private static double ComputeSegmentLength(WorkflowRenderPoint start, WorkflowRenderPoint end) + { + var dx = end.X - start.X; + var dy = end.Y - start.Y; + return Math.Sqrt((dx * dx) + (dy * dy)); + } + + private static bool HasGatewayCornerDiagonal( + WorkflowRenderRoutedEdge edge, + WorkflowRenderPositionedNode node, + bool fromSource) + { + if (node.Kind is not ("Decision" or "Fork" or "Join")) + { + return false; + } + + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + return false; + } + + var boundary = fromSource ? path[0] : path[^1]; + var adjacent = fromSource ? path[1] : path[^2]; + var deltaX = Math.Abs(boundary.X - adjacent.X); + var deltaY = Math.Abs(boundary.Y - adjacent.Y); + if (deltaX < 3d || deltaY < 3d) + { + return false; + } + + return ElkShapeBoundaries.IsNearGatewayVertex(ToElkNode(node), new ElkPoint { X = boundary.X, Y = boundary.Y }); + } + + private static bool HasGatewayInteriorAdjacentPoint( + WorkflowRenderRoutedEdge edge, + WorkflowRenderPositionedNode node, + bool fromSource) + { + if (node.Kind is not ("Decision" or "Fork" or "Join")) + { + return false; + } + + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + return false; + } + + var adjacent = fromSource ? path[1] : path[^2]; + return ElkShapeBoundaries.IsInsideNodeBoundingBoxInterior( + ToElkNode(node), + new ElkPoint { X = adjacent.X, Y = adjacent.Y }); + } + + private static bool HasGatewaySourceExitCurl( + WorkflowRenderRoutedEdge edge, + WorkflowRenderPositionedNode sourceNode) + { + if (sourceNode.Kind is not ("Decision" or "Fork" or "Join")) + { + return false; + } + + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 4) + { + return false; + } + + const double coordinateTolerance = 0.5d; + var sample = path.Take(Math.Min(path.Count, 6)).ToArray(); + var desiredDx = path[^1].X - path[0].X; + var desiredDy = path[^1].Y - path[0].Y; + var absDx = Math.Abs(desiredDx); + var absDy = Math.Abs(desiredDy); + + if (absDx >= absDy * 1.35d) + { + var prefixCount = ResolveGatewaySourceCurlPrefixCount(sample, desiredDx, horizontalDominant: true, coordinateTolerance); + return HasAxisReversalFromStart(sample.Take(prefixCount).Select(point => point.Y), desiredDy); + } + + if (absDy >= absDx * 1.35d) + { + var prefixCount = ResolveGatewaySourceCurlPrefixCount(sample, desiredDy, horizontalDominant: false, coordinateTolerance); + return HasAxisReversalFromStart(sample.Take(prefixCount).Select(point => point.X), desiredDx); + } + + return HasAxisReversalFromStart(sample.Select(point => point.X), desiredDx) + || HasAxisReversalFromStart(sample.Select(point => point.Y), desiredDy); + } + + private static int ResolveGatewaySourceCurlPrefixCount( + IReadOnlyList sample, + double desiredDelta, + bool horizontalDominant, + double tolerance) + { + if (sample.Count < 2 || Math.Abs(desiredDelta) <= tolerance) + { + return sample.Count; + } + + var start = sample[0]; + for (var i = 1; i < sample.Count; i++) + { + var delta = horizontalDominant + ? sample[i].X - start.X + : sample[i].Y - start.Y; + if (Math.Abs(delta) > tolerance && Math.Sign(delta) == Math.Sign(desiredDelta)) + { + return Math.Max(3, i + 1); + } + } + + return sample.Count; + } + + private static bool HasGatewaySourcePreferredFaceMismatch( + WorkflowRenderRoutedEdge edge, + WorkflowRenderPositionedNode sourceNode, + IReadOnlyCollection allNodes) + { + if (sourceNode.Kind is not ("Decision" or "Fork" or "Join")) + { + return false; + } + + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + return false; + } + + if (!ElkEdgePostProcessor.HasClearGatewaySourceDirectRepairOpportunity( + path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(), + ToElkNode(sourceNode), + allNodes.Select(ToElkNode).ToArray(), + edge.SourceNodeId, + edge.TargetNodeId)) + { + return false; + } + + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var boundaryDx = path[0].X - centerX; + var boundaryDy = path[0].Y - centerY; + + if (Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d) + { + return Math.Sign(boundaryDx) != Math.Sign(desiredDx) + || Math.Abs(boundaryDy) > sourceNode.Height * 0.28d; + } + + if (Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d) + { + return Math.Sign(boundaryDy) != Math.Sign(desiredDy) + || Math.Abs(boundaryDx) > sourceNode.Width * 0.28d; + } + + return false; + } + + private static bool HasGatewaySourceDominantAxisDetour( + WorkflowRenderRoutedEdge edge, + WorkflowRenderPositionedNode sourceNode, + IReadOnlyCollection allNodes) + { + if (sourceNode.Kind is not ("Decision" or "Fork" or "Join")) + { + return false; + } + + var path = ExtractPath(edge); + if (path.Count < 3) + { + return false; + } + + if (!ElkEdgePostProcessor.HasClearGatewaySourceDirectRepairOpportunity( + path.Select(point => new ElkPoint { X = point.X, Y = point.Y }).ToArray(), + ToElkNode(sourceNode), + allNodes.Select(ToElkNode).ToArray(), + edge.SourceNodeId, + edge.TargetNodeId)) + { + return false; + } + + const double coordinateTolerance = 0.5d; + var centerX = sourceNode.X + (sourceNode.Width / 2d); + var centerY = sourceNode.Y + (sourceNode.Height / 2d); + var desiredDx = path[^1].X - centerX; + var desiredDy = path[^1].Y - centerY; + var dominantHorizontal = Math.Abs(desiredDx) >= Math.Abs(desiredDy) * 1.25d && Math.Sign(desiredDx) != 0; + var dominantVertical = Math.Abs(desiredDy) >= Math.Abs(desiredDx) * 1.25d && Math.Sign(desiredDy) != 0; + if (!dominantHorizontal && !dominantVertical) + { + return false; + } + + var boundary = path[0]; + var adjacent = path[1]; + var firstDx = adjacent.X - boundary.X; + var firstDy = adjacent.Y - boundary.Y; + if (dominantHorizontal) + { + if (Math.Sign(firstDx) != Math.Sign(desiredDx) || Math.Abs(firstDx) <= coordinateTolerance) + { + return true; + } + + return Math.Abs(firstDy) > Math.Max(24d, Math.Abs(desiredDy) + 12d) + && Math.Abs(firstDy) > Math.Abs(firstDx) * 1.25d; + } + + if (Math.Sign(firstDy) != Math.Sign(desiredDy) || Math.Abs(firstDy) <= coordinateTolerance) + { + return true; + } + + return Math.Abs(firstDx) > Math.Max(24d, Math.Abs(desiredDx) + 12d) + && Math.Abs(firstDx) > Math.Abs(firstDy) * 1.25d; + } + + private static bool HasGatewaySourceVertexExit( + WorkflowRenderRoutedEdge edge, + WorkflowRenderPositionedNode sourceNode) + { + if (sourceNode.Kind is not ("Decision" or "Fork" or "Join")) + { + return false; + } + + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + return false; + } + + var boundary = new ElkPoint { X = path[0].X, Y = path[0].Y }; + return ElkShapeBoundaries.IsNearGatewayVertex(ToElkNode(sourceNode), boundary); + } + + private static bool HasGatewaySourceScoringIssue( + WorkflowRenderRoutedEdge edge, + IReadOnlyCollection allNodes) + { + var sourceNode = allNodes.Single(node => node.Id == edge.SourceNodeId); + if (sourceNode.Kind is not ("Decision" or "Fork" or "Join")) + { + return false; + } + + var path = ExtractPath(edge) + .Select(point => new ElkPoint { X = point.X, Y = point.Y }) + .ToArray(); + if (path.Length < 2) + { + return false; + } + + return ElkEdgePostProcessor.HasClearGatewaySourceScoringOpportunity( + path, + ToElkNode(sourceNode), + allNodes.Select(ToElkNode).ToArray(), + edge.SourceNodeId, + edge.TargetNodeId); + } + + private static bool HasNearNodeClearanceViolation( + WorkflowRenderRoutedEdge edge, + WorkflowRenderPositionedNode node, + double minClearance) + { + foreach (var section in edge.Sections) + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + for (var i = 0; i < points.Count - 1; i++) + { + var start = points[i]; + var end = points[i + 1]; + var horizontal = Math.Abs(start.Y - end.Y) < 2d; + var vertical = Math.Abs(start.X - end.X) < 2d; + if (!horizontal && !vertical) + { + continue; + } + + if (horizontal) + { + var overlapX = Math.Max(start.X, end.X) > node.X && Math.Min(start.X, end.X) < node.X + node.Width; + var distance = Math.Min(Math.Abs(start.Y - node.Y), Math.Abs(start.Y - (node.Y + node.Height))); + if (overlapX && distance > 0.5d && distance < minClearance) + { + return true; + } + } + else + { + var overlapY = Math.Max(start.Y, end.Y) > node.Y && Math.Min(start.Y, end.Y) < node.Y + node.Height; + var distance = Math.Min(Math.Abs(start.X - node.X), Math.Abs(start.X - (node.X + node.Width))); + if (overlapY && distance > 0.5d && distance < minClearance) + { + return true; + } + } + } + } + + return false; + } + + private static IEnumerable GetBoundaryAngleViolations( + WorkflowRenderRoutedEdge edge, + IReadOnlyCollection nodes) + { + var nodesById = nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + var path = new List(); + foreach (var section in edge.Sections) + { + if (path.Count == 0) + { + path.Add(section.StartPoint); + } + + path.AddRange(section.BendPoints); + path.Add(section.EndPoint); + } + + if (path.Count < 2) + { + yield break; + } + + if (nodesById.TryGetValue(edge.SourceNodeId, out var sourceNode) + && !HasValidBoundaryAngle(path[0], path[1], sourceNode)) + { + yield return $"{edge.Id}:source:{sourceNode.Id}"; + } + + if (nodesById.TryGetValue(edge.TargetNodeId, out var targetNode) + && !HasValidBoundaryAngle(path[^1], path[^2], targetNode)) + { + yield return $"{edge.Id}:target:{targetNode.Id}"; + } + } + + private static bool HasValidBoundaryAngle( + WorkflowRenderPoint boundaryPoint, + WorkflowRenderPoint adjacentPoint, + WorkflowRenderPositionedNode node) + { + if (node.Kind is "Decision" or "Fork" or "Join") + { + return ElkShapeBoundaries.HasValidGatewayBoundaryAngle( + ToElkNode(node), + new ElkPoint { X = boundaryPoint.X, Y = boundaryPoint.Y }, + new ElkPoint { X = adjacentPoint.X, Y = adjacentPoint.Y }); + } + + var segDx = Math.Abs(boundaryPoint.X - adjacentPoint.X); + var segDy = Math.Abs(boundaryPoint.Y - adjacentPoint.Y); + if (segDx < 3d && segDy < 3d) + { + return true; + } + + var side = ResolveBoundarySide(boundaryPoint, node); + var validForVerticalSide = segDx > segDy * 3d; + var validForHorizontalSide = segDy > segDx * 3d; + return side switch + { + "left" or "right" => validForVerticalSide, + "top" or "bottom" => validForHorizontalSide, + _ => true, + }; + } + + private static bool HasAxisReversalFromStart(IEnumerable values, double desiredDelta) + { + const double tolerance = 0.5d; + var distinctValues = new List(); + foreach (var value in values) + { + if (distinctValues.Count == 0 || Math.Abs(distinctValues[^1] - value) > tolerance) + { + distinctValues.Add(value); + } + } + + if (distinctValues.Count < 3) + { + return false; + } + + var directions = new List(); + for (var i = 1; i < distinctValues.Count; i++) + { + var delta = distinctValues[i] - distinctValues[i - 1]; + if (Math.Abs(delta) <= tolerance) + { + continue; + } + + directions.Add(Math.Sign(delta)); + } + + if (directions.Count < 2) + { + return false; + } + + if (Math.Abs(desiredDelta) <= tolerance) + { + return directions.Distinct().Count() > 1; + } + + var desiredSign = Math.Sign(desiredDelta); + var sawOpposite = false; + foreach (var direction in directions) + { + if (direction == desiredSign) + { + if (sawOpposite) + { + return true; + } + + continue; + } + + sawOpposite = true; + } + + return false; + } + + private static IEnumerable GetBelowGraphOffenders( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + if (nodes.Count == 0) + { + yield break; + } + + var graphBottom = nodes.Max(node => node.Y + node.Height) + 4d; + foreach (var edge in edges) + { + foreach (var section in edge.Sections) + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + for (var i = 0; i < points.Count - 1; i++) + { + if (Math.Max(points[i].Y, points[i + 1].Y) <= graphBottom) + { + continue; + } + + yield return edge.Id; + goto NextEdge; + } + } + + NextEdge: + ; + } + } + + private static IEnumerable GetUnderNodeOffenders( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + if (serviceNodes.Length == 0) + { + yield break; + } + + var minClearance = Math.Min(serviceNodes.Average(node => node.Width), serviceNodes.Average(node => node.Height)) / 2d; + foreach (var edge in edges) + { + foreach (var section in edge.Sections) + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + for (var i = 0; i < points.Count - 1; i++) + { + var start = points[i]; + var end = points[i + 1]; + if (Math.Abs(start.Y - end.Y) > 2d) + { + continue; + } + + var minX = Math.Min(start.X, end.X); + var maxX = Math.Max(start.X, end.X); + foreach (var node in serviceNodes) + { + if (node.Id == edge.SourceNodeId || node.Id == edge.TargetNodeId) + { + continue; + } + + if (maxX <= node.X + 0.5d || minX >= node.X + node.Width - 0.5d) + { + continue; + } + + var distanceBelowNode = start.Y - (node.Y + node.Height); + if (distanceBelowNode <= 0.5d || distanceBelowNode >= minClearance) + { + continue; + } + + yield return edge.Id; + goto NextEdge; + } + } + } + + NextEdge: + ; + } + } + + private static IEnumerable GetLongDiagonalOffenders( + IReadOnlyCollection edges, + IReadOnlyCollection nodes) + { + var serviceNodes = nodes.Where(node => node.Kind is not "Start" and not "End").ToArray(); + if (serviceNodes.Length == 0) + { + yield break; + } + + var averageShapeSize = (serviceNodes.Average(node => node.Width) + serviceNodes.Average(node => node.Height)) / 2d; + var maxDiagonalLength = Math.Max(96d, averageShapeSize * 2d); + + foreach (var edge in edges) + { + foreach (var section in edge.Sections) + { + var points = new List { section.StartPoint }; + points.AddRange(section.BendPoints); + points.Add(section.EndPoint); + for (var i = 0; i < points.Count - 1; i++) + { + var dx = Math.Abs(points[i + 1].X - points[i].X); + var dy = Math.Abs(points[i + 1].Y - points[i].Y); + if (dx <= 3d || dy <= 3d) + { + continue; + } + + var length = Math.Sqrt((dx * dx) + (dy * dy)); + if (length <= maxDiagonalLength) + { + continue; + } + + yield return edge.Id; + goto NextEdge; + } + } + + NextEdge: + ; + } + } + + private static ElkPositionedNode ToElkNode(WorkflowRenderPositionedNode node) + { + return new ElkPositionedNode + { + Id = node.Id, + Label = node.Label, + Kind = node.Kind, + X = node.X, + Y = node.Y, + Width = node.Width, + Height = node.Height, + }; + } + private static string ResolveBoundarySide(WorkflowRenderPoint point, WorkflowRenderPositionedNode node) { var left = Math.Abs(point.X - node.X); @@ -379,4 +1627,36 @@ public class DocumentProcessingWorkflowRenderingTests return "bottom"; } + + private static string ResolveBoundaryApproachSide( + WorkflowRenderPoint boundaryPoint, + WorkflowRenderPoint adjacentPoint, + WorkflowRenderPositionedNode node) + { + var deltaX = boundaryPoint.X - adjacentPoint.X; + var deltaY = boundaryPoint.Y - adjacentPoint.Y; + var absDx = Math.Abs(deltaX); + var absDy = Math.Abs(deltaY); + if (absDx <= 0.5d && absDy > 0.5d) + { + return deltaY >= 0d ? "top" : "bottom"; + } + + if (absDy <= 0.5d && absDx > 0.5d) + { + return deltaX >= 0d ? "left" : "right"; + } + + if (absDx > absDy * 1.25d) + { + return deltaX >= 0d ? "left" : "right"; + } + + if (absDy > absDx * 1.25d) + { + return deltaY >= 0d ? "top" : "bottom"; + } + + return ResolveBoundarySide(boundaryPoint, node); + } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs index bafafa099..e6cddf768 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgePostProcessor.cs @@ -9647,6 +9647,11 @@ internal static class ElkEdgePostProcessor } const double tolerance = 0.5d; + if (HasShortOrthogonalTargetHook(path, targetNode, side, tolerance)) + { + return true; + } + var startIndex = Math.Max(0, path.Count - (side is "left" or "right" ? 4 : 3)); var axisValues = new List(path.Count - startIndex); for (var i = startIndex; i < path.Count; i++) @@ -9722,6 +9727,85 @@ internal static class ElkEdgePostProcessor return false; } + private static bool HasShortOrthogonalTargetHook( + IReadOnlyList path, + ElkPositionedNode targetNode, + string side, + double tolerance) + { + if (path.Count < 3) + { + return false; + } + + var boundaryPoint = path[^1]; + var runStartIndex = path.Count - 2; + if (side is "left" or "right") + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].Y - boundaryPoint.Y) <= tolerance) + { + runStartIndex--; + } + } + else + { + while (runStartIndex > 0 && Math.Abs(path[runStartIndex - 1].X - boundaryPoint.X) <= tolerance) + { + runStartIndex--; + } + } + + if (runStartIndex == 0) + { + return false; + } + + var overallDeltaX = path[^1].X - path[0].X; + var overallDeltaY = path[^1].Y - path[0].Y; + var overallAbsDx = Math.Abs(overallDeltaX); + var overallAbsDy = Math.Abs(overallDeltaY); + var sameRowThreshold = Math.Max(24d, targetNode.Height / 3d); + var sameColumnThreshold = Math.Max(24d, targetNode.Width / 3d); + var looksHorizontal = overallAbsDx >= overallAbsDy * 1.15d + && overallAbsDy <= sameRowThreshold + && Math.Sign(overallDeltaX) != 0; + var looksVertical = overallAbsDy >= overallAbsDx * 1.15d + && overallAbsDx <= sameColumnThreshold + && Math.Sign(overallDeltaY) != 0; + var contradictsDominantApproach = side switch + { + "left" or "right" => looksVertical, + "top" or "bottom" => looksHorizontal, + _ => false, + }; + if (!contradictsDominantApproach) + { + return false; + } + + var runStart = path[runStartIndex]; + var boundaryDepth = side is "left" or "right" + ? Math.Abs(boundaryPoint.X - runStart.X) + : Math.Abs(boundaryPoint.Y - runStart.Y); + var requiredDepth = side is "left" or "right" + ? targetNode.Width + : targetNode.Height; + if (boundaryDepth + tolerance >= requiredDepth) + { + return false; + } + + var predecessor = path[runStartIndex - 1]; + var predecessorDx = Math.Abs(runStart.X - predecessor.X); + var predecessorDy = Math.Abs(runStart.Y - predecessor.Y); + return side switch + { + "left" or "right" => predecessorDy > predecessorDx * 3d, + "top" or "bottom" => predecessorDx > predecessorDy * 3d, + _ => false, + }; + } + private static bool IsOnWrongSideOfTarget( ElkPoint point, ElkPositionedNode targetNode,