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>
247 lines
13 KiB
C#
247 lines
13 KiB
C#
using Clipper2Lib;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Geometry;
|
|
using M = System.Math;
|
|
|
|
namespace OpenNest.Engine.Gpt6Astra;
|
|
|
|
internal sealed record PackedShape(ShapeVariant Variant, double X, double Y);
|
|
internal sealed record SheetTrial(int StockIndex, int[] Counts, List<PackedShape> Shapes, double Area, double Span);
|
|
|
|
/// <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.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>();
|
|
var order = Enumerable.Range(0, parts.Length)
|
|
.OrderBy(i => parts[i].Requirement.Priority)
|
|
.ThenBy(i => flexibility[i])
|
|
.ThenByDescending(i => parts[i].Variants.Select(v => v.Width * v.Height).DefaultIfEmpty(0).Min())
|
|
.ThenBy(i => i).ToArray();
|
|
double area = 0, right = 0, top = 0;
|
|
foreach (var p in order)
|
|
{
|
|
while (counts[p] < parts[p].Requirement.Quantity)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
PackedShape? best = null;
|
|
(double, double, double, double) bestScore = (double.MaxValue, 0, 0, 0);
|
|
foreach (var v in parts[p].Variants)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (v.Width > width + 1e-9 || v.Height > height + 1e-9) continue;
|
|
var pose = Find(v, placed, width, height, stock.PartSpacing, mode, right, top, spaces);
|
|
if (pose == null) continue;
|
|
var score = Score(pose, width, height, mode, right, top);
|
|
if (score.CompareTo(bestScore) < 0) { best = pose; bestScore = score; }
|
|
}
|
|
if (best == null) break;
|
|
placed.Add(best);
|
|
counts[p]++;
|
|
area += parts[p].Area;
|
|
right = M.Max(right, best.X + best.Variant.Width);
|
|
top = M.Max(top, best.Y + best.Variant.Height);
|
|
}
|
|
}
|
|
return new(stockIndex, counts, placed, area, right * top);
|
|
}
|
|
|
|
private PackedShape? Find(ShapeVariant moving, List<PackedShape> placed, double width, double height,
|
|
double spacing, int mode, double right, double top, Dictionary<int, SearchSpace> spaces)
|
|
{
|
|
var maxX = M.Max(0, width - moving.Width);
|
|
var maxY = M.Max(0, height - moving.Height);
|
|
if (!spaces.TryGetValue(moving.Id, out var space))
|
|
{
|
|
space = new SearchSpace();
|
|
space.Anchors.AddRange(new PointD[] { new(0, 0), new(maxX, 0), new(0, maxY), new(maxX, maxY) });
|
|
space.Free.Add(new PathD { new(0, 0), new(maxX, 0), new(maxX, maxY), new(0, maxY) });
|
|
spaces.Add(moving.Id, space);
|
|
}
|
|
var points = space.Anchors;
|
|
var forbidden = new PathsD();
|
|
var blockers = space.Blockers;
|
|
foreach (var other in placed.Skip(space.Processed))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var paths = GeometryPrecision.Translate(geometry.Forbidden(other.Variant, moving, spacing, token), other.X, other.Y);
|
|
foreach (var path in paths)
|
|
{
|
|
forbidden.Add(path);
|
|
if (!other.Variant.Material.Any(p => !Clipper.IsPositive(p)))
|
|
blockers.Add((path, path.Min(p => p.x), path.Min(p => p.y), path.Max(p => p.x), path.Max(p => p.y)));
|
|
// Clipping loses zero-area feasible regions. Retain their NFP vertices
|
|
// and intersections with plate boundaries explicitly for exact fits.
|
|
for (var i = 0; (maxX < 1e-8 || maxY < 1e-8) && i < path.Count; i++)
|
|
{
|
|
var a = path[i]; var b = path[(i + 1) % path.Count];
|
|
Add(a.x, a.y);
|
|
CrossX(0); CrossX(maxX); CrossY(0); CrossY(maxY);
|
|
void CrossX(double x)
|
|
{
|
|
if (M.Abs(b.x - a.x) < 1e-12) return;
|
|
var t = (x - a.x) / (b.x - a.x);
|
|
if (t >= 0 && t <= 1) Add(x, a.y + t * (b.y - a.y));
|
|
}
|
|
void CrossY(double y)
|
|
{
|
|
if (M.Abs(b.y - a.y) < 1e-12) return;
|
|
var t = (y - a.y) / (b.y - a.y);
|
|
if (t >= 0 && t <= 1) Add(a.x + t * (b.x - a.x), y);
|
|
}
|
|
}
|
|
}
|
|
// Axis contacts also cover exact spacing when the padded NFP cannot fit.
|
|
foreach (var x in new[] { other.X, other.X + other.Variant.Width + spacing,
|
|
other.X - moving.Width - spacing })
|
|
foreach (var y in new[] { 0, other.Y, other.Y + other.Variant.Height + spacing,
|
|
other.Y - moving.Height - spacing }) Add(x, y);
|
|
|
|
// The solid-outline NFP deliberately fills holes. Search each real hole
|
|
// separately, then validate against material, not the outer envelope.
|
|
foreach (var hole in other.Variant.Material.Where(p => !Clipper.IsPositive(p)))
|
|
{
|
|
var l = hole.Min(p => p.x) + other.X + spacing + (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.
|
|
// Interior samples also cover fits that require an off-center placement.
|
|
foreach (var fx in new[] { 0.25, 0.5, 0.75 })
|
|
foreach (var fy in new[] { 0.25, 0.5, 0.75 }) Add(l + fx * (r - l), b + fy * (t - b));
|
|
}
|
|
}
|
|
if (placed.Count > 0 && maxX > 1e-8 && maxY > 1e-8)
|
|
{
|
|
space.Free = Clipper.Difference(space.Free, forbidden, FillRule.NonZero, GeometryPrecision.Digits);
|
|
}
|
|
space.Processed = placed.Count;
|
|
points = new List<PointD>(space.Anchors);
|
|
foreach (var path in space.Free) foreach (var p in path) Add(p.x, p.y);
|
|
var seen = new HashSet<(long, long)>();
|
|
foreach (var pose in points.Select(p => new PackedShape(moving, p.x, p.y))
|
|
.OrderBy(p => Score(p, width, height, mode, right, top)))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (!seen.Add(((long)M.Round(pose.X * 1e6), (long)M.Round(pose.Y * 1e6)))) continue;
|
|
if (blockers.Any(b => pose.X > b.L && pose.X < b.R && pose.Y > b.B && pose.Y < b.T &&
|
|
StrictlyInside(b.Path, pose.X, pose.Y))) continue;
|
|
if (Valid(pose, placed, spacing)) return pose;
|
|
if (spacing > 0) continue;
|
|
// Exact contacts can be invalid only after the host's four-decimal
|
|
// polygon rounding. Try nearby outward contacts without changing angle.
|
|
foreach (var (dx, dy) in new (double, double)[] {
|
|
((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;
|
|
if (Valid(nudged, placed, spacing)) return nudged;
|
|
}
|
|
}
|
|
return null;
|
|
|
|
void Add(double x, double y)
|
|
{
|
|
if (x < -1e-7 || y < -1e-7 || x > maxX + 1e-7 || y > maxY + 1e-7) return;
|
|
points.Add(new(M.Clamp(x, 0, maxX), M.Clamp(y, 0, maxY)));
|
|
}
|
|
}
|
|
|
|
private static (double, double, double, double) Score(PackedShape pose, double width, double height,
|
|
int mode, double right, double top)
|
|
{
|
|
var r = pose.X + pose.Variant.Width;
|
|
var t = pose.Y + pose.Variant.Height;
|
|
// Two directional searches use the same configuration-space algorithm. The
|
|
// third objective minimizes the growing used rectangle rather than a strip.
|
|
return mode switch
|
|
{
|
|
1 => (r + 0.01 * t * width / height, pose.Y, pose.X, pose.Variant.Width * pose.Variant.Height),
|
|
2 => (M.Max(right, r) * M.Max(top, t), t, r, pose.Variant.Width * pose.Variant.Height),
|
|
_ => (t + 0.01 * r * height / width, pose.X, pose.Y, pose.Variant.Width * pose.Variant.Height)
|
|
};
|
|
}
|
|
|
|
private sealed class SearchSpace
|
|
{
|
|
internal int Processed;
|
|
internal PathsD Free = new();
|
|
internal List<PointD> Anchors = new();
|
|
internal List<(PathD Path, double L, double B, double R, double T)> Blockers = new();
|
|
}
|
|
|
|
private static bool StrictlyInside(PathD path, double x, double y)
|
|
{
|
|
var inside = false;
|
|
for (var i = 0; i < path.Count; i++)
|
|
{
|
|
var a = path[i]; var b = path[(i + 1) % path.Count];
|
|
var cross = (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x);
|
|
if (M.Abs(cross) <= 2e-6 * M.Max(1, M.Abs(b.x - a.x) + M.Abs(b.y - a.y)) &&
|
|
x >= M.Min(a.x, b.x) - 1e-6 && x <= M.Max(a.x, b.x) + 1e-6 &&
|
|
y >= M.Min(a.y, b.y) - 1e-6 && y <= M.Max(a.y, b.y) + 1e-6) return false;
|
|
if ((a.y > y) != (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
private bool Valid(PackedShape candidate, List<PackedShape> placed, double spacing)
|
|
{
|
|
PathsD? material = null;
|
|
foreach (var other in placed)
|
|
{
|
|
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 ||
|
|
other.Y >= candidate.Y + candidate.Variant.Height + gap) continue;
|
|
token.ThrowIfCancellationRequested();
|
|
if (candidate.Variant.BoxLike && other.Variant.BoxLike)
|
|
{
|
|
if (candidate.X >= other.X + other.Variant.Width + spacing - 1e-9 ||
|
|
other.X >= candidate.X + candidate.Variant.Width + spacing - 1e-9 ||
|
|
candidate.Y >= other.Y + other.Variant.Height + spacing - 1e-9 ||
|
|
other.Y >= candidate.Y + candidate.Variant.Height + spacing - 1e-9) continue;
|
|
return false;
|
|
}
|
|
material ??= GeometryPrecision.Translate(candidate.Variant.Material, candidate.X, candidate.Y);
|
|
var obstacle = GeometryPrecision.Translate(other.Variant.Halo(spacing), other.X, other.Y);
|
|
var overlap = Clipper.Intersect(material, obstacle, FillRule.NonZero, GeometryPrecision.Digits);
|
|
if (M.Abs(Clipper.Area(overlap)) > 1e-8) return false;
|
|
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))
|
|
{
|
|
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;
|
|
}
|
|
|
|
|
|
}
|