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