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 OpenNest.Geometry;
using OpenNest.Engine.Jobs;
using M = System.Math;
namespace OpenNest.Engine.Gpt6Astra;
@@ -47,7 +48,9 @@ internal sealed class ContactGeometry
}
token.ThrowIfCancellationRequested();
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,
EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
// Bound cache residency for jobs with many distinct rotation pairs.
+26 -48
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>
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 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;
validationOriginX = stock.WorkArea.Left;
validationOriginY = stock.WorkArea.Bottom;
var width = stock.WorkArea.Length;
var height = stock.WorkArea.Width;
var counts = (int[])committed.Clone();
var placed = new List<PackedShape>();
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.
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;
var l = hole.Min(p => p.x) + other.X + spacing + (4 * GridUnit);
var b = hole.Min(p => p.y) + other.Y + spacing + (4 * GridUnit);
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 - (4 * GridUnit);
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.
@@ -144,8 +145,8 @@ internal sealed class ContactPlacer(PreparedPart[] parts, ContactGeometry geomet
// 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) })
((3 * GridUnit), 0), (0, (3 * GridUnit)), ((3 * GridUnit), (3 * GridUnit)), (-(3 * GridUnit), 0),
(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 };
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)
{
PathsD? material = null;
PathsD? validationMaterial = null;
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 ||
other.X >= candidate.X + candidate.Variant.Width + 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 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 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))
{
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;
NestJobPlacement Pose(PackedShape p) => new("check", 0,
validationOriginX + p.X - p.Variant.OriginX,
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();
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;
}
}
@@ -14,8 +14,7 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
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();
s.Fits(v.Width, v.Height))).ToArray()).ToArray();
var placer = new ContactPlacer(parts, new ContactGeometry(), token);
var initial = new Plan(new int[parts.Length], new int[job.Plates.Count], new List<SheetTrial>(), 0);
var frontier = new List<Plan> { initial };
@@ -56,7 +55,9 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
evaluated++;
}
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++)
{
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 sheets = new List<SheetTrial>(state.Sheets) { trial };
var next = new Plan(trial.Counts, used, sheets,
state.Cost + job.Plates[s].Size.Length * job.Plates[s].Size.Width);
state.Cost + sheetCost);
if (BetterFulfillment(next, best)) best = next;
if (IsComplete(next))
{
@@ -97,29 +98,26 @@ public sealed class Gpt6AstraNestingEngine : INestingEngine
}
}
var selected = complete ?? best;
var counts = new int[parts.Length];
var plates = new List<NestJobPlateResult>();
var builder = new NestJobResultBuilder(job, progress);
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()));
builder.AddSheet(job.Plates[sheet.StockIndex], Poses(sheet));
}
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])));
return builder.Build(reason);
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 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 OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
using M = System.Math;
@@ -25,24 +23,11 @@ internal sealed class ShapeVariant
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 required JobPartGeometry Geometry { get; init; }
internal bool BoxLike => Material.Count == 1 && GridAligned(OriginX) && GridAligned(OriginY) &&
GridAligned(Width) && GridAligned(Height) &&
M.Abs(Outline.Area() - Width * Height) < 1e-8 * M.Max(1, Width * Height);
private static bool GridAligned(double x) => M.Abs(x - M.Round(x * 10000) / 10000) < 1e-9;
private readonly Dictionary<double, PathsD> validationRegions = new();
internal PathsD ValidationRegion(double spacing)
{
if (validationRegions.TryGetValue(spacing, out var cached)) return cached;
// Match the external validator's sequence: flatten/round in the original
// rotated snapshot frame, then translate. Rounding after normalization is
// not equivalent at a zero-clearance contact.
var region = ClipperBridge.OffsetForValidation(ValidationProfile, spacing, 0.001);
var paths = new PathsD(region.Outers.Select(p => ClipperBridge.ToPath(p, true)));
paths.AddRange(region.Holes.Select(p => ClipperBridge.ToPath(p, false)));
return validationRegions[spacing] = GeometryPrecision.Translate(paths, -OriginX, -OriginY);
}
private readonly Dictionary<double, PathsD> halos = new();
internal PathsD Halo(double spacing)
@@ -50,7 +35,7 @@ internal sealed class ShapeVariant
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);
var delta = spacing + (Curved ? NestTolerances.SafeClearanceMargin(NestTolerances.ValidationOutline) : spacing > 0 ? NestTolerances.SafeClearanceMargin(0) : 0);
return halos[spacing] = delta == 0 ? Material : Clipper.InflatePaths(Material, delta,
JoinType.Round, EndType.Polygon, 2, GeometryPrecision.Digits, 0.00001);
}
@@ -77,17 +62,19 @@ internal static class GeometryPreparation
return job.Parts.Select((part, index) =>
{
token.ThrowIfCancellationRequested();
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
.Where(e => SpecialLayers.IsMaterial(e.Layer)).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 geometry = JobPartGeometry.Read(part.Geometry);
var baseProfile = geometry.Profile;
var closed = new[] { geometry.Perimeter }.Concat(geometry.Cutouts)
.SelectMany(shape => shape.Entities).ToList();
var area = geometry.MaterialArea;
var variants = new List<ShapeVariant>();
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();
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;
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);
var material = ClipperBridge.ToRegion(profile, NestTolerances.ValidationOutline, 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 outline = ClipperBridge.Flatten(profile.Perimeter, NestTolerances.ValidationOutline, 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
@@ -118,7 +104,7 @@ internal static class GeometryPreparation
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 });
Hull = hull, Convex = convex, Geometry = geometry });
}
var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
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.
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;
bool Fits(ShapeVariant v) => stock.Fits(v.Width, v.Height);
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
}
ordered = shortlist.DistinctBy(v => v.Id).ToArray();
@@ -151,7 +136,7 @@ internal static class GeometryPreparation
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)
{
// All half-turns matter for asymmetric parts, unlike envelope-only packing.
@@ -163,19 +148,7 @@ internal static class GeometryPreparation
}
}
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);
}
}
values = policy.EnumerateAngles().ToList();
var seen = new HashSet<long>();
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,
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
1. Validate immutable job input. Read owned analytic material with `JobPartGeometry.Read`. 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
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
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.
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),
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;
@@ -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
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.
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.
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.
@@ -53,10 +52,11 @@ corrects curved-hole validation; the placement algorithm remains entirely in Gpt
## 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
at `NestTolerances.ValidationOutline`. Contact generation and material halos both use
`SafeClearanceMargin` so proposed contacts pass the same clearance gate. Axis-aligned
rectangle contacts preserve exact requested spacing. `NestLayoutCheck.Clears` replaces
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
triangles into disjoint fragments with consistent half-space clipping, resolving the reproduced
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.
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.
## 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>
<ItemGroup>
<ProjectReference Include="../OpenNest.Engine.Gpt6Astra.csproj" />
<Compile Include="$(OpenNestRoot)OpenNest.Benchmark/NestValidator.cs" Link="NestValidator.cs" />
</ItemGroup>
</Project>
@@ -7,7 +7,6 @@ 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;
@@ -30,10 +29,8 @@ if (args.Contains("--diagnose-ring"))
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)}");
var violations = NestLayoutCheck.Violations(j, result);
Console.WriteLine($"q={quadrant} offset={offset} valid={violations.Count == 0}: {string.Join(';', violations)}");
}
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[] {
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());
Console.WriteLine("case,valid,placed,requested,sheets,area,milliseconds");
Console.WriteLine("case,valid,placed,requested,sheets,cost,milliseconds");
foreach (var (name, job) in cases)
{
if (args.Length > 1 && !name.Contains(args[1], StringComparison.OrdinalIgnoreCase)) continue;
@@ -82,13 +79,10 @@ foreach (var (name, job) in cases)
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}");
var violations = NestLayoutCheck.Violations(job, result);
if (violations.Count != 0) Environment.ExitCode = 1;
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}");
foreach (var violation in violations.Take(4)) Console.Error.WriteLine($"{name}: {violation}");
}
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
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,
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`.
@@ -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.Converters;
using OpenNest.Engine.Jobs;
@@ -23,7 +26,7 @@ public class Gpt6AstraNestingEngineTests
var before = job.Parts.Select(p => p.Geometry.Motions.ToArray()).ToArray();
var result = new Gpt6AstraNestingEngine().Solve(job);
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);
var again = new Gpt6AstraNestingEngine().Solve(job);
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)) });
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal("small", Assert.Single(result.Plates).StockId);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Theory]
@@ -50,7 +53,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(reason, result.StopReason);
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -83,7 +86,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 20), partSpacing: 0.4) });
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -130,7 +133,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 30), partSpacing: 0.2) });
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -148,7 +151,7 @@ public class Gpt6AstraNestingEngineTests
});
var result = new Gpt6AstraNestingEngine().Solve(job);
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);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -176,7 +179,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -188,7 +191,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("large", Assert.Single(result.Plates).StockId);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -198,7 +201,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(8, 8), 1) });
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -210,7 +213,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(24, 48), partSpacing: 0.15) });
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -222,7 +225,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().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);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -232,7 +235,7 @@ public class Gpt6AstraNestingEngineTests
new[] { new NestPlateStock("s", new Size(4.25, 4.25), 1, 0.25) });
var result = new Gpt6AstraNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Theory]
@@ -250,7 +253,7 @@ public class Gpt6AstraNestingEngineTests
}, new[] { new NestPlateStock("s", new Size(20, 25), partSpacing: spacing) });
var result = new Gpt6AstraNestingEngine().Solve(job);
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);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -287,7 +290,7 @@ public class Gpt6AstraNestingEngineTests
var result = new Gpt6AstraNestingEngine().Solve(job, token: cancellation.Token);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
Validate(job, result);
LayoutAssert.Valid(job, result);
}
[Fact]
@@ -300,76 +303,19 @@ public class Gpt6AstraNestingEngineTests
rotation: RotationPolicy.Fixed(0)) },
new[] { new NestPlateStock("s", new Size(10.4, 20.6), 1, partSpacing: 0.2) });
var result = new Gpt6AstraNestingEngine().Solve(job);
Validate(job, result);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
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>
{ 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>
<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" />
</ItemGroup>
</Project>