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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 11:09:04 -04:00
1 parent a1458a355b
commit d35178c3b5
24 files changed
+1638 -125

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
using System;
namespace OpenNest
{
/// <summary>
/// The sheet edge a <see cref="ClampRail"/> clamp sits on, named relative to the plate
/// origin so one rail resolves onto every sheet size and quadrant.
/// </summary>
public enum ClampEdge
{
/// <summary>The edge along X through the origin (operator side).</summary>
OriginSide = 0,
/// <summary>The edge along X across the sheet's width from the origin.</summary>
FarSide = 1,
/// <summary>The edge along Y through the origin.</summary>
OriginEnd = 2,
/// <summary>The edge along Y across the sheet's length from the origin.</summary>
FarEnd = 3,
}
public static class ClampEdges
{
/// <summary>True for the two edges along X, whose clamp positions are X distances.</summary>
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;
}
/// <summary>
/// One clamp of a <see cref="ClampRail"/>: 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.
/// </summary>
public readonly record struct RailClamp(ClampEdge Edge, double Center) : IComparable<RailClamp>
{
public int CompareTo(RailClamp other)
{
var edge = Edge.CompareTo(other.Edge);
return edge != 0 ? edge : Center.CompareTo(other.Center);
}
}
}
+109 -44
View File
@@ -6,12 +6,12 @@ using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// 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
/// (<see cref="ClampEdge"/>) 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.
/// </summary>
public sealed class ClampRail : IEquatable<ClampRail>
{
@@ -21,14 +21,30 @@ namespace OpenNest
/// </summary>
public const double DefaultRemovalMinutes = 5.0;
/// <summary>A rail whose clamps all sit on the origin-side edge.</summary>
public ClampRail(
double width,
double depth,
IEnumerable<double> 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<RailClamp> 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<double>();
foreach (var center in centers)
var sorted = new SortedSet<RailClamp>();
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();
}
/// <summary>Clamp extent along the edge (X).</summary>
/// <summary>Clamp extent along its edge.</summary>
public double Width { get; }
/// <summary>Clamp intrusion into the sheet from the origin-side edge (Y).</summary>
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
public double Depth { get; }
/// <summary>
@@ -74,9 +93,68 @@ namespace OpenNest
/// </summary>
public double RemovalMinutes { get; }
/// <summary>Distinct clamp centers along X from the origin, ascending.</summary>
/// <summary>
/// Distinct clamps ordered by edge, then center. A clamp's position in this list
/// gives its <c>clamp-N</c> id, so a rail with only origin-side clamps keeps the ids
/// it had before clamps could sit on other edges.
/// </summary>
public IReadOnlyList<RailClamp> Clamps { get; }
/// <summary>Centers of the origin-side clamps, ascending; see <see cref="Clamps"/> for all.</summary>
public IReadOnlyList<double> Centers { get; }
/// <summary>A copy with the same size and removal time and these clamps.</summary>
public ClampRail WithClamps(IEnumerable<RailClamp> clamps) =>
new(Width, Depth, clamps, RemovalMinutes);
/// <summary>The <c>clamp-N</c> id of the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
public static string IdOf(int index) => $"clamp-{index + 1}";
/// <summary>
/// The footprint <paramref name="clamp"/> 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.
/// </summary>
public Box Footprint(RailClamp clamp, Size size, int quadrant) =>
Footprint(clamp, Width, Depth, size, quadrant);
/// <summary>
/// The footprint of a <paramref name="width"/> by <paramref name="depth"/> clamp at
/// <paramref name="clamp"/>; see <see cref="Footprint(RailClamp, Size, int)"/>.
/// </summary>
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);
}
/// <summary>
/// The zones this rail places on a sheet, in plate coordinates (the frame of
/// <see cref="Plate.ClampZones"/>: origin at the sheet's lower-left corner). The
@@ -88,30 +166,12 @@ namespace OpenNest
/// </summary>
public IReadOnlyList<ClampZone> 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<ClampZone>();
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<ClampZone>(Centers.Count);
for (var i = 0; i < Centers.Count; i++)
var zones = new List<ClampZone>(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}";
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ namespace OpenNest
/// </summary>
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)
+31 -21
View File
@@ -8,7 +8,7 @@ namespace OpenNest.Data;
/// </summary>
public sealed class ClampRailEditor
{
private readonly List<double> _centers = new();
private readonly List<RailClamp> _clamps = new();
/// <summary>Starts from the nest's rail, or the default clamp size with no clamps.</summary>
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());
}
/// <summary>The nest's units; every value is in these units.</summary>
public Units Units { get; }
/// <summary>Clamp extent along the edge (X).</summary>
/// <summary>Clamp extent along its edge.</summary>
public double Width { get; set; }
/// <summary>Clamp intrusion into the sheet (Y).</summary>
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
public double Depth { get; set; }
/// <summary>Minutes to remove one clamp and put it back after cutting (D9); not a
@@ -38,37 +38,46 @@ public sealed class ClampRailEditor
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
public bool ApplyToNewNests { get; set; }
/// <summary>Clamp centers along X from the plate origin, ascending and distinct.</summary>
public IReadOnlyList<double> Centers => _centers;
/// <summary>Clamps ordered by edge, then center from the plate origin; distinct.</summary>
public IReadOnlyList<RailClamp> Clamps => _clamps;
/// <summary>Origin-side clamp centers along X from the plate origin, ascending.</summary>
public IReadOnlyList<double> Centers =>
_clamps.Where(c => c.Edge == ClampEdge.OriginSide).Select(c => c.Center).ToList();
/// <summary>Adds an origin-side clamp; see <see cref="TryAddClamp"/>.</summary>
public bool TryAddCenter(double center, out string? error) =>
TryAddClamp(new RailClamp(ClampEdge.OriginSide, center), out error);
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Removes the clamp at <paramref name="index"/> in <see cref="Clamps"/>.</summary>
public void RemoveCenterAt(int index)
{
if (index >= 0 && index < _centers.Count)
_centers.RemoveAt(index);
if (index >= 0 && index < _clamps.Count)
_clamps.RemoveAt(index);
}
/// <summary>
@@ -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());
}
/// <summary>
@@ -93,7 +102,7 @@ public sealed class ClampRailEditor
/// negative/non-finite removal time.
/// </summary>
public ClampRail? BuildRail() =>
_centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes);
_clamps.Count == 0 ? null : new ClampRail(Width, Depth, _clamps, RemovalMinutes);
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
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;
}
}
+96 -32
View File
@@ -63,9 +63,50 @@ public sealed class ClampSettings
/// </summary>
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
/// <summary>Far-side clamp centers along X, measured from the plate origin.</summary>
public IReadOnlyList<double> FarSideCenters { get; set; } = Array.Empty<double>();
/// <summary>Origin-end clamp centers along Y, measured from the plate origin.</summary>
public IReadOnlyList<double> OriginEndCenters { get; set; } = Array.Empty<double>();
/// <summary>Far-end clamp centers along Y, measured from the plate origin.</summary>
public IReadOnlyList<double> FarEndCenters { get; set; } = Array.Empty<double>();
/// <summary>Every configured clamp, by edge then center as listed.</summary>
public IEnumerable<RailClamp> 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)));
/// <summary>The number of configured clamps on every edge.</summary>
public int ClampCount =>
Centers.Count + FarSideCenters.Count + OriginEndCenters.Count + FarEndCenters.Count;
/// <summary>The centers listed for <paramref name="edge"/>.</summary>
public IReadOnlyList<double> CentersOn(ClampEdge edge) =>
edge switch
{
ClampEdge.FarSide => FarSideCenters,
ClampEdge.OriginEnd => OriginEndCenters,
ClampEdge.FarEnd => FarEndCenters,
_ => Centers,
};
/// <summary>Lists <paramref name="clamps"/> under their edges, replacing every edge's centers.</summary>
public void SetClamps(IEnumerable<RailClamp> 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);
}
/// <summary>The built-in settings used for a missing or unreadable file: clamps off.</summary>
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".
/// </summary>
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<double>(),
};
if (rail != null)
settings.SetClamps(rail.Clamps);
return settings;
}
/// <summary>
/// The rail a new nest in <paramref name="units"/> receives, or null when clamps are
@@ -99,11 +144,11 @@ public sealed class ClampSettings
/// </summary>
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);
}
/// <summary>
@@ -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<double>();
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;
}
/// <summary>Valid centers of a JSON array, distinct and ascending; flags anything dropped.</summary>
private static double[] ReadCenters(JsonElement centers, ref bool ignored)
{
if (centers.ValueKind != JsonValueKind.Array)
{
ignored = true;
return Array.Empty<double>();
}
var valid = new List<double>();
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();
}
/// <summary>
/// Writes the file (creating the parent directory), retrying briefly on IO collisions
/// the same way <see cref="LocalJsonProvider"/> does.
/// </summary>
public void Save(string path, int maxRetries = 3)
{
var dto = new
var dto = new Dictionary<string, object>
{
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));
@@ -0,0 +1,88 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest.Geometry;
namespace OpenNest.IO.Tests;
/// <summary>
/// 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.
/// </summary>
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<double>());
Assert.Equal(20, rail["farSideCenters"]![0]!.GetValue<double>());
Assert.Equal(8, rail["originEndCenters"]![0]!.GetValue<double>());
Assert.Equal(30, rail["farEndCenters"]![0]!.GetValue<double>());
}
[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);
}
}
+61 -8
View File
@@ -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<double>[OtherEdgeKeys.Length];
for (var i = 0; i < others.Length; i++)
{
others[i] = new List<double>();
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<double> centers)
{
if (centers == null || centers.Count == 0)
return;
writer.WriteStartArray(key);
foreach (var center in centers)
writer.WriteNumberValue(center);
writer.WriteEndArray();
}
private static List<double> ReadCenters(JsonElement centers)
{
var list = new List<double>();
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<double>())
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
{
/// <summary>Clamp extent along the origin-side edge (X).</summary>
/// <summary>Clamp extent along its edge.</summary>
public double Width { get; init; }
/// <summary>Clamp intrusion into the sheet from that edge (Y).</summary>
/// <summary>Clamp intrusion into the sheet from its edge.</summary>
public double Depth { get; init; }
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
/// <summary>Origin-side clamp centers along X, measured from the plate origin.</summary>
public List<double> Centers { get; init; } = new();
/// <summary>Far-side clamp centers along X from the origin; omitted when none.</summary>
public List<double> FarSideCenters { get; init; } = new();
/// <summary>Origin-end clamp centers along Y from the origin; omitted when none.</summary>
public List<double> OriginEndCenters { get; init; } = new();
/// <summary>Far-end clamp centers along Y from the origin; omitted when none.</summary>
public List<double> FarEndCenters { get; init; } = new();
/// <summary>
/// Minutes to remove one clamp and put it back after cutting (D9). Null in older
/// files and readers fall back to <see cref="OpenNest.ClampRail.DefaultRemovalMinutes"/>.
+12 -2
View File
@@ -474,7 +474,14 @@ namespace OpenNest.IO
/// </summary>
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<RailClamp> Edge(List<double> centers, ClampEdge edge) =>
centers?.Select(c => new RailClamp(edge, c)) ?? Enumerable.Empty<RailClamp>();
}
private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap)
+6
View File
@@ -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<double> CentersOn(ClampRail rail, ClampEdge edge) =>
rail.Clamps.Where(c => c.Edge == edge).Select(c => c.Center).ToList();
/// <summary>Numeric suffix of a rail clamp id ("clamp-3" -&gt; 3), for a natural sort of
/// <see cref="PlateDto.RemovedClamps"/> instead of lexical ("clamp-10" before "clamp-2").
/// A non-matching id sorts last by its own ordinal text.</summary>
+167
View File
@@ -0,0 +1,167 @@
using OpenNest.Data;
using OpenNest.Geometry;
namespace OpenNest.Tests;
/// <summary>
/// Rail clamps on every sheet edge (<see cref="ClampEdge"/>): 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.
/// </summary>
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);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ public class ClampRailTests
public void CentersMustBeFiniteAndFromTheOrigin(double center)
{
Assert.Throws<ArgumentOutOfRangeException>(() => new ClampRail(1.5, 0.625, new[] { 6, center }));
Assert.Throws<ArgumentNullException>(() => new ClampRail(1.5, 0.625, null!));
Assert.Throws<ArgumentNullException>(() => new ClampRail(1.5, 0.625, (IEnumerable<double>)null!));
}
[Fact]
@@ -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;
/// <summary>
/// Plate &gt; Edit Clamps: dragging, adding, typing and deleting clamps on the plate view
/// changes the nest's rail, so every sheet follows it.
/// </summary>
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<TextBox>());
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<TextBox>());
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.");
}
+730
View File
@@ -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
{
/// <summary>
/// Plate &gt; 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 <see cref="ClampRail"/> through
/// <see cref="Nest.SetClampRail"/>, 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 (<c>ClampDimensionToCenter</c> setting).
/// </summary>
[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<RailClamp> Clamps => Rail?.Clamps ?? Array.Empty<RailClamp>();
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;
/// <summary>Unit vector out of the sheet across <paramref name="edge"/>.</summary>
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),
};
/// <summary>The point on <paramref name="edge"/> <paramref name="distance"/> from the origin.</summary>
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);
}
/// <summary>The value a dimension shows for a clamp centered at <paramref name="center"/>.</summary>
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;
/// <summary>
/// The clamp position on the sheet edge nearest <paramref name="point"/>, or null when
/// that edge is farther than <paramref name="reach"/>. The along-edge distance stays on
/// the sheet and snaps the dimension's value unless Shift is held.
/// </summary>
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;
}
/// <summary>
/// Makes <paramref name="clamps"/> 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.
/// </summary>
private bool Commit(IEnumerable<RailClamp> 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);
}
/// <summary>A text box that commits on Enter instead of letting the form handle it.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
private void DrawDimensions(Graphics g, IReadOnlyList<RailClamp> 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<RectangleF>();
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";
}
}
}
+2
View File
@@ -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;
}
+12 -1
View File
@@ -167,6 +167,12 @@ namespace OpenNest.Controls
/// </summary>
internal Func<bool> CanEditClamps { get; set; }
/// <summary>
/// The nest whose clamp rail Plate &gt; Edit Clamps changes; null when this view has
/// no nest to edit (clamp edit mode then does nothing).
/// </summary>
internal Func<Nest> 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();
+32
View File
@@ -0,0 +1,32 @@
namespace OpenNest.Forms;
/// <summary>Operator-facing names for <see cref="ClampEdge"/>, relative to the plate origin.</summary>
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)",
};
/// <summary>Lowercase phrase for status text, e.g. "the far side".</summary>
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",
};
}
+19 -10
View File
@@ -8,8 +8,8 @@ using OpenNest.Data;
namespace OpenNest.Forms;
/// <summary>
/// Nest &gt; 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 &gt; 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
/// <see cref="ClampRailEditor"/>; 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)
+1
View File
@@ -73,6 +73,7 @@ namespace OpenNest.Forms
PlateView.PartRemoved += PlateView_PartRemoved;
PlateView.PartsReordered += PlateView_PartsReordered;
PlateView.Dock = DockStyle.Fill;
PlateView.ClampNest = () => Nest;
InitializeComponent();
CreatePlateHeader();
+10 -1
View File
@@ -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;
+2
View File
@@ -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);
+13
View File
@@ -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;
}
}
}
}
+3
View File
@@ -80,5 +80,8 @@
<Setting Name="StatusBarLocationFollowsMouse" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">True</Value>
</Setting>
<Setting Name="ClampDimensionToCenter" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
</Settings>
</SettingsFile>
+29 -3
View File
@@ -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
+9 -1
View File
@@ -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