feat(core): add rectangular clamp zones to plates

Introduce ClampZone: an axis-aligned rectangular keep-out in plate
coordinates (id + detached Box), validated finite, positive-size and
identified. Plate.ClampZones is a first-class collection alongside
Parts/CutOffs with a ClampZonesChanged event for view refresh. Zones are
never parts: quantity, utilization, cut sequence and CNC output ignore
them (locked by tests); they carry no z-height reasoning.
This commit is contained in:
aj committed 2026-10-12 15:09:08 -04:00
1 parent 311178c15a
commit 137eaea046
3 files changed
+205

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
using System;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>Stable identifier used by persistence and diagnostics.</summary>
public string Id { get; }
/// <summary>
/// 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).
/// </summary>
public Box Bounds { get; }
public override string ToString() =>
$"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
}
}
+20
View File
@@ -55,6 +55,10 @@ namespace OpenNest
Parts.ItemAdded += Parts_PartAdded;
Parts.ItemRemoved += Parts_PartRemoved;
CutOffs = new ObservableList<CutOff>();
ClampZones = new ObservableList<ClampZone>();
ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
Quadrant = 1;
}
@@ -102,6 +106,22 @@ namespace OpenNest
/// </summary>
public ObservableList<CutOff> CutOffs { get; set; }
/// <summary>
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
/// cut sequence and CNC output; placement rules and display consume them. Mutate
/// through this list; <see cref="ClampZonesChanged"/> fires for every change.
/// </summary>
public ObservableList<ClampZone> ClampZones { get; }
/// <summary>
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
/// the zone overlay. Carries no geometry arguments.
/// </summary>
public event EventHandler? ClampZonesChanged;
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
/// <summary>
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
+133
View File
@@ -0,0 +1,133 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests;
/// <summary>
/// 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.
/// </summary>
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<ArgumentException>(() => new ClampZone("clamp", new Box(x, y, length, width)));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void MissingIdIsRejected(string? id)
{
Assert.ThrowsAny<ArgumentException>(
() => new ClampZone(id!, new Box(0, 0, 1.5, 0.625))
);
}
[Fact]
public void NullBoundsRejected()
{
Assert.Throws<ArgumentNullException>(() => 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);
}
}