feat(desktop): share part delete, nudge, push, rotate and drag-move edits

PlateEdits ports the WinForms plate view's edits of the selection, with
its undo labels and coalescing: Delete (parts and cut-offs, cut-offs
regenerated once when the plate has any), Nudge by the offset increment
or one repeat step, Push to contact, Rotate about the selection's
centre keeping its lower-left corner (a quarter turn, or by wheel
notches at 10 degrees or a tenth of that), repeated nudges and wheel
turns undoing as one step.

The Select tool drags the selection as one Move step. Cut-offs
regenerate when a drag actually moved parts (WinForms also does it on a
plain click, adding an extra undo step); the regeneration runs before
the step completes, so a failure rolls the drag back, and a view that
loses the pointer mid-drag ends the move as a release would, keeping
the selection.

A plate tool host now has to supply its undo wiring, the nest's cut-off
settings and an edits lock (WinForms EditsLocked): while locked no edit
runs and no press selects or starts a move.
This commit is contained in:
aj committed 2026-11-14 20:26:01 -05:00
1 parent e2a27df7ef
commit 203945dabf
7 files changed
+933 -4

No files matched your search

+15
View File
@@ -100,6 +100,21 @@ namespace OpenNest.Avalonia.Controls
public PlateHitTester HitTester => hitTester;
/// <summary>
/// Opens one undoable edit of the shown nest (its tab's history); unset, edits are not
/// recorded.
/// </summary>
public Func<string, object, Undo.ChangeScope> BeginChangeHandler { get; set; }
/// <summary>The nest's settings that cut-offs regenerate with after an edit.</summary>
public CutOffSettings CutOffSettings { get; set; } = new();
/// <summary>No edits while Auto Nest works on the nest (WinForms EditsLocked).</summary>
public bool EditsLocked { get; set; }
Undo.ChangeScope IPlateToolHost.BeginChange(string label, object coalesceKey) =>
BeginChangeHandler?.Invoke(label, coalesceKey) ?? Undo.ChangeScope.Detached();
/// <summary>The cursor in plate coordinates, or null when it is outside the view.</summary>
public Vector? CursorWorld { get; private set; }
@@ -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++;
/// <summary>The history edits record into, or null to record nothing.</summary>
public NestUndoRecorder Recorder { get; set; }
/// <summary>The label of every change begun, in order.</summary>
public List<string> ChangeLabels { get; } = new();
private CutOffSettings cutOffSettings = new();
public CutOffSettings CutOffSettings
{
get => ThrowOnCutOffSettings
? throw new InvalidOperationException("cut-off settings unavailable")
: cutOffSettings;
set => cutOffSettings = value;
}
/// <summary>Makes reading <see cref="CutOffSettings"/> throw, as a failed rebuild.</summary>
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();
}
/// <summary>Puts the plate in a nest with a fresh history that the edits record into.</summary>
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);
@@ -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;
/// <summary>Where the plate's cut-off strokes run, piece by piece.</summary>
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<Vector> 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);
}
}
@@ -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<SelectTool>(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<SelectTool>(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<InvalidOperationException>(() => Tool.PointerReleased(host.At(80, 25)));
Assert.False(Assert.IsType<SelectTool>(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<SelectTool>(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<SelectTool>(Tool).Window);
Assert.True(host.Selection.IsEmpty);
}
}
+216
View File
@@ -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;
/// <summary>
/// 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 (<see cref="IPlateToolHost.EditsLocked"/>).
/// </summary>
public sealed class PlateEdits
{
public PlateEdits(IPlateToolHost host)
{
Host = host ?? throw new ArgumentNullException(nameof(host));
}
public IPlateToolHost Host { get; }
/// <summary>How far an arrow-key nudge moves the selection, in plate units.</summary>
public double OffsetIncrement { get; set; } = 10;
/// <summary>
/// Whether an arrow key steps the selection by its own size instead (<see cref="Step"/>).
/// </summary>
public bool CalculatedOffset { get; set; }
/// <summary>How far one wheel notch rotates the selection, in degrees.</summary>
public double RotateIncrementDegrees { get; set; } = 10;
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// An arrow key: moves by <see cref="OffsetIncrement"/>, or with
/// <see cref="CalculatedOffset"/> steps.
/// </summary>
public void Nudge(PushDirection direction)
{
if (CalculatedOffset)
{
Step(direction);
return;
}
var offset = SpatialQuery.DirectionToOffset(direction, OffsetIncrement);
NudgeBy(offset.X, offset.Y);
}
/// <summary>Moves the selection by (<paramref name="dx"/>, <paramref name="dy"/>).</summary>
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();
}
/// <summary>
/// Moves the selection one repeat step in <paramref name="direction"/>: its size along that
/// axis plus the part spacing, measured on its geometry (<see cref="Compactor.Step"/>).
/// </summary>
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();
}
/// <summary>
/// Pushes the selection in <paramref name="direction"/> until it meets a part or the edge.
/// </summary>
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();
}
/// <summary>
/// Rotates the selection by <paramref name="radians"/> about its center, keeping its lower-left
/// corner; with <paramref name="coalesce"/>, quick repeats undo as one.
/// </summary>
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();
}
/// <summary>A middle click: a quarter turn counterclockwise, its own undo step.</summary>
public void RotateQuarterTurn() => Rotate(Angle.ToRadians(90));
/// <summary>
/// Shift+wheel: <paramref name="notches"/> wheel notches, positive turned away from the user,
/// rotate clockwise by <see cref="RotateIncrementDegrees"/> each (a tenth of it when
/// <paramref name="fine"/>, Ctrl held); a turn of the wheel undoes as one rotation.
/// </summary>
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<Part> 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;
}
/// <summary>
/// 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.
/// </summary>
private sealed class SelectionKey : IEquatable<SelectionKey>
{
private readonly string operation;
private readonly Plate plate;
private readonly Part[] parts;
public SelectionKey(string operation, Plate plate, IEnumerable<Part> 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);
}
}
+26
View File
@@ -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
/// <summary>Requests a redraw.</summary>
void Invalidate();
/// <summary>
/// Opens one undoable edit of the plate (see <see cref="NestUndoRecorder.BeginChange"/>). A
/// view without a nest history records nothing.
/// </summary>
ChangeScope BeginChange(string label, object coalesceKey = null);
/// <summary>
/// The settings cut-offs are rebuilt with after an edit moves or removes parts.
/// </summary>
CutOffSettings CutOffSettings { get; }
/// <summary>
/// True while the plate must not be edited, as the WinForms view's <c>EditsLocked</c>: 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.
/// </summary>
bool EditsLocked { get; }
}
/// <summary>
@@ -99,5 +119,11 @@ public abstract class PlateTool
/// <returns>Whether the tool used the event.</returns>
public virtual bool PointerReleased(ToolPointer pointer) => false;
/// <summary>
/// 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.
/// </summary>
public virtual void PointerCaptureLost() { }
protected void RaiseHintChanged() => HintChanged?.Invoke(this, EventArgs.Empty);
}
+93 -4
View File
@@ -1,3 +1,6 @@
using OpenNest.Geometry;
using OpenNest.Undo;
namespace OpenNest.Desktop.Interaction;
/// <summary>
@@ -6,12 +9,24 @@ namespace OpenNest.Desktop.Interaction;
/// <item>A left click within <see cref="CutOffPickPixels"/> of a cut-off's stroke selects that cut-off alone.</item>
/// <item>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.</item>
/// <item>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.</item>
/// <item>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 (<see cref="SelectionWindow"/>).</item>
/// <item>A right click on an unselected part selects it alone, for its menu; on an empty area it
/// clears the selection.</item>
/// </list>
/// 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.
/// <para>
/// While the host's edits are locked (<see cref="IPlateToolHost.EditsLocked"/>) a press does
/// nothing at all, as in WinForms, whose <c>PlateView.OnMouseDown</c> 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.
/// </para>
/// </summary>
public sealed class SelectTool : PlateTool
{
@@ -28,11 +43,19 @@ public sealed class SelectTool : PlateTool
/// <summary>The window being dragged, or null.</summary>
public SelectionWindow? Window { get; private set; }
/// <summary>Whether a drag is moving the selected parts.</summary>
public bool IsMoving => move != null;
private ChangeScope move;
private Vector lastMoveWorld;
/// <summary>Busy while dragging a window or while anything is selected, so Escape first deselects.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
}