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:
24
src/__Libraries/StellaOps.ElkSharp/AGENTS.md
Normal file
24
src/__Libraries/StellaOps.ElkSharp/AGENTS.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
269
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs
Normal file
269
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouteRefiner.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
193
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs
Normal file
193
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingGeometry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
176
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs
Normal file
176
src/__Libraries/StellaOps.ElkSharp/ElkEdgeRoutingScoring.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user