feat(astra): add independent nesting engine and fix hole validation
Replace the Terra scaffold with an independent configuration-space contact placer and bounded stock-plan search. Include plugin tests, synthetic and DXF benchmark drivers, results, and deployment documentation. Correct shared collision clipping and hole subtraction so curved-hole inserts validate consistently. Cover translated layouts, spacing violations, operand order, winding, and independent boolean-area comparisons. Validation: 1,293 tests passed with 12 fixture skips; all 34 synthetic/generated and four DXF cases are valid and complete.
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Astra;
|
||||
|
||||
/// <summary>Independent configuration-space contact packing with bounded stock-plan search.</summary>
|
||||
public sealed class AstraNestingEngine : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.Validate(job);
|
||||
var parts = GeometryPreparation.Prepare(job, token);
|
||||
var fit = parts.Select(p => job.Plates.Select(s => p.Variants.Any(v =>
|
||||
v.Width <= s.Size.Length - s.EdgeSpacing.Left - s.EdgeSpacing.Right + 1e-9 &&
|
||||
v.Height <= s.Size.Width - s.EdgeSpacing.Top - s.EdgeSpacing.Bottom + 1e-9)).ToArray()).ToArray();
|
||||
var placer = new ContactPlacer(parts, new ContactGeometry(), token);
|
||||
var initial = new Plan(new int[parts.Length], new int[job.Plates.Count], new List<SheetTrial>(), 0);
|
||||
var frontier = new List<Plan> { initial };
|
||||
var best = initial;
|
||||
Plan? complete = IsComplete(initial) ? initial : null;
|
||||
var trials = new Dictionary<string, SheetTrial>(StringComparer.Ordinal);
|
||||
var priorities = job.Parts.Select(p => p.Priority).Distinct().OrderDescending().ToArray();
|
||||
var evaluated = 0;
|
||||
var unitCosts = Enumerable.Repeat(double.PositiveInfinity, parts.Length).ToArray();
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var children = new Dictionary<string, Plan>(StringComparer.Ordinal);
|
||||
foreach (var state in frontier)
|
||||
{
|
||||
if (state.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue)) continue;
|
||||
var lowerBound = LowerBound(state);
|
||||
if (complete != null && lowerBound >= complete.Cost - 1e-7) continue;
|
||||
var flexibility = Enumerable.Range(0, parts.Length).Select(p =>
|
||||
Enumerable.Range(0, job.Plates.Count).Count(s => fit[p][s] && Available(state, s))).ToArray();
|
||||
for (var s = 0; s < job.Plates.Count; s++)
|
||||
{
|
||||
if (!Available(state, s)) continue;
|
||||
// Search breadth is work-count bounded, never elapsed-time dependent.
|
||||
// Past this budget, continue filling greedily instead of abandoning demand.
|
||||
var modes = evaluated < 24 ? 2 : 1;
|
||||
for (var mode = 0; mode < modes; mode++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var key = $"{s}/{mode}/{string.Join(',', state.Counts)}/{string.Join(',', flexibility)}";
|
||||
if (!trials.TryGetValue(key, out var trial))
|
||||
{
|
||||
progress?.Report(new(NestJobStage.EvaluatingCandidate, job.Plates[s].Id,
|
||||
state.Sheets.Count, 0, 0));
|
||||
trial = placer.Pack(s, job.Plates[s], state.Counts, flexibility, mode);
|
||||
if (trials.Count >= 256) trials.Clear();
|
||||
trials[key] = trial;
|
||||
evaluated++;
|
||||
}
|
||||
if (trial.Shapes.Count == 0) continue;
|
||||
var sheetCost = job.Plates[s].Size.Length * job.Plates[s].Size.Width;
|
||||
for (var p = 0; p < parts.Length; p++)
|
||||
{
|
||||
var delivered = trial.Counts[p] - state.Counts[p];
|
||||
if (delivered > 0) unitCosts[p] = M.Min(unitCosts[p], sheetCost / delivered);
|
||||
}
|
||||
var used = (int[])state.Used.Clone(); used[s]++;
|
||||
var sheets = new List<SheetTrial>(state.Sheets) { trial };
|
||||
var next = new Plan(trial.Counts, used, sheets,
|
||||
state.Cost + job.Plates[s].Size.Length * job.Plates[s].Size.Width);
|
||||
if (BetterFulfillment(next, best)) best = next;
|
||||
if (IsComplete(next))
|
||||
{
|
||||
if (complete == null || next.Cost < complete.Cost - 1e-7 ||
|
||||
(M.Abs(next.Cost - complete.Cost) < 1e-7 && next.Sheets.Count < complete.Sheets.Count)) complete = next;
|
||||
continue;
|
||||
}
|
||||
var stateKey = $"{string.Join(',', next.Counts)}/{string.Join(',', next.Used)}";
|
||||
if (!children.TryGetValue(stateKey, out var prior) || next.Cost < prior.Cost)
|
||||
children[stateKey] = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
var ranked = children.Values.Where(p => complete == null || LowerBound(p) < complete.Cost - 1e-7)
|
||||
.OrderBy(Estimate).ThenByDescending(PlacedArea).ThenBy(p => p.Cost).ToList();
|
||||
frontier = new List<Plan>();
|
||||
if (ranked.Count > 0)
|
||||
{
|
||||
frontier.Add(ranked[0]);
|
||||
// A material-only lower bound favors cheap small-sheet prefixes and
|
||||
// can discard every high-throughput plan. Preserve one progress leader.
|
||||
var leader = ranked.OrderByDescending(PlacedArea).ThenBy(p => p.Cost).First();
|
||||
if (!ReferenceEquals(leader, ranked[0])) frontier.Add(leader);
|
||||
foreach (var candidate in ranked)
|
||||
{
|
||||
if (frontier.Count >= (evaluated < 64 ? 3 : 2)) break;
|
||||
if (!frontier.Contains(candidate)) frontier.Add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
var selected = complete ?? best;
|
||||
var counts = new int[parts.Length];
|
||||
var plates = new List<NestJobPlateResult>();
|
||||
foreach (var sheet in selected.Sheets)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var stock = job.Plates[sheet.StockIndex];
|
||||
var x = (stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length) + stock.EdgeSpacing.Left;
|
||||
var y = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom;
|
||||
var placements = sheet.Shapes.Select(p => new NestJobPlacement(job.Parts[p.Variant.Part].Id,
|
||||
counts[p.Variant.Part]++, x + p.X - p.Variant.OriginX,
|
||||
y + p.Y - p.Variant.OriginY, p.Variant.Angle)).ToArray();
|
||||
plates.Add(new(plates.Count, stock, placements));
|
||||
progress?.Report(new(NestJobStage.PlateCommitted, stock.Id, plates.Count - 1,
|
||||
plates.Count, counts.Sum()));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
var reason = complete != null ? NestJobStopReason.Completed :
|
||||
selected.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue) ? NestJobStopReason.PlateLimitReached :
|
||||
!Enumerable.Range(0, job.Plates.Count).Any(s => Available(selected, s)) ? NestJobStopReason.StockExhausted :
|
||||
NestJobStopReason.NoPlacementFound;
|
||||
return new(complete != null ? NestJobStatus.Complete : NestJobStatus.Incomplete, reason, plates,
|
||||
job.Parts.Select((p, i) => new PartFulfillment(p.Id, p.Quantity, counts[i], p.Quantity - counts[i])),
|
||||
job.Plates.Select((s, i) => new StockUsage(s.Id, selected.Used[i], s.Quantity - selected.Used[i])));
|
||||
|
||||
bool Available(Plan p, int s) => p.Used[s] < (job.Plates[s].Quantity ?? int.MaxValue);
|
||||
bool IsComplete(Plan p) => parts.Select((part, i) => p.Counts[i] == part.Requirement.Quantity).All(v => v);
|
||||
double PlacedArea(Plan p) => parts.Select((part, i) => p.Counts[i] * part.Area).Sum();
|
||||
double LowerBound(Plan p) => p.Cost + parts.Select((part, i) =>
|
||||
(part.Requirement.Quantity - p.Counts[i]) * part.Area).Sum();
|
||||
double Estimate(Plan p)
|
||||
{
|
||||
var projected = 0.0;
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
if (double.IsFinite(unitCosts[i])) projected = M.Max(projected,
|
||||
(parts[i].Requirement.Quantity - p.Counts[i]) * unitCosts[i]);
|
||||
return M.Max(LowerBound(p), p.Cost + projected);
|
||||
}
|
||||
bool BetterFulfillment(Plan a, Plan b)
|
||||
{
|
||||
foreach (var priority in priorities)
|
||||
{
|
||||
var ac = parts.Select((p, i) => p.Requirement.Priority == priority ? a.Counts[i] : 0).Sum();
|
||||
var bc = parts.Select((p, i) => p.Requirement.Priority == priority ? b.Counts[i] : 0).Sum();
|
||||
if (ac != bc) return ac > bc;
|
||||
}
|
||||
return a.Cost < b.Cost;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Plan(int[] Counts, int[] Used, List<SheetTrial> Sheets, double Cost);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Astra;
|
||||
|
||||
/// <summary>Per-solve configuration-space cache, never a shared mutable geometry cache.</summary>
|
||||
internal sealed class ContactGeometry
|
||||
{
|
||||
private readonly Dictionary<(int, int, double), PathsD> cache = new();
|
||||
|
||||
internal PathsD Forbidden(ShapeVariant stationary, ShapeVariant moving, double spacing, CancellationToken token)
|
||||
{
|
||||
var key = (stationary.Id, moving.Id, spacing);
|
||||
if (cache.TryGetValue(key, out var value)) return value;
|
||||
token.ThrowIfCancellationRequested();
|
||||
PathsD paths;
|
||||
if (stationary.BoxLike && moving.BoxLike)
|
||||
{
|
||||
// Exact axis-aligned rectangle contacts need four configuration-space
|
||||
// vertices, not hundreds of round-offset samples. The square corner is
|
||||
// conservative for diagonal clearance and leaves row/column fits exact.
|
||||
var gap = spacing;
|
||||
paths = new PathsD { new PathD {
|
||||
new(-moving.Width - gap, -moving.Height - gap),
|
||||
new(stationary.Width + gap, -moving.Height - gap),
|
||||
new(stationary.Width + gap, stationary.Height + gap),
|
||||
new(-moving.Width - gap, stationary.Height + gap) } };
|
||||
if (cache.Count >= 8192) cache.Clear();
|
||||
return cache[key] = paths;
|
||||
}
|
||||
if (stationary.Convex && moving.Convex)
|
||||
{
|
||||
var nfp = NoFitPolygon.ComputeConvex(stationary.Hull, moving.Hull);
|
||||
paths = new PathsD { ClipperBridge.ToPath(nfp, positive: true) };
|
||||
}
|
||||
else
|
||||
{
|
||||
// Minkowski edge quads may enclose spurious interior voids. Filling all
|
||||
// positive outer paths is conservative for solid perimeter nesting; real
|
||||
// part holes are searched separately and checked against material regions.
|
||||
var a = ToInteger(stationary.ContactOutline, false);
|
||||
var b = ToInteger(moving.ContactOutline, true);
|
||||
var sum = Clipper.MinkowskiSum(b, a, true);
|
||||
paths = new PathsD(sum.Where(Clipper.IsPositive).Select(path => new PathD(
|
||||
path.Select(p => new PointD(p.X / GeometryPrecision.Scale, p.Y / GeometryPrecision.Scale)))));
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
var delta = spacing + stationary.ContactError + moving.ContactError
|
||||
+ (stationary.Curved || moving.Curved ? 0.003 : spacing > 0 ? 0.0003 : 0);
|
||||
if (delta > 0) paths = Clipper.InflatePaths(paths, delta, JoinType.Round,
|
||||
EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
|
||||
// Bound cache residency for jobs with many distinct rotation pairs.
|
||||
if (cache.Count >= 8192) cache.Clear();
|
||||
return cache[key] = paths;
|
||||
}
|
||||
|
||||
private static Path64 ToInteger(Polygon polygon, bool reflect)
|
||||
{
|
||||
var scale = reflect ? -GeometryPrecision.Scale : GeometryPrecision.Scale;
|
||||
var path = ClipperBridge.ToPath(polygon, positive: true);
|
||||
return new Path64(path.Select(p => new Point64((long)M.Round(p.x * scale), (long)M.Round(p.y * scale))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Astra;
|
||||
|
||||
internal sealed record PackedShape(ShapeVariant Variant, double X, double Y);
|
||||
internal sealed record SheetTrial(int StockIndex, int[] Counts, List<PackedShape> Shapes, double Area, double Span);
|
||||
|
||||
/// <summary>Searches vertices of the available translation region and exact-fit contacts.</summary>
|
||||
internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geometry, CancellationToken token)
|
||||
{
|
||||
private readonly Dictionary<(int, int, double, double, double, double, double), bool> validationCache = new();
|
||||
private double validationOriginX;
|
||||
private double validationOriginY;
|
||||
|
||||
internal SheetTrial Pack(int stockIndex, NestPlateStock stock, int[] committed, int[] flexibility, int mode)
|
||||
{
|
||||
validationOriginX = (stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length) + stock.EdgeSpacing.Left;
|
||||
validationOriginY = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom;
|
||||
var width = stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right;
|
||||
var height = stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top;
|
||||
var counts = (int[])committed.Clone();
|
||||
var placed = new List<PackedShape>();
|
||||
var spaces = new Dictionary<int, SearchSpace>();
|
||||
var order = Enumerable.Range(0, parts.Length)
|
||||
.OrderByDescending(i => parts[i].Requirement.Priority)
|
||||
.ThenBy(i => flexibility[i])
|
||||
.ThenByDescending(i => parts[i].Variants.Select(v => v.Width * v.Height).DefaultIfEmpty(0).Min())
|
||||
.ThenBy(i => i).ToArray();
|
||||
double area = 0, right = 0, top = 0;
|
||||
foreach (var p in order)
|
||||
{
|
||||
while (counts[p] < parts[p].Requirement.Quantity)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
PackedShape? best = null;
|
||||
(double, double, double, double) bestScore = (double.MaxValue, 0, 0, 0);
|
||||
foreach (var v in parts[p].Variants)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (v.Width > width + 1e-9 || v.Height > height + 1e-9) continue;
|
||||
var pose = Find(v, placed, width, height, stock.PartSpacing, mode, right, top, spaces);
|
||||
if (pose == null) continue;
|
||||
var score = Score(pose, width, height, mode, right, top);
|
||||
if (score.CompareTo(bestScore) < 0) { best = pose; bestScore = score; }
|
||||
}
|
||||
if (best == null) break;
|
||||
placed.Add(best);
|
||||
counts[p]++;
|
||||
area += parts[p].Area;
|
||||
right = M.Max(right, best.X + best.Variant.Width);
|
||||
top = M.Max(top, best.Y + best.Variant.Height);
|
||||
}
|
||||
}
|
||||
return new(stockIndex, counts, placed, area, right * top);
|
||||
}
|
||||
|
||||
private PackedShape? Find(ShapeVariant moving, List<PackedShape> placed, double width, double height,
|
||||
double spacing, int mode, double right, double top, Dictionary<int, SearchSpace> spaces)
|
||||
{
|
||||
var maxX = M.Max(0, width - moving.Width);
|
||||
var maxY = M.Max(0, height - moving.Height);
|
||||
if (!spaces.TryGetValue(moving.Id, out var space))
|
||||
{
|
||||
space = new SearchSpace();
|
||||
space.Anchors.AddRange(new PointD[] { new(0, 0), new(maxX, 0), new(0, maxY), new(maxX, maxY) });
|
||||
space.Free.Add(new PathD { new(0, 0), new(maxX, 0), new(maxX, maxY), new(0, maxY) });
|
||||
spaces.Add(moving.Id, space);
|
||||
}
|
||||
var points = space.Anchors;
|
||||
var forbidden = new PathsD();
|
||||
var blockers = space.Blockers;
|
||||
foreach (var other in placed.Skip(space.Processed))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var paths = GeometryPrecision.Translate(geometry.Forbidden(other.Variant, moving, spacing, token), other.X, other.Y);
|
||||
foreach (var path in paths)
|
||||
{
|
||||
forbidden.Add(path);
|
||||
if (!other.Variant.Material.Any(p => !Clipper.IsPositive(p)))
|
||||
blockers.Add((path, path.Min(p => p.x), path.Min(p => p.y), path.Max(p => p.x), path.Max(p => p.y)));
|
||||
// Clipping loses zero-area feasible regions. Retain their NFP vertices
|
||||
// and intersections with plate boundaries explicitly for exact fits.
|
||||
for (var i = 0; (maxX < 1e-8 || maxY < 1e-8) && i < path.Count; i++)
|
||||
{
|
||||
var a = path[i]; var b = path[(i + 1) % path.Count];
|
||||
Add(a.x, a.y);
|
||||
CrossX(0); CrossX(maxX); CrossY(0); CrossY(maxY);
|
||||
void CrossX(double x)
|
||||
{
|
||||
if (M.Abs(b.x - a.x) < 1e-12) return;
|
||||
var t = (x - a.x) / (b.x - a.x);
|
||||
if (t >= 0 && t <= 1) Add(x, a.y + t * (b.y - a.y));
|
||||
}
|
||||
void CrossY(double y)
|
||||
{
|
||||
if (M.Abs(b.y - a.y) < 1e-12) return;
|
||||
var t = (y - a.y) / (b.y - a.y);
|
||||
if (t >= 0 && t <= 1) Add(a.x + t * (b.x - a.x), y);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Axis contacts also cover exact spacing when the padded NFP cannot fit.
|
||||
foreach (var x in new[] { other.X, other.X + other.Variant.Width + spacing,
|
||||
other.X - moving.Width - spacing })
|
||||
foreach (var y in new[] { 0, other.Y, other.Y + other.Variant.Height + spacing,
|
||||
other.Y - moving.Height - spacing }) Add(x, y);
|
||||
|
||||
// The solid-outline NFP deliberately fills holes. Search each real hole
|
||||
// separately, then validate against material, not the outer envelope.
|
||||
foreach (var hole in other.Variant.Material.Where(p => !Clipper.IsPositive(p)))
|
||||
{
|
||||
var l = hole.Min(p => p.x) + other.X + spacing + 0.0004;
|
||||
var b = hole.Min(p => p.y) + other.Y + spacing + 0.0004;
|
||||
var r = hole.Max(p => p.x) + other.X - spacing - moving.Width - 0.0004;
|
||||
var t = hole.Max(p => p.y) + other.Y - spacing - moving.Height - 0.0004;
|
||||
if (r < l || t < b) continue;
|
||||
Add(l, b); Add(r, b); Add(l, t); Add(r, t); Add((l + r) / 2, (b + t) / 2);
|
||||
// Box corners miss the useful interior of circular and rounded holes.
|
||||
// Interior samples also cover fits that require an off-center placement.
|
||||
foreach (var fx in new[] { 0.25, 0.5, 0.75 })
|
||||
foreach (var fy in new[] { 0.25, 0.5, 0.75 }) Add(l + fx * (r - l), b + fy * (t - b));
|
||||
}
|
||||
}
|
||||
if (placed.Count > 0 && maxX > 1e-8 && maxY > 1e-8)
|
||||
{
|
||||
space.Free = Clipper.Difference(space.Free, forbidden, FillRule.NonZero, GeometryPrecision.Digits);
|
||||
}
|
||||
space.Processed = placed.Count;
|
||||
points = new List<PointD>(space.Anchors);
|
||||
foreach (var path in space.Free) foreach (var p in path) Add(p.x, p.y);
|
||||
var seen = new HashSet<(long, long)>();
|
||||
foreach (var pose in points.Select(p => new PackedShape(moving, p.x, p.y))
|
||||
.OrderBy(p => Score(p, width, height, mode, right, top)))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!seen.Add(((long)M.Round(pose.X * 1e6), (long)M.Round(pose.Y * 1e6)))) continue;
|
||||
if (blockers.Any(b => pose.X > b.L && pose.X < b.R && pose.Y > b.B && pose.Y < b.T &&
|
||||
StrictlyInside(b.Path, pose.X, pose.Y))) continue;
|
||||
if (Valid(pose, placed, spacing)) return pose;
|
||||
if (spacing > 0) continue;
|
||||
// Exact contacts can be invalid only after the host's four-decimal
|
||||
// polygon rounding. Try nearby outward contacts without changing angle.
|
||||
foreach (var (dx, dy) in new (double, double)[] {
|
||||
(0.0003, 0), (0, 0.0003), (0.0003, 0.0003), (-0.0003, 0),
|
||||
(0, -0.0003), (-0.0003, 0.0003), (0.0003, -0.0003), (-0.0003, -0.0003) })
|
||||
{
|
||||
var nudged = pose with { X = pose.X + dx, Y = pose.Y + dy };
|
||||
if (nudged.X < 0 || nudged.Y < 0 || nudged.X > maxX || nudged.Y > maxY) continue;
|
||||
if (Valid(nudged, placed, spacing)) return nudged;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
void Add(double x, double y)
|
||||
{
|
||||
if (x < -1e-7 || y < -1e-7 || x > maxX + 1e-7 || y > maxY + 1e-7) return;
|
||||
points.Add(new(M.Clamp(x, 0, maxX), M.Clamp(y, 0, maxY)));
|
||||
}
|
||||
}
|
||||
|
||||
private static (double, double, double, double) Score(PackedShape pose, double width, double height,
|
||||
int mode, double right, double top)
|
||||
{
|
||||
var r = pose.X + pose.Variant.Width;
|
||||
var t = pose.Y + pose.Variant.Height;
|
||||
// Two directional searches use the same configuration-space algorithm. The
|
||||
// third objective minimizes the growing used rectangle rather than a strip.
|
||||
return mode switch
|
||||
{
|
||||
1 => (r + 0.01 * t * width / height, pose.Y, pose.X, pose.Variant.Width * pose.Variant.Height),
|
||||
2 => (M.Max(right, r) * M.Max(top, t), t, r, pose.Variant.Width * pose.Variant.Height),
|
||||
_ => (t + 0.01 * r * height / width, pose.X, pose.Y, pose.Variant.Width * pose.Variant.Height)
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class SearchSpace
|
||||
{
|
||||
internal int Processed;
|
||||
internal PathsD Free = new();
|
||||
internal List<PointD> Anchors = new();
|
||||
internal List<(PathD Path, double L, double B, double R, double T)> Blockers = new();
|
||||
}
|
||||
|
||||
private static bool StrictlyInside(PathD path, double x, double y)
|
||||
{
|
||||
var inside = false;
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
var a = path[i]; var b = path[(i + 1) % path.Count];
|
||||
var cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x);
|
||||
if (M.Abs(cross) <= 2e-6 * M.Max(1, M.Abs(b.x - a.x) + M.Abs(b.y - a.y)) &&
|
||||
x >= M.Min(a.x, b.x) - 1e-6 && x <= M.Max(a.x, b.x) + 1e-6 &&
|
||||
y >= M.Min(a.y, b.y) - 1e-6 && y <= M.Max(a.y, b.y) + 1e-6) return false;
|
||||
if ((a.y > y) != (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
private bool Valid(PackedShape candidate, List<PackedShape> placed, double spacing)
|
||||
{
|
||||
PathsD? material = null;
|
||||
PathsD? validationMaterial = null;
|
||||
foreach (var other in placed)
|
||||
{
|
||||
var gap = spacing + (candidate.Variant.Curved || other.Variant.Curved ? 0.003 : 0.0001);
|
||||
if (candidate.X >= other.X + other.Variant.Width + gap ||
|
||||
other.X >= candidate.X + candidate.Variant.Width + gap ||
|
||||
candidate.Y >= other.Y + other.Variant.Height + gap ||
|
||||
other.Y >= candidate.Y + candidate.Variant.Height + gap) continue;
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (candidate.Variant.BoxLike && other.Variant.BoxLike)
|
||||
{
|
||||
if (candidate.X >= other.X + other.Variant.Width + spacing - 1e-9 ||
|
||||
other.X >= candidate.X + candidate.Variant.Width + spacing - 1e-9 ||
|
||||
candidate.Y >= other.Y + other.Variant.Height + spacing - 1e-9 ||
|
||||
other.Y >= candidate.Y + candidate.Variant.Height + spacing - 1e-9) continue;
|
||||
return false;
|
||||
}
|
||||
material ??= GeometryPrecision.Translate(candidate.Variant.Material, candidate.X, candidate.Y);
|
||||
var obstacle = GeometryPrecision.Translate(other.Variant.Halo(spacing), other.X, other.Y);
|
||||
var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits);
|
||||
if (M.Abs(Clipper.Area(overlap)) > 1e-8) return false;
|
||||
validationMaterial ??= GeometryPrecision.Translate(candidate.Variant.ValidationRegion(0), candidate.X, candidate.Y);
|
||||
var validationObstacle = GeometryPrecision.Translate(other.Variant.ValidationRegion(spacing), other.X, other.Y);
|
||||
if (M.Abs(Clipper.Area(Clipper.Intersect(validationMaterial, validationObstacle,
|
||||
FillRule.NonZero, GeometryPrecision.Digits))) > 1e-8) return false;
|
||||
if (spacing == 0 || candidate.Variant.Material.Count > 1 || other.Variant.Material.Count > 1)
|
||||
{
|
||||
var outerIntersection = Clipper.Intersect(
|
||||
new PathsD(validationMaterial.Where(Clipper.IsPositive)),
|
||||
new PathsD(validationObstacle.Where(Clipper.IsPositive)), FillRule.NonZero, GeometryPrecision.Digits);
|
||||
if (spacing != 0 && M.Abs(Clipper.Area(outerIntersection)) <= 1e-8) continue;
|
||||
var key = (candidate.Variant.Id, other.Variant.Id, spacing,
|
||||
candidate.X + validationOriginX, candidate.Y + validationOriginY,
|
||||
other.X + validationOriginX, other.Y + validationOriginY);
|
||||
if (!validationCache.TryGetValue(key, out var collides))
|
||||
{
|
||||
collides = ValidationOverlap(
|
||||
GeometryPrecision.Translate(validationMaterial, validationOriginX, validationOriginY),
|
||||
GeometryPrecision.Translate(validationObstacle, validationOriginX, validationOriginY));
|
||||
if (validationCache.Count >= 4096) validationCache.Clear();
|
||||
validationCache[key] = collides;
|
||||
}
|
||||
if (collides) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private static bool ValidationOverlap(PathsD a, PathsD b)
|
||||
{
|
||||
var holesA = a.Where(p => !Clipper.IsPositive(p)).Select(ClipperBridge.ToPolygon).ToList();
|
||||
var holesB = b.Where(p => !Clipper.IsPositive(p)).Select(ClipperBridge.ToPolygon).ToList();
|
||||
foreach (var outerA in a.Where(Clipper.IsPositive))
|
||||
foreach (var outerB in b.Where(Clipper.IsPositive))
|
||||
{
|
||||
var pa = ClipperBridge.ToPolygon(outerA);
|
||||
var pb = ClipperBridge.ToPolygon(outerB);
|
||||
// The benchmark orders by world-space left bound before clipping.
|
||||
if (pa.Left <= pb.Left ? Collision.HasOverlap(pa, pb, holesA, holesB) :
|
||||
Collision.HasOverlap(pb, pa, holesB, holesA)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<Compile Remove="tests/**/*.cs;benchmarks/**/*.cs" />
|
||||
<ProjectReference Include="../../OpenNest.Core/OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,187 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Astra;
|
||||
|
||||
internal sealed record PreparedPart(NestJobPart Requirement, double Area, ShapeVariant[] Variants);
|
||||
|
||||
internal sealed class ShapeVariant
|
||||
{
|
||||
internal required int Id { get; init; }
|
||||
internal required int Part { get; init; }
|
||||
internal required double Angle { get; init; }
|
||||
internal required double OriginX { get; init; }
|
||||
internal required double OriginY { get; init; }
|
||||
internal required double Width { get; init; }
|
||||
internal required double Height { get; init; }
|
||||
internal required bool Curved { get; init; }
|
||||
internal required PathsD Material { get; init; }
|
||||
internal required Polygon Outline { get; init; }
|
||||
internal required Polygon ContactOutline { get; init; }
|
||||
internal required double ContactError { get; init; }
|
||||
internal required Polygon Hull { get; init; }
|
||||
internal required bool Convex { get; init; }
|
||||
internal required ShapeProfile ValidationProfile { get; init; }
|
||||
internal bool BoxLike => Material.Count == 1 && GridAligned(OriginX) && GridAligned(OriginY) &&
|
||||
GridAligned(Width) && GridAligned(Height) &&
|
||||
M.Abs(Outline.Area() - Width * Height) < 1e-8 * M.Max(1, Width * Height);
|
||||
private static bool GridAligned(double x) => M.Abs(x - M.Round(x * 10000) / 10000) < 1e-9;
|
||||
private readonly Dictionary<double, PathsD> validationRegions = new();
|
||||
|
||||
internal PathsD ValidationRegion(double spacing)
|
||||
{
|
||||
if (validationRegions.TryGetValue(spacing, out var cached)) return cached;
|
||||
// Match the external validator's sequence: flatten/round in the original
|
||||
// rotated snapshot frame, then translate. Rounding after normalization is
|
||||
// not equivalent at a zero-clearance contact.
|
||||
var region = ClipperBridge.OffsetForValidation(ValidationProfile, spacing, 0.001);
|
||||
var paths = new PathsD(region.Outers.Select(p => ClipperBridge.ToPath(p, true)));
|
||||
paths.AddRange(region.Holes.Select(p => ClipperBridge.ToPath(p, false)));
|
||||
return validationRegions[spacing] = GeometryPrecision.Translate(paths, -OriginX, -OriginY);
|
||||
}
|
||||
private readonly Dictionary<double, PathsD> halos = new();
|
||||
|
||||
internal PathsD Halo(double spacing)
|
||||
{
|
||||
if (halos.TryGetValue(spacing, out var cached)) return cached;
|
||||
// Raw outlines already circumscribe curves; the extra clearance covers independent
|
||||
// flattenings after pose materialization and the validator's four-decimal grid.
|
||||
var delta = spacing + (Curved ? 0.0021 : spacing > 0 ? 0.00015 : 0);
|
||||
return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta,
|
||||
JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class GeometryPrecision
|
||||
{
|
||||
internal const int Digits = 6;
|
||||
internal const double Scale = 1_000_000;
|
||||
internal const double Epsilon = 0.000002;
|
||||
|
||||
internal static PathsD Translate(PathsD paths, double x, double y) =>
|
||||
new(paths.Select(path => new PathD(path.Select(p => new PointD(p.x + x, p.y + y)))));
|
||||
|
||||
internal static PathsD FromPolygons(IEnumerable<Polygon> polygons, bool positive) =>
|
||||
new(polygons.Select(p => ClipperBridge.ToPath(p, positive)));
|
||||
}
|
||||
|
||||
internal static class GeometryPreparation
|
||||
{
|
||||
internal static PreparedPart[] Prepare(NestJob job, CancellationToken token)
|
||||
{
|
||||
var id = 0;
|
||||
return job.Parts.Select((part, index) =>
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
|
||||
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)).ToList();
|
||||
// Input validation has established that open marks lie inside material. They
|
||||
// must not be interpreted as holes by ShapeProfile.
|
||||
var closed = ShapeBuilder.GetShapes(entities).Where(s => s.IsClosed())
|
||||
.SelectMany(s => s.Entities).ToList();
|
||||
var baseProfile = new ShapeProfile(closed);
|
||||
var area = baseProfile.Perimeter.Area() - baseProfile.Cutouts.Sum(h => h.Area());
|
||||
var variants = new List<ShapeVariant>();
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var angle in Angles(part.Rotation, baseProfile))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var rotated = closed.Select(e => { var copy = e.Clone(); copy.Rotate(angle); return copy; }).ToList();
|
||||
var x = rotated.Min(e => e.Left);
|
||||
var y = rotated.Min(e => e.Bottom);
|
||||
var w = rotated.Max(e => e.Right) - x;
|
||||
var h = rotated.Max(e => e.Top) - y;
|
||||
if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0)
|
||||
throw new ArgumentException($"Unusable rotated bounds: {part.Id}.");
|
||||
var validationProfile = new ShapeProfile(rotated.Select(e => e.Clone()).ToList());
|
||||
foreach (var e in rotated) e.Offset(-x, -y);
|
||||
var profile = new ShapeProfile(rotated);
|
||||
var material = ClipperBridge.ToRegion(profile, 0.001, circumscribe: true);
|
||||
// Circular/symmetric parts should not multiply identical NFP work. Compare
|
||||
// normalized closed contours, including holes, independent of start vertex.
|
||||
var key = string.Join("|", material.Select(Canonical).Order(StringComparer.Ordinal));
|
||||
if (!keys.Add(key)) continue;
|
||||
var outline = ClipperBridge.Flatten(profile.Perimeter, 0.001, circumscribe: true);
|
||||
var hull = ConvexHull.Compute(outline.Vertices);
|
||||
var convex = M.Abs(hull.Area() - outline.Area()) < 1e-7 * M.Max(1, hull.Area());
|
||||
// Concave Minkowski sums have quadratic input size. Only the contact
|
||||
// proposal outline is simplified; fine material remains the safety gate.
|
||||
// Pad the resulting NFP by both approximation error bounds.
|
||||
var contactError = !convex && outline.Vertices.Count > 64 ? M.Max(0.002, M.Min(w, h) * 0.002) : 0;
|
||||
var contactOutline = contactError == 0 ? outline :
|
||||
ClipperBridge.Flatten(profile.Perimeter, contactError, circumscribe: true);
|
||||
variants.Add(new ShapeVariant { Id = id++, Part = index, Angle = angle,
|
||||
OriginX = x, OriginY = y, Width = w, Height = h,
|
||||
Curved = rotated.Any(e => e is Arc or Circle), Material = material,
|
||||
Outline = outline, ContactOutline = contactOutline, ContactError = contactError,
|
||||
Hull = hull, Convex = convex, ValidationProfile = validationProfile });
|
||||
}
|
||||
var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
|
||||
if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8)
|
||||
{
|
||||
var minimum = ordered[0].Width * ordered[0].Height;
|
||||
var all = ordered;
|
||||
var shortlist = ordered.Where(v => v.Width * v.Height <= minimum * 1.08 + 1e-7).Take(16).ToList();
|
||||
// A diagonal may be the only orientation fitting a narrow stock. Never
|
||||
// discard every fitting orientation merely because its envelope is larger.
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
bool Fits(ShapeVariant v) => v.Width <= stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right + 1e-9 &&
|
||||
v.Height <= stock.Size.Width - stock.EdgeSpacing.Top - stock.EdgeSpacing.Bottom + 1e-9;
|
||||
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
|
||||
}
|
||||
ordered = shortlist.DistinctBy(v => v.Id).ToArray();
|
||||
}
|
||||
return new PreparedPart(part, area, ordered);
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static string Canonical(PathD path)
|
||||
{
|
||||
if (path.Count == 0) return "";
|
||||
var points = path.Select(p => ((long)M.Round(p.x * 100000), (long)M.Round(p.y * 100000))).ToArray();
|
||||
var first = 0;
|
||||
for (var i = 1; i < points.Length; i++) if (points[i].CompareTo(points[first]) < 0) first = i;
|
||||
return string.Join(";", Enumerable.Range(0, points.Length).Select(i => points[(i + first) % points.Length]));
|
||||
}
|
||||
|
||||
private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile)
|
||||
{
|
||||
var values = new List<double>();
|
||||
if (policy.Kind == RotationPolicyKind.Automatic)
|
||||
{
|
||||
// All half-turns matter for asymmetric parts, unlike envelope-only packing.
|
||||
for (var i = 0; i < 24; i++) values.Add(i * M.PI / 12);
|
||||
foreach (var line in profile.Perimeter.Entities.OfType<Line>().OrderByDescending(l => l.Length).Take(8))
|
||||
{
|
||||
var angle = -M.Atan2(line.EndPoint.Y - line.StartPoint.Y, line.EndPoint.X - line.StartPoint.X);
|
||||
for (var i = 0; i < 4; i++) values.Add(angle + i * M.PI / 2);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var last = policy.Kind == RotationPolicyKind.Fixed ? 0 : M.Floor((policy.End - policy.Start) / policy.Step);
|
||||
if (!double.IsFinite(last)) last = 720;
|
||||
var samples = (int)M.Min(720, last);
|
||||
for (var i = 0; i <= samples; i++)
|
||||
{
|
||||
var k = samples == 0 ? 0 : M.Floor(last * ((double)i / samples));
|
||||
var angle = policy.Start + k * policy.Step;
|
||||
if (!double.IsFinite(angle) || !policy.Allows(angle)) continue;
|
||||
values.Add(angle);
|
||||
if (policy.Allow180Equivalent) values.Add(angle + M.PI);
|
||||
}
|
||||
}
|
||||
var seen = new HashSet<long>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
var angle = value % (2 * M.PI);
|
||||
if (angle < 0) angle += 2 * M.PI;
|
||||
if (policy.Allows(angle) && seen.Add((long)M.Round(angle * 1e9))) yield return angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# OpenNest.Engine.Astra
|
||||
|
||||
An independent, deterministic .NET 8 CNC nesting plugin. Its public parameterless
|
||||
`AstraNestingEngine` implements `INestingEngine`. The project remains outside `OpenNest.sln`.
|
||||
|
||||
## Placement algorithm
|
||||
|
||||
Astra searches configuration space: for each stationary/moving orientation pair, a no-fit
|
||||
polygon describes the translations that would overlap. Subtracting these regions from the
|
||||
sheet's usable translation rectangle exposes contact positions where another part can fit.
|
||||
This permits overlapping bounding rectangles, complementary triangle pairs, staggered circles,
|
||||
concave interlocking, and insertion into straight-edged and curved holes.
|
||||
|
||||
1. Validate immutable job input. Reconstruct owned analytic entities with `DrawingJobMapper`
|
||||
and `ConvertProgram`. Closed contours define material; internal open marks do not become
|
||||
holes. Preserve the snapshot's origin when converting normalized placements back to poses.
|
||||
2. Prepare rotated outlines and material regions with holes, using conservative curve flattening.
|
||||
Automatic angles combine 15-degree samples over a full turn with orientations aligned to the
|
||||
longest straight edges. Symmetric duplicates are removed. Prefer up to 16 orientations whose
|
||||
envelope area is within 8% of the minimum; retain additional orientations when needed to fit
|
||||
a candidate stock. Fixed and bounded rotation policies remain enforced. Bounded sweeps use
|
||||
up to 721 integer step indices, including permitted half-turn equivalents.
|
||||
3. Process high-priority parts first, then parts fitting fewer available stock types, then larger
|
||||
envelopes. Larger frames precede inserts. Search every retained orientation for each instance.
|
||||
4. Build cached Minkowski/no-fit regions. Convex pairs use Core's linear convex NFP primitive;
|
||||
concave pairs use Clipper's integer Minkowski sum. Arc-heavy concave contact outlines use
|
||||
a coarser mesh with both approximation bounds added to clearance; fine material geometry
|
||||
still checks every candidate. Positive outer boundaries are filled conservatively.
|
||||
Axis-aligned rectangles have a four-vertex contact shortcut.
|
||||
5. Maintain each orientation's available translation region incrementally as parts are added.
|
||||
Search its boundary vertices, exact-fit contacts and hole anchors. Reject points inside solid
|
||||
no-fit regions before expensive checks. Check surviving candidates against actual material
|
||||
regions with holes and spacing offsets. Zero-clearance contacts also pass the shared triangulated
|
||||
collision check, with tiny position adjustments when rounding makes an exact contact unsafe.
|
||||
Hole contacts use the same check in the final sheet coordinate frame, with bounded caching.
|
||||
Two directional objectives try bottom-up and left-to-right growth using the same contact algorithm.
|
||||
6. Search stock plans with a beam of up to three states. Rank by observed delivery cost and
|
||||
remaining material, while preserving a state with high placed area. This avoids starving
|
||||
large-sheet plans in favor of cheap but inefficient small-sheet prefixes. A genuine material
|
||||
area lower bound prunes plans only once a complete cheaper plan exists. After 24 evaluated
|
||||
trials only one directional objective is used; after 64, beam width reduces to two.
|
||||
Work counts, not elapsed time or randomness, control search breadth.
|
||||
7. Select a complete plan with lowest purchased area, breaking equal-cost ties by sheet count.
|
||||
If no complete plan is found, maximize fulfilled counts by priority, then minimize cost.
|
||||
Emit committed-sheet progress, contiguous per-part instance indices, inventory, fulfillment
|
||||
and the contract's job-level stop reason. Cancellation throws without returning a partial job.
|
||||
|
||||
The engine never invokes another engine, registry, job runner, whole-plate nester or filler.
|
||||
All order, stock, orientation, placement, search and stopping decisions belong to Astra.
|
||||
Core geometry and Clipper are primitives, not alternative nesters. A shared Core collision fix
|
||||
corrects curved-hole validation; the placement algorithm remains entirely in Astra.
|
||||
|
||||
## Precision and safety
|
||||
|
||||
Analytic rotated bounds govern sheet containment. Material curves are conservatively flattened
|
||||
at 0.001 job units. Positive configuration-space spacing includes 0.0003 extra units for non-rectangular
|
||||
straight outlines; curved outlines reserve 0.003 extra units even at zero spacing, accounting for offset/chord error and the
|
||||
benchmark validator's four-decimal grid. Axis-aligned rectangle contacts preserve exact requested
|
||||
spacing. Actual material intersection checks backstop candidate construction. Both straight-edged
|
||||
and curved holes are available for insertion. The shared collision routine now subtracts hole
|
||||
triangles into disjoint fragments with consistent half-space clipping, resolving the reproduced
|
||||
curved-hole false positive. See the benchmark report for regression results.
|
||||
|
||||
Concave contact outlines exceeding 64 vertices use a chord tolerance of the greater of 0.002
|
||||
units or 0.2% of the smaller envelope dimension. The pair's two tolerances are added to the
|
||||
NFP offset. This reduces Minkowski input size without coarsening the final material checks.
|
||||
|
||||
The broad phase is deliberately conservative. It can miss a valid close fit; output validation
|
||||
is exercised separately through the benchmark's materialized geometry validator in tests.
|
||||
|
||||
## Structure
|
||||
|
||||
- `AstraNestingEngine.cs`: bounded stock-plan search, accounting, progress and result construction.
|
||||
- `PreparedGeometry.cs`: snapshots, allowed orientations, symmetry reduction and material regions.
|
||||
- `ContactGeometry.cs`: cached no-fit polygons and rectangle specialization.
|
||||
- `ContactPlacer.cs`: incremental available regions, contact/inside-hole search and collision checks.
|
||||
- `tests/`: xUnit tests plus a linked copy of the existing benchmark validator source.
|
||||
- `benchmarks/`: standalone synthetic benchmark driver, reproducible repository-DXF manifests,
|
||||
baseline/current CSV results and comparison notes. It is not compiled into the plugin.
|
||||
|
||||
`OpenNest.Engine.Astra.csproj` references Core explicitly and inherits Engine/net8.0 settings from
|
||||
`../Directory.Build.props`. Test and benchmark sources are excluded from the plugin assembly.
|
||||
|
||||
## Build, test and deploy
|
||||
|
||||
```bash
|
||||
dotnet build Engines/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj -c Release
|
||||
dotnet test Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj -c Release
|
||||
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
|
||||
mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines
|
||||
cp Engines/OpenNest.Engine.Astra/bin/Release/net8.0/OpenNest.Engine.Astra.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/
|
||||
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll Engines/OpenNest.Engine.Astra/benchmarks/dxf --engines AstraNestingEngine --parallel 1
|
||||
```
|
||||
|
||||
The host supplies Core, Engine and their dependencies. Plugin discovery uses the CLR type name
|
||||
`AstraNestingEngine`; no registry call exists in the plugin.
|
||||
Rebuild the host with this checkout's `OpenNest.Core` as well: replacing only the plugin DLL
|
||||
does not update the shared curved-hole collision fix.
|
||||
|
||||
## Limitations
|
||||
|
||||
This is bounded heuristic search, not a proof of minimum sheet cost or infeasibility. Early part
|
||||
order is not backtracked within a sheet, already placed parts are not moved, and available
|
||||
orientations are sampled/pruned. Hole search uses anchor positions, not a complete inner-fit
|
||||
polygon solver. Small usable regions inside complex cutouts may be missed. The benchmark report
|
||||
includes an isolated host-validator reproducer and its corrected outcomes.
|
||||
Filling NFP interior voids can exclude unusual interlocking configurations.
|
||||
Salvage-credit options and `PlacementStrategy` do not change Astra's objective; `MaxPlates` is
|
||||
respected. Stock dimensions, all edge spacings, quadrants, priorities and rotation policies are
|
||||
honored. No real `.nest` fixtures were available in this workspace.
|
||||
|
||||
Complex concave outlines, dense bounded sweeps, many part types or very large quantities can
|
||||
be expensive. NFP and trial cache entry counts are bounded, but individual geometry can be large.
|
||||
Cancellation is checked throughout search and between geometry operations; shared validation and
|
||||
individual Clipper calls are not interruptible. The v2 search costs more CPU than the original
|
||||
bounding-rectangle baseline. See `benchmarks/README.md` for measured tradeoffs.
|
||||
|
||||
## Current validation
|
||||
|
||||
Release build succeeded with .NET SDK 8.0.425 on Linux. All 27 xUnit cases passed, including
|
||||
independent benchmark validation of materialized results, exact positive/zero clearance,
|
||||
non-cardinal rotations, hole insertion, automatic diagonal-only stock fits, all quadrants,
|
||||
curves, incremental geometry, determinism, inventory, cancellation and stock-plan regressions.
|
||||
All 34 synthetic/generated benchmark cases and all four repository-DXF cases were valid and complete.
|
||||
Existing nullable warnings originate from the benchmark validator linked into the test project.
|
||||
@@ -0,0 +1,89 @@
|
||||
using OpenNest;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Shapes;
|
||||
using CncProgram = OpenNest.CNC.Program;
|
||||
using M = System.Math;
|
||||
|
||||
namespace OpenNest.Engine.Astra.Benchmarks;
|
||||
|
||||
internal static class GeneratedCases
|
||||
{
|
||||
internal static IEnumerable<(string Name, NestJob Job)> Create()
|
||||
{
|
||||
var library = new ShapeDefinition[] {
|
||||
new RoundedRectangleShape { Length = 11, Width = 5, Radius = 1.8 },
|
||||
new TShape { Width = 10, Height = 9, StemWidth = 2, BarHeight = 2 },
|
||||
new TrapezoidShape { BottomWidth = 10, TopWidth = 3, Height = 6 },
|
||||
new NgonShape { Sides = 5, Width = 6 },
|
||||
new NgonShape { Sides = 6, Width = 6 },
|
||||
new RingShape { OuterDiameter = 10, InnerDiameter = 7 },
|
||||
new PipeFlangeShape { OD = 7.5, HoleDiameter = 0.875, HolePatternDiameter = 5.5,
|
||||
HoleCount = 8, PipeSize = "2", PipeClearance = 0.0625 }
|
||||
};
|
||||
for (var i = 0; i < library.Length; i++)
|
||||
yield return ($"generated-library-{i}-{library[i].Name}", Job(new[] {
|
||||
Part("main", library[i].GetDrawing().Program, i == 6 ? 12 : 24)
|
||||
}, i));
|
||||
yield return ("generated-ring-inserts", Job(new[] {
|
||||
Part("ring", library[5].GetDrawing().Program, 8),
|
||||
Part("insert", new CircleShape { Diameter = 6 }.GetDrawing().Program, 8)
|
||||
}, 1));
|
||||
var curvedC = new CncProgram();
|
||||
curvedC.MoveTo(6, 0); curvedC.ArcTo(0, -6, 0, 0, RotationType.CCW);
|
||||
curvedC.LineTo(0, -4); curvedC.ArcTo(4, 0, 0, 0, RotationType.CW); curvedC.LineTo(6, 0);
|
||||
yield return ("generated-curved-C", Job(new[] { Part("C", curvedC, 16) }, 2));
|
||||
yield return ("generated-narrow-U", Job(new[] { Part("U", Poly(0, 0, 10, 0, 10, 10,
|
||||
8.5, 10, 8.5, 1.5, 1.5, 1.5, 1.5, 10, 0, 10), 24) }, 3));
|
||||
yield return ("generated-stars", Job(new[] { Part("star", Star(7, 6, 2.5), 20) }, 0));
|
||||
for (var seed = 0; seed < 12; seed++)
|
||||
{
|
||||
var random = new Random(19073 + seed);
|
||||
var parts = new List<NestJobPart>();
|
||||
for (var p = 0; p < 4; p++)
|
||||
{
|
||||
CncProgram program;
|
||||
if (p == 0) program = new RoundedRectangleShape { Length = 5 + random.NextDouble() * 7,
|
||||
Width = 3 + random.NextDouble() * 3, Radius = 0.7 }.GetDrawing().Program;
|
||||
else if (p == 1) program = Star(5 + seed % 3, 3 + random.NextDouble() * 2, 1.5 + random.NextDouble());
|
||||
else if (p == 2) program = new TrapezoidShape { BottomWidth = 5 + random.NextDouble() * 5,
|
||||
TopWidth = 2 + random.NextDouble() * 2, Height = 3 + random.NextDouble() * 4 }.GetDrawing().Program;
|
||||
else program = new NgonShape { Sides = 3 + seed % 5, Width = 3 + random.NextDouble() * 3 }.GetDrawing().Program;
|
||||
// Nonzero source origins exercise pose reconstruction as well as shape packing.
|
||||
program.Offset(new Vector(seed * 1.37 - 5, p * 2.13 - 3));
|
||||
var rotation = p == 2 ? RotationPolicy.Fixed((seed % 4) * M.PI / 7) :
|
||||
p == 3 ? RotationPolicy.BoundedSweep(-M.PI / 3, M.PI / 2, M.PI / 6, true) : RotationPolicy.Automatic;
|
||||
parts.Add(Part($"p{p}", program, random.Next(3, 9), rotation));
|
||||
}
|
||||
yield return ($"generated-seed-{seed:00}", Job(parts.ToArray(), seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static NestJob Job(NestJobPart[] parts, int seed) => new(parts, new[] {
|
||||
new NestPlateStock("small", new Size(23 + seed % 3, 41 + seed % 5), 2,
|
||||
seed % 4 == 0 ? 0 : 0.1 + seed % 3 * 0.075, new Spacing(0.2, 0.3, 0.4, 0.5), seed % 4 + 1),
|
||||
new NestPlateStock("large", new Size(47, 83), partSpacing: 0.2,
|
||||
edgeSpacing: new Spacing(0.3, 0.2, 0.5, 0.4), quadrant: seed % 4 + 1)
|
||||
});
|
||||
|
||||
private static NestJobPart Part(string name, CncProgram p, int quantity, RotationPolicy rotation = null) =>
|
||||
new(name, PartGeometrySnapshot.FromProgram(p), quantity, rotation: rotation);
|
||||
|
||||
private static CncProgram Star(int arms, double outer, double inner)
|
||||
{
|
||||
var coordinates = Enumerable.Range(0, arms * 2).SelectMany(i => {
|
||||
var radius = i % 2 == 0 ? outer : inner;
|
||||
var angle = i * M.PI / arms;
|
||||
return new[] { radius * M.Cos(angle), radius * M.Sin(angle) };
|
||||
}).ToArray();
|
||||
return Poly(coordinates);
|
||||
}
|
||||
|
||||
private static CncProgram Poly(params double[] points)
|
||||
{
|
||||
var p = new CncProgram(); p.MoveTo(points[0], points[1]);
|
||||
for (var i = 2; i < points.Length; i += 2) p.LineTo(points[i], points[i + 1]);
|
||||
p.LineTo(points[0], points[1]); return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType><Nullable>disable</Nullable></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../OpenNest.Engine.Astra.csproj" />
|
||||
<Compile Include="../../../OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using OpenNest;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Benchmark;
|
||||
using CncProgram = OpenNest.CNC.Program;
|
||||
|
||||
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
|
||||
if (args.Contains("--diagnose-ring"))
|
||||
{
|
||||
var ringPart = new NestJobPart("ring", PartGeometrySnapshot.FromProgram(
|
||||
new OpenNest.Shapes.RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing().Program), 1);
|
||||
var insertPart = new NestJobPart("insert", PartGeometrySnapshot.FromProgram(
|
||||
new OpenNest.Shapes.CircleShape { Diameter = 6 }.GetDrawing().Program), 1);
|
||||
foreach (var quadrant in new[] { 1, 2 })
|
||||
foreach (var offset in new[] { 0.0, 0.0003, 0.05, 0.1 })
|
||||
{
|
||||
var s = new NestPlateStock("s", new Size(24, 42), 1, 0.175,
|
||||
new Spacing(0.2, 0.3, 0.4, 0.5), quadrant);
|
||||
var j = new NestJob(new[] { ringPart, insertPart }, new[] { s });
|
||||
var x = (quadrant == 1 ? 0 : -42) + s.EdgeSpacing.Left + 5;
|
||||
var y = s.EdgeSpacing.Bottom + 5;
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, s, new[] { new NestJobPlacement("ring", 0, x, y, 0),
|
||||
new NestJobPlacement("insert", 0, x + offset, y, 0) }) },
|
||||
new[] { new PartFulfillment("ring", 1, 1, 0), new PartFulfillment("insert", 1, 1, 0) },
|
||||
new[] { new StockUsage("s", 1, 0) });
|
||||
var materialized = NestResultMaterializer.Materialize(j, result);
|
||||
var check = NestValidator.Validate(materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(),
|
||||
j.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)));
|
||||
Console.WriteLine($"q={quadrant} offset={offset} valid={check.Valid}: {string.Join(';', check.Violations)}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
var assembly = args.Length > 0 && args[0].EndsWith(".dll")
|
||||
? new AssemblyLoadContext("benchmark-plugin", isCollectible: true).LoadFromAssemblyPath(Path.GetFullPath(args[0])) : Assembly.Load("OpenNest.Engine.Astra");
|
||||
var engine = (INestingEngine)Activator.CreateInstance(assembly.GetType("OpenNest.Engine.Astra.AstraNestingEngine")!);
|
||||
var cases = new List<(string Name, NestJob Job)>();
|
||||
var standard = new[] { new NestPlateStock("small", new Size(24, 48), partSpacing: 0.15),
|
||||
new NestPlateStock("large", new Size(48, 96), partSpacing: 0.15) };
|
||||
NestJobPart Part(string name, CncProgram p, int count, RotationPolicy rotation = null) =>
|
||||
new(name, PartGeometrySnapshot.FromProgram(p), count, rotation: rotation);
|
||||
CncProgram Polygon(params double[] xy)
|
||||
{
|
||||
var p = new CncProgram(); p.MoveTo(xy[0], xy[1]);
|
||||
for (var i = 2; i < xy.Length; i += 2) p.LineTo(xy[i], xy[i + 1]);
|
||||
p.LineTo(xy[0], xy[1]); return p;
|
||||
}
|
||||
CncProgram Rect(double w, double h) => Polygon(0, 0, w, 0, w, h, 0, h);
|
||||
CncProgram Circle(double r) { var p = new CncProgram(); p.MoveTo(r, 0); p.ArcTo(r, 0, 0, 0, RotationType.CCW); return p; }
|
||||
void Add(string name, NestJobPart[] parts, NestPlateStock[] stocks = null, NestJobOptions options = null) =>
|
||||
cases.Add((name, new NestJob(parts, stocks ?? standard, options)));
|
||||
Add("triangles", new[] { Part("triangle", Polygon(0, 0, 10, 0, 0, 10), 60) });
|
||||
Add("circles", new[] { Part("circle", Circle(2.5), 90) });
|
||||
Add("circles-dense", new[] { Part("circle", Circle(2.5), 80) });
|
||||
Add("concave-L", new[] { Part("L", Polygon(0, 0, 8, 0, 8, 2, 2, 2, 2, 8, 0, 8), 60) });
|
||||
Add("mixed", new[] { Part("rect", Rect(9, 4), 30), Part("triangle", Polygon(0, 0, 8, 0, 3, 6), 25),
|
||||
Part("circle", Circle(2), 30), Part("L", Polygon(0, 0, 7, 0, 7, 2, 2, 2, 2, 6, 0, 6), 20) });
|
||||
var ring = Rect(10, 10); ring.MoveTo(1, 1); ring.LineTo(1, 9); ring.LineTo(9, 9); ring.LineTo(9, 1); ring.LineTo(1, 1);
|
||||
Add("holes", new[] { Part("frame", ring, 8), Part("insert", Rect(7, 7), 8) });
|
||||
Add("rectangles", Enumerable.Range(0, 8).Select(i => Part($"r{i}", Rect(2 + i, 3 + i % 3), 12)).ToArray());
|
||||
Add("grain", new[] { Part("fixed", Rect(13, 3), 20, RotationPolicy.Fixed(System.Math.PI / 6)),
|
||||
Part("sweep", Rect(7, 2), 40, RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4)) });
|
||||
Add("scarce-stock", new[] { Part("small", Rect(4, 4), 8), Part("large", Rect(10, 10), 1) },
|
||||
new[] { new NestPlateStock("scarce", new Size(10, 10), 1), new NestPlateStock("small-only", new Size(4, 8)) });
|
||||
Add("tail", new[] { Part("rect", Rect(6, 4), 17) }, new[] {
|
||||
new NestPlateStock("small", new Size(8, 12), partSpacing: 0.1), new NestPlateStock("large", new Size(20, 30), partSpacing: 0.1) });
|
||||
Add("plate-cap", new[] { Part("r", Rect(5, 5), 10) }, new[] {
|
||||
new NestPlateStock("small", new Size(10, 10)), new NestPlateStock("large", new Size(20, 20)) }, new NestJobOptions(maxPlates: 1));
|
||||
cases.AddRange(OpenNest.Engine.Astra.Benchmarks.GeneratedCases.Create());
|
||||
Console.WriteLine("case,valid,placed,requested,sheets,area,milliseconds");
|
||||
foreach (var (name, job) in cases)
|
||||
{
|
||||
if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
|
||||
try
|
||||
{
|
||||
var result = engine.Solve(job, token: cts.Token); sw.Stop();
|
||||
var nest = NestResultMaterializer.Materialize(job, result);
|
||||
var validation = NestValidator.Validate(nest.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(),
|
||||
job.Parts.ToDictionary(p => nest.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)));
|
||||
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||
if (!validation.Valid) Environment.ExitCode = 1;
|
||||
Console.WriteLine($"{name},{validation.Valid},{result.Fulfillment.Sum(f => f.Placed)},{job.Parts.Sum(p => p.Quantity)},{result.Plates.Count},{result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width)},{sw.ElapsedMilliseconds}");
|
||||
foreach (var violation in validation.Violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
|
||||
}
|
||||
catch (Exception ex) { Environment.ExitCode = 1; Console.WriteLine($"{name},ERROR,,,,,{sw.ElapsedMilliseconds}"); Console.Error.WriteLine(ex); }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
# Astra development benchmark report
|
||||
|
||||
Measured locally on Linux with .NET SDK 8.0.425, Release builds, 2026-09-23.
|
||||
The shared-validator fix was verified on 2026-09-24; its results are recorded separately below.
|
||||
The baseline is Astra's original independent guillotine/bounding-rectangle implementation,
|
||||
archived before the contact-search rewrite. These are not measurements against Opus or a
|
||||
claim of performance on an unseen competition dataset.
|
||||
|
||||
## Synthetic cases
|
||||
|
||||
Both versions were run on exactly the same programmatically generated geometry and stock.
|
||||
The driver validates materialized output with `OpenNest.Benchmark.NestValidator`, including
|
||||
quantity, stock settings, rotation, material overlap and spacing. Timings cover `Solve` only,
|
||||
exclude external validation, and are single-run observations rather than stable distributions.
|
||||
Every contact result is valid and complete. The baseline is valid but incomplete on `plate-cap`.
|
||||
|
||||
| Case | Baseline area | Contact area | Change | Contact time (ms) |
|
||||
|---|---:|---:|---:|---:|
|
||||
| triangles | 8064 | 4608 | -42.9% | 3354 |
|
||||
| circles | 3456 | 3456 | 0.0% | 1021 |
|
||||
| circles-dense | 3456 | 2304 | -33.3% | 742 |
|
||||
| concave-L | 5760 | 3456 | -40.0% | 1055 |
|
||||
| mixed | 4608 | 3456 | -25.0% | 1990 |
|
||||
| holes | 2304 | 1152 | -50.0% | 306 |
|
||||
| rectangles | 3456 | 3456 | 0.0% | 261 |
|
||||
| grain | 4608 | 2304 | -50.0% | 527 |
|
||||
| scarce-stock | 228 | 228 | 0.0% | 1 |
|
||||
| tail | 600 | 600 | 0.0% | 4 |
|
||||
| plate-cap | 100 | 400 | 4/10 → 10/10 placed | 4 |
|
||||
|
||||
Excluding `plate-cap`, where baseline completion differs, purchased area fell from 36540
|
||||
to 25020: **31.5% less area** across these ten cases. The original solver
|
||||
usually took 0–30 ms; contact search takes approximately 1 ms to 3.4 s on this set. Packing
|
||||
quality improved at a substantial CPU cost. No speedup over the original baseline is claimed.
|
||||
|
||||
## Extended generated cases
|
||||
|
||||
`GeneratedCases.cs` adds 23 jobs: rounded rectangles, T-shapes, trapezoids, pentagons,
|
||||
hexagons, rings, pipe flanges, ring/insert mixtures, curved C-shapes, narrow U-shapes,
|
||||
stars, and 12 seeded mixed jobs. These exercise all four quadrants, asymmetric edge margins,
|
||||
finite small-sheet inventory, translated source origins, zero/positive spacing, fixed
|
||||
non-cardinal rotations and bounded sweeps. The library supplies most shapes; the C, U and
|
||||
star contours are generated directly. Seeded cases use seeds 19073 through 19084.
|
||||
|
||||
Both versions place every requested part in all 23 jobs with valid output. Seven cases use
|
||||
less purchased area; the other sixteen match the baseline. Aggregate area drops from
|
||||
58259 to 44353, **23.9% less area**. The changed cases are:
|
||||
|
||||
| Generated case | Baseline area | Contact area | Reduction |
|
||||
|---|---:|---:|---:|
|
||||
| T-shapes | 5917 | 2016 | 65.9% |
|
||||
| Hexagons | 2160 | 1080 | 50.0% |
|
||||
| Pipe flanges | 1932 | 966 | 50.0% |
|
||||
| Curved C-shapes | 6051 | 2150 | 64.5% |
|
||||
| Narrow U-shapes | 5925 | 3901 | 34.2% |
|
||||
| Seed 19083 | 1968 | 984 | 50.0% |
|
||||
| Seed 19084 | 2100 | 1050 | 50.0% |
|
||||
|
||||
The initial fine-mesh curved-C search exceeded the driver's 90-second cancellation budget
|
||||
(an in-flight Minkowski operation delayed cancellation to 110 seconds). Separately coarsening
|
||||
its contact outline, padding both approximation errors, and retaining fine safety geometry
|
||||
reduced that case to approximately 2.3–2.5 seconds. No wall-clock cutoff was added to the engine.
|
||||
The final regression pass validates all 34 generated/synthetic jobs and all four DXF jobs;
|
||||
27 independent xUnit cases also pass. Raw results are in `results/`.
|
||||
|
||||
## Curved-hole validator fix (2026-09-24)
|
||||
|
||||
The generated ring/insert job exposed a shared-validator false positive. A ring with inner
|
||||
radius 3.5 containing a concentric radius-3 disk has 0.5 units of clearance, yet the host's
|
||||
triangulated collision check can report a violation with required spacing 0.175. The outcome
|
||||
also changes with translations. Checking every candidate against that routine made a small
|
||||
ring job take approximately 80–90 seconds.
|
||||
|
||||
Astra initially reserved curved cutouts as solid during placement, preserving the original
|
||||
drawing in output. This workaround finished the ring/insert job in about 50 ms at the baseline
|
||||
sheet cost. It has now been removed following a fix in `OpenNest.Core/Geometry/Collision.cs`.
|
||||
|
||||
Hole subtraction previously clipped each fragment independently against every triangle edge,
|
||||
duplicating surviving area. It also classified points with an epsilon-shifted boundary but
|
||||
intersected against the unshifted line, which could extrapolate outside the source segment.
|
||||
The corrected routine emits disjoint outside fragments and carries the inside remainder to
|
||||
the next edge. Classification and interpolation use the same signed cross products. Exact
|
||||
closing vertices and local-coordinate area checks avoid additional small-fragment errors.
|
||||
This remains the shared hand-written collision algorithm; Clipper is only an independent
|
||||
oracle in the new tests, not a replacement per-pair validator.
|
||||
|
||||
The eight original translated reproductions all pass. Regression coverage also rejects real
|
||||
spacing violations, checks both operand orders and windings, exercises all four quadrants,
|
||||
and compares overlap areas against Clipper on 80 seeded pairs with multiple/concave holes.
|
||||
The main suite passes 1,096 tests (12 font-fixture skips), Engine passes 170, and Astra passes
|
||||
27. Astra's curved-hole tests now require a ring and insert to share stock whose usable area
|
||||
fits only the ring, proving that insertion is enabled.
|
||||
|
||||
All 34 synthetic/generated jobs remain valid and complete with unchanged sheet-area costs.
|
||||
The ring/insert job with hole search enabled takes about 4.8 seconds in this run and still
|
||||
uses two small sheets. This fix improves validity and enables insertion; it does not improve
|
||||
that job's stock plan. Results are in `results/validator-fixed-synthetic-generated.csv`.
|
||||
All four repository-DXF jobs also remain valid and complete at unchanged sheet-area costs;
|
||||
their rerun is recorded in `results/validator-fixed-dxf.csv`.
|
||||
|
||||
The isolated reproduction does not invoke any nesting engine:
|
||||
|
||||
```bash
|
||||
dotnet run --project Engines/OpenNest.Engine.Astra/benchmarks -c Release -- --diagnose-ring
|
||||
```
|
||||
|
||||
`results/ring-validator-reproducer.txt` retains the original failures;
|
||||
`results/ring-validator-fixed.txt` records the corrected outcomes. Rebuild the host's Core
|
||||
dependency when deploying. The benchmark validator's spacing rules and source are unchanged.
|
||||
|
||||
## Repository DXFs
|
||||
|
||||
The four manifests under `dxf/` use PT45, PT23 and PT11 repository drawings. Every result from
|
||||
both versions was valid and complete. Runs used `--parallel 1`.
|
||||
|
||||
| Manifest | Baseline area | Contact area | Change |
|
||||
|---|---:|---:|---:|
|
||||
| locked.manifest | 115200 | 115200 | 0.0% |
|
||||
| mixed.manifest | 144000 | 115200 | -20.0% |
|
||||
| original.manifest | 115200 | 115200 | 0.0% |
|
||||
| volume.manifest | 374400 | 374400 | 0.0% |
|
||||
|
||||
The mixed three-drawing job improves 20%; the other three retain baseline sheet-area cost.
|
||||
The initial contact version regressed on `volume`; preserving a high-progress beam state and
|
||||
ranking with observed per-part delivery cost removed that regression. Final results purchase
|
||||
720000 area units versus 748800, a 3.8% reduction across the four manifests. Fewer physical
|
||||
sheets sometimes have the same purchased area; those are not counted as area savings.
|
||||
|
||||
An exploratory run of the original manifest against StockLadder and Default found StockLadder
|
||||
valid/complete at the same 115200 area cost; Default's result was flagged for spacing. That
|
||||
single case does not establish general superiority. There is no Opus result available here.
|
||||
|
||||
## Local geometry optimizations
|
||||
|
||||
Apart from the shared Core collision fix described above, these optimizations are in Astra.
|
||||
Astra caches NFPs and incrementally
|
||||
subtracts each newly placed part from available translation regions, avoiding repeated unions
|
||||
of all previous obstacles. A four-vertex rectangle configuration-space specialization avoids
|
||||
round-offset polygons and polygon collision work for exact axis-aligned rectangle contacts.
|
||||
During development, the 96-rectangle case dropped from roughly 1.5 s to 0.24 s after this
|
||||
specialization. This is an end-to-end observation, not an isolated component microbenchmark.
|
||||
|
||||
Precision regression tests cover exact clearances and rotated zero-spacing contacts. In the
|
||||
latter case, the host's four-decimal polygon rounding and triangulated collision test can
|
||||
reject a contact accepted by Clipper at six decimals. Astra now retains the original rotated
|
||||
frame for that validation, checks zero-clearance contacts with Core's collision primitive,
|
||||
and tries tiny nearby translations when exact contact is unsafe.
|
||||
|
||||
## Reproduce
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
dotnet run --project Engines/OpenNest.Engine.Astra/benchmarks -c Release
|
||||
```
|
||||
|
||||
Run only the extended generated suite with `-- current generated`; the first positional
|
||||
argument is either a previous plugin DLL or a label for the current build. Invalid layouts
|
||||
and crashes make the driver exit with a nonzero status. Incompleteness is reported separately.
|
||||
|
||||
The standalone driver also accepts a prior plugin DLL and optional case-name filter:
|
||||
|
||||
```bash
|
||||
dotnet run --project Engines/OpenNest.Engine.Astra/benchmarks -c Release -- /path/to/previous/OpenNest.Engine.Astra.dll triangles
|
||||
```
|
||||
|
||||
An isolated assembly load context prevents .NET from silently substituting the currently
|
||||
built plugin when comparing another version with the same assembly name. Historical baseline
|
||||
CSV files are included; the old binary is not committed. The driver's 90-second cancellation
|
||||
budget is a benchmark safeguard and is not an elapsed-time stopping rule inside the engine.
|
||||
|
||||
For real DXFs, build and deploy the plugin as described in the parent README, then:
|
||||
|
||||
```bash
|
||||
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll Engines/OpenNest.Engine.Astra/benchmarks/dxf --engines AstraNestingEngine --parallel 1 --csv /tmp/astra-dxf.csv
|
||||
```
|
||||
|
||||
The CSV files under `results/` retain the measured results. Tests run independently:
|
||||
|
||||
```bash
|
||||
dotnet test Engines/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj -c Release
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 8,
|
||||
"allowRotation": false
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 4,
|
||||
"allowRotation": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 5,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 4,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT11.dxf",
|
||||
"quantity": 6,
|
||||
"allowRotation": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 8,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 4,
|
||||
"allowRotation": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"sheetSizes": [
|
||||
"120x240",
|
||||
"240x480"
|
||||
],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf",
|
||||
"quantity": 32,
|
||||
"allowRotation": true
|
||||
},
|
||||
{
|
||||
"dxf": "../../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf",
|
||||
"quantity": 16,
|
||||
"allowRotation": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
|
||||
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,83,
|
||||
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.5983,0.5983,144000.00,144000.00,144000.00,5,120 x 240×5,298,
|
||||
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,4,120 x 240×4,21,
|
||||
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,51,
|
||||
|
@@ -0,0 +1,24 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
generated-library-0-RoundedRectangle,True,24,24,2,1886,24
|
||||
generated-library-1-T,True,24,24,3,5917,0
|
||||
generated-library-2-Trapezoid,True,24,24,2,2150,0
|
||||
generated-library-3-Ngon,True,24,24,2,2024,0
|
||||
generated-library-4-Ngon,True,24,24,2,2160,0
|
||||
generated-library-5-Ring,True,24,24,1,3901,3
|
||||
generated-library-6-PipeFlange,True,12,12,2,1932,19
|
||||
generated-ring-inserts,True,16,16,2,2016,2
|
||||
generated-curved-C,True,16,16,3,6051,0
|
||||
generated-narrow-U,True,24,24,3,5925,0
|
||||
generated-stars,True,20,20,1,3901,0
|
||||
generated-seed-00,True,19,19,2,1886,1
|
||||
generated-seed-01,True,22,22,2,2016,1
|
||||
generated-seed-02,True,21,21,2,2150,1
|
||||
generated-seed-03,True,20,20,1,1012,1
|
||||
generated-seed-04,True,24,24,1,1080,1
|
||||
generated-seed-05,True,22,22,2,2050,1
|
||||
generated-seed-06,True,27,27,2,1932,1
|
||||
generated-seed-07,True,19,19,1,1032,1
|
||||
generated-seed-08,True,17,17,1,1100,1
|
||||
generated-seed-09,True,22,22,2,2070,1
|
||||
generated-seed-10,True,20,20,2,1968,1
|
||||
generated-seed-11,True,19,19,2,2100,1
|
||||
|
@@ -0,0 +1,12 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
triangles,True,60,60,4,8064,30
|
||||
circles,True,90,90,3,3456,4
|
||||
circles-dense,True,80,80,3,3456,0
|
||||
concave-L,True,60,60,2,5760,0
|
||||
mixed,True,105,105,4,4608,1
|
||||
holes,True,16,16,2,2304,2
|
||||
rectangles,True,96,96,3,3456,1
|
||||
grain,True,60,60,4,4608,0
|
||||
scarce-stock,True,9,9,5,228,0
|
||||
tail,True,17,17,1,600,0
|
||||
plate-cap,True,4,10,1,100,0
|
||||
|
@@ -0,0 +1,5 @@
|
||||
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
|
||||
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,259,
|
||||
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.7479,0.7479,115200.00,115200.00,115200.00,1,240 x 480×1,1724,
|
||||
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,133,
|
||||
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,389,
|
||||
|
@@ -0,0 +1,24 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
generated-library-0-RoundedRectangle,True,24,24,2,1886,175
|
||||
generated-library-1-T,True,24,24,2,2016,175
|
||||
generated-library-2-Trapezoid,True,24,24,2,2150,120
|
||||
generated-library-3-Ngon,True,24,24,2,2024,536
|
||||
generated-library-4-Ngon,True,24,24,1,1080,93
|
||||
generated-library-5-Ring,True,24,24,1,3901,85
|
||||
generated-library-6-PipeFlange,True,12,12,1,966,46
|
||||
generated-ring-inserts,True,16,16,2,2016,48
|
||||
generated-curved-C,True,16,16,2,2150,1929
|
||||
generated-narrow-U,True,24,24,1,3901,136
|
||||
generated-stars,True,20,20,1,3901,3118
|
||||
generated-seed-00,True,19,19,2,1886,578
|
||||
generated-seed-01,True,22,22,2,2016,403
|
||||
generated-seed-02,True,21,21,2,2150,1447
|
||||
generated-seed-03,True,20,20,1,1012,356
|
||||
generated-seed-04,True,24,24,1,1080,600
|
||||
generated-seed-05,True,22,22,2,2050,1467
|
||||
generated-seed-06,True,27,27,2,1932,1251
|
||||
generated-seed-07,True,19,19,1,1032,724
|
||||
generated-seed-08,True,17,17,1,1100,980
|
||||
generated-seed-09,True,22,22,2,2070,930
|
||||
generated-seed-10,True,20,20,1,984,460
|
||||
generated-seed-11,True,19,19,1,1050,1151
|
||||
|
@@ -0,0 +1,12 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
triangles,True,60,60,1,4608,3354
|
||||
circles,True,90,90,3,3456,1021
|
||||
circles-dense,True,80,80,2,2304,742
|
||||
concave-L,True,60,60,3,3456,1055
|
||||
mixed,True,105,105,3,3456,1990
|
||||
holes,True,16,16,1,1152,306
|
||||
rectangles,True,96,96,3,3456,261
|
||||
grain,True,60,60,2,2304,527
|
||||
scarce-stock,True,9,9,5,228,1
|
||||
tail,True,17,17,1,600,4
|
||||
plate-cap,True,10,10,1,400,4
|
||||
|
@@ -0,0 +1,8 @@
|
||||
q=1 offset=0 valid=True:
|
||||
q=1 offset=0.0003 valid=True:
|
||||
q=1 offset=0.05 valid=True:
|
||||
q=1 offset=0.1 valid=True:
|
||||
q=2 offset=0 valid=True:
|
||||
q=2 offset=0.0003 valid=True:
|
||||
q=2 offset=0.05 valid=True:
|
||||
q=2 offset=0.1 valid=True:
|
||||
@@ -0,0 +1,8 @@
|
||||
q=1 offset=0 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=1 offset=0.0003 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=1 offset=0.05 valid=True:
|
||||
q=1 offset=0.1 valid=True:
|
||||
q=2 offset=0 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=2 offset=0.0003 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=2 offset=0.05 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
q=2 offset=0.1 valid=False: 'ring' and 'insert' are closer than the required spacing (0.175)
|
||||
@@ -0,0 +1,5 @@
|
||||
Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes
|
||||
locked.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,466,
|
||||
mixed.manifest,AstraNestingEngine,True,False,True,15,15,0.7479,0.7479,115200.00,115200.00,115200.00,1,240 x 480×1,2820,
|
||||
original.manifest,AstraNestingEngine,True,False,True,12,12,0.6481,0.6481,115200.00,115200.00,115200.00,1,240 x 480×1,435,
|
||||
volume.manifest,AstraNestingEngine,True,False,True,48,48,0.7977,0.7977,374400.00,374400.00,374400.00,4,240 x 480×3; 120 x 240×1,2278,
|
||||
|
+35
@@ -0,0 +1,35 @@
|
||||
case,valid,placed,requested,sheets,area,milliseconds
|
||||
triangles,True,60,60,1,4608,3344
|
||||
circles,True,90,90,3,3456,1008
|
||||
circles-dense,True,80,80,2,2304,748
|
||||
concave-L,True,60,60,3,3456,1082
|
||||
mixed,True,105,105,3,3456,1966
|
||||
holes,True,16,16,1,1152,293
|
||||
rectangles,True,96,96,3,3456,246
|
||||
grain,True,60,60,2,2304,513
|
||||
scarce-stock,True,9,9,5,228,1
|
||||
tail,True,17,17,1,600,4
|
||||
plate-cap,True,10,10,1,400,4
|
||||
generated-library-0-RoundedRectangle,True,24,24,2,1886,170
|
||||
generated-library-1-T,True,24,24,2,2016,172
|
||||
generated-library-2-Trapezoid,True,24,24,2,2150,115
|
||||
generated-library-3-Ngon,True,24,24,2,2024,510
|
||||
generated-library-4-Ngon,True,24,24,1,1080,89
|
||||
generated-library-5-Ring,True,24,24,1,3901,918
|
||||
generated-library-6-PipeFlange,True,12,12,1,966,1467
|
||||
generated-ring-inserts,True,16,16,2,2016,4755
|
||||
generated-curved-C,True,16,16,2,2150,1883
|
||||
generated-narrow-U,True,24,24,1,3901,134
|
||||
generated-stars,True,20,20,1,3901,3087
|
||||
generated-seed-00,True,19,19,2,1886,568
|
||||
generated-seed-01,True,22,22,2,2016,398
|
||||
generated-seed-02,True,21,21,2,2150,1427
|
||||
generated-seed-03,True,20,20,1,1012,352
|
||||
generated-seed-04,True,24,24,1,1080,600
|
||||
generated-seed-05,True,22,22,2,2050,1447
|
||||
generated-seed-06,True,27,27,2,1932,1254
|
||||
generated-seed-07,True,19,19,1,1032,705
|
||||
generated-seed-08,True,17,17,1,1100,1008
|
||||
generated-seed-09,True,22,22,2,2070,1025
|
||||
generated-seed-10,True,20,20,1,984,504
|
||||
generated-seed-11,True,19,19,1,1050,1353
|
||||
|
@@ -0,0 +1,347 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Astra.Tests;
|
||||
|
||||
public class AstraNestingEngineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MixedPartsRespectBoundsSpacingRotationAndIdentity(int quadrant)
|
||||
{
|
||||
var job = new NestJob(new[] {
|
||||
Rectangle("a", 4, 2, 12, RotationPolicy.Fixed(System.Math.PI / 2), 7, -3),
|
||||
Rectangle("b", 3, 3, 8, RotationPolicy.BoundedSweep(-System.Math.PI / 4, System.Math.PI / 2, System.Math.PI / 4))
|
||||
}, new[] { new NestPlateStock("stock", new Size(15, 20), 10, 0.25,
|
||||
new Spacing(1, 2, 3, 1), quadrant) });
|
||||
var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray();
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
for (var i = 0; i < job.Parts.Count; i++) Assert.Equal(before[i], job.Parts[i].Geometry.Motions);
|
||||
var again = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(result.Plates.SelectMany(p => p.Placements), again.Plates.SelectMany(p => p.Placements));
|
||||
Assert.Equal(result.Plates.Select(p => p.StockId), again.Plates.Select(p => p.StockId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChoosesSmallestSheetWhenDemandFitsBoth()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 1) }, new[] {
|
||||
new NestPlateStock("large", new Size(20, 20)), new NestPlateStock("small", new Size(2, 2)) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal("small", Assert.Single(result.Plates).StockId);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, null, NestJobStopReason.StockExhausted)]
|
||||
[InlineData(null, 1, NestJobStopReason.PlateLimitReached)]
|
||||
public void StopsAtInventoryOrPlateLimit(int? quantity, int? limit, NestJobStopReason reason)
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 3) },
|
||||
new[] { new NestPlateStock("s", new Size(2, 2), quantity) }, new NestJobOptions(maxPlates: limit));
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(reason, result.StopReason);
|
||||
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpossibleAndEmptyJobsTerminate()
|
||||
{
|
||||
var part = Rectangle("p", 10, 10, 1);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, new AstraNestingEngine().Solve(
|
||||
new NestJob(new[] { part }, new[] { new NestPlateStock("s", new Size(2, 2)) })).StopReason);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, new AstraNestingEngine().Solve(
|
||||
new NestJob(new[] { part }, Array.Empty<NestPlateStock>())).StopReason);
|
||||
Assert.Equal(NestJobStatus.Complete, new AstraNestingEngine().Solve(
|
||||
new NestJob(Array.Empty<NestJobPart>(), Array.Empty<NestPlateStock>())).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CircleBoundsAreAnalyticAndIncrementalGeometryWorks()
|
||||
{
|
||||
var circle = new Program();
|
||||
circle.MoveTo(13, 10);
|
||||
circle.ArcTo(13, 10, 10, 10, RotationType.CCW);
|
||||
var incremental = new Program(Mode.Incremental);
|
||||
incremental.MoveTo(-5, -5);
|
||||
incremental.LineTo(2, 0);
|
||||
incremental.LineTo(0, 3);
|
||||
incremental.LineTo(-2, 0);
|
||||
incremental.LineTo(0, -3);
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("circle", PartGeometrySnapshot.FromProgram(circle), 5),
|
||||
new NestJobPart("incremental", PartGeometrySnapshot.FromProgram(incremental), 5)
|
||||
}, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationBeforeAndDuringSolveThrows()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 10) },
|
||||
new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
Assert.Throws<OperationCanceledException>(() => new AstraNestingEngine().Solve(job, token: cts.Token));
|
||||
using var during = new CancellationTokenSource();
|
||||
Assert.Throws<OperationCanceledException>(() => new AstraNestingEngine().Solve(job,
|
||||
new CallbackProgress(_ => during.Cancel()), during.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriorityWinsScarceSpaceAndProgressReflectsCommits()
|
||||
{
|
||||
var low = Rectangle("low", 2, 2, 1);
|
||||
var high = new NestJobPart("high", low.Geometry, 1, priority: 9);
|
||||
var job = new NestJob(new[] { low, high }, new[] { new NestPlateStock("s", new Size(2, 2), 1) });
|
||||
var updates = new List<NestJobProgress>();
|
||||
var result = new AstraNestingEngine().Solve(job, new CallbackProgress(updates.Add));
|
||||
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
|
||||
Assert.Equal(NestJobStage.PlateCommitted, updates.Last().Stage);
|
||||
Assert.Equal(1, updates.Last().CommittedParts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcaveAndHoledPartsRemainValid()
|
||||
{
|
||||
var l = new Program();
|
||||
l.MoveTo(0, 0); l.LineTo(6, 0); l.LineTo(6, 2);
|
||||
l.LineTo(2, 2); l.LineTo(2, 6); l.LineTo(0, 6); l.LineTo(0, 0);
|
||||
var holed = DrawingJobMapper.ToProgram(Rectangle("template", 8, 8, 1).Geometry);
|
||||
holed.MoveTo(2, 2); holed.LineTo(2, 6); holed.LineTo(6, 6);
|
||||
holed.LineTo(6, 2); holed.LineTo(2, 2);
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("concave", PartGeometrySnapshot.FromProgram(l), 7),
|
||||
new NestJobPart("hole", PartGeometrySnapshot.FromProgram(holed), 3)
|
||||
}, new[] { new NestPlateStock("s", new Size(20, 30), partSpacing: 0.2) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeededMixedRectanglesPassBenchmarkValidator()
|
||||
{
|
||||
var random = new Random(7301);
|
||||
for (var trial = 0; trial < 12; trial++)
|
||||
{
|
||||
var parts = Enumerable.Range(0, 6).Select(i => Rectangle($"p{i}",
|
||||
random.Next(1, 9), random.Next(1, 9), random.Next(1, 6),
|
||||
i % 2 == 0 ? RotationPolicy.Automatic : RotationPolicy.Fixed(0))).ToArray();
|
||||
var job = new NestJob(parts, new[] {
|
||||
new NestPlateStock("small", new Size(15, 20), 1, 0.1),
|
||||
new NestPlateStock("large", new Size(25, 30), partSpacing: 0.3)
|
||||
});
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComplementaryTrianglesShareOneEnvelope()
|
||||
{
|
||||
var triangle = new Program(); triangle.MoveTo(0, 0); triangle.LineTo(10, 0);
|
||||
triangle.LineTo(0, 10); triangle.LineTo(0, 0);
|
||||
var job = new NestJob(new[] { new NestJobPart("t", PartGeometrySnapshot.FromProgram(triangle), 2) },
|
||||
new[] { new NestPlateStock("s", new Size(10, 10), 1) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlacesInsertInsideFrameHole()
|
||||
{
|
||||
var frame = DrawingJobMapper.ToProgram(Rectangle("template", 10, 10, 1).Geometry);
|
||||
frame.MoveTo(1, 1); frame.LineTo(1, 9); frame.LineTo(9, 9);
|
||||
frame.LineTo(9, 1); frame.LineTo(1, 1);
|
||||
var job = new NestJob(new[] { new NestJobPart("frame", PartGeometrySnapshot.FromProgram(frame), 1),
|
||||
Rectangle("insert", 7, 7, 1) }, new[] { new NestPlateStock("s", new Size(10, 10), 1, 0.25) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlateLimitSelectsSheetThatCompletesDemand()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 5, 5, 10) }, new[] {
|
||||
new NestPlateStock("small", new Size(10, 10)), new NestPlateStock("large", new Size(20, 20))
|
||||
}, new NestJobOptions(maxPlates: 1));
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("large", Assert.Single(result.Plates).StockId);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutomaticDiagonalIsRetainedWhenItIsTheOnlyStockFit()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("diagonal", 10, 1, 1, RotationPolicy.Automatic) },
|
||||
new[] { new NestPlateStock("s", new Size(8, 8), 1) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DenseTrianglesRespectPositiveSpacing()
|
||||
{
|
||||
var triangle = new Program(); triangle.MoveTo(0, 0); triangle.LineTo(10, 0);
|
||||
triangle.LineTo(0, 10); triangle.LineTo(0, 0);
|
||||
var job = new NestJob(new[] { new NestJobPart("t", PartGeometrySnapshot.FromProgram(triangle), 20) },
|
||||
new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBatchDoesNotLoseEfficientLargeSheetPlans()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 6, 4, 100, RotationPolicy.Automatic) }, new[] {
|
||||
new NestPlateStock("small", new Size(8, 12), partSpacing: 0.1),
|
||||
new NestPlateStock("large", new Size(20, 30), partSpacing: 0.1) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width) <= 3600);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactPositiveSpacingRectangleGridStillFits()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("p", 2, 2, 4) },
|
||||
new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(0.1)]
|
||||
public void MixedRotatedContoursPassIndependentValidation(double spacing)
|
||||
{
|
||||
for (var trial = 0; trial < 5; trial++)
|
||||
{
|
||||
var t = new Program(); t.MoveTo(0, 0); t.LineTo(4 + trial, 0);
|
||||
t.LineTo(1, 3 + trial); t.LineTo(0, 0);
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("t", PartGeometrySnapshot.FromProgram(t), 7),
|
||||
Rectangle("r", 3, 2, 5, RotationPolicy.Fixed(trial * System.Math.PI / 7))
|
||||
}, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Validate(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void CurvedCutoutsAcceptInsertsWhenOnlyOneRingFitsTheStock(int quadrant)
|
||||
{
|
||||
var ring = new OpenNest.Shapes.RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing();
|
||||
var insert = new OpenNest.Shapes.CircleShape { Diameter = 6 }.GetDrawing();
|
||||
var job = new NestJob(new[] {
|
||||
new NestJobPart("ring", PartGeometrySnapshot.FromProgram(ring.Program), 1),
|
||||
new NestJobPart("insert", PartGeometrySnapshot.FromProgram(insert.Program), 1)
|
||||
}, new[] { new NestPlateStock("s", new Size(10.8, 10.6), 1, partSpacing: 0.175,
|
||||
edgeSpacing: new Spacing(0.2, 0.3, 0.4, 0.5), quadrant: quadrant) });
|
||||
var result = new AstraNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurvedConcavityInterlocksWithoutFineMeshMinkowskiExplosion()
|
||||
{
|
||||
var c = new Program();
|
||||
c.MoveTo(6, 0); c.ArcTo(0, -6, 0, 0, RotationType.CCW);
|
||||
c.LineTo(0, -4); c.ArcTo(4, 0, 0, 0, RotationType.CW); c.LineTo(6, 0);
|
||||
var job = new NestJob(new[] { new NestJobPart("C", PartGeometrySnapshot.FromProgram(c), 8) },
|
||||
new[] { new NestPlateStock("s", new Size(25, 43), 1, 0.25,
|
||||
new Spacing(0.2, 0.3, 0.4, 0.5), 3) });
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var result = new AstraNestingEngine().Solve(job, token: cancellation.Token);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Validate(job, result);
|
||||
}
|
||||
|
||||
private sealed class CallbackProgress(Action<NestJobProgress> callback) : IProgress<NestJobProgress>
|
||||
{ public void Report(NestJobProgress value) => callback(value); }
|
||||
|
||||
private static NestJobPart Rectangle(string id, double w, double h, int count,
|
||||
RotationPolicy? rotation = null, double x = 0, double y = 0)
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(x, y); p.LineTo(x + w, y); p.LineTo(x + w, y + h);
|
||||
p.LineTo(x, y + h); p.LineTo(x, y);
|
||||
return new(id, PartGeometrySnapshot.FromProgram(p), count, rotation: rotation ?? RotationPolicy.Fixed(0));
|
||||
}
|
||||
|
||||
private static void Validate(NestJob job, NestJobResult result)
|
||||
{
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
|
||||
p => (Name: p.Id, Quantity: p.Quantity));
|
||||
var validation = OpenNest.Benchmark.NestValidator.Validate(
|
||||
materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(), requirements);
|
||||
OpenNest.Benchmark.NestValidator.ValidateAgainstJob(job, result,
|
||||
job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||
Assert.True(validation.Valid, string.Join("; ", validation.Violations));
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var s = sheet.Stock;
|
||||
var left = (s.Quadrant is 1 or 4 ? 0 : -s.Size.Length) + s.EdgeSpacing.Left;
|
||||
var bottom = (s.Quadrant is 1 or 2 ? 0 : -s.Size.Width) + s.EdgeSpacing.Bottom;
|
||||
var right = left + s.Size.Length - s.EdgeSpacing.Left - s.EdgeSpacing.Right;
|
||||
var top = bottom + s.Size.Width - s.EdgeSpacing.Bottom - s.EdgeSpacing.Top;
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
var part = job.Parts.Single(p => p.Id == pose.PartId);
|
||||
Assert.True(part.Rotation.Allows(pose.Rotation));
|
||||
var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
|
||||
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid)).ToArray();
|
||||
foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); }
|
||||
var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom),
|
||||
R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top));
|
||||
Assert.True(b.L >= left - 1e-7 && b.B >= bottom - 1e-7 && b.R <= right + 1e-7 && b.T <= top + 1e-7);
|
||||
}
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray();
|
||||
Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order());
|
||||
var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id);
|
||||
Assert.Equal(placed.Length, fulfillment.Placed);
|
||||
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
|
||||
}
|
||||
foreach (var usage in result.StockUsage)
|
||||
{
|
||||
var stock = job.Plates.Single(s => s.Id == usage.StockId);
|
||||
Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used);
|
||||
Assert.Equal(stock.Quantity - usage.Used, usage.Remaining);
|
||||
Assert.True(usage.Remaining is null or >= 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -10,6 +10,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Terra.csproj" />
|
||||
<Compile Include="../../../OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Astra.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"sheetSizes": ["120x240", "240x480"],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"parts": [
|
||||
{ "dxf": "../../../OpenNest.Tests/Bending/TestData/4526 A14 PT45.dxf", "quantity": 8 },
|
||||
{ "dxf": "../../../OpenNest.Tests/Bending/TestData/4526 A14 PT23.dxf", "quantity": 4 }
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Shared settings and the OpenNest.Engine reference come from Engines/Directory.Build.props. -->
|
||||
</Project>
|
||||
@@ -1,70 +0,0 @@
|
||||
# OpenNest.Engine.Terra
|
||||
|
||||
An independent `INestingEngine` implementation. It must not be a wrapper, ensemble, or
|
||||
selector over OpenNest's built-in engines. `Solve()` must not call, instantiate, or
|
||||
delegate to any existing `INestingEngine` (`StockLadderNestingEngine`,
|
||||
`FixedStrategyNestingEngine`), `NestingEngineRegistry`, `NestJobRunner`, or the whole-plate
|
||||
nesters/fillers behind `PlateNesterFactory` (`DefaultPlateNester`, `StripPlateNester`,
|
||||
`RemnantPlateNester`, `PlateFillService`, `DefaultPlateFiller`, ...). It must also never run
|
||||
several of them and keep the best result.
|
||||
|
||||
The decisions that make it an engine must be yours: which sheet(s) to use, which parts go
|
||||
where and in what order, which pattern/strategy to apply to which region, and when to stop.
|
||||
|
||||
## Allowed building blocks
|
||||
|
||||
Reuse is encouraged. These are tools you drive, composed by your own decision logic:
|
||||
|
||||
- `OpenNest.Core` geometry: `Polygon`, `Shape`, `BoundingBox`, `Vector`, `Box`, `ConvexHull`,
|
||||
`ConvexDecomposition`, `RotatingCalipers`, `Collision`, `NoFitPolygon`, `ShapeProfile`,
|
||||
`SpatialQuery`.
|
||||
- Fill and pattern components in `OpenNest.Engine.Fill`: `FillLinear`, `FillExtents`,
|
||||
`PairFiller`, `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor`, `FillScore`,
|
||||
`Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`,
|
||||
`BestCombination`.
|
||||
- `OpenNest.Engine.BestFit` (`BestFitFinder`, `PairEvaluator`, ...), `RectanglePacking`,
|
||||
`CirclePacking`.
|
||||
|
||||
If you find a faster or better way to do something a shared component already does (for
|
||||
example linear patterning), implement it inside this engine's own project and leave the
|
||||
shared code untouched. Do not edit `OpenNest.Core` or `OpenNest.Engine`. Call it out in your
|
||||
report (what it replaces, why it is better, measured numbers) so it can be generalized and
|
||||
upstreamed for every engine later.
|
||||
|
||||
## What to fill in
|
||||
|
||||
`TerraNestingEngine.cs` — implement `Solve()`. Pick and document an actual
|
||||
placement strategy (NFP-based sliding placement, skyline/shelf packer,
|
||||
simulated-annealing/genetic layout search, guillotine-cut packer,
|
||||
physics/gravity-settling, etc). It's fine to be simpler or worse than the built-in
|
||||
engines to start; it must not be the same algorithm re-derived through indirection.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
dotnet build Engines/OpenNest.Engine.Terra/OpenNest.Engine.Terra.csproj
|
||||
```
|
||||
|
||||
This project is intentionally **outside** `OpenNest.sln` (same pattern as the
|
||||
`OpenNest.Engine.Aurora` plugin) — it's discovered at runtime as a plugin, not built
|
||||
as part of the main solution.
|
||||
|
||||
## Try it out with the benchmark
|
||||
|
||||
`OpenNest.Benchmark` auto-loads plugin engines from an `Engines/` folder next to its
|
||||
own build output:
|
||||
|
||||
```bash
|
||||
dotnet build Engines/OpenNest.Engine.Terra/OpenNest.Engine.Terra.csproj -c Release
|
||||
dotnet build OpenNest.Benchmark/OpenNest.Benchmark.csproj -c Release
|
||||
|
||||
mkdir -p OpenNest.Benchmark/bin/Release/net8.0/Engines
|
||||
cp Engines/OpenNest.Engine.Terra/bin/Release/net8.0/OpenNest.Engine.Terra.dll OpenNest.Benchmark/bin/Release/net8.0/Engines/
|
||||
|
||||
dotnet OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <path-to-.nest-or-folder>
|
||||
```
|
||||
|
||||
Or build and deploy in one step with `./Engines/Build-Engines.ps1 -Engines Terra`.
|
||||
|
||||
Your engine will show up in the report under its CLR type name (`TerraNestingEngine`),
|
||||
competing on equal footing against the built-in engines.
|
||||
@@ -1,41 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.Terra;
|
||||
|
||||
/// <summary>
|
||||
/// TODO: name and describe the actual placement strategy here (e.g. "skyline packer with
|
||||
/// greedy shelf assignment", "NFP-based sliding placement with simulated-annealing order
|
||||
/// search", etc). This must be an independently designed algorithm — see README.md.
|
||||
/// </summary>
|
||||
public sealed class TerraNestingEngine : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
// TODO: implement independent placement logic here.
|
||||
//
|
||||
// Do NOT call NestingEngineRegistry.Create(...), PlateNesterFactory, PlateFillService,
|
||||
// or any built-in INestingEngine, and do not run several and keep the best. The
|
||||
// Fill/ and pattern components (FillLinear, PairFiller, PatternTiler, Compactor, ...)
|
||||
// and OpenNest.Core geometry ARE fair game as tools; the decisions are yours.
|
||||
//
|
||||
// job.Parts -> requested parts (PartGeometrySnapshot geometry, quantity, priority, rotation policy)
|
||||
// job.Plates -> candidate stock sheets (size, spacing, quadrant, quantity)
|
||||
// job.Options -> job-wide options
|
||||
//
|
||||
// Return a NestJobResult built from NestJobPlateResult (one per used sheet, holding
|
||||
// ordered NestJobPlacement values), PartFulfillment (requested vs placed per part id),
|
||||
// and StockUsage (sheets used per stock id).
|
||||
|
||||
throw new NotImplementedException(
|
||||
"Terra nesting engine placement logic not yet implemented."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Terra.Tests;
|
||||
|
||||
public class TerraNestingEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void SolveReturnsAResultForASingleSimplePart()
|
||||
{
|
||||
// TODO: replace with a real fixture once Solve() is implemented — this only
|
||||
// proves the plumbing (project reference, constructor, interface) is wired up.
|
||||
var engine = new TerraNestingEngine();
|
||||
|
||||
Assert.NotNull(engine);
|
||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||
}
|
||||
}
|
||||
@@ -184,73 +184,14 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
private static Polygon ClipConvex(Polygon subject, Polygon clip)
|
||||
{
|
||||
var output = new List<Vector>(subject.Vertices);
|
||||
|
||||
// Remove closing vertex if present
|
||||
if (
|
||||
output.Count > 1
|
||||
&& output[0].X == output[output.Count - 1].X
|
||||
&& output[0].Y == output[output.Count - 1].Y
|
||||
)
|
||||
output.RemoveAt(output.Count - 1);
|
||||
|
||||
var clipVerts = new List<Vector>(clip.Vertices);
|
||||
if (
|
||||
clipVerts.Count > 1
|
||||
&& clipVerts[0].X == clipVerts[clipVerts.Count - 1].X
|
||||
&& clipVerts[0].Y == clipVerts[clipVerts.Count - 1].Y
|
||||
)
|
||||
clipVerts.RemoveAt(clipVerts.Count - 1);
|
||||
|
||||
for (var i = 0; i < clipVerts.Count; i++)
|
||||
var output = OpenVertices(subject);
|
||||
var clipVerts = OpenVertices(clip);
|
||||
for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++)
|
||||
{
|
||||
if (output.Count == 0)
|
||||
return null;
|
||||
|
||||
var edgeStart = clipVerts[i];
|
||||
var edgeEnd = clipVerts[(i + 1) % clipVerts.Count];
|
||||
var input = output;
|
||||
output = new List<Vector>();
|
||||
|
||||
for (var j = 0; j < input.Count; j++)
|
||||
{
|
||||
var current = input[j];
|
||||
var next = input[(j + 1) % input.Count];
|
||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
||||
|
||||
if (currentInside)
|
||||
{
|
||||
output.Add(current);
|
||||
if (!nextInside)
|
||||
{
|
||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (ix.IsValid())
|
||||
output.Add(ix);
|
||||
}
|
||||
}
|
||||
else if (nextInside)
|
||||
{
|
||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (ix.IsValid())
|
||||
output.Add(ix);
|
||||
}
|
||||
}
|
||||
output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true);
|
||||
}
|
||||
|
||||
if (output.Count < 3)
|
||||
return null;
|
||||
|
||||
var result = new Polygon();
|
||||
result.Vertices.AddRange(output);
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
|
||||
// Reject degenerate slivers
|
||||
if (result.Area() < Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return result;
|
||||
return PositiveAreaPolygon(output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -263,24 +204,6 @@ namespace OpenNest.Geometry
|
||||
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intersection of lines (a1->a2) and (b1->b2). Returns Vector.Invalid if parallel.
|
||||
/// </summary>
|
||||
private static Vector LineIntersection(Vector a1, Vector a2, Vector b1, Vector b2)
|
||||
{
|
||||
var d1x = a2.X - a1.X;
|
||||
var d1y = a2.Y - a1.Y;
|
||||
var d2x = b2.X - b1.X;
|
||||
var d2y = b2.Y - b1.Y;
|
||||
var cross = d1x * d2y - d1y * d2x;
|
||||
|
||||
if (System.Math.Abs(cross) < Tolerance.Epsilon)
|
||||
return Vector.Invalid;
|
||||
|
||||
var t = ((b1.X - a1.X) * d2y - (b1.Y - a1.Y) * d2x) / cross;
|
||||
return new Vector(a1.X + t * d1x, a1.Y + t * d1y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts holes from overlap regions.
|
||||
/// </summary>
|
||||
@@ -320,10 +243,9 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts hole triangles from a region. Exact: a piece outside a convex hole
|
||||
/// triangle equals the union of its clips against each triangle edge's outside
|
||||
/// half-space, so overlap confined to a cutout disappears while any material
|
||||
/// sliver outside the hole survives.
|
||||
/// Subtracts hole triangles from a convex region. At each edge, emit the outside
|
||||
/// portion and carry only the inside remainder to the next edge. The emitted
|
||||
/// pieces are disjoint and convex, so no repeated triangulation is needed.
|
||||
/// </summary>
|
||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||
{
|
||||
@@ -335,79 +257,113 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var piece in current)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(piece.BoundingBox, holeTri.BoundingBox))
|
||||
// Subtraction must also remove thin fragments created by clipping.
|
||||
// The pair-level length tolerance would skip some of these even
|
||||
// when their area is large enough to count as an overlap.
|
||||
var a = piece.BoundingBox;
|
||||
var b = holeTri.BoundingBox;
|
||||
if (a.Right <= b.Left || b.Right <= a.Left || a.Top <= b.Bottom || b.Top <= a.Bottom)
|
||||
{
|
||||
next.Add(piece);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pieceTri in TriangulateWithBounds(piece))
|
||||
var remainder = OpenVertices(piece);
|
||||
var holeVerts = OpenVertices(holeTri);
|
||||
for (var i = 0; i < holeVerts.Count && remainder.Count >= 3; i++)
|
||||
{
|
||||
var holeVerts = holeTri.Vertices;
|
||||
var holeCount = holeTri.IsClosed() ? holeVerts.Count - 1 : holeVerts.Count;
|
||||
var survived = false;
|
||||
for (var i = 0; i < holeCount; i++)
|
||||
survived |= AddIfPositiveArea(
|
||||
next,
|
||||
ClipOutsideHalfSpace(
|
||||
pieceTri,
|
||||
holeVerts[i],
|
||||
holeVerts[(i + 1) % holeCount]
|
||||
)
|
||||
);
|
||||
if (!survived)
|
||||
continue; // piece lies entirely within the hole
|
||||
var start = holeVerts[i];
|
||||
var end = holeVerts[(i + 1) % holeVerts.Count];
|
||||
var outside = PositiveAreaPolygon(ClipHalfSpace(remainder, start, end, false));
|
||||
if (outside != null)
|
||||
next.Add(outside);
|
||||
remainder = ClipHalfSpace(remainder, start, end, true);
|
||||
}
|
||||
}
|
||||
|
||||
current = next;
|
||||
if (current.Count == 0)
|
||||
break;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sutherland-Hodgman clip of a convex polygon to the strict outside of the
|
||||
/// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross < -Epsilon).
|
||||
/// Clips an open vertex list against one half-space. Classification and
|
||||
/// interpolation use the same signed cross products: intersections always
|
||||
/// lie on the input segment. An epsilon-shifted inside test combined with
|
||||
/// intersections on the unshifted line can extrapolate and create material.
|
||||
/// Apply the area tolerance only to the resulting polygons, not to edge signs.
|
||||
/// </summary>
|
||||
private static List<Vector> ClipOutsideHalfSpace(
|
||||
Polygon piece,
|
||||
private static List<Vector> ClipHalfSpace(
|
||||
List<Vector> vertices,
|
||||
Vector edgeStart,
|
||||
Vector edgeEnd
|
||||
Vector edgeEnd,
|
||||
bool inside
|
||||
)
|
||||
{
|
||||
var verts = piece.Vertices;
|
||||
var count = piece.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||
var kept = new List<Vector>();
|
||||
for (var i = 0; i < count; i++)
|
||||
for (var i = 0; i < vertices.Count; i++)
|
||||
{
|
||||
var current = verts[i];
|
||||
var next = verts[(i + 1) % count];
|
||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
||||
if (!currentInside)
|
||||
kept.Add(current);
|
||||
if (currentInside == nextInside)
|
||||
continue;
|
||||
var intersection = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (intersection.IsValid())
|
||||
kept.Add(intersection);
|
||||
var current = vertices[i];
|
||||
var next = vertices[(i + 1) % vertices.Count];
|
||||
var currentDistance = Cross(edgeStart, edgeEnd, current);
|
||||
var nextDistance = Cross(edgeStart, edgeEnd, next);
|
||||
if (inside ? currentDistance >= 0 : currentDistance <= 0)
|
||||
AddDistinct(kept, current);
|
||||
|
||||
// Only strict opposite signs cross the line. Boundary endpoints
|
||||
// are already kept, and near-parallel crossings need no cutoff.
|
||||
if ((currentDistance < 0 && nextDistance > 0) || (currentDistance > 0 && nextDistance < 0))
|
||||
{
|
||||
var t = currentDistance / (currentDistance - nextDistance);
|
||||
AddDistinct(kept, new Vector(
|
||||
current.X + t * (next.X - current.X),
|
||||
current.Y + t * (next.Y - current.Y)));
|
||||
}
|
||||
}
|
||||
if (kept.Count > 1 && SamePoint(kept[0], kept[kept.Count - 1]))
|
||||
kept.RemoveAt(kept.Count - 1);
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static bool AddIfPositiveArea(List<Polygon> polygons, List<Vector> vertices)
|
||||
private static bool SamePoint(Vector a, Vector b) => a.X == b.X && a.Y == b.Y;
|
||||
|
||||
private static void AddDistinct(List<Vector> vertices, Vector point)
|
||||
{
|
||||
if (vertices.Count == 0 || !SamePoint(vertices[vertices.Count - 1], point))
|
||||
vertices.Add(point);
|
||||
}
|
||||
|
||||
private static List<Vector> OpenVertices(Polygon polygon)
|
||||
{
|
||||
var vertices = new List<Vector>(polygon.Vertices);
|
||||
if (vertices.Count > 1 && SamePoint(vertices[0], vertices[vertices.Count - 1]))
|
||||
vertices.RemoveAt(vertices.Count - 1);
|
||||
return vertices;
|
||||
}
|
||||
|
||||
private static Polygon PositiveAreaPolygon(List<Vector> vertices)
|
||||
{
|
||||
if (vertices.Count < 3)
|
||||
return false;
|
||||
return null;
|
||||
|
||||
// Measure relative to a vertex to avoid cancellation of world-coordinate
|
||||
// products when a small clipped fragment is far from the origin.
|
||||
var twiceArea = 0.0;
|
||||
for (var i = 1; i + 1 < vertices.Count; i++)
|
||||
twiceArea += Cross(vertices[0], vertices[i], vertices[i + 1]);
|
||||
if (System.Math.Abs(twiceArea) <= 2 * Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(vertices);
|
||||
polygon.Close();
|
||||
// Polygon.Close uses fuzzy Vector equality; clipping needs an exact
|
||||
// closing vertex even when the last edge is shorter than Epsilon.
|
||||
polygon.Vertices.Add(vertices[0]);
|
||||
polygon.UpdateBounds();
|
||||
if (polygon.Area() <= Tolerance.Epsilon)
|
||||
return false;
|
||||
polygons.Add(polygon);
|
||||
return true;
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Tests.Geometry;
|
||||
|
||||
public class CollisionHoleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(5.2, 5.4, 0)]
|
||||
[InlineData(5.2, 5.4, 0.0003)]
|
||||
[InlineData(5.2, 5.4, 0.05)]
|
||||
[InlineData(5.2, 5.4, 0.1)]
|
||||
[InlineData(-36.8, 5.4, 0)]
|
||||
[InlineData(-36.8, 5.4, 0.0003)]
|
||||
[InlineData(-36.8, 5.4, 0.05)]
|
||||
[InlineData(-36.8, 5.4, 0.1)]
|
||||
public void Check_CircularInsertClearsShrunkHole(double x, double y, double offset)
|
||||
{
|
||||
var ring = ClipperBridge.OffsetForValidation(Profile(5, 3.5), 0.175, 0.001);
|
||||
var insert = ClipperBridge.OffsetForValidation(Profile(3), 0, 0.001);
|
||||
var outer = ring.LargestOuter();
|
||||
var hole = Assert.Single(ring.Holes);
|
||||
var disk = insert.LargestOuter();
|
||||
Move(outer, x, y);
|
||||
Move(hole, x, y);
|
||||
Move(disk, x + offset, y);
|
||||
|
||||
// Check both operands and windings: neither translation nor triangulation
|
||||
// order should change the material represented by these polygons.
|
||||
for (var winding = 0; winding < 2; winding++)
|
||||
{
|
||||
var result = Collision.Check(outer, disk, ring.Holes);
|
||||
Assert.False(result.Overlaps, $"Unexpected overlap area: {result.OverlapArea:R}");
|
||||
Assert.False(Collision.HasOverlap(disk, outer, holesB: ring.Holes));
|
||||
outer.Reverse();
|
||||
hole.Reverse();
|
||||
disk.Reverse();
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void NestValidator_RingInsertAcceptsClearanceAndRejectsViolations(int quadrant)
|
||||
{
|
||||
var ring = new RingShape { OuterDiameter = 10, InnerDiameter = 7 }.GetDrawing();
|
||||
var insert = new CircleShape { Diameter = 6 }.GetDrawing();
|
||||
var plate = new Plate(24, 42) { Quadrant = quadrant, PartSpacing = 0.175 };
|
||||
var x = (quadrant is 1 or 4 ? 0 : -42) + 5.2;
|
||||
var y = (quadrant is 1 or 2 ? 0 : -24) + 5.4;
|
||||
foreach (var offset in new[] { 0.0, 0.0003, 0.05, 0.1, 0.32, 0.34, 0.6 })
|
||||
{
|
||||
// Analytic clearance is 3.5 - 3 - offset. The last two positions
|
||||
// violate spacing; the last also physically overlaps the ring.
|
||||
var a = new Part(ring) { Location = new Vector(x, y) };
|
||||
var b = new Part(insert) { Location = new Vector(x + offset, y) };
|
||||
var result = NestValidator.Validate(
|
||||
new List<(Plate, List<Part>)> { (plate, new List<Part> { a, b }) },
|
||||
new Dictionary<Drawing, (string, int)> { [ring] = ("ring", 1), [insert] = ("insert", 1) });
|
||||
|
||||
Assert.True(result.Valid == (offset <= 0.32),
|
||||
$"Quadrant {quadrant}, offset {offset}: {string.Join("; ", result.Violations)}");
|
||||
if (offset > 0.32)
|
||||
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_HoleSubtractionDoesNotDuplicateSurvivingArea()
|
||||
{
|
||||
var outer = Polygon((0, 0), (10, 0), (10, 10), (0, 10));
|
||||
var hole = Polygon((2, 2), (8, 2), (5, 8));
|
||||
|
||||
var result = Collision.Check(outer, outer, new List<Polygon> { hole });
|
||||
|
||||
Assert.True(result.Overlaps);
|
||||
Assert.Equal(82, result.OverlapArea, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Check_MultipleConcaveHolesMatchesIndependentBooleanArea()
|
||||
{
|
||||
var random = new Random(240924);
|
||||
for (var trial = 0; trial < 80; trial++)
|
||||
{
|
||||
var outer = Polygon((0, 0), (20, 0), (20, 20), (0, 20));
|
||||
var holes = new List<Polygon>
|
||||
{
|
||||
Polygon((2, 5), (5, 2), (8, 5), (5, 8)),
|
||||
Polygon((9, 4), (16, 4), (16, 11), (13, 11), (13, 7), (9, 7))
|
||||
};
|
||||
var x = random.NextDouble() * 19 - 2;
|
||||
var y = random.NextDouble() * 19 - 2;
|
||||
var w = 3 + random.NextDouble() * 6;
|
||||
var h = 3 + random.NextDouble() * 6;
|
||||
var other = Polygon((x, y), (x + w, y), (x + w, y + h), (x, y + h));
|
||||
var otherHoles = new List<Polygon>
|
||||
{
|
||||
Polygon((x + 1, y + 1), (x + w - 1, y + 1), (x + w / 2, y + h - 1))
|
||||
};
|
||||
foreach (var polygon in new[] { outer, other }.Concat(holes).Concat(otherHoles))
|
||||
{
|
||||
Move(polygon, trial % 2 == 0 ? -42.123456 : 5.2, trial % 3 == 0 ? -24.654321 : 5.4);
|
||||
if (trial % 2 == 0)
|
||||
polygon.Reverse();
|
||||
}
|
||||
|
||||
var materialA = new PathsD { ClipperBridge.ToPath(outer, true) };
|
||||
materialA.AddRange(holes.Select(p => ClipperBridge.ToPath(p, false)));
|
||||
var materialB = new PathsD { ClipperBridge.ToPath(other, true) };
|
||||
materialB.AddRange(otherHoles.Select(p => ClipperBridge.ToPath(p, false)));
|
||||
var expected = System.Math.Abs(Clipper.Area(Clipper.Intersect(materialA, materialB, FillRule.NonZero, 6)));
|
||||
var result = Collision.Check(outer, other, holes, otherHoles);
|
||||
var swapped = Collision.Check(other, outer, otherHoles, holes);
|
||||
|
||||
Assert.True(System.Math.Abs(result.OverlapArea - expected) < 0.001,
|
||||
$"Trial {trial}: expected area {expected:R}, got {result.OverlapArea:R}");
|
||||
Assert.True(System.Math.Abs(swapped.OverlapArea - expected) < 0.001,
|
||||
$"Swapped trial {trial}: expected area {expected:R}, got {swapped.OverlapArea:R}");
|
||||
Assert.Equal(expected > 0.001, result.Overlaps);
|
||||
Assert.Equal(result.Overlaps, swapped.Overlaps);
|
||||
}
|
||||
}
|
||||
|
||||
private static ShapeProfile Profile(params double[] radii) =>
|
||||
new(radii.Select(r => (Entity)new Circle(0, 0, r)).ToList());
|
||||
|
||||
private static void Move(Polygon polygon, double x, double y)
|
||||
{
|
||||
polygon.Offset(x, y);
|
||||
polygon.UpdateBounds();
|
||||
}
|
||||
|
||||
private static Polygon Polygon(params (double X, double Y)[] points)
|
||||
{
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(points.Select(p => new Vector(p.X, p.Y)));
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
@@ -120,9 +120,8 @@ public class CollisionTests
|
||||
|
||||
var result = Collision.Check(a, b, holesA: holeA);
|
||||
|
||||
// Hole subtraction uses a conservative approach (keeps partial overlaps),
|
||||
// so we only verify that a collision is still detected for solid material.
|
||||
Assert.True(result.Overlaps);
|
||||
Assert.Equal(3, result.OverlapArea, 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user