mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-12 13:41:09 -05:00
feat(engine): drop a sheet by packing edge parts first and turning the key part by its contacts
The Irregular engine grows each sheet from one side, so parts that fit only when the outer ones hug opposite edges never met: ten one-off brackets (four tapered 112-inch bars, four gussets, two small plates) stacked three bars on one sheet and took three 60 x 120 sheets where two hold them. After the search, a sheet-reduction pass splits a complete single-stock plan over one sheet fewer and packs each sheet edge first: large parts to the corners (flush, most outline on the edges, thinnest across the sheet), small parts into the gaps last, and one key part into the pocket between. Clamped sides move in past the keep-outs so edge parts line up on one straight edge. When no sampled orientation fits the key, ContactFit turns it by its contacts (each overlap a linear constraint on dx, dy and rotation) instead of sampling more angles; the exact no-fit free region then confirms the angle, so spacing and validation are unchanged. That job now nests on two sheets, the middle bar at 4.4 degrees; the repository benchmark jobs are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1430bf5ba
commit
863ce4afde
6 files changed
+913
-1
No files matched your search
@@ -0,0 +1,106 @@
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularEdgeFirstTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
|
||||
new Dictionary<int, IReadOnlyList<PairPose>>();
|
||||
|
||||
private static EdgeFirstFill Packer(IReadOnlyList<global::OpenNest.Engine.NestingEngines.Irregular.PartType> types, NestPlateStock stock)
|
||||
{
|
||||
var nfps = new NoFitCache(stock.PartSpacing);
|
||||
var counter = new WorkCounter();
|
||||
var next = 1_000_000;
|
||||
return new EdgeFirstFill(types, nfps, stock, null, counter,
|
||||
() => new FrontierPacker(types, nfps, NoPairs, stock, PackAxis.X, 1, counter),
|
||||
(type, angle) => PartCatalog.CreateOrientation(type, next++, angle));
|
||||
}
|
||||
|
||||
private static NestJobResult Result(NestJob job, IReadOnlyList<global::OpenNest.Engine.NestingEngines.Irregular.PartType> types, SheetFill fill)
|
||||
{
|
||||
var builder = new NestJobResultBuilder(job);
|
||||
builder.AddSheet(fill.Stock, fill.Parts.Select(p => (types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
|
||||
return builder.Build(NestJobStopReason.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactsTurnAPartThatNoRightAngleFits()
|
||||
{
|
||||
// 61 long on a 60-long sheet: only a turn off the axes fits it, and the catalog has none.
|
||||
var stock = Stock("sheet", 20, 60, spacing: 0.25);
|
||||
var job = Job(new[] { Part("bar", Rectangle(61, 2), 1, RotationPolicy.Automatic) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
Assert.DoesNotContain(types[0].Orientations, o => o.Width <= 60 && o.Height <= 20);
|
||||
|
||||
var fill = Packer(types, stock).Fill(new[] { 1 }, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(fill);
|
||||
var rotation = Assert.Single(fill!.Parts).Orientation.Rotation;
|
||||
Assert.NotEqual(0, System.Math.IEEERemainder(rotation, System.Math.PI / 2), 3);
|
||||
LayoutAssert.Valid(job, Result(job, types, fill));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALockedRotationIsNeverTurned()
|
||||
{
|
||||
var stock = Stock("sheet", 20, 60, spacing: 0.25);
|
||||
var job = Job(new[] { Part("bar", Rectangle(61, 2), 1, RotationPolicy.Fixed(0)) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
|
||||
Assert.Null(Packer(types, stock).Fill(new[] { 1 }, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgePartsTakeOppositeCornersAndTheKeyFillsBetween()
|
||||
{
|
||||
var stock = Stock("sheet", 20, 60, spacing: 0.25);
|
||||
var job = Job(new[]
|
||||
{
|
||||
Part("a", Rectangle(20, 8), 1, RotationPolicy.Automatic),
|
||||
Part("b", Rectangle(20, 8), 1, RotationPolicy.Automatic),
|
||||
Part("key", Rectangle(19, 19), 1, RotationPolicy.Automatic),
|
||||
}, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
|
||||
var fill = Packer(types, stock).Fill(new[] { 1, 1, 1 }, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(fill);
|
||||
Assert.Equal(3, fill!.Parts.Count);
|
||||
LayoutAssert.Valid(job, Result(job, types, fill));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReductionMergesTwoSheetsThatFitOne()
|
||||
{
|
||||
var stock = Stock("sheet", 20, 60, spacing: 0.25);
|
||||
var job = Job(new[] { Part("box", Rectangle(10, 10), 2, RotationPolicy.Automatic) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
var pose = types[0].Orientations[0];
|
||||
var sheet = new SheetFill(stock, new[] { new Placed(pose, -pose.MinX, -pose.MinY) }, types[0].Area);
|
||||
|
||||
var result = new IrregularNestingEngine().ReduceSheetsForTesting(job, new[] { sheet, sheet });
|
||||
|
||||
Assert.Single(result.Plates);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReductionKeepsAPlanItCannotShorten()
|
||||
{
|
||||
var stock = Stock("sheet", 20, 60, spacing: 0.25);
|
||||
var job = Job(new[] { Part("box", Rectangle(50, 15), 2, RotationPolicy.Automatic) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
var pose = types[0].Orientations[0];
|
||||
var sheet = new SheetFill(stock, new[] { new Placed(pose, -pose.MinX, -pose.MinY) }, types[0].Area);
|
||||
|
||||
var result = new IrregularNestingEngine().ReduceSheetsForTesting(job, new[] { sheet, sheet });
|
||||
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ internal sealed class ClampObstacles
|
||||
|
||||
public int Count => rectangles.Length;
|
||||
|
||||
/// <summary>The keep-out rectangle, clearance included: a part's outline must stay outside it.</summary>
|
||||
public PathD Outline(int index) => rectangles[index].Outline;
|
||||
|
||||
// No additional physical spacing. NoFitCache retains the established arc/grid safety
|
||||
// margin and covered-hole certification for the moving orientation and rectangle.
|
||||
public Nfp Get(int index, Orientation moving) => nfps.Get(rectangles[index], moving);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Finds a free rotation for a part that fits no sampled orientation, from the contacts it makes
|
||||
/// rather than from more sampled angles.
|
||||
///
|
||||
/// A part on a sheet moves three ways: x, y and rotation. Its pose starts at a catalog
|
||||
/// orientation with its center on the largest free pocket. Each pass collects its contacts:
|
||||
/// every footprint vertex inside or near a neighbor's footprint, every neighbor vertex inside or
|
||||
/// near its own, and every outline vertex at or past the work area. A contact is a linear
|
||||
/// constraint on the small move (dx, dy, d-theta) that clears it with its normal, and nearby
|
||||
/// clear contacts keep the move from opening new overlaps. Cyclic projection finds the least move
|
||||
/// meeting them all; the pose takes it (at most one unit) and the pass repeats until nothing
|
||||
/// overlaps. Three contacts then hold the part, so its angle is set by the neighbors around it.
|
||||
///
|
||||
/// The solver only proposes a pose. The caller checks it on the exact free region of an
|
||||
/// orientation flattened at that angle, the same no-fit polygons every other placement uses,
|
||||
/// so spacing and validation are unchanged. Deterministic: no clocks or randomness.
|
||||
/// </summary>
|
||||
internal static class ContactFit
|
||||
{
|
||||
/// <summary>Most passes from one starting pose.</summary>
|
||||
private const int MaxPasses = 400;
|
||||
|
||||
/// <summary>Projection sweeps over the contacts of one pass.</summary>
|
||||
private const int Sweeps = 60;
|
||||
|
||||
/// <summary>Clear contacts nearer than this still constrain the move.</summary>
|
||||
private const double Margin = 0.3;
|
||||
|
||||
/// <summary>Penetration still counted as touching.</summary>
|
||||
private const double Touch = 1e-4;
|
||||
|
||||
/// <summary>Passes without a smaller worst overlap before the move is halved.</summary>
|
||||
private const int StallPasses = 8;
|
||||
|
||||
/// <summary>Shortest move cap before the solve gives up.</summary>
|
||||
private const double MinStep = 1e-3;
|
||||
|
||||
/// <summary>Worst overlap, as a share of the part's half size, of a pose still handed back
|
||||
/// for the exact check.</summary>
|
||||
private const double NearlyClear = 0.01;
|
||||
|
||||
/// <summary>Largest move of one pass, in job units (rotation measured at the part's half size).</summary>
|
||||
private const double MaxStep = 1.0;
|
||||
|
||||
/// <summary>An obstacle polygon and whether the moving footprint (true) or bare outline
|
||||
/// (false) must clear it: placed parts are footprints, clamp keep-outs already hold their
|
||||
/// clearance.</summary>
|
||||
internal readonly record struct Obstacle(PathD Polygon, bool AgainstFootprint);
|
||||
|
||||
/// <summary>A solved pose: the absolute rotation and the translation of the snapshot origin.</summary>
|
||||
internal readonly record struct Pose(double Rotation, double X, double Y);
|
||||
|
||||
/// <summary>
|
||||
/// Solves from <paramref name="start"/> at the given center; <paramref name="footprint"/>
|
||||
/// belongs to <paramref name="start"/>. Returns the clear pose, else the least-overlapping one
|
||||
/// when its worst overlap is under one percent of the part's half size, else null.
|
||||
/// </summary>
|
||||
public static Pose? Solve(
|
||||
Orientation start,
|
||||
PathD footprint,
|
||||
IReadOnlyList<Obstacle> obstacles,
|
||||
Box work,
|
||||
double centerX,
|
||||
double centerY,
|
||||
WorkCounter counter)
|
||||
{
|
||||
var bounds = Clipper.GetBounds(start.Outline);
|
||||
var pivotX = (bounds.left + bounds.right) / 2;
|
||||
var pivotY = (bounds.top + bounds.bottom) / 2;
|
||||
var radius = System.Math.Max(1e-6, System.Math.Max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 2);
|
||||
var foot = Local(footprint, pivotX, pivotY);
|
||||
var line = Local(start.Outline, pivotX, pivotY);
|
||||
var near = obstacles.Select(o => (o.Polygon, o.AgainstFootprint, Bounds: Clipper.GetBounds(o.Polygon))).ToArray();
|
||||
|
||||
double theta = 0, x = centerX, y = centerY;
|
||||
Pose At() => new(start.Rotation + theta,
|
||||
x - (System.Math.Cos(theta) * pivotX - System.Math.Sin(theta) * pivotY),
|
||||
y - (System.Math.Sin(theta) * pivotX + System.Math.Cos(theta) * pivotY));
|
||||
Pose? best = null;
|
||||
var bestWorst = double.PositiveInfinity;
|
||||
var cap = MaxStep;
|
||||
var stalled = 0;
|
||||
var constraints = new List<(double Gx, double Gy, double Gt, double Depth)>();
|
||||
for (var pass = 0; pass < MaxPasses; pass++)
|
||||
{
|
||||
var movingFoot = Place(foot, theta, x, y);
|
||||
var movingLine = Place(line, theta, x, y);
|
||||
constraints.Clear();
|
||||
void Add(PointD at, double nx, double ny, double depth)
|
||||
{
|
||||
var rx = at.x - x;
|
||||
var ry = at.y - y;
|
||||
constraints.Add((nx, ny, (rx * ny - ry * nx) / radius, depth));
|
||||
}
|
||||
|
||||
var footBounds = Clipper.GetBounds(movingFoot);
|
||||
var lineBounds = Clipper.GetBounds(movingLine);
|
||||
foreach (var (polygon, againstFoot, box) in near)
|
||||
{
|
||||
var moving = againstFoot ? movingFoot : movingLine;
|
||||
var movingBox = againstFoot ? footBounds : lineBounds;
|
||||
if (box.left > movingBox.right + Margin || box.right < movingBox.left - Margin
|
||||
|| box.top > movingBox.bottom + Margin || box.bottom < movingBox.top - Margin)
|
||||
continue;
|
||||
foreach (var q in moving)
|
||||
{
|
||||
if (q.x < box.left - Margin || q.x > box.right + Margin || q.y < box.top - Margin || q.y > box.bottom + Margin)
|
||||
continue;
|
||||
var (distance, nx, ny) = Nearest(polygon, q);
|
||||
if (Clipper.PointInPolygon(q, polygon) == PointInPolygonResult.IsInside)
|
||||
Add(q, nx, ny, distance);
|
||||
else if (distance < Margin)
|
||||
Add(q, -nx, -ny, -distance);
|
||||
}
|
||||
foreach (var w in polygon)
|
||||
{
|
||||
if (w.x < movingBox.left - Margin || w.x > movingBox.right + Margin
|
||||
|| w.y < movingBox.top - Margin || w.y > movingBox.bottom + Margin)
|
||||
continue;
|
||||
var (distance, nx, ny) = Nearest(moving, w);
|
||||
if (Clipper.PointInPolygon(w, moving) == PointInPolygonResult.IsInside)
|
||||
Add(w, -nx, -ny, distance);
|
||||
else if (distance < Margin)
|
||||
Add(w, nx, ny, -distance);
|
||||
}
|
||||
counter.Add(1);
|
||||
}
|
||||
foreach (var v in movingLine)
|
||||
{
|
||||
if (v.x - work.Left < Margin)
|
||||
Add(v, 1, 0, work.Left - v.x);
|
||||
if (work.Right - v.x < Margin)
|
||||
Add(v, -1, 0, v.x - work.Right);
|
||||
if (v.y - work.Bottom < Margin)
|
||||
Add(v, 0, 1, work.Bottom - v.y);
|
||||
if (work.Top - v.y < Margin)
|
||||
Add(v, 0, -1, v.y - work.Top);
|
||||
}
|
||||
|
||||
var worst = constraints.Count == 0 ? 0 : constraints.Max(c => c.Depth);
|
||||
if (worst <= Touch)
|
||||
return At();
|
||||
if (worst < bestWorst - Touch)
|
||||
{
|
||||
best = At();
|
||||
bestWorst = worst;
|
||||
stalled = 0;
|
||||
}
|
||||
else if (++stalled >= StallPasses)
|
||||
{
|
||||
// Linearized contacts overshoot near the end; settle with shorter moves.
|
||||
cap /= 2;
|
||||
stalled = 0;
|
||||
if (cap < MinStep)
|
||||
break;
|
||||
}
|
||||
|
||||
double dx = 0, dy = 0, du = 0;
|
||||
for (var sweep = 0; sweep < Sweeps; sweep++)
|
||||
foreach (var (gx, gy, gt, depth) in constraints)
|
||||
{
|
||||
var need = depth + Touch;
|
||||
var have = gx * dx + gy * dy + gt * du;
|
||||
if (have >= need)
|
||||
continue;
|
||||
var scale = (need - have) / (gx * gx + gy * gy + gt * gt);
|
||||
dx += scale * gx;
|
||||
dy += scale * gy;
|
||||
du += scale * gt;
|
||||
}
|
||||
var step = System.Math.Sqrt(dx * dx + dy * dy + du * du);
|
||||
if (step < 1e-9)
|
||||
break; // The contacts pull against each other: no move clears them.
|
||||
if (step > cap)
|
||||
{
|
||||
dx *= cap / step;
|
||||
dy *= cap / step;
|
||||
du *= cap / step;
|
||||
}
|
||||
x += dx;
|
||||
y += dy;
|
||||
theta += du / radius;
|
||||
}
|
||||
// Nearly clear: the angle is what the caller needs, and its exact free region finds the
|
||||
// position, so a pose still overlapping slightly is worth checking.
|
||||
return bestWorst <= NearlyClear * radius ? best : null;
|
||||
}
|
||||
|
||||
private static PathD Local(PathD path, double pivotX, double pivotY)
|
||||
{
|
||||
var result = new PathD(path.Count);
|
||||
foreach (var p in path)
|
||||
result.Add(new PointD(p.x - pivotX, p.y - pivotY));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PathD Place(PathD local, double theta, double x, double y)
|
||||
{
|
||||
var cos = System.Math.Cos(theta);
|
||||
var sin = System.Math.Sin(theta);
|
||||
var result = new PathD(local.Count);
|
||||
foreach (var p in local)
|
||||
result.Add(new PointD(cos * p.x - sin * p.y + x, sin * p.x + cos * p.y + y));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Distance from <paramref name="q"/> to the nearest edge of a CCW polygon, with
|
||||
/// that edge's outward normal.</summary>
|
||||
private static (double Distance, double Nx, double Ny) Nearest(PathD polygon, PointD q)
|
||||
{
|
||||
double best = double.PositiveInfinity, bestNx = 0, bestNy = 0;
|
||||
for (var i = 0; i < polygon.Count; i++)
|
||||
{
|
||||
var a = polygon[i];
|
||||
var b = polygon[(i + 1) % polygon.Count];
|
||||
var ex = b.x - a.x;
|
||||
var ey = b.y - a.y;
|
||||
var lengthSquared = ex * ex + ey * ey;
|
||||
if (lengthSquared < 1e-18)
|
||||
continue;
|
||||
var t = System.Math.Clamp(((q.x - a.x) * ex + (q.y - a.y) * ey) / lengthSquared, 0, 1);
|
||||
var px = a.x + t * ex - q.x;
|
||||
var py = a.y + t * ey - q.y;
|
||||
var distance = System.Math.Sqrt(px * px + py * py);
|
||||
if (distance >= best)
|
||||
continue;
|
||||
var length = System.Math.Sqrt(lengthSquared);
|
||||
best = distance;
|
||||
bestNx = ey / length;
|
||||
bestNy = -ex / length;
|
||||
}
|
||||
return (best, bestNx, bestNy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Packs one sheet's given parts edge first: the large parts go to the sheet's corners and edges,
|
||||
/// one large part (the key) fits the pocket they leave in the middle, and the small parts fill the
|
||||
/// gaps last. All or nothing: returns null unless every part is placed.
|
||||
///
|
||||
/// The frontier packer grows a layout from one side, so parts that only fit together when the
|
||||
/// outer ones hug opposite edges never meet. Here each edge part takes the corner where it sits
|
||||
/// flush, preferring the pose with the most outline on the sheet boundary and then the corner
|
||||
/// farthest from the parts already placed, so the free room collects in the middle. The key part
|
||||
/// takes any sampled orientation that fits there; failing that, <see cref="ContactFit"/> turns it
|
||||
/// against its neighbors to an angle no catalog holds. Each large part is tried as the key in
|
||||
/// turn, largest first.
|
||||
///
|
||||
/// Every position comes from an exact free region (inner-fit rectangle less the no-fit polygons
|
||||
/// of clamps and placed parts), as in <see cref="FrontierPacker"/>. Deterministic.
|
||||
/// </summary>
|
||||
internal sealed class EdgeFirstFill(
|
||||
IReadOnlyList<PartType> types,
|
||||
NoFitCache nfps,
|
||||
NestPlateStock stock,
|
||||
ClampObstacles? clamps,
|
||||
WorkCounter counter,
|
||||
Func<FrontierPacker> fillers,
|
||||
Func<PartType, double, Orientation?> orient)
|
||||
{
|
||||
/// <summary>Parts below this share of the largest part's area fill gaps after the key.</summary>
|
||||
internal const double FillerShare = 0.15;
|
||||
|
||||
/// <summary>Corner poses tried per edge part, best first.</summary>
|
||||
internal const int Branches = 2;
|
||||
|
||||
/// <summary>Most complete edge arrangements tried per key.</summary>
|
||||
internal const int MaxLeaves = 8;
|
||||
|
||||
/// <summary>Most part types tried as the key.</summary>
|
||||
internal const int MaxKeys = 4;
|
||||
|
||||
/// <summary>Corner distances within this count as equally flush.</summary>
|
||||
private const double Flush = 0.05;
|
||||
|
||||
/// <summary>Outline edges this close to a work-area side lie on it.</summary>
|
||||
private const double OnEdge = 0.02;
|
||||
|
||||
/// <summary>Most pockets the key part is started in.</summary>
|
||||
private const int MaxPockets = 2;
|
||||
|
||||
/// <summary>Angle step, in radians (0.1 degree), of the exact checks around a solved angle.</summary>
|
||||
private const double RefineStep = System.Math.PI / 1800;
|
||||
|
||||
/// <summary>Exact checks each side of a solved angle (two degrees).</summary>
|
||||
private const int RefineSteps = 20;
|
||||
|
||||
/// <summary>Same slack as the frontier packer's inner-fit rectangle.</summary>
|
||||
private const double FitSlack = 2e-4;
|
||||
|
||||
/// <summary>
|
||||
/// The work area this fill packs: the stock's, with each side that carries clamps moved in
|
||||
/// just past their keep-outs, as a larger edge spacing would. Packing the clear rectangle
|
||||
/// above the clamps keeps parts flush with one straight edge instead of stepping between
|
||||
/// clamps. A clamp off every side stays an obstacle instead.
|
||||
/// </summary>
|
||||
private readonly Box work = ClearOfClamps(stock.WorkArea, clamps).Area;
|
||||
|
||||
/// <summary>Clamps not cleared by moving a side: obstacles inside the work area.</summary>
|
||||
private readonly ClampObstacles? obstacles = ClearOfClamps(stock.WorkArea, clamps).OnEdges ? null : clamps;
|
||||
|
||||
private static (Box Area, bool OnEdges) ClearOfClamps(Box area, ClampObstacles? clamps)
|
||||
{
|
||||
var allOnEdges = true;
|
||||
double left = area.Left, right = area.Right, bottom = area.Bottom, top = area.Top;
|
||||
for (var i = 0; clamps != null && i < clamps.Count; i++)
|
||||
{
|
||||
var box = Clipper.GetBounds(clamps.Outline(i));
|
||||
if (box.right <= area.Left || box.left >= area.Right || box.bottom <= area.Bottom || box.top >= area.Top)
|
||||
continue; // Outside the work area: nothing to clear.
|
||||
if (box.left <= area.Left + OnEdge)
|
||||
left = System.Math.Max(left, box.right);
|
||||
else if (box.right >= area.Right - OnEdge)
|
||||
right = System.Math.Min(right, box.left);
|
||||
else if (box.top <= area.Bottom + OnEdge) // Clipper RectD: top is the minimum Y.
|
||||
bottom = System.Math.Max(bottom, box.bottom);
|
||||
else if (box.bottom >= area.Top - OnEdge)
|
||||
top = System.Math.Min(top, box.top);
|
||||
else
|
||||
allOnEdges = false;
|
||||
}
|
||||
if (!allOnEdges || right <= left || top <= bottom)
|
||||
return (area, false);
|
||||
return (new Box(left, bottom, right - left, top - bottom), true);
|
||||
}
|
||||
|
||||
public SheetFill? Fill(IReadOnlyList<int> demand, CancellationToken token)
|
||||
{
|
||||
var instances = new List<int>();
|
||||
foreach (var type in types)
|
||||
for (var i = 0; i < demand[type.Index]; i++)
|
||||
instances.Add(type.Index);
|
||||
if (instances.Count == 0 || instances.Any(t => types[t].Orientations.Count == 0))
|
||||
return null;
|
||||
var largest = instances.Max(t => types[t].Area);
|
||||
var majors = instances.Where(t => types[t].Area >= FillerShare * largest)
|
||||
.OrderByDescending(t => types[t].Area).ThenBy(t => t).ToList();
|
||||
var small = new int[types.Count];
|
||||
foreach (var t in instances.Where(t => types[t].Area < FillerShare * largest))
|
||||
small[t]++;
|
||||
var smallCount = small.Sum();
|
||||
|
||||
foreach (var key in majors.Distinct().Take(MaxKeys))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var edges = majors.ToList();
|
||||
edges.Remove(key);
|
||||
var placed = new List<Placed>();
|
||||
var leaves = 0;
|
||||
if (!Arrange(edges, 0, types[key], placed, small, smallCount, ref leaves, token))
|
||||
continue;
|
||||
return new SheetFill(stock, placed, placed.Sum(p => types[p.Orientation.TypeIndex].Area));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places the edge parts from <paramref name="next"/> on, each in one of its best
|
||||
/// <see cref="Branches"/> corner poses, depth first, then the key and the small parts; at most
|
||||
/// <see cref="MaxLeaves"/> complete edge arrangements per key. Leaves <paramref name="placed"/>
|
||||
/// holding the sheet on success.
|
||||
/// </summary>
|
||||
private bool Arrange(List<int> edges, int next, PartType key, List<Placed> placed, int[] small, int smallCount,
|
||||
ref int leaves, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (next == edges.Count)
|
||||
{
|
||||
if (leaves++ >= MaxLeaves)
|
||||
return false;
|
||||
var middle = Key(key, placed);
|
||||
if (middle == null)
|
||||
return false;
|
||||
placed.Add(middle);
|
||||
if (smallCount > 0)
|
||||
{
|
||||
var gaps = fillers().Fill(small, token, placed);
|
||||
if (gaps.Parts.Count != smallCount)
|
||||
{
|
||||
placed.RemoveAt(placed.Count - 1);
|
||||
return false;
|
||||
}
|
||||
placed.AddRange(gaps.Parts);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
foreach (var pose in Corners(types[edges[next]], placed).Take(Branches))
|
||||
{
|
||||
placed.Add(pose);
|
||||
if (Arrange(edges, next + 1, key, placed, small, smallCount, ref leaves, token))
|
||||
return true;
|
||||
placed.RemoveAt(placed.Count - 1);
|
||||
if (leaves >= MaxLeaves)
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The flush corner pose with the most outline on the sheet edges, then the one
|
||||
/// thinnest across the sheet (hugging its long edges), then the one farthest from the parts
|
||||
/// placed.</summary>
|
||||
private List<Placed> Corners(PartType type, IReadOnlyList<Placed> placed)
|
||||
{
|
||||
var ranked = new List<(Placed Pose, double Gap, double Contact, double Across, double Spread)>();
|
||||
var lengthwise = work.Length >= work.Width;
|
||||
foreach (var o in type.Orientations)
|
||||
{
|
||||
var free = Free(o, placed, out var minX, out var minY, out var maxX, out var maxY);
|
||||
if (free.Count == 0)
|
||||
continue;
|
||||
for (var corner = 0; corner < 4; corner++)
|
||||
{
|
||||
var right = corner is 1 or 2;
|
||||
var top = corner is 2 or 3;
|
||||
double cornerGap = double.PositiveInfinity, x = 0, y = 0;
|
||||
foreach (var path in free)
|
||||
foreach (var raw in path)
|
||||
{
|
||||
var px = System.Math.Clamp(raw.x, minX, maxX);
|
||||
var py = System.Math.Clamp(raw.y, minY, maxY);
|
||||
var gap = (right ? work.Right - (px + o.MaxX) : px + o.MinX - work.Left)
|
||||
+ (top ? work.Top - (py + o.MaxY) : py + o.MinY - work.Bottom);
|
||||
if (gap < cornerGap - 1e-9)
|
||||
{
|
||||
cornerGap = gap;
|
||||
x = px;
|
||||
y = py;
|
||||
}
|
||||
}
|
||||
if (double.IsPositiveInfinity(cornerGap))
|
||||
continue;
|
||||
var candidate = new Placed(o, x, y);
|
||||
if (ranked.Any(r => ReferenceEquals(r.Pose.Orientation, o) && System.Math.Abs(r.Pose.X - x) < 1e-6
|
||||
&& System.Math.Abs(r.Pose.Y - y) < 1e-6))
|
||||
continue;
|
||||
ranked.Add((candidate, System.Math.Floor(cornerGap / Flush), Contact(candidate),
|
||||
lengthwise ? o.Height : o.Width, Spread(candidate, placed)));
|
||||
}
|
||||
}
|
||||
// Flush first, then most outline on the edges, thinnest across the sheet, farthest out;
|
||||
// a stable sort keeps orientation and corner order on ties.
|
||||
return ranked
|
||||
.OrderBy(r => r.Gap)
|
||||
.ThenByDescending(r => System.Math.Round(r.Contact, 4))
|
||||
.ThenBy(r => System.Math.Round(r.Across, 4))
|
||||
.ThenByDescending(r => System.Math.Round(r.Spread, 4))
|
||||
.Select(r => r.Pose)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>The key part: a sampled orientation if one fits, else a contact-solved angle.</summary>
|
||||
private Placed? Key(PartType type, IReadOnlyList<Placed> placed)
|
||||
{
|
||||
var pockets = Pockets(placed, type.Area);
|
||||
foreach (var o in type.Orientations)
|
||||
{
|
||||
var free = Free(o, placed, out var minX, out var minY, out var maxX, out var maxY);
|
||||
if (free.Count > 0)
|
||||
return Nearest(o, free, minX, minY, maxX, maxY, pockets.FirstOrDefault());
|
||||
}
|
||||
if (type.Part.Rotation.Kind != RotationPolicyKind.Automatic || pockets.Count == 0)
|
||||
return null;
|
||||
|
||||
var contacts = new List<ContactFit.Obstacle>();
|
||||
foreach (var part in placed)
|
||||
contacts.Add(new(Clipper.TranslatePath(nfps.Footprint(part.Orientation), part.X, part.Y), true));
|
||||
for (var i = 0; obstacles != null && i < obstacles.Count; i++)
|
||||
contacts.Add(new(obstacles.Outline(i), false));
|
||||
foreach (var pocket in pockets)
|
||||
foreach (var start in Starts(type))
|
||||
{
|
||||
var pose = ContactFit.Solve(start, nfps.Footprint(start), contacts, work, pocket.x, pocket.y, counter);
|
||||
if (pose is not { } solved)
|
||||
continue;
|
||||
// The contacts bring the part to the edge of its fit; the exact free region
|
||||
// decides, from the solved angle outward until one fits.
|
||||
for (var step = 0; step <= 2 * RefineSteps; step++)
|
||||
{
|
||||
var offset = (step + 1) / 2 * RefineStep * (step % 2 == 0 ? 1 : -1);
|
||||
var angle = solved.Rotation + offset;
|
||||
if (!type.Part.Rotation.Allows(angle))
|
||||
continue;
|
||||
var o = orient(type, angle);
|
||||
if (o == null)
|
||||
continue;
|
||||
var free = Free(o, placed, out var minX, out var minY, out var maxX, out var maxY);
|
||||
if (free.Count > 0)
|
||||
return Nearest(o, free, minX, minY, maxX, maxY, null, solved.X, solved.Y);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Starting poses for the contact solve: the orientations whose box fits the work
|
||||
/// area, or all of them when none does (only a turn can fit the part then).</summary>
|
||||
private IEnumerable<Orientation> Starts(PartType type)
|
||||
{
|
||||
var fitting = type.Orientations.Where(o => o.Width <= work.Length + 1e-9 && o.Height <= work.Width + 1e-9).ToList();
|
||||
return fitting.Count > 0 ? fitting : type.Orientations;
|
||||
}
|
||||
|
||||
/// <summary>Centers of the largest free pockets that could hold <paramref name="area"/>.</summary>
|
||||
private List<PointD> Pockets(IReadOnlyList<Placed> placed, double area)
|
||||
{
|
||||
var room = new PathsD { Rectangle(work.Left, work.Bottom, work.Right, work.Top) };
|
||||
var taken = new PathsD(placed.Select(p => Clipper.TranslatePath(nfps.Footprint(p.Orientation), p.X, p.Y)));
|
||||
for (var i = 0; obstacles != null && i < obstacles.Count; i++)
|
||||
taken.Add(obstacles.Outline(i));
|
||||
room = Clipper.Difference(room, taken, FillRule.NonZero, NoFitCache.Precision);
|
||||
return room.Where(p => Clipper.IsPositive(p) && Clipper.Area(p) >= area)
|
||||
.OrderByDescending(p => Clipper.Area(p))
|
||||
.Take(MaxPockets)
|
||||
.Select(Centroid)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static PointD Centroid(PathD path)
|
||||
{
|
||||
double a = 0, cx = 0, cy = 0;
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
var p = path[i];
|
||||
var q = path[(i + 1) % path.Count];
|
||||
var cross = p.x * q.y - q.x * p.y;
|
||||
a += cross;
|
||||
cx += (p.x + q.x) * cross;
|
||||
cy += (p.y + q.y) * cross;
|
||||
}
|
||||
return System.Math.Abs(a) < 1e-12 ? path[0] : new PointD(cx / (3 * a), cy / (3 * a));
|
||||
}
|
||||
|
||||
/// <summary>The free vertex whose part center (or reference, with <paramref name="refX"/>)
|
||||
/// lies nearest the target.</summary>
|
||||
private static Placed Nearest(Orientation o, PathsD free, double minX, double minY, double maxX, double maxY,
|
||||
PointD? center, double? refX = null, double? refY = null)
|
||||
{
|
||||
Placed? best = null;
|
||||
var bestDistance = double.PositiveInfinity;
|
||||
foreach (var path in free)
|
||||
foreach (var raw in path)
|
||||
{
|
||||
var x = System.Math.Clamp(raw.x, minX, maxX);
|
||||
var y = System.Math.Clamp(raw.y, minY, maxY);
|
||||
double dx, dy;
|
||||
if (refX is double rx && refY is double ry)
|
||||
{
|
||||
dx = x - rx;
|
||||
dy = y - ry;
|
||||
}
|
||||
else if (center is { } c)
|
||||
{
|
||||
dx = x + (o.MinX + o.MaxX) / 2 - c.x;
|
||||
dy = y + (o.MinY + o.MaxY) / 2 - c.y;
|
||||
}
|
||||
else
|
||||
{
|
||||
dx = x;
|
||||
dy = y;
|
||||
}
|
||||
var distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance - 1e-12)
|
||||
{
|
||||
bestDistance = distance;
|
||||
best = new Placed(o, x, y);
|
||||
}
|
||||
}
|
||||
return best!;
|
||||
}
|
||||
|
||||
/// <summary>Length of outline lying on the work area's sides.</summary>
|
||||
private double Contact(Placed part)
|
||||
{
|
||||
var outline = part.Orientation.Outline;
|
||||
// Arc outlines keep flattening padding in their bounds, so they stop that far short.
|
||||
var near = OnEdge + part.Orientation.Tolerance;
|
||||
bool On(double a, double b, double side) => System.Math.Abs(a - side) < near && System.Math.Abs(b - side) < near;
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < outline.Count; i++)
|
||||
{
|
||||
var a = outline[i];
|
||||
var b = outline[(i + 1) % outline.Count];
|
||||
double ax = a.x + part.X, ay = a.y + part.Y, bx = b.x + part.X, by = b.y + part.Y;
|
||||
var along = On(ax, bx, work.Left) || On(ax, bx, work.Right) || On(ay, by, work.Bottom) || On(ay, by, work.Top);
|
||||
if (along)
|
||||
total += System.Math.Sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static double Spread(Placed part, IReadOnlyList<Placed> placed)
|
||||
{
|
||||
if (placed.Count == 0)
|
||||
return 0;
|
||||
var cx = (part.Left + part.Right) / 2;
|
||||
var cy = (part.Bottom + part.Top) / 2;
|
||||
return placed.Min(p =>
|
||||
{
|
||||
var dx = (p.Left + p.Right) / 2 - cx;
|
||||
var dy = (p.Bottom + p.Top) / 2 - cy;
|
||||
return System.Math.Sqrt(dx * dx + dy * dy);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Legal reference points of <paramref name="o"/>: the inner-fit rectangle less the
|
||||
/// no-fit polygons of clamps and placed parts. Empty when it does not fit.</summary>
|
||||
private PathsD Free(Orientation o, IReadOnlyList<Placed> placed,
|
||||
out double minX, out double minY, out double maxX, out double maxY)
|
||||
{
|
||||
minX = work.Left - o.MinX;
|
||||
maxX = work.Right - o.MaxX;
|
||||
minY = work.Bottom - o.MinY;
|
||||
maxY = work.Top - o.MaxY;
|
||||
if (o.Width > work.Length + 1e-9 || o.Height > work.Width + 1e-9)
|
||||
return new PathsD();
|
||||
maxX = System.Math.Max(minX, maxX);
|
||||
maxY = System.Math.Max(minY, maxY);
|
||||
var free = new PathsD { Rectangle(minX - FitSlack, minY - FitSlack, maxX + FitSlack, maxY + FitSlack) };
|
||||
for (var i = 0; obstacles != null && i < obstacles.Count && free.Count > 0; i++)
|
||||
free = Subtract(free, obstacles.Get(i, o), 0, 0);
|
||||
foreach (var part in placed)
|
||||
{
|
||||
if (free.Count == 0)
|
||||
break;
|
||||
free = Subtract(free, nfps.Get(part.Orientation, o), part.X, part.Y);
|
||||
}
|
||||
return free;
|
||||
}
|
||||
|
||||
private PathsD Subtract(PathsD free, Nfp nfp, double dx, double dy)
|
||||
{
|
||||
counter.Add(1);
|
||||
var result = Clipper.Difference(free, Clipper.TranslatePaths(nfp.Region, dx, dy), FillRule.NonZero,
|
||||
NoFitCache.Precision);
|
||||
result.RemoveAll(p => p.Count < 3);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PathD Rectangle(double left, double bottom, double right, double top) =>
|
||||
new() { new(left, bottom), new(right, bottom), new(right, top), new(left, top) };
|
||||
}
|
||||
@@ -56,6 +56,12 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
/// <summary>Whether the grouped variants run; off only in tests of the ungrouped search.</summary>
|
||||
internal bool GroupLikeParts { get; init; } = true;
|
||||
|
||||
/// <summary>Whether the sheet-reduction pass runs; off only in tests of the search before it.</summary>
|
||||
internal bool ReduceSheets { get; init; } = true;
|
||||
|
||||
/// <summary>Most parts a plan may hold for the sheet-reduction pass to try it.</summary>
|
||||
internal const int MaxReduceParts = 40;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic work budget, in free-region subtractions, after which no further variant
|
||||
/// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock.
|
||||
@@ -145,6 +151,17 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
if (!ReferenceEquals(improved, plan))
|
||||
ReportPlan(job, types, improved, progress, token);
|
||||
}
|
||||
// Then try to drop a sheet: split the parts over one sheet fewer and pack each sheet edge
|
||||
// first, the key part turned by its contacts. Replaces the result only by beating it.
|
||||
if (ReduceSheets)
|
||||
{
|
||||
var reduced = solver.ReduceSheets(improved);
|
||||
if (!ReferenceEquals(reduced, improved))
|
||||
{
|
||||
improved = reduced;
|
||||
ReportPlan(job, types, improved, progress, token);
|
||||
}
|
||||
}
|
||||
// More searching can only end in cancellation, so it needs a listener for its plans, and
|
||||
// it needs demand and two or more types, or every weighted decode repeats a variant.
|
||||
if (job.Options.SearchUntilCancelled && token.CanBeCanceled && progress != null
|
||||
@@ -222,6 +239,16 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
return BuildResult(job, types, solver.ImproveTail(plan, WorkBudget * 2), null, default);
|
||||
}
|
||||
|
||||
/// <summary>Runs the sheet-reduction pass on a test-owned complete plan.</summary>
|
||||
internal NestJobResult ReduceSheetsForTesting(NestJob job, IReadOnlyList<SheetFill> physicalPlan)
|
||||
{
|
||||
var types = PartCatalog.Build(job);
|
||||
using var solver = new Solver(job, types, null, default, WorkBudget, DrawingFor, SheetPacked, BatchSelected, TailReplaced);
|
||||
var plan = new Plan(physicalPlan, physicalPlan.Sum(s => NetCost(job.Options, s)), 0, NestJobStopReason.Completed,
|
||||
NestJobCost.UsesExplicitCosts(job), job.Options);
|
||||
return BuildResult(job, types, solver.ReduceSheets(plan), null, default);
|
||||
}
|
||||
|
||||
/// <summary>Decodes one variant with the given type weights, whole demand placeable; for
|
||||
/// tests of the weighted placement rule only.</summary>
|
||||
internal NestJobResult PlanForTesting(NestJob job, int variant, IReadOnlyList<double>? weights)
|
||||
@@ -352,6 +379,92 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops sheets while it can: the parts of a complete plan on one stock are split over
|
||||
/// one sheet fewer (by area, largest first onto the lightest sheet, then dealt in a snake)
|
||||
/// and each sheet packed by <see cref="EdgeFirstFill"/>. A split that packs every sheet
|
||||
/// replaces the plan when it beats it.
|
||||
/// </summary>
|
||||
public Plan ReduceSheets(Plan plan)
|
||||
{
|
||||
while (plan.Unplaced == 0 && plan.Sheets.Count >= 2)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var stock = plan.Sheets[0].Stock;
|
||||
if (stock.Outline != null || plan.Sheets.Any(s => !ReferenceEquals(s.Stock, stock)))
|
||||
break;
|
||||
var instances = plan.Sheets.SelectMany(s => s.Parts).Select(p => p.Orientation.TypeIndex)
|
||||
.OrderByDescending(t => types[t].Area).ThenBy(t => t).ToList();
|
||||
var target = plan.Sheets.Count - 1;
|
||||
var room = stock.WorkArea.Length * stock.WorkArea.Width;
|
||||
if (instances.Count > MaxReduceParts || instances.Sum(t => types[t].Area) > target * room)
|
||||
break;
|
||||
|
||||
Plan? found = null;
|
||||
foreach (var bins in Splits(instances, target))
|
||||
{
|
||||
var sheets = new List<SheetFill>();
|
||||
foreach (var bin in bins)
|
||||
{
|
||||
var fill = new EdgeFirstFill(types, CacheFor(stock), stock, ClampsFor(stock), Work,
|
||||
() => new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, PackAxis.X, 1.0, Work,
|
||||
null, ClampsFor(stock)),
|
||||
Orient).Fill(bin, token);
|
||||
if (fill == null)
|
||||
break;
|
||||
sheets.Add(fill);
|
||||
}
|
||||
if (sheets.Count < target)
|
||||
continue;
|
||||
var candidate = new Plan(sheets, NestJobCost.RequireFinite(sheets.Sum(s => NetCost(job.Options, s))), 0,
|
||||
NestJobStopReason.Completed, NestJobCost.UsesExplicitCosts(job), job.Options);
|
||||
if (candidate.IsBetterThan(plan))
|
||||
found = candidate;
|
||||
break;
|
||||
}
|
||||
if (found == null)
|
||||
break;
|
||||
plan = found;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>Per-sheet demands for <paramref name="count"/> sheets: largest part first onto
|
||||
/// the lightest sheet, then the same parts dealt out in a snake.</summary>
|
||||
private IEnumerable<int[][]> Splits(IReadOnlyList<int> instances, int count)
|
||||
{
|
||||
var greedy = Enumerable.Range(0, count).Select(_ => new int[types.Count]).ToArray();
|
||||
var load = new double[count];
|
||||
foreach (var t in instances)
|
||||
{
|
||||
var lightest = 0;
|
||||
for (var i = 1; i < count; i++)
|
||||
if (load[i] < load[lightest] - 1e-9)
|
||||
lightest = i;
|
||||
greedy[lightest][t]++;
|
||||
load[lightest] += types[t].Area;
|
||||
}
|
||||
yield return greedy;
|
||||
if (count < 2)
|
||||
yield break;
|
||||
var snake = Enumerable.Range(0, count).Select(_ => new int[types.Count]).ToArray();
|
||||
for (var i = 0; i < instances.Count; i++)
|
||||
{
|
||||
var round = i / count;
|
||||
var slot = i % count;
|
||||
snake[round % 2 == 0 ? slot : count - 1 - slot][instances[i]]++;
|
||||
}
|
||||
if (!snake.Zip(greedy).All(p => p.First.SequenceEqual(p.Second)))
|
||||
yield return snake;
|
||||
}
|
||||
|
||||
/// <summary>Next private orientation index: far above the catalog's, pair and block poses.</summary>
|
||||
private int solvedOrientations = 1_000_000;
|
||||
|
||||
/// <summary>A pose of <paramref name="type"/> at a solved angle, with its own NFP cache key.</summary>
|
||||
private Orientation? Orient(PartType type, double angle) =>
|
||||
PartCatalog.CreateOrientation(type, solvedOrientations++, angle);
|
||||
|
||||
private NoFitCache CacheFor(NestPlateStock stock)
|
||||
{
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing);
|
||||
|
||||
+32
-1
@@ -14,7 +14,7 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, its tests in `OpenNest.Engine.Tests
|
||||
| Default | Any job; used when no engine is named | Runs Irregular, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost, then the largest compactness offcut; remaining ties keep the engine listed first here |
|
||||
| Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost |
|
||||
| Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock and a tail re-plan |
|
||||
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan, then grouped variants that pack like parts together; under a time limit it keeps trying seeded variants until the limit |
|
||||
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan, then grouped variants that pack like parts together, then a sheet-reduction pass that packs edge parts first and turns a key part by its contacts; under a time limit it keeps trying seeded variants until the limit |
|
||||
| FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost |
|
||||
| Lattice, Strip, Vertical Remnant, Horizontal Remnant | Many copies of a few parts | The fixed placement strategies behind interactive fill; Lattice is the multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder), and the remnant strategies run it ranked to keep the largest drop. In whole jobs, all four place a plate with any fixed or bounded part rotation by the rotation-aware ordered placer instead |
|
||||
|
||||
@@ -234,6 +234,37 @@ re-plan. The search until cancelled takes grouped decodes in turn with the varia
|
||||
same seeded weights. On the help tutorial's parts (30 gussets, 20 ells, 10 plates, 12 flanges on
|
||||
60 x 120 at 0.3 spacing) the layout ends at 34.9 rather than 38.1.
|
||||
|
||||
### Sheet reduction
|
||||
|
||||
Last, Irregular tries to drop a sheet from a complete plan of at most 40 parts on one rectangular
|
||||
stock. It splits the parts over one sheet fewer (by area, largest first onto the lightest sheet,
|
||||
then the same parts dealt out in a snake) and packs each sheet edge first (`EdgeFirstFill`); a
|
||||
split that packs every sheet replaces the plan when it beats it, and the pass repeats while it
|
||||
can. The frontier packer grows a layout from one side, so parts that fit only when the outer ones
|
||||
hug opposite edges never meet there.
|
||||
|
||||
- Parts under 15% of the largest part's area wait for the gaps. Each large part is tried as the
|
||||
key, largest first (at most four); the others go to the corners, each in one of its two best
|
||||
corner poses, depth first, at most eight arrangements per key. A pose ranks by its gap to the
|
||||
corner, then the length of outline on the sheet edges, then how thin it is across the sheet,
|
||||
then its distance from the parts already placed.
|
||||
- Clamps along a side move that side in past their keep-outs, as a larger edge spacing would, so
|
||||
edge parts sit flush with one straight line; a clamp off every side stays an obstacle. The
|
||||
small parts fill around the real clamps.
|
||||
- The key takes any sampled orientation that fits. Failing that, `ContactFit` starts it in the
|
||||
largest free pocket and turns it by its contacts: every footprint vertex inside or near a
|
||||
neighbor, every neighbor vertex inside or near it and every outline vertex at the work-area
|
||||
edge is a linear constraint on a small move (dx, dy, rotation), and the least move meeting them
|
||||
all is taken until nothing overlaps. Three contacts then hold the part, so the neighbors set its
|
||||
angle; no angle list is sampled. Rotation-locked parts are never turned.
|
||||
- The solver only proposes the angle. Orientations flattened at it, then 0.1 degree steps out to
|
||||
two degrees either side, are checked on the exact free region (no-fit polygons, unchanged
|
||||
spacing) until one fits.
|
||||
|
||||
On a job of ten one-off brackets (four 112-inch tapered bars, four gussets, two small plates) on
|
||||
60 x 120 with a clamp rail, the bars stacked three to a sheet and the plan took three sheets; the
|
||||
pass packs two bars, two gussets and a plate per sheet, the middle bar turned 4.4 degrees, on two.
|
||||
|
||||
## Flat bar
|
||||
|
||||
FlatBar treats each stock's long work-area axis as the bar's length and lays parts in a single
|
||||
|
||||
Reference in new issue
Block a user