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);