From a3f1124d115bfcf7182a7ce6c43646e1253cc78f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 15:40:27 -0400 Subject: [PATCH] feat(engine): carry clamp zones into stock, validation and repeat identity NestPlateStock takes an optional clamp-zone list, translated into the placement frame exactly like WorkArea (quadrants 1-4) and detached from caller boxes; empty input keeps stock bit-identical to before. Plate mappings (DrawingJobMapper.FromPlate/CreatePlate, NestStockBuilder) map zones across; materialized plates carry them for checking. NestJobValidator rejects zones outside the sheet or duplicate ids. RepeatedPlateGrouping and NestLayoutCheck treat clamp sets as stock identity (C8), and the layout checker flags part material inside a zone inflated by bottom-edge spacing with the shared spacing slack (C1/C5) - no tolerance loosened. --- .../Jobs/ClampZoneEngineTests.cs | 207 ++++++++++++++++++ .../Jobs/Adapters/DrawingJobMapper.cs | 15 +- OpenNest.Engine/Jobs/NestJobValidator.cs | 38 ++++ OpenNest.Engine/Jobs/NestLayoutCheck.cs | 75 +++++++ OpenNest.Engine/Jobs/NestPlateStock.cs | 30 ++- OpenNest.Engine/Jobs/NestStockBuilder.cs | 3 +- OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs | 26 ++- 7 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/ClampZoneEngineTests.cs 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);