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>
161 lines
8.5 KiB
C#
161 lines
8.5 KiB
C#
using Clipper2Lib;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Geometry;
|
|
using M = System.Math;
|
|
|
|
namespace OpenNest.Engine.Gpt6Astra;
|
|
|
|
internal sealed record PreparedPart(NestJobPart Requirement, double Area, ShapeVariant[] Variants);
|
|
|
|
internal sealed class ShapeVariant
|
|
{
|
|
internal required int Id { get; init; }
|
|
internal required int Part { get; init; }
|
|
internal required double Angle { get; init; }
|
|
internal required double OriginX { get; init; }
|
|
internal required double OriginY { get; init; }
|
|
internal required double Width { get; init; }
|
|
internal required double Height { get; init; }
|
|
internal required bool Curved { get; init; }
|
|
internal required PathsD Material { get; init; }
|
|
internal required Polygon Outline { get; init; }
|
|
internal required Polygon ContactOutline { get; init; }
|
|
internal required double ContactError { get; init; }
|
|
internal required Polygon Hull { get; init; }
|
|
internal required bool Convex { get; init; }
|
|
internal required 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> halos = new();
|
|
|
|
internal PathsD Halo(double spacing)
|
|
{
|
|
if (halos.TryGetValue(spacing, out var cached)) return cached;
|
|
// Raw outlines already circumscribe curves; the extra clearance covers independent
|
|
// flattenings after pose materialization and the validator's four-decimal grid.
|
|
var delta = spacing + (Curved ? 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);
|
|
}
|
|
}
|
|
|
|
internal static class GeometryPrecision
|
|
{
|
|
internal const int Digits = 6;
|
|
internal const double Scale = 1_000_000;
|
|
internal const double Epsilon = 0.000002;
|
|
|
|
internal static PathsD Translate(PathsD paths, double x, double y) =>
|
|
new(paths.Select(path => new PathD(path.Select(p => new PointD(p.x + x, p.y + y)))));
|
|
|
|
internal static PathsD FromPolygons(IEnumerable<Polygon> polygons, bool positive) =>
|
|
new(polygons.Select(p => ClipperBridge.ToPath(p, positive)));
|
|
}
|
|
|
|
internal static class GeometryPreparation
|
|
{
|
|
internal static PreparedPart[] Prepare(NestJob job, CancellationToken token)
|
|
{
|
|
var id = 0;
|
|
return job.Parts.Select((part, index) =>
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var 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);
|
|
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();
|
|
var x = rotated.Min(e => e.Left);
|
|
var y = rotated.Min(e => e.Bottom);
|
|
var w = rotated.Max(e => e.Right) - x;
|
|
var h = rotated.Max(e => e.Top) - y;
|
|
if (!double.IsFinite(w) || !double.IsFinite(h) || w <= 0 || h <= 0)
|
|
throw new ArgumentException($"Unusable rotated bounds: {part.Id}.");
|
|
foreach (var e in rotated) e.Offset(-x, -y);
|
|
var profile = new ShapeProfile(rotated);
|
|
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, 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
|
|
// proposal outline is simplified; fine material remains the safety gate.
|
|
// Pad the resulting NFP by both approximation error bounds.
|
|
var contactError = !convex && outline.Vertices.Count > 64 ? M.Max(0.002, M.Min(w, h) * 0.002) : 0;
|
|
var contactOutline = contactError == 0 ? outline :
|
|
ClipperBridge.Flatten(profile.Perimeter, contactError, circumscribe: true);
|
|
variants.Add(new ShapeVariant { Id = id++, Part = index, Angle = angle,
|
|
OriginX = x, OriginY = y, Width = w, Height = h,
|
|
Curved = rotated.Any(e => e is Arc or Circle), Material = material,
|
|
Outline = outline, ContactOutline = contactOutline, ContactError = contactError,
|
|
Hull = hull, Convex = convex, Geometry = geometry });
|
|
}
|
|
var ordered = variants.OrderBy(v => M.Round(v.Width * v.Height, 7)).ToArray();
|
|
if (part.Rotation.Kind == RotationPolicyKind.Automatic && ordered.Length > 8)
|
|
{
|
|
var minimum = ordered[0].Width * ordered[0].Height;
|
|
var all = ordered;
|
|
var shortlist = ordered.Where(v => v.Width * v.Height <= minimum * 1.08 + 1e-7).Take(16).ToList();
|
|
// A diagonal may be the only orientation fitting a narrow stock. Never
|
|
// discard every fitting orientation merely because its envelope is larger.
|
|
foreach (var stock in job.Plates)
|
|
{
|
|
bool Fits(ShapeVariant v) => stock.Fits(v.Width, v.Height);
|
|
if (!shortlist.Any(Fits)) shortlist.AddRange(all.Where(Fits).Take(4));
|
|
}
|
|
ordered = shortlist.DistinctBy(v => v.Id).ToArray();
|
|
}
|
|
return new PreparedPart(part, area, ordered);
|
|
}).ToArray();
|
|
}
|
|
|
|
private static string Canonical(PathD path)
|
|
{
|
|
if (path.Count == 0) return "";
|
|
var points = path.Select(p => ((long)M.Round(p.x * 100000), (long)M.Round(p.y * 100000))).ToArray();
|
|
var first = 0;
|
|
for (var i = 1; i < points.Length; i++) if (points[i].CompareTo(points[first]) < 0) first = i;
|
|
return string.Join(";", Enumerable.Range(0, points.Length).Select(i => points[(i + first) % points.Length]));
|
|
}
|
|
|
|
private static IEnumerable<double> Angles(RotationPolicy policy, ShapeProfile profile)
|
|
{
|
|
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.
|
|
for (var i = 0; i < 24; i++) values.Add(i * M.PI / 12);
|
|
foreach (var line in profile.Perimeter.Entities.OfType<Line>().OrderByDescending(l => l.Length).Take(8))
|
|
{
|
|
var angle = -M.Atan2(line.EndPoint.Y - line.StartPoint.Y, line.EndPoint.X - line.StartPoint.X);
|
|
for (var i = 0; i < 4; i++) values.Add(angle + i * M.PI / 2);
|
|
}
|
|
}
|
|
else
|
|
values = policy.EnumerateAngles().ToList();
|
|
var seen = new HashSet<long>();
|
|
foreach (var value in values)
|
|
{
|
|
var angle = value % (2 * M.PI);
|
|
if (angle < 0) angle += 2 * M.PI;
|
|
if (policy.Allows(angle) && seen.Add((long)M.Round(angle * 1e9))) yield return angle;
|
|
}
|
|
}
|
|
}
|