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