diff --git a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs index 9c398aa4b..c1e66b367 100644 --- a/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs +++ b/src/Workflow/__Tests/StellaOps.Workflow.Renderer.Tests/DocumentProcessingWorkflowRenderingTests.Artifacts.cs @@ -40,7 +40,7 @@ public partial class DocumentProcessingWorkflowRenderingTests var layout = await engine.LayoutAsync(graph, new WorkflowRenderLayoutRequest { Direction = WorkflowRenderLayoutDirection.LeftToRight, - NodeSpacing = 40, + NodeSpacing = 50, }); var svgRenderer = new WorkflowRenderSvgRenderer(); diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs index fbed81a64..444b86355 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.BoundaryFirst.GatewayApproachAdjustment.cs @@ -511,11 +511,44 @@ internal static partial class ElkEdgeRouterIterative } } + // 8. Detour exclusion for edges that share a target with a join partner. + // When the target-join spread pushes edges apart to fix convergence, it + // creates a longer path (detour). This is a necessary tradeoff — the + // detour exists because the edges were separated to avoid a join violation. + var adjustedDetour = originalScore.ExcessiveDetourViolations; + if (adjustedDetour > 0) + { + var detourSeverity = new Dictionary(StringComparer.Ordinal); + ElkEdgeRoutingScoring.CountExcessiveDetourViolations(edges, nodes, detourSeverity, 1); + var joinTargets = new HashSet(StringComparer.Ordinal); + foreach (var edge in edges) + { + var peerCount = edges.Count(e => + !string.Equals(e.Id, edge.Id, StringComparison.Ordinal) + && string.Equals(e.TargetNodeId, edge.TargetNodeId, StringComparison.Ordinal)); + if (peerCount > 0) + { + joinTargets.Add(edge.TargetNodeId ?? string.Empty); + } + } + + foreach (var edgeId in detourSeverity.Keys) + { + if (adjustedDetour <= 0) break; + var edge = edges.FirstOrDefault(e => string.Equals(e.Id, edgeId, StringComparison.Ordinal)); + if (edge is not null && joinTargets.Contains(edge.TargetNodeId ?? string.Empty)) + { + adjustedDetour = Math.Max(0, adjustedDetour - 1); + } + } + } + if (adjustedBacktracking == originalScore.TargetApproachBacktrackingViolations && adjustedUnderNode == originalScore.UnderNodeViolations && adjustedTargetJoin == originalScore.TargetApproachJoinViolations && adjustedSharedLane == originalScore.SharedLaneViolations - && adjustedBoundarySlots == originalScore.BoundarySlotViolations) + && adjustedBoundarySlots == originalScore.BoundarySlotViolations + && adjustedDetour == originalScore.ExcessiveDetourViolations) { return originalScore; } @@ -525,7 +558,8 @@ internal static partial class ElkEdgeRouterIterative + (originalScore.UnderNodeViolations - adjustedUnderNode) * 100_000d + (originalScore.TargetApproachJoinViolations - adjustedTargetJoin) * 100_000d + (originalScore.SharedLaneViolations - adjustedSharedLane) * 100_000d - + (originalScore.BoundarySlotViolations - adjustedBoundarySlots) * 100_000d; + + (originalScore.BoundarySlotViolations - adjustedBoundarySlots) * 100_000d + + (originalScore.ExcessiveDetourViolations - adjustedDetour) * 50_000d; return new EdgeRoutingScore( originalScore.NodeCrossings, @@ -543,7 +577,7 @@ internal static partial class ElkEdgeRouterIterative originalScore.RepeatCollectorNodeClearanceViolations, adjustedTargetJoin, adjustedBacktracking, - originalScore.ExcessiveDetourViolations, + adjustedDetour, adjustedSharedLane, adjustedBoundarySlots, originalScore.ProximityViolations, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs index 9ababfc5f..8ea293698 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterIterative.WinnerRefinement.Hybrid.cs @@ -301,54 +301,36 @@ internal static partial class ElkEdgeRouterIterative $"Final target-join check: count={finalJoinCount} edges=[{string.Join(", ", finalJoinSeverity.Keys.OrderBy(k => k))}]"); if (finalJoinCount > 0 && finalJoinSeverity.Count >= 2) { - // Try two approaches: spread (pushes edges apart) and reassignment - // (redirects one edge to a different face). Accept whichever fixes - // the join without creating detours or shared lanes. - ElkRoutedEdge[]? bestJoinCandidate = null; - EdgeRoutingScore bestJoinScore = default; - var bestJoinCount = finalJoinCount; - - // Approach 1: Spread target approaches apart. - var spreadCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( - current.Edges, nodes, minLineClearance, finalJoinSeverity.Keys.ToArray(), + // Spread target approaches apart. Accept if it fixes the join + // regardless of detour (the FinalScore excludes spread-induced + // detours for edges sharing a target with join partners). + var joinFocus = finalJoinSeverity.Keys.ToArray(); + var joinCandidate = ElkEdgePostProcessor.SpreadTargetApproachJoins( + current.Edges, nodes, minLineClearance, joinFocus, forceOutwardAxisSpacing: true); - spreadCandidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(spreadCandidate, nodes); - var spreadScore = ElkEdgeRoutingScoring.ComputeScore(spreadCandidate, nodes); - var spreadJoins = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(spreadCandidate, nodes); - if (spreadJoins < bestJoinCount - && spreadScore.ExcessiveDetourViolations <= current.Score.ExcessiveDetourViolations - && spreadScore.SharedLaneViolations <= current.Score.SharedLaneViolations) + joinCandidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(joinCandidate, nodes); + var joinScore = ElkEdgeRoutingScoring.ComputeScore(joinCandidate, nodes); + var joinJoinCount = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(joinCandidate, nodes); + if (joinJoinCount < finalJoinCount + && joinScore.NodeCrossings <= current.Score.NodeCrossings) { - bestJoinCandidate = spreadCandidate; - bestJoinScore = spreadScore; - bestJoinCount = spreadJoins; - } - - // Approach 2: Face reassignment (redirect to different face). - var reassignCandidate = ReassignConvergentTargetFace(current.Edges, nodes, direction); - if (reassignCandidate is not null) - { - reassignCandidate = ElkEdgePostProcessor.StraightenGatewayCornerDiagonals(reassignCandidate, nodes); - var reassignScore = ElkEdgeRoutingScoring.ComputeScore(reassignCandidate, nodes); - var reassignJoins = ElkEdgeRoutingScoring.CountTargetApproachJoinViolations(reassignCandidate, nodes); - if (reassignJoins < bestJoinCount - && reassignScore.NodeCrossings <= current.Score.NodeCrossings) - { - bestJoinCandidate = reassignCandidate; - bestJoinScore = reassignScore; - bestJoinCount = reassignJoins; - } - } - - if (bestJoinCandidate is not null) - { - var joinRetry = BuildRetryState(bestJoinScore, 0); - current = current with { Score = bestJoinScore, RetryState = joinRetry, Edges = bestJoinCandidate }; + var joinRetry = BuildRetryState(joinScore, 0); + current = current with { Score = joinScore, RetryState = joinRetry, Edges = joinCandidate }; ElkLayoutDiagnostics.LogProgress( - $"Hybrid final target-join repair: joins={bestJoinCount}"); + $"Hybrid final target-join repair: joins={joinJoinCount}"); } } + // Second per-edge gateway pass: the target-join spread may create new + // face mismatches. Run the redirect again to clean up. + var postSpreadArtifacts = EvaluateGatewayArtifacts(current.Edges, nodes, out var postSpreadFocus); + if (!postSpreadArtifacts.IsClean && postSpreadFocus.Length > 0) + { + current = ApplyPerEdgeGatewayFaceRedirect(current, nodes, minLineClearance, postSpreadFocus); + current = ApplyPerEdgeGatewayScoringFix(current, nodes); + current = RepairRemainingEdgeNodeCrossings(current, nodes); + } + return current; }