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>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<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>
|
</ItemGroup>
|
||||||
</Project>
|
</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>
|
/// </summary>
|
||||||
private static Polygon ClipConvex(Polygon subject, Polygon clip)
|
private static Polygon ClipConvex(Polygon subject, Polygon clip)
|
||||||
{
|
{
|
||||||
var output = new List<Vector>(subject.Vertices);
|
var output = OpenVertices(subject);
|
||||||
|
var clipVerts = OpenVertices(clip);
|
||||||
// Remove closing vertex if present
|
for (var i = 0; i < clipVerts.Count && output.Count >= 3; i++)
|
||||||
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++)
|
|
||||||
{
|
{
|
||||||
if (output.Count == 0)
|
output = ClipHalfSpace(output, clipVerts[i], clipVerts[(i + 1) % clipVerts.Count], true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (output.Count < 3)
|
return PositiveAreaPolygon(output);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -263,24 +204,6 @@ namespace OpenNest.Geometry
|
|||||||
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
|
- (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>
|
/// <summary>
|
||||||
/// Subtracts holes from overlap regions.
|
/// Subtracts holes from overlap regions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -320,10 +243,9 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Subtracts hole triangles from a region. Exact: a piece outside a convex hole
|
/// Subtracts hole triangles from a convex region. At each edge, emit the outside
|
||||||
/// triangle equals the union of its clips against each triangle edge's outside
|
/// portion and carry only the inside remainder to the next edge. The emitted
|
||||||
/// half-space, so overlap confined to a cutout disappears while any material
|
/// pieces are disjoint and convex, so no repeated triangulation is needed.
|
||||||
/// sliver outside the hole survives.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||||
{
|
{
|
||||||
@@ -335,79 +257,113 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
foreach (var piece in current)
|
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);
|
next.Add(piece);
|
||||||
continue;
|
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 start = holeVerts[i];
|
||||||
var holeCount = holeTri.IsClosed() ? holeVerts.Count - 1 : holeVerts.Count;
|
var end = holeVerts[(i + 1) % holeVerts.Count];
|
||||||
var survived = false;
|
var outside = PositiveAreaPolygon(ClipHalfSpace(remainder, start, end, false));
|
||||||
for (var i = 0; i < holeCount; i++)
|
if (outside != null)
|
||||||
survived |= AddIfPositiveArea(
|
next.Add(outside);
|
||||||
next,
|
remainder = ClipHalfSpace(remainder, start, end, true);
|
||||||
ClipOutsideHalfSpace(
|
|
||||||
pieceTri,
|
|
||||||
holeVerts[i],
|
|
||||||
holeVerts[(i + 1) % holeCount]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (!survived)
|
|
||||||
continue; // piece lies entirely within the hole
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
current = next;
|
current = next;
|
||||||
|
if (current.Count == 0)
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sutherland-Hodgman clip of a convex polygon to the strict outside of the
|
/// Clips an open vertex list against one half-space. Classification and
|
||||||
/// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross < -Epsilon).
|
/// 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>
|
/// </summary>
|
||||||
private static List<Vector> ClipOutsideHalfSpace(
|
private static List<Vector> ClipHalfSpace(
|
||||||
Polygon piece,
|
List<Vector> vertices,
|
||||||
Vector edgeStart,
|
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>();
|
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 current = vertices[i];
|
||||||
var next = verts[(i + 1) % count];
|
var next = vertices[(i + 1) % vertices.Count];
|
||||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
var currentDistance = Cross(edgeStart, edgeEnd, current);
|
||||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
var nextDistance = Cross(edgeStart, edgeEnd, next);
|
||||||
if (!currentInside)
|
if (inside ? currentDistance >= 0 : currentDistance <= 0)
|
||||||
kept.Add(current);
|
AddDistinct(kept, current);
|
||||||
if (currentInside == nextInside)
|
|
||||||
continue;
|
// Only strict opposite signs cross the line. Boundary endpoints
|
||||||
var intersection = LineIntersection(edgeStart, edgeEnd, current, next);
|
// are already kept, and near-parallel crossings need no cutoff.
|
||||||
if (intersection.IsValid())
|
if ((currentDistance < 0 && nextDistance > 0) || (currentDistance > 0 && nextDistance < 0))
|
||||||
kept.Add(intersection);
|
{
|
||||||
|
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;
|
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)
|
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();
|
var polygon = new Polygon();
|
||||||
polygon.Vertices.AddRange(vertices);
|
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();
|
polygon.UpdateBounds();
|
||||||
if (polygon.Area() <= Tolerance.Epsilon)
|
return polygon;
|
||||||
return false;
|
|
||||||
polygons.Add(polygon);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
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.True(result.Overlaps);
|
||||||
|
Assert.Equal(3, result.OverlapArea, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user