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,