mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-03 14:52:11 -04:00
feat(irregular): offer certified Fill blocks for repeated parts
This commit is contained in:
1 parent
e886bcfa35
commit
c01a348243
5 files changed
+442
-8
No files matched your search
@@ -0,0 +1,165 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularBlockTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
|
||||
new Dictionary<int, IReadOnlyList<PairPose>>();
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void SmallDemandNeverPreparesAFill(int quantity)
|
||||
{
|
||||
var job = Job(new[] { Part("ell", LShape(9, 7, 3), quantity, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
|
||||
var types = PartCatalog.Build(job);
|
||||
using var catalog = new BlockCatalog(0.25, types, NoPairs);
|
||||
Assert.Empty(catalog.Get(types[0], quantity, new Box(0, 0, 60, 40), CancellationToken.None));
|
||||
Assert.Equal(0, catalog.PreparationCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillProposalIsTrimmedAndCertifiedWithoutChangingSingleRotations()
|
||||
{
|
||||
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 7, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
|
||||
var types = PartCatalog.Build(job);
|
||||
var original = types[0].Orientations.ToArray();
|
||||
using var catalog = new BlockCatalog(0.25, types, NoPairs);
|
||||
var block = catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None);
|
||||
Assert.Equal(7, block.Count);
|
||||
Assert.Equal(original, types[0].Orientations);
|
||||
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
|
||||
for (var i = 0; i < block.Count; i++)
|
||||
for (var j = i + 1; j < block.Count; j++)
|
||||
Assert.True(NestLayoutCheck.Clears(geometry,
|
||||
new NestJobPlacement("ell", i, block[i].X, block[i].Y, block[i].Orientation.Rotation), geometry,
|
||||
new NestJobPlacement("ell", j, block[j].X, block[j].Y, block[j].Orientation.Rotation), 0.25));
|
||||
Assert.Same(block, catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(2.1)]
|
||||
public void InvalidInternalSpacingRejectsTheWholeProposal(double offset)
|
||||
{
|
||||
var program = Shapes.Rectangle(2, 2);
|
||||
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var drawing = new Drawing("box", program);
|
||||
var members = Enumerable.Range(0, 3).Select(i => new OpenNest.Part(drawing)
|
||||
{ Location = new Vector(i * offset, 0) }).ToArray();
|
||||
var orientations = type.Orientations.ToList();
|
||||
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
|
||||
Assert.Equal(type.Orientations, orientations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PhysicalFreeRectanglesExcludeOccupiedMaterialAndClearance()
|
||||
{
|
||||
var job = Job(new[] { Rectangle("box", 4, 10, 1) }, new[] { Stock("sheet", 10, 20) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var part = new Placed(type.Orientations[0], 8, 0);
|
||||
var rectangles = BlockCatalog.Rectangles(new Box(0, 0, 20, 10), new[] { part }, 0.25);
|
||||
Assert.NotEmpty(rectangles);
|
||||
Assert.All(rectangles, box => Assert.True(box.Right <= 7.75 || box.Left >= 12.25));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedEllBlockCompetesWithPairsOnAnOpenSheet()
|
||||
{
|
||||
var stock = Stock("sheet", 30, 40, spacing: 0.25);
|
||||
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 44, RotationPolicy.Automatic) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
var pairs = PairCatalog.Build(types, 0.25, 40, 30, CancellationToken.None);
|
||||
using var blocks = new BlockCatalog(0.25, types, pairs);
|
||||
var before = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
|
||||
PackAxis.X, 1, new WorkCounter()).Fill(new[] { 44 }, CancellationToken.None);
|
||||
var after = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
|
||||
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 44 }, CancellationToken.None);
|
||||
Assert.True(after.Parts.Count > before.Parts.Count,
|
||||
$"Before {before.Parts.Count}; after {after.Parts.Count}");
|
||||
var result = new NestJobResultBuilder(job);
|
||||
result.AddSheet(stock, after.Parts.Select(p => ("ell", p.X, p.Y, p.Orientation.Rotation)));
|
||||
LayoutAssert.Valid(job, result.Build(NestJobStopReason.StockExhausted));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UndersizedRectangleKeepsSinglesFallback()
|
||||
{
|
||||
var stock = Stock("sheet", 3, 3, spacing: 0.25);
|
||||
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { stock });
|
||||
var types = PartCatalog.Build(job);
|
||||
using var blocks = new BlockCatalog(0.25, types, NoPairs);
|
||||
Assert.Empty(blocks.Get(types[0], 3, new Box(0, 0, 3, 3), CancellationToken.None));
|
||||
Assert.Equal(0, blocks.PreparationCount);
|
||||
var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock,
|
||||
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 3 }, CancellationToken.None);
|
||||
Assert.Single(fill.Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForbiddenRotationsAreRejectedBeforeAddingGroupOrientations()
|
||||
{
|
||||
var program = LShape(9, 7, 3);
|
||||
var job = Job(new[] { Part("ell", program, 3, RotationPolicy.Fixed(0)) },
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var drawing = new Drawing("ell", program);
|
||||
var members = Enumerable.Range(0, 3).Select(i =>
|
||||
{
|
||||
var part = OpenNest.Part.CreateAtOrigin(drawing, System.Math.PI / 2);
|
||||
part.Offset(new Vector(i * 12, 0));
|
||||
return part;
|
||||
}).ToArray();
|
||||
var orientations = type.Orientations.ToList();
|
||||
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
|
||||
Assert.Equal(type.Orientations, orientations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationDuringCertificationPropagates()
|
||||
{
|
||||
var program = Shapes.Rectangle(2, 2);
|
||||
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
|
||||
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
|
||||
var type = PartCatalog.Build(job)[0];
|
||||
var drawing = new Drawing("box", program);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var members = new CancellingMembers(Enumerable.Range(0, 3)
|
||||
.Select(i => new OpenNest.Part(drawing) { Location = new Vector(i * 3, 0) }).ToArray(), cancellation);
|
||||
Assert.Throws<OperationCanceledException>(() => BlockCatalog.Resolve(type, members,
|
||||
type.Orientations.ToList(), 0.25, cancellation.Token));
|
||||
}
|
||||
|
||||
private sealed class CancellingMembers(OpenNest.Part[] parts, CancellationTokenSource cancellation)
|
||||
: IReadOnlyList<OpenNest.Part>
|
||||
{
|
||||
public int Count => parts.Length;
|
||||
public OpenNest.Part this[int index] => parts[index];
|
||||
public IEnumerator<OpenNest.Part> GetEnumerator()
|
||||
{
|
||||
foreach (var part in parts)
|
||||
yield return part;
|
||||
cancellation.Cancel();
|
||||
}
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationIsNotConvertedIntoAnEmptyProposal()
|
||||
{
|
||||
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { Stock("sheet", 10, 20) });
|
||||
var types = PartCatalog.Build(job);
|
||||
using var catalog = new BlockCatalog(0.25, types, NoPairs);
|
||||
Assert.Throws<OperationCanceledException>(() =>
|
||||
catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true)));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -37,6 +37,22 @@ with a single boundary/containment union.
|
||||
Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden
|
||||
space, preserving genuine enclosed placement pockets without changing spacing tolerances.
|
||||
|
||||
When remaining demand exceeds two, Irregular also offers Default Fill patterns as optional
|
||||
multi-member candidates, not as solid bounding boxes or a whole-job Default fallback. It searches
|
||||
the empty work area and physical leftover space for up to two high-area rectangles. Occupied
|
||||
outlines are expanded by part spacing before rectangle search. Each sheet prepares blocks initially
|
||||
and after its first placement, for up to four high-demand-area types; each type has at most eight
|
||||
new Fill preparations per spacing per solve. Repeated rectangles reuse private drawing/candidate
|
||||
caches. Quantity-one and quantity-two requests never run block Fill.
|
||||
|
||||
Block members are trimmed to remaining demand, mapped back to source-frame rotations, and checked
|
||||
for legal rotations and internal material clearance before competing with singles and pairs.
|
||||
Group-only rotations do not expand the single-part rotation choices. Placement intersects all
|
||||
member free regions and subtracts each placed member separately, preserving usable gaps. A failed
|
||||
or invalid Fill proposal leaves singles and pairs available. Large enclosed-pocket blocks remain
|
||||
pending the hole-geometry integration; containment cutting order and shop-use safety acceptance
|
||||
remain separate sequencer/verification work.
|
||||
|
||||
## Renamed engines
|
||||
|
||||
Earlier releases shipped these as plug-ins under other names. The registry maps the old names so
|
||||
|
||||
Reference in new issue
Block a user