diff --git a/OpenNest.Avalonia/Controls/PlateCanvas.cs b/OpenNest.Avalonia/Controls/PlateCanvas.cs
index 3d2e4783..e8a11046 100644
--- a/OpenNest.Avalonia/Controls/PlateCanvas.cs
+++ b/OpenNest.Avalonia/Controls/PlateCanvas.cs
@@ -100,6 +100,21 @@ namespace OpenNest.Avalonia.Controls
public PlateHitTester HitTester => hitTester;
+ ///
+ /// Opens one undoable edit of the shown nest (its tab's history); unset, edits are not
+ /// recorded.
+ ///
+ public Func BeginChangeHandler { get; set; }
+
+ /// The nest's settings that cut-offs regenerate with after an edit.
+ public CutOffSettings CutOffSettings { get; set; } = new();
+
+ /// No edits while Auto Nest works on the nest (WinForms EditsLocked).
+ public bool EditsLocked { get; set; }
+
+ Undo.ChangeScope IPlateToolHost.BeginChange(string label, object coalesceKey) =>
+ BeginChangeHandler?.Invoke(label, coalesceKey) ?? Undo.ChangeScope.Detached();
+
/// The cursor in plate coordinates, or null when it is outside the view.
public Vector? CursorWorld { get; private set; }
diff --git a/OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs b/OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs
index a6d9b40d..0f53a935 100644
--- a/OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs
+++ b/OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs
@@ -4,6 +4,7 @@ using OpenNest.CutOffs;
using OpenNest.Desktop.Interaction;
using OpenNest.Desktop.Rendering;
using OpenNest.Geometry;
+using OpenNest.Undo;
namespace OpenNest.Desktop.Tests.Interaction;
@@ -34,6 +35,48 @@ internal sealed class TestToolHost : IPlateToolHost
public void Invalidate() => Invalidations++;
+ /// The history edits record into, or null to record nothing.
+ public NestUndoRecorder Recorder { get; set; }
+
+ /// The label of every change begun, in order.
+ public List ChangeLabels { get; } = new();
+
+ private CutOffSettings cutOffSettings = new();
+
+ public CutOffSettings CutOffSettings
+ {
+ get => ThrowOnCutOffSettings
+ ? throw new InvalidOperationException("cut-off settings unavailable")
+ : cutOffSettings;
+ set => cutOffSettings = value;
+ }
+
+ /// Makes reading throw, as a failed rebuild.
+ public bool ThrowOnCutOffSettings { get; set; }
+
+ public bool EditsLocked { get; set; }
+
+ public ChangeScope BeginChange(string label, object coalesceKey = null)
+ {
+ ChangeLabels.Add(label);
+ return Recorder?.BeginChange(label, coalesceKey) ?? ChangeScope.Detached();
+ }
+
+ /// Puts the plate in a nest with a fresh history that the edits record into.
+ public NestUndoRecorder RecordHistory()
+ {
+ var nest = new Nest { Name = "test" };
+ foreach (var drawing in Plate.Parts.Select(p => p.BaseDrawing).Distinct())
+ {
+ if (!drawing.IsCutOff)
+ nest.Drawings.Add(drawing);
+ }
+ nest.Plates.Add(Plate);
+ Recorder = new NestUndoRecorder(new Document { Nest = nest });
+ Recorder.Reset();
+ return Recorder;
+ }
+
public ToolPointer At(double x, double y, ToolButton button = ToolButton.Left,
ToolModifiers modifiers = ToolModifiers.None) =>
new(new Vector(x, y), View.WorldToScreen(x, y), button, modifiers);
diff --git a/OpenNest.Desktop.Tests/Interaction/PlateEditsTests.cs b/OpenNest.Desktop.Tests/Interaction/PlateEditsTests.cs
new file mode 100644
index 00000000..9cf52704
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/PlateEditsTests.cs
@@ -0,0 +1,341 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Desktop.Interaction;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+public class PlateEditsTests
+{
+ private readonly Plate plate = InteractionFixtures.Plate();
+ private readonly TestToolHost host;
+ private readonly PlateEdits edits;
+
+ public PlateEditsTests()
+ {
+ host = new TestToolHost(plate);
+ edits = new PlateEdits(host);
+ }
+
+ private PlateSelection Selection => host.Selection;
+
+ /// Where the plate's cut-off strokes run, piece by piece.
+ internal static string CutOffStrokes(Plate plate) =>
+ string.Join(";", plate.Parts.Where(p => p.BaseDrawing.IsCutOff).Select(p =>
+ {
+ var box = p.BoundingBox;
+ return $"{p.Program.Codes.Count}:{box.Left:0.###},{box.Bottom:0.###},"
+ + $"{box.Right:0.###},{box.Top:0.###}";
+ }));
+
+ private static void AssertAt(Vector expected, Vector actual) =>
+ Assert.True(expected.DistanceTo(actual) < 1e-9, $"expected {expected}, was {actual}");
+
+ [Fact]
+ public void Delete_removes_selected_parts_and_cut_offs_and_clears_the_selection()
+ {
+ var part = plate.Parts[1];
+ Selection.Select(part);
+ Selection.Select(plate.CutOffs[0]);
+
+ edits.Delete();
+
+ Assert.DoesNotContain(part, plate.Parts);
+ Assert.Empty(plate.CutOffs);
+ Assert.DoesNotContain(plate.Parts, p => p.BaseDrawing.IsCutOff);
+ Assert.True(Selection.IsEmpty);
+ Assert.Equal(new[] { "Delete" }, host.ChangeLabels);
+ Assert.True(host.Invalidations > 0);
+ }
+
+ [Fact]
+ public void Deleting_the_only_cut_off_alone_removes_its_strokes()
+ {
+ Selection.Select(plate.CutOffs[0]);
+
+ edits.Delete();
+
+ Assert.Empty(plate.CutOffs);
+ Assert.DoesNotContain(plate.Parts, p => p.BaseDrawing.IsCutOff);
+ Assert.Equal(5, plate.Parts.Count);
+ }
+
+ [Fact]
+ public void Delete_rebuilds_the_remaining_cut_offs_without_the_deleted_parts_gap()
+ {
+ var expected = CutOffStrokes(plate);
+ var crossing = new Part(plate.Parts[1].BaseDrawing, new Vector(75, 40));
+ plate.Parts.Add(crossing);
+ plate.RegenerateCutOffs(host.CutOffSettings);
+ Assert.NotEqual(expected, CutOffStrokes(plate));
+ Selection.Select(crossing);
+
+ edits.Delete();
+
+ Assert.Equal(expected, CutOffStrokes(plate));
+ }
+
+ [Fact]
+ public void Delete_with_nothing_selected_records_no_change()
+ {
+ var recorder = host.RecordHistory();
+ var count = plate.Parts.Count;
+
+ edits.Delete();
+
+ Assert.Equal(count, plate.Parts.Count);
+ Assert.Empty(host.ChangeLabels);
+ Assert.False(recorder.CanUndo);
+ }
+
+ [Fact]
+ public void Nudge_moves_by_the_increment_and_repeats_undo_as_one_step()
+ {
+ var recorder = host.RecordHistory();
+ Selection.Select(plate.Parts[1]);
+
+ edits.Nudge(PushDirection.Right);
+ edits.Nudge(PushDirection.Right);
+
+ AssertAt(new Vector(40, 0), plate.Parts[1].Location);
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.Equal("Nudge", recorder.History.UndoLabel);
+
+ Selection.SelectOnly(plate.Parts[4]);
+ edits.NudgeBy(0, 2);
+
+ AssertAt(new Vector(20, 22), plate.Parts[4].Location);
+ Assert.Equal(2, recorder.History.UndoCount);
+
+ recorder.Undo();
+ recorder.Undo();
+ AssertAt(new Vector(20, 0), recorder.Document.Nest.Plates[0].Parts[1].Location);
+ }
+
+ [Fact]
+ public void A_calculated_nudge_steps_the_selection_by_its_own_size()
+ {
+ var twin = InteractionFixtures.Plate();
+ twin.PartSpacing = plate.PartSpacing = 1.5;
+ var step = Compactor.Step(new() { twin.Parts[4] }, twin, PushDirection.Up);
+ Assert.True(step > 0);
+ edits.OffsetIncrement = 3;
+ edits.CalculatedOffset = true;
+ Selection.Select(plate.Parts[4]);
+
+ edits.Nudge(PushDirection.Up);
+
+ AssertAt(new Vector(20, 20 + step), plate.Parts[4].Location);
+ Assert.Equal(new[] { "Nudge" }, host.ChangeLabels);
+ }
+
+ [Fact]
+ public void A_fixed_nudge_moves_by_the_offset_increment()
+ {
+ edits.OffsetIncrement = 3;
+ Selection.Select(plate.Parts[4]);
+
+ edits.Nudge(PushDirection.Down);
+
+ AssertAt(new Vector(20, 17), plate.Parts[4].Location);
+ }
+
+ [Fact]
+ public void Push_moves_the_selection_until_it_meets_another_part()
+ {
+ Selection.Select(plate.Parts[4]);
+
+ edits.Push(PushDirection.Down);
+
+ Assert.Equal(10 + plate.PartSpacing, plate.Parts[4].BoundingBox.Bottom, 9);
+ Assert.Equal(20, plate.Parts[4].Location.X, 9);
+ Assert.Equal(new[] { "Push" }, host.ChangeLabels);
+ }
+
+ [Fact]
+ public void A_quarter_turn_keeps_the_selection_bounds_lower_left_corner()
+ {
+ var recorder = host.RecordHistory();
+ Selection.Select(plate.Parts[1]);
+ Selection.Select(plate.Parts[4]);
+
+ edits.RotateQuarterTurn();
+
+ var bounds = new[] { plate.Parts[1], plate.Parts[4] }.GetBoundingBox();
+ AssertAt(new Vector(20, 0), bounds.Location);
+ Assert.Equal(30, bounds.Length, 9);
+ Assert.Equal(10, bounds.Width, 9);
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.Equal("Rotate", recorder.History.UndoLabel);
+
+ edits.RotateQuarterTurn();
+
+ Assert.Equal(2, recorder.History.UndoCount);
+ }
+
+ [Theory]
+ [InlineData(1, false, -10)]
+ [InlineData(2, false, -20)]
+ [InlineData(-1, false, 10)]
+ [InlineData(1, true, -1)]
+ [InlineData(-3, true, 3)]
+ public void The_wheel_turns_clockwise_away_from_the_user_and_finely_with_Ctrl(
+ int notches, bool fine, double degrees)
+ {
+ var part = plate.Parts[1];
+ Selection.Select(part);
+
+ edits.RotateByWheel(notches, fine);
+
+ var expected = Angle.ToRadians(degrees);
+ Assert.Equal(System.Math.Cos(expected), System.Math.Cos(part.Rotation), 9);
+ Assert.Equal(System.Math.Sin(expected), System.Math.Sin(part.Rotation), 9);
+ }
+
+ [Fact]
+ public void Wheel_turns_undo_as_one_rotation()
+ {
+ var recorder = host.RecordHistory();
+ Selection.Select(plate.Parts[1]);
+
+ edits.RotateByWheel(1, false);
+ edits.RotateByWheel(1, false);
+
+ Assert.Equal(1, recorder.History.UndoCount);
+ }
+
+ [Fact]
+ public void Without_a_selection_no_edit_changes_anything()
+ {
+ Selection.Select(plate.CutOffs[0]);
+ var before = plate.Parts.Select(p => p.Location).ToList();
+
+ edits.Nudge(PushDirection.Left);
+ edits.NudgeBy(1, 1);
+ edits.Push(PushDirection.Left);
+ edits.Rotate(1);
+ edits.RotateQuarterTurn();
+ edits.RotateByWheel(1, false);
+ edits.CalculatedOffset = true;
+ edits.Nudge(PushDirection.Left);
+
+ AssertUnmoved(before);
+ Assert.Empty(host.ChangeLabels);
+ }
+
+ private void AssertUnmoved(List before)
+ {
+ Assert.Equal(before.Count, plate.Parts.Count);
+ for (var i = 0; i < before.Count; i++)
+ AssertAt(before[i], plate.Parts[i].Location);
+ }
+
+ [Fact]
+ public void Rotating_rebuilds_the_cut_offs_around_the_turned_parts()
+ {
+ var strip = new Part(new Drawing("strip", InteractionFixtures.Rect(10, 4)),
+ new Vector(72, 40));
+ plate.Parts.Add(strip);
+ plate.RegenerateCutOffs(host.CutOffSettings);
+ var before = CutOffStrokes(plate);
+ Selection.Select(strip);
+
+ edits.RotateQuarterTurn();
+
+ Assert.True(strip.BoundingBox.Right < 80);
+ Assert.NotEqual(before, CutOffStrokes(plate));
+ var twin = InteractionFixtures.Plate();
+ var turned = new Part(new Drawing("strip", InteractionFixtures.Rect(4, 10)),
+ new Vector(72, 40));
+ twin.Parts.Add(turned);
+ twin.RegenerateCutOffs(new());
+ Assert.Equal(CutOffStrokes(twin), CutOffStrokes(plate));
+ }
+
+ [Fact]
+ public void A_step_that_cannot_move_records_no_undo_step()
+ {
+ // A flat strip has no height, so an upward step with no spacing has no distance.
+ plate.PartSpacing = 0;
+ var flat = new Part(new Drawing("flat", InteractionFixtures.Rect(10, 0)),
+ new Vector(60, 60));
+ plate.Parts.Add(flat);
+ var recorder = host.RecordHistory();
+ Assert.Equal(0, Compactor.Step(new() { flat }, plate, PushDirection.Up));
+ edits.CalculatedOffset = true;
+ Selection.Select(flat);
+
+ edits.Nudge(PushDirection.Up);
+
+ Assert.Equal(new[] { "Nudge" }, host.ChangeLabels);
+ Assert.Equal(0, recorder.History.UndoCount);
+ Assert.False(recorder.HasUnrecordedChange);
+ }
+
+ [Fact]
+ public void Delete_on_a_plate_without_cut_offs_does_not_rebuild_them()
+ {
+ plate.CutOffs.Clear();
+ plate.RegenerateCutOffs(host.CutOffSettings);
+ host.ThrowOnCutOffSettings = true;
+ var part = plate.Parts[1];
+ Selection.Select(part);
+
+ edits.Delete();
+
+ Assert.DoesNotContain(part, plate.Parts);
+ Assert.Equal(new[] { "Delete" }, host.ChangeLabels);
+ }
+
+ [Fact]
+ public void Undo_after_Delete_restores_the_parts()
+ {
+ var recorder = host.RecordHistory();
+ Selection.Select(plate.Parts[1]);
+ Selection.Select(plate.Parts[4]);
+
+ edits.Delete();
+ Assert.Equal(3, plate.Parts.Count(p => !p.BaseDrawing.IsCutOff));
+ Assert.Equal("Delete", recorder.History.UndoLabel);
+
+ recorder.Undo();
+
+ var restored = recorder.Document.Nest.Plates[0].Parts
+ .Where(p => !p.BaseDrawing.IsCutOff).ToList();
+ Assert.Equal(5, restored.Count);
+ AssertAt(new Vector(20, 0), restored[1].Location);
+ AssertAt(new Vector(20, 20), restored[4].Location);
+ }
+
+ [Fact]
+ public void While_edits_are_locked_no_edit_changes_or_records_anything()
+ {
+ var recorder = host.RecordHistory();
+ host.EditsLocked = true;
+ Selection.Select(plate.Parts[1]);
+ Selection.Select(plate.Parts[4]);
+ var before = plate.Parts.Select(p => p.Location).ToList();
+ var strokes = CutOffStrokes(plate);
+
+ edits.Delete();
+ edits.Nudge(PushDirection.Left);
+ edits.NudgeBy(1, 1);
+ edits.Push(PushDirection.Down);
+ edits.Rotate(1);
+ edits.RotateQuarterTurn();
+ edits.RotateByWheel(1, false);
+ edits.CalculatedOffset = true;
+ edits.Nudge(PushDirection.Up);
+
+ AssertUnmoved(before);
+ Assert.Equal(0, plate.Parts[1].Rotation, 12);
+ Assert.Single(plate.CutOffs);
+ Assert.Equal(strokes, CutOffStrokes(plate));
+ Assert.Equal(2, Selection.Parts.Count);
+ Assert.Empty(host.ChangeLabels);
+ Assert.Equal(0, recorder.History.UndoCount);
+ Assert.False(recorder.HasUnrecordedChange);
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Interaction/SelectToolMoveTests.cs b/OpenNest.Desktop.Tests/Interaction/SelectToolMoveTests.cs
new file mode 100644
index 00000000..9e4a5abe
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/SelectToolMoveTests.cs
@@ -0,0 +1,199 @@
+using System;
+using OpenNest.Desktop.Interaction;
+using OpenNest.Geometry;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+public class SelectToolMoveTests
+{
+ private readonly Plate plate = InteractionFixtures.Plate();
+ private readonly TestToolHost host;
+
+ public SelectToolMoveTests()
+ {
+ host = new TestToolHost(plate);
+ }
+
+ private PlateTool Tool => host.Tools.Current;
+
+ private static void AssertAt(Vector expected, Vector actual) =>
+ Assert.True(expected.DistanceTo(actual) < 1e-9, $"expected {expected}, was {actual}");
+
+ [Fact]
+ public void Dragging_a_part_moves_every_selected_part_as_one_Move_step()
+ {
+ var recorder = host.RecordHistory();
+ host.Click(25, 25);
+ host.Click(25, 5, ToolModifiers.Control);
+
+ Tool.PointerPressed(host.At(25, 5));
+ Tool.PointerMoved(host.At(27, 6));
+ Tool.PointerMoved(host.At(30, 8));
+ Tool.PointerReleased(host.At(30, 8));
+
+ AssertAt(new Vector(25, 3), plate.Parts[1].Location);
+ AssertAt(new Vector(25, 23), plate.Parts[4].Location);
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.Equal("Move", recorder.History.UndoLabel);
+ Assert.False(Assert.IsType(Tool).IsMoving);
+ }
+
+ [Fact]
+ public void A_window_drag_moves_nothing()
+ {
+ host.Click(25, 5);
+
+ host.Drag(15, -1, 31, 11);
+
+ AssertAt(new Vector(20, 0), plate.Parts[1].Location);
+ Assert.Equal(new[] { plate.Parts[1] }, host.Selection.Parts);
+ // The one Move label is the earlier click on the part; the window drag began none.
+ Assert.Equal(new[] { "Move" }, host.ChangeLabels);
+ }
+
+ [Fact]
+ public void Cancelling_mid_drag_keeps_the_partial_move_as_one_step()
+ {
+ var recorder = host.RecordHistory();
+ Tool.PointerPressed(host.At(25, 5));
+ Tool.PointerMoved(host.At(30, 5));
+
+ Tool.Cancel();
+
+ AssertAt(new Vector(25, 0), plate.Parts[1].Location);
+ Assert.True(host.Selection.IsEmpty);
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.False(recorder.IsChangeInProgress);
+ }
+
+ [Fact]
+ public void A_click_on_a_part_records_nothing_even_with_cut_offs_on_the_plate()
+ {
+ var recorder = host.RecordHistory();
+
+ host.Click(25, 5);
+ host.Click(25, 25);
+
+ Assert.False(recorder.HasUnrecordedChange);
+ Assert.Equal(0, recorder.History.UndoCount);
+ }
+
+ [Fact]
+ public void Releasing_a_drag_rebuilds_the_cut_offs_around_the_moved_part()
+ {
+ var before = PlateEditsTests.CutOffStrokes(plate);
+
+ host.Drag(25, 25, 80, 25);
+
+ AssertAt(new Vector(75, 20), plate.Parts[4].Location);
+ Assert.NotEqual(before, PlateEditsTests.CutOffStrokes(plate));
+ }
+
+ [Fact]
+ public void Showing_another_plate_mid_drag_ends_the_move_as_one_Move_step()
+ {
+ var recorder = host.RecordHistory();
+ Tool.PointerPressed(host.At(25, 5));
+ Tool.PointerMoved(host.At(30, 5));
+
+ host.Tools.OnPlateChanged();
+
+ AssertAt(new Vector(25, 0), plate.Parts[1].Location);
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.Equal("Move", recorder.History.UndoLabel);
+ Assert.False(recorder.IsChangeInProgress);
+ }
+
+ [Fact]
+ public void While_edits_are_locked_a_press_selects_nothing_and_a_drag_moves_nothing()
+ {
+ var recorder = host.RecordHistory();
+ host.Click(25, 5);
+ host.EditsLocked = true;
+ var strokes = PlateEditsTests.CutOffStrokes(plate);
+
+ Assert.False(Tool.PointerPressed(host.At(25, 25)));
+ host.Drag(25, 25, 80, 25);
+ host.Drag(60, 60, 90, 90);
+ host.Click(80, 50);
+
+ Assert.False(Assert.IsType(Tool).IsMoving);
+ AssertAt(new Vector(20, 20), plate.Parts[4].Location);
+ Assert.Equal(new[] { plate.Parts[1] }, host.Selection.Parts);
+ Assert.Empty(host.Selection.CutOffs);
+ Assert.Equal(strokes, PlateEditsTests.CutOffStrokes(plate));
+ // The one Move label is the unlocked click before the lock.
+ Assert.Equal(new[] { "Move" }, host.ChangeLabels);
+ Assert.Equal(0, recorder.History.UndoCount);
+ Assert.False(recorder.HasUnrecordedChange);
+ }
+
+ [Fact]
+ public void A_move_open_when_the_lock_appears_finishes_at_its_release()
+ {
+ var recorder = host.RecordHistory();
+ Tool.PointerPressed(host.At(25, 5));
+ Tool.PointerMoved(host.At(27, 5));
+ host.EditsLocked = true;
+ Tool.PointerMoved(host.At(30, 5));
+ Tool.PointerReleased(host.At(30, 5));
+
+ AssertAt(new Vector(25, 0), plate.Parts[1].Location);
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.False(recorder.IsChangeInProgress);
+ }
+
+ [Fact]
+ public void A_release_whose_cut_off_rebuild_throws_rolls_the_move_back()
+ {
+ var recorder = host.RecordHistory();
+ Tool.PointerPressed(host.At(25, 25));
+ Tool.PointerMoved(host.At(80, 25));
+ host.ThrowOnCutOffSettings = true;
+
+ Assert.Throws(() => Tool.PointerReleased(host.At(80, 25)));
+
+ Assert.False(Assert.IsType(Tool).IsMoving);
+ Assert.False(recorder.IsChangeInProgress);
+ Assert.Equal(0, recorder.History.UndoCount);
+ var restored = recorder.Document.Nest.Plates[0];
+ Assert.NotSame(plate, restored);
+ AssertAt(new Vector(20, 20), restored.Parts[4].Location);
+ AssertAt(new Vector(20, 0), restored.Parts[1].Location);
+ Assert.False(recorder.HasUnrecordedChange);
+ }
+
+ [Fact]
+ public void Losing_the_pointer_mid_drag_ends_the_move_as_one_step_and_keeps_the_selection()
+ {
+ var recorder = host.RecordHistory();
+ var before = PlateEditsTests.CutOffStrokes(plate);
+ Tool.PointerPressed(host.At(25, 25));
+ Tool.PointerMoved(host.At(50, 25));
+ Tool.PointerMoved(host.At(80, 25));
+
+ Tool.PointerCaptureLost();
+
+ AssertAt(new Vector(75, 20), plate.Parts[4].Location);
+ Assert.NotEqual(before, PlateEditsTests.CutOffStrokes(plate));
+ Assert.Equal(new[] { plate.Parts[4] }, host.Selection.Parts);
+ Assert.False(Assert.IsType(Tool).IsMoving);
+ Assert.Equal(1, recorder.History.UndoCount);
+ Assert.Equal("Move", recorder.History.UndoLabel);
+ Assert.False(recorder.IsChangeInProgress);
+ Assert.False(recorder.HasUnrecordedChange);
+ }
+
+ [Fact]
+ public void Losing_the_pointer_mid_window_drops_the_window_without_selecting()
+ {
+ Tool.PointerPressed(host.At(15, 15));
+ Tool.PointerMoved(host.At(35, 35));
+
+ Tool.PointerCaptureLost();
+ Tool.PointerReleased(host.At(35, 35));
+
+ Assert.Null(Assert.IsType(Tool).Window);
+ Assert.True(host.Selection.IsEmpty);
+ }
+}
diff --git a/OpenNest.Desktop/Interaction/PlateEdits.cs b/OpenNest.Desktop/Interaction/PlateEdits.cs
new file mode 100644
index 00000000..1e90dc8f
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/PlateEdits.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Desktop.Interaction;
+
+///
+/// The WinForms plate view's edits of the selection, toolkit-neutral: delete, nudge, step, push
+/// and rotate. Each edit is one undo step named as in WinForms; repeated nudges and steps of the
+/// same parts, and wheel rotations, undo as one. Every edit does nothing without a plate or
+/// selected parts (Delete also acts on selected cut-offs alone) and redraws the view, and nothing
+/// at all while the host's edits are locked ().
+///
+public sealed class PlateEdits
+{
+ public PlateEdits(IPlateToolHost host)
+ {
+ Host = host ?? throw new ArgumentNullException(nameof(host));
+ }
+
+ public IPlateToolHost Host { get; }
+
+ /// How far an arrow-key nudge moves the selection, in plate units.
+ public double OffsetIncrement { get; set; } = 10;
+
+ ///
+ /// Whether an arrow key steps the selection by its own size instead ().
+ ///
+ public bool CalculatedOffset { get; set; }
+
+ /// How far one wheel notch rotates the selection, in degrees.
+ public double RotateIncrementDegrees { get; set; } = 10;
+
+ ///
+ /// Removes the selected parts and cut-offs and clears the selection. When the plate had
+ /// cut-offs, the remaining ones are rebuilt once both kinds are gone, so they close the gaps
+ /// left around deleted parts.
+ ///
+ public void Delete()
+ {
+ var plate = Host.Plate;
+ if (plate == null || Host.EditsLocked)
+ return;
+ var selection = Host.Selection;
+ selection.Prune();
+ var parts = selection.Parts.ToList();
+ var cutOffs = selection.CutOffs.ToList();
+ if (parts.Count == 0 && cutOffs.Count == 0)
+ return;
+
+ using var change = Host.BeginChange("Delete");
+ var regenerateCutOffs = plate.CutOffs.Count > 0;
+ change.MarkChanged();
+ foreach (var cutOff in cutOffs)
+ plate.CutOffs.Remove(cutOff);
+ foreach (var part in parts)
+ plate.Parts.Remove(part);
+ selection.Clear();
+ if (regenerateCutOffs)
+ plate.RegenerateCutOffs(Host.CutOffSettings);
+ Host.Invalidate();
+ change.Complete();
+ }
+
+ ///
+ /// An arrow key: moves by , or with
+ /// steps.
+ ///
+ public void Nudge(PushDirection direction)
+ {
+ if (CalculatedOffset)
+ {
+ Step(direction);
+ return;
+ }
+
+ var offset = SpatialQuery.DirectionToOffset(direction, OffsetIncrement);
+ NudgeBy(offset.X, offset.Y);
+ }
+
+ /// Moves the selection by (, ).
+ public void NudgeBy(double dx, double dy)
+ {
+ if (!TryGetSelectedParts(out var plate, out var parts))
+ return;
+
+ using var change = Host.BeginChange("Nudge", new SelectionKey("Nudge", plate, parts));
+ change.MarkChanged();
+ foreach (var part in parts)
+ part.Offset(dx, dy);
+ Host.Invalidate();
+ change.Complete();
+ }
+
+ ///
+ /// Moves the selection one repeat step in : its size along that
+ /// axis plus the part spacing, measured on its geometry ().
+ ///
+ public void Step(PushDirection direction)
+ {
+ if (!TryGetSelectedParts(out var plate, out var parts))
+ return;
+
+ using var change = Host.BeginChange("Nudge", new SelectionKey("Nudge", plate, parts));
+ if (Compactor.Step(parts, plate, direction) > 0)
+ change.MarkChanged();
+ Host.Invalidate();
+ change.Complete();
+ }
+
+ ///
+ /// Pushes the selection in until it meets a part or the edge.
+ ///
+ public void Push(PushDirection direction)
+ {
+ if (!TryGetSelectedParts(out var plate, out var parts))
+ return;
+
+ using var change = Host.BeginChange("Push");
+ change.MarkChanged();
+ Compactor.Push(parts, plate, direction);
+ Host.Invalidate();
+ change.Complete();
+ }
+
+ ///
+ /// Rotates the selection by about its center, keeping its lower-left
+ /// corner; with , quick repeats undo as one.
+ ///
+ public void Rotate(double radians, bool coalesce = false)
+ {
+ if (!TryGetSelectedParts(out var plate, out var parts))
+ return;
+
+ using var change = Host.BeginChange("Rotate",
+ coalesce ? new SelectionKey("Rotate", plate, parts) : null);
+ change.MarkChanged();
+ var bounds = parts.GetBoundingBox();
+ var center = bounds.Center;
+ var anchor = bounds.Location;
+ foreach (var part in parts)
+ part.Rotate(radians, center);
+
+ var diff = anchor - parts.GetBoundingBox().Location;
+ foreach (var part in parts)
+ part.Offset(diff);
+
+ if (plate.CutOffs.Count > 0)
+ plate.RegenerateCutOffs(Host.CutOffSettings);
+ Host.Invalidate();
+ change.Complete();
+ }
+
+ /// A middle click: a quarter turn counterclockwise, its own undo step.
+ public void RotateQuarterTurn() => Rotate(Angle.ToRadians(90));
+
+ ///
+ /// Shift+wheel: wheel notches, positive turned away from the user,
+ /// rotate clockwise by each (a tenth of it when
+ /// , Ctrl held); a turn of the wheel undoes as one rotation.
+ ///
+ public void RotateByWheel(int notches, bool fine)
+ {
+ if (notches == 0)
+ return;
+ var increment = fine ? RotateIncrementDegrees * 0.1 : RotateIncrementDegrees;
+ var degrees = (notches > 0 ? -increment : increment) * System.Math.Abs(notches);
+ var angle = Angle.ToRadians(degrees);
+ Rotate(angle, coalesce: true);
+ }
+
+ private bool TryGetSelectedParts(out Plate plate, out List parts)
+ {
+ plate = Host.Plate;
+ parts = null;
+ if (plate == null || Host.EditsLocked)
+ return false;
+ Host.Selection.Prune();
+ parts = Host.Selection.Parts.ToList();
+ return parts.Count > 0;
+ }
+
+ ///
+ /// Equal for the same edit of the same parts, in the same order, on the same plate, so
+ /// repeated nudges or wheel turns coalesce into one undo entry.
+ ///
+ private sealed class SelectionKey : IEquatable
+ {
+ private readonly string operation;
+ private readonly Plate plate;
+ private readonly Part[] parts;
+
+ public SelectionKey(string operation, Plate plate, IEnumerable selection)
+ {
+ this.operation = operation;
+ this.plate = plate;
+ parts = selection.ToArray();
+ }
+
+ public bool Equals(SelectionKey other) =>
+ other != null
+ && operation == other.operation
+ && ReferenceEquals(plate, other.plate)
+ && parts.Length == other.parts.Length
+ && parts.Zip(other.parts).All(pair => ReferenceEquals(pair.First, pair.Second));
+
+ public override bool Equals(object obj) => Equals(obj as SelectionKey);
+
+ public override int GetHashCode() =>
+ HashCode.Combine(operation, RuntimeHelpers.GetHashCode(plate), parts.Length);
+ }
+}
diff --git a/OpenNest.Desktop/Interaction/PlateTool.cs b/OpenNest.Desktop/Interaction/PlateTool.cs
index 87be86d2..b254aa12 100644
--- a/OpenNest.Desktop/Interaction/PlateTool.cs
+++ b/OpenNest.Desktop/Interaction/PlateTool.cs
@@ -1,6 +1,8 @@
using System;
+using OpenNest.CutOffs;
using OpenNest.Desktop.Rendering;
using OpenNest.Geometry;
+using OpenNest.Undo;
namespace OpenNest.Desktop.Interaction;
@@ -44,6 +46,24 @@ public interface IPlateToolHost
/// Requests a redraw.
void Invalidate();
+
+ ///
+ /// Opens one undoable edit of the plate (see ). A
+ /// view without a nest history records nothing.
+ ///
+ ChangeScope BeginChange(string label, object coalesceKey = null);
+
+ ///
+ /// The settings cut-offs are rebuilt with after an edit moves or removes parts.
+ ///
+ CutOffSettings CutOffSettings { get; }
+
+ ///
+ /// True while the plate must not be edited, as the WinForms view's EditsLocked: the
+ /// nest is locked (Auto Nest is running on it; later, an inspection panel shows it). Edits
+ /// and drag-moves are refused; panning and zooming stay live.
+ ///
+ bool EditsLocked { get; }
}
///
@@ -99,5 +119,11 @@ public abstract class PlateTool
/// Whether the tool used the event.
public virtual bool PointerReleased(ToolPointer pointer) => false;
+ ///
+ /// The view lost the pointer mid-gesture (Alt+Tab during a drag, say), so no release will
+ /// follow: the tool ends the gesture in progress.
+ ///
+ public virtual void PointerCaptureLost() { }
+
protected void RaiseHintChanged() => HintChanged?.Invoke(this, EventArgs.Empty);
}
diff --git a/OpenNest.Desktop/Interaction/SelectTool.cs b/OpenNest.Desktop/Interaction/SelectTool.cs
index 0e4f5b2e..ebf3632e 100644
--- a/OpenNest.Desktop/Interaction/SelectTool.cs
+++ b/OpenNest.Desktop/Interaction/SelectTool.cs
@@ -1,3 +1,6 @@
+using OpenNest.Geometry;
+using OpenNest.Undo;
+
namespace OpenNest.Desktop.Interaction;
///
@@ -6,12 +9,24 @@ namespace OpenNest.Desktop.Interaction;
/// - A left click within of a cut-off's stroke selects that cut-off alone.
/// - A left click on a part selects it (with its common-line group) in place of the selection;
/// with Ctrl alone held, or on a part already selected, it adds to the selection instead.
+/// - Dragging from a part moves every selected part with the pointer, as one "Move" undo
+/// step; on release the plate's cut-offs are rebuilt around the parts' new places.
/// - A left press on an empty area clears the selection and starts a selection window; on
/// release the window adds the parts and cut-offs it selects ().
/// - A right click on an unselected part selects it alone, for its menu; on an empty area it
/// clears the selection.
///
-/// Moving the selection by dragging belongs to the move tools built on this one.
+/// Cancelling ends a move in progress where it is (still one undo step) and clears the selection.
+/// Losing the pointer mid-drag ends a move as a release where it is and keeps the selection; a
+/// selection window is dropped without selecting.
+///
+/// While the host's edits are locked () a press does
+/// nothing at all, as in WinForms, whose PlateView.OnMouseDown returns before the Select
+/// action sees the press: nothing is selected or deselected and no move starts. A move already
+/// open when the lock appears is left to finish at its release: a lock is started by a command,
+/// not mid-drag, and ending the move then would leave WinForms' quirk of a drag scope held open
+/// until some later release.
+///
///
public sealed class SelectTool : PlateTool
{
@@ -28,11 +43,19 @@ public sealed class SelectTool : PlateTool
/// The window being dragged, or null.
public SelectionWindow? Window { get; private set; }
+ /// Whether a drag is moving the selected parts.
+ public bool IsMoving => move != null;
+
+ private ChangeScope move;
+ private Vector lastMoveWorld;
+
/// Busy while dragging a window or while anything is selected, so Escape first deselects.
public override bool IsBusy => Window != null || !Host.Selection.IsEmpty;
public override void Cancel()
{
+ // Parts already dragged stay where they are, so the move is recorded.
+ EndMove();
Window = null;
Host.Selection.Clear();
Host.Invalidate();
@@ -41,13 +64,14 @@ public sealed class SelectTool : PlateTool
public override bool PointerPressed(ToolPointer pointer)
{
var plate = Host.Plate;
- if (plate == null)
+ if (plate == null || Host.EditsLocked)
return false;
var selection = Host.Selection;
selection.Prune();
if (pointer.Button == ToolButton.Left)
{
+ EndMove();
Window = null;
var tolerance = Host.View.LengthToWorld(CutOffPickPixels);
var cutOff = Host.HitTester.CutOffAt(plate, pointer.World, tolerance);
@@ -70,6 +94,8 @@ public sealed class SelectTool : PlateTool
if (pointer.Modifiers != ToolModifiers.Control && !selection.Contains(part))
selection.Clear();
selection.Select(part);
+ move = Host.BeginChange("Move");
+ lastMoveWorld = pointer.World;
}
Host.Invalidate();
return true;
@@ -93,7 +119,19 @@ public sealed class SelectTool : PlateTool
public override bool PointerMoved(ToolPointer pointer)
{
- if (Window is not { } window || pointer.Button != ToolButton.Left)
+ if (pointer.Button != ToolButton.Left)
+ return false;
+ if (move != null)
+ {
+ var offset = pointer.World - lastMoveWorld;
+ lastMoveWorld = pointer.World;
+ move.MarkChanged();
+ foreach (var part in Host.Selection.Parts)
+ part.Offset(offset.X, offset.Y);
+ Host.Invalidate();
+ return true;
+ }
+ if (Window is not { } window)
return false;
Window = window with { Second = pointer.World };
Host.Invalidate();
@@ -102,7 +140,14 @@ public sealed class SelectTool : PlateTool
public override bool PointerReleased(ToolPointer pointer)
{
- if (Window is not { } window || pointer.Button != ToolButton.Left)
+ if (pointer.Button != ToolButton.Left)
+ return false;
+ if (move != null)
+ {
+ FinishMove();
+ return true;
+ }
+ if (Window is not { } window)
return false;
window = window with { Second = pointer.World };
Window = null;
@@ -115,4 +160,48 @@ public sealed class SelectTool : PlateTool
Host.Invalidate();
return true;
}
+
+ public override void PointerCaptureLost()
+ {
+ if (move != null)
+ FinishMove();
+ Window = null;
+ Host.Invalidate();
+ }
+
+ ///
+ /// Ends the open move as a release does: the cut-offs are rebuilt around moved parts, then
+ /// the step is recorded. If rebuilding throws, the scope is disposed incomplete, so the move
+ /// rolls back, and the exception propagates.
+ ///
+ private void FinishMove()
+ {
+ var scope = move;
+ move = null;
+ try
+ {
+ // Only after a real move: rebuilding gives the cut-offs new programs, which would
+ // otherwise count as an unrecorded change after a plain click.
+ var movedOn = Host.Plate;
+ if (scope.IsChanged && movedOn != null && Host.Selection.Parts.Count > 0
+ && movedOn.CutOffs.Count > 0)
+ movedOn.RegenerateCutOffs(Host.CutOffSettings);
+ scope.Complete();
+ }
+ finally
+ {
+ scope.Dispose();
+ Host.Invalidate();
+ }
+ }
+
+ private void EndMove()
+ {
+ var scope = move;
+ move = null;
+ if (scope == null)
+ return;
+ scope.Complete();
+ scope.Dispose();
+ }
}