This repository has been archived on 2026-09-29. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
OpenNest-Engines/OpenNest.Engine.Rectangles/BoxCatalog.cs
T
aj 13478f5102 feat(rectangles): add rectangle-lane maximal-rectangles engine
Nests every part as the axis-aligned box of its material at its minimum-area
rotation and packs boxes with a maximal-rectangles free list (6 placement rules x
global/ordered pick). Sheet choice uses the salvage-credited look-ahead cost.

Curved extremes read the way the layout check sees them (circumscribed arcs on
the Clipper grid), so discs, rings and obrounds stay valid at box contact; an
80-job sweep failed 16-50 jobs before that rule.

Local 2026 production lanes (--min-salvage-dimension 12, --parallel 3):
- 91 all-rectangular jobs: 91/91 valid, cost 333081 vs best general 332153
  (Gpt6Astra, 89/91 valid), 5.9 s vs 13.7 s (Opus55) and 169.5 s (Gpt6Astra)
- 77 box-filling (>= 90%) jobs: 77/77 valid, lowest total cost 641637
2026-09-29 20:49:00 -04:00

137 lines
6.4 KiB
C#

using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Rectangles;
/// <summary>One allowed rotation of a part, reduced to its analytic material bounding box.</summary>
/// <param name="Angle">Rotation in radians about the snapshot origin.</param>
/// <param name="Width">Material X extent after rotation.</param>
/// <param name="Height">Material Y extent after rotation.</param>
/// <param name="OffsetX">Rotated material bounds' left edge relative to the snapshot origin.</param>
/// <param name="OffsetY">Rotated material bounds' bottom edge relative to the snapshot origin.</param>
internal sealed record BoxOrientation(double Angle, double Width, double Height, double OffsetX, double OffsetY);
/// <summary>A requested part type: every instance shares the same orientations.</summary>
internal sealed record BoxType(
int Index,
NestJobPart Part,
IReadOnlyList<BoxOrientation> Orientations,
double MaterialArea)
{
public string Id => Part.Id;
public int Priority => Part.Priority;
/// <summary>Smallest bounding-box area over the allowed orientations.</summary>
public double BoxArea => Orientations.Count == 0 ? 0 : Orientations.Min(o => o.Width * o.Height);
/// <summary>Shortest side over all orientations; free space narrower than this is useless.</summary>
public double MinSide => Orientations.Count == 0 ? double.MaxValue
: Orientations.Min(o => System.Math.Min(o.Width, o.Height));
}
/// <summary>
/// Reduces every requested part to the axis-aligned boxes of its useful rotations. Only material
/// contours count (rapids and scribe/etch marks are excluded), exactly as the layout check's
/// bounds test does. Orientations are the host rotation candidates whose box area is within a
/// hair of the minimum (the minimum-area bounding rectangle plus its right-angle turn), with
/// duplicate box shapes removed. Unreadable geometry yields a type with no orientations.
/// </summary>
internal static class BoxCatalog
{
private const double AreaTieRelative = 1e-6;
private const double DimensionTie = 1e-7;
public static IReadOnlyList<BoxType> Build(NestJob job)
{
var types = new List<BoxType>(job.Parts.Count);
for (var i = 0; i < job.Parts.Count; i++)
types.Add(Read(i, job.Parts[i]));
return types;
}
private static BoxType Read(int index, NestJobPart part)
{
var geometry = JobPartGeometry.TryRead(part.Geometry);
if (geometry == null)
return new BoxType(index, part, Array.Empty<BoxOrientation>(), 0);
var candidates = new List<BoxOrientation>();
foreach (var angle in RotationCandidates.ForShape(part.Rotation, geometry.Perimeter))
{
var bounds = RotatedMaterialBounds(part.Geometry, angle);
if (bounds is not { } b || !(b.Width > 0) || !(b.Height > 0))
continue;
candidates.Add(new BoxOrientation(angle, b.Width, b.Height, b.Left, b.Bottom));
}
if (candidates.Count == 0)
return new BoxType(index, part, Array.Empty<BoxOrientation>(), geometry.MaterialArea);
var minArea = candidates.Min(c => c.Width * c.Height);
var kept = new List<BoxOrientation>();
foreach (var c in candidates)
{
if (c.Width * c.Height > minArea * (1 + AreaTieRelative))
continue;
if (kept.Any(k => System.Math.Abs(k.Width - c.Width) <= DimensionTie
&& System.Math.Abs(k.Height - c.Height) <= DimensionTie))
continue;
kept.Add(c);
}
return new BoxType(index, part, kept, geometry.MaterialArea);
}
/// <summary>
/// Material bounds after rotation, as the layout check will see them. The check flattens
/// perimeter arcs circumscribed and snaps to a 1e-4 Clipper grid, so a curved extreme reads
/// slightly outside the true arc. Each side takes the larger of the analytic bound and the
/// check's own outline (ClipperBridge.OffsetForValidation at zero inflation, flattened in the
/// same local frame), and any side where the outline sticks out gets one more grid unit.
/// Straight edges are unchanged, so rectangles still pack at exactly the part spacing.
/// </summary>
private static (double Left, double Bottom, double Width, double Height)? RotatedMaterialBounds(
PartGeometrySnapshot snapshot, double angle)
{
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(snapshot))
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
if (entities.Count == 0)
return null;
foreach (var entity in entities)
entity.Rotate(angle);
var left = entities.Min(e => e.Left);
var bottom = entities.Min(e => e.Bottom);
var right = entities.Max(e => e.Right);
var top = entities.Max(e => e.Top);
if (!double.IsFinite(left) || !double.IsFinite(bottom) || !double.IsFinite(right) || !double.IsFinite(top))
return null;
var profile = new ShapeProfile(entities);
var outline = profile.Perimeter == null ? null
: ClipperBridge.OffsetForValidation(profile, 0, NestTolerances.ValidationOutline).LargestOuter();
if (outline != null && outline.Vertices.Count >= 3)
{
left = Widen(left, outline.Vertices.Min(v => v.X), -1);
bottom = Widen(bottom, outline.Vertices.Min(v => v.Y), -1);
right = Widen(right, outline.Vertices.Max(v => v.X), +1);
top = Widen(top, outline.Vertices.Max(v => v.Y), +1);
}
return (left, bottom, right - left, top - bottom);
}
/// <summary>
/// Pushes a side out to the check's outline plus one grid unit when the outline sticks out by
/// more than a quarter of the spacing slack. Smaller differences are grid rounding (at most half
/// a unit per vertex) or negligible bulge: two facing sides then lose under 0.00035 in total,
/// inside NestTolerances.SpacingSlack, and ignoring them keeps rotated rectangles exact.
/// </summary>
private static double Widen(double analytic, double outline, int direction)
{
var grid = System.Math.Pow(10, -NestTolerances.ClipperPrecision);
var beyond = (outline - analytic) * direction;
return beyond > NestTolerances.SpacingSlack / 4 ? outline + direction * grid : analytic;
}
}