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(); + } }