diff --git a/OpenNest.Core/ClampRail.cs b/OpenNest.Core/ClampRail.cs
new file mode 100644
index 0000000..ae13b30
--- /dev/null
+++ b/OpenNest.Core/ClampRail.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// The machine's fixed clamp rail: identical rectangular clamps whose X centers are
+ /// measured from the plate origin along the origin-side (operator) edge. Plates always
+ /// start at the origin, so the same rail resolves onto every sheet size without
+ /// re-scaling; a clamp whose footprint lies wholly beyond a sheet is skipped and one
+ /// that still reaches onto it is kept, clipped to the sheet.
+ ///
+ public sealed class ClampRail : IEquatable
+ {
+ public ClampRail(double width, double depth, IEnumerable centers)
+ {
+ ArgumentNullException.ThrowIfNull(centers);
+ if (!double.IsFinite(width) || width <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(width),
+ "Clamp width must be finite and positive."
+ );
+ if (!double.IsFinite(depth) || depth <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(depth),
+ "Clamp depth must be finite and positive."
+ );
+
+ var sorted = new SortedSet();
+ foreach (var center in centers)
+ {
+ if (!double.IsFinite(center) || center < 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(centers),
+ "Clamp centers must be finite and measured from the origin (zero or more)."
+ );
+ sorted.Add(center);
+ }
+
+ Width = width;
+ Depth = depth;
+ Centers = sorted.ToArray();
+ }
+
+ /// Clamp extent along the edge (X).
+ public double Width { get; }
+
+ /// Clamp intrusion into the sheet from the origin-side edge (Y).
+ public double Depth { get; }
+
+ /// Distinct clamp centers along X from the origin, ascending.
+ public IReadOnlyList Centers { get; }
+
+ ///
+ /// The zones this rail places on a sheet, in plate coordinates (the frame of
+ /// : origin at the sheet's lower-left corner). The
+ /// quadrant locates the plate origin, and with it the origin-side edge, on the sheet.
+ /// Ids are clamp-N by rail position, so one physical clamp keeps its id on
+ /// every sheet even when others are skipped.
+ ///
+ public IReadOnlyList ResolveFor(Size size, int quadrant)
+ {
+ var length = size.Length;
+ var sheetWidth = size.Width;
+ if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth))
+ return Array.Empty();
+
+ var extendsLeft = quadrant is 2 or 3;
+ var extendsDown = quadrant is 3 or 4;
+ var depth = System.Math.Min(Depth, sheetWidth);
+ var y = extendsDown ? sheetWidth - depth : 0;
+ var half = Width / 2;
+
+ var zones = new List(Centers.Count);
+ for (var i = 0; i < Centers.Count; i++)
+ {
+ // Distances from the origin along the edge.
+ var near = Centers[i] - half;
+ var far = Centers[i] + half;
+ if (near >= length)
+ continue; // wholly beyond this sheet
+
+ var clipped = near < 0 || far > length;
+ var start = near < 0 ? 0 : near;
+ var extent = clipped ? System.Math.Min(far, length) - start : Width;
+ if (!(extent > 0))
+ continue;
+
+ // Plate coordinates are measured from the lower-left corner; a sheet extending
+ // to -X from the origin has its origin edge at x = length.
+ var x = extendsLeft ? length - (start + extent) : start;
+ zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, extent, depth)));
+ }
+
+ return zones;
+ }
+
+ /// True when the plate's zones are exactly (ids and bits) this rail's for its sheet.
+ public bool Matches(Plate plate)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
+ }
+
+ internal static bool SameZones(IList current, IReadOnlyList expected)
+ {
+ if (current.Count != expected.Count)
+ return false;
+ for (var i = 0; i < current.Count; i++)
+ {
+ var a = current[i];
+ var b = expected[i];
+ if (
+ !string.Equals(a.Id, b.Id, StringComparison.Ordinal)
+ || a.Bounds.X != b.Bounds.X
+ || a.Bounds.Y != b.Bounds.Y
+ || a.Bounds.Length != b.Bounds.Length
+ || a.Bounds.Width != b.Bounds.Width
+ )
+ return false;
+ }
+ return true;
+ }
+
+ public bool Equals(ClampRail other) =>
+ other is not null
+ && Width.Equals(other.Width)
+ && Depth.Equals(other.Depth)
+ && Centers.SequenceEqual(other.Centers);
+
+ public override bool Equals(object obj) => Equals(obj as ClampRail);
+
+ public override int GetHashCode()
+ {
+ var hash = HashCode.Combine(Width, Depth, Centers.Count);
+ foreach (var center in Centers)
+ hash = HashCode.Combine(hash, center);
+ return hash;
+ }
+
+ public override string ToString() =>
+ $"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Centers.Select(c => c.ToString("R")))}]";
+ }
+}
diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs
index 2f9d1ca..6d288c8 100644
--- a/OpenNest.Core/Nest.cs
+++ b/OpenNest.Core/Nest.cs
@@ -65,6 +65,21 @@ namespace OpenNest
return plate;
}
+ ///
+ /// Makes this nest's machine clamp rail: new plates follow it
+ /// () and every existing plate re-resolves its
+ /// zones from it. Null, or a rail without clamps, removes the rail and every plate's
+ /// zones; the rail is nest-wide, so per-plate zones are replaced either way.
+ ///
+ public void SetClampRail(ClampRail rail)
+ {
+ if (rail != null && rail.Centers.Count == 0)
+ rail = null;
+ PlateDefaults.ClampRail = rail;
+ foreach (var plate in Plates)
+ plate.SetClampRail(rail);
+ }
+
public void UpdateDrawingQuantities()
{
foreach (var drawing in Drawings)
@@ -114,6 +129,12 @@ namespace OpenNest
set { plate.PartSpacing = value; }
}
+ ///
+ /// The machine clamp rail new plates follow, or null for none. Set through
+ /// to also update existing plates.
+ ///
+ public ClampRail ClampRail { get; set; }
+
public void SetFromExisting(Plate plate)
{
Quadrant = plate.Quadrant;
@@ -124,7 +145,7 @@ namespace OpenNest
public Plate CreateNew()
{
- return new Plate()
+ var created = new Plate()
{
Size = Size,
EdgeSpacing = EdgeSpacing,
@@ -132,6 +153,9 @@ namespace OpenNest
Quadrant = Quadrant,
Quantity = 1,
};
+ if (ClampRail != null)
+ created.SetClampRail(ClampRail);
+ return created;
}
}
}
diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs
index c441d96..3fa0ce8 100644
--- a/OpenNest.Core/Plate.cs
+++ b/OpenNest.Core/Plate.cs
@@ -85,9 +85,20 @@ namespace OpenNest
public Spacing EdgeSpacing;
///
- /// The size of the plate.
+ /// The size of the plate. A plate following a re-resolves
+ /// its clamp zones for the new size.
///
- public Size Size { get; set; }
+ public Size Size
+ {
+ get { return size; }
+ set
+ {
+ size = value;
+ ResolveClampRail();
+ }
+ }
+
+ private Size size;
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
@@ -122,6 +133,42 @@ namespace OpenNest
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
+ ///
+ /// The machine clamp rail this plate follows, or null when its
+ /// are explicit (engine sheets, files without a rail). While
+ /// set, the zones are derived: re-resolved whenever or
+ /// changes, because the clamps are fixed to the machine.
+ ///
+ public ClampRail ClampRail { get; private set; }
+
+ ///
+ /// Follows and replaces the zones with its resolution for
+ /// this sheet; null detaches and removes every zone. Unchanged zones raise no events.
+ ///
+ public void SetClampRail(ClampRail rail)
+ {
+ ClampRail = rail;
+ ReplaceClampZones(
+ rail == null ? Array.Empty() : rail.ResolveFor(Size, Quadrant)
+ );
+ }
+
+ private void ResolveClampRail()
+ {
+ // Called from the constructor's Size assignment before the zone list exists.
+ if (ClampRail != null)
+ ReplaceClampZones(ClampRail.ResolveFor(Size, Quadrant));
+ }
+
+ private void ReplaceClampZones(IReadOnlyList zones)
+ {
+ if (ClampRail.SameZones(ClampZones, zones))
+ return;
+ ClampZones.Clear();
+ foreach (var zone in zones)
+ ClampZones.Add(zone);
+ }
+
///
/// Regenerates all cut-off drawings and materializes them as parts.
/// Existing cut-off parts are removed first, then each cut-off is
@@ -256,7 +303,11 @@ namespace OpenNest
public int Quadrant
{
get { return quadrant; }
- set { quadrant = value <= 4 && value > 0 ? value : 1; }
+ set
+ {
+ quadrant = value <= 4 && value > 0 ? value : 1;
+ ResolveClampRail();
+ }
}
///
diff --git a/OpenNest.Tests/ClampRailTests.cs b/OpenNest.Tests/ClampRailTests.cs
new file mode 100644
index 0000000..eae374c
--- /dev/null
+++ b/OpenNest.Tests/ClampRailTests.cs
@@ -0,0 +1,266 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+///
+/// The fixed machine clamp rail: X centers measured from the plate origin along the
+/// origin-side edge, resolved onto every sheet size without re-scaling (D2/D3).
+///
+public class ClampRailTests
+{
+ // The user's machine: ~8 clamps, 1.5" along the edge x 0.625" into the sheet.
+ private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
+
+ private static ClampRail Machine() => new(1.5, 0.625, MachineCenters);
+
+ // Plate(width, length): Width is the Y extent, Length the X extent along the clamp edge.
+ private static Plate Sheet(double width, double length, int quadrant = 1) =>
+ new(width, length) { Quadrant = quadrant };
+
+ [Theory]
+ [InlineData(0, 0.625)]
+ [InlineData(-1, 0.625)]
+ [InlineData(double.NaN, 0.625)]
+ [InlineData(double.PositiveInfinity, 0.625)]
+ [InlineData(1.5, 0)]
+ [InlineData(1.5, -0.5)]
+ [InlineData(1.5, double.NaN)]
+ public void NonPositiveOrNonFiniteSizesAreRejected(double width, double depth)
+ {
+ Assert.Throws(() => new ClampRail(width, depth, new[] { 6.0 }));
+ }
+
+ [Theory]
+ [InlineData(-0.5)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ public void CentersMustBeFiniteAndFromTheOrigin(double center)
+ {
+ Assert.Throws(() => new ClampRail(1.5, 0.625, new[] { 6, center }));
+ Assert.Throws(() => new ClampRail(1.5, 0.625, null!));
+ }
+
+ [Fact]
+ public void CentersAreSortedAndDistinct()
+ {
+ var rail = new ClampRail(1.5, 0.625, new[] { 36.0, 6, 21, 6 });
+ Assert.Equal(new[] { 6.0, 21, 36 }, rail.Centers);
+ }
+
+ [Fact]
+ public void FullLengthSheetGetsEveryClampOnTheFrontEdge()
+ {
+ var zones = Machine().ResolveFor(new Size(60, 120), quadrant: 1);
+
+ Assert.Equal(8, zones.Count);
+ for (var i = 0; i < zones.Count; i++)
+ {
+ Assert.Equal($"clamp-{i + 1}", zones[i].Id);
+ Assert.Equal(MachineCenters[i] - 0.75, zones[i].Bounds.X);
+ Assert.Equal(0, zones[i].Bounds.Y);
+ Assert.Equal(1.5, zones[i].Bounds.Length);
+ Assert.Equal(0.625, zones[i].Bounds.Width);
+ }
+ }
+
+ [Fact]
+ public void ShorterSheetSkipsClampsBeyondItWithoutRescaling()
+ {
+ // D2: a 60" sheet keeps the clamps at their table positions and drops the rest.
+ var zones = Machine().ResolveFor(new Size(48, 60), quadrant: 1);
+
+ Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-3", "clamp-4" }, zones.Select(z => z.Id));
+ Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, zones.Select(z => z.Bounds.X));
+ }
+
+ [Fact]
+ public void ClampStraddlingTheSheetEndIsClippedNotDropped()
+ {
+ var rail = new ClampRail(1.5, 0.625, new[] { 59.5, 60.5, 60.75, 61 });
+
+ var zones = rail.ResolveFor(new Size(48, 60), quadrant: 1);
+
+ // 59.5 covers [58.75, 60]; 60.5 still reaches onto the sheet [59.75, 60];
+ // 60.75 only touches the end and 61 lies wholly beyond it.
+ Assert.Equal(new[] { "clamp-1", "clamp-2" }, zones.Select(z => z.Id));
+ Assert.Equal(58.75, zones[0].Bounds.X);
+ Assert.Equal(1.25, zones[0].Bounds.Length);
+ Assert.Equal(59.75, zones[1].Bounds.X);
+ Assert.Equal(0.25, zones[1].Bounds.Length);
+ }
+
+ [Fact]
+ public void ClampAtTheOriginAndDeepClampsAreClippedToTheSheet()
+ {
+ var rail = new ClampRail(1.5, 0.625, new[] { 0.25 });
+ var origin = Assert.Single(rail.ResolveFor(new Size(48, 96), quadrant: 1));
+ Assert.Equal(0, origin.Bounds.X);
+ Assert.Equal(1.0, origin.Bounds.Length);
+
+ var deep = Assert.Single(new ClampRail(1.5, 10, new[] { 6.0 }).ResolveFor(new Size(4, 96), 1));
+ Assert.Equal(4, deep.Bounds.Width);
+ }
+
+ [Theory]
+ [InlineData(0, 96)]
+ [InlineData(48, 0)]
+ [InlineData(double.NaN, 96)]
+ public void DegenerateSheetsGetNoZones(double width, double length)
+ {
+ Assert.Empty(Machine().ResolveFor(new Size(width, length), quadrant: 1));
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void ZonesSitOnTheOriginEdgeMeasuredFromTheOriginInEveryQuadrant(int quadrant)
+ {
+ var plate = Sheet(48, 96, quadrant);
+ plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }));
+ var zone = Assert.Single(plate.ClampZones);
+
+ // Plate coordinates start at the sheet's lower-left corner; translating by the
+ // sheet's placement corner gives machine coordinates around the origin.
+ var sheet = plate.BoundingBox(includeParts: false);
+ var machine = zone.Bounds.Translate(sheet.X, sheet.Y);
+ var awayFromOrigin = quadrant is 1 or 4 ? 1 : -1;
+ var intoSheet = quadrant is 1 or 2 ? 1 : -1;
+
+ Assert.Equal(6.0, System.Math.Abs(machine.Center.X), 12);
+ Assert.Equal(awayFromOrigin, System.Math.Sign(machine.Center.X));
+ Assert.Equal(0.0, intoSheet > 0 ? machine.Bottom : machine.Top, 12);
+ Assert.Equal(0.625, machine.Width);
+ Assert.Equal(1.5, machine.Length, 12);
+ Assert.True(zone.Bounds.Left >= 0 && zone.Bounds.Right <= 96 + 1e-9);
+ Assert.True(zone.Bounds.Bottom >= 0 && zone.Bounds.Top <= 48 + 1e-9);
+ }
+
+ [Fact]
+ public void PlateFollowsItsRailWhenResizedOrRotated()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine());
+ Assert.Equal(8, plate.ClampZones.Count);
+ Assert.True(plate.ClampRail.Matches(plate));
+
+ plate.Size = new Size(48, 60);
+ Assert.Equal(4, plate.ClampZones.Count);
+
+ // Rotating swaps the sheet's extents; the machine's clamps stay where they are.
+ plate.Rotate90(RotationType.CW);
+ Assert.Equal(new Size(60, 48), plate.Size);
+ Assert.Equal(new[] { 5.25, 20.25, 35.25 }, plate.ClampZones.Select(z => z.Bounds.X));
+
+ plate.Quadrant = 2;
+ Assert.True(plate.ClampRail.Matches(plate));
+ Assert.Equal(48 - 6.75, plate.ClampZones[0].Bounds.X);
+ }
+
+ [Fact]
+ public void UnchangedResolutionRaisesNoEvents()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine());
+ var original = plate.ClampZones.ToList();
+ var changes = 0;
+ plate.ClampZonesChanged += (_, _) => changes++;
+
+ plate.Size = new Size(48, 120);
+ plate.Quadrant = 1;
+ plate.SetClampRail(Machine());
+
+ Assert.Equal(0, changes);
+ Assert.Equal(original, plate.ClampZones); // same zone instances
+ }
+
+ [Fact]
+ public void DetachingTheRailRemovesItsZones()
+ {
+ var plate = Sheet(48, 120);
+ plate.SetClampRail(Machine());
+
+ plate.SetClampRail(null!);
+
+ Assert.Null(plate.ClampRail);
+ Assert.Empty(plate.ClampZones);
+ plate.Size = new Size(48, 96);
+ Assert.Empty(plate.ClampZones);
+ }
+
+ [Fact]
+ public void PlatesWithoutARailKeepExplicitZonesThroughResizes()
+ {
+ var plate = Sheet(48, 96);
+ plate.ClampZones.Add(new ClampZone("manual", new Box(5.25, 0, 1.5, 0.625)));
+
+ plate.Size = new Size(48, 120);
+ plate.Quadrant = 3;
+
+ Assert.Equal("manual", Assert.Single(plate.ClampZones).Id);
+ Assert.False(Machine().Matches(plate));
+ }
+
+ [Fact]
+ public void NewPlatesFollowTheNestRailAtTheirOwnSize()
+ {
+ var nest = new Nest();
+ nest.PlateDefaults.Size = new Size(48, 60);
+ nest.SetClampRail(Machine());
+
+ var plate = nest.CreatePlate();
+
+ Assert.Same(nest.PlateDefaults.ClampRail, plate.ClampRail);
+ Assert.Equal(4, plate.ClampZones.Count);
+ plate.Size = new Size(60, 120);
+ Assert.Equal(8, plate.ClampZones.Count);
+ }
+
+ [Fact]
+ public void NestRailReplacesEveryPlatesZones()
+ {
+ var nest = new Nest();
+ nest.PlateDefaults.Size = new Size(48, 120);
+ var a = nest.CreatePlate();
+ var b = nest.CreatePlate();
+ b.Size = new Size(48, 60);
+ b.ClampZones.Add(new ClampZone("manual", new Box(1, 0, 1, 1)));
+
+ nest.SetClampRail(Machine());
+ Assert.Equal(8, a.ClampZones.Count);
+ Assert.Equal(4, b.ClampZones.Count);
+ Assert.DoesNotContain(b.ClampZones, z => z.Id == "manual");
+
+ // A rail without clamps is no rail: zones and the template are cleared everywhere.
+ nest.SetClampRail(new ClampRail(1.5, 0.625, Array.Empty()));
+ Assert.Null(nest.PlateDefaults.ClampRail);
+ Assert.All(nest.Plates, p => Assert.Empty(p.ClampZones));
+ Assert.Empty(nest.CreatePlate().ClampZones);
+ }
+
+ [Fact]
+ public void RailZonesStayOutOfQuantityAndUtilization()
+ {
+ var drawing = new Drawing("part", new OpenNest.CNC.Program());
+ var nest = new Nest();
+ nest.Drawings.Add(drawing);
+ nest.SetClampRail(Machine());
+ var plate = nest.CreatePlate();
+
+ Assert.Equal(8, plate.ClampZones.Count);
+ Assert.Empty(plate.Parts);
+ Assert.Equal(0, plate.Utilization());
+ nest.UpdateDrawingQuantities();
+ Assert.Equal(0, drawing.Quantity.Nested);
+ }
+
+ [Fact]
+ public void RailsCompareByValue()
+ {
+ Assert.Equal(Machine(), new ClampRail(1.5, 0.625, MachineCenters.Reverse()));
+ Assert.Equal(Machine().GetHashCode(), new ClampRail(1.5, 0.625, MachineCenters).GetHashCode());
+ Assert.NotEqual(Machine(), new ClampRail(1.5, 0.5, MachineCenters));
+ Assert.NotEqual(Machine(), new ClampRail(1.5, 0.625, MachineCenters.Take(7)));
+ }
+}