diff --git a/OpenNest.Core/ClampZoneFrame.cs b/OpenNest.Core/ClampZoneFrame.cs index 94a58982..70f90d44 100644 --- a/OpenNest.Core/ClampZoneFrame.cs +++ b/OpenNest.Core/ClampZoneFrame.cs @@ -5,12 +5,12 @@ using OpenNest.Geometry; namespace OpenNest; /// -/// Maps a live plate's , stored in the sheet's lower-left frame +/// Maps a plate's , stored in the sheet's lower-left frame /// (), into the signed placement frame of its parts, /// cut-offs and motions. The translation matches the sheet itself and the engine stock: /// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are -/// kept, never clipped. Engine-internal plates built from a NestPlateStock already -/// carry placement-frame zones and must not be mapped again. +/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds +/// from a NestPlateStock, so this is the only translation any consumer applies. /// public static class ClampZoneFrame { diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs index d299ff60..21da7b6a 100644 --- a/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs @@ -78,12 +78,16 @@ public class ClampZoneEngineTests Assert.Equal(ZoneX - 96, zone.Bounds.X); Assert.Equal(0, zone.Bounds.Y); - // Materializing the stock back keeps the placement frame: no second transform. + // A plate materialized from the stock stores plate coordinates like every Plate; + // mapping it into the placement frame reproduces the stock zone exactly. var materialized = DrawingJobMapper.CreatePlate(stock); var copy = Assert.Single(materialized.ClampZones); - Assert.Equal(zone.Bounds.X, copy.Bounds.X); + Assert.Equal(ZoneX, copy.Bounds.X); Assert.Equal(0, copy.Bounds.Y); // quadrant 2 keeps Y at zero Assert.Equal("clamp-0", copy.Id); + var placed = Assert.Single(ClampZoneFrame.ToPlacement(materialized)); + Assert.Equal(zone.Bounds.X, placed.Bounds.X); + Assert.Equal(zone.Bounds.Y, placed.Bounds.Y); } [Fact] diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs new file mode 100644 index 00000000..a6e169e3 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/ClampZoneSheetFrameTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.Diagnostics; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// Every stores clamp zones in the sheet's lower-left frame, including +/// plates the engine builds from stock; consumers map them into the signed placement frame +/// through . Fixtures use a real rail in all quadrants, with +/// expected footprints from the physical rule (center +/- width/2 from the origin along the +/// edge, depth into the sheet). +/// +public class ClampZoneSheetFrameTests +{ + private static (string, long, long, long, long) Bits(ClampZone zone) => + (zone.Id, BitConverter.DoubleToInt64Bits(zone.Bounds.X), BitConverter.DoubleToInt64Bits(zone.Bounds.Y), + BitConverter.DoubleToInt64Bits(zone.Bounds.Length), BitConverter.DoubleToInt64Bits(zone.Bounds.Width)); + + private static Box World(int quadrant, double center, double width = 1.5, double depth = 0.625) + { + var x = quadrant is 2 or 3 ? -(center + width / 2) : center - width / 2; + var y = quadrant is 3 or 4 ? -depth : 0; + return new Box(x, y, width, depth); + } + + private static Program Rectangle(double length, double width) + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(length, 0); + program.LineTo(length, width); + program.LineTo(0, width); + program.LineTo(0, 0); + return program; + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void MaterializedPlatesStoreSheetFrameZonesAndWarnForCutsThroughThem(int quadrant) + { + var live = new Plate(81, 120) { Quadrant = quadrant }; + live.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 120.5 })); + var stock = DrawingJobMapper.FromPlate("s", live, 1); + var job = new NestJob(Array.Empty(), new[] { stock }); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, Array.Empty()) }, + Array.Empty(), Array.Empty()); + + var nest = NestResultMaterializer.Materialize(job, result).Nest; + var plate = Assert.Single(nest.Plates); + + Assert.Equal(live.ClampZones.Select(Bits), plate.ClampZones.Select(Bits)); + Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits)); + var footprint = World(quadrant, 35); + Assert.Equal(footprint.Left, stock.ClampZones[0].Bounds.Left, 9); + Assert.Equal(footprint.Bottom, stock.ClampZones[0].Bounds.Bottom, 9); + + // A short cut-off through the physical clamp must be found and require consent. + plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical) + { + StartLimit = quadrant is 3 or 4 ? -2 : 0, + EndLimit = quadrant is 3 or 4 ? 0 : 2, + }); + Assert.Equal("clamp-1", Assert.Single(ClampCutDiagnostics.Find(plate)).ClampId); + plate.RegenerateCutOffs(new CutOffSettings()); + var report = PostVerificationAnalyzer.Analyze(nest); + Assert.Contains(report.Findings, f => f.ClampId == "clamp-1"); + Assert.False(report.CanPost(false)); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void InteractiveFillOnLiveRailPlateAvoidsClampsInEveryQuadrant(int quadrant) + { + // Five rows of 4.29 squares at 0.5 spacing span 23.45 of the 23.5 work-area height, so an + // unobstructed fill reaches the front edge in every quadrant. + var plate = new Plate(new Size(24, 48)) + { + Quadrant = quadrant, + EdgeSpacing = new Spacing(0.25, 0.25), + PartSpacing = 0.5, + }; + var centers = new[] { 10.75, 20.75, 30.75, 40.75 }; + plate.SetClampRail(new ClampRail(1.5, 0.625, centers)); + var keepOuts = centers.Select(c => World(quadrant, c).Offset(0.25)).ToArray(); + var item = new NestItem { Drawing = new Drawing("square", Rectangle(4.29, 4.29)), Quantity = 500 }; + + var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, CancellationToken.None); + + Assert.True(parts.Count >= 20, $"only {parts.Count} parts placed"); + foreach (var part in parts) + { + var box = part.BoundingBox; + Assert.DoesNotContain(keepOuts, k => box.Left < k.Right - 1e-6 && k.Left < box.Right - 1e-6 + && box.Bottom < k.Top - 1e-6 && k.Bottom < box.Top - 1e-6); + } + } +} diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs index 56882564..6a55dad2 100644 --- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs +++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs @@ -116,9 +116,10 @@ public static class DrawingJobMapper EdgeSpacing = stock.EdgeSpacing, Quadrant = stock.Quadrant, }; - // Zones are immutable and already carry the placement-frame transform. - foreach (var zone in stock.ClampZones) - plate.ClampZones.Add(zone); + // Every Plate stores zones in plate (sheet lower-left) coordinates; consumers map + // them into the placement frame through ClampZoneFrame, exactly as the stock did. + foreach (var zone in stock.SheetClampZones) + plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds)); return plate; } } diff --git a/OpenNest.Engine/Jobs/InteractiveClamps.cs b/OpenNest.Engine/Jobs/InteractiveClamps.cs index cf7f6c45..206b195a 100644 --- a/OpenNest.Engine/Jobs/InteractiveClamps.cs +++ b/OpenNest.Engine/Jobs/InteractiveClamps.cs @@ -10,7 +10,7 @@ namespace OpenNest.Engine.Jobs; /// /// Interactive-path clamp keep-outs (Slice 4): the inflated rectangles and static obstacle /// parts that make the single-plate fill, push and pack surfaces respect -/// like existing parts. Zones are never material: the fake +/// (mapped into the placement frame) like existing parts. Zones are never material: the fake /// obstacle parts exist only inside a single call - they are never added to /// , plates, jobs or files. /// @@ -18,14 +18,18 @@ internal static class InteractiveClamps { public static bool HasZones(Plate plate) => plate.ClampZones.Count > 0; - /// Raw zone rectangles, detached copies, capture order. + /// + /// Raw zone rectangles in the parts' placement frame (plate zones are stored in sheet + /// coordinates, see ), detached copies, capture order. + /// public static IReadOnlyList ZoneRectangles(Plate plate) { if (plate.ClampZones.Count == 0) return Array.Empty(); - var boxes = new Box[plate.ClampZones.Count]; + var zones = ClampZoneFrame.ToPlacement(plate); + var boxes = new Box[zones.Count]; for (var i = 0; i < boxes.Length; i++) - boxes[i] = Copy(plate.ClampZones[i].Bounds); + boxes[i] = Copy(zones[i].Bounds); return boxes; } @@ -49,9 +53,10 @@ internal static class InteractiveClamps { if (plate.ClampZones.Count == 0) return Array.Empty(); - var boxes = new Box[plate.ClampZones.Count]; + var zones = ClampZoneFrame.ToPlacement(plate); + var boxes = new Box[zones.Count]; for (var i = 0; i < boxes.Length; i++) - boxes[i] = plate.ClampZones[i].Bounds.Offset(clearance); + boxes[i] = zones[i].Bounds.Offset(clearance); return boxes; } diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index e09cbc98..dbad50a6 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -174,7 +174,8 @@ public static class NestLayoutCheck { var inflateBy = InflationFor(plate.EdgeSpacing.Bottom); - foreach (var zone in plate.ClampZones) + // Plate zones are stored in sheet coordinates; parts are in the placement frame. + foreach (var zone in ClampZoneFrame.ToPlacement(plate)) { // Box.Offset(d) grows the rectangle on all four sides. var keepOut = zone.Bounds.Offset(inflateBy); diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs index 85c0ad61..a855ea7f 100644 --- a/OpenNest.Engine/Jobs/NestPlateStock.cs +++ b/OpenNest.Engine/Jobs/NestPlateStock.cs @@ -49,13 +49,19 @@ public sealed class NestPlateStock if (clampZones == null || clampZones.Count == 0) { ClampZones = Array.Empty(); + SheetClampZones = Array.Empty(); } else { var zones = new ClampZone[clampZones.Count]; + var sheetZones = new ClampZone[clampZones.Count]; for (var i = 0; i < zones.Length; i++) + { zones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds.Translate(left, bottom)); + sheetZones[i] = new ClampZone(clampZones[i].Id, clampZones[i].Bounds); + } ClampZones = zones; + SheetClampZones = sheetZones; } } @@ -65,6 +71,14 @@ public sealed class NestPlateStock /// public IReadOnlyList ClampZones { get; } + /// + /// The same zones exactly as given, in plate coordinates (the frame of + /// ), for plates built from this stock. Keeping the input + /// avoids a lossy translate-and-back; of such a + /// plate reproduces bit for bit. + /// + internal IReadOnlyList SheetClampZones { get; } + /// Generic per-physical-sheet cost; null selects area scoring. public double? Cost { get; } public string Id { get; } diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 44ae3b35..92aef37f 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -74,6 +74,12 @@ expanded on all sides by its bottom edge spacing. Part spacing is not added to c 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. +Every `Plate`, including plates the engine builds from stock, stores clamp zones relative +to the sheet's lower-left corner; `NestPlateStock.ClampZones` carries them in the signed +placement frame of part coordinates. Code that compares a plate's zones with parts, cuts +or motions maps them through `ClampZoneFrame.ToPlacement`, which reproduces the stock's +zones exactly; reading `Plate.ClampZones` bounds directly is wrong in quadrants 2-4. + 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