diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index ef121bfe..674a1159 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -145,8 +145,9 @@ namespace OpenNest private static readonly IReadOnlySet NoRemovedClamps = ImmutableHashSet.Empty; /// - /// 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 + /// (). Empty unless /// is set; 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()); } + /// + /// The rail clamp this sheet places at (the parts' placement + /// frame, see ), whether in place or removed, grown by + /// on every side; null without a rail or a clamp there. + /// Returned bounds are in the placement frame. + /// + 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; + } + + /// + /// Takes the rail clamp off this sheet, or puts it back, as a + /// manual counterpart to Auto Nest's last-resort removal (D8): the zones and + /// follow. False when nothing changed: no rail, a clamp + /// the rail does not place on this sheet, or one already in the requested state. + /// + 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. diff --git a/OpenNest.Tests/ClampRailTests.cs b/OpenNest.Tests/ClampRailTests.cs index c7f98734..c66a38e4 100644 --- a/OpenNest.Tests/ClampRailTests.cs +++ b/OpenNest.Tests/ClampRailTests.cs @@ -395,4 +395,104 @@ public class ClampRailTests Assert.True(((ICollection)removed.RemovedClamps).IsReadOnly); Assert.True(((ICollection)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); + } } diff --git a/OpenNest.WinForms.Tests/Controls/PlateViewClampMenuTests.cs b/OpenNest.WinForms.Tests/Controls/PlateViewClampMenuTests.cs new file mode 100644 index 00000000..63e3c0d8 --- /dev/null +++ b/OpenNest.WinForms.Tests/Controls/PlateViewClampMenuTests.cs @@ -0,0 +1,84 @@ +using OpenNest.Actions; +using OpenNest.Controls; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Controls; + +/// Right-click on a machine clamp takes it off the current sheet or puts it back. +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()); + 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()); + 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."); +} diff --git a/OpenNest/Controls/ClampMenuHandler.cs b/OpenNest/Controls/ClampMenuHandler.cs new file mode 100644 index 00000000..23eccf4e --- /dev/null +++ b/OpenNest/Controls/ClampMenuHandler.cs @@ -0,0 +1,95 @@ +using System; +using System.Drawing; +using System.Windows.Forms; +using OpenNest.Actions; + +namespace OpenNest.Controls +{ + /// + /// Right-click on a machine clamp in a : takes that clamp off the + /// current sheet or puts it back (). Only in plate + /// select mode, never while inspecting or previewing a proposal; the change itself is + /// refused while a fill runs or says a nest run is + /// using the clamps. + /// + internal sealed class ClampMenuHandler : IDisposable + { + private readonly PlateView view; + private ContextMenuStrip menu; + + public ClampMenuHandler(PlateView view) + { + this.view = view; + } + + /// Shows the menu when is on a rail clamp. + public bool TryShow(Point location, double tolerance) + { + if (BuildMenu(location, tolerance) == null) + return false; + menu.Show(view, location); + return true; + } + + /// + /// The menu for the rail clamp at (control coordinates), + /// or null when there is none or clamps cannot be edited from here. + /// + 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(); + } +} diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs index a6c94401..4bc35841 100644 --- a/OpenNest/Controls/PlateRenderer.cs +++ b/OpenNest/Controls/PlateRenderer.cs @@ -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); diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index c137b7cf..80ce1461 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -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 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(); 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; } + /// + /// 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. + /// + internal Func 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(); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 99dfe86f..6be64aeb 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -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() + .Where(form => ReferenceEquals(form.Nest, nest)) + .Select(form => form.PlateView) + .ToArray(); + + /// + /// True while a nest run or fill may be reading 's clamps. + /// Clamps feed nesting and validation: never change them under a running operation. + /// + private bool ClampsInUse(Nest nest) => + nestingInProgress + || Application.OpenForms.OfType().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() - .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().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, diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 828ab545..ab185ff5 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -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 diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 73a8cb27..d7a5bb13 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -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);