feat(desktop): remove or restore a clamp by right-click

Operators sometimes pull a clamp for a manual layout, but only Auto Nest's
last-resort search could take clamps off a sheet. Right-clicking a clamp in
Select mode now offers to remove it from that sheet or put it back, reusing
the existing per-sheet RemovedClamps, so the zones, cut-time charge, display
and .nest persistence all behave as they do for Auto Nest removals.

The change is refused while a nest run or fill may be reading the clamps,
sharing the guard the Clamps dialog already used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-16 06:20:30 -04:00
1 parent d028d82f0a
commit 7d254ce18d
9 files changed
+371 -15

No files matched your search

+41 -2
View File
@@ -145,8 +145,9 @@ namespace OpenNest
private static readonly IReadOnlySet<string> NoRemovedClamps = ImmutableHashSet<string>.Empty;
/// <summary>
/// Rail clamp ids taken off this sheet as a last resort (D8) so a part that could not
/// be nested anywhere with every clamp in place still fits. Empty unless
/// Rail clamp ids taken off this sheet, by Auto Nest as a last resort (D8) so a part
/// that could not be nested anywhere with every clamp in place still fits, or by hand
/// (<see cref="SetClampRemoved"/>). Empty unless
/// <see cref="ClampRail"/> is set; <see cref="ClampZones"/> already excludes them, so
/// nothing else needs to consult this list to avoid them. Ids stay listed across a
/// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it
@@ -188,6 +189,44 @@ namespace OpenNest
ReplaceClampZones(ResolveRailZones());
}
/// <summary>
/// The rail clamp this sheet places at <paramref name="point"/> (the parts' placement
/// frame, see <see cref="ClampZoneFrame"/>), whether in place or removed, grown by
/// <paramref name="tolerance"/> on every side; null without a rail or a clamp there.
/// Returned bounds are in the placement frame.
/// </summary>
public ClampZone RailClampAt(Vector point, double tolerance = 0)
{
if (ClampRail == null)
return null;
var left = Quadrant is 2 or 3 ? -Size.Length : 0;
var bottom = Quadrant is 3 or 4 ? -Size.Width : 0;
foreach (var zone in ClampRail.ResolveFor(Size, Quadrant))
{
var box = zone.Bounds.Translate(left, bottom);
if (box.Offset(tolerance).Contains(point))
return new ClampZone(zone.Id, box);
}
return null;
}
/// <summary>
/// Takes the rail clamp <paramref name="id"/> off this sheet, or puts it back, as a
/// manual counterpart to Auto Nest's last-resort removal (D8): the zones and
/// <see cref="ClampRemovalMinutes"/> follow. False when nothing changed: no rail, a clamp
/// the rail does not place on this sheet, or one already in the requested state.
/// </summary>
public bool SetClampRemoved(string id, bool removed)
{
if (ClampRail == null || id == null || RemovedClamps.Contains(id) == removed)
return false;
if (removed && !ClampRail.ResolveFor(Size, Quadrant).Any(zone => zone.Id == id))
return false;
var ids = removed ? RemovedClamps.Append(id) : RemovedClamps.Where(x => x != id);
SetClampRail(ClampRail, ids);
return true;
}
private void ResolveClampRail()
{
// Called from the constructor's Size assignment before the zone list exists.
+100
View File
@@ -395,4 +395,104 @@ public class ClampRailTests
Assert.True(((ICollection<string>)removed.RemovedClamps).IsReadOnly);
Assert.True(((ICollection<string>)untouched.RemovedClamps).IsReadOnly);
}
// Manual per-sheet removal (right-click in the plate view).
[Fact]
public void SetClampRemovedTakesAClampOffAndPutsItBack()
{
var plate = Sheet(48, 120);
plate.SetClampRail(Machine());
var changes = 0;
plate.ClampZonesChanged += (_, _) => changes++;
Assert.True(plate.SetClampRemoved("clamp-3", true));
Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps);
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-3");
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes);
Assert.True(changes > 0);
Assert.True(plate.SetClampRemoved("clamp-3", false));
Assert.Empty(plate.RemovedClamps);
Assert.True(Machine().Matches(plate));
Assert.Equal(0, plate.ClampRemovalMinutes);
}
[Fact]
public void SetClampRemovedKeepsOtherRemovedClamps()
{
var plate = Sheet(48, 120);
plate.SetClampRail(Machine(), new[] { "clamp-1" });
Assert.True(plate.SetClampRemoved("clamp-3", true));
Assert.Equal(new[] { "clamp-1", "clamp-3" }, plate.RemovedClamps.OrderBy(id => id));
Assert.True(plate.SetClampRemoved("clamp-1", false));
Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps);
Assert.Equal(Machine(), plate.ClampRail);
}
[Fact]
public void SetClampRemovedReportsNoChange()
{
var bare = Sheet(48, 120);
Assert.False(bare.SetClampRemoved("clamp-1", true)); // no rail
var plate = Sheet(48, 60);
plate.SetClampRail(Machine());
Assert.False(plate.SetClampRemoved("clamp-1", false)); // already in place
Assert.False(plate.SetClampRemoved("clamp-8", true)); // beyond this sheet
Assert.False(plate.SetClampRemoved("clamp-99", true)); // unknown to the rail
Assert.False(plate.SetClampRemoved(null!, true));
Assert.Empty(plate.RemovedClamps);
Assert.True(plate.SetClampRemoved("clamp-2", true));
Assert.False(plate.SetClampRemoved("clamp-2", true)); // already removed
}
[Fact]
public void ARemovedClampBeyondTheSheetCanStillBePutBack()
{
var plate = Sheet(48, 120);
plate.SetClampRail(Machine(), new[] { "clamp-8" });
plate.Size = new Size(48, 60);
Assert.True(plate.SetClampRemoved("clamp-8", false));
Assert.Empty(plate.RemovedClamps);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void RailClampAtFindsClampsInThePartsPlacementFrame(int quadrant)
{
var plate = Sheet(48, 120, quadrant);
plate.SetClampRail(Machine(), new[] { "clamp-3" });
var full = Sheet(48, 120, quadrant);
full.SetClampRail(Machine());
var expected = ClampZoneFrame.ToPlacement(full); // every clamp, removed ones too
foreach (var zone in expected)
{
var hit = plate.RailClampAt(zone.Bounds.Center);
Assert.NotNull(hit);
Assert.Equal(zone.Id, hit.Id);
Assert.Equal(zone.Bounds.X, hit.Bounds.X, 9);
Assert.Equal(zone.Bounds.Y, hit.Bounds.Y, 9);
}
}
[Fact]
public void RailClampAtHonoursTheToleranceAndNeedsARail()
{
var plate = Sheet(48, 120);
Assert.Null(plate.RailClampAt(new Vector(36, 0.3))); // no rail
plate.SetClampRail(Machine());
Assert.Equal("clamp-3", plate.RailClampAt(new Vector(36, 0.3))?.Id);
Assert.Null(plate.RailClampAt(new Vector(36, 0.7)));
Assert.Equal("clamp-3", plate.RailClampAt(new Vector(36, 0.7), tolerance: 0.1)?.Id);
}
}
@@ -0,0 +1,84 @@
using OpenNest.Actions;
using OpenNest.Controls;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Controls;
/// <summary>Right-click on a machine clamp takes it off the current sheet or puts it back.</summary>
public class PlateViewClampMenuTests
{
// Default view scale is about one pixel per inch: allow for rounding to whole pixels.
private const double Tolerance = 1.0;
private static readonly double[] Centers = { 6, 21, 36, 51, 66, 81, 96, 111 };
private static Plate RailPlate()
{
var plate = new Plate(48, 120);
plate.SetClampRail(new ClampRail(1.5, 0.625, Centers));
return plate;
}
[Fact]
public void RemovesAClampAndPutsItBack() => StaTestThread.Run(() =>
{
var plate = RailPlate();
using var view = new PlateView { Plate = plate };
var onClamp3 = view.PointWorldToControl(new Vector(36, 0.3125));
var menu = view.ClampMenu.BuildMenu(onClamp3, Tolerance);
Assert.NotNull(menu);
var remove = Assert.Single(menu.Items.Cast<System.Windows.Forms.ToolStripItem>());
Assert.Equal("Remove clamp-3 from This Sheet", remove.Text);
remove.PerformClick();
Assert.Equal(new[] { "clamp-3" }, plate.RemovedClamps);
Assert.DoesNotContain(plate.ClampZones, zone => zone.Id == "clamp-3");
Assert.StartsWith("Removed clamp-3", view.Status);
// A removed clamp is still hit (it is drawn dashed) and offers to come back.
var restore = Assert.Single(
view.ClampMenu.BuildMenu(onClamp3, Tolerance)!.Items.Cast<System.Windows.Forms.ToolStripItem>());
Assert.Equal("Put clamp-3 Back on This Sheet", restore.Text);
restore.PerformClick();
Assert.Empty(plate.RemovedClamps);
Assert.Equal(8, plate.ClampZones.Count);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
[Fact]
public void OffersNothingAwayFromAClampOrWhenClampsCannotBeEdited() => StaTestThread.Run(() =>
{
var plate = RailPlate();
using var view = new PlateView { Plate = plate };
var onClamp3 = view.PointWorldToControl(new Vector(36, 0.3125));
Assert.Null(view.ClampMenu.BuildMenu(view.PointWorldToControl(new Vector(28, 20)), Tolerance));
view.InspectionOnly = true;
Assert.Null(view.ClampMenu.BuildMenu(onClamp3, Tolerance));
view.InspectionOnly = false;
view.Actions.SetAction(typeof(ActionZoomWindow));
Assert.Null(view.ClampMenu.BuildMenu(onClamp3, Tolerance));
view.Actions.SetAction(typeof(ActionSelect));
view.Plate = new Plate(48, 120); // no rail
Assert.Null(view.ClampMenu.BuildMenu(onClamp3, Tolerance));
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
[Fact]
public void IgnoresAClickFromAMenuOpenedOnAnotherSheet() => StaTestThread.Run(() =>
{
var plate = RailPlate();
using var view = new PlateView { Plate = plate };
var menu = view.ClampMenu.BuildMenu(view.PointWorldToControl(new Vector(36, 0.3125)), Tolerance);
var remove = menu!.Items[0];
view.Plate = RailPlate();
remove.PerformClick();
Assert.Empty(plate.RemovedClamps);
Assert.Empty(view.Plate.RemovedClamps);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
}
+95
View File
@@ -0,0 +1,95 @@
using System;
using System.Drawing;
using System.Windows.Forms;
using OpenNest.Actions;
namespace OpenNest.Controls
{
/// <summary>
/// Right-click on a machine clamp in a <see cref="PlateView"/>: takes that clamp off the
/// current sheet or puts it back (<see cref="Plate.SetClampRemoved"/>). Only in plate
/// select mode, never while inspecting or previewing a proposal; the change itself is
/// refused while a fill runs or <see cref="PlateView.CanEditClamps"/> says a nest run is
/// using the clamps.
/// </summary>
internal sealed class ClampMenuHandler : IDisposable
{
private readonly PlateView view;
private ContextMenuStrip menu;
public ClampMenuHandler(PlateView view)
{
this.view = view;
}
/// <summary>Shows the menu when <paramref name="location"/> is on a rail clamp.</summary>
public bool TryShow(Point location, double tolerance)
{
if (BuildMenu(location, tolerance) == null)
return false;
menu.Show(view, location);
return true;
}
/// <summary>
/// The menu for the rail clamp at <paramref name="location"/> (control coordinates),
/// or null when there is none or clamps cannot be edited from here.
/// </summary>
internal ContextMenuStrip BuildMenu(Point location, double tolerance)
{
var plate = view.Plate;
if (
plate?.ClampRail == null
|| !view.AllowSelect
|| view.InspectionOnly
|| view.HasProposalDisplay
|| view.Actions.CurrentAction is not ActionSelect
)
return null;
var clamp = plate.RailClampAt(view.PointControlToWorld(location), tolerance);
if (clamp == null)
return null;
var removed = plate.RemovedClamps.Contains(clamp.Id);
menu ??= new ContextMenuStrip();
while (menu.Items.Count > 0)
menu.Items[0].Dispose();
var item = menu.Items.Add(
removed ? $"Put {clamp.Id} Back on This Sheet" : $"Remove {clamp.Id} from This Sheet"
);
item.Click += (_, _) => Apply(plate, clamp.Id, !removed);
return menu;
}
private void Apply(Plate plate, string id, bool remove)
{
// The view may have moved to another sheet while the menu was open.
if (!ReferenceEquals(plate, view.Plate))
return;
// Clamps feed nesting and validation: never change them under a running operation.
if (view.IsFillInProgress || view.CanEditClamps?.Invoke() == false)
{
MessageBox.Show(
view.FindForm(),
"Finish or cancel the current nesting or plate action first.",
"Clamps",
MessageBoxButtons.OK,
MessageBoxIcon.Information
);
return;
}
if (!plate.SetClampRemoved(id, remove))
return;
view.Status = remove
? $"Removed {id} from this sheet (+{plate.ClampRail.RemovalMinutes:0.#} min cut time)"
: $"Put {id} back on this sheet";
view.Invalidate();
}
public void Dispose() => menu?.Dispose();
}
}
+2 -1
View File
@@ -205,7 +205,8 @@ namespace OpenNest.Controls
}
}
// D8: a clamp Auto Nest pulled as a last resort. Distinct from an active clamp - a
// D8: a clamp taken off this sheet, by Auto Nest as a last resort or by hand (right-click).
// Distinct from an active clamp - a
// dashed grey outline and a small "remove" marker, still display + exclusion only
// (no hit test, selection or sequencing).
private static readonly Color RemovedClampOutlineColor = Color.FromArgb(200, 110, 110, 110);
+15
View File
@@ -33,6 +33,7 @@ namespace OpenNest.Controls
private CutOffSettings cutOffSettings = new CutOffSettings();
private SelectionManager selection;
private CutOffHandler cutOffHandler;
private readonly ClampMenuHandler clampMenu;
private PreviewManager previewManager;
protected List<LayoutPart> parts;
private Point middleMouseDownPoint;
@@ -72,6 +73,7 @@ namespace OpenNest.Controls
internal SelectionManager Selection => selection;
internal CutOffHandler CutOffs => cutOffHandler;
internal ClampMenuHandler ClampMenu => clampMenu;
internal ActionManager Actions => actionManager;
internal PreviewManager Previews => previewManager;
@@ -109,6 +111,7 @@ namespace OpenNest.Controls
parts = new List<LayoutPart>();
selection = new SelectionManager(this);
cutOffHandler = new CutOffHandler(this);
clampMenu = new ClampMenuHandler(this);
previewManager = new PreviewManager(this);
redrawTimer = new Timer()
@@ -156,6 +159,12 @@ namespace OpenNest.Controls
internal bool InspectionOnly { get; set; }
internal System.Action InspectionCloseRequested { get; set; }
/// <summary>
/// Asked before a right-click changes which clamps are on the sheet; false refuses
/// (e.g. a nest run is using the clamps). Null allows.
/// </summary>
internal Func<bool> CanEditClamps { get; set; }
public bool AllowZoom { get; set; }
public bool AllowSelect { get; set; }
@@ -476,6 +485,9 @@ namespace OpenNest.Controls
return;
}
if (e.Button == MouseButtons.Right)
clampMenu.TryShow(e.Location, 3.0 / ViewScale);
base.OnMouseUp(e);
}
@@ -769,7 +781,10 @@ namespace OpenNest.Controls
plate.PartAdded -= plate_PartAdded;
plate.PartRemoved -= plate_PartRemoved;
plate.PartsReordered -= plate_PartsReordered;
plate.ClampZonesChanged -= plate_ClampZonesChanged;
}
clampMenu?.Dispose();
CanEditClamps = null;
overlapOverlay?.Dispose();
validationOverlay?.Dispose();
hoverTimer?.Dispose();
+20 -11
View File
@@ -665,6 +665,8 @@ namespace OpenNest.Forms
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
activeForm.PlateView.PartAdded += PlateView_PartAdded;
activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
var activeNest = activeForm.Nest;
activeForm.PlateView.CanEditClamps = () => !ClampsInUse(activeNest);
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
mnuViewDrawPiercePoints.Checked = activeForm.PlateView.DrawPiercePoints;
mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds;
@@ -1560,6 +1562,22 @@ namespace OpenNest.Forms
activeForm.ShowNestInfoEditor();
}
private PlateView[] ViewsOf(Nest nest) =>
MdiChildren
.OfType<EditNestForm>()
.Where(form => ReferenceEquals(form.Nest, nest))
.Select(form => form.PlateView)
.ToArray();
/// <summary>
/// True while a nest run or fill may be reading <paramref name="nest"/>'s clamps.
/// Clamps feed nesting and validation: never change them under a running operation.
/// </summary>
private bool ClampsInUse(Nest nest) =>
nestingInProgress
|| Application.OpenForms.OfType<NestProgressForm>().Any()
|| ViewsOf(nest).Any(v => v.IsFillInProgress);
private void Clamps_Click(object sender, EventArgs e)
{
var editForm = activeForm;
@@ -1567,17 +1585,8 @@ namespace OpenNest.Forms
return;
var nest = editForm.Nest;
var views = MdiChildren
.OfType<EditNestForm>()
.Where(form => ReferenceEquals(form.Nest, nest))
.Select(form => form.PlateView)
.ToArray();
// Clamps feed nesting and validation: never change them under a running operation.
if (
nestingInProgress
|| Application.OpenForms.OfType<NestProgressForm>().Any()
|| views.Any(v => v.IsFillInProgress || v.Actions.CurrentAction?.IsBusy() == true)
)
var views = ViewsOf(nest);
if (ClampsInUse(nest) || views.Any(v => v.Actions.CurrentAction?.IsBusy() == true))
{
MessageBox.Show(
this,
+13
View File
@@ -73,6 +73,19 @@ 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.
### Removing a clamp by hand
In Select mode, right-click a clamp on the sheet and choose **Remove clamp-N
from This Sheet**. Only that sheet changes. The clamp is drawn dashed with
"remove", parts may be placed over it, and it adds the rail's removal minutes to
that sheet's cut time, the same as a clamp Auto Nest removed. To put it back,
right-click the dashed clamp and choose **Put clamp-N Back on This Sheet**.
Parts already over the restored clamp are not moved; Check Overlaps and pre-post
verification report them. The menu is not offered while inspecting or
previewing a proposal. The change is refused while nesting or a fill is
running. **OK** in **Nest > Clamps...** re-resolves every plate from the edited
rail and puts every removed clamp back, whether removed by hand or by Auto Nest.
### Last-resort clamp removal
When a part cannot be placed on an empty sheet of any offered size with every
+1 -1
View File
@@ -93,7 +93,7 @@ zones are kept as written.
The rail's optional `removalMinutes` is the time to take one clamp off and put
it back; a missing or invalid value loads as five minutes. A plate on which
Auto Nest removed clamps as a last resort lists their rail ids in an optional
clamps were removed (by Auto Nest as a last resort, or by hand) lists their rail ids in an optional
`removedClamps` array (omitted when none were removed); its `clamps` already
exclude them, and it follows the rail minus those ids. A malformed
`removedClamps` value reads as none removed (non-string entries are skipped);