From c374e02614ab26ce3753af43b60e4acb6a6e372e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 17:15:54 -0400 Subject: [PATCH] feat(irregular): treat clamp zones as static NFP obstacles --- .../NestingEngines/IrregularClampZoneTests.cs | 239 ++++++++++++++++++ .../RectanglesClampZoneTests.cs | 6 +- .../Irregular/ClampObstacles.cs | 52 ++++ .../Irregular/FrontierPacker.cs | 19 +- .../Irregular/IrregularNestingEngine.cs | 13 +- docs/nesting-engines.md | 19 ++ 6 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs create mode 100644 OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs new file mode 100644 index 00000000..3107ec4e --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularClampZoneTests.cs @@ -0,0 +1,239 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Default; +using OpenNest.Engine.NestingEngines.Irregular; +using OpenNest.Engine.NestingEngines.Rectangles; +using OpenNest.Geometry; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public class IrregularClampZoneTests +{ + private static readonly IReadOnlyDictionary> NoPairs = + new Dictionary>(); + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void CurvedPartsClearEveryInflatedClampInEveryQuadrant(int quadrant) + { + var stock = Stock("sheet", 10, 20, spacing: 0.1, quadrant: quadrant, + edge: new Spacing(0.1, 0.25, 0.1, 0.1), clampZones: + [new("left", new Box(0, 0, 3, 0.625)), new("middle", new Box(7, 0, 1.5, 0.625))]); + var job = Job([Part("disc", Disc(1), 9, RotationPolicy.Fixed(0))], [stock]); + var result = new IrregularNestingEngine().Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(9, Assert.Single(result.Plates).Placements.Count); + AssertDiscsClearClamps(result, 1); + LayoutAssert.Valid(job, result); + Assert.Contains(result.Plates[0].Placements, p => p.Y - 1 < stock.WorkArea.Bottom + 0.05); + } + + [Theory] + [InlineData("Irregular")] + [InlineData("Rectangles")] + [InlineData("Default")] + public void BothLanesAndDefaultAvoidClamps(string engine) + { + var job = Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], + [Stock("sheet", 5, 12, edge: new Spacing(0.25, 0.25, 0.25, 0.25), + clampZones: [new("front", new Box(0, 0, 3, 0.625))])]); + INestingEngine solver = engine switch + { + "Irregular" => new IrregularNestingEngine(), + "Rectangles" => new RectanglesNestingEngine(), + _ => new DefaultNestingEngine(), + }; + var result = solver.Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); + AssertDiscsClearClamps(result, 1); + LayoutAssert.Valid(job, result); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + public void ClampClearanceUsesBottomEdgeSpacingNotPartSpacing(double partSpacing) + { + var stock = Stock("sheet", 4, 5, spacing: partSpacing, + edge: new Spacing(0, 0.25, 0, 0), + clampZones: [new("rail", new Box(0, 0, 5, 0.625))]); + var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]); + var result = new IrregularNestingEngine().Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + var pose = Assert.Single(Assert.Single(result.Plates).Placements); + Assert.InRange(pose.Y - 1, 0.875, 0.90); + AssertDiscsClearClamps(result, 1); + LayoutAssert.Valid(job, result); + } + + [Fact] + public void FullRailBlocksTheOnlyRowInsteadOfReturningAnUnsafePlacement() + { + var stock = Stock("sheet", 2.5, 6, edge: new Spacing(0, 0.25, 0, 0), + clampZones: [new("rail", new Box(0, 0, 6, 0.625))]); + var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]); + var result = new IrregularNestingEngine().Solve(job); + + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Empty(result.Plates); + Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced); + LayoutAssert.Valid(job, result); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void PairOnlyOrientationsAlsoLoseClampBlockedSpace(int axis) + { + var stock = Stock("sheet", 10, 10, edge: new Spacing(0, 0.25, 0, 0), + clampZones: [new("rail", new Box(0, 0, 10, 1))]); + var job = Job([Rectangle("bar", 2, 3, 2, RotationPolicy.Automatic)], [stock]); + var types = PartCatalog.Build(job); + var extra = PartCatalog.CreateOrientation(types[0], 100, System.Math.PI / 2)!; + var pair = new PairPose { A = extra, B = extra, Dx = 0, Dy = 3 }; + var pairs = new Dictionary> { [0] = new[] { pair } }; + var fill = new FrontierPacker(types, new NoFitCache(0), pairs, stock, + (PackAxis)axis, 1, new WorkCounter()).Fill([2], default); + + Assert.Equal(2, fill.Parts.Count); + Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25)); + if (axis == 0) + Assert.All(fill.Parts, p => Assert.Same(extra, p.Orientation)); + AssertValidFill(job, fill); + } + + [Fact] + public void BlockOnlyOrientationsAlsoLoseClampBlockedSpace() + { + var stock = Stock("sheet", 20, 12, edge: new Spacing(0, 0.25, 0, 0), + clampZones: [new("rail", new Box(0, 0, 12, 1))]); + var job = Job([Rectangle("bar", 2, 3, 6, RotationPolicy.Automatic)], [stock]); + var types = PartCatalog.Build(job); + using var blocks = new BlockCatalog(0.1, types, NoPairs, new WorkCounter(), long.MaxValue); + var fill = new FrontierPacker(types, new NoFitCache(0.1), NoPairs, stock, + PackAxis.Y, 1, new WorkCounter(), blocks).Fill([6], default); + + Assert.Equal(6, fill.Parts.Count); + Assert.True(blocks.PreparationCount > 0); + Assert.Contains(fill.Parts, p => !types[0].Orientations.Contains(p.Orientation)); + Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25)); + AssertValidFill(job, fill); + } + + [Fact] + public void SameSpacingStocksDoNotShareClampGeometry() + { + var wall = new ClampZone("rail", new Box(0, 0, 6, 1)); + var job = Job([Rectangle("part", 2, 2, 1)], + [Stock("blocked", 2.5, 6, clampZones: [wall]), Stock("open", 2.5, 6)]); + var result = new IrregularNestingEngine().Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal("open", Assert.Single(result.Plates).StockId); + LayoutAssert.Valid(job, result); + } + + [Fact] + public void ConcavePartCanUseItsOpenNotchAroundAClamp() + { + // The only fitting pose surrounds the rail in an open notch. Blocking the + // part's bounding box instead of its NFP would incorrectly reject this job. + var program = Polyline((0, 0), (1, 0), (1, 3), (3, 3), + (3, 0), (4, 0), (4, 4), (0, 4)); + var stock = Stock("sheet", 4.2, 4, edge: new Spacing(0, 0.1, 0, 0), + clampZones: [new("notch", new Box(1.5, 0, 1, 2))]); + var job = Job([Part("notched", program, 1, RotationPolicy.Fixed(0))], [stock]); + var result = new IrregularNestingEngine().Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + var pose = Assert.Single(Assert.Single(result.Plates).Placements); + Assert.Equal(0, pose.X, 8); + Assert.Equal(0.1, pose.Y, 8); + // Analytic notch walls versus the rectangle inflated by 0.1. + Assert.True(pose.X + 1 <= 1.4 && pose.X + 3 >= 2.6 && pose.Y + 3 >= 2.1); + LayoutAssert.Valid(job, result); + } + + [Fact] + public void DifferentClampedStocksAtTheSameSpacingKeepTheirOwnObstacles() + { + var job = Job([Rectangle("part", 2, 2, 2)], + [Stock("left", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(0, 0, 3, 1))]), + Stock("right", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(3, 0, 3, 1))])]); + var result = new IrregularNestingEngine().Solve(job); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(2, result.Plates.Count); + foreach (var sheet in result.Plates) + { + var pose = Assert.Single(sheet.Placements); + Assert.True(sheet.StockId == "left" ? pose.X >= 3 : pose.X + 2 <= 3); + } + LayoutAssert.Valid(job, result); + } + + [Fact] + public void CancelledClampPackingDoesNotReturnAnEmptySuccess() + { + var stock = Stock("sheet", 4, 5, clampZones: [new("rail", new Box(0, 0, 5, 1))]); + var job = Job([Rectangle("part", 2, 2, 1)], [stock]); + var packer = new FrontierPacker(PartCatalog.Build(job), new NoFitCache(0), NoPairs, + stock, PackAxis.X, 1, new WorkCounter()); + Assert.Throws(() => packer.Fill([1], new CancellationToken(true))); + } + + [Theory] + [InlineData(0, "0F90BA3EF6C6392CF1C1E01A6209D55CA88B4F968D6DD0D01850AE15A349FE88")] + [InlineData(1, "A970D56C5768763CE339999F91ADD7FFB9EC2AF6C1585CC51C4B0869D45D0C53")] + [InlineData(2, "65ECA7F7163DB0AB7BCA331FBF2A240604B25C44D62CE97CB2015FC78A9631AA")] + // Full result hashes captured on d71f086 before static-obstacle preparation. + public void EmptyClampLayoutsKeepBaselineBits(int fixture, string expected) + { + var job = fixture switch + { + 0 => Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], [Stock("sheet", 5, 12, spacing: 0.25)]), + 1 => Job([Part("ell", LShape(4, 3, 1), 1), Part("tri", Triangle(3, 2), 1)], + [Stock("sheet", 10, 15, spacing: 0.25)]), + _ => Job([Rectangle("square", 4, 4, 3)], [Stock("sheet", 5, 5, spacing: 0.25)]), + }; + var result = new IrregularNestingEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result)))); + Assert.Equal(expected, hash); + } + + private static void AssertDiscsClearClamps(NestJobResult result, double radius) + { + foreach (var sheet in result.Plates) + foreach (var pose in sheet.Placements) + foreach (var zone in sheet.Stock.ClampZones) + { + var b = zone.Bounds; + var c = sheet.Stock.EdgeSpacing.Bottom; + var dx = pose.X - System.Math.Clamp(pose.X, b.Left - c, b.Right + c); + var dy = pose.Y - System.Math.Clamp(pose.Y, b.Bottom - c, b.Top + c); + Assert.True(dx * dx + dy * dy >= radius * radius, + $"Disc at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}"); + } + } + + private static void AssertValidFill(NestJob job, SheetFill fill) + { + var builder = new NestJobResultBuilder(job); + builder.AddSheet(fill.Stock, fill.Parts.Select(p => + (job.Parts[p.Orientation.TypeIndex].Id, p.X, p.Y, p.Orientation.Rotation))); + LayoutAssert.Valid(job, builder.Build(NestJobStopReason.Completed)); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs index 37c4d9d8..1d1597f1 100644 --- a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs @@ -11,10 +11,8 @@ namespace OpenNest.Engine.Tests.NestingEngines; /// /// The Rectangles lane treats clamp zones as hard obstacles: placements never enter /// the keep-out, demand uses the material between clamps, and layouts with zero -/// clamps stay bit-identical to the pre-clamp baseline (C2). Per the plan's Slice 1 -/// gate these tests name the Rectangles engine directly: the Irregular lane gets -/// clamp parity in Slice 2, so until then the Default router is not asserted to -/// satisfy C1 on clamp fixtures. +/// clamps stay bit-identical to the pre-clamp baseline (C2). Irregular and Default +/// parity is covered separately by IrregularClampZoneTests. /// public class RectanglesClampZoneTests { diff --git a/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs new file mode 100644 index 00000000..54bfb28d --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/ClampObstacles.cs @@ -0,0 +1,52 @@ +#nullable enable +using Clipper2Lib; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Static, clearance-inflated rectangles prepared once per stock per solve. Kept out of +/// the part catalog and its spacing cache: clamp clearance is bottom edge spacing, not +/// part spacing, and two stocks at the same part spacing may have different clamps. +/// +internal sealed class ClampObstacles +{ + private readonly Orientation[] rectangles; + private readonly NoFitCache nfps = new(0); + + public ClampObstacles(NestPlateStock stock) + { + rectangles = new Orientation[stock.ClampZones.Count]; + var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom); + for (var i = 0; i < rectangles.Length; i++) + { + var box = stock.ClampZones[i].Bounds; + var left = box.Left - clearance; + var bottom = box.Bottom - clearance; + var right = box.Right + clearance; + var top = box.Top + clearance; + rectangles[i] = new Orientation + { + // Real part types are nonnegative; the private cache cannot alias them. + TypeIndex = -1, + Index = i, + Rotation = 0, + Tolerance = 0, + MinX = left, + MinY = bottom, + MaxX = right, + MaxY = top, + Outline = new PathD + { + new(left, bottom), new(right, bottom), new(right, top), new(left, top), + }, + }; + } + } + + public int Count => rectangles.Length; + + // 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); +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs index 00d9a6f1..deb3b394 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs @@ -35,8 +35,8 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList Par /// /// For every (part type, orientation) still in play the packer keeps the exact set of legal /// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of -/// everything already placed. Each placement subtracts one translated NFP from each region, -/// so regions only shrink, and a region that empties is retired for the rest of the sheet. +/// static clamps and everything already placed. Each placement subtracts one translated NFP +/// from each region, so regions only shrink; an empty region is retired for the sheet. /// /// Choice rule, applied over all types and orientations at once (not in a fixed order): /// 1. Gap fill - if any part fits without pushing the packing front forward, place the @@ -62,6 +62,7 @@ internal sealed class FrontierPacker private readonly Box work; private readonly WorkCounter counter; private readonly BlockCatalog? blocks; + private readonly ClampObstacles? clamps; public FrontierPacker( IReadOnlyList types, @@ -71,10 +72,12 @@ internal sealed class FrontierPacker PackAxis axis, double beta, WorkCounter counter, - BlockCatalog? blocks = null + BlockCatalog? blocks = null, + ClampObstacles? clamps = null ) { this.blocks = blocks; + this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock); this.counter = counter; this.types = types; this.nfps = nfps; @@ -97,6 +100,16 @@ internal sealed class FrontierPacker if (!stock.Fits(o.Width, o.Height)) return false; var region = new Region(o, work, single); + if (clamps != null) + for (var i = 0; i < clamps.Count; i++) + { + token.ThrowIfCancellationRequested(); + var nfp = clamps.Get(i, o); + token.ThrowIfCancellationRequested(); + region.Subtract(nfp, 0, 0); + counter.Add(1); + token.ThrowIfCancellationRequested(); + } states.Add(region); byOrientation[o] = region; return true; diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index 55dcf083..688615d2 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -118,6 +118,7 @@ public sealed class IrregularNestingEngine : INestingEngine private const int MaxTail = 3; private readonly Dictionary caches = new(); + private readonly Dictionary clamps = new(); private readonly Dictionary>> pairs = new(); private readonly Dictionary blocks = new(); private readonly IReadOnlyDictionary parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); @@ -198,6 +199,15 @@ public sealed class IrregularNestingEngine : INestingEngine return cache; } + private ClampObstacles? ClampsFor(NestPlateStock stock) + { + if (stock.ClampZones.Count == 0) + return null; + if (!clamps.TryGetValue(stock, out var found)) + clamps[stock] = found = new ClampObstacles(stock); + return found; + } + public bool PairFits(PartType type) { if (type.Part.Quantity < 2 || type.Orientations.Count == 0) @@ -265,7 +275,8 @@ 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, BlocksFor(stock)); + var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, + BlocksFor(stock), ClampsFor(stock)); var fill = packer.Fill(remaining, token); sheetPacked?.Invoke(); if (fill.Parts.Count > 0) diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 066e1a1d..8a432ce6 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -67,6 +67,25 @@ or invalid Fill proposal leaves singles and pairs available. Large enclosed-pock pending the hole-geometry integration; containment cutting order and shop-use safety acceptance remain separate sequencer/verification work. +## Rectangular clamp zones + +Whole-job Rectangles and Irregular exclude each stock's rectangular clamp zones, +expanded on all sides by its bottom edge spacing. Part spacing is not added to clamp +clearance. Default runs and validates both lanes as usual. Empty clamp collections +leave the existing packing path unchanged; clamps never become requested or placed parts. + +Irregular prepares static rectangle obstacles and their cached no-fit polygons once +per stock per solve, separately from the part-spacing cache. Every orientation's initial +free region excludes these obstacles, including extra orientations introduced by pairs +or Fill blocks. Blocks still size their proposals from work-area/placed-part rectangles; +member free regions determine whether the whole proposal can actually fit around clamps. +Open notches and material between clamps remain available. Existing arc/grid safety +margins, covered-hole certification and cancellation apply without relaxed tolerances. + +This is whole-job engine support, not interactive fill/push, desktop clamp editing or +cutoff/post-verification support; those consumers are separate work. Other engines and +plug-ins have not gained clamp-aware placement from this change. + ## Repeating completed layouts Rectangles and Irregular can skip repeated sheet searches after selecting one nonempty layout