feat(irregular): offer certified Fill blocks for repeated parts

This commit is contained in:
aj committed 2026-10-01 16:17:25 -04:00
1 parent e886bcfa35
commit c01a348243
5 files changed
+442 -8

No files matched your search

@@ -0,0 +1,156 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>Per-solve, per-spacing Fill proposals. Only the members occupy material.</summary>
internal sealed class BlockCatalog : IDisposable
{
private readonly double spacing;
private readonly Dictionary<int, Drawing> drawings = new();
private readonly Dictionary<int, List<Orientation>> orientations = new();
private readonly Dictionary<int, int> attempts = new();
internal int PreparationCount => attempts.Values.Sum();
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
{
this.spacing = spacing;
foreach (var type in types)
{
var poses = type.Orientations.ToList();
if (pairs.TryGetValue(type.Index, out var found))
poses.AddRange(found.SelectMany(p => new[] { p.A, p.B }));
orientations[type.Index] = poses.Distinct().ToList();
}
}
internal static IReadOnlyList<Box> Rectangles(Box work, IReadOnlyList<Placed> placed, double spacing)
{
var free = new PathsD { new PathD
{
new(work.Left, work.Bottom), new(work.Right, work.Bottom),
new(work.Right, work.Top), new(work.Left, work.Top),
} };
foreach (var part in placed)
{
// Physical occupied material, not a reference-point region for any moving part.
// The catalog currently prepares solid outlines; future profile preparation owns holes.
var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline },
spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon,
2, NoFitCache.Precision);
free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y),
FillRule.NonZero, NoFitCache.Precision);
}
return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area())
.ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray();
}
public IReadOnlyList<Placed> Get(PartType type, int quantity, Box rectangle, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0)
return Array.Empty<Placed>();
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
if (cache.TryGetValue(key, out var cached))
return cached;
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
return Array.Empty<Placed>();
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
var result = Build(type, quantity, rectangle, token);
cache[key] = result;
return result;
}
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
{
if (!drawings.TryGetValue(type.Index, out var drawing))
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
var plate = new Plate(new Size(rectangle.Width, rectangle.Length)) { PartSpacing = spacing };
try
{
// The drawing is private: stabilize this cache entry before Fill's candidate pruning.
var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing);
var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
fits.Clear();
fits.AddRange(sorted);
var members = PlateFillService.FillItem("Default", plate, new NestItem
{
Drawing = drawing,
Quantity = quantity,
RotationStart = type.Part.Rotation.Start,
RotationEnd = type.Part.Rotation.End,
StepAngle = DrawingJobMapper.LegacyStep(type.Part.Rotation),
}, new Box(0, 0, rectangle.Length, rectangle.Width), null!, token);
token.ThrowIfCancellationRequested();
return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Array.Empty<Placed>();
}
}
public void Dispose()
{
foreach (var drawing in drawings.Values)
BestFitCache.Invalidate(drawing);
drawings.Clear();
}
internal static IReadOnlyList<Placed> Resolve(PartType type, IReadOnlyList<Part> members,
List<Orientation> orientations, double spacing, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (members.Count <= 2)
return Array.Empty<Placed>();
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
return Array.Empty<Placed>();
// Canonical rebinding is already performed by FillItem. Quantization removes sub-grid
// arithmetic differences from equivalent Fill proposals; certify the resulting poses.
var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0,
System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8),
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray();
if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation)
|| !type.Part.Rotation.Allows(p.Rotation)))
return Array.Empty<Placed>();
for (var i = 0; i < poses.Length; i++)
for (var j = i + 1; j < poses.Length; j++)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing))
return Array.Empty<Placed>();
}
var result = new List<Placed>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation);
if (orientation == null)
{
orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation);
if (orientation == null)
return Array.Empty<Placed>();
orientations.Add(orientation);
}
result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y));
}
return result;
}
}
@@ -61,6 +61,7 @@ internal sealed class FrontierPacker
private readonly double beta;
private readonly Box work;
private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
public FrontierPacker(
IReadOnlyList<PartType> types,
@@ -69,9 +70,11 @@ internal sealed class FrontierPacker
NestPlateStock stock,
PackAxis axis,
double beta,
WorkCounter counter
WorkCounter counter,
BlockCatalog? blocks = null
)
{
this.blocks = blocks;
this.counter = counter;
this.types = types;
this.nfps = nfps;
@@ -116,13 +119,46 @@ internal sealed class FrontierPacker
}
var placed = new List<Placed>();
var blockStates = new List<BlockState>();
var preparations = 0;
void PrepareBlocks()
{
if (blocks == null || preparations++ >= 2)
return;
var rectangles = BlockCatalog.Rectangles(work, placed, stock.PartSpacing);
foreach (var type in types.Where(t => left[t.Index] > 2)
.OrderByDescending(t => t.Area * left[t.Index]).ThenBy(t => t.Index).Take(4))
foreach (var rectangle in rectangles)
{
var members = blocks.Get(type, left[type.Index], rectangle, token);
if (members.Count <= 2)
continue;
var width = members.Max(p => p.Right) - members.Min(p => p.Left);
var height = members.Max(p => p.Top) - members.Min(p => p.Bottom);
if (!stock.Fits(width, height))
continue;
foreach (var member in members)
{
if (byOrientation.ContainsKey(member.Orientation))
continue;
if (!Track(member.Orientation, single: false))
break;
var region = byOrientation[member.Orientation];
foreach (var existing in placed)
region.Subtract(nfps.Get(existing.Orientation, member.Orientation), existing.X, existing.Y);
}
if (members.All(p => byOrientation.ContainsKey(p.Orientation)))
blockStates.Add(new BlockState(members, members.Select(p => byOrientation[p.Orientation]).ToArray()));
}
}
PrepareBlocks();
var partArea = 0.0;
var front = axis == PackAxis.X ? work.Left : work.Bottom;
while (states.Count > 0)
{
token.ThrowIfCancellationRequested();
var choice = Choose(states, offered, front);
var choice = Choose(states, offered, blockStates, front);
if (choice == null)
break;
@@ -135,6 +171,7 @@ internal sealed class FrontierPacker
}
left[typeIndex] -= choice.Count;
blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]);
if (left[typeIndex] == 0)
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
if (left[typeIndex] < 2)
@@ -158,8 +195,10 @@ internal sealed class FrontierPacker
}
states.RemoveAll(s => s.IsEmpty);
offered.RemoveAll(p => p.A.IsEmpty || p.B.IsEmpty);
if (offered.Count == 0)
if (offered.Count == 0 && blocks == null)
states.RemoveAll(s => !s.Single);
blockStates.RemoveAll(b => b.Regions.Any(r => r.IsEmpty));
PrepareBlocks();
}
return new SheetFill(stock, placed, partArea);
@@ -170,7 +209,7 @@ internal sealed class FrontierPacker
/// under the same rule; a pair counts as one piece of twice the part area, and on a tie
/// the single (considered first) is kept.
/// </summary>
private IReadOnlyList<Placed>? Choose(List<Region> states, List<PairState> offered, double front)
private IReadOnlyList<Placed>? Choose(List<Region> states, List<PairState> offered, List<BlockState> blocks, double front)
{
IReadOnlyList<Placed>? bestParts = null;
var bestFills = false;
@@ -236,9 +275,51 @@ internal sealed class FrontierPacker
);
}
foreach (var block in blocks)
{
if (!block.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
var typeIndex = block.Members[0].Orientation.TypeIndex;
Consider(() => block.Members.Select(p => new Placed(p.Orientation, p.X + point.x, p.Y + point.y)).ToArray(),
typeIndex, block.Members.Count * types[typeIndex].Area, advance, side, lead);
}
return bestParts;
}
private sealed class BlockState(IReadOnlyList<Placed> members, Region[] regions)
{
public IReadOnlyList<Placed> Members { get; } = members;
public Region[] Regions { get; } = regions;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
var free = Clipper.TranslatePaths(Regions[0].Free, -Members[0].X, -Members[0].Y);
var minX = double.NegativeInfinity;
var minY = double.NegativeInfinity;
var maxX = double.PositiveInfinity;
var maxY = double.PositiveInfinity;
for (var i = 0; i < Members.Count; i++)
{
var member = Members[i];
var region = Regions[i];
minX = System.Math.Max(minX, region.MinX - member.X);
minY = System.Math.Max(minY, region.MinY - member.Y);
maxX = System.Math.Min(maxX, region.MaxX - member.X);
maxY = System.Math.Min(maxY, region.MaxY - member.Y);
if (i > 0)
{
counter.Add(1);
free = Clipper.Intersect(free, Clipper.TranslatePaths(region.Free, -member.X, -member.Y),
FillRule.NonZero, NoFitCache.Precision);
}
}
return BestVertex(free, minX, minY, System.Math.Max(minX, maxX), System.Math.Max(minY, maxY),
(Members.Min(p => p.Left), Members.Min(p => p.Bottom), Members.Max(p => p.Right), Members.Max(p => p.Top)),
axis, front, out point, out advance, out side, out lead);
}
}
/// <summary>Legal reference points for one orientation on this sheet.</summary>
private sealed class Region
{
@@ -48,7 +48,7 @@ public sealed class IrregularNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job);
var solver = new Solver(job, types, progress, token);
using var solver = new Solver(job, types, progress, token);
// Pair-only orientations may fit stock even when the sampled single poses do not.
// Demand that neither a single nor a pair can fit is reported unplaced.
@@ -59,7 +59,8 @@ public sealed class IrregularNestingEngine : INestingEngine
stock.Quantity != 0
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
);
demand[type.Index] = placeable || solver.PairFits(type) ? type.Part.Quantity : 0;
demand[type.Index] = placeable || solver.PairFits(type)
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
}
Plan? best = null;
@@ -86,11 +87,26 @@ public sealed class IrregularNestingEngine : INestingEngine
IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
) : IDisposable
{
public void Dispose()
{
foreach (var catalog in blocks.Values)
catalog.Dispose();
}
private const int MaxTail = 3;
private readonly Dictionary<double, NoFitCache> caches = new();
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
private readonly Dictionary<double, BlockCatalog> blocks = new();
private BlockCatalog BlocksFor(NestPlateStock stock)
{
var spacing = System.Math.Max(0, stock.PartSpacing);
if (!blocks.TryGetValue(spacing, out var found))
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock));
return found;
}
public WorkCounter Work { get; } = new();
@@ -225,7 +241,7 @@ public sealed class IrregularNestingEngine : INestingEngine
if (stock.Quantity is int available && used[stock.Id] >= available)
continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work);
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill)));