diff --git a/OpenNest.Core/ClampEdge.cs b/OpenNest.Core/ClampEdge.cs new file mode 100644 index 00000000..af23b4b3 --- /dev/null +++ b/OpenNest.Core/ClampEdge.cs @@ -0,0 +1,46 @@ +using System; + +namespace OpenNest +{ + /// + /// The sheet edge a clamp sits on, named relative to the plate + /// origin so one rail resolves onto every sheet size and quadrant. + /// + public enum ClampEdge + { + /// The edge along X through the origin (operator side). + OriginSide = 0, + + /// The edge along X across the sheet's width from the origin. + FarSide = 1, + + /// The edge along Y through the origin. + OriginEnd = 2, + + /// The edge along Y across the sheet's length from the origin. + FarEnd = 3, + } + + public static class ClampEdges + { + /// True for the two edges along X, whose clamp positions are X distances. + public static bool RunsAlongX(this ClampEdge edge) => + edge is ClampEdge.OriginSide or ClampEdge.FarSide; + + public static bool IsDefined(ClampEdge edge) => + edge is ClampEdge.OriginSide or ClampEdge.FarSide or ClampEdge.OriginEnd or ClampEdge.FarEnd; + } + + /// + /// One clamp of a : its edge and its center measured from the plate + /// origin along that edge (X for the sides, Y for the ends). Orders by edge, then center. + /// + public readonly record struct RailClamp(ClampEdge Edge, double Center) : IComparable + { + public int CompareTo(RailClamp other) + { + var edge = Edge.CompareTo(other.Edge); + return edge != 0 ? edge : Center.CompareTo(other.Center); + } + } +} diff --git a/OpenNest.Core/ClampRail.cs b/OpenNest.Core/ClampRail.cs index 65ac565d..21d779d3 100644 --- a/OpenNest.Core/ClampRail.cs +++ b/OpenNest.Core/ClampRail.cs @@ -6,12 +6,12 @@ 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; one that - /// still reaches onto it is kept whole: the zone is an avoidance area, so its full - /// footprint applies even where it hangs past the sheet. + /// The machine's fixed clamp rail: identical rectangular clamps, each on one sheet edge + /// () with its center measured from the plate origin along that + /// 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; + /// one that still reaches onto it is kept whole: the zone is an avoidance area, so its + /// full footprint applies even where it hangs past the sheet. /// public sealed class ClampRail : IEquatable { @@ -21,14 +21,30 @@ namespace OpenNest /// public const double DefaultRemovalMinutes = 5.0; + /// A rail whose clamps all sit on the origin-side edge. public ClampRail( double width, double depth, IEnumerable centers, double removalMinutes = DefaultRemovalMinutes + ) + : this( + width, + depth, + (centers ?? throw new ArgumentNullException(nameof(centers))) + .Select(c => new RailClamp(ClampEdge.OriginSide, c)), + removalMinutes + ) + { } + + public ClampRail( + double width, + double depth, + IEnumerable clamps, + double removalMinutes = DefaultRemovalMinutes ) { - ArgumentNullException.ThrowIfNull(centers); + ArgumentNullException.ThrowIfNull(clamps); if (!double.IsFinite(width) || width <= 0) throw new ArgumentOutOfRangeException( nameof(width), @@ -45,27 +61,30 @@ namespace OpenNest "Clamp removal time must be finite and zero or more." ); - var sorted = new SortedSet(); - foreach (var center in centers) + var sorted = new SortedSet(); + foreach (var clamp in clamps) { - if (!double.IsFinite(center) || center < 0) + if (!double.IsFinite(clamp.Center) || clamp.Center < 0) throw new ArgumentOutOfRangeException( - nameof(centers), + "centers", "Clamp centers must be finite and measured from the origin (zero or more)." ); - sorted.Add(center); + if (!ClampEdges.IsDefined(clamp.Edge)) + throw new ArgumentOutOfRangeException(nameof(clamps), "Unknown clamp edge."); + sorted.Add(clamp); } Width = width; Depth = depth; RemovalMinutes = removalMinutes; - Centers = sorted.ToArray(); + Clamps = sorted.ToArray(); + Centers = Clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToArray(); } - /// Clamp extent along the edge (X). + /// Clamp extent along its edge. public double Width { get; } - /// Clamp intrusion into the sheet from the origin-side edge (Y). + /// Clamp intrusion into the sheet from its edge. public double Depth { get; } /// @@ -74,9 +93,68 @@ namespace OpenNest /// public double RemovalMinutes { get; } - /// Distinct clamp centers along X from the origin, ascending. + /// + /// Distinct clamps ordered by edge, then center. A clamp's position in this list + /// gives its clamp-N id, so a rail with only origin-side clamps keeps the ids + /// it had before clamps could sit on other edges. + /// + public IReadOnlyList Clamps { get; } + + /// Centers of the origin-side clamps, ascending; see for all. public IReadOnlyList Centers { get; } + /// A copy with the same size and removal time and these clamps. + public ClampRail WithClamps(IEnumerable clamps) => + new(Width, Depth, clamps, RemovalMinutes); + + /// The clamp-N id of the clamp at in . + public static string IdOf(int index) => $"clamp-{index + 1}"; + + /// + /// The footprint would have on a sheet, in plate coordinates + /// (lower-left corner origin), or null when it lies wholly beyond the sheet. The clamp + /// need not belong to this rail; it takes this rail's width and depth. + /// + public Box Footprint(RailClamp clamp, Size size, int quadrant) => + Footprint(clamp, Width, Depth, size, quadrant); + + /// + /// The footprint of a by clamp at + /// ; see . + /// + public static Box Footprint(RailClamp clamp, double width, double depth, Size size, int quadrant) + { + var length = size.Length; + var sheetWidth = size.Width; + if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth)) + return null; + + var extendsLeft = quadrant is 2 or 3; + var extendsDown = quadrant is 3 or 4; + var half = width / 2; + // Distances from the origin along the edge. + var near = clamp.Center - half; + var far = clamp.Center + half; + + if (clamp.Edge.RunsAlongX()) + { + if (near >= length) + return null; // wholly beyond this sheet + // The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y. + var atTop = (clamp.Edge == ClampEdge.FarSide) != extendsDown; + // 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 - far : near; + return new Box(x, atTop ? sheetWidth - depth : 0, width, depth); + } + + if (near >= sheetWidth) + return null; + var atRight = (clamp.Edge == ClampEdge.FarEnd) != extendsLeft; + var y = extendsDown ? sheetWidth - far : near; + return new Box(atRight ? length - depth : 0, y, depth, width); + } + /// /// The zones this rail places on a sheet, in plate coordinates (the frame of /// : origin at the sheet's lower-left corner). The @@ -88,30 +166,12 @@ namespace OpenNest /// 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; - // The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y. - var y = extendsDown ? sheetWidth - Depth : 0; - var half = Width / 2; - - var zones = new List(Centers.Count); - for (var i = 0; i < Centers.Count; i++) + var zones = new List(Clamps.Count); + for (var i = 0; i < Clamps.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 - - // 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 - far : near; - zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, Width, Depth))); + var box = Footprint(Clamps[i], size, quadrant); + if (box != null) + zones.Add(new ClampZone(IdOf(i), box)); } return zones; @@ -165,19 +225,24 @@ namespace OpenNest && Width.Equals(other.Width) && Depth.Equals(other.Depth) && RemovalMinutes.Equals(other.RemovalMinutes) - && Centers.SequenceEqual(other.Centers); + && Clamps.SequenceEqual(other.Clamps); public override bool Equals(object obj) => Equals(obj as ClampRail); public override int GetHashCode() { - var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Centers.Count); - foreach (var center in Centers) - hash = HashCode.Combine(hash, center); + var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Clamps.Count); + foreach (var clamp in Clamps) + hash = HashCode.Combine(hash, clamp); return hash; } public override string ToString() => - $"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Centers.Select(c => c.ToString("R")))}]"; + $"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Clamps.Select(Describe))}]"; + + private static string Describe(RailClamp clamp) => + clamp.Edge == ClampEdge.OriginSide + ? clamp.Center.ToString("R") + : $"{clamp.Edge} {clamp.Center:R}"; } } diff --git a/OpenNest.Core/Nest.cs b/OpenNest.Core/Nest.cs index 6d288c83..8ac26428 100644 --- a/OpenNest.Core/Nest.cs +++ b/OpenNest.Core/Nest.cs @@ -73,7 +73,7 @@ namespace OpenNest /// public void SetClampRail(ClampRail rail) { - if (rail != null && rail.Centers.Count == 0) + if (rail != null && rail.Clamps.Count == 0) rail = null; PlateDefaults.ClampRail = rail; foreach (var plate in Plates) diff --git a/OpenNest.Data/ClampRailEditor.cs b/OpenNest.Data/ClampRailEditor.cs index 5f210f85..22bf2819 100644 --- a/OpenNest.Data/ClampRailEditor.cs +++ b/OpenNest.Data/ClampRailEditor.cs @@ -8,7 +8,7 @@ namespace OpenNest.Data; /// public sealed class ClampRailEditor { - private readonly List _centers = new(); + private readonly List _clamps = new(); /// Starts from the nest's rail, or the default clamp size with no clamps. public ClampRailEditor(ClampRail? rail, Units units, bool applyToNewNests) @@ -19,16 +19,16 @@ public sealed class ClampRailEditor Width = initial.Width; Depth = initial.Depth; RemovalMinutes = initial.RemovalMinutes; - _centers.AddRange(initial.Centers); + _clamps.AddRange(initial.Clamps.Order()); } /// The nest's units; every value is in these units. public Units Units { get; } - /// Clamp extent along the edge (X). + /// Clamp extent along its edge. public double Width { get; set; } - /// Clamp intrusion into the sheet (Y). + /// Clamp intrusion into the sheet from its edge. public double Depth { get; set; } /// Minutes to remove one clamp and put it back after cutting (D9); not a @@ -38,37 +38,46 @@ public sealed class ClampRailEditor /// Whether saved machine defaults give new nests these clamps. public bool ApplyToNewNests { get; set; } - /// Clamp centers along X from the plate origin, ascending and distinct. - public IReadOnlyList Centers => _centers; + /// Clamps ordered by edge, then center from the plate origin; distinct. + public IReadOnlyList Clamps => _clamps; + + /// Origin-side clamp centers along X from the plate origin, ascending. + public IReadOnlyList Centers => + _clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToList(); + + /// Adds an origin-side clamp; see . + public bool TryAddCenter(double center, out string? error) => + TryAddClamp(new RailClamp(ClampEdge.OriginSide, center), out error); /// - /// Adds a clamp center, keeping the list sorted. Refuses (with an operator-facing - /// reason) values that are not finite, negative, or already listed. + /// Adds a clamp, keeping the list sorted. Refuses (with an operator-facing reason) + /// centers that are not finite, negative, or already listed on that edge. /// - public bool TryAddCenter(double center, out string? error) + public bool TryAddClamp(RailClamp clamp, out string? error) { - if (!double.IsFinite(center) || center < 0) + if (!double.IsFinite(clamp.Center) || clamp.Center < 0) { error = "Enter a clamp center measured from the plate origin (zero or more)."; return false; } - var index = _centers.BinarySearch(center); + var index = _clamps.BinarySearch(clamp); if (index >= 0) { - error = $"A clamp is already centered at {center:0.####}."; + error = $"A clamp is already centered at {clamp.Center:0.####} on that edge."; return false; } - _centers.Insert(~index, center); + _clamps.Insert(~index, clamp); error = null; return true; } + /// Removes the clamp at in . public void RemoveCenterAt(int index) { - if (index >= 0 && index < _centers.Count) - _centers.RemoveAt(index); + if (index >= 0 && index < _clamps.Count) + _clamps.RemoveAt(index); } /// @@ -83,8 +92,8 @@ public sealed class ClampRailEditor Depth = converted.Depth; RemovalMinutes = converted.RemovalMinutes; ApplyToNewNests = settings.Enabled; - _centers.Clear(); - _centers.AddRange(converted.Centers.Distinct().OrderBy(c => c)); + _clamps.Clear(); + _clamps.AddRange(converted.Clamps.Distinct().Order()); } /// @@ -93,7 +102,7 @@ public sealed class ClampRailEditor /// negative/non-finite removal time. /// public ClampRail? BuildRail() => - _centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes); + _clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes); /// The dialog's OK: makes the edited rail the nest's, re-resolving every plate. public void ApplyTo(Nest nest) @@ -119,14 +128,15 @@ public sealed class ClampRailEditor nameof(RemovalMinutes), "Clamp removal time must be finite and zero or more." ); - return new ClampSettings + var settings = new ClampSettings { - Enabled = ApplyToNewNests && _centers.Count > 0, + Enabled = ApplyToNewNests && _clamps.Count > 0, Units = Units, Width = Width, Depth = Depth, RemovalMinutes = RemovalMinutes, - Centers = _centers.ToArray(), }; + settings.SetClamps(_clamps); + return settings; } } diff --git a/OpenNest.Data/ClampSettings.cs b/OpenNest.Data/ClampSettings.cs index eb71bd05..b666081b 100644 --- a/OpenNest.Data/ClampSettings.cs +++ b/OpenNest.Data/ClampSettings.cs @@ -63,9 +63,50 @@ public sealed class ClampSettings /// public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes; - /// Clamp centers along X, measured from the plate origin. + /// Origin-side clamp centers along X, measured from the plate origin. public IReadOnlyList Centers { get; set; } = Array.Empty(); + /// Far-side clamp centers along X, measured from the plate origin. + public IReadOnlyList FarSideCenters { get; set; } = Array.Empty(); + + /// Origin-end clamp centers along Y, measured from the plate origin. + public IReadOnlyList OriginEndCenters { get; set; } = Array.Empty(); + + /// Far-end clamp centers along Y, measured from the plate origin. + public IReadOnlyList FarEndCenters { get; set; } = Array.Empty(); + + /// Every configured clamp, by edge then center as listed. + public IEnumerable Clamps => + Centers.Select(c => new RailClamp(ClampEdge.OriginSide, c)) + .Concat(FarSideCenters.Select(c => new RailClamp(ClampEdge.FarSide, c))) + .Concat(OriginEndCenters.Select(c => new RailClamp(ClampEdge.OriginEnd, c))) + .Concat(FarEndCenters.Select(c => new RailClamp(ClampEdge.FarEnd, c))); + + /// The number of configured clamps on every edge. + public int ClampCount => + Centers.Count + FarSideCenters.Count + OriginEndCenters.Count + FarEndCenters.Count; + + /// The centers listed for . + public IReadOnlyList CentersOn(ClampEdge edge) => + edge switch + { + ClampEdge.FarSide => FarSideCenters, + ClampEdge.OriginEnd => OriginEndCenters, + ClampEdge.FarEnd => FarEndCenters, + _ => Centers, + }; + + /// Lists under their edges, replacing every edge's centers. + public void SetClamps(IEnumerable clamps) + { + var list = clamps.ToList(); + double[] On(ClampEdge edge) => list.Where(c => c.Edge == edge).Select(c => c.Center).ToArray(); + Centers = On(ClampEdge.OriginSide); + FarSideCenters = On(ClampEdge.FarSide); + OriginEndCenters = On(ClampEdge.OriginEnd); + FarEndCenters = On(ClampEdge.FarEnd); + } + /// The built-in settings used for a missing or unreadable file: clamps off. public static ClampSettings Fallback => new(); @@ -81,16 +122,20 @@ public sealed class ClampSettings /// Captures a nest's rail (or the default clamp size and no centers when it has none) /// in that nest's units, e.g. for "save as machine defaults". /// - public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled) => - new() + public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled) + { + var settings = new ClampSettings { Enabled = enabled, Units = units, Width = rail?.Width ?? ConvertDefault(DefaultWidth, units), Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units), RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes, - Centers = rail?.Centers.ToArray() ?? Array.Empty(), }; + if (rail != null) + settings.SetClamps(rail.Clamps); + return settings; + } /// /// The rail a new nest in receives, or null when clamps are @@ -99,11 +144,11 @@ public sealed class ClampSettings /// public ClampRail? RailFor(Units units) { - if (!Enabled || Centers.Count == 0) + if (!Enabled || ClampCount == 0) return null; var converted = ConvertedTo(units); - return new ClampRail(converted.Width, converted.Depth, converted.Centers, converted.RemovalMinutes); + return new ClampRail(converted.Width, converted.Depth, converted.Clamps, converted.RemovalMinutes); } /// @@ -122,6 +167,9 @@ public sealed class ClampSettings Depth = Depth * scale, RemovalMinutes = RemovalMinutes, Centers = Centers.Select(c => c * scale).ToArray(), + FarSideCenters = FarSideCenters.Select(c => c * scale).ToArray(), + OriginEndCenters = OriginEndCenters.Select(c => c * scale).ToArray(), + FarEndCenters = FarEndCenters.Select(c => c * scale).ToArray(), }; } @@ -221,45 +269,61 @@ public sealed class ClampSettings } if (values.TryGetValue("centers", out var centers)) - { - if (centers.ValueKind == JsonValueKind.Array) - { - var valid = new List(); - foreach (var center in centers.EnumerateArray()) - { - if (TryLength(center, out var x) && x >= 0) - valid.Add(x); - else - ignored = true; - } - settings.Centers = valid.Distinct().OrderBy(x => x).ToArray(); - } - else - { - ignored = true; - } - } + settings.Centers = ReadCenters(centers, ref ignored); + if (values.TryGetValue("farSideCenters", out var farSide)) + settings.FarSideCenters = ReadCenters(farSide, ref ignored); + if (values.TryGetValue("originEndCenters", out var originEnd)) + settings.OriginEndCenters = ReadCenters(originEnd, ref ignored); + if (values.TryGetValue("farEndCenters", out var farEnd)) + settings.FarEndCenters = ReadCenters(farEnd, ref ignored); status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok; return settings; } + /// Valid centers of a JSON array, distinct and ascending; flags anything dropped. + private static double[] ReadCenters(JsonElement centers, ref bool ignored) + { + if (centers.ValueKind != JsonValueKind.Array) + { + ignored = true; + return Array.Empty(); + } + + var valid = new List(); + foreach (var center in centers.EnumerateArray()) + { + if (TryLength(center, out var x) && x >= 0) + valid.Add(x); + else + ignored = true; + } + return valid.Distinct().OrderBy(x => x).ToArray(); + } + /// /// Writes the file (creating the parent directory), retrying briefly on IO collisions /// the same way does. /// public void Save(string path, int maxRetries = 3) { - var dto = new + var dto = new Dictionary { - version = CurrentVersion, - enabled = Enabled, - units = Units.ToString().ToLowerInvariant(), - width = Width, - depth = Depth, - removalMinutes = RemovalMinutes, - centers = Centers, + ["version"] = CurrentVersion, + ["enabled"] = Enabled, + ["units"] = Units.ToString().ToLowerInvariant(), + ["width"] = Width, + ["depth"] = Depth, + ["removalMinutes"] = RemovalMinutes, + ["centers"] = Centers, }; + // Other edges are written only when used, so origin-side-only files stay unchanged. + if (FarSideCenters.Count > 0) + dto["farSideCenters"] = FarSideCenters; + if (OriginEndCenters.Count > 0) + dto["originEndCenters"] = OriginEndCenters; + if (FarEndCenters.Count > 0) + dto["farEndCenters"] = FarEndCenters; var json = JsonSerializer.Serialize(dto, JsonOptions); var directory = Path.GetDirectoryName(Path.GetFullPath(path)); diff --git a/OpenNest.IO.Tests/ClampEdgePersistenceTests.cs b/OpenNest.IO.Tests/ClampEdgePersistenceTests.cs new file mode 100644 index 00000000..90a88c9e --- /dev/null +++ b/OpenNest.IO.Tests/ClampEdgePersistenceTests.cs @@ -0,0 +1,88 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json.Nodes; +using OpenNest.Geometry; + +namespace OpenNest.IO.Tests; + +/// +/// Rails with clamps on other edges than the origin side persist those clamps in optional +/// per-edge arrays; origin-side-only rails write exactly the keys they always did. +/// +public class ClampEdgePersistenceTests +{ + private static ClampRail Mixed() => new(1.5, 0.625, new[] + { + new RailClamp(ClampEdge.OriginSide, 6), + new RailClamp(ClampEdge.FarSide, 20), + new RailClamp(ClampEdge.OriginEnd, 8), + new RailClamp(ClampEdge.FarEnd, 30), + }); + + private static Nest NestWith(ClampRail rail) + { + var nest = new Nest("edges"); + nest.PlateDefaults.Size = new Size(48, 96); + nest.SetClampRail(rail); + var plate = nest.CreatePlate(); + plate.CutOffs.Add(new CutOff(new Vector(40, 0), CutOffAxis.Vertical)); + return nest; + } + + private static MemoryStream Write(Nest nest) + { + var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + stream.Position = 0; + return stream; + } + + private static JsonNode RailJson(MemoryStream zip) + { + zip.Position = 0; + using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true); + using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open(), Encoding.UTF8); + var json = JsonNode.Parse(reader.ReadToEnd())!["plateDefaults"]!["clampRail"]!; + zip.Position = 0; + return json; + } + + [Fact] + public void ClampsOnEveryEdgeRoundTripAndPlatesFollowTheRail() + { + var reopened = new NestReader(Write(NestWith(Mixed()))).Read(); + + Assert.Equal(Mixed(), reopened.PlateDefaults.ClampRail); + var plate = Assert.Single(reopened.Plates); + Assert.Same(reopened.PlateDefaults.ClampRail, plate.ClampRail); + Assert.Equal(4, plate.ClampZones.Count); + } + + [Fact] + public void OriginSideOnlyRailsOmitTheOtherEdgeKeys() + { + var rail = RailJson(Write(NestWith(new ClampRail(1.5, 0.625, new double[] { 6, 21 })))).AsObject(); + + Assert.Equal(new[] { "width", "depth", "centers", "removalMinutes" }, rail.Select(p => p.Key)); + } + + [Fact] + public void OtherEdgesAreWrittenInTheirOwnArrays() + { + var rail = RailJson(Write(NestWith(Mixed()))); + + Assert.Equal(6, rail["centers"]![0]!.GetValue()); + Assert.Equal(20, rail["farSideCenters"]![0]!.GetValue()); + Assert.Equal(8, rail["originEndCenters"]![0]!.GetValue()); + Assert.Equal(30, rail["farEndCenters"]![0]!.GetValue()); + } + + [Fact] + public void ARailWithNoOriginSideClampsStillLoads() + { + var farOnly = new ClampRail(1.5, 0.625, new[] { new RailClamp(ClampEdge.FarSide, 12) }); + var reopened = new NestReader(Write(NestWith(farOnly))).Read(); + + Assert.Equal(farOnly, reopened.PlateDefaults.ClampRail); + } +} diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index a4c3abf1..d65ef307 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -78,6 +78,50 @@ namespace OpenNest.IO ) return null; + var list = ReadCenters(centers); + if (list == null) + return null; + // Clamps on the other edges arrived later: a missing key means none there. + var others = new List[OtherEdgeKeys.Length]; + for (var i = 0; i < others.Length; i++) + { + others[i] = new List(); + if (!rail.TryGetProperty(OtherEdgeKeys[i], out var edge)) + continue; + others[i] = edge.ValueKind == JsonValueKind.Array ? ReadCenters(edge) : null; + if (others[i] == null) + return null; + } + // Missing or unreadable -> null; the reader then falls back to the default. + double? removalMinutes = TryNumber(rail, "removalMinutes", out var minutes) + ? minutes + : null; + return new ClampRailDto + { + Width = width, + Depth = depth, + Centers = list, + FarSideCenters = others[0], + OriginEndCenters = others[1], + FarEndCenters = others[2], + RemovalMinutes = removalMinutes, + }; + } + + private static readonly string[] OtherEdgeKeys = { "farSideCenters", "originEndCenters", "farEndCenters" }; + + private static void WriteCenters(Utf8JsonWriter writer, string key, List centers) + { + if (centers == null || centers.Count == 0) + return; + writer.WriteStartArray(key); + foreach (var center in centers) + writer.WriteNumberValue(center); + writer.WriteEndArray(); + } + + private static List ReadCenters(JsonElement centers) + { var list = new List(); foreach (var center in centers.EnumerateArray()) { @@ -85,11 +129,7 @@ namespace OpenNest.IO return null; list.Add(x); } - // Missing or unreadable -> null; the reader then falls back to the default. - double? removalMinutes = TryNumber(rail, "removalMinutes", out var minutes) - ? minutes - : null; - return new ClampRailDto { Width = width, Depth = depth, Centers = list, RemovalMinutes = removalMinutes }; + return list; } public override void Write( @@ -105,6 +145,10 @@ namespace OpenNest.IO foreach (var center in value.Centers ?? new List()) writer.WriteNumberValue(center); writer.WriteEndArray(); + // Omitted when empty so origin-side-only rails write exactly as before. + WriteCenters(writer, OtherEdgeKeys[0], value.FarSideCenters); + WriteCenters(writer, OtherEdgeKeys[1], value.OriginEndCenters); + WriteCenters(writer, OtherEdgeKeys[2], value.FarEndCenters); if (value.RemovalMinutes is double removalMinutes) writer.WriteNumber("removalMinutes", removalMinutes); writer.WriteEndObject(); @@ -156,15 +200,24 @@ namespace OpenNest.IO public record ClampRailDto { - /// Clamp extent along the origin-side edge (X). + /// Clamp extent along its edge. public double Width { get; init; } - /// Clamp intrusion into the sheet from that edge (Y). + /// Clamp intrusion into the sheet from its edge. public double Depth { get; init; } - /// Clamp centers along X, measured from the plate origin. + /// Origin-side clamp centers along X, measured from the plate origin. public List Centers { get; init; } = new(); + /// Far-side clamp centers along X from the origin; omitted when none. + public List FarSideCenters { get; init; } = new(); + + /// Origin-end clamp centers along Y from the origin; omitted when none. + public List OriginEndCenters { get; init; } = new(); + + /// Far-end clamp centers along Y from the origin; omitted when none. + public List FarEndCenters { get; init; } = new(); + /// /// Minutes to remove one clamp and put it back after cutting (D9). Null in older /// files and readers fall back to . diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index ed285fc9..e4efec37 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -474,7 +474,14 @@ namespace OpenNest.IO /// private static ClampRail ReadClampRail(ClampRailDto dto) { - if (dto?.Centers == null || dto.Centers.Count == 0) + if (dto == null) + return null; + var clamps = Edge(dto.Centers, ClampEdge.OriginSide) + .Concat(Edge(dto.FarSideCenters, ClampEdge.FarSide)) + .Concat(Edge(dto.OriginEndCenters, ClampEdge.OriginEnd)) + .Concat(Edge(dto.FarEndCenters, ClampEdge.FarEnd)) + .ToList(); + if (clamps.Count == 0) return null; try { @@ -484,12 +491,15 @@ namespace OpenNest.IO var minutes = dto.RemovalMinutes is double m && double.IsFinite(m) && m >= 0 ? m : ClampRail.DefaultRemovalMinutes; - return new ClampRail(dto.Width, dto.Depth, dto.Centers, minutes); + return new ClampRail(dto.Width, dto.Depth, clamps, minutes); } catch (ArgumentException) { return null; } + + static IEnumerable Edge(List centers, ClampEdge edge) => + centers?.Select(c => new RailClamp(edge, c)) ?? Enumerable.Empty(); } private Nest BuildNest(NestDto dto, Dictionary drawingMap) diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index b9da6175..e1f59292 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -274,9 +274,15 @@ namespace OpenNest.IO Width = rail.Width, Depth = rail.Depth, Centers = rail.Centers.ToList(), + FarSideCenters = CentersOn(rail, ClampEdge.FarSide), + OriginEndCenters = CentersOn(rail, ClampEdge.OriginEnd), + FarEndCenters = CentersOn(rail, ClampEdge.FarEnd), RemovalMinutes = rail.RemovalMinutes, }; + private static List CentersOn(ClampRail rail, ClampEdge edge) => + rail.Clamps.Where(c => c.Edge == edge).Select(c => c.Center).ToList(); + /// Numeric suffix of a rail clamp id ("clamp-3" -> 3), for a natural sort of /// instead of lexical ("clamp-10" before "clamp-2"). /// A non-matching id sorts last by its own ordinal text. diff --git a/OpenNest.Tests/ClampEdgeTests.cs b/OpenNest.Tests/ClampEdgeTests.cs new file mode 100644 index 00000000..78bdfb85 --- /dev/null +++ b/OpenNest.Tests/ClampEdgeTests.cs @@ -0,0 +1,167 @@ +using OpenNest.Data; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +/// +/// Rail clamps on every sheet edge (): positions measured from the +/// plate origin along each edge, footprints for every quadrant, ids that keep origin-side +/// rails unchanged, and the settings/editor round trips behind the Clamps dialog. +/// +public class ClampEdgeTests +{ + private static readonly Size Sheet = new(48, 120); // Width (Y) 48, Length (X) 120 + + private static ClampRail AllEdges() => new(2, 0.5, new[] + { + new RailClamp(ClampEdge.OriginSide, 10), + new RailClamp(ClampEdge.FarSide, 20), + new RailClamp(ClampEdge.OriginEnd, 5), + new RailClamp(ClampEdge.FarEnd, 30), + }); + + private static Box Zone(ClampRail rail, int quadrant, int index) => + rail.ResolveFor(Sheet, quadrant).Single(z => z.Id == ClampRail.IdOf(index)).Bounds; + + private static void AssertBox(Box expected, Box actual) + { + Assert.Equal(expected.X, actual.X, 9); + Assert.Equal(expected.Y, actual.Y, 9); + Assert.Equal(expected.Length, actual.Length, 9); + Assert.Equal(expected.Width, actual.Width, 9); + } + + [Fact] + public void QuadrantOneFootprintsSitOnEachEdge() + { + var rail = AllEdges(); + AssertBox(new Box(9, 0, 2, 0.5), Zone(rail, 1, 0)); // origin side: bottom + AssertBox(new Box(19, 47.5, 2, 0.5), Zone(rail, 1, 1)); // far side: top + AssertBox(new Box(0, 4, 0.5, 2), Zone(rail, 1, 2)); // origin end: left + AssertBox(new Box(119.5, 29, 0.5, 2), Zone(rail, 1, 3)); // far end: right + } + + [Fact] + public void QuadrantThreeMirrorsEveryEdgeAboutTheOrigin() + { + // Quadrant 3: the origin is the sheet's upper-right corner. + var rail = AllEdges(); + AssertBox(new Box(109, 47.5, 2, 0.5), Zone(rail, 3, 0)); // origin side: top + AssertBox(new Box(99, 0, 2, 0.5), Zone(rail, 3, 1)); // far side: bottom + AssertBox(new Box(119.5, 42, 0.5, 2), Zone(rail, 3, 2)); // origin end: right + AssertBox(new Box(0, 17, 0.5, 2), Zone(rail, 3, 3)); // far end: left + } + + [Fact] + public void QuadrantTwoAndFourFlipOneAxis() + { + var rail = AllEdges(); + AssertBox(new Box(109, 0, 2, 0.5), Zone(rail, 2, 0)); + AssertBox(new Box(0, 29, 0.5, 2), Zone(rail, 2, 3)); + AssertBox(new Box(9, 47.5, 2, 0.5), Zone(rail, 4, 0)); + AssertBox(new Box(0, 42, 0.5, 2), Zone(rail, 4, 2)); + } + + [Fact] + public void EndClampsBeyondTheSheetWidthAreSkipped() + { + var rail = new ClampRail(2, 0.5, new[] + { + new RailClamp(ClampEdge.OriginEnd, 47.5), // still reaches onto a 48" wide sheet + new RailClamp(ClampEdge.OriginEnd, 50), // wholly beyond it + }); + + var zone = Assert.Single(rail.ResolveFor(Sheet, 1)); + Assert.Equal("clamp-1", zone.Id); + Assert.Equal(2, rail.ResolveFor(new Size(60, 120), 1).Count); + } + + [Fact] + public void ClampsOrderByEdgeSoOriginSideIdsAreUnchanged() + { + var originOnly = new ClampRail(2, 0.5, new double[] { 30, 10 }); + var mixed = originOnly.WithClamps(originOnly.Clamps.Append(new RailClamp(ClampEdge.FarSide, 1))); + + Assert.Equal(new double[] { 10, 30 }, mixed.Centers); + Assert.Equal(ClampEdge.FarSide, mixed.Clamps[2].Edge); + var ids = mixed.ResolveFor(Sheet, 1).Select(z => z.Id).ToArray(); + Assert.Equal(new[] { "clamp-1", "clamp-2", "clamp-3" }, ids); + AssertBox(Zone(originOnly, 1, 1), Zone(mixed, 1, 1)); + } + + [Fact] + public void EdgeIsPartOfEquality() + { + var a = new ClampRail(2, 0.5, new[] { new RailClamp(ClampEdge.OriginSide, 10) }); + var b = new ClampRail(2, 0.5, new[] { new RailClamp(ClampEdge.FarSide, 10) }); + Assert.NotEqual(a, b); + Assert.Equal(a, new ClampRail(2, 0.5, new double[] { 10 })); + } + + [Fact] + public void NestKeepsARailWithOnlyOtherEdgeClamps() + { + var nest = new Nest("edges"); + nest.PlateDefaults.Size = Sheet; + var plate = nest.CreatePlate(); + nest.SetClampRail(new ClampRail(2, 0.5, new[] { new RailClamp(ClampEdge.FarEnd, 12) })); + + Assert.NotNull(nest.PlateDefaults.ClampRail); + AssertBox(new Box(119.5, 11, 0.5, 2), Assert.Single(plate.ClampZones).Bounds); + } + + [Fact] + public void SettingsRoundTripEveryEdgeAndOmitUnusedKeys() + { + var directory = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString()); + var path = Path.Combine(directory, "clamps.json"); + try + { + var settings = ClampSettings.FromRail(AllEdges(), Units.Inches, enabled: true); + settings.Save(path); + var loaded = ClampSettings.Load(path, out var status); + + Assert.Equal(ClampSettingsStatus.Ok, status); + Assert.Equal(AllEdges(), loaded.RailFor(Units.Inches)); + Assert.Equal(4, loaded.ClampCount); + + ClampSettings.FromRail(new ClampRail(2, 0.5, new double[] { 10 }), Units.Inches, true).Save(path); + var json = File.ReadAllText(path); + Assert.DoesNotContain("farSideCenters", json); + Assert.DoesNotContain("originEndCenters", json); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void SettingsConvertEveryEdgeBetweenUnits() + { + var settings = ClampSettings.FromRail(AllEdges(), Units.Inches, enabled: true); + var rail = settings.RailFor(Units.Millimeters)!; + Assert.Equal(30 * 25.4, rail.Clamps.Single(c => c.Edge == ClampEdge.FarEnd).Center, 9); + } + + [Fact] + public void EditorAddsClampsPerEdgeAndRefusesDuplicatesOnTheSameEdge() + { + var editor = new ClampRailEditor(null, Units.Inches, applyToNewNests: false); + + Assert.True(editor.TryAddClamp(new RailClamp(ClampEdge.FarSide, 10), out _)); + Assert.True(editor.TryAddCenter(10, out _)); // same distance, different edge + Assert.False(editor.TryAddClamp(new RailClamp(ClampEdge.FarSide, 10), out var error)); + Assert.NotNull(error); + + Assert.Equal(ClampEdge.OriginSide, editor.Clamps[0].Edge); + editor.RemoveCenterAt(1); + Assert.Equal(new double[] { 10 }, editor.Centers); + Assert.Equal(ClampEdge.OriginSide, Assert.Single(editor.BuildRail()!.Clamps).Edge); + + editor.TryAddClamp(new RailClamp(ClampEdge.OriginEnd, 4), out _); + var saved = editor.ToMachineDefaults(); + Assert.Equal(new double[] { 4 }, saved.OriginEndCenters); + } +} diff --git a/OpenNest.Tests/ClampRailTests.cs b/OpenNest.Tests/ClampRailTests.cs index c66a38e4..02bbf5f9 100644 --- a/OpenNest.Tests/ClampRailTests.cs +++ b/OpenNest.Tests/ClampRailTests.cs @@ -37,7 +37,7 @@ public class ClampRailTests 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!)); + Assert.Throws(() => new ClampRail(1.5, 0.625, (IEnumerable)null!)); } [Fact] diff --git a/OpenNest.WinForms.Tests/Controls/ActionClampEditTests.cs b/OpenNest.WinForms.Tests/Controls/ActionClampEditTests.cs new file mode 100644 index 00000000..8ee600bf --- /dev/null +++ b/OpenNest.WinForms.Tests/Controls/ActionClampEditTests.cs @@ -0,0 +1,158 @@ +using System.Drawing; +using System.Reflection; +using System.Windows.Forms; +using OpenNest.Actions; +using OpenNest.Controls; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Controls; + +/// +/// Plate > Edit Clamps: dragging, adding, typing and deleting clamps on the plate view +/// changes the nest's rail, so every sheet follows it. +/// +public class ActionClampEditTests +{ + private static Nest RailNest() + { + var nest = new Nest("clamps") { Units = Units.Inches }; + nest.PlateDefaults.Size = new OpenNest.Geometry.Size(48, 120); + nest.SetClampRail(new ClampRail(2, 0.5, new double[] { 20, 60 })); + nest.CreatePlate(); + nest.CreatePlate(); + return nest; + } + + private static PlateView EditView(Nest nest) + { + var view = new PlateView { Plate = nest.Plates[0], Size = new System.Drawing.Size(900, 450) }; + view.ClampNest = () => nest; + view.ZoomToFit(); + view.SetAction(typeof(ActionClampEdit)); + Paint(view); // dimension labels are laid out while painting + return view; + } + + private static void Raise(PlateView view, string method, EventArgs args) => + typeof(PlateView).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(view, new object[] { args }); + + private static void Paint(PlateView view) + { + using var bitmap = new Bitmap(view.Width, view.Height); + using var g = Graphics.FromImage(bitmap); + Raise(view, "OnPaint", new PaintEventArgs(g, view.ClientRectangle)); + } + + private static Point At(PlateView view, double x, double y) => view.PointWorldToControl(new Vector(x, y)); + + private static void Move(PlateView view, Point p, MouseButtons button = MouseButtons.None) => + Raise(view, "OnMouseMove", new MouseEventArgs(button, 0, p.X, p.Y, 0)); + + private static void Click(PlateView view, Point p) + { + Move(view, p); + Raise(view, "OnMouseDown", new MouseEventArgs(MouseButtons.Left, 1, p.X, p.Y, 0)); + Raise(view, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, p.X, p.Y, 0)); + } + + private static void Drag(PlateView view, Point from, Point to) + { + Move(view, from); + Raise(view, "OnMouseDown", new MouseEventArgs(MouseButtons.Left, 1, from.X, from.Y, 0)); + Move(view, new Point((from.X + to.X) / 2, (from.Y + to.Y) / 2), MouseButtons.Left); + Move(view, to, MouseButtons.Left); + Raise(view, "OnMouseUp", new MouseEventArgs(MouseButtons.Left, 1, to.X, to.Y, 0)); + } + + [Fact] + public void DraggingAClampAlongItsEdgeMovesItOnEverySheet() => StaTestThread.Run(() => + { + var nest = RailNest(); + using var view = EditView(nest); + + Drag(view, At(view, 20, 0.25), At(view, 40, 0.25)); + + var moved = nest.PlateDefaults.ClampRail!.Clamps; + Assert.Equal(ClampEdge.OriginSide, moved[0].Edge); + Assert.InRange(moved[0].Center, 38, 42); // pixel rounding, then a 1/8" snap + Assert.Equal(0, moved[0].Center % 0.125, 9); + Assert.Equal(60, moved[1].Center); + Assert.All(nest.Plates, plate => Assert.Same(nest.PlateDefaults.ClampRail, plate.ClampRail)); + }, TimeSpan.FromMinutes(1), "The STA test did not complete."); + + [Fact] + public void DraggingAClampOntoAnotherEdgeMovesItThere() => StaTestThread.Run(() => + { + var nest = RailNest(); + using var view = EditView(nest); + + Drag(view, At(view, 60, 0.25), At(view, 119.9, 30)); + + var clamp = nest.PlateDefaults.ClampRail!.Clamps.Single(c => c.Edge != ClampEdge.OriginSide); + Assert.Equal(ClampEdge.FarEnd, clamp.Edge); + Assert.InRange(clamp.Center, 28, 32); + Assert.Contains(nest.Plates[1].ClampZones, z => z.Bounds.Left > 119); + }, TimeSpan.FromMinutes(1), "The STA test did not complete."); + + [Fact] + public void ClickingNearAnEdgeAddsAClamp() => StaTestThread.Run(() => + { + var nest = RailNest(); + using var view = EditView(nest); + + Click(view, At(view, 90, 47.8)); // just inside the far side + + var added = nest.PlateDefaults.ClampRail!.Clamps.Single(c => c.Edge == ClampEdge.FarSide); + Assert.InRange(added.Center, 88, 92); + Assert.Equal(3, nest.Plates[1].ClampZones.Count); + }, TimeSpan.FromMinutes(1), "The STA test did not complete."); + + [Fact] + public void ClickingInsideTheSheetAddsNothing() => StaTestThread.Run(() => + { + var nest = RailNest(); + using var view = EditView(nest); + + Click(view, At(view, 60, 24)); + + Assert.Equal(2, nest.PlateDefaults.ClampRail!.Clamps.Count); + }, TimeSpan.FromMinutes(1), "The STA test did not complete."); + + [Fact] + public void TypingADimensionMovesTheClamp() => StaTestThread.Run(() => + { + var nest = RailNest(); + using var view = EditView(nest); + // The near-edge dimension of the clamp centered at 20 (2 wide) reads 19, below the sheet. + var editTo = Properties.Settings.Default.ClampDimensionToCenter ? 30.0 : 29.0; + var label = view.PointWorldToControl(new Vector(Properties.Settings.Default.ClampDimensionToCenter ? 20 : 19, 0)); + label.Offset(0, 30); + + Click(view, label); + var editor = Assert.Single(view.Controls.OfType()); + editor.Text = editTo.ToString(System.Globalization.CultureInfo.CurrentCulture); + typeof(TextBox).GetMethod("OnKeyDown", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(editor, new object[] { new KeyEventArgs(Keys.Enter) }); + + Assert.Empty(view.Controls.OfType()); + Assert.Equal(new double[] { 30, 60 }, nest.PlateDefaults.ClampRail!.Centers); + }, TimeSpan.FromMinutes(1), "The STA test did not complete."); + + [Fact] + public void DeleteRemovesTheSelectedClampAndNeverParts() => StaTestThread.Run(() => + { + var nest = RailNest(); + using var view = EditView(nest); + + Click(view, At(view, 60, 0.25)); + Raise(view, "OnKeyDown", new KeyEventArgs(Keys.Delete)); + + Assert.Equal(new double[] { 20 }, nest.PlateDefaults.ClampRail!.Centers); + + Click(view, At(view, 20, 0.25)); + Raise(view, "OnKeyDown", new KeyEventArgs(Keys.Delete)); + Assert.Null(nest.PlateDefaults.ClampRail); + Assert.Empty(nest.Plates[1].ClampZones); + }, TimeSpan.FromMinutes(1), "The STA test did not complete."); +} diff --git a/OpenNest/Actions/ActionClampEdit.cs b/OpenNest/Actions/ActionClampEdit.cs new file mode 100644 index 00000000..13648681 --- /dev/null +++ b/OpenNest/Actions/ActionClampEdit.cs @@ -0,0 +1,730 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Globalization; +using System.Linq; +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Data; +using OpenNest.Forms; +using OpenNest.Geometry; + +namespace OpenNest.Actions +{ + /// + /// Plate > Edit Clamps: moves, adds and deletes the nest's machine clamps directly on + /// the plate view. Drag a clamp along its edge or onto another edge, click near an edge + /// to add one, click a clamp's locating dimension to type its position, Delete removes + /// the selected clamp. Every change replaces the nest's through + /// , so all sheets follow it, exactly like the Clamps + /// dialog. Dimensions measure from the plate origin to the clamp center or to the clamp + /// edge nearer the origin (ClampDimensionToCenter setting). + /// + [DisplayName("Edit Clamps")] + public class ActionClampEdit : Action + { + // Screen sizes, in pixels. + private const float ExtensionLength = 22f; + // End-edge labels sit beside the sheet: a shorter line keeps them inside zoom-to-fit's border. + private const float EndExtensionLength = 8f; + private const float LabelPadding = 3f; + private const float EdgeReach = 16f; + private const float HitTolerance = 3f; + private const int DragThreshold = 3; + + private static readonly Color HandleColor = Color.FromArgb(220, 30, 110, 200); + private static readonly Color HoverColor = Color.FromArgb(200, 255, 140, 0); + private static readonly Color DimensionColor = Color.FromArgb(235, 230, 120, 20); + + private readonly List<(RailClamp Clamp, RectangleF Bounds)> labels = new(); + private RailClamp? selected; + private RailClamp? hovered; + private RailClamp? hoveredLabel; + private RailClamp? ghost; + private RailClamp? dragFrom; + private RailClamp? dragTo; + private double dragOffset; + private Point dragStart; + private bool dragMoved; + private DimensionBox editor; + private RailClamp editing; + private ContextMenuStrip menu; + private ClampSettings defaults; + + public ActionClampEdit(PlateView plateView) + : base(plateView) + { + plateView.DeselectAll(); + ConnectEvents(); + } + + public override bool SurvivesPlateChange => true; + + public override bool ResumeOnEscape => false; + + private static bool DimensionToCenter + { + get => Properties.Settings.Default.ClampDimensionToCenter; + set + { + Properties.Settings.Default.ClampDimensionToCenter = value; + Properties.Settings.Default.Save(); + } + } + + private Nest Nest => plateView.ClampNest?.Invoke(); + + private Plate Plate => plateView.Plate; + + private ClampRail Rail => Nest?.PlateDefaults.ClampRail; + + private IReadOnlyList Clamps => Rail?.Clamps ?? Array.Empty(); + + private Units Units => Nest?.Units ?? Units.Inches; + + // A nest without clamps takes its first clamp's size from the machine defaults. + private ClampSettings Defaults => + defaults ??= MachineSettings.LoadSavedClampSettings(out _).ConvertedTo(Units); + + private double ClampWidth => Rail?.Width ?? Defaults.Width; + + private double ClampDepth => Rail?.Depth ?? Defaults.Depth; + + public override void ConnectEvents() + { + plateView.MouseDown += OnMouseDown; + plateView.MouseMove += OnMouseMove; + plateView.MouseUp += OnMouseUp; + plateView.KeyDown += OnKeyDown; + plateView.Paint += OnPaint; + plateView.Invalidate(); + } + + public override void DisconnectEvents() + { + plateView.MouseDown -= OnMouseDown; + plateView.MouseMove -= OnMouseMove; + plateView.MouseUp -= OnMouseUp; + plateView.KeyDown -= OnKeyDown; + plateView.Paint -= OnPaint; + CloseEditor(apply: false); + menu?.Dispose(); + menu = null; + plateView.Cursor = Cursors.Cross; + plateView.Invalidate(); + } + + public override void CancelAction() + { + CloseEditor(apply: false); + dragFrom = null; + dragTo = null; + plateView.Invalidate(); + } + + public override bool IsBusy() => editor != null || dragFrom != null; + + public override void OnPlateChanged() + { + CancelAction(); + hovered = hoveredLabel = ghost = null; + } + + #region Geometry (the parts' placement frame: plate origin at 0,0) + + private int SignX => Plate.Quadrant is 2 or 3 ? -1 : 1; + + private int SignY => Plate.Quadrant is 3 or 4 ? -1 : 1; + + private double EdgeLength(ClampEdge edge) => + edge.RunsAlongX() ? Plate.Size.Length : Plate.Size.Width; + + /// Unit vector out of the sheet across . + private Vector Outward(ClampEdge edge) => + edge switch + { + ClampEdge.OriginSide => new Vector(0, -SignY), + ClampEdge.FarSide => new Vector(0, SignY), + ClampEdge.OriginEnd => new Vector(-SignX, 0), + _ => new Vector(SignX, 0), + }; + + /// The point on from the origin. + private Vector PointOnEdge(ClampEdge edge, double distance) => + edge switch + { + ClampEdge.OriginSide => new Vector(SignX * distance, 0), + ClampEdge.FarSide => new Vector(SignX * distance, SignY * Plate.Size.Width), + ClampEdge.OriginEnd => new Vector(0, SignY * distance), + _ => new Vector(SignX * Plate.Size.Length, SignY * distance), + }; + + private Box Footprint(RailClamp clamp) + { + var box = ClampRail.Footprint(clamp, ClampWidth, ClampDepth, Plate.Size, Plate.Quadrant); + return box?.Translate(SignX < 0 ? -Plate.Size.Length : 0, SignY < 0 ? -Plate.Size.Width : 0); + } + + /// The value a dimension shows for a clamp centered at . + private double Measure(double center) => DimensionToCenter ? center : center - ClampWidth / 2; + + private double CenterFor(double measure) => DimensionToCenter ? measure : measure + ClampWidth / 2; + + private double SnapIncrement => Units == Units.Millimeters ? 1.0 : 0.125; + + /// + /// The clamp position on the sheet edge nearest , or null when + /// that edge is farther than . The along-edge distance stays on + /// the sheet and snaps the dimension's value unless Shift is held. + /// + private RailClamp? Project(Vector point, double reach, double offset = 0, ClampEdge? offsetEdge = null) + { + RailClamp? best = null; + var bestDistance = double.MaxValue; + foreach (var edge in ClampEdgeText.All) + { + var length = EdgeLength(edge); + var along = edge.RunsAlongX() ? SignX * point.X : SignY * point.Y; + along = System.Math.Clamp(along, 0, length); + var distance = PointOnEdge(edge, along).DistanceTo(point); + if (distance >= bestDistance) + continue; + bestDistance = distance; + + var center = along + (edge == offsetEdge ? offset : 0); + if ((Control.ModifierKeys & Keys.Shift) == 0) + { + var step = SnapIncrement; + center = CenterFor(System.Math.Round(Measure(center) / step) * step); + } + best = new RailClamp(edge, System.Math.Clamp(center, 0, length)); + } + return bestDistance <= reach ? best : null; + } + + private RailClamp? ClampAt(Vector point) + { + var tolerance = HitTolerance / plateView.ViewScale; + foreach (var clamp in Clamps) + { + var box = Footprint(clamp); + if (box != null && box.Offset(tolerance).Contains(point)) + return clamp; + } + return null; + } + + private RailClamp? LabelAt(Point location) + { + var point = plateView.PointControlToGraph(location); + foreach (var label in labels) + { + if (label.Bounds.Contains(point)) + return label.Clamp; + } + return null; + } + + #endregion + + #region Mouse and keys + + private void OnMouseDown(object sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Left || Nest == null) + return; + + var label = LabelAt(e.Location); + if (label != null) + { + selected = label; + OpenEditor(label.Value); + return; + } + + var clamp = ClampAt(plateView.CurrentPoint); + if (clamp != null) + { + selected = clamp; + dragFrom = clamp; + dragTo = null; + dragMoved = false; + dragStart = e.Location; + var c = clamp.Value; + var grabbed = c.Edge.RunsAlongX() ? SignX * plateView.CurrentPoint.X : SignY * plateView.CurrentPoint.Y; + dragOffset = c.Center - grabbed; + plateView.Status = $"{Describe(c)}: drag along any edge (Shift: no snap)"; + } + else if (ghost != null) + { + Add(ghost.Value); + ghost = null; + } + else + { + selected = null; + } + plateView.Invalidate(); + } + + private void OnMouseMove(object sender, MouseEventArgs e) + { + if (Nest == null) + return; + + if (dragFrom != null && e.Button == MouseButtons.Left) + { + if (!dragMoved + && System.Math.Abs(e.X - dragStart.X) <= DragThreshold + && System.Math.Abs(e.Y - dragStart.Y) <= DragThreshold) + return; + dragMoved = true; + dragTo = Project(plateView.CurrentPoint, double.MaxValue, dragOffset, dragFrom.Value.Edge); + if (dragTo != null) + plateView.Status = $"Move to {Format(Measure(dragTo.Value.Center))} on {ClampEdgeText.Phrase(dragTo.Value.Edge)}"; + plateView.Invalidate(); + return; + } + + if (e.Button != MouseButtons.None) + return; + + var label = LabelAt(e.Location); + var clamp = label == null ? ClampAt(plateView.CurrentPoint) : null; + RailClamp? add = null; + if (label == null && clamp == null) + { + var reach = EdgeReach / plateView.ViewScale + ClampDepth; + add = Project(plateView.CurrentPoint, reach); + if (add != null && Clamps.Contains(add.Value)) + add = null; + } + + if (label == hoveredLabel && clamp == hovered && add == ghost) + return; + + hoveredLabel = label; + hovered = clamp; + ghost = add; + plateView.Cursor = label != null ? Cursors.Hand : clamp != null ? Cursors.SizeAll : Cursors.Cross; + plateView.Status = + label != null ? $"Click to type the position of {Describe(label.Value)}" + : clamp != null ? $"Drag {Describe(clamp.Value)} to move it; Delete removes the selected clamp" + : add != null ? $"Click to add a clamp on {ClampEdgeText.Phrase(add.Value.Edge)} at {Format(Measure(add.Value.Center))}" + : "Edit Clamps: drag clamps along any edge, click near an edge to add one, Esc to finish"; + plateView.Invalidate(); + } + + private void OnMouseUp(object sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Right && editor == null && dragFrom == null) + { + ShowMenu(e.Location); + return; + } + + if (e.Button != MouseButtons.Left || dragFrom == null) + return; + + var from = dragFrom.Value; + var to = dragTo; + dragFrom = null; + dragTo = null; + if (dragMoved && to != null && to.Value != from) + Move(from, to.Value); + plateView.Invalidate(); + } + + private void OnKeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Delete && selected != null && editor == null) + { + Delete(selected.Value); + e.Handled = true; + } + } + + private void ShowMenu(Point location) + { + if (Nest == null) + return; + + var clamp = ClampAt(plateView.CurrentPoint) ?? LabelAt(location); + var add = clamp == null ? ghost : null; + if (clamp != null) + selected = clamp; + + menu ??= new ContextMenuStrip(); + while (menu.Items.Count > 0) + menu.Items[0].Dispose(); + + if (clamp != null) + { + var c = clamp.Value; + menu.Items.Add("Edit Position...", null, (_, _) => OpenEditor(c)); + menu.Items.Add($"Delete {Describe(c)}", null, (_, _) => Delete(c)); + menu.Items.Add(new ToolStripSeparator()); + } + else if (add != null) + { + var a = add.Value; + menu.Items.Add($"Add Clamp on {CultureInfo.CurrentCulture.TextInfo.ToTitleCase(ClampEdgeText.Phrase(a.Edge))}", null, (_, _) => Add(a)); + menu.Items.Add(new ToolStripSeparator()); + } + + var toCenter = new ToolStripMenuItem("Dimension to Clamp Center") { Checked = DimensionToCenter }; + toCenter.Click += (_, _) => SetDimensionToCenter(true); + var toEdge = new ToolStripMenuItem("Dimension to Near Edge") { Checked = !DimensionToCenter }; + toEdge.Click += (_, _) => SetDimensionToCenter(false); + menu.Items.Add(toCenter); + menu.Items.Add(toEdge); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Finish Editing Clamps", null, (_, _) => plateView.SetAction(typeof(ActionSelect))); + + plateView.Invalidate(); + menu.Show(plateView, location); + } + + private void SetDimensionToCenter(bool value) + { + DimensionToCenter = value; + plateView.Status = value + ? "Clamp dimensions now locate clamp centers" + : "Clamp dimensions now locate the clamp edge nearer the origin"; + plateView.Invalidate(); + } + + #endregion + + #region Edits + + private void Add(RailClamp clamp) + { + if (Clamps.Contains(clamp)) + return; + if (Commit(Clamps.Append(clamp), $"Added a clamp on {ClampEdgeText.Phrase(clamp.Edge)} at {Format(Measure(clamp.Center))}")) + selected = clamp; + } + + private void Move(RailClamp from, RailClamp to) + { + if (Clamps.Contains(to)) + { + plateView.Status = "A clamp is already at that position."; + return; + } + if (Commit(Clamps.Where(c => c != from).Append(to), + $"Moved the clamp to {Format(Measure(to.Center))} on {ClampEdgeText.Phrase(to.Edge)}")) + selected = to; + } + + private void Delete(RailClamp clamp) + { + if (Commit(Clamps.Where(c => c != clamp), $"Deleted the clamp at {Format(Measure(clamp.Center))} on {ClampEdgeText.Phrase(clamp.Edge)}")) + selected = null; + } + + /// + /// Makes the nest's rail (every sheet follows it). Refused + /// while a nest run or fill is using the clamps or a proposal is on display. + /// + private bool Commit(IEnumerable clamps, string status) + { + var nest = Nest; + if (nest == null) + return false; + + if (plateView.IsFillInProgress || plateView.HasProposalDisplay || plateView.CanEditClamps?.Invoke() == false) + { + MessageBox.Show( + plateView.FindForm(), + "Finish or cancel the current nesting or plate action first.", + "Edit Clamps", + MessageBoxButtons.OK, + MessageBoxIcon.Information + ); + return false; + } + + var list = clamps.ToList(); + ClampRail rail; + try + { + rail = list.Count == 0 + ? null + : new ClampRail(ClampWidth, ClampDepth, list, Rail?.RemovalMinutes ?? Defaults.RemovalMinutes); + } + catch (ArgumentException ex) + { + plateView.Status = ex.Message; + return false; + } + + // A new rail puts back every clamp a sheet had removed, as the Clamps dialog does. + var restored = nest.Plates.Count(p => p.RemovedClamps.Count > 0); + nest.SetClampRail(rail); + plateView.Status = restored == 0 + ? status + : $"{status}; removed clamps were put back on {restored} sheet(s)"; + plateView.Invalidate(); + return true; + } + + #endregion + + #region Dimension editor + + private void OpenEditor(RailClamp clamp) + { + CloseEditor(apply: false); + var label = labels.FirstOrDefault(l => l.Clamp == clamp); + if (label.Bounds.IsEmpty) + return; + + editing = clamp; + var location = plateView.PointGraphToControl(label.Bounds.Location); + editor = new DimensionBox + { + Font = plateView.Font, + Text = Measure(clamp.Center).ToString("0.####", CultureInfo.CurrentCulture), + Location = location, + Width = System.Math.Max((int)label.Bounds.Width + 24, 80), + }; + editor.Commit += (_, _) => CloseEditor(apply: true); + editor.LostFocus += (_, _) => CloseEditor(apply: true); + plateView.Controls.Add(editor); + editor.Focus(); + editor.SelectAll(); + plateView.Status = DimensionToCenter + ? "Type the distance from the origin to the clamp center, then Enter (Esc cancels)" + : "Type the distance from the origin to the clamp's near edge, then Enter (Esc cancels)"; + } + + private void CloseEditor(bool apply) + { + var box = editor; + if (box == null) + return; + editor = null; // first: removing the box raises LostFocus again + var text = box.Text; + plateView.Controls.Remove(box); + box.Dispose(); + if (plateView.CanFocus) + plateView.Focus(); + + if (apply && TryParseLength(text, out var value)) + { + var center = CenterFor(value); + if (!double.IsFinite(center) || center < 0) + plateView.Status = "The clamp center must be at or beyond the origin."; + else if (center != editing.Center) + Move(editing, new RailClamp(editing.Edge, center)); + } + else if (apply) + { + plateView.Status = $"'{text}' is not a distance."; + } + plateView.Invalidate(); + } + + private static bool TryParseLength(string text, out double value) + { + text = (text ?? "").Trim().TrimEnd('"').Replace("mm", "").Replace("in", "").Trim(); + return double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value) + || double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + + /// A text box that commits on Enter instead of letting the form handle it. + private sealed class DimensionBox : TextBox + { + public event EventHandler Commit; + + protected override bool IsInputKey(Keys keyData) => + keyData == Keys.Enter || base.IsInputKey(keyData); + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.KeyCode == Keys.Enter) + { + e.SuppressKeyPress = true; + Commit?.Invoke(this, EventArgs.Empty); + return; + } + base.OnKeyDown(e); + } + } + + #endregion + + #region Painting + + private void OnPaint(object sender, PaintEventArgs e) + { + labels.Clear(); + if (Nest == null || plateView.HasProposalDisplay) + return; + + var g = e.Graphics; + var smoothing = g.SmoothingMode; + g.SmoothingMode = SmoothingMode.AntiAlias; + + var shown = Clamps.ToList(); + if (dragFrom != null && dragTo != null) + { + DrawFootprint(g, dragFrom.Value, Color.FromArgb(150, 120, 120, 120), 1.5f, DashStyle.Dash); + shown.Remove(dragFrom.Value); + shown.Add(dragTo.Value); + } + + foreach (var clamp in shown) + { + var active = clamp == dragTo || clamp == selected; + var color = active ? HandleColor : clamp == hovered || clamp == hoveredLabel ? HoverColor : HandleColor; + DrawFootprint(g, clamp, color, active ? 3f : clamp == hovered ? 2.5f : 1f, DashStyle.Solid, + fill: clamp == dragTo); + } + + if (ghost != null) + DrawGhost(g, ghost.Value); + + DrawDimensions(g, shown); + g.SmoothingMode = smoothing; + } + + private RectangleF ToGraph(Box box) + { + var location = plateView.PointWorldToGraph(box.Location); + var width = plateView.LengthWorldToGui(box.Length); + var height = plateView.LengthWorldToGui(box.Width); + return new RectangleF(location.X, location.Y - height, width, height); + } + + private void DrawFootprint(Graphics g, RailClamp clamp, Color color, float width, DashStyle dash, bool fill = false) + { + var box = Footprint(clamp); + if (box == null) + return; + var rect = ToGraph(box); + if (fill) + { + using var brush = new SolidBrush(Color.FromArgb(70, HandleColor)); + g.FillRectangle(brush, rect); + } + using var pen = new Pen(color, width) { DashStyle = dash }; + g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); + } + + private void DrawGhost(Graphics g, RailClamp clamp) + { + var box = Footprint(clamp); + if (box == null) + return; + var rect = ToGraph(box); + using var brush = new SolidBrush(Color.FromArgb(50, HoverColor)); + using var pen = new Pen(HoverColor, 1.5f) { DashStyle = DashStyle.Dash }; + g.FillRectangle(brush, rect); + g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); + + var size = System.Math.Min(System.Math.Min(rect.Width, rect.Height) * 0.35f, 6f); + var cx = rect.X + rect.Width / 2; + var cy = rect.Y + rect.Height / 2; + using var plus = new Pen(HoverColor, 1.5f); + g.DrawLine(plus, cx - size, cy, cx + size, cy); + g.DrawLine(plus, cx, cy - size, cx, cy + size); + } + + /// + /// Ordinate dimensions: from each clamp's reference point a line runs out across the + /// sheet edge to a label with its distance from the origin. Labels that would overlap + /// on the same edge are pushed farther out. + /// + private void DrawDimensions(Graphics g, IReadOnlyList clamps) + { + using var linePen = new Pen(DimensionColor, 1.5f); + using var font = new Font(plateView.Font.FontFamily, 8.25f); + using var textBrush = new SolidBrush(Color.Black); + var placed = new List(); + + foreach (var group in clamps.GroupBy(c => c.Edge)) + { + var edge = group.Key; + var outward = Outward(edge); + var screenOut = new PointF((float)outward.X, (float)-outward.Y); + var alongX = edge.RunsAlongX(); + + foreach (var clamp in group.OrderBy(c => c.Center)) + { + if (Footprint(clamp) == null) + continue; // beyond this sheet + var measure = Measure(clamp.Center); + var text = Format(measure); + var textSize = g.MeasureString(text, font); + var labelSize = new SizeF(textSize.Width + 2 * LabelPadding, textSize.Height + LabelPadding); + // End-edge labels read vertically, as on a drawing, so they stay close to the sheet. + if (!alongX) + labelSize = new SizeF(labelSize.Height, labelSize.Width); + + var anchor = plateView.PointWorldToGraph(PointOnEdge(edge, measure)); + var depth = plateView.LengthWorldToGui(ClampDepth); + var start = new PointF(anchor.X - screenOut.X * depth, anchor.Y - screenOut.Y * depth); + + RectangleF rect; + PointF end; + var length = alongX ? ExtensionLength : EndExtensionLength; + var tries = 0; + do + { + end = new PointF(anchor.X + screenOut.X * length, anchor.Y + screenOut.Y * length); + rect = alongX + ? new RectangleF(end.X - labelSize.Width / 2, screenOut.Y > 0 ? end.Y : end.Y - labelSize.Height, + labelSize.Width, labelSize.Height) + : new RectangleF(screenOut.X > 0 ? end.X : end.X - labelSize.Width, end.Y - labelSize.Height / 2, + labelSize.Width, labelSize.Height); + length += (alongX ? labelSize.Height : labelSize.Width) + 2; + } while (placed.Any(p => p.IntersectsWith(RectangleF.Inflate(rect, 2, 2))) && ++tries < 8); + placed.Add(rect); + + g.DrawLine(linePen, start, end); + var active = clamp == selected || clamp == dragTo; + var hot = clamp == hoveredLabel; + using (var back = new SolidBrush(active ? Color.FromArgb(235, 215, 232, 252) + : hot ? Color.FromArgb(235, 255, 236, 210) : Color.FromArgb(235, 255, 255, 255))) + g.FillRectangle(back, rect); + using (var border = new Pen(active ? HandleColor : DimensionColor, active || hot ? 1.5f : 1f)) + g.DrawRectangle(border, rect.X, rect.Y, rect.Width, rect.Height); + if (alongX) + { + g.DrawString(text, font, textBrush, rect.X + LabelPadding, rect.Y + LabelPadding / 2); + } + else + { + var state = g.Save(); + g.TranslateTransform(rect.X + rect.Width / 2, rect.Y + rect.Height / 2); + g.RotateTransform(-90); + g.DrawString(text, font, textBrush, -textSize.Width / 2, -textSize.Height / 2); + g.Restore(state); + } + + // The label being dragged is not a click target until it lands. + if (clamp != dragTo) + labels.Add((clamp, rect)); + } + } + } + + #endregion + + private string Format(double value) => + Units == Units.Millimeters + ? $"{value.ToString("0.#", CultureInfo.CurrentCulture)} mm" + : $"{value.ToString("0.###", CultureInfo.CurrentCulture)}\""; + + private string Describe(RailClamp clamp) + { + var index = Clamps.ToList().IndexOf(clamp); + return index >= 0 ? ClampRail.IdOf(index) : "the clamp"; + } + } +} diff --git a/OpenNest/Controls/ClampMenuHandler.cs b/OpenNest/Controls/ClampMenuHandler.cs index 23eccf4e..1afda797 100644 --- a/OpenNest/Controls/ClampMenuHandler.cs +++ b/OpenNest/Controls/ClampMenuHandler.cs @@ -59,6 +59,8 @@ namespace OpenNest.Controls removed ? $"Put {clamp.Id} Back on This Sheet" : $"Remove {clamp.Id} from This Sheet" ); item.Click += (_, _) => Apply(plate, clamp.Id, !removed); + if (view.ClampNest != null) + menu.Items.Add("Edit Clamps", null, (_, _) => view.SetAction(typeof(ActionClampEdit))); return menu; } diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 324271e9..95000b7a 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -167,6 +167,12 @@ namespace OpenNest.Controls /// internal Func CanEditClamps { get; set; } + /// + /// The nest whose clamp rail Plate > Edit Clamps changes; null when this view has + /// no nest to edit (clamp edit mode then does nothing). + /// + internal Func ClampNest { get; set; } + public bool AllowZoom { get; set; } public bool AllowSelect { get; set; } @@ -630,7 +636,11 @@ namespace OpenNest.Controls switch (e.KeyCode) { case Keys.Delete: - selection.DeleteSelected(); + // Clamp edit mode deletes its selected clamp, never parts. + if (actionManager.CurrentAction is ActionClampEdit) + base.OnKeyDown(e); + else + selection.DeleteSelected(); break; case Keys.F: @@ -794,6 +804,7 @@ namespace OpenNest.Controls } clampMenu?.Dispose(); CanEditClamps = null; + ClampNest = null; overlapOverlay?.Dispose(); validationOverlay?.Dispose(); cutSimulation?.Dispose(); diff --git a/OpenNest/Forms/ClampEdgeText.cs b/OpenNest/Forms/ClampEdgeText.cs new file mode 100644 index 00000000..603d3a2d --- /dev/null +++ b/OpenNest/Forms/ClampEdgeText.cs @@ -0,0 +1,32 @@ +namespace OpenNest.Forms; + +/// Operator-facing names for , relative to the plate origin. +internal static class ClampEdgeText +{ + public static readonly ClampEdge[] All = + { + ClampEdge.OriginSide, + ClampEdge.FarSide, + ClampEdge.OriginEnd, + ClampEdge.FarEnd, + }; + + public static string Name(ClampEdge edge) => + edge switch + { + ClampEdge.FarSide => "Far side (X)", + ClampEdge.OriginEnd => "Origin end (Y)", + ClampEdge.FarEnd => "Far end (Y)", + _ => "Origin side (X)", + }; + + /// Lowercase phrase for status text, e.g. "the far side". + public static string Phrase(ClampEdge edge) => + edge switch + { + ClampEdge.FarSide => "the far side", + ClampEdge.OriginEnd => "the origin end", + ClampEdge.FarEnd => "the far end", + _ => "the origin side", + }; +} diff --git a/OpenNest/Forms/ClampsForm.cs b/OpenNest/Forms/ClampsForm.cs index 316355c2..81ab5105 100644 --- a/OpenNest/Forms/ClampsForm.cs +++ b/OpenNest/Forms/ClampsForm.cs @@ -8,8 +8,8 @@ using OpenNest.Data; namespace OpenNest.Forms; /// -/// Nest > Clamps...: edits the current nest's machine clamp rail (identical clamps at X -/// centers measured from the plate origin along the origin-side edge). OK applies the rail +/// Nest > Clamps...: edits the current nest's machine clamp rail (identical clamps, each on +/// one sheet edge with its center measured from the plate origin along it). OK applies the rail /// to every plate of this nest; Cancel discards those edits. Save as Machine Defaults writes /// clamps.json immediately and is not undone by Cancel. All decisions live in /// ; this form only renders and forwards input. @@ -22,6 +22,7 @@ public sealed class ClampsForm : Form private readonly NumericUpDown depthBox; private readonly NumericUpDown removalMinutesBox; private readonly NumericUpDown centerBox; + private readonly ComboBox edgeBox; private readonly ListBox centersList; private readonly Button addButton; private readonly Button removeButton; @@ -64,10 +65,10 @@ public sealed class ClampsForm : Form MaximumSize = new Size(450, 0), Padding = new Padding(0, 0, 0, 8), Text = - "Fixed clamps along the plate edge through the origin. Auto Nest keeps parts " - + "clear of each clamp by the plate's bottom edge spacing; clamps that do not " - + "reach a shorter plate are left off it. Interactive fill and push, the " - + "overlap check and cutoffs do not avoid clamps yet.", + "Fixed clamps on the plate edges, each centered a distance from the origin " + + "along its edge. Auto Nest keeps parts clear of each clamp by the plate's " + + "bottom edge spacing; clamps that do not reach a smaller plate are left off " + + "it. Plate > Edit Clamps moves and adds clamps on the plate view.", } ); @@ -94,8 +95,13 @@ public sealed class ClampsForm : Form WrapContents = false, }; centerBox = NewLengthBox(0, minimum: 0m); + edgeBox = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 120 }; + foreach (var edge in ClampEdgeText.All) + edgeBox.Items.Add(ClampEdgeText.Name(edge)); + edgeBox.SelectedIndex = 0; addButton = new Button { Text = "Add", AutoSize = true }; removeButton = new Button { Text = "Remove", AutoSize = true, Enabled = false }; + centerEntry.Controls.Add(edgeBox); centerEntry.Controls.Add(new Label { Text = "Center:", AutoSize = true, Padding = new Padding(0, 6, 0, 0) }); centerEntry.Controls.Add(centerBox); centerEntry.Controls.Add(addButton); @@ -215,7 +221,8 @@ public sealed class ClampsForm : Form private void AddCenter() { - if (!Editor.TryAddCenter((double)centerBox.Value, out var error)) + var clamp = new RailClamp(ClampEdgeText.All[edgeBox.SelectedIndex], (double)centerBox.Value); + if (!Editor.TryAddClamp(clamp, out var error)) { MessageBox.Show(this, error, Text, MessageBoxButtons.OK, MessageBoxIcon.Information); return; @@ -237,8 +244,10 @@ public sealed class ClampsForm : Form { centersList.BeginUpdate(); centersList.Items.Clear(); - foreach (var center in Editor.Centers) - centersList.Items.Add(center.ToString("0.####", CultureInfo.CurrentCulture)); + foreach (var clamp in Editor.Clamps) + centersList.Items.Add( + $"{ClampEdgeText.Name(clamp.Edge)}: {clamp.Center.ToString("0.####", CultureInfo.CurrentCulture)}" + ); centersList.EndUpdate(); removeButton.Enabled = centersList.SelectedIndex >= 0; } @@ -261,7 +270,7 @@ public sealed class ClampsForm : Form var settings = Editor.ToMachineDefaults(); settings.Save(settingsPath); savedLabel.Text = settings.Enabled - ? $"Saved {DateTime.Now:t}: new nests get these {settings.Centers.Count} clamps. Cancel does not undo this." + ? $"Saved {DateTime.Now:t}: new nests get these {settings.ClampCount} clamps. Cancel does not undo this." : $"Saved {DateTime.Now:t}: new nests get no clamps. Cancel does not undo this."; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index cfa13a88..f322e67c 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -73,6 +73,7 @@ namespace OpenNest.Forms PlateView.PartRemoved += PlateView_PartRemoved; PlateView.PartsReordered += PlateView_PartsReordered; PlateView.Dock = DockStyle.Fill; + PlateView.ClampNest = () => Nest; InitializeComponent(); CreatePlateHeader(); diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 755c3467..df0af5b8 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -154,6 +154,7 @@ namespace OpenNest.Forms toolStripMenuItem20 = new System.Windows.Forms.ToolStripSeparator(); mnuPlateViewInCad = new System.Windows.Forms.ToolStripMenuItem(); mnuPlateCutOff = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateEditClamps = new System.Windows.Forms.ToolStripMenuItem(); mnuPlateAutomaticCutOff = new System.Windows.Forms.ToolStripMenuItem(); mnuWindow = new System.Windows.Forms.ToolStripMenuItem(); mnuWindowCascade = new System.Windows.Forms.ToolStripMenuItem(); @@ -746,7 +747,7 @@ namespace OpenNest.Forms // // mnuPlate // - mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateEdit, mnuPlateSetAsDefault, toolStripMenuItem18, mnuPlateAdd, mnuPlateRemove, toolStripMenuItem16, mnuPlateCutOff, mnuPlateAutomaticCutOff, mnuPlateRotate, mnuResizeToFitParts, toolStripMenuItem13, mnuPlateArrange, mnuSequenceParts, calculateCutTimeToolStripMenuItem1, mnuPlateSimulateCutting, toolStripMenuItem21, mnuPlatePlanCutting, mnuPlateAssignLeadIns, mnuPlatePlaceLeadIn, mnuPlateRemoveLeadIns, toolStripMenuItem20, mnuPlateViewInCad }); + mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateEdit, mnuPlateSetAsDefault, toolStripMenuItem18, mnuPlateAdd, mnuPlateRemove, toolStripMenuItem16, mnuPlateCutOff, mnuPlateAutomaticCutOff, mnuPlateEditClamps, mnuPlateRotate, mnuResizeToFitParts, toolStripMenuItem13, mnuPlateArrange, mnuSequenceParts, calculateCutTimeToolStripMenuItem1, mnuPlateSimulateCutting, toolStripMenuItem21, mnuPlatePlanCutting, mnuPlateAssignLeadIns, mnuPlatePlaceLeadIn, mnuPlateRemoveLeadIns, toolStripMenuItem20, mnuPlateViewInCad }); mnuPlate.Name = "mnuPlate"; mnuPlate.Size = new System.Drawing.Size(45, 20); mnuPlate.Text = "&Plate"; @@ -798,6 +799,13 @@ namespace OpenNest.Forms mnuPlateCutOff.Text = "Sheet Cut-Off"; mnuPlateCutOff.Click += CutOff_Click; // + // mnuPlateEditClamps + // + mnuPlateEditClamps.Name = "mnuPlateEditClamps"; + mnuPlateEditClamps.Size = new System.Drawing.Size(177, 22); + mnuPlateEditClamps.Text = "Edit Clamps"; + mnuPlateEditClamps.Click += EditClamps_Click; + // // mnuPlateAutomaticCutOff // mnuPlateAutomaticCutOff.Name = "mnuPlateAutomaticCutOff"; @@ -1348,6 +1356,7 @@ namespace OpenNest.Forms private System.Windows.Forms.ToolStripMenuItem mnuNest; private System.Windows.Forms.ToolStripMenuItem mnuNestEdit; private System.Windows.Forms.ToolStripMenuItem mnuNestClamps; + private System.Windows.Forms.ToolStripMenuItem mnuPlateEditClamps; private System.Windows.Forms.ToolStripMenuItem mnuNestImportDrawing; private System.Windows.Forms.ToolStripMenuItem mnuNestShapeLibrary; private System.Windows.Forms.ToolStripMenuItem mnuNestDrawingLibrary; diff --git a/OpenNest/Forms/MainForm.Plate.cs b/OpenNest/Forms/MainForm.Plate.cs index 27267398..b0d14626 100644 --- a/OpenNest/Forms/MainForm.Plate.cs +++ b/OpenNest/Forms/MainForm.Plate.cs @@ -55,6 +55,8 @@ namespace OpenNest.Forms private void CutOff_Click(object sender, EventArgs e) => WithActive(f => f.PlateView.SetAction(typeof(ActionCutOff))); + private void EditClamps_Click(object sender, EventArgs e) => WithActive(f => f.PlateView.SetAction(typeof(ActionClampEdit))); + private void AutomaticCutOff_Click(object sender, EventArgs e) => ShowAutomaticCutOff(allPlates: false); private void NestAutomaticCutOff_Click(object sender, EventArgs e) => ShowAutomaticCutOff(allPlates: true); diff --git a/OpenNest/Properties/Settings.Designer.cs b/OpenNest/Properties/Settings.Designer.cs index 965f119a..8a66dc0f 100644 --- a/OpenNest/Properties/Settings.Designer.cs +++ b/OpenNest/Properties/Settings.Designer.cs @@ -332,5 +332,18 @@ namespace OpenNest.Properties { this["StatusBarLocationFollowsMouse"] = value; } } + + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("False")] + // Edit Clamps dimensions locate clamp centers; false locates the edge nearer the origin. + public bool ClampDimensionToCenter { + get { + return ((bool)(this["ClampDimensionToCenter"])); + } + set { + this["ClampDimensionToCenter"] = value; + } + } } } diff --git a/OpenNest/Properties/Settings.settings b/OpenNest/Properties/Settings.settings index 8f65e873..cbe87e74 100644 --- a/OpenNest/Properties/Settings.settings +++ b/OpenNest/Properties/Settings.settings @@ -80,5 +80,8 @@ True + + False + diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index eccda7db..c473e679 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -60,9 +60,11 @@ reports the fallback; startup does not replace the saved missing-engine preferen ## Machine edge clamps A nest can carry the machine's fixed clamp rail: identical rectangular clamps -(width along the edge, depth into the sheet) at X centers measured from the -plate origin along the origin-side edge. **Nest > Clamps...** edits the current -nest's rail. **OK** applies it to every plate of the nest (**Cancel** discards +(width along the edge, depth into the sheet), each on one sheet edge with its +center measured from the plate origin along that edge. The edges are named from +the origin: the origin side and far side run along X, the origin end and far +end along Y. **Nest > Clamps...** edits the current nest's rail as a list, and +**Plate > Edit Clamps** edits it on the plate view (below). **OK** applies it to every plate of the nest (**Cancel** discards the edits); removing every center removes the clamps. **Load Machine Defaults** only fills the dialog. **Save as Machine Defaults** writes `%APPDATA%\OpenNest\clamps.json` immediately, with the clamps, their units and @@ -91,6 +93,30 @@ pre-post verification warns (with per-attempt consent) about parts, cut-offs and direct rapids crossing an active clamp - including the first rapid when a clamp covers the program start point. +### Editing clamps on the plate view + +**Plate > Edit Clamps** (or **Edit Clamps** on a clamp's right-click menu) +switches the plate view to clamp edit mode. Each clamp shows a locating +dimension: a line out across the sheet edge to its distance from the plate +origin. Right-click the sheet to choose **Dimension to Clamp Center** or +**Dimension to Near Edge** (the clamp edge nearer the origin, the default); the +choice is remembered. + +- Drag a clamp to move it along its edge, or onto another edge. Positions snap + to 1/8" (1 mm in metric nests) on the dimension's value; hold Shift to move + freely. +- Point near an edge where there is no clamp: a dashed clamp previews the + position, and a click adds it. A nest with no clamps takes the clamp size and + removal time from the machine defaults. +- Click a dimension to type its value, then Enter (Escape cancels). +- Select a clamp and press Delete, or right-click it, to remove it. +- Escape or **Finish Editing Clamps** returns to Select mode. + +Every change is applied at once to the nest's rail, so every plate follows it, +as with **OK** in **Nest > Clamps...**: removed clamps are put back on every +sheet, and changes are refused while nesting or a fill is running. Clamps +beyond the current sheet are not shown. + ### Removing a clamp by hand In Select mode, right-click a clamp on the sheet and choose **Remove clamp-N diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 9b894d12..2b44ab32 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -82,7 +82,15 @@ dropped while the rest of the nest loads. `plateDefaults.clampRail` optionally stores the nest's machine clamp rail: `width` (along the edge), `depth` (into the sheet) and `centers` (X positions -measured from the plate origin along the origin-side edge). New plates follow +measured from the plate origin along the origin-side edge). Clamps on the other +edges are listed in optional `farSideCenters` (X, along the edge across the +sheet's width from the origin), `originEndCenters` (Y, along the edge through +the origin) and `farEndCenters` (Y, along the edge across the sheet's length); +each is omitted when empty, so origin-side-only rails are written exactly as +before. Rail clamp ids (`clamp-N`) number the origin-side clamps first, then +far side, origin end and far end, each by ascending center. Older readers +ignore the extra arrays; a plate's saved `clamps` then no longer match their +rail and load as explicit zones. New plates follow the rail and resolve it for their own size and quadrant; clamps wholly beyond a sheet are skipped and one still reaching onto it keeps its whole footprint, so a zone may extend past the sheet edge. The key is omitted