diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularBlockTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularBlockTests.cs new file mode 100644 index 0000000..92b79ef --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularBlockTests.cs @@ -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> NoPairs = + new Dictionary>(); + + [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(() => BlockCatalog.Resolve(type, members, + type.Orientations.ToList(), 0.25, cancellation.Token)); + } + + private sealed class CancellingMembers(OpenNest.Part[] parts, CancellationTokenSource cancellation) + : IReadOnlyList + { + public int Count => parts.Length; + public OpenNest.Part this[int index] => parts[index]; + public IEnumerator 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(() => + catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true))); + } +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/BlockCatalog.cs b/OpenNest.Engine/NestingEngines/Irregular/BlockCatalog.cs new file mode 100644 index 0000000..c00b04d --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/BlockCatalog.cs @@ -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; + +/// Per-solve, per-spacing Fill proposals. Only the members occupy material. +internal sealed class BlockCatalog : IDisposable +{ + private readonly double spacing; + private readonly Dictionary drawings = new(); + private readonly Dictionary> orientations = new(); + private readonly Dictionary attempts = new(); + internal int PreparationCount => attempts.Values.Sum(); + private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList> cache = new(); + + public BlockCatalog(double spacing, IReadOnlyList types, + IReadOnlyDictionary> 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 Rectangles(Box work, IReadOnlyList 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 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(); + 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(); + attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1; + var result = Build(type, quantity, rectangle, token); + cache[key] = result; + return result; + } + + private IReadOnlyList 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(); + } + } + + public void Dispose() + { + foreach (var drawing in drawings.Values) + BestFitCache.Invalidate(drawing); + drawings.Clear(); + } + + internal static IReadOnlyList Resolve(PartType type, IReadOnlyList members, + List orientations, double spacing, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (members.Count <= 2) + return Array.Empty(); + var geometry = JobPartGeometry.TryRead(type.Part.Geometry); + if (geometry == null) + return Array.Empty(); + // 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(); + 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(); + } + var result = new List(); + 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(); + orientations.Add(orientation); + } + result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y)); + } + return result; + } +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs index 552bea5..00d9a6f 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs @@ -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 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(); + var blockStates = new List(); + 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. /// - private IReadOnlyList? Choose(List states, List offered, double front) + private IReadOnlyList? Choose(List states, List offered, List blocks, double front) { IReadOnlyList? 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 members, Region[] regions) + { + public IReadOnlyList 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); + } + } + /// Legal reference points for one orientation on this sheet. private sealed class Region { diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index 537f628..aeb106e 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -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 types, IProgress? progress, CancellationToken token - ) + ) : IDisposable { + public void Dispose() + { + foreach (var catalog in blocks.Values) + catalog.Dispose(); + } + private const int MaxTail = 3; private readonly Dictionary caches = new(); private readonly Dictionary>> pairs = new(); + private readonly Dictionary 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))); diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 00d6fa3..7d3c9e1 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -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