From d35178c3b52d547b48a8b5ad8b7d05d1d4a68d94 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 09:52:38 -0400 Subject: [PATCH] feat(clamps): edit clamps on the plate view, on any sheet edge Clamps could only sit on the origin-side edge and be edited as a list in Nest > Clamps. Setting them up against a real sheet is easier by dragging them where they are, so Plate > Edit Clamps adds an edit mode: 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 distance from the origin, Delete to remove. Dimensions measure to the clamp center or its near edge (remembered per user). Edits replace the nest's rail so every sheet follows, like the dialog. Rail clamps now carry a ClampEdge (origin side, far side, origin end, far end), positioned from the origin along that edge. Origin-side clamps keep their ids and files; other edges persist in optional per-edge arrays in .nest files and clamps.json, omitted when empty. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/ClampEdge.cs | 46 ++ OpenNest.Core/ClampRail.cs | 153 ++-- OpenNest.Core/Nest.cs | 2 +- OpenNest.Data/ClampRailEditor.cs | 52 +- OpenNest.Data/ClampSettings.cs | 128 ++- .../ClampEdgePersistenceTests.cs | 88 +++ OpenNest.IO/NestFormat.cs | 69 +- OpenNest.IO/NestReader.cs | 14 +- OpenNest.IO/NestWriter.cs | 6 + OpenNest.Tests/ClampEdgeTests.cs | 167 ++++ OpenNest.Tests/ClampRailTests.cs | 2 +- .../Controls/ActionClampEditTests.cs | 158 ++++ OpenNest/Actions/ActionClampEdit.cs | 730 ++++++++++++++++++ OpenNest/Controls/ClampMenuHandler.cs | 2 + OpenNest/Controls/PlateView.cs | 13 +- OpenNest/Forms/ClampEdgeText.cs | 32 + OpenNest/Forms/ClampsForm.cs | 29 +- OpenNest/Forms/EditNestForm.cs | 1 + OpenNest/Forms/MainForm.Designer.cs | 11 +- OpenNest/Forms/MainForm.Plate.cs | 2 + OpenNest/Properties/Settings.Designer.cs | 13 + OpenNest/Properties/Settings.settings | 3 + docs/automatic-nesting.md | 32 +- docs/nest-file-format.md | 10 +- 24 files changed, 1638 insertions(+), 125 deletions(-) create mode 100644 OpenNest.Core/ClampEdge.cs create mode 100644 OpenNest.IO.Tests/ClampEdgePersistenceTests.cs create mode 100644 OpenNest.Tests/ClampEdgeTests.cs create mode 100644 OpenNest.WinForms.Tests/Controls/ActionClampEditTests.cs create mode 100644 OpenNest/Actions/ActionClampEdit.cs create mode 100644 OpenNest/Forms/ClampEdgeText.cs 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