Opus55 ignored NestJobPart.Priority, so the shared contract test (lower number wins scarce stock) failed; lower-number priority now precedes its placement score. SheetEconomics is replaced by the host's NestJobCost so it optimizes exactly what the benchmark scores, and its footprint margin comes from NestTolerances.SafeClearanceMargin plus four Clipper grid units - the same 0.003 total as before, which keeps its contact points. Synthetic benchmark (5 jobs, salvage 0.5): all valid, cost unchanged at 5452.79, time 611 -> 456 ms. Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
6.2 KiB
C#
157 lines
6.2 KiB
C#
using Clipper2Lib;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Opus55;
|
|
|
|
/// <summary>
|
|
/// One allowed pose of a part type: its rotation, its polygonized outline at that rotation
|
|
/// (reference point = snapshot origin), and the outline's conservative bounds.
|
|
/// </summary>
|
|
internal sealed class Orientation
|
|
{
|
|
public required int TypeIndex { get; init; }
|
|
public required int Index { get; init; }
|
|
public required double Rotation { get; init; }
|
|
|
|
/// <summary>CCW outline whose every point lies within <see cref="Tolerance"/> of the true perimeter.</summary>
|
|
public required PathD Outline { get; init; }
|
|
|
|
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
|
|
public required double Tolerance { get; init; }
|
|
|
|
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
|
|
public required double MinX { get; init; }
|
|
public required double MinY { get; init; }
|
|
public required double MaxX { get; init; }
|
|
public required double MaxY { get; init; }
|
|
|
|
public double Width => MaxX - MinX;
|
|
public double Height => MaxY - MinY;
|
|
}
|
|
|
|
internal sealed class PartType
|
|
{
|
|
public required int Index { get; init; }
|
|
public required NestJobPart Part { get; init; }
|
|
public required double Area { get; init; }
|
|
public required IReadOnlyList<Orientation> Orientations { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts job snapshots into the polygon world the packer works in. Parts whose geometry
|
|
/// cannot be read are kept with no orientations, so they surface as unplaced instead of
|
|
/// failing the whole job.
|
|
/// </summary>
|
|
internal static class PartCatalog
|
|
{
|
|
/// <summary>Finest chord deviation of the working outline from true arcs, in job units.</summary>
|
|
public const double ChordTolerance = 0.002;
|
|
|
|
/// <summary>Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m).</summary>
|
|
private const int TargetVertices = 64;
|
|
|
|
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
|
|
private const int MaxOrientations = 8;
|
|
|
|
public static IReadOnlyList<PartType> Build(NestJob job)
|
|
{
|
|
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
|
|
// pair costs a feasible-region update per placement.
|
|
var perType = System.Math.Clamp(48 / System.Math.Max(1, job.Parts.Count), 2, MaxOrientations);
|
|
var types = new List<PartType>(job.Parts.Count);
|
|
for (var index = 0; index < job.Parts.Count; index++)
|
|
{
|
|
var part = job.Parts[index];
|
|
Shape? perimeter;
|
|
try
|
|
{
|
|
perimeter = ReadPerimeter(part.Geometry);
|
|
}
|
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException)
|
|
{
|
|
perimeter = null;
|
|
}
|
|
|
|
if (perimeter == null)
|
|
{
|
|
types.Add(new PartType { Index = index, Part = part, Area = 0, Orientations = [] });
|
|
continue;
|
|
}
|
|
|
|
var angles = RotationCandidates.DistinctOutlines(perimeter,
|
|
CandidateAngles(part.Rotation, perimeter, perType));
|
|
var tolerance = ChooseTolerance(perimeter);
|
|
var orientations = new List<Orientation>();
|
|
foreach (var angle in angles)
|
|
{
|
|
var outline = Polygonize(perimeter, angle, tolerance);
|
|
if (outline.Count < 3)
|
|
continue;
|
|
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
|
|
}
|
|
|
|
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
|
|
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
|
|
}
|
|
return types;
|
|
}
|
|
|
|
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
|
|
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
|
|
|
/// <summary>
|
|
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small
|
|
/// enough for cheap Minkowski sums. Lines are always exact, so only arc-heavy parts pay.
|
|
/// </summary>
|
|
private static double ChooseTolerance(Shape perimeter)
|
|
{
|
|
var box = perimeter.BoundingBox;
|
|
var cap = System.Math.Max(ChordTolerance, 0.001 * System.Math.Max(box.Width, box.Length));
|
|
var tolerance = ChordTolerance;
|
|
while (tolerance * 2 <= cap && perimeter.ToPolygonWithTolerance(tolerance).Vertices.Count > TargetVertices)
|
|
tolerance *= 2;
|
|
return tolerance;
|
|
}
|
|
|
|
private static PathD Polygonize(Shape perimeter, double angle, double tolerance)
|
|
{
|
|
var shape = (Shape)perimeter.Clone();
|
|
if (angle != 0)
|
|
shape.Rotate(angle);
|
|
var polygon = shape.ToPolygonWithTolerance(tolerance);
|
|
var path = new PathD(polygon.Vertices.Count);
|
|
foreach (var v in polygon.Vertices)
|
|
{
|
|
if (path.Count > 0 && System.Math.Abs(path[^1].x - v.X) < 1e-9 && System.Math.Abs(path[^1].y - v.Y) < 1e-9)
|
|
continue;
|
|
path.Add(new PointD(v.X, v.Y));
|
|
}
|
|
if (path.Count > 1 && System.Math.Abs(path[0].x - path[^1].x) < 1e-9 && System.Math.Abs(path[0].y - path[^1].y) < 1e-9)
|
|
path.RemoveAt(path.Count - 1);
|
|
if (!Clipper.IsPositive(path))
|
|
path.Reverse();
|
|
return path;
|
|
}
|
|
|
|
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
|
|
{
|
|
var bounds = Clipper.GetBounds(outline);
|
|
return new Orientation
|
|
{
|
|
TypeIndex = typeIndex,
|
|
Index = index,
|
|
Rotation = angle,
|
|
Outline = outline,
|
|
Tolerance = tolerance,
|
|
MinX = bounds.left - tolerance,
|
|
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
|
|
MaxX = bounds.right + tolerance,
|
|
MaxY = bounds.bottom + tolerance,
|
|
};
|
|
}
|
|
|
|
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) =>
|
|
RotationCandidates.ForShape(policy, perimeter, limit).ToList();
|
|
}
|