refactor(gpt6astra): use host geometry, tolerances and layout checks

Gpt6Astra reverse-engineered the validator: hand-tuned paddings and a
copied check sequence (ValidationOverlap) to match its rounding. It now
reads parts with JobPartGeometry, takes clearance from NestTolerances,
checks candidates with NestLayoutCheck.Clears, and assembles results with
NestJobResultBuilder and NestJobCost; its tests use the shared kit. Its
contact search, beam search and extra Automatic angles are unchanged.

Synthetic benchmark (5 jobs, salvage 0.5): all valid, 2 sheets each,
cost 5574.07 -> 5470.07; time 1871 -> 2400 ms from the stricter shared
check on arc-heavy jobs.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 09:29:27 -04:00
co-authored by Codex Claude Opus 5.5
parent c691381f30
commit d0c6af783b
10 changed files with 120 additions and 217 deletions
+4 -1
View File
@@ -1,5 +1,6 @@
using Clipper2Lib; using Clipper2Lib;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Engine.Jobs;
using M = System.Math; using M = System.Math;
namespace OpenNest.Engine.Gpt6Astra; namespace OpenNest.Engine.Gpt6Astra;
@@ -47,7 +48,9 @@ internal sealed class ContactGeometry
} }
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var delta = spacing + stationary.ContactError + moving.ContactError var delta = spacing + stationary.ContactError + moving.ContactError
+ (stationary.Curved || moving.Curved ? 0.003 : spacing > 0 ? 0.0003 : 0); + (stationary.Curved || moving.Curved
? NestTolerances.SafeClearanceMargin(NestTolerances.ValidationOutline)
: spacing > 0 ? NestTolerances.SafeClearanceMargin(0) : 0);
if (delta > 0) paths = Clipper.InflatePaths(paths, delta, JoinType.Round, if (delta > 0) paths = Clipper.InflatePaths(paths, delta, JoinType.Round,
EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001); EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
// Bound cache residency for jobs with many distinct rotation pairs. // Bound cache residency for jobs with many distinct rotation pairs.
+19 -41
View File
@@ -11,16 +11,17 @@ internal sealed record SheetTrial(int StockIndex, int[] Counts, List<PackedShape
/// <summary>Searches vertices of the available translation region and exact-fit contacts.</summary> /// <summary>Searches vertices of the available translation region and exact-fit contacts.</summary>
internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geometry, CancellationToken token) internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geometry, CancellationToken token)
{ {
private static readonly double GridUnit = M.Pow(10, -NestTolerances.ClipperPrecision);
private readonly Dictionary<(int, int, double, double, double, double, double), bool> validationCache = new(); private readonly Dictionary<(int, int, double, double, double, double, double), bool> validationCache = new();
private double validationOriginX; private double validationOriginX;
private double validationOriginY; private double validationOriginY;
internal SheetTrial Pack(int stockIndex, NestPlateStock stock, int[] committed, int[] flexibility, int mode) 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; validationOriginX = stock.WorkArea.Left;
validationOriginY = (stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width) + stock.EdgeSpacing.Bottom; validationOriginY = stock.WorkArea.Bottom;
var width = stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right; var width = stock.WorkArea.Length;
var height = stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top; var height = stock.WorkArea.Width;
var counts = (int[])committed.Clone(); var counts = (int[])committed.Clone();
var placed = new List<PackedShape>(); var placed = new List<PackedShape>();
var spaces = new Dictionary<int, SearchSpace>(); var spaces = new Dictionary<int, SearchSpace>();
@@ -112,10 +113,10 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
// separately, then validate against material, not the outer envelope. // separately, then validate against material, not the outer envelope.
foreach (var hole in other.Variant.Material.Where(p => !Clipper.IsPositive(p))) 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 l = hole.Min(p => p.x) + other.X + spacing + (4 * GridUnit);
var b = hole.Min(p => p.y) + other.Y + spacing + 0.0004; var b = hole.Min(p => p.y) + other.Y + spacing + (4 * GridUnit);
var r = hole.Max(p => p.x) + other.X - spacing - moving.Width - 0.0004; var r = hole.Max(p => p.x) + other.X - spacing - moving.Width - (4 * GridUnit);
var t = hole.Max(p => p.y) + other.Y - spacing - moving.Height - 0.0004; var t = hole.Max(p => p.y) + other.Y - spacing - moving.Height - (4 * GridUnit);
if (r < l || t < b) continue; 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); 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. // Box corners miss the useful interior of circular and rounded holes.
@@ -144,8 +145,8 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
// Exact contacts can be invalid only after the host's four-decimal // Exact contacts can be invalid only after the host's four-decimal
// polygon rounding. Try nearby outward contacts without changing angle. // polygon rounding. Try nearby outward contacts without changing angle.
foreach (var (dx, dy) in new (double, double)[] { foreach (var (dx, dy) in new (double, double)[] {
(0.0003, 0), (0, 0.0003), (0.0003, 0.0003), (-0.0003, 0), ((3 * GridUnit), 0), (0, (3 * GridUnit)), ((3 * GridUnit), (3 * GridUnit)), (-(3 * GridUnit), 0),
(0, -0.0003), (-0.0003, 0.0003), (0.0003, -0.0003), (-0.0003, -0.0003) }) (0, -(3 * GridUnit)), (-(3 * GridUnit), (3 * GridUnit)), ((3 * GridUnit), -(3 * GridUnit)), (-(3 * GridUnit), -(3 * GridUnit)) })
{ {
var nudged = pose with { X = pose.X + dx, Y = pose.Y + dy }; 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 (nudged.X < 0 || nudged.Y < 0 || nudged.X > maxX || nudged.Y > maxY) continue;
@@ -202,10 +203,9 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
private bool Valid(PackedShape candidate, List<PackedShape> placed, double spacing) private bool Valid(PackedShape candidate, List<PackedShape> placed, double spacing)
{ {
PathsD? material = null; PathsD? material = null;
PathsD? validationMaterial = null;
foreach (var other in placed) foreach (var other in placed)
{ {
var gap = spacing + (candidate.Variant.Curved || other.Variant.Curved ? 0.003 : 0.0001); var gap = spacing + (candidate.Variant.Curved || other.Variant.Curved ? NestTolerances.SafeClearanceMargin(NestTolerances.ValidationOutline) : NestTolerances.SafeClearanceMargin(0));
if (candidate.X >= other.X + other.Variant.Width + gap || if (candidate.X >= other.X + other.Variant.Width + gap ||
other.X >= candidate.X + candidate.Variant.Width + gap || other.X >= candidate.X + candidate.Variant.Width + gap ||
candidate.Y >= other.Y + other.Variant.Height + gap || candidate.Y >= other.Y + other.Variant.Height + gap ||
@@ -223,46 +223,24 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
var obstacle = GeometryPrecision.Translate(other.Variant.Halo(spacing), other.X, other.Y); var obstacle = GeometryPrecision.Translate(other.Variant.Halo(spacing), other.X, other.Y);
var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits); var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits);
if (M.Abs(Clipper.Area(overlap)) > 1e-8) return false; 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, var key = (candidate.Variant.Id, other.Variant.Id, spacing,
candidate.X + validationOriginX, candidate.Y + validationOriginY, candidate.X + validationOriginX, candidate.Y + validationOriginY,
other.X + validationOriginX, other.Y + validationOriginY); other.X + validationOriginX, other.Y + validationOriginY);
if (!validationCache.TryGetValue(key, out var collides)) if (!validationCache.TryGetValue(key, out var collides))
{ {
collides = ValidationOverlap( NestJobPlacement Pose(PackedShape p) => new("check", 0,
GeometryPrecision.Translate(validationMaterial, validationOriginX, validationOriginY), validationOriginX + p.X - p.Variant.OriginX,
GeometryPrecision.Translate(validationObstacle, validationOriginX, validationOriginY)); validationOriginY + p.Y - p.Variant.OriginY, p.Variant.Angle);
// Equal-left ties follow commit order, just as the full-layout check does.
collides = !NestLayoutCheck.Clears(other.Variant.Geometry, Pose(other),
candidate.Variant.Geometry, Pose(candidate), spacing);
if (validationCache.Count >= 4096) validationCache.Clear(); if (validationCache.Count >= 4096) validationCache.Clear();
validationCache[key] = collides; validationCache[key] = collides;
} }
if (collides) return false; if (collides) return false;
} }
}
return true; 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;
}
} }
@@ -14,8 +14,7 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
NestJobValidator.Validate(job); NestJobValidator.Validate(job);
var parts = GeometryPreparation.Prepare(job, token); var parts = GeometryPreparation.Prepare(job, token);
var fit = parts.Select(p => job.Plates.Select(s => p.Variants.Any(v => 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 && s.Fits(v.Width, v.Height))).ToArray()).ToArray();
v.Height <= s.Size.Width - s.EdgeSpacing.Top - s.EdgeSpacing.Bottom + 1e-9)).ToArray()).ToArray();
var placer = new ContactPlacer(parts, new ContactGeometry(), token); var placer = new ContactPlacer(parts, new ContactGeometry(), token);
var initial = new Plan(new int[parts.Length], new int[job.Plates.Count], new List<SheetTrial>(), 0); var initial = new Plan(new int[parts.Length], new int[job.Plates.Count], new List<SheetTrial>(), 0);
var frontier = new List<Plan> { initial }; var frontier = new List<Plan> { initial };
@@ -56,7 +55,9 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
evaluated++; evaluated++;
} }
if (trial.Shapes.Count == 0) continue; if (trial.Shapes.Count == 0) continue;
var sheetCost = job.Plates[s].Size.Length * job.Plates[s].Size.Width; var sheetCost = NestJobCost.NetSheetArea(job,
new NestJobPlateResult(0, job.Plates[s], Poses(trial).Select(p =>
new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))));
for (var p = 0; p < parts.Length; p++) for (var p = 0; p < parts.Length; p++)
{ {
var delivered = trial.Counts[p] - state.Counts[p]; var delivered = trial.Counts[p] - state.Counts[p];
@@ -65,7 +66,7 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
var used = (int[])state.Used.Clone(); used[s]++; var used = (int[])state.Used.Clone(); used[s]++;
var sheets = new List<SheetTrial>(state.Sheets) { trial }; var sheets = new List<SheetTrial>(state.Sheets) { trial };
var next = new Plan(trial.Counts, used, sheets, var next = new Plan(trial.Counts, used, sheets,
state.Cost + job.Plates[s].Size.Length * job.Plates[s].Size.Width); state.Cost + sheetCost);
if (BetterFulfillment(next, best)) best = next; if (BetterFulfillment(next, best)) best = next;
if (IsComplete(next)) if (IsComplete(next))
{ {
@@ -97,29 +98,26 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
} }
} }
var selected = complete ?? best; var selected = complete ?? best;
var counts = new int[parts.Length]; var builder = new NestJobResultBuilder(job, progress);
var plates = new List<NestJobPlateResult>();
foreach (var sheet in selected.Sheets) foreach (var sheet in selected.Sheets)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var stock = job.Plates[sheet.StockIndex]; builder.AddSheet(job.Plates[sheet.StockIndex], Poses(sheet));
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(); token.ThrowIfCancellationRequested();
var reason = complete != null ? NestJobStopReason.Completed : var reason = complete != null ? NestJobStopReason.Completed :
selected.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue) ? NestJobStopReason.PlateLimitReached : selected.Sheets.Count >= (job.Options.MaxPlates ?? int.MaxValue) ? NestJobStopReason.PlateLimitReached :
!Enumerable.Range(0, job.Plates.Count).Any(s => Available(selected, s)) ? NestJobStopReason.StockExhausted : !Enumerable.Range(0, job.Plates.Count).Any(s => Available(selected, s)) ? NestJobStopReason.StockExhausted :
NestJobStopReason.NoPlacementFound; NestJobStopReason.NoPlacementFound;
return new(complete != null ? NestJobStatus.Complete : NestJobStatus.Incomplete, reason, plates, return builder.Build(reason);
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]))); IEnumerable<(string PartId, double X, double Y, double Rotation)> Poses(SheetTrial sheet)
{
var work = job.Plates[sheet.StockIndex].WorkArea;
return sheet.Shapes.Select(p => (job.Parts[p.Variant.Part].Id,
work.Left + p.X - p.Variant.OriginX, work.Bottom + p.Y - p.Variant.OriginY,
p.Variant.Angle));
}
bool Available(Plan p, int s) => p.Used[s] < (job.Plates[s].Quantity ?? int.MaxValue); 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); bool IsComplete(Plan p) => parts.Select((part, i) => p.Counts[i] == part.Requirement.Quantity).All(v => v);
+19 -46
View File
@@ -1,7 +1,5 @@
using Clipper2Lib; using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
using M = System.Math; using M = System.Math;
@@ -25,24 +23,11 @@ internal sealed class ShapeVariant
internal required double ContactError { get; init; } internal required double ContactError { get; init; }
internal required Polygon Hull { get; init; } internal required Polygon Hull { get; init; }
internal required bool Convex { get; init; } internal required bool Convex { get; init; }
internal required ShapeProfile ValidationProfile { get; init; } internal required JobPartGeometry Geometry { get; init; }
internal bool BoxLike => Material.Count == 1 && GridAligned(OriginX) && GridAligned(OriginY) && internal bool BoxLike => Material.Count == 1 && GridAligned(OriginX) && GridAligned(OriginY) &&
GridAligned(Width) && GridAligned(Height) && GridAligned(Width) && GridAligned(Height) &&
M.Abs(Outline.Area() - Width * Height) < 1e-8 * M.Max(1, Width * 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 static bool GridAligned(double x) => M.Abs(x - M.Round(x * 10000) / 10000) < 1e-9;
private readonly Dictionary<double, PathsD> validationRegions = new();
internal PathsD ValidationRegion(double spacing)
{
if (validationRegions.TryGetValue(spacing, out var cached)) return cached;
// Match the external validator's sequence: flatten/round in the original
// rotated snapshot frame, then translate. Rounding after normalization is
// not equivalent at a zero-clearance contact.
var region = ClipperBridge.OffsetForValidation(ValidationProfile, spacing, 0.001);
var paths = new PathsD(region.Outers.Select(p => ClipperBridge.ToPath(p, true)));
paths.AddRange(region.Holes.Select(p => ClipperBridge.ToPath(p, false)));
return validationRegions[spacing] = GeometryPrecision.Translate(paths, -OriginX, -OriginY);
}
private readonly Dictionary<double, PathsD> halos = new(); private readonly Dictionary<double, PathsD> halos = new();
internal PathsD Halo(double spacing) internal PathsD Halo(double spacing)
@@ -50,7 +35,7 @@ internal sealed class ShapeVariant
if (halos.TryGetValue(spacing, out var cached)) return cached; if (halos.TryGetValue(spacing, out var cached)) return cached;
// Raw outlines already circumscribe curves; the extra clearance covers independent // Raw outlines already circumscribe curves; the extra clearance covers independent
// flattenings after pose materialization and the validator's four-decimal grid. // flattenings after pose materialization and the validator's four-decimal grid.
var delta = spacing + (Curved ? 0.0021 : spacing > 0 ? 0.00015 : 0); var delta = spacing + (Curved ? NestTolerances.SafeClearanceMargin(NestTolerances.ValidationOutline) : spacing > 0 ? NestTolerances.SafeClearanceMargin(0) : 0);
return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta, return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta,
JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001); JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
} }
@@ -77,17 +62,19 @@ internal static class GeometryPreparation
return job.Parts.Select((part, index) => return job.Parts.Select((part, index) =>
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry)) var geometry = JobPartGeometry.Read(part.Geometry);
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList(); var baseProfile = geometry.Profile;
// Input validation has established that open marks lie inside material. They var closed = new[] { geometry.Perimeter }.Concat(geometry.Cutouts)
// must not be interpreted as holes by ShapeProfile. .SelectMany(shape => shape.Entities).ToList();
var closed = ShapeBuilder.GetShapes(entities).Where(s => s.IsClosed()) var area = geometry.MaterialArea;
.SelectMany(s => s.Entities).ToList();
var baseProfile = new ShapeProfile(closed);
var area = baseProfile.Perimeter.Area() - baseProfile.Cutouts.Sum(h => h.Area());
var variants = new List<ShapeVariant>(); var variants = new List<ShapeVariant>();
var keys = new HashSet<string>(StringComparer.Ordinal); var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var angle in Angles(part.Rotation, baseProfile)) var angles = Angles(part.Rotation, baseProfile).ToArray();
// The host symmetry primitive compares perimeters only. Keep full-material
// signatures for holed parts, whose cutouts can break perimeter symmetry.
var distinct = baseProfile.Cutouts.Count == 0
? RotationCandidates.DistinctOutlines(baseProfile.Perimeter, angles) : angles;
foreach (var angle in distinct)
{ {
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
var rotated = closed.Select(e => { var copy = e.Clone(); copy.Rotate(angle); return copy; }).ToList(); var rotated = closed.Select(e => { var copy = e.Clone(); copy.Rotate(angle); return copy; }).ToList();
@@ -97,15 +84,14 @@ internal static class GeometryPreparation
var h = rotated.Max(e => e.Top) - y; var h = rotated.Max(e => e.Top) - y;
if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0) if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0)
throw new ArgumentException($"Unusable rotated bounds: {part.Id}."); 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); foreach (var e in rotated) e.Offset(-x, -y);
var profile = new ShapeProfile(rotated); var profile = new ShapeProfile(rotated);
var material = ClipperBridge.ToRegion(profile, 0.001, circumscribe: true); var material = ClipperBridge.ToRegion(profile, NestTolerances.ValidationOutline, circumscribe: true);
// Circular/symmetric parts should not multiply identical NFP work. Compare // Circular/symmetric parts should not multiply identical NFP work. Compare
// normalized closed contours, including holes, independent of start vertex. // normalized closed contours, including holes, independent of start vertex.
var key = string.Join("|", material.Select(Canonical).Order(StringComparer.Ordinal)); var key = string.Join("|", material.Select(Canonical).Order(StringComparer.Ordinal));
if (!keys.Add(key)) continue; if (!keys.Add(key)) continue;
var outline = ClipperBridge.Flatten(profile.Perimeter, 0.001, circumscribe: true); var outline = ClipperBridge.Flatten(profile.Perimeter, NestTolerances.ValidationOutline, circumscribe: true);
var hull = ConvexHull.Compute(outline.Vertices); var hull = ConvexHull.Compute(outline.Vertices);
var convex = M.Abs(hull.Area() - outline.Area()) < 1e-7 * M.Max(1, hull.Area()); 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 // Concave Minkowski sums have quadratic input size. Only the contact
@@ -118,7 +104,7 @@ internal static class GeometryPreparation
OriginX = x, OriginY = y, Width = w, Height = h, OriginX = x, OriginY = y, Width = w, Height = h,
Curved = rotated.Any(e => e is Arc or Circle), Material = material, Curved = rotated.Any(e => e is Arc or Circle), Material = material,
Outline = outline, ContactOutline = contactOutline, ContactError = contactError, Outline = outline, ContactOutline = contactOutline, ContactError = contactError,
Hull = hull, Convex = convex, ValidationProfile = validationProfile }); Hull = hull, Convex = convex, Geometry = geometry });
} }
var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray(); var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8) if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8)
@@ -130,8 +116,7 @@ internal static class GeometryPreparation
// discard every fitting orientation merely because its envelope is larger. // discard every fitting orientation merely because its envelope is larger.
foreach (var stock in job.Plates) foreach (var stock in job.Plates)
{ {
bool Fits(ShapeVariant v) => v.Width <= stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right + 1e-9 && bool Fits(ShapeVariant v) => stock.Fits(v.Width, v.Height);
v.Height <= stock.Size.Width - stock.EdgeSpacing.Top - stock.EdgeSpacing.Bottom + 1e-9;
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4)); if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
} }
ordered = shortlist.DistinctBy(v => v.Id).ToArray(); ordered = shortlist.DistinctBy(v => v.Id).ToArray();
@@ -151,7 +136,7 @@ internal static class GeometryPreparation
private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile) private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile)
{ {
var values = new List<double>(); var values = new List<double>(RotationCandidates.ForShape(policy, profile.Perimeter));
if (policy.Kind == RotationPolicyKind.Automatic) if (policy.Kind == RotationPolicyKind.Automatic)
{ {
// All half-turns matter for asymmetric parts, unlike envelope-only packing. // All half-turns matter for asymmetric parts, unlike envelope-only packing.
@@ -163,19 +148,7 @@ internal static class GeometryPreparation
} }
} }
else else
{ values = policy.EnumerateAngles().ToList();
var last = policy.Kind == RotationPolicyKind.Fixed ? 0 : M.Floor((policy.End - policy.Start) / policy.Step);
if (!double.IsFinite(last)) last = 720;
var samples = (int)M.Min(720, last);
for (var i = 0; i <= samples; i++)
{
var k = samples == 0 ? 0 : M.Floor(last * ((double)i / samples));
var angle = policy.Start + k * policy.Step;
if (!double.IsFinite(angle) || !policy.Allows(angle)) continue;
values.Add(angle);
if (policy.Allow180Equivalent) values.Add(angle + M.PI);
}
}
var seen = new HashSet<long>(); var seen = new HashSet<long>();
foreach (var value in values) foreach (var value in values)
{ {
+21 -9
View File
@@ -11,15 +11,14 @@ sheet's usable translation rectangle exposes contact positions where another par
This permits overlapping bounding rectangles, complementary triangle pairs, staggered circles, This permits overlapping bounding rectangles, complementary triangle pairs, staggered circles,
concave interlocking, and insertion into straight-edged and curved holes. concave interlocking, and insertion into straight-edged and curved holes.
1. Validate immutable job input. Reconstruct owned analytic entities with `DrawingJobMapper` 1. Validate immutable job input. Read owned analytic material with `JobPartGeometry.Read`. Closed contours define material; internal open marks do not become
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. 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. 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 Automatic angles extend `RotationCandidates.ForShape` with 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 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 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 a candidate stock. Fixed and bounded rotation policies remain enforced. Bounded sweeps use
up to 721 integer step indices, including permitted half-turn equivalents. up to 720 base samples through `RotationPolicy.EnumerateAngles`, including permitted half-turn equivalents.
3. Process high-priority parts first (a lower `Priority` number ranks higher, as in the host), 3. Process high-priority parts first (a lower `Priority` number ranks higher, as in the host),
then parts fitting fewer available stock types, then larger envelopes. Larger frames precede inserts. Search every retained orientation for each instance. 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; 4. Build cached Minkowski/no-fit regions. Convex pairs use Core's linear convex NFP primitive;
@@ -40,7 +39,7 @@ concave interlocking, and insertion into straight-edged and curved holes.
area lower bound prunes plans only once a complete cheaper plan exists. After 24 evaluated 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. trials only one directional objective is used; after 64, beam width reduces to two.
Work counts, not elapsed time or randomness, control search breadth. 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. 7. Select a complete plan with lowest `NestJobCost.NetSheetArea` including salvage, breaking equal-cost ties by sheet count.
If no complete plan is found, maximize fulfilled counts by priority, then minimize cost. 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 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. and the contract's job-level stop reason. Cancellation throws without returning a partial job.
@@ -53,10 +52,11 @@ corrects curved-hole validation; the placement algorithm remains entirely in Gpt
## Precision and safety ## Precision and safety
Analytic rotated bounds govern sheet containment. Material curves are conservatively flattened 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 at `NestTolerances.ValidationOutline`. Contact generation and material halos both use
straight outlines; curved outlines reserve 0.003 extra units even at zero spacing, accounting for offset/chord error and the `SafeClearanceMargin` so proposed contacts pass the same clearance gate. Axis-aligned
benchmark validator's four-decimal grid. Axis-aligned rectangle contacts preserve exact requested rectangle contacts preserve exact requested spacing. `NestLayoutCheck.Clears` replaces
spacing. Actual material intersection checks backstop candidate construction. Both straight-edged the copied validator construction and collision sequence, with world-pose caching.
Actual material intersection checks backstop candidate construction. Both straight-edged
and curved holes are available for insertion. The shared collision routine now subtracts hole 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 triangles into disjoint fragments with consistent half-space clipping, resolving the reproduced
curved-hole false positive. See the benchmark report for regression results. curved-hole false positive. See the benchmark report for regression results.
@@ -123,3 +123,15 @@ non-cardinal rotations, hole insertion, automatic diagonal-only stock fits, all
curves, incremental geometry, determinism, inventory, cancellation and stock-plan regressions. 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. 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. Existing nullable warnings originate from the benchmark validator linked into the test project.
## Shared services migration
Stock bounds and fit checks use the host stock primitives; committed results and progress
use `NestJobResultBuilder`. The test project shares `Engine.Testing` and no longer links
benchmark source. The synthetic console also validates through `NestLayoutCheck` and
reports `NestJobCost.Evaluate`. Automatic 15-degree/edge sampling, beam search, contact
placement, exact rectangle handling, and full-material symmetry signatures for holed
parts remain engine-owned. The host symmetry helper compares only perimeters.
The five salvage benchmarks stayed valid and complete. Cost fell from 5574.07 to
5470.07 overall (no job worsened at report precision); see [PR 5 results](../MIGRATION-PR5.md).
@@ -2,6 +2,5 @@
<PropertyGroup><OutputType>Exe</OutputType><Nullable>disable</Nullable></PropertyGroup> <PropertyGroup><OutputType>Exe</OutputType><Nullable>disable</Nullable></PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" /> <ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
<Compile Include="$(OpenNestRoot)OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -7,7 +7,6 @@ using OpenNest.CNC;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Benchmark;
using CncProgram = OpenNest.CNC.Program; using CncProgram = OpenNest.CNC.Program;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
@@ -30,10 +29,8 @@ if (args.Contains("--diagnose-ring"))
new NestJobPlacement("insert", 0, x + offset, 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 PartFulfillment("ring", 1, 1, 0), new PartFulfillment("insert", 1, 1, 0) },
new[] { new StockUsage("s", 1, 0) }); new[] { new StockUsage("s", 1, 0) });
var materialized = NestResultMaterializer.Materialize(j, result); var violations = NestLayoutCheck.Violations(j, result);
var check = NestValidator.Validate(materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(), Console.WriteLine($"q={quadrant} offset={offset} valid={violations.Count == 0}: {string.Join(';', violations)}");
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; return;
} }
@@ -73,7 +70,7 @@ Add("tail", new[] { Part("rect", Rect(6, 4), 17) }, new[] {
Add("plate-cap", new[] { Part("r", Rect(5, 5), 10) }, new[] { 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)); new NestPlateStock("small", new Size(10, 10)), new NestPlateStock("large", new Size(20, 20)) }, new NestJobOptions(maxPlates: 1));
cases.AddRange(OpenNest.Engine.Gpt6Astra.Benchmarks.GeneratedCases.Create()); cases.AddRange(OpenNest.Engine.Gpt6Astra.Benchmarks.GeneratedCases.Create());
Console.WriteLine("case,valid,placed,requested,sheets,area,milliseconds"); Console.WriteLine("case,valid,placed,requested,sheets,cost,milliseconds");
foreach (var (name, job) in cases) foreach (var (name, job) in cases)
{ {
if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue; if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue;
@@ -82,13 +79,10 @@ foreach (var (name, job) in cases)
try try
{ {
var result = engine.Solve(job, token: cts.Token); sw.Stop(); var result = engine.Solve(job, token: cts.Token); sw.Stop();
var nest = NestResultMaterializer.Materialize(job, result); var violations = NestLayoutCheck.Violations(job, result);
var validation = NestValidator.Validate(nest.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(), if (violations.Count != 0) Environment.ExitCode = 1;
job.Parts.ToDictionary(p => nest.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity))); Console.WriteLine($"{name},{violations.Count == 0},{result.Fulfillment.Sum(f => f.Placed)},{job.Parts.Sum(p => p.Quantity)},{result.Plates.Count},{NestJobCost.Evaluate(job, result)},{sw.ElapsedMilliseconds}");
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation); foreach (var violation in violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
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); } catch (Exception ex) { Environment.ExitCode = 1; Console.WriteLine($"{name},ERROR,,,,,{sw.ElapsedMilliseconds}"); Console.Error.WriteLine(ex); }
} }
@@ -9,7 +9,7 @@ claim of performance on an unseen competition dataset.
## Synthetic cases ## Synthetic cases
Both versions were run on exactly the same programmatically generated geometry and stock. Both versions were run on exactly the same programmatically generated geometry and stock.
The driver validates materialized output with `OpenNest.Benchmark.NestValidator`, including The driver validates materialized output with `NestLayoutCheck.Violations`, including
quantity, stock settings, rotation, material overlap and spacing. Timings cover `Solve` only, quantity, stock settings, rotation, material overlap and spacing. Timings cover `Solve` only,
exclude external validation, and are single-run observations rather than stable distributions. 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`. Every contact result is valid and complete. The baseline is valid but incomplete on `plate-cap`.
@@ -1,3 +1,6 @@
using OpenNest.Engine.Testing;
using static OpenNest.Engine.Testing.JobBuilder;
using static OpenNest.Engine.Testing.Shapes;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs;
@@ -23,7 +26,7 @@ public class Gpt6AstraNestingEngineTests
var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray(); var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray();
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
for (var i = 0; i < job.Parts.Count; i++) Assert.Equal(before[i], job.Parts[i].Geometry.Motions); for (var i = 0; i < job.Parts.Count; i++) Assert.Equal(before[i], job.Parts[i].Geometry.Motions);
var again = new Gpt6AstraNestingEngine().Solve(job); var again = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(result.Plates.SelectMany(p => p.Placements), again.Plates.SelectMany(p => p.Placements)); Assert.Equal(result.Plates.SelectMany(p => p.Placements), again.Plates.SelectMany(p => p.Placements));
@@ -37,7 +40,7 @@ public class Gpt6AstraNestingEngineTests
new NestPlateStock("large", new Size(20, 20)), new NestPlateStock("small", new Size(2, 2)) }); new NestPlateStock("large", new Size(20, 20)), new NestPlateStock("small", new Size(2, 2)) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal("small", Assert.Single(result.Plates).StockId); Assert.Equal("small", Assert.Single(result.Plates).StockId);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Theory] [Theory]
@@ -50,7 +53,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(reason, result.StopReason); Assert.Equal(reason, result.StopReason);
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced); Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -83,7 +86,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) }); }, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -130,7 +133,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 30), partSpacing: 0.2) }); }, new[] { new NestPlateStock("s", new Size(20, 30), partSpacing: 0.2) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -148,7 +151,7 @@ public class Gpt6AstraNestingEngineTests
}); });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
} }
@@ -162,7 +165,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -176,7 +179,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -188,7 +191,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("large", Assert.Single(result.Plates).StockId); Assert.Equal("large", Assert.Single(result.Plates).StockId);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -198,7 +201,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(8, 8), 1) }); new[] { new NestPlateStock("s", new Size(8, 8), 1) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -210,7 +213,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) }); new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -222,7 +225,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.True(result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width) <= 3600); Assert.True(result.Plates.Sum(p => p.Stock.Size.Length * p.Stock.Size.Width) <= 3600);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -232,7 +235,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) }); new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Theory] [Theory]
@@ -250,7 +253,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) }); }, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
} }
@@ -271,7 +274,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -287,7 +290,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job, token: cancellation.Token); var result = new Gpt6AstraNestingEngine().Solve(job, token: cancellation.Token);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates); Assert.Single(result.Plates);
Validate(job, result); LayoutAssert.Valid(job, result);
} }
[Fact] [Fact]
@@ -300,76 +303,19 @@ public class Gpt6AstraNestingEngineTests
rotation: RotationPolicy.Fixed(0)) }, rotation: RotationPolicy.Fixed(0)) },
new[] { new NestPlateStock("s", new Size(10.4, 20.6), 1, partSpacing: 0.2) }); new[] { new NestPlateStock("s", new Size(10.4, 20.6), 1, partSpacing: 0.2) });
var result = new Gpt6AstraNestingEngine().Solve(job); var result = new Gpt6AstraNestingEngine().Solve(job);
Validate(job, result); LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
} }
private static Program NotchedPartWithEtch()
{
var p = new Program();
p.MoveTo(0, 0); p.LineTo(10, 0); p.LineTo(10, 4); p.LineTo(8, 4); p.LineTo(8, 6);
p.LineTo(10, 6); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(0, 0);
p.MoveTo(7.5, 5);
p.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
return p;
}
private sealed class CallbackProgress(Action<NestJobProgress> callback) : IProgress<NestJobProgress> private sealed class CallbackProgress(Action<NestJobProgress> callback) : IProgress<NestJobProgress>
{ public void Report(NestJobProgress value) => callback(value); } { 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 => SpecialLayers.IsMaterial(e.Layer)).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);
}
}
} }
public sealed class Gpt6AstraContractTests : EngineContractTests<Gpt6AstraNestingEngine> { }
@@ -10,7 +10,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<Compile Include="$(OpenNestRoot)OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" /> <ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
<ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" /> <ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>