Qwen3.8-Flash-Next's final version after a 14.5-hour optimization run (its commit 7d7fca3): cost-first sheet trials, largest-area-first demand order, and a cached-triangulation exact gate that brought a 219-part production job from timeout to ~106 s. 13/13 tests pass against OpenNest master. README cleaned for publishing: the model-facing template rules are replaced by a one-line independence statement, the production job is described generically instead of by its PEP job/file name (also in a JobSolver comment), results show both sheet pools as re-measured here (the 9-size claim in its report didn't reproduce: it grabs 96x240 and under-fills them), and the stale StockLadder-crash note is gone now that core leaves etch marks out of nesting. Also drops a stale Aurora plugin reference from Opus55's README and lists the engine in the repo README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
525 lines
18 KiB
C#
525 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using OpenNest.Converters;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Adapters;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Math;
|
|
|
|
namespace OpenNest.Engine.Qwen38FlashNext.Engine;
|
|
|
|
using Math = System.Math;
|
|
|
|
/// <summary>
|
|
/// A job requirement prepared once per solve: snapshot motions rebuilt into an owned
|
|
/// closed contour topology (perimeter + cutouts; rapid/layer-mark geometry dropped),
|
|
/// flattened collision polygons, and material area.
|
|
/// </summary>
|
|
internal sealed class PartModel
|
|
{
|
|
private PartModel(
|
|
string id,
|
|
int quantity,
|
|
int priority,
|
|
RotationPolicy rotation,
|
|
ShapeProfile profile,
|
|
Shape perimeterShape,
|
|
List<Shape> cutoutShapes,
|
|
double area
|
|
)
|
|
{
|
|
Id = id;
|
|
Quantity = quantity;
|
|
Priority = priority;
|
|
Rotation = rotation;
|
|
Profile = profile;
|
|
PerimeterShape = perimeterShape;
|
|
CutoutShapes = cutoutShapes;
|
|
Area = area;
|
|
}
|
|
|
|
public string Id { get; }
|
|
public int Quantity { get; }
|
|
public int Priority { get; }
|
|
public RotationPolicy Rotation { get; }
|
|
|
|
/// <summary>Closed contour topology (perimeter CCW, cutouts) used for region offsets.</summary>
|
|
public ShapeProfile Profile { get; }
|
|
|
|
/// <summary>Analytic closed perimeter (arcs preserved) for conservative flattening.</summary>
|
|
public Shape PerimeterShape { get; }
|
|
|
|
public List<Shape> CutoutShapes { get; }
|
|
|
|
/// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary>
|
|
public double Area { get; }
|
|
|
|
/// <summary>
|
|
/// Chord tolerance for the engine's internal collision polygons. Must stay FINER
|
|
/// than the validator's OutlineTolerance (0.001): any chord cuts the cap off a
|
|
/// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a
|
|
/// subset of the validator's material in notched regions and admits real spacing
|
|
/// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator,
|
|
/// every engine polygon contains the validator's, so a cleared gate is conservative.
|
|
/// </summary>
|
|
public const double CollisionTolerance = 0.0005;
|
|
|
|
/// <summary>
|
|
/// Returns null when the snapshot has no usable closed contour - such a part can
|
|
/// never be placed and is reported unplaced rather than failing the whole job.
|
|
/// </summary>
|
|
public static PartModel? TryCreate(NestJobPart part)
|
|
{
|
|
try
|
|
{
|
|
var entities = new List<Entity>();
|
|
foreach (
|
|
var entity in ConvertProgram.ToGeometry(
|
|
DrawingJobMapper.ToProgram(part.Geometry)
|
|
)
|
|
)
|
|
if (!ReferenceEquals(entity.Layer, SpecialLayers.Rapid))
|
|
entities.Add(entity);
|
|
if (entities.Count == 0)
|
|
return null;
|
|
|
|
var profile = new ShapeProfile(entities);
|
|
if (profile.Perimeter == null)
|
|
return null;
|
|
profile.NormalizeWinding();
|
|
|
|
var area = Math.Abs(profile.Perimeter.Area());
|
|
foreach (var cutout in profile.Cutouts)
|
|
area -= Math.Abs(cutout.Area());
|
|
if (!double.IsFinite(area) || area <= Tolerance.Epsilon)
|
|
return null;
|
|
|
|
return new PartModel(
|
|
part.Id,
|
|
part.Quantity,
|
|
part.Priority,
|
|
part.Rotation,
|
|
profile,
|
|
profile.Perimeter,
|
|
new List<Shape>(profile.Cutouts),
|
|
area
|
|
);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Malformed snapshots are unplaceable, not fatal: report them unplaced so
|
|
// the rest of the job still nests.
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One part contour rotated about the snapshot origin - exactly the frame a
|
|
/// <see cref="NestJobPlacement"/> produces (rotate, then translate by X/Y). Bounds,
|
|
/// convex hull, and the spacing-inflated outline are computed once and reused.
|
|
/// </summary>
|
|
internal sealed class OrientationModel
|
|
{
|
|
internal OrientationModel(
|
|
double angle,
|
|
Polygon perimeter,
|
|
List<Polygon> holes,
|
|
Polygon? inflatedPerimeter,
|
|
List<Polygon> inflatedHoles,
|
|
double spacing
|
|
)
|
|
{
|
|
Angle = angle;
|
|
Perimeter = perimeter;
|
|
Holes = holes;
|
|
InflatedPerimeter = inflatedPerimeter;
|
|
InflatedHoles = inflatedHoles;
|
|
Spacing = spacing;
|
|
|
|
var minX = double.MaxValue;
|
|
var minY = double.MaxValue;
|
|
var maxX = double.MinValue;
|
|
var maxY = double.MinValue;
|
|
foreach (var v in perimeter.Vertices)
|
|
{
|
|
if (v.X < minX)
|
|
minX = v.X;
|
|
if (v.X > maxX)
|
|
maxX = v.X;
|
|
if (v.Y < minY)
|
|
minY = v.Y;
|
|
if (v.Y > maxY)
|
|
maxY = v.Y;
|
|
}
|
|
MinX = minX;
|
|
MinY = minY;
|
|
MaxX = maxX;
|
|
MaxY = maxY;
|
|
|
|
var hullPoints = new List<Vector>();
|
|
try
|
|
{
|
|
var hull = ConvexHull.Compute(perimeter.Vertices);
|
|
foreach (var v in hull.Vertices)
|
|
{
|
|
if (hullPoints.Count > 0 && v.Equals(hullPoints[^1]))
|
|
continue;
|
|
hullPoints.Add(v);
|
|
}
|
|
if (hullPoints.Count > 1 && hullPoints[0].Equals(hullPoints[^1]))
|
|
hullPoints.RemoveAt(hullPoints.Count - 1);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
hullPoints.Clear();
|
|
}
|
|
Hull = hullPoints.Count >= 3 ? hullPoints : perimeter.Vertices;
|
|
|
|
// True when the flattened perimeter is itself convex (no concavities) and the
|
|
// part has no cutouts: for two such parts the convex NFP is EXACT - material
|
|
// equals hull - so an anchor inside it is forbidden with no material test.
|
|
var convex = holes.Count == 0;
|
|
if (convex)
|
|
{
|
|
var verts = perimeter.Vertices;
|
|
var m = verts.Count;
|
|
if (m > 2 && verts[0].Equals(verts[m - 1]))
|
|
m--;
|
|
for (var i = 0; i < m && convex; i++)
|
|
{
|
|
var ax = verts[i].X;
|
|
var ay = verts[i].Y;
|
|
var bx = verts[(i + 1) % m].X;
|
|
var by = verts[(i + 1) % m].Y;
|
|
var cx = verts[(i + 2) % m].X;
|
|
var cy = verts[(i + 2) % m].Y;
|
|
if ((bx - ax) * (cy - by) - (by - ay) * (cx - bx) < -1e-9)
|
|
convex = false;
|
|
}
|
|
}
|
|
IsConvexSolid = convex;
|
|
}
|
|
|
|
/// <summary>No cutouts and a convex perimeter: material equals hull.</summary>
|
|
public bool IsConvexSolid { get; }
|
|
|
|
public double Angle { get; }
|
|
|
|
/// <summary>Circumscribed flattened perimeter in the rotated frame (pre-translation).</summary>
|
|
public Polygon Perimeter { get; }
|
|
|
|
public List<Polygon> Holes { get; }
|
|
|
|
/// <summary>Material outline inflated by <see cref="Spacing"/> (null when spacing is zero).</summary>
|
|
public Polygon? InflatedPerimeter { get; }
|
|
|
|
/// <summary>Cutouts shrunk by <see cref="Spacing"/>; holes that close up are dropped (treated solid).</summary>
|
|
public List<Polygon> InflatedHoles { get; }
|
|
|
|
public double Spacing { get; }
|
|
|
|
public double MinX { get; }
|
|
public double MinY { get; }
|
|
public double MaxX { get; }
|
|
public double MaxY { get; }
|
|
public double Width => MaxX - MinX;
|
|
public double Height => MaxY - MinY;
|
|
|
|
/// <summary>Convex hull of the perimeter (open vertex list, at least 3 points).</summary>
|
|
public List<Vector> Hull { get; }
|
|
|
|
/// <summary>
|
|
/// Fast clearance outline of the raw perimeter in this orientation's local frame
|
|
/// (lazily built; translated per anchor in O(1) via <see cref="FastPoly.Translated"/>).
|
|
/// </summary>
|
|
public FastPoly? PerimeterFast => _perimeterFast ??= FastPoly.From(Perimeter);
|
|
|
|
private FastPoly? _perimeterFast;
|
|
|
|
/// <summary>
|
|
/// Fast clearance outline of the gate material (spacing-inflated when positive) in
|
|
/// this orientation's local frame.
|
|
/// </summary>
|
|
public FastPoly? GateFast =>
|
|
_gateFast ??= FastPoly.From(InflatedPerimeter ?? Perimeter);
|
|
|
|
private FastPoly? _gateFast;
|
|
|
|
/// <summary>
|
|
/// Cached triangulation of the raw material (perimeter + holes) in this
|
|
/// orientation's local frame for the allocation-free exact gate.
|
|
/// </summary>
|
|
public TriSet? MaterialTris => _materialTris ??= TriSet.Build(Perimeter, Holes);
|
|
|
|
private TriSet? _materialTris;
|
|
|
|
/// <summary>
|
|
/// Cached triangulation of the gate material (spacing-inflated perimeter with
|
|
/// shrunk holes) in this orientation's local frame.
|
|
/// </summary>
|
|
public TriSet? GateTris =>
|
|
_gateTris ??= TriSet.Build(InflatedPerimeter ?? Perimeter, InflatedPerimeter != null ? InflatedHoles : Holes);
|
|
|
|
private TriSet? _gateTris;
|
|
}
|
|
|
|
/// <summary>Builds and caches per-(part, orientation, spacing) geometry for one engine run.</summary>
|
|
internal sealed class PartPreparation
|
|
{
|
|
private readonly List<PartModel> models = new();
|
|
private readonly Dictionary<string, int> indexById = new(StringComparer.Ordinal);
|
|
private readonly Dictionary<(string, double, double), OrientationModel> orientations = new();
|
|
|
|
/// <summary>
|
|
/// Cross-packer memo of exact material overlap: (placed orientation, placed anchor,
|
|
/// candidate orientation, candidate anchor) -> overlap. Sheet trials rebuild greedy
|
|
/// placement deterministically, so identical world poses recur across trials and
|
|
/// across sheets; the memo collapses the repeated polygon-clipping work. Bounded so
|
|
/// it can never grow unboundedly on pathological jobs.
|
|
/// </summary>
|
|
private readonly Dictionary<OverlapKey, bool> overlaps = new();
|
|
|
|
internal sealed class OverlapKey : IEquatable<OverlapKey>
|
|
{
|
|
private readonly int _placedHash;
|
|
private readonly long _px;
|
|
private readonly long _py;
|
|
private readonly int _candHash;
|
|
private readonly long _cx;
|
|
private readonly long _cy;
|
|
|
|
public OverlapKey(int placedHash, double px, double py, int candHash, double cx, double cy)
|
|
{
|
|
_placedHash = placedHash;
|
|
_px = (long)Math.Round(px * 1e6);
|
|
_py = (long)Math.Round(py * 1e6);
|
|
_candHash = candHash;
|
|
_cx = (long)Math.Round(cx * 1e6);
|
|
_cy = (long)Math.Round(cy * 1e6);
|
|
}
|
|
|
|
public bool Equals(OverlapKey? other) =>
|
|
other != null
|
|
&& _placedHash == other._placedHash
|
|
&& _px == other._px
|
|
&& _py == other._py
|
|
&& _candHash == other._candHash
|
|
&& _cx == other._cx
|
|
&& _cy == other._cy;
|
|
|
|
public override bool Equals(object? obj) => Equals(obj as OverlapKey);
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
var hash = _placedHash;
|
|
hash = unchecked(hash * 397 + _px.GetHashCode());
|
|
hash = unchecked(hash * 397 + _py.GetHashCode());
|
|
hash = unchecked(hash * 397 + _candHash);
|
|
hash = unchecked(hash * 397 + _cx.GetHashCode());
|
|
hash = unchecked(hash * 397 + _cy.GetHashCode());
|
|
return hash;
|
|
}
|
|
}
|
|
|
|
private const int OverlapMemoCap = 500_000;
|
|
|
|
public bool MaterialOverlapMemo(
|
|
OrientationModel placed,
|
|
double placedX,
|
|
double placedY,
|
|
OrientationModel candidate,
|
|
double candidateX,
|
|
double candidateY,
|
|
Func<bool> compute
|
|
)
|
|
{
|
|
var key = new OverlapKey(
|
|
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(placed),
|
|
placedX,
|
|
placedY,
|
|
System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(candidate),
|
|
candidateX,
|
|
candidateY
|
|
);
|
|
if (overlaps.TryGetValue(key, out var known))
|
|
return known;
|
|
if (overlaps.Count >= OverlapMemoCap)
|
|
overlaps.Clear();
|
|
var value = compute();
|
|
overlaps[key] = value;
|
|
return value;
|
|
}
|
|
|
|
public IReadOnlyList<PartModel> Models => models;
|
|
|
|
public PartPreparation(IReadOnlyList<NestJobPart> parts)
|
|
{
|
|
foreach (var part in parts)
|
|
{
|
|
var model = PartModel.TryCreate(part);
|
|
if (model == null)
|
|
{
|
|
InvalidIds.Add(part.Id);
|
|
continue;
|
|
}
|
|
indexById[model.Id] = models.Count;
|
|
models.Add(model);
|
|
}
|
|
}
|
|
|
|
/// <summary>Requirements whose snapshot geometry could not be interpreted at all.</summary>
|
|
public List<string> InvalidIds { get; } = new();
|
|
|
|
public bool TryGetModel(string partId, out PartModel model)
|
|
{
|
|
model = null!;
|
|
if (!indexById.TryGetValue(partId, out var index))
|
|
return false;
|
|
model = models[index];
|
|
return true;
|
|
}
|
|
|
|
public OrientationModel Oriented(PartModel model, double angle, double spacing)
|
|
{
|
|
// Round keys so policy-equivalent angles (0 vs 2pi) share one cached orientation.
|
|
var key = (model.Id, Math.Round(angle, 9), Math.Round(spacing, 9));
|
|
if (orientations.TryGetValue(key, out var cached))
|
|
return cached;
|
|
|
|
var perimeterShape = (Shape)model.PerimeterShape.Clone();
|
|
perimeterShape.Rotate(angle);
|
|
var perimeter = perimeterShape.ToPolygonWithTolerance(
|
|
PartModel.CollisionTolerance,
|
|
circumscribe: true
|
|
);
|
|
var holes = new List<Polygon>(model.CutoutShapes.Count);
|
|
foreach (var cutout in model.CutoutShapes)
|
|
{
|
|
var shape = (Shape)cutout.Clone();
|
|
shape.Rotate(angle);
|
|
holes.Add(
|
|
shape.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
|
|
);
|
|
}
|
|
|
|
Polygon? inflated = null;
|
|
var inflatedHoles = new List<Polygon>();
|
|
if (spacing > Tolerance.Epsilon)
|
|
{
|
|
// Conservative (circumscribed, padded) region offset: a superset of the
|
|
// validator's inflation, so accepted clearances never fall short. The
|
|
// offset commutes with rotation, so inflate the unrotated profile once and
|
|
// rotate the result into this orientation's frame - an unrotated inflation
|
|
// would test the candidate against the material of a different angle.
|
|
var region = ClipperBridge.Offset(model.Profile, spacing, 0.02, circumscribe: true);
|
|
var outer = region.LargestOuter();
|
|
if (outer != null)
|
|
{
|
|
outer.Rotate(angle);
|
|
outer.UpdateBounds();
|
|
inflated = outer;
|
|
}
|
|
foreach (var hole in region.Holes)
|
|
if (hole != null)
|
|
{
|
|
hole.Rotate(angle);
|
|
hole.UpdateBounds();
|
|
inflatedHoles.Add(hole);
|
|
}
|
|
}
|
|
|
|
var result = new OrientationModel(angle, perimeter, holes, inflated, inflatedHoles, spacing);
|
|
orientations[key] = result;
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Legal orientations for a requirement: exactly the policy angles when the policy
|
|
/// enumerates them, otherwise 0/90/180/270 degrees plus the minimum-area bounding
|
|
/// rectangle angle (rotating-calipers), with 180-degree equivalents included.
|
|
/// </summary>
|
|
public static List<double> CandidateAngles(PartModel model)
|
|
{
|
|
var angles = new List<double>();
|
|
var policy = model.Rotation;
|
|
if (policy.Kind == RotationPolicyKind.Automatic)
|
|
{
|
|
angles.Add(0);
|
|
angles.Add(Math.PI / 2);
|
|
angles.Add(Math.PI);
|
|
angles.Add(3 * Math.PI / 2);
|
|
try
|
|
{
|
|
var hull = ConvexHull.Compute(
|
|
model
|
|
.PerimeterShape
|
|
.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
|
|
.Vertices
|
|
);
|
|
var obb = RotatingCalipers.MinimumBoundingRectangle(hull);
|
|
var normalized = OpenNest.Math.Angle.NormalizeRad(obb.Angle);
|
|
if (normalized > 0.001 && normalized < Math.PI - 0.001)
|
|
{
|
|
angles.Add(normalized);
|
|
angles.Add(OpenNest.Math.Angle.NormalizeRad(normalized + Math.PI));
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// A calipers failure only costs candidate angles, never correctness.
|
|
}
|
|
}
|
|
else if (policy.Kind == RotationPolicyKind.Fixed)
|
|
{
|
|
angles.Add(policy.Start);
|
|
if (policy.Allow180Equivalent)
|
|
angles.Add(policy.Start + Math.PI);
|
|
}
|
|
else
|
|
{
|
|
// BoundedSweep: enumerate the exact step grid the policy allows.
|
|
var count = (int)Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
|
|
if (count < 0)
|
|
count = 0;
|
|
if (count > 4000)
|
|
count = 4000;
|
|
for (var i = 0; i <= count; i++)
|
|
{
|
|
angles.Add(policy.Start + i * policy.Step);
|
|
if (policy.Allow180Equivalent)
|
|
angles.Add(policy.Start + i * policy.Step + Math.PI);
|
|
}
|
|
}
|
|
|
|
// Normalize to [0, 2pi), deduplicate, preserve first-seen order (deterministic).
|
|
var unique = new List<double>();
|
|
foreach (var angle in angles)
|
|
{
|
|
var normalized = OpenNest.Math.Angle.NormalizeRad(angle);
|
|
if (normalized < 0)
|
|
normalized += 2 * Math.PI;
|
|
var duplicate = false;
|
|
foreach (var existing in unique)
|
|
if (Math.Abs(SignedDelta(existing, normalized)) < 1e-9)
|
|
{
|
|
duplicate = true;
|
|
break;
|
|
}
|
|
if (!duplicate)
|
|
unique.Add(normalized);
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
private static double SignedDelta(double a, double b)
|
|
{
|
|
var delta = (a - b) % (2 * Math.PI);
|
|
if (delta > Math.PI)
|
|
delta -= 2 * Math.PI;
|
|
if (delta < -Math.PI)
|
|
delta += 2 * Math.PI;
|
|
return delta;
|
|
}
|
|
}
|