diff --git a/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs b/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs
new file mode 100644
index 0000000..7e08038
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs
@@ -0,0 +1,207 @@
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Adapters;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Tests.Jobs;
+
+///
+/// Clamp zones as engine input: immutable stock carry with the WorkArea quadrant
+/// transform, validation of zone geometry, repeated-layout identity (C8) and the
+/// layout-check violation against the D6-inflated rectangle (C1/C5).
+///
+public class ClampZoneEngineTests
+{
+ private const double ZoneX = 5.25;
+ private const double ZoneLength = 1.5;
+ private const double ZoneWidth = 0.625;
+
+ private static ClampZone Zone(string id = "clamp-0", double x = ZoneX) =>
+ new(id, new Box(x, 0, ZoneLength, ZoneWidth));
+
+ private static NestPlateStock Stock(
+ int quadrant = 1,
+ double bottomSpacing = 0.25,
+ params ClampZone[] zones
+ ) =>
+ new("sheet", new Size(48, 96), null, 0.25,
+ new Spacing(0.25, bottomSpacing, 0.25, 0.25), quadrant, null, zones);
+
+ // ---- stock carry and quadrant transform ----------------------------------------
+
+ [Fact]
+ public void StockWithoutZonesExposesAnEmptyList()
+ {
+ var stock = new NestPlateStock("s", new Size(48, 96));
+ Assert.Empty(stock.ClampZones);
+ }
+
+ [Theory]
+ [InlineData(1, 0.0, 0.0)]
+ [InlineData(2, -96.0, 0.0)]
+ [InlineData(3, -96.0, -48.0)]
+ [InlineData(4, 0.0, -48.0)]
+ public void ZonesTransformWithTheWorkAreaIntoEveryQuadrant(int quadrant, double dx, double dy)
+ {
+ var stock = Stock(quadrant: quadrant, zones: Zone());
+
+ var zone = Assert.Single(stock.ClampZones);
+ Assert.Equal("clamp-0", zone.Id);
+ Assert.Equal(ZoneX + dx, zone.Bounds.X); // exact bits: same translation WorkArea uses
+ Assert.Equal(dy, zone.Bounds.Y);
+ Assert.Equal(ZoneLength, zone.Bounds.Length);
+ Assert.Equal(ZoneWidth, zone.Bounds.Width);
+
+ // The keep-out stays inside the sheet box of this quadrant.
+ var work = stock.WorkArea;
+ Assert.True(zone.Bounds.Right <= work.Right + 0.25);
+ Assert.True(zone.Bounds.Bottom >= work.Bottom - 0.25 - 0.25);
+ }
+
+ [Fact]
+ public void StockDetachesZonesFromCallerBoxes()
+ {
+ var bounds = new Box(ZoneX, 0, ZoneLength, ZoneWidth);
+ var zone = new ClampZone("clamp", bounds);
+ var stock = Stock(zones: zone);
+ bounds.Offset(500, 500);
+ Assert.Equal(ZoneX, Assert.Single(stock.ClampZones).Bounds.X);
+ }
+
+ [Fact]
+ public void MapperCarriesPlateZonesInBothDirections()
+ {
+ var plate = new Plate(48, 96) { Quadrant = 2 };
+ plate.ClampZones.Add(Zone());
+
+ var stock = DrawingJobMapper.FromPlate("s", plate, quantity: null);
+ var zone = Assert.Single(stock.ClampZones);
+ Assert.Equal(ZoneX - 96, zone.Bounds.X);
+ Assert.Equal(0, zone.Bounds.Y);
+
+ // Materializing the stock back keeps the placement frame: no second transform.
+ var materialized = DrawingJobMapper.CreatePlate(stock);
+ var copy = Assert.Single(materialized.ClampZones);
+ Assert.Equal(zone.Bounds.X, copy.Bounds.X);
+ Assert.Equal(0, copy.Bounds.Y); // quadrant 2 keeps Y at zero
+ Assert.Equal("clamp-0", copy.Id);
+ }
+
+ [Fact]
+ public void StockBuilderCarriesTemplateZonesForEveryOption()
+ {
+ var template = new Plate(48, 96);
+ template.ClampZones.Add(Zone("a", 3));
+ template.ClampZones.Add(Zone("b", 20));
+
+ var single = Assert.Single(NestStockBuilder.SinglePlate(template));
+ Assert.Equal(2, single.ClampZones.Count);
+
+ var options = NestStockBuilder.FromTemplate(template,
+ [new PlateOption { Width = 48, Length = 96 }, new PlateOption { Width = 40, Length = 80 }]);
+ Assert.All(options, stock => Assert.Equal(2, stock.ClampZones.Count));
+ }
+
+ // ---- validator ------------------------------------------------------------------
+
+ [Fact]
+ public void ValidatorAcceptsEdgeFlushZones()
+ {
+ var job = new NestJob([], [Stock(zones: new[] { Zone(), Zone("flush", 94.5) })]);
+ NestJobValidator.Validate(job); // y = 0 front edge and right-edge flush both legal
+ }
+
+ [Fact]
+ public void ValidatorRejectsZonesOutsideTheSheet()
+ {
+ var beyond = new NestJob([], [Stock(zones: Zone(x: 95.5))]);
+ Assert.Throws(() => NestJobValidator.Validate(beyond));
+
+ var belowEdge = new NestJob([], [new NestPlateStock("s", new Size(48, 96),
+ clampZones: [new ClampZone("under", new Box(5, -0.5, 1.5, 0.625))])]);
+ Assert.Throws(() => NestJobValidator.Validate(belowEdge));
+ }
+
+ [Fact]
+ public void ValidatorRejectsDuplicateZoneIds()
+ {
+ var job = new NestJob([], [Stock(zones: new[] { Zone("dup", 3), Zone("dup", 20) })]);
+ Assert.Throws(() => NestJobValidator.Validate(job));
+ }
+
+ // ---- repeated-layout identity (C8) ----------------------------------------------
+
+ [Fact]
+ public void DifferentClampSetsAreDifferentStock()
+ {
+ var plain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25);
+ var clamped = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
+ clampZones: [Zone()]);
+ var sameAsPlain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
+ clampZones: [Zone()]);
+
+ Assert.False(RepeatedPlateGrouping.SameStock(plain, clamped));
+ Assert.True(RepeatedPlateGrouping.SameStock(clamped, sameAsPlain));
+
+ var pose = new NestJobPlacement("p", 0, 1, 1, 0);
+ var groups = RepeatedPlateGrouping.Create(
+ [
+ new NestJobPlateResult(0, clamped, [pose]),
+ new NestJobPlateResult(1, plain, [pose]),
+ new NestJobPlateResult(2, sameAsPlain, [pose]),
+ ], consolidate: true);
+ // sheet2 repeats sheet0's exact layout and an identical clamp set, so only the
+ // plain-clamp sheet stays a separate group; the clamped layout is never reused
+ // for the plain sheet (C8).
+ Assert.Equal(new[] { 2, 1 }, groups.Select(g => g.Quantity).ToArray());
+ }
+
+ // ---- layout check (C1/C5) ---------------------------------------------------------
+
+ private static NestJobPart RectPart(string id = "p", double w = 2, double h = 2) =>
+ new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(w, h)), 1);
+
+ private static IReadOnlyList Check(NestPlateStock stock, double x, double y)
+ {
+ var job = new NestJob([RectPart()], [stock]);
+ var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
+ [new NestJobPlateResult(0, stock, [new NestJobPlacement("p", 0, x, y, 0)])],
+ [], []);
+ return NestLayoutCheck.Violations(job, result);
+ }
+
+ [Fact]
+ public void CheckerFlagsPartsInsideTheInflatedClampRectangle()
+ {
+ // keep-out = zone inflated by bottom EdgeSpacing (0.25): x [~5.0, ~7.0], y up to ~0.875
+ var violations = Check(Stock(zones: Zone()), x: 5.3, y: 0.4);
+ Assert.Contains(violations, v => v.Contains("clamp-0") && v.Contains("clamp zone"));
+ }
+
+ [Fact]
+ public void CheckerAcceptsPartsClearOfTheInflatedClampRectangle()
+ {
+ // Above the inflated depth (0.625 + 0.25) and clear in X beyond the inflated width.
+ Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 5.3, y: 1.0),
+ v => v.Contains("clamp zone"));
+ Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 7.2, y: 0.4),
+ v => v.Contains("clamp zone"));
+ }
+
+ [Fact]
+ public void CheckerEnforcesTransformedZonesInOtherQuadrants()
+ {
+ // Quadrant 3 moves the front-edge zone to (5.25-96, -48); a part inside it must red.
+ var stock = Stock(quadrant: 3, zones: Zone());
+ var violations = Check(stock, x: 5.25 - 96 + 0.1, y: -48 + 0.4);
+ Assert.Contains(violations, v => v.Contains("clamp zone"));
+
+ // The same sheet a clear distance away from the transformed zone passes.
+ Assert.DoesNotContain(Check(stock, x: -50, y: -40), v => v.Contains("clamp zone"));
+ }
+
+ [Fact]
+ public void EmptyClampListAddsNoViolations()
+ {
+ Assert.DoesNotContain(Check(Stock(), x: 10, y: 10), v => v.Contains("clamp"));
+ }
+}
diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
index 0cb8260..5688256 100644
--- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
+++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using OpenNest.CNC;
namespace OpenNest.Engine.Jobs.Adapters;
@@ -54,7 +55,9 @@ public static class DrawingJobMapper
quantity,
plate.PartSpacing,
plate.EdgeSpacing,
- plate.Quadrant
+ plate.Quadrant,
+ cost: null,
+ clampZones: new List(plate.ClampZones)
);
}
@@ -104,12 +107,18 @@ public static class DrawingJobMapper
internal static double LegacyStep(RotationPolicy policy) =>
policy.Kind == RotationPolicyKind.Fixed ? OpenNest.Math.Angle.TwoPI : policy.Step;
- internal static Plate CreatePlate(NestPlateStock stock) =>
- new(stock.Size)
+ internal static Plate CreatePlate(NestPlateStock stock)
+ {
+ var plate = new Plate(stock.Size)
{
Quantity = 1,
PartSpacing = stock.PartSpacing,
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);
+ return plate;
+ }
}
diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs
index 94baea1..ac322bb 100644
--- a/OpenNest.Engine/Jobs/NestJobValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobValidator.cs
@@ -32,6 +32,7 @@ public static class NestJobValidator
$"Invalid stock dimensions/settings: {stock.Id}.",
nameof(job)
);
+ ValidateClampZones(stock, job);
}
foreach (var part in job.Parts)
{
@@ -71,6 +72,43 @@ public static class NestJobValidator
+ string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id)));
}
+ ///
+ /// Every stock zone must be uniquely identified and lie within its own sheet
+ /// (edge-inclusive; geometry and finiteness already enforced by ).
+ /// Zones are compared in the placement frame, where the stock carries them.
+ ///
+ private static void ValidateClampZones(NestPlateStock stock, NestJob job)
+ {
+ if (stock.ClampZones.Count == 0)
+ return;
+
+ var ids = new HashSet(StringComparer.Ordinal);
+ var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
+ var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
+ const double slack = NestTolerances.WorkAreaSlack;
+
+ foreach (var zone in stock.ClampZones)
+ {
+ if (!ids.Add(zone.Id))
+ throw new ArgumentException(
+ $"Clamp zone id '{zone.Id}' is defined twice on stock {stock.Id}.",
+ nameof(job)
+ );
+
+ var b = zone.Bounds;
+ if (
+ b.Left < left - slack
+ || b.Right > left + stock.Size.Length + slack
+ || b.Bottom < bottom - slack
+ || b.Top > bottom + stock.Size.Width + slack
+ )
+ throw new ArgumentException(
+ $"Clamp zone '{zone.Id}' on stock {stock.Id} lies outside the sheet.",
+ nameof(job)
+ );
+ }
+ }
+
internal static void ValidateCandidate(
PlateCandidate candidate,
NestPlateStock stock,
diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
index c2597a3..e09cbc9 100644
--- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs
+++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
@@ -153,11 +153,63 @@ public static class NestLayoutCheck
ValidateBounds(parts, plate, requirements, result);
ValidateAreaBudget(parts, plate, result);
ValidateSpacing(parts, plate.PartSpacing, requirements, result);
+ if (plate.ClampZones.Count > 0)
+ ValidateClamps(parts, plate, requirements, result);
}
return result;
}
+ ///
+ /// C1/C5: no part material may enter a clamp zone inflated by the plate's bottom-edge
+ /// spacing (D6) - the same clearance the part owes the sheet edge, enforced with the
+ /// shared spacing slack so exact engine placements are not reddened by rounding.
+ ///
+ private static void ValidateClamps(
+ List parts,
+ Plate plate,
+ IReadOnlyDictionary requirements,
+ List result
+ )
+ {
+ var inflateBy = InflationFor(plate.EdgeSpacing.Bottom);
+
+ foreach (var zone in plate.ClampZones)
+ {
+ // Box.Offset(d) grows the rectangle on all four sides.
+ var keepOut = zone.Bounds.Offset(inflateBy);
+ var keepOutOutline = RectangleOutline(keepOut);
+
+ foreach (var part in parts)
+ {
+ if (!BoxesTouch(keepOut, MaterialBounds(part)))
+ continue;
+
+ var material = Outline(part, 0);
+ if (material == null || !Overlaps(material, keepOutOutline))
+ continue;
+
+ result.Add(
+ $"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) overlaps "
+ + $"clamp zone '{zone.Id}' including its edge clearance"
+ );
+ }
+ }
+ }
+
+ private static PartOutline RectangleOutline(Box box)
+ {
+ var ring = new List
+ {
+ new Line(box.Left, box.Bottom, box.Right, box.Bottom),
+ new Line(box.Right, box.Bottom, box.Right, box.Top),
+ new Line(box.Right, box.Top, box.Left, box.Top),
+ new Line(box.Left, box.Top, box.Left, box.Bottom),
+ };
+ var profile = new ShapeProfile(ring);
+ return Outline(profile, new Vector(0, 0), 0);
+ }
+
///
/// Checks what the materialized layout cannot show: every sheet must be
/// one of the job's own stock entries (an engine may not invent a sheet
@@ -239,8 +291,31 @@ public static class NestLayoutCheck
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
&& expected.Quadrant == actual.Quadrant
+ && SameZones(expected.ClampZones, actual.ClampZones)
);
+ // Clamp sets are part of stock settings; a different set is not the offered stock.
+ private static bool SameZones(
+ IReadOnlyList x,
+ IReadOnlyList y
+ )
+ {
+ if (x.Count != y.Count)
+ return false;
+ for (var i = 0; i < x.Count; i++)
+ {
+ if (
+ !string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal)
+ || !x[i].Bounds.X.IsEqualTo(y[i].Bounds.X)
+ || !x[i].Bounds.Y.IsEqualTo(y[i].Bounds.Y)
+ || !x[i].Bounds.Length.IsEqualTo(y[i].Bounds.Length)
+ || !x[i].Bounds.Width.IsEqualTo(y[i].Bounds.Width)
+ )
+ return false;
+ }
+ return true;
+ }
+
private static string Describe(RotationPolicy policy) =>
policy.Kind switch
{
diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs
index 15d8e0b..85c0ad6 100644
--- a/OpenNest.Engine/Jobs/NestPlateStock.cs
+++ b/OpenNest.Engine/Jobs/NestPlateStock.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
@@ -14,19 +15,21 @@ public sealed class NestPlateStock
int? quantity = null,
double partSpacing = 0,
Spacing edgeSpacing = default,
- int quadrant = 1
+ int quadrant = 1,
+ IReadOnlyList clampZones = null
)
- : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { }
+ : this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null, clampZones) { }
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
- Spacing edgeSpacing, int quadrant, double? cost)
+ Spacing edgeSpacing, int quadrant, double? cost,
+ IReadOnlyList clampZones = null)
{
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
- Cost = cost;
ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
+ Cost = cost;
Id = id;
Size = size;
Quantity = quantity;
@@ -41,8 +44,27 @@ public sealed class NestPlateStock
size.Length - edgeSpacing.Left - edgeSpacing.Right,
size.Width - edgeSpacing.Bottom - edgeSpacing.Top
);
+ // Zones arrive in plate coordinates and are carried in the placement frame,
+ // translated exactly like WorkArea. Detached copies: stock is immutable input.
+ if (clampZones == null || clampZones.Count == 0)
+ {
+ ClampZones = Array.Empty();
+ }
+ else
+ {
+ var zones = 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));
+ ClampZones = zones;
+ }
}
+ ///
+ /// Keep-out rectangles in the placement frame (quadrant applied), translated exactly
+ /// like . Empty when the plate defines none; never null.
+ ///
+ public IReadOnlyList ClampZones { get; }
+
/// Generic per-physical-sheet cost; null selects area scoring.
public double? Cost { get; }
public string Id { get; }
diff --git a/OpenNest.Engine/Jobs/NestStockBuilder.cs b/OpenNest.Engine/Jobs/NestStockBuilder.cs
index 6f4db77..12bd8df 100644
--- a/OpenNest.Engine/Jobs/NestStockBuilder.cs
+++ b/OpenNest.Engine/Jobs/NestStockBuilder.cs
@@ -30,7 +30,8 @@ public static class NestStockBuilder
template.PartSpacing,
template.EdgeSpacing,
template.Quadrant,
- option.Cost == 0 ? null : option.Cost
+ option.Cost == 0 ? null : option.Cost,
+ new List(template.ClampZones)
));
}
NestJobValidator.ValidateStockCosts(stock);
diff --git a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
index 9f4d5f1..6b42a08 100644
--- a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
+++ b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
@@ -41,7 +41,31 @@ internal static class RepeatedPlateGrouping
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& a.Quantity == b.Quantity
&& a.Cost.HasValue == b.Cost.HasValue
- && (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value));
+ && (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
+ && SameZones(a.ClampZones, b.ClampZones);
+
+ // C8: a layout solved with clamps is never reused for a plate without them - and a
+ // different clamp set is different stock. Ordered; ids and exact bits participate.
+ private static bool SameZones(
+ System.Collections.Generic.IReadOnlyList x,
+ System.Collections.Generic.IReadOnlyList y
+ )
+ {
+ if (x.Count != y.Count)
+ return false;
+ for (var i = 0; i < x.Count; i++)
+ {
+ if (
+ !string.Equals(x[i].Id, y[i].Id, StringComparison.Ordinal)
+ || Bits(x[i].Bounds.X) != Bits(y[i].Bounds.X)
+ || Bits(x[i].Bounds.Y) != Bits(y[i].Bounds.Y)
+ || Bits(x[i].Bounds.Length) != Bits(y[i].Bounds.Length)
+ || Bits(x[i].Bounds.Width) != Bits(y[i].Bounds.Width)
+ )
+ return false;
+ }
+ return true;
+ }
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);