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,