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