diff --git a/OpenNest.Engine.Astra/AstraNestingEngine.cs b/OpenNest.Engine.Astra/AstraNestingEngine.cs
new file mode 100644
index 0000000..a866253
--- /dev/null
+++ b/OpenNest.Engine.Astra/AstraNestingEngine.cs
@@ -0,0 +1,150 @@
+using OpenNest.Engine.Jobs;
+using M = System.Math;
+
+namespace OpenNest.Engine.Astra;
+
+/// Independent configuration-space contact packing with bounded stock-plan search.
+public sealed class AstraNestingEngine : INestingEngine
+{
+ public NestJobResult Solve(NestJob job, IProgress? 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(), 0);
+ var frontier = new List { initial };
+ var best = initial;
+ Plan? complete = IsComplete(initial) ? initial : null;
+ var trials = new Dictionary(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(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(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();
+ 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();
+ 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 Sheets, double Cost);
+}
diff --git a/OpenNest.Engine.Astra/ContactGeometry.cs b/OpenNest.Engine.Astra/ContactGeometry.cs
new file mode 100644
index 0000000..d3e9f2c
--- /dev/null
+++ b/OpenNest.Engine.Astra/ContactGeometry.cs
@@ -0,0 +1,64 @@
+using Clipper2Lib;
+using OpenNest.Geometry;
+using M = System.Math;
+
+namespace OpenNest.Engine.Astra;
+
+/// Per-solve configuration-space cache, never a shared mutable geometry cache.
+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))));
+ }
+}
diff --git a/OpenNest.Engine.Astra/ContactPlacer.cs b/OpenNest.Engine.Astra/ContactPlacer.cs
new file mode 100644
index 0000000..81e7c3b
--- /dev/null
+++ b/OpenNest.Engine.Astra/ContactPlacer.cs
@@ -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 Shapes, double Area, double Span);
+
+/// Searches vertices of the available translation region and exact-fit contacts.
+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();
+ var spaces = new Dictionary();
+ 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 placed, double width, double height,
+ double spacing, int mode, double right, double top, Dictionary 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(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 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 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;
+ }
+
+}
diff --git a/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj b/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj
new file mode 100644
index 0000000..724216d
--- /dev/null
+++ b/OpenNest.Engine.Astra/OpenNest.Engine.Astra.csproj
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/OpenNest.Engine.Astra/PreparedGeometry.cs b/OpenNest.Engine.Astra/PreparedGeometry.cs
new file mode 100644
index 0000000..bfd3073
--- /dev/null
+++ b/OpenNest.Engine.Astra/PreparedGeometry.cs
@@ -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 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 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 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();
+ var keys = new HashSet(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 Angles(RotationPolicy policy, ShapeProfile profile)
+ {
+ var values = new List();
+ 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().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();
+ 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;
+ }
+ }
+}
diff --git a/OpenNest.Engine.Astra/README.md b/OpenNest.Engine.Astra/README.md
new file mode 100644
index 0000000..f92a641
--- /dev/null
+++ b/OpenNest.Engine.Astra/README.md
@@ -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.
diff --git a/OpenNest.Engine.Astra/benchmarks/GeneratedCases.cs b/OpenNest.Engine.Astra/benchmarks/GeneratedCases.cs
new file mode 100644
index 0000000..d7069f5
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/GeneratedCases.cs
@@ -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();
+ 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;
+ }
+}
diff --git a/OpenNest.Engine.Astra/benchmarks/OpenNest.Engine.Astra.Benchmarks.csproj b/OpenNest.Engine.Astra/benchmarks/OpenNest.Engine.Astra.Benchmarks.csproj
new file mode 100644
index 0000000..3f698ce
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/OpenNest.Engine.Astra.Benchmarks.csproj
@@ -0,0 +1,7 @@
+
+ Exedisable
+
+
+
+
+
diff --git a/OpenNest.Engine.Astra/benchmarks/Program.cs b/OpenNest.Engine.Astra/benchmarks/Program.cs
new file mode 100644
index 0000000..6211edc
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/Program.cs
@@ -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); }
+}
diff --git a/OpenNest.Engine.Astra/benchmarks/README.md b/OpenNest.Engine.Astra/benchmarks/README.md
new file mode 100644
index 0000000..8f11656
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/README.md
@@ -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
+```
diff --git a/OpenNest.Engine.Astra/benchmarks/dxf/locked.manifest.json b/OpenNest.Engine.Astra/benchmarks/dxf/locked.manifest.json
new file mode 100644
index 0000000..4f0ab13
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/dxf/locked.manifest.json
@@ -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
+ }
+ ]
+}
\ No newline at end of file
diff --git a/OpenNest.Engine.Astra/benchmarks/dxf/mixed.manifest.json b/OpenNest.Engine.Astra/benchmarks/dxf/mixed.manifest.json
new file mode 100644
index 0000000..19e5afd
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/dxf/mixed.manifest.json
@@ -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
+ }
+ ]
+}
\ No newline at end of file
diff --git a/OpenNest.Engine.Astra/benchmarks/dxf/original.manifest.json b/OpenNest.Engine.Astra/benchmarks/dxf/original.manifest.json
new file mode 100644
index 0000000..3b66ed7
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/dxf/original.manifest.json
@@ -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
+ }
+ ]
+}
\ No newline at end of file
diff --git a/OpenNest.Engine.Astra/benchmarks/dxf/volume.manifest.json b/OpenNest.Engine.Astra/benchmarks/dxf/volume.manifest.json
new file mode 100644
index 0000000..3dd9a78
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/dxf/volume.manifest.json
@@ -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
+ }
+ ]
+}
\ No newline at end of file
diff --git a/OpenNest.Engine.Astra/benchmarks/results/baseline-dxf.csv b/OpenNest.Engine.Astra/benchmarks/results/baseline-dxf.csv
new file mode 100644
index 0000000..886a6f4
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/baseline-dxf.csv
@@ -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,
diff --git a/OpenNest.Engine.Astra/benchmarks/results/baseline-generated.csv b/OpenNest.Engine.Astra/benchmarks/results/baseline-generated.csv
new file mode 100644
index 0000000..5c42d43
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/baseline-generated.csv
@@ -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
diff --git a/OpenNest.Engine.Astra/benchmarks/results/baseline-synthetic.csv b/OpenNest.Engine.Astra/benchmarks/results/baseline-synthetic.csv
new file mode 100644
index 0000000..c6fca17
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/baseline-synthetic.csv
@@ -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
diff --git a/OpenNest.Engine.Astra/benchmarks/results/contact-dxf.csv b/OpenNest.Engine.Astra/benchmarks/results/contact-dxf.csv
new file mode 100644
index 0000000..76a07ae
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/contact-dxf.csv
@@ -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,
diff --git a/OpenNest.Engine.Astra/benchmarks/results/contact-generated.csv b/OpenNest.Engine.Astra/benchmarks/results/contact-generated.csv
new file mode 100644
index 0000000..7a09661
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/contact-generated.csv
@@ -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
diff --git a/OpenNest.Engine.Astra/benchmarks/results/contact-synthetic.csv b/OpenNest.Engine.Astra/benchmarks/results/contact-synthetic.csv
new file mode 100644
index 0000000..08644e2
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/contact-synthetic.csv
@@ -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
diff --git a/OpenNest.Engine.Astra/benchmarks/results/ring-validator-fixed.txt b/OpenNest.Engine.Astra/benchmarks/results/ring-validator-fixed.txt
new file mode 100644
index 0000000..12c9c26
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/ring-validator-fixed.txt
@@ -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:
diff --git a/OpenNest.Engine.Astra/benchmarks/results/ring-validator-reproducer.txt b/OpenNest.Engine.Astra/benchmarks/results/ring-validator-reproducer.txt
new file mode 100644
index 0000000..dc81f51
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/ring-validator-reproducer.txt
@@ -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)
diff --git a/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-dxf.csv b/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-dxf.csv
new file mode 100644
index 0000000..a9994d9
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-dxf.csv
@@ -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,
diff --git a/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-synthetic-generated.csv b/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-synthetic-generated.csv
new file mode 100644
index 0000000..741eb88
--- /dev/null
+++ b/OpenNest.Engine.Astra/benchmarks/results/validator-fixed-synthetic-generated.csv
@@ -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
diff --git a/OpenNest.Engine.Astra/tests/AstraNestingEngineTests.cs b/OpenNest.Engine.Astra/tests/AstraNestingEngineTests.cs
new file mode 100644
index 0000000..cc92143
--- /dev/null
+++ b/OpenNest.Engine.Astra/tests/AstraNestingEngineTests.cs
@@ -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())).StopReason);
+ Assert.Equal(NestJobStatus.Complete, new AstraNestingEngine().Solve(
+ new NestJob(Array.Empty(), Array.Empty())).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(() => new AstraNestingEngine().Solve(job, token: cts.Token));
+ using var during = new CancellationTokenSource();
+ Assert.Throws(() => 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();
+ 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 callback) : IProgress
+ { 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);
+ }
+ }
+}
diff --git a/OpenNest.Engine.Terra/tests/OpenNest.Engine.Terra.Tests.csproj b/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj
similarity index 74%
rename from OpenNest.Engine.Terra/tests/OpenNest.Engine.Terra.Tests.csproj
rename to OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj
index c9776ea..ae3dfab 100644
--- a/OpenNest.Engine.Terra/tests/OpenNest.Engine.Terra.Tests.csproj
+++ b/OpenNest.Engine.Astra/tests/OpenNest.Engine.Astra.Tests.csproj
@@ -10,6 +10,7 @@
-
+
+
diff --git a/OpenNest.Engine.Astra/tests/real-dxf.manifest.json b/OpenNest.Engine.Astra/tests/real-dxf.manifest.json
new file mode 100644
index 0000000..a904c27
--- /dev/null
+++ b/OpenNest.Engine.Astra/tests/real-dxf.manifest.json
@@ -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 }
+ ]
+}
diff --git a/OpenNest.Engine.Terra/OpenNest.Engine.Terra.csproj b/OpenNest.Engine.Terra/OpenNest.Engine.Terra.csproj
deleted file mode 100644
index 1c0be6e..0000000
--- a/OpenNest.Engine.Terra/OpenNest.Engine.Terra.csproj
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/OpenNest.Engine.Terra/README.md b/OpenNest.Engine.Terra/README.md
deleted file mode 100644
index 74657ed..0000000
--- a/OpenNest.Engine.Terra/README.md
+++ /dev/null
@@ -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
-```
-
-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.
diff --git a/OpenNest.Engine.Terra/TerraNestingEngine.cs b/OpenNest.Engine.Terra/TerraNestingEngine.cs
deleted file mode 100644
index 9b1bfa9..0000000
--- a/OpenNest.Engine.Terra/TerraNestingEngine.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System;
-using System.Threading;
-using OpenNest.Engine.Jobs;
-
-namespace OpenNest.Engine.Terra;
-
-///
-/// 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.
-///
-public sealed class TerraNestingEngine : INestingEngine
-{
- public NestJobResult Solve(
- NestJob job,
- IProgress? 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."
- );
- }
-}
diff --git a/OpenNest.Engine.Terra/tests/TerraNestingEngineTests.cs b/OpenNest.Engine.Terra/tests/TerraNestingEngineTests.cs
deleted file mode 100644
index c917b9d..0000000
--- a/OpenNest.Engine.Terra/tests/TerraNestingEngineTests.cs
+++ /dev/null
@@ -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(engine);
- }
-}