From a9fc9f0afba3b062dec4fe1a4ea17a91da5ef1bb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 7 Nov 2026 17:07:52 -0500 Subject: [PATCH] feat(ui): duplicate a plate by Ctrl+dragging it or from its right-click menu Copying a layout meant recreating the plate and copying its parts by hand. Ctrl+drag a row in the Plates list drops a copy at that slot; the row's right-click menu has Duplicate Plate, which inserts the copy after it. Plate.CopyLayout copies the sheet (size, outline, spacing, quadrant, grain, clamps, support bed), the parts with their programs and lead-in state, common-line groups and cut-off definitions, regenerated in their places in the sequence, as a save and reload would. The copy is one sheet with no stock item codes, since those name particular physical sheets. The desktop gives it its own cutting parameters and records it as one undo step. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Plate.cs | 75 ++++++++++++ OpenNest.Core/PlateManager.cs | 41 +++++++ OpenNest.Tests/PlateManagerTests.cs | 113 ++++++++++++++++++ .../Forms/PlateReorderTests.cs | 30 +++++ .../Forms/UndoRecordingTests.cs | 4 + OpenNest/Forms/EditNestForm.PlateOrder.cs | 75 +++++++++++- docs/undo-redo.md | 3 +- 7 files changed, 334 insertions(+), 7 deletions(-) diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index f2c36c49..9df3ee37 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -463,6 +463,81 @@ namespace OpenNest /// places relative to the cut-offs either way. Where only a first place is known the /// count is a guess, so a cut-off that splits the sheet goes last, as sequencing puts it. /// + /// + /// A new, unowned plate with this plate's sheet and layout: size, outline, spacing, + /// quadrant, grain, clamps, support bed, parts (programs and lead-in state included), + /// common-line groups and cut-off definitions, regenerated in their places in the + /// sequence. It is one sheet ( 1) with no stock item codes, since + /// those name particular physical sheets. Adding the parts counts them as nested at once, + /// as every plate does, so callers add the copy to a nest. + /// is shared; callers that need an independent copy + /// replace it. + /// + public Plate CopyLayout() + { + var copy = new Plate(Size) + { + Quantity = 1, + Quadrant = Quadrant, + PartSpacing = PartSpacing, + EdgeSpacing = EdgeSpacing, + GrainAngle = GrainAngle, + CuttingParameters = CuttingParameters, + SupportBed = SupportBed, + CutCommonLinesTwice = CutCommonLinesTwice, + }; + copy.Outline = Outline; + + if (ClampRail != null) + copy.SetClampRail(ClampRail, RemovedClamps); + else + foreach (var zone in ClampZones) + copy.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds)); + + var copies = new Dictionary(); + foreach (var part in Parts.Where(p => !p.BaseDrawing.IsCutOff)) + { + var clone = (Part)part.Clone(); + copy.Parts.Add(clone); + copies[part] = clone; + } + + foreach (var group in CommonLines) + { + var members = group.Members.Where(copies.ContainsKey).Select(member => copies[member]).ToList(); + if (members.Count >= 2) + copy.CommonLines.Add(new CommonLineGroup(members, group.Kerf)); + } + + if (CutOffs.Count == 0) + return copy; + + // Cut-off segments keep their places among the parts, as a save and reload does. + var places = new Dictionary>(); + foreach (var cutoff in CutOffs) + { + var duplicate = new CutOff(cutoff.Position, cutoff.Axis) + { + StartLimit = cutoff.StartLimit, + EndLimit = cutoff.EndLimit, + HeadLockOvercut = cutoff.HeadLockOvercut, + FarEdgeFirst = cutoff.FarEdgeFirst, + Gaps = cutoff.Gaps, + }; + copy.CutOffs.Add(duplicate); + + var segments = CutOffSegments.Of(this, cutoff) + .Select(segment => Parts.IndexOf(segment.Part)) + .Where(index => index >= 0) + .ToArray(); + if (segments.Length > 0) + places[duplicate] = segments; + } + + copy.RegenerateCutOffs(CutOffSettings ?? new CutOffSettings(), places, null); + return copy; + } + public void RegenerateCutOffs(CutOffSettings settings, IReadOnlyDictionary> places, IReadOnlyDictionary firstPlaces) => RegenerateCutOffs(settings, (cutoff, extents) => diff --git a/OpenNest.Core/PlateManager.cs b/OpenNest.Core/PlateManager.cs index 2844391a..5df94bb3 100644 --- a/OpenNest.Core/PlateManager.cs +++ b/OpenNest.Core/PlateManager.cs @@ -209,6 +209,47 @@ namespace OpenNest return true; } + /// + /// Whether can copy the plate at + /// to : any plate but the trailing empty sentinel, landing + /// anywhere from the first place to just before the sentinel. + /// + public bool CanDuplicatePlate(int fromIndex, int toIndex) => + fromIndex >= 0 && fromIndex < MovableCount + && toIndex >= 0 && toIndex <= MovableCount + && HasLayout(nest.Plates[fromIndex]); + + private static bool HasLayout(Plate plate) => plate.Parts.Count > 0 || plate.CutOffs.Count > 0; + + /// + /// Inserts a copy of the plate at (see + /// ) so it lands at , and makes + /// the copy the current plate. The trailing empty sentinel stays last. + /// + /// The copy, or null when is false. + public Plate DuplicatePlate(int fromIndex, int toIndex) + { + if (!CanDuplicatePlate(fromIndex, toIndex)) + return null; + + var copy = nest.Plates[fromIndex].CopyLayout(); + suppressNavigation = true; + try + { + nest.Plates.Insert(toIndex, copy); + } + finally + { + suppressNavigation = false; + } + + EnsureSentinel(); + PlateListChanged?.Invoke(this, EventArgs.Empty); + CurrentIndex = nest.Plates.IndexOf(copy); + FireCurrentPlateChanged(); + return copy; + } + public void BeginBatch() { batching = true; diff --git a/OpenNest.Tests/PlateManagerTests.cs b/OpenNest.Tests/PlateManagerTests.cs index 1bca597f..4be2c1fa 100644 --- a/OpenNest.Tests/PlateManagerTests.cs +++ b/OpenNest.Tests/PlateManagerTests.cs @@ -703,4 +703,117 @@ public class PlateManagerTests Assert.Same(configured, mgr.CurrentPlate); Assert.DoesNotContain(empty, nest.Plates); } + + [Fact] + public void DuplicatePlate_InsertsACopyAtTheSlotAndMakesItCurrent() + { + var (nest, mgr, filled) = NestWithFilledPlates(2); + using var _ = mgr; + var source = filled[1]; + source.Parts[0].Offset(new Vector(5, 7)); + source.Quantity = 3; + var drawing = source.Parts[0].BaseDrawing; + var nested = drawing.Quantity.Nested; + + var copy = mgr.DuplicatePlate(1, 0); + + Assert.NotNull(copy); + Assert.Equal(new[] { copy, filled[0], filled[1] }, nest.Plates.Take(3)); + Assert.Equal(4, nest.Plates.Count); + Assert.Empty(nest.Plates[^1].Parts); + Assert.Same(copy, mgr.CurrentPlate); + Assert.Equal(0, mgr.CurrentIndex); + Assert.Equal(1, copy.Quantity); + var part = Assert.Single(copy.Parts); + Assert.NotSame(source.Parts[0], part); + Assert.Same(drawing, part.BaseDrawing); + Assert.Equal(source.Parts[0].Location, part.Location); + Assert.Equal(nested + 1, drawing.Quantity.Nested); + } + + [Fact] + public void DuplicatePlate_AtTheEndLandsBeforeTheSentinel() + { + var (nest, mgr, filled) = NestWithFilledPlates(2); + using var _ = mgr; + var sentinel = nest.Plates[^1]; + + var copy = mgr.DuplicatePlate(0, 2); + + Assert.Equal(new[] { filled[0], filled[1], copy, sentinel }, nest.Plates); + Assert.Same(copy, mgr.CurrentPlate); + } + + [Theory] + [InlineData(2, 0)] + [InlineData(-1, 0)] + [InlineData(0, 3)] + [InlineData(0, -1)] + public void DuplicatePlate_RefusesTheSentinelAndSlotsPastIt(int from, int to) + { + var (nest, mgr, _) = NestWithFilledPlates(2); + using var __ = mgr; + var before = nest.Plates.ToArray(); + + Assert.Null(mgr.DuplicatePlate(from, to)); + Assert.Equal(before, nest.Plates); + } + + [Fact] + public void CopyLayout_CopiesTheSheetAndRemapsCommonLines() + { + var plate = new Plate(48, 96) + { + Quantity = 2, + Quadrant = 3, + PartSpacing = 0.25, + EdgeSpacing = new Spacing(0.5, 0.5, 0.5, 0.5), + GrainAngle = 1.5, + CutCommonLinesTwice = true, + }; + plate.StockItemCodes = new[] { "A", "B" }; + var a = MakePart(); + var b = MakePart(); + b.Offset(new Vector(10.1, 0)); + plate.Parts.Add(a); + plate.Parts.Add(b); + plate.CommonLines.Add(new CommonLineGroup(new[] { a, b }, 0.1)); + + var copy = plate.CopyLayout(); + + Assert.Equal(plate.Size, copy.Size); + Assert.Equal(3, copy.Quadrant); + Assert.Equal(0.25, copy.PartSpacing); + Assert.Equal(plate.EdgeSpacing, copy.EdgeSpacing); + Assert.Equal(1.5, copy.GrainAngle); + Assert.True(copy.CutCommonLinesTwice); + Assert.Equal(1, copy.Quantity); + Assert.Empty(copy.StockItemCodes); + Assert.Equal(2, copy.Parts.Count); + var group = Assert.Single(copy.CommonLines); + Assert.Equal(new[] { copy.Parts[0], copy.Parts[1] }, group.Members); + Assert.Equal(0.1, group.Kerf); + } + + [Fact] + public void CopyLayout_RegeneratesCutOffsInTheirPlaces() + { + var plate = new Plate(48, 96) { Quantity = 1 }; + plate.Parts.Add(MakePart()); + var second = MakePart(); + second.Offset(new Vector(30, 0)); + plate.Parts.Add(second); + plate.CutOffs.Add(new CutOff(new Vector(20, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(new CutOffSettings()); + var order = plate.Parts.Select(p => p.BaseDrawing.IsCutOff).ToArray(); + Assert.Contains(true, order); + + var copy = plate.CopyLayout(); + + var cutoff = Assert.Single(copy.CutOffs); + Assert.NotSame(plate.CutOffs[0], cutoff); + Assert.Equal(plate.CutOffs[0].Position, cutoff.Position); + Assert.Equal(order, copy.Parts.Select(p => p.BaseDrawing.IsCutOff)); + Assert.All(copy.Parts.Where(p => p.BaseDrawing.IsCutOff), p => Assert.Same(cutoff.Drawing, p.BaseDrawing)); + } } diff --git a/OpenNest.WinForms.Tests/Forms/PlateReorderTests.cs b/OpenNest.WinForms.Tests/Forms/PlateReorderTests.cs index f30aab50..96893ffe 100644 --- a/OpenNest.WinForms.Tests/Forms/PlateReorderTests.cs +++ b/OpenNest.WinForms.Tests/Forms/PlateReorderTests.cs @@ -45,4 +45,34 @@ public class PlateReorderTests Assert.True(list.Items[1].Selected); Assert.Equal("2", list.Items[1].Text); }); + + [Fact] + public void DuplicatingTheCurrentPlateInsertsItsCopyAfterItAndSelectsIt() => UndoTestEnv.Run(env => + { + env.Editor.PlateManager.LoadAt(0); + var source = env.Nest.Plates[0]; + var count = env.Nest.Plates.Count; + + Assert.True(env.Editor.DuplicateCurrentPlate()); + + Assert.Equal(count + 1, env.Nest.Plates.Count); + Assert.Same(source, env.Nest.Plates[0]); + var copy = env.Nest.Plates[1]; + Assert.NotSame(source, copy); + Assert.Equal(source.Parts.Count, copy.Parts.Count); + Assert.Same(copy, env.Editor.PlateManager.CurrentPlate); + if (source.CuttingParameters != null) + Assert.NotSame(source.CuttingParameters, copy.CuttingParameters); + var list = (ListView)Assert.Single(env.Editor.Controls.Find("platesListView", true)); + Assert.True(list.Items[1].Selected); + Assert.NotNull(list.ContextMenuStrip); + }); + + [Fact] + public void TheEmptySentinelCannotBeDuplicated() => UndoTestEnv.Run(env => + { + var count = env.Nest.Plates.Count; + Assert.False(env.Editor.DuplicatePlate(count - 1, 0)); + Assert.Equal(count, env.Nest.Plates.Count); + }); } diff --git a/OpenNest.WinForms.Tests/Forms/UndoRecordingTests.cs b/OpenNest.WinForms.Tests/Forms/UndoRecordingTests.cs index 8c974258..264406c5 100644 --- a/OpenNest.WinForms.Tests/Forms/UndoRecordingTests.cs +++ b/OpenNest.WinForms.Tests/Forms/UndoRecordingTests.cs @@ -82,6 +82,10 @@ public class UndoRecordingTests public void MovePlateRecordsOneEntry() => UndoTestEnv.Run(env => env.AssertOneUndoableEdit("Move Plate", () => Assert.True(env.Editor.MovePlate(3, 0)))); + [Fact] + public void DuplicatePlateRecordsOneEntry() => UndoTestEnv.Run(env => + env.AssertOneUndoableEdit("Duplicate Plate", () => Assert.True(env.Editor.DuplicatePlate(0, 1)))); + [Fact] public void MovePlateIntoItsOwnPlaceRecordsNothing() => UndoTestEnv.Run(env => { diff --git a/OpenNest/Forms/EditNestForm.PlateOrder.cs b/OpenNest/Forms/EditNestForm.PlateOrder.cs index 30838f4b..2b7bc955 100644 --- a/OpenNest/Forms/EditNestForm.PlateOrder.cs +++ b/OpenNest/Forms/EditNestForm.PlateOrder.cs @@ -5,6 +5,7 @@ using System.Windows.Forms; namespace OpenNest.Forms { // Reordering plates in the Plates list: drag a row to a new position, or Alt+Up/Alt+Down. + // Ctrl+drag drops a copy of the plate instead, as does Duplicate Plate on the row's menu. // The trailing empty new-plate sentinel stays last and cannot be dragged. public partial class EditNestForm { @@ -43,6 +44,48 @@ namespace OpenNest.Forms Enabled = false, }; platesListView.Controls.Add(plateDropIndicator); + + var duplicateItem = new ToolStripMenuItem("Duplicate Plate", null, (sender, e) => DuplicateCurrentPlate()); + var menu = new ContextMenuStrip(); + menu.Items.Add(duplicateItem); + menu.Opening += (sender, e) => + { + var index = PlateManager.CurrentIndex; + duplicateItem.Enabled = !IsInspecting && PlateManager.CanDuplicatePlate(index, index + 1); + }; + platesListView.ContextMenuStrip = menu; + } + + /// + /// Inserts a copy of the plate at so it lands at + /// , as one undoable step; the copy becomes the current plate. + /// + /// Whether a plate was added. + public bool DuplicatePlate(int fromIndex, int toIndex) + { + if (IsInspecting || !PlateManager.CanDuplicatePlate(fromIndex, toIndex)) + return false; + + using (var change = BeginChange("Duplicate Plate")) + { + change.MarkChanged(); + var copy = PlateManager.DuplicatePlate(fromIndex, toIndex); + // The copy gets its own parameters, so editing one sheet's leaves the other's alone. + if (copy.CuttingParameters != null) + copy.CuttingParameters = IO.CuttingParametersSerializer.FromDto( + IO.CuttingParametersSerializer.ToDto(copy.CuttingParameters)); + change.Complete(); + } + + FirePlateChanged(); + return true; + } + + /// Inserts a copy of the current plate right after it. + public bool DuplicateCurrentPlate() + { + var index = PlateManager.CurrentIndex; + return DuplicatePlate(index, index + 1); } /// @@ -83,12 +126,20 @@ namespace OpenNest.Forms { if (IsInspecting || e.Button != MouseButtons.Left || e.Item is not ListViewItem item) return; - if (item.Index >= PlateManager.MovableCount || PlateManager.MovableCount < 2) + if (item.Index >= PlateManager.MovableCount) + return; + + var effects = DragDropEffects.None; + if (PlateManager.MovableCount >= 2) + effects |= DragDropEffects.Move; + if (PlateManager.CanDuplicatePlate(item.Index, item.Index + 1)) + effects |= DragDropEffects.Copy; + if (effects == DragDropEffects.None) return; try { - platesListView.DoDragDrop(new PlateDragData(this, item.Index), DragDropEffects.Move); + platesListView.DoDragDrop(new PlateDragData(this, item.Index), effects); } finally { @@ -115,18 +166,26 @@ namespace OpenNest.Forms AutoScrollPlateList(client); var slot = GetPlateDropSlot(client); - var to = PlateDropTarget(data.Index, slot); - if (!PlateManager.CanMovePlate(data.Index, to)) + var copying = IsCopyDrag(e); + var allowed = copying + ? PlateManager.CanDuplicatePlate(data.Index, slot) + : (e.AllowedEffect & DragDropEffects.Move) != 0 + && PlateManager.CanMovePlate(data.Index, PlateDropTarget(data.Index, slot)); + if (!allowed) { e.Effect = DragDropEffects.None; HidePlateDropIndicator(); return; } - e.Effect = DragDropEffects.Move; + e.Effect = copying ? DragDropEffects.Copy : DragDropEffects.Move; ShowPlateDropIndicator(slot); } + // Ctrl (key state bit 8) during the drag drops a copy where the plate allows one. + private static bool IsCopyDrag(DragEventArgs e) => + (e.KeyState & 8) != 0 && (e.AllowedEffect & DragDropEffects.Copy) != 0; + private void PlatesListView_DragDrop(object sender, DragEventArgs e) { HidePlateDropIndicator(); @@ -135,7 +194,11 @@ namespace OpenNest.Forms return; var client = platesListView.PointToClient(new Point(e.X, e.Y)); - MovePlate(data.Index, PlateDropTarget(data.Index, GetPlateDropSlot(client))); + var slot = GetPlateDropSlot(client); + if (IsCopyDrag(e)) + DuplicatePlate(data.Index, slot); + else + MovePlate(data.Index, PlateDropTarget(data.Index, slot)); } /// diff --git a/docs/undo-redo.md b/docs/undo-redo.md index f8d19231..d1caa0f0 100644 --- a/docs/undo-redo.md +++ b/docs/undo-redo.md @@ -21,7 +21,8 @@ Everything stored in the nest: - parts: add, delete, cut, move, drag, nudge, rotate, align, push, center, expand spacing, clone; - placement: fill, fill area, pattern tile, best-fit placement, Auto Nest, Clear Nest; -- plates: add, remove, remove empty plates, Move Plate (dragging a row in the Plates list or +- plates: add, remove, remove empty plates, Duplicate Plate (Ctrl+dragging a row in the Plates + list or its right-click menu), Move Plate (dragging a row in the Plates list or Alt+Up/Alt+Down), Edit Plate (its sheet outline included), rotate, resize, cutting parameters, clamps and clamp rail, the support grid, the nest's default plate; - cutting: manual and automatic cut-offs and their drags, Auto Sequence, Set Sequence,