mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-09 12:05:44 -05:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
872cd8e4b8
commit
a9fc9f0afb
7 files changed
+334
-7
No files matched your search
@@ -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.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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 (<see cref="Quantity"/> 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.
|
||||
/// <see cref="CuttingParameters"/> is shared; callers that need an independent copy
|
||||
/// replace it.
|
||||
/// </summary>
|
||||
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<Part, Part>();
|
||||
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<CutOff, IReadOnlyList<int>>();
|
||||
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<CutOff, IReadOnlyList<int>> places,
|
||||
IReadOnlyDictionary<CutOff, int> firstPlaces) =>
|
||||
RegenerateCutOffs(settings, (cutoff, extents) =>
|
||||
|
||||
@@ -209,6 +209,47 @@ namespace OpenNest
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <see cref="DuplicatePlate"/> can copy the plate at <paramref name="fromIndex"/>
|
||||
/// to <paramref name="toIndex"/>: any plate but the trailing empty sentinel, landing
|
||||
/// anywhere from the first place to just before the sentinel.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a copy of the plate at <paramref name="fromIndex"/> (see
|
||||
/// <see cref="Plate.CopyLayout"/>) so it lands at <paramref name="toIndex"/>, and makes
|
||||
/// the copy the current plate. The trailing empty sentinel stays last.
|
||||
/// </summary>
|
||||
/// <returns>The copy, or null when <see cref="CanDuplicatePlate"/> is false.</returns>
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a copy of the plate at <paramref name="fromIndex"/> so it lands at
|
||||
/// <paramref name="toIndex"/>, as one undoable step; the copy becomes the current plate.
|
||||
/// </summary>
|
||||
/// <returns>Whether a plate was added.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Inserts a copy of the current plate right after it.</summary>
|
||||
public bool DuplicateCurrentPlate()
|
||||
{
|
||||
var index = PlateManager.CurrentIndex;
|
||||
return DuplicatePlate(index, index + 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user