Implement missing backend endpoints for release orchestration

TASK-002: 11 deployment monitoring endpoints in JobEngine
  (list, get, logs, events, metrics, pause/resume/cancel/rollback/retry)
TASK-003: 6 evidence management endpoints in JobEngine
  (list, get, verify, export, raw, timeline)
TASK-005: 3 release dashboard endpoints in JobEngine
  (dashboard summary, approve/reject promotion)
TASK-006: 2 registry image search endpoints in Scanner
  (search with 9 mock images, digests lookup)

All endpoints return seed/mock data for testing. Auth policies
match existing patterns. Dual route registration on both
/api/ and /api/v1/ prefixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-23 15:52:20 +02:00
parent d3353e9d16
commit dd29786e38
17 changed files with 2066 additions and 26 deletions

View File

@@ -0,0 +1,24 @@
# AGENTS.md · StellaOps.ElkSharp
## Scope
- Working directory: `src/__Libraries/StellaOps.ElkSharp/`
- This library provides deterministic in-process layout primitives for workflow rendering.
- Prefer additive, tightly scoped changes that preserve the current routing contract before introducing new behavior.
## Required Reading
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/code-of-conduct/TESTING_PRACTICES.md`
- `docs/workflow/ENGINE.md`
- The active sprint in `docs/implplan/` covering ElkSharp work
## Local Rules
- Preserve deterministic output for the same graph and options. Do not introduce random tie-breaking.
- Keep orthogonal routing as the default contract unless a sprint explicitly broadens it.
- Treat channel assignment, dummy-edge reconstruction, and anchor selection as authoritative upstream inputs.
- Do not replace corridor and backward-route behavior with generic rerouting unless the sprint explicitly changes that contract.
- Keep `TopToBottom` behavior stable unless the sprint explicitly includes it.
## Testing
- Run the targeted workflow renderer test project for ElkSharp changes.
- Add regression tests for geometry-sensitive behavior before broad refactors.
- Prefer assertions on concrete node and edge geometry over build-only validation.

View File

@@ -7,16 +7,35 @@ internal static class ElkEdgePostProcessorAStar
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
string sourceId, string targetId,
double margin)
{
return RerouteWithGridAStar(
start,
end,
obstacles,
sourceId,
targetId,
new OrthogonalAStarOptions(margin, 200d, 0d, 14d),
[],
CancellationToken.None);
}
internal static List<ElkPoint>? RerouteWithGridAStar(
ElkPoint start, ElkPoint end,
(double Left, double Top, double Right, double Bottom, string Id)[] obstacles,
string sourceId, string targetId,
OrthogonalAStarOptions options,
IReadOnlyList<OrthogonalSoftObstacle> softObstacles,
CancellationToken cancellationToken)
{
var xs = new SortedSet<double> { start.X, end.X };
var ys = new SortedSet<double> { start.Y, end.Y };
foreach (var ob in obstacles)
{
if (ob.Id == sourceId || ob.Id == targetId) continue;
xs.Add(ob.Left - margin);
xs.Add(ob.Right + margin);
ys.Add(ob.Top - margin);
ys.Add(ob.Bottom + margin);
xs.Add(ob.Left - options.Margin);
xs.Add(ob.Right + options.Margin);
ys.Add(ob.Top - options.Margin);
ys.Add(ob.Bottom + options.Margin);
}
var xArr = xs.ToArray();
@@ -64,7 +83,6 @@ internal static class ElkEdgePostProcessorAStar
}
// A* with (ix, iy, direction) state; direction: 0=none, 1=horizontal, 2=vertical
const double bendPenalty = 200d;
var stateCount = xCount * yCount * 3;
var gScore = new double[stateCount];
Array.Fill(gScore, double.MaxValue);
@@ -89,6 +107,8 @@ internal static class ElkEdgePostProcessorAStar
var closed = new HashSet<int>();
while (openSet.Count > 0 && iterations++ < maxIterations)
{
cancellationToken.ThrowIfCancellationRequested();
var current = openSet.Dequeue();
if (!closed.Add(current))
@@ -139,9 +159,16 @@ internal static class ElkEdgePostProcessorAStar
if (IsBlocked(curIx, curIy, nx, ny)) continue;
var newDir = dirs[d];
var bend = (curDir != 0 && curDir != newDir) ? bendPenalty : 0d;
var bend = (curDir != 0 && curDir != newDir) ? options.BendPenalty : 0d;
var dist = Math.Abs(xArr[nx] - xArr[curIx]) + Math.Abs(yArr[ny] - yArr[curIy]);
var tentativeG = gScore[current] + dist + bend;
var softCost = ComputeSoftObstacleCost(
xArr[curIx],
yArr[curIy],
xArr[nx],
yArr[ny],
softObstacles,
options);
var tentativeG = gScore[current] + dist + bend + softCost;
var neighborState = StateId(nx, ny, newDir);
if (tentativeG < gScore[neighborState])
@@ -156,4 +183,43 @@ internal static class ElkEdgePostProcessorAStar
return null;
}
private static double ComputeSoftObstacleCost(
double x1,
double y1,
double x2,
double y2,
IReadOnlyList<OrthogonalSoftObstacle> softObstacles,
OrthogonalAStarOptions options)
{
if (options.SoftObstacleWeight <= 0d || softObstacles.Count == 0)
{
return 0d;
}
var candidateStart = new ElkPoint { X = x1, Y = y1 };
var candidateEnd = new ElkPoint { X = x2, Y = y2 };
var cost = 0d;
foreach (var obstacle in softObstacles)
{
if (ElkEdgeRoutingGeometry.SegmentsIntersect(candidateStart, candidateEnd, obstacle.Start, obstacle.End))
{
cost += 120d * options.SoftObstacleWeight;
continue;
}
if (ElkEdgeRoutingGeometry.AreParallelAndClose(
candidateStart,
candidateEnd,
obstacle.Start,
obstacle.End,
options.SoftObstacleClearance))
{
cost += 18d * options.SoftObstacleWeight;
}
}
return cost;
}
}

View File

@@ -0,0 +1,269 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeRouteRefiner
{
private static readonly OrthogonalAStarOptions[] TrialTemplates =
[
new OrthogonalAStarOptions(18d, 200d, 0.15d, 14d),
new OrthogonalAStarOptions(24d, 200d, 0.25d, 14d),
new OrthogonalAStarOptions(18d, 120d, 0.45d, 12d),
new OrthogonalAStarOptions(28d, 320d, 0.55d, 16d),
];
internal static ElkRoutedEdge[] Optimize(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
ElkLayoutOptions layoutOptions,
CancellationToken cancellationToken)
{
var options = ResolveOptions(layoutOptions);
if (options.Enabled != true
|| layoutOptions.Direction != ElkLayoutDirection.LeftToRight
|| edges.Length == 0
|| nodes.Length == 0)
{
return edges;
}
var bestEdges = edges;
var bestScore = ElkEdgeRoutingScoring.ComputeScore(bestEdges, nodes);
var bestNodeCrossings = bestScore.NodeCrossings;
for (var passIndex = 0; passIndex < options.MaxGlobalPasses; passIndex++)
{
cancellationToken.ThrowIfCancellationRequested();
var issues = ElkEdgeRoutingScoring.DetectIssues(bestEdges, nodes)
.Take(options.MaxProblemEdgesPerPass)
.ToArray();
if (issues.Length == 0)
{
break;
}
var improvedThisPass = false;
foreach (var issue in issues)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryImproveEdge(bestEdges, nodes, issue.EdgeId, bestScore, options, cancellationToken, out var improvedEdges, out var improvedScore))
{
continue;
}
bestEdges = improvedEdges;
bestScore = improvedScore;
bestNodeCrossings = bestScore.NodeCrossings;
improvedThisPass = true;
if (bestNodeCrossings == 0 && bestScore.EdgeCrossings == 0)
{
break;
}
}
if (!improvedThisPass)
{
break;
}
if (bestNodeCrossings == 0 && bestScore.EdgeCrossings == 0)
{
break;
}
}
return bestEdges;
}
private static bool TryImproveEdge(
ElkRoutedEdge[] edges,
ElkPositionedNode[] nodes,
string edgeId,
EdgeRoutingScore baselineScore,
EdgeRefinementOptions options,
CancellationToken cancellationToken,
out ElkRoutedEdge[] improvedEdges,
out EdgeRoutingScore improvedScore)
{
improvedEdges = edges;
improvedScore = baselineScore;
var edgeIndex = Array.FindIndex(edges, edge => string.Equals(edge.Id, edgeId, StringComparison.Ordinal));
if (edgeIndex < 0)
{
return false;
}
var edge = edges[edgeIndex];
if (!CanRefineEdge(edge, nodes))
{
return false;
}
var obstacleRectangles = nodes.Select(node => (
Left: node.X,
Top: node.Y,
Right: node.X + node.Width,
Bottom: node.Y + node.Height,
Id: node.Id)).ToArray();
var softObstacles = BuildSoftObstacles(edges, edgeId);
var bestLocalEdges = edges;
var bestLocalScore = baselineScore;
var trials = BuildTrials(options).Take(options.MaxTrialsPerProblemEdge);
foreach (var trial in trials)
{
cancellationToken.ThrowIfCancellationRequested();
var reroutedSections = new List<ElkEdgeSection>(edge.Sections.Count);
var rerouteFailed = false;
foreach (var section in edge.Sections)
{
var rerouted = ElkEdgePostProcessorAStar.RerouteWithGridAStar(
section.StartPoint,
section.EndPoint,
obstacleRectangles,
edge.SourceNodeId,
edge.TargetNodeId,
trial,
softObstacles,
cancellationToken);
if (rerouted is null || rerouted.Count < 2)
{
rerouteFailed = true;
break;
}
reroutedSections.Add(new ElkEdgeSection
{
StartPoint = rerouted[0],
EndPoint = rerouted[^1],
BendPoints = rerouted.Skip(1).Take(rerouted.Count - 2).ToArray(),
});
}
if (rerouteFailed)
{
continue;
}
var candidateEdges = (ElkRoutedEdge[])edges.Clone();
candidateEdges[edgeIndex] = new ElkRoutedEdge
{
Id = edge.Id,
SourceNodeId = edge.SourceNodeId,
TargetNodeId = edge.TargetNodeId,
SourcePortId = edge.SourcePortId,
TargetPortId = edge.TargetPortId,
Kind = edge.Kind,
Label = edge.Label,
Sections = reroutedSections,
};
var candidateScore = ElkEdgeRoutingScoring.ComputeScore(candidateEdges, nodes);
if (!IsBetterCandidate(candidateScore, bestLocalScore))
{
continue;
}
bestLocalEdges = candidateEdges;
bestLocalScore = candidateScore;
}
if (ReferenceEquals(bestLocalEdges, edges))
{
return false;
}
improvedEdges = bestLocalEdges;
improvedScore = bestLocalScore;
return true;
}
private static bool IsBetterCandidate(EdgeRoutingScore candidate, EdgeRoutingScore baseline)
{
if (candidate.NodeCrossings != baseline.NodeCrossings)
{
return candidate.NodeCrossings < baseline.NodeCrossings;
}
if (candidate.EdgeCrossings != baseline.EdgeCrossings)
{
return candidate.EdgeCrossings < baseline.EdgeCrossings;
}
return candidate.Value > baseline.Value + 0.01d;
}
private static bool CanRefineEdge(ElkRoutedEdge edge, IReadOnlyCollection<ElkPositionedNode> nodes)
{
if (!string.IsNullOrWhiteSpace(edge.SourcePortId) || !string.IsNullOrWhiteSpace(edge.TargetPortId))
{
return false;
}
if (!string.IsNullOrWhiteSpace(edge.Kind)
&& edge.Kind.StartsWith("backward|", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (nodes.Count == 0)
{
return false;
}
var graphMinY = nodes.Min(node => node.Y);
var graphMaxY = nodes.Max(node => node.Y + node.Height);
if (ElkEdgePostProcessor.HasCorridorBendPoints(edge, graphMinY, graphMaxY))
{
return false;
}
return !ElkEdgePostProcessor.IsRepeatCollectorLabel(edge.Label);
}
private static IReadOnlyList<OrthogonalSoftObstacle> BuildSoftObstacles(
IReadOnlyCollection<ElkRoutedEdge> edges,
string excludedEdgeId)
{
return ElkEdgeRoutingGeometry.FlattenSegments(edges)
.Where(segment => !string.Equals(segment.EdgeId, excludedEdgeId, StringComparison.Ordinal))
.Select(segment => new OrthogonalSoftObstacle(segment.Start, segment.End))
.ToArray();
}
private static IEnumerable<OrthogonalAStarOptions> BuildTrials(EdgeRefinementOptions options)
{
foreach (var template in TrialTemplates)
{
var softObstacleWeight = options.SoftObstacleWeight <= 0d
? 0d
: Math.Max(options.SoftObstacleWeight, template.SoftObstacleWeight);
yield return new OrthogonalAStarOptions(
Math.Max(options.BaseObstacleMargin, template.Margin),
template.BendPenalty,
softObstacleWeight,
Math.Max(8d, options.SoftObstacleClearance));
}
}
private static EdgeRefinementOptions ResolveOptions(ElkLayoutOptions layoutOptions)
{
var requested = layoutOptions.EdgeRefinement ?? new EdgeRefinementOptions();
var enabled = requested.Enabled ?? layoutOptions.Effort == ElkLayoutEffort.Best;
return new EdgeRefinementOptions
{
Enabled = enabled,
MaxGlobalPasses = Math.Max(0, requested.MaxGlobalPasses),
MaxTrialsPerProblemEdge = Math.Max(1, requested.MaxTrialsPerProblemEdge),
MaxProblemEdgesPerPass = Math.Max(1, requested.MaxProblemEdgesPerPass),
BaseObstacleMargin = Math.Max(8d, requested.BaseObstacleMargin),
SoftObstacleWeight = Math.Max(0d, requested.SoftObstacleWeight),
SoftObstacleClearance = Math.Max(8d, requested.SoftObstacleClearance),
};
}
}

View File

@@ -0,0 +1,193 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeRoutingGeometry
{
private const double CoordinateTolerance = 0.5d;
internal static IReadOnlyList<RoutedEdgeSegment> FlattenSegments(IReadOnlyCollection<ElkRoutedEdge> edges)
{
var segments = new List<RoutedEdgeSegment>();
foreach (var edge in edges)
{
segments.AddRange(FlattenSegments(edge));
}
return segments;
}
internal static IReadOnlyList<RoutedEdgeSegment> FlattenSegments(ElkRoutedEdge edge)
{
var segments = new List<RoutedEdgeSegment>();
foreach (var section in edge.Sections)
{
var points = new List<ElkPoint> { section.StartPoint };
points.AddRange(section.BendPoints);
points.Add(section.EndPoint);
for (var i = 0; i < points.Count - 1; i++)
{
segments.Add(new RoutedEdgeSegment(edge.Id, points[i], points[i + 1]));
}
}
return segments;
}
internal static double ComputePathLength(ElkRoutedEdge edge)
{
return FlattenSegments(edge).Sum(segment => ComputeSegmentLength(segment.Start, segment.End));
}
internal static double ComputeSegmentLength(ElkPoint start, ElkPoint end)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
return Math.Sqrt((dx * dx) + (dy * dy));
}
internal static bool SegmentsIntersect(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2)
{
if (ShareEndpoint(a1, a2, b1, b2))
{
return false;
}
if (AreCollinearAndOverlapping(a1, a2, b1, b2))
{
return false;
}
if (IsHorizontal(a1, a2) && IsVertical(b1, b2))
{
return IntersectsOrthogonal(a1, a2, b1, b2);
}
if (IsVertical(a1, a2) && IsHorizontal(b1, b2))
{
return IntersectsOrthogonal(b1, b2, a1, a2);
}
return SegmentsIntersectGeneral(a1, a2, b1, b2);
}
internal static bool AreParallelAndClose(
ElkPoint a1,
ElkPoint a2,
ElkPoint b1,
ElkPoint b2,
double clearance)
{
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2))
{
return Math.Abs(a1.Y - b1.Y) <= clearance
&& OverlapLength(Math.Min(a1.X, a2.X), Math.Max(a1.X, a2.X), Math.Min(b1.X, b2.X), Math.Max(b1.X, b2.X)) > 1d;
}
if (IsVertical(a1, a2) && IsVertical(b1, b2))
{
return Math.Abs(a1.X - b1.X) <= clearance
&& OverlapLength(Math.Min(a1.Y, a2.Y), Math.Max(a1.Y, a2.Y), Math.Min(b1.Y, b2.Y), Math.Max(b1.Y, b2.Y)) > 1d;
}
return false;
}
internal static bool AreCollinearAndOverlapping(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2)
{
if (IsHorizontal(a1, a2) && IsHorizontal(b1, b2) && Math.Abs(a1.Y - b1.Y) <= CoordinateTolerance)
{
return OverlapLength(Math.Min(a1.X, a2.X), Math.Max(a1.X, a2.X), Math.Min(b1.X, b2.X), Math.Max(b1.X, b2.X)) > 1d;
}
if (IsVertical(a1, a2) && IsVertical(b1, b2) && Math.Abs(a1.X - b1.X) <= CoordinateTolerance)
{
return OverlapLength(Math.Min(a1.Y, a2.Y), Math.Max(a1.Y, a2.Y), Math.Min(b1.Y, b2.Y), Math.Max(b1.Y, b2.Y)) > 1d;
}
return false;
}
internal static ElkPoint ResolveApproachPoint(ElkRoutedEdge edge)
{
var lastSection = edge.Sections.Last();
if (lastSection.BendPoints.Count > 0)
{
return lastSection.BendPoints.Last();
}
return lastSection.StartPoint;
}
internal static bool PointsEqual(ElkPoint left, ElkPoint right)
{
return Math.Abs(left.X - right.X) <= CoordinateTolerance
&& Math.Abs(left.Y - right.Y) <= CoordinateTolerance;
}
private static bool IsHorizontal(ElkPoint start, ElkPoint end) => Math.Abs(start.Y - end.Y) <= CoordinateTolerance;
private static bool IsVertical(ElkPoint start, ElkPoint end) => Math.Abs(start.X - end.X) <= CoordinateTolerance;
private static bool IntersectsOrthogonal(ElkPoint horizontalStart, ElkPoint horizontalEnd, ElkPoint verticalStart, ElkPoint verticalEnd)
{
var minHorizontalX = Math.Min(horizontalStart.X, horizontalEnd.X);
var maxHorizontalX = Math.Max(horizontalStart.X, horizontalEnd.X);
var minVerticalY = Math.Min(verticalStart.Y, verticalEnd.Y);
var maxVerticalY = Math.Max(verticalStart.Y, verticalEnd.Y);
return verticalStart.X > minHorizontalX + CoordinateTolerance
&& verticalStart.X < maxHorizontalX - CoordinateTolerance
&& horizontalStart.Y > minVerticalY + CoordinateTolerance
&& horizontalStart.Y < maxVerticalY - CoordinateTolerance;
}
private static bool ShareEndpoint(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2)
{
return PointsEqual(a1, b1)
|| PointsEqual(a1, b2)
|| PointsEqual(a2, b1)
|| PointsEqual(a2, b2);
}
private static bool SegmentsIntersectGeneral(ElkPoint a1, ElkPoint a2, ElkPoint b1, ElkPoint b2)
{
var o1 = Orientation(a1, a2, b1);
var o2 = Orientation(a1, a2, b2);
var o3 = Orientation(b1, b2, a1);
var o4 = Orientation(b1, b2, a2);
if (o1 != o2 && o3 != o4)
{
return true;
}
return o1 == 0 && OnSegment(a1, b1, a2)
|| o2 == 0 && OnSegment(a1, b2, a2)
|| o3 == 0 && OnSegment(b1, a1, b2)
|| o4 == 0 && OnSegment(b1, a2, b2);
}
private static int Orientation(ElkPoint start, ElkPoint middle, ElkPoint end)
{
var value = ((middle.Y - start.Y) * (end.X - middle.X)) - ((middle.X - start.X) * (end.Y - middle.Y));
if (Math.Abs(value) <= CoordinateTolerance)
{
return 0;
}
return value > 0 ? 1 : 2;
}
private static bool OnSegment(ElkPoint start, ElkPoint point, ElkPoint end)
{
return point.X <= Math.Max(start.X, end.X) + CoordinateTolerance
&& point.X >= Math.Min(start.X, end.X) - CoordinateTolerance
&& point.Y <= Math.Max(start.Y, end.Y) + CoordinateTolerance
&& point.Y >= Math.Min(start.Y, end.Y) - CoordinateTolerance;
}
private static double OverlapLength(double firstMin, double firstMax, double secondMin, double secondMax)
{
return Math.Min(firstMax, secondMax) - Math.Max(firstMin, secondMin);
}
}

View File

@@ -0,0 +1,176 @@
namespace StellaOps.ElkSharp;
internal static class ElkEdgeRoutingScoring
{
internal static EdgeRoutingScore ComputeScore(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes)
{
var nodeCrossings = CountEdgeNodeCrossings(edges, nodes, null);
var edgeCrossings = CountEdgeEdgeCrossings(edges, null);
var bendCount = SumBendPoints(edges);
var totalPathLength = SumPathLengths(edges);
var targetCongestion = CountTargetApproachCongestion(edges);
var value = -(nodeCrossings * 100_000d)
- (edgeCrossings * 650d)
- (bendCount * 5d)
- (targetCongestion * 25d)
- (totalPathLength * 0.1d);
return new EdgeRoutingScore(
nodeCrossings,
edgeCrossings,
bendCount,
targetCongestion,
totalPathLength,
value);
}
internal static IReadOnlyList<EdgeRoutingIssue> DetectIssues(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes)
{
var severityByEdgeId = new Dictionary<string, int>(StringComparer.Ordinal);
CountEdgeNodeCrossings(edges, nodes, severityByEdgeId, 100);
CountEdgeEdgeCrossings(edges, severityByEdgeId, 50);
foreach (var edge in edges)
{
var bendCount = edge.Sections.Sum(section => section.BendPoints.Count);
if (bendCount > 5)
{
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + 5;
}
var directDistance = edge.Sections.Sum(section =>
Math.Abs(section.EndPoint.X - section.StartPoint.X) + Math.Abs(section.EndPoint.Y - section.StartPoint.Y));
var pathLength = ElkEdgeRoutingGeometry.ComputePathLength(edge);
if (pathLength > directDistance * 1.8d && bendCount > 2)
{
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + 2;
}
}
return severityByEdgeId
.Where(pair => pair.Value > 0)
.OrderByDescending(pair => pair.Value)
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
.Select(pair => new EdgeRoutingIssue(pair.Key, pair.Value))
.ToArray();
}
internal static int CountEdgeNodeCrossings(
IReadOnlyCollection<ElkRoutedEdge> edges,
IReadOnlyCollection<ElkPositionedNode> nodes,
Dictionary<string, int>? severityByEdgeId,
int severityWeight = 1)
{
var obstacles = nodes.Select(node => (
Left: node.X,
Top: node.Y,
Right: node.X + node.Width,
Bottom: node.Y + node.Height,
Id: node.Id)).ToArray();
var crossingCount = 0;
foreach (var edge in edges)
{
var edgeCrossings = 0;
foreach (var segment in ElkEdgeRoutingGeometry.FlattenSegments(edge))
{
if (ElkEdgePostProcessor.SegmentCrossesObstacle(
segment.Start,
segment.End,
obstacles,
edge.SourceNodeId,
edge.TargetNodeId))
{
crossingCount++;
edgeCrossings++;
}
}
if (edgeCrossings > 0 && severityByEdgeId is not null)
{
severityByEdgeId[edge.Id] = severityByEdgeId.GetValueOrDefault(edge.Id) + (edgeCrossings * severityWeight);
}
}
return crossingCount;
}
internal static int CountEdgeEdgeCrossings(
IReadOnlyCollection<ElkRoutedEdge> edges,
Dictionary<string, int>? severityByEdgeId,
int severityWeight = 1)
{
var crossingCount = 0;
var segments = ElkEdgeRoutingGeometry.FlattenSegments(edges);
for (var i = 0; i < segments.Count; i++)
{
for (var j = i + 1; j < segments.Count; j++)
{
if (string.Equals(segments[i].EdgeId, segments[j].EdgeId, StringComparison.Ordinal))
{
continue;
}
if (!ElkEdgeRoutingGeometry.SegmentsIntersect(
segments[i].Start,
segments[i].End,
segments[j].Start,
segments[j].End))
{
continue;
}
crossingCount++;
if (severityByEdgeId is null)
{
continue;
}
severityByEdgeId[segments[i].EdgeId] = severityByEdgeId.GetValueOrDefault(segments[i].EdgeId) + severityWeight;
severityByEdgeId[segments[j].EdgeId] = severityByEdgeId.GetValueOrDefault(segments[j].EdgeId) + severityWeight;
}
}
return crossingCount;
}
internal static int SumBendPoints(IReadOnlyCollection<ElkRoutedEdge> edges)
{
return edges.Sum(edge => edge.Sections.Sum(section => section.BendPoints.Count));
}
internal static double SumPathLengths(IReadOnlyCollection<ElkRoutedEdge> edges)
{
return edges.Sum(ElkEdgeRoutingGeometry.ComputePathLength);
}
internal static int CountTargetApproachCongestion(IReadOnlyCollection<ElkRoutedEdge> edges)
{
var congestionCount = 0;
foreach (var group in edges.GroupBy(edge => edge.TargetNodeId, StringComparer.Ordinal))
{
var approaches = group
.Select(edge => ElkEdgeRoutingGeometry.ResolveApproachPoint(edge))
.OrderBy(point => point.Y)
.ThenBy(point => point.X)
.ToArray();
for (var i = 1; i < approaches.Length; i++)
{
if (Math.Abs(approaches[i].Y - approaches[i - 1].Y) <= 4d
&& Math.Abs(approaches[i].X - approaches[i - 1].X) <= 24d)
{
congestionCount++;
}
}
}
return congestionCount;
}
}

View File

@@ -45,3 +45,30 @@ internal sealed record DummyNodeResult(
Dictionary<string, int> AugmentedInputOrder,
HashSet<string> DummyNodeIds,
Dictionary<string, List<string>> EdgeDummyChains);
internal readonly record struct EdgeRoutingScore(
int NodeCrossings,
int EdgeCrossings,
int BendCount,
int TargetCongestion,
double TotalPathLength,
double Value);
internal readonly record struct EdgeRoutingIssue(
string EdgeId,
int Severity);
internal readonly record struct RoutedEdgeSegment(
string EdgeId,
ElkPoint Start,
ElkPoint End);
internal readonly record struct OrthogonalAStarOptions(
double Margin,
double BendPenalty,
double SoftObstacleWeight,
double SoftObstacleClearance);
internal readonly record struct OrthogonalSoftObstacle(
ElkPoint Start,
ElkPoint End);

View File

@@ -67,6 +67,18 @@ public sealed record ElkLayoutOptions
public ElkLayoutEffort Effort { get; init; } = ElkLayoutEffort.Best;
public int? OrderingIterations { get; init; }
public int? PlacementIterations { get; init; }
public EdgeRefinementOptions? EdgeRefinement { get; init; }
}
public sealed record EdgeRefinementOptions
{
public bool? Enabled { get; init; }
public int MaxGlobalPasses { get; init; } = 2;
public int MaxTrialsPerProblemEdge { get; init; } = 4;
public int MaxProblemEdgesPerPass { get; init; } = 12;
public double BaseObstacleMargin { get; init; } = 18;
public double SoftObstacleWeight { get; init; } = 0.4d;
public double SoftObstacleClearance { get; init; } = 14d;
}
public sealed record ElkPoint

View File

@@ -105,6 +105,8 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
.ToArray();
for (var gutterPass = 0; gutterPass < 3; gutterPass++)
{
cancellationToken.ThrowIfCancellationRequested();
if (!ElkEdgeChannelGutters.ExpandVerticalCorridorGutters(
positionedNodes,
routedEdges,
@@ -139,6 +141,8 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
for (var compactPass = 0; compactPass < 2; compactPass++)
{
cancellationToken.ThrowIfCancellationRequested();
if (!ElkEdgeChannelGutters.CompactSparseVerticalCorridorGutters(
positionedNodes,
routedEdges,
@@ -207,16 +211,18 @@ public sealed class ElkSharpLayeredLayoutEngine : IElkLayoutEngine
.OrderBy(x => inputOrder.GetValueOrDefault(x.Id, int.MaxValue))
.ToArray();
// Post-processing pipeline (5 generic passes, no node-specific logic):
// Post-processing pipeline (deterministic generic passes, no node-specific logic):
// 1. Project endpoints onto actual node shape boundaries (diamond/hexagon/rectangle)
routedEdges = ElkEdgePostProcessor.SnapAnchorsToNodeBoundary(routedEdges, finalNodes);
// 2. Reroute any edge crossing node bounding boxes (including diagonals from shape projection)
// 2. Deterministic bounded refinement for crossing-prone orthogonal routes
routedEdges = ElkEdgeRouteRefiner.Optimize(routedEdges, finalNodes, options, cancellationToken);
// 3. Reroute any edge crossing node bounding boxes (including diagonals from shape projection)
routedEdges = ElkEdgePostProcessor.AvoidNodeCrossings(routedEdges, finalNodes, options.Direction);
// 3. Convert any remaining diagonal segments to orthogonal L-corners
// 4. Convert any remaining diagonal segments to orthogonal L-corners
routedEdges = ElkEdgePostProcessor.EliminateDiagonalSegments(routedEdges, finalNodes);
// 4. Simplify: remove collinear/duplicate points, try L-shape shortcuts
// 5. Simplify: remove collinear/duplicate points, try L-shape shortcuts
routedEdges = ElkEdgePostProcessorSimplify.SimplifyEdgePaths(routedEdges, finalNodes);
// 5. Compress outer corridor distances
// 6. Compress outer corridor distances
routedEdges = ElkEdgePostProcessorSimplify.TightenOuterCorridors(routedEdges, finalNodes);
return Task.FromResult(new ElkLayoutResult