diff --git a/OpenNest.Core/ClampZone.cs b/OpenNest.Core/ClampZone.cs new file mode 100644 index 00000000..43f15fb4 --- /dev/null +++ b/OpenNest.Core/ClampZone.cs @@ -0,0 +1,52 @@ +using System; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame: + /// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps + /// are the first users; a future "don't cut here" tag is another entry of this shape. + /// Zones are exclusion and display only - never material, quantity, utilization, + /// cut sequence or CNC output. Rectangles only: axis-aligned by construction. + /// + public sealed class ClampZone + { + public ClampZone(string id, Box bounds) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentNullException.ThrowIfNull(bounds); + if ( + !double.IsFinite(bounds.X) + || !double.IsFinite(bounds.Y) + || !double.IsFinite(bounds.Length) + || !double.IsFinite(bounds.Width) + ) + throw new ArgumentException( + $"Clamp zone '{id}' bounds must have finite coordinates and size.", + nameof(bounds) + ); + if (bounds.Length <= 0 || bounds.Width <= 0) + throw new ArgumentException( + $"Clamp zone '{id}' must have a positive length (X) and width (Y).", + nameof(bounds) + ); + + Id = id; + // Detached copy: the zone owns its rectangle, callers may keep mutating theirs. + Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width); + } + + /// Stable identifier used by persistence and diagnostics. + public string Id { get; } + + /// + /// The rectangle in plate coordinates: Length is the extent along X, Width the + /// extent along Y (depth from the y = 0 operator-side edge for machine clamps). + /// + public Box Bounds { get; } + + public override string ToString() => + $"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}"; + } +} diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 0f3cf969..c441d961 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -55,6 +55,10 @@ namespace OpenNest Parts.ItemAdded += Parts_PartAdded; Parts.ItemRemoved += Parts_PartRemoved; CutOffs = new ObservableList(); + ClampZones = new ObservableList(); + ClampZones.ItemAdded += (_, _) => OnClampZonesChanged(); + ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged(); + ClampZones.ItemChanged += (_, _) => OnClampZonesChanged(); Quadrant = 1; } @@ -102,6 +106,22 @@ namespace OpenNest /// public ObservableList CutOffs { get; set; } + /// + /// Fixed rectangular machine-clamp keep-outs in plate coordinates + /// (). Never parts: excluded from quantity, utilization, + /// cut sequence and CNC output; placement rules and display consume them. Mutate + /// through this list; fires for every change. + /// + public ObservableList ClampZones { get; } + + /// + /// Raised after content changes so views can refresh + /// the zone overlay. Carries no geometry arguments. + /// + public event EventHandler? ClampZonesChanged; + + private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty); + /// /// Regenerates all cut-off drawings and materializes them as parts. /// Existing cut-off parts are removed first, then each cut-off is diff --git a/OpenNest.Tests/ClampZoneTests.cs b/OpenNest.Tests/ClampZoneTests.cs new file mode 100644 index 00000000..e720f927 --- /dev/null +++ b/OpenNest.Tests/ClampZoneTests.cs @@ -0,0 +1,133 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +/// +/// Rectangular machine-clamp keep-out zones: a first-class rectangle on the plate, +/// never a part. Zones are exclusion and display only - quantity, utilization and +/// cut accounting must never see them. +/// +public class ClampZoneTests +{ + private static ClampZone Valid(string id = "clamp-1") => + new(id, new Box(5.25, 0, 1.5, 0.625)); + + [Fact] + public void ZoneCarriesIdAndBounds() + { + var zone = Valid(); + Assert.Equal("clamp-1", zone.Id); + Assert.Equal(5.25, zone.Bounds.X); + Assert.Equal(0, zone.Bounds.Y); + Assert.Equal(1.5, zone.Bounds.Length); + Assert.Equal(0.625, zone.Bounds.Width); + } + + [Fact] + public void ZoneOwnsADetachedCopyOfItsBounds() + { + var bounds = new Box(5.25, 0, 1.5, 0.625); + var zone = new ClampZone("clamp", bounds); + bounds.Offset(100, 100); + Assert.Equal(5.25, zone.Bounds.X); + Assert.Equal(0, zone.Bounds.Y); + } + + [Theory] + [InlineData(double.NaN, 0, 1.5, 0.625)] + [InlineData(0, double.PositiveInfinity, 1.5, 0.625)] + [InlineData(0, 0, double.NaN, 0.625)] + [InlineData(0, 0, 1.5, 0)] // zero-size zone keeps out nothing + [InlineData(0, 0, -1.5, 0.625)] // negative size is not a rectangle + [InlineData(0, 0, 1.5, -0.625)] + public void MalformedBoundsAreRejected(double x, double y, double length, double width) + { + Assert.Throws(() => new ClampZone("clamp", new Box(x, y, length, width))); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void MissingIdIsRejected(string? id) + { + Assert.ThrowsAny( + () => new ClampZone(id!, new Box(0, 0, 1.5, 0.625)) + ); + } + + [Fact] + public void NullBoundsRejected() + { + Assert.Throws(() => new ClampZone("clamp", null!)); + } + + [Fact] + public void PlateStartsEmptyAndKeepsIndependentLists() + { + var a = new Plate(48, 96); + var b = new Plate(48, 96); + Assert.Empty(a.ClampZones); + a.ClampZones.Add(Valid()); + Assert.Single(a.ClampZones); + Assert.Empty(b.ClampZones); + } + + [Fact] + public void ZoneChangesRaiseClampZonesChanged() + { + var plate = new Plate(48, 96); + var raised = 0; + plate.ClampZonesChanged += (_, _) => raised++; + + var zone = Valid(); + plate.ClampZones.Add(zone); + Assert.Equal(1, raised); + + plate.ClampZones.Remove(zone); + Assert.Equal(2, raised); + } + + [Fact] + public void ZonesAreInvisibleToQuantityAndUtilization() + { + var plate = new Plate(48, 96) { Quantity = 1 }; + for (var i = 0; i < 8; i++) + plate.ClampZones.Add(new ClampZone($"clamp-{i}", new Box(i * 2.0, 0, 1.5, 0.625))); + + var drawing = MakeRectDrawing(10, 5); + drawing.Quantity.Required = 4; + var part = new Part(drawing); + plate.Parts.Add(part); + + // The single placed part accounts exactly as it does on a plate without clamps. + Assert.Equal(1, drawing.Quantity.Nested); + Assert.Single(plate.Parts); // clamp zones never enter Parts + Assert.Equal(drawing.Area / plate.Area(), plate.Utilization(), 9); + + plate.Parts.Remove(part); + Assert.Equal(0, drawing.Quantity.Nested); + } + + [Fact] + public void ZonesNeverEnterCutSequenceOrPartList() + { + var plate = new Plate(48, 96); + plate.ClampZones.Add(Valid()); + Assert.All(plate.Parts, p => Assert.False(p.BaseDrawing.IsCutOff)); + Assert.Empty(plate.Parts); + Assert.DoesNotContain(plate.ClampZones, z => ReferenceEquals(z, null)); + } + + private static Drawing MakeRectDrawing(double length, double width) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(length, 0))); + pgm.Codes.Add(new LinearMove(new Vector(length, width))); + pgm.Codes.Add(new LinearMove(new Vector(0, width))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing("rect", pgm); + } +}