mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-15 05:35:34 -05:00
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:
1 parent
e2a27df7ef
commit
203945dabf
7 files changed
+933
-4
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user