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); - } -}