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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user