diff --git a/OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs b/OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs
new file mode 100644
index 00000000..a6d9b40d
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs
@@ -0,0 +1,101 @@
+using OpenNest.CNC;
+using OpenNest.CommonLines;
+using OpenNest.CutOffs;
+using OpenNest.Desktop.Interaction;
+using OpenNest.Desktop.Rendering;
+using OpenNest.Geometry;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+/// A plate view without a toolkit: the tools' host in tests.
+internal sealed class TestToolHost : IPlateToolHost
+{
+ public TestToolHost(Plate plate)
+ {
+ Plate = plate;
+ Selection.Plate = plate;
+ // About ten pixels per unit, so a cut-off is picked within half a unit.
+ View.Resize(1100, 1100);
+ View.ZoomToArea(new Box(0, 0, 100, 100));
+ Tools = new PlateToolManager(this);
+ }
+
+ public Plate Plate { get; set; }
+
+ public PlateSelection Selection { get; } = new();
+
+ public PlateHitTester HitTester { get; } = new();
+
+ public ViewTransform View { get; } = new();
+
+ public PlateToolManager Tools { get; }
+
+ public int Invalidations { get; private set; }
+
+ public void Invalidate() => Invalidations++;
+
+ 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);
+
+ public void Click(double x, double y, ToolModifiers modifiers = ToolModifiers.None)
+ {
+ Tools.Current.PointerPressed(At(x, y, modifiers: modifiers));
+ Tools.Current.PointerReleased(At(x, y, modifiers: modifiers));
+ }
+
+ public void Drag(double x1, double y1, double x2, double y2)
+ {
+ Tools.Current.PointerPressed(At(x1, y1));
+ Tools.Current.PointerMoved(At((x1 + x2) / 2, (y1 + y2) / 2));
+ Tools.Current.PointerMoved(At(x2, y2));
+ Tools.Current.PointerReleased(At(x2, y2));
+ }
+}
+
+internal static class InteractionFixtures
+{
+ public static Program Rect(double width, double height, double x = 0, double y = 0)
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(new Vector(x, y)));
+ program.Codes.Add(new LinearMove(new Vector(x + width, y)));
+ program.Codes.Add(new LinearMove(new Vector(x + width, y + height)));
+ program.Codes.Add(new LinearMove(new Vector(x, y + height)));
+ program.Codes.Add(new LinearMove(new Vector(x, y)));
+ return program;
+ }
+
+ /// A 10x10 square with a 3-unit-radius round hole at its centre.
+ public static Drawing Holed()
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(new Vector(8, 5)));
+ program.Codes.Add(new ArcMove(new Vector(8, 5), new Vector(5, 5), RotationType.CW));
+ foreach (var code in Rect(10, 10).Codes)
+ program.Codes.Add(code);
+ return new Drawing("holed", program);
+ }
+
+ ///
+ /// A 100x100 plate: a holed square at (0,0), a square at (20,0), two squares one kerf apart in a
+ /// common line at (40,0) and (50.05,0), a square at (20,20) on top of nothing, and a vertical
+ /// cut-off at x = 80.
+ ///
+ public static Plate Plate()
+ {
+ var square = new Drawing("square", Rect(10, 10));
+ var plate = new Plate(new Size(100, 100));
+ plate.Parts.Add(new Part(Holed(), new Vector(0, 0)));
+ plate.Parts.Add(new Part(square, new Vector(20, 0)));
+ var a = new Part(square, new Vector(40, 0));
+ var b = new Part(square, new Vector(50.05, 0));
+ plate.Parts.Add(a);
+ plate.Parts.Add(b);
+ plate.Parts.Add(new Part(square, new Vector(20, 20)));
+ plate.CommonLines.Add(new CommonLineGroup(new[] { a, b }, 0.05));
+ plate.CutOffs.Add(new CutOff(new Vector(80, 0), CutOffAxis.Vertical));
+ plate.RegenerateCutOffs(new CutOffSettings());
+ return plate;
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Interaction/PlateHitTesterTests.cs b/OpenNest.Desktop.Tests/Interaction/PlateHitTesterTests.cs
new file mode 100644
index 00000000..348598bd
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/PlateHitTesterTests.cs
@@ -0,0 +1,128 @@
+using OpenNest.Desktop.Interaction;
+using OpenNest.Geometry;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+public class PlateHitTesterTests
+{
+ private readonly Plate plate = InteractionFixtures.Plate();
+ private readonly PlateHitTester hits = new();
+
+ private Part Holed => plate.Parts[0];
+
+ private Part Square => plate.Parts[1];
+
+ [Fact]
+ public void A_window_dragged_right_contains_and_dragged_left_or_vertically_intersects()
+ {
+ Assert.Equal(WindowSelectionMode.Contains, new SelectionWindow(new Vector(0, 0), new Vector(5, 5)).Mode);
+ Assert.Equal(WindowSelectionMode.Intersect, new SelectionWindow(new Vector(5, 0), new Vector(0, 5)).Mode);
+ Assert.Equal(WindowSelectionMode.Intersect, new SelectionWindow(new Vector(5, 0), new Vector(5, 9)).Mode);
+ }
+
+ [Fact]
+ public void A_segment_crossing_the_window_without_ending_in_it_intersects()
+ {
+ var window = new SelectionWindow(new Vector(4, 4), new Vector(6, 6));
+
+ Assert.True(window.Intersects(new Vector(0, 5), new Vector(10, 5)));
+ Assert.False(window.Intersects(new Vector(0, 0), new Vector(10, 1)));
+ Assert.True(window.Intersects(new Vector(0, 0), new Vector(10, 10)));
+ }
+
+ [Fact]
+ public void A_point_hits_the_material_but_not_a_hole()
+ {
+ Assert.Same(Holed, hits.PartAt(plate, new Vector(1, 1)));
+ Assert.Null(hits.PartAt(plate, new Vector(5, 5)));
+ Assert.Same(Square, hits.PartAt(plate, new Vector(25, 5)));
+ Assert.Null(hits.PartAt(plate, new Vector(15, 5)));
+ }
+
+ [Fact]
+ public void The_topmost_part_is_hit_where_parts_overlap()
+ {
+ var top = new Part(Square.BaseDrawing, new Vector(25, 5));
+ plate.Parts.Add(top);
+
+ Assert.Same(top, hits.PartAt(plate, new Vector(27, 7)));
+ Assert.Same(Square, hits.PartAt(plate, new Vector(22, 2)));
+ }
+
+ [Fact]
+ public void A_moved_part_is_hit_where_it_now_is()
+ {
+ Assert.Same(Square, hits.PartAt(plate, new Vector(25, 5)));
+
+ Square.Offset(0, 50);
+
+ Assert.Null(hits.PartAt(plate, new Vector(25, 5)));
+ Assert.Same(Square, hits.PartAt(plate, new Vector(25, 55)));
+ }
+
+ [Fact]
+ public void Cut_off_segments_are_never_hit_as_parts()
+ {
+ var segment = plate.Parts.Single(p => p.BaseDrawing.IsCutOff);
+ var box = segment.BoundingBox;
+
+ Assert.Null(hits.PartAt(plate, box.Center));
+ Assert.DoesNotContain(segment, hits.PartsIn(plate, new SelectionWindow(new Vector(-1, -1), new Vector(101, 101))));
+ }
+
+ [Fact]
+ public void A_contains_window_takes_only_parts_wholly_inside()
+ {
+ var window = new SelectionWindow(new Vector(-1, -1), new Vector(31, 11));
+
+ Assert.Equal(new[] { Holed, Square }, hits.PartsIn(plate, window));
+
+ var partial = new SelectionWindow(new Vector(-1, -1), new Vector(25, 11));
+ Assert.Equal(new[] { Holed }, hits.PartsIn(plate, partial));
+ }
+
+ [Fact]
+ public void A_contains_window_includes_a_part_touching_its_edge()
+ {
+ var window = new SelectionWindow(new Vector(20, 0), new Vector(30, 10));
+
+ Assert.Contains(Square, hits.PartsIn(plate, window));
+ }
+
+ [Fact]
+ public void An_intersect_window_takes_parts_it_touches()
+ {
+ var window = new SelectionWindow(new Vector(25, 5), new Vector(8, 8));
+
+ Assert.Equal(new[] { Holed, Square }, hits.PartsIn(plate, window));
+ }
+
+ [Fact]
+ public void An_intersect_window_inside_a_part_selects_it_but_one_inside_a_hole_does_not()
+ {
+ var inMaterial = new SelectionWindow(new Vector(27, 7), new Vector(23, 3));
+ var inHole = new SelectionWindow(new Vector(6, 6), new Vector(4, 4));
+
+ Assert.Equal(new[] { Square }, hits.PartsIn(plate, inMaterial));
+ Assert.Empty(hits.PartsIn(plate, inHole));
+ }
+
+ [Fact]
+ public void A_cut_off_is_hit_near_its_stroke_within_the_tolerance()
+ {
+ var cutOff = plate.CutOffs[0];
+
+ Assert.Same(cutOff, hits.CutOffAt(plate, new Vector(80.4, 50), 0.5));
+ Assert.Null(hits.CutOffAt(plate, new Vector(81, 50), 0.5));
+ }
+
+ [Fact]
+ public void Cut_offs_are_windowed_on_their_strokes()
+ {
+ var cutOff = plate.CutOffs[0];
+
+ Assert.Equal(new[] { cutOff }, hits.CutOffsIn(plate, new SelectionWindow(new Vector(85, 60), new Vector(75, 40))));
+ Assert.Empty(hits.CutOffsIn(plate, new SelectionWindow(new Vector(75, 40), new Vector(85, 60))));
+ Assert.Equal(new[] { cutOff }, hits.CutOffsIn(plate, new SelectionWindow(new Vector(75, -1), new Vector(85, 101))));
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Interaction/PlateSelectionTests.cs b/OpenNest.Desktop.Tests/Interaction/PlateSelectionTests.cs
new file mode 100644
index 00000000..1b797f3e
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/PlateSelectionTests.cs
@@ -0,0 +1,114 @@
+using OpenNest.Desktop.Interaction;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+public class PlateSelectionTests
+{
+ private readonly Plate plate = InteractionFixtures.Plate();
+ private readonly PlateSelection selection = new();
+ private int changes;
+
+ public PlateSelectionTests()
+ {
+ selection.Plate = plate;
+ selection.Changed += (_, _) => changes++;
+ }
+
+ [Fact]
+ public void Selecting_a_common_line_member_brings_its_group()
+ {
+ selection.Select(plate.Parts[3]);
+
+ Assert.Equal(new[] { plate.Parts[3], plate.Parts[2] }, selection.Parts);
+ Assert.Equal(1, changes);
+ }
+
+ [Fact]
+ public void Selecting_what_is_already_selected_changes_nothing()
+ {
+ selection.Select(plate.Parts[1]);
+ selection.Select(plate.Parts[1]);
+ selection.ClearCutOffs();
+
+ Assert.Single(selection.Parts);
+ Assert.Equal(1, changes);
+ }
+
+ [Fact]
+ public void Select_all_takes_every_part_but_cut_off_segments_once()
+ {
+ selection.Select(plate.Parts[1]);
+
+ selection.SelectAll();
+
+ var expected = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToList();
+ Assert.Equal(expected.Count, selection.Parts.Count);
+ Assert.All(expected, part => Assert.True(selection.Contains(part)));
+ Assert.Empty(selection.CutOffs);
+ }
+
+ [Fact]
+ public void Cut_off_segments_are_not_selected_as_parts()
+ {
+ selection.Select(plate.Parts.Single(p => p.BaseDrawing.IsCutOff));
+
+ Assert.True(selection.IsEmpty);
+ Assert.Equal(0, changes);
+ }
+
+ [Fact]
+ public void Select_only_replaces_parts_and_cut_offs()
+ {
+ selection.Select(plate.Parts[1]);
+ selection.SelectOnly(plate.CutOffs[0]);
+
+ Assert.Empty(selection.Parts);
+ Assert.Equal(new[] { plate.CutOffs[0] }, selection.CutOffs);
+
+ selection.SelectOnly(plate.Parts[0]);
+
+ Assert.Equal(new[] { plate.Parts[0] }, selection.Parts);
+ Assert.Empty(selection.CutOffs);
+ }
+
+ [Fact]
+ public void Showing_another_plate_clears_the_selection()
+ {
+ selection.SelectAll();
+ selection.Plate = InteractionFixtures.Plate();
+
+ Assert.True(selection.IsEmpty);
+ }
+
+ [Fact]
+ public void The_selection_reads_and_sets_by_part_index_in_plate_order()
+ {
+ selection.Select(plate.Parts[3]);
+ selection.Select(plate.Parts[0]);
+
+ Assert.Equal(new[] { 0, 2, 3 }, selection.PartIndices);
+
+ var copy = new PlateSelection { Plate = InteractionFixtures.Plate() };
+ copy.SelectIndices(new[] { 1, 3, 99, -1 });
+
+ Assert.Equal(new[] { 1, 2, 3 }, copy.PartIndices);
+ }
+
+ [Fact]
+ public void Prune_drops_parts_and_cut_offs_no_longer_on_the_plate()
+ {
+ selection.Select(plate.Parts[1]);
+ selection.Select(plate.Parts[0]);
+ selection.Select(plate.CutOffs[0]);
+ var removed = plate.Parts[1];
+ plate.Parts.Remove(removed);
+ plate.CutOffs.Clear();
+
+ Assert.True(selection.Prune());
+
+ Assert.False(selection.Contains(removed));
+ Assert.Equal(new[] { plate.Parts[0] }, selection.Parts);
+ Assert.Empty(selection.CutOffs);
+ Assert.False(selection.Prune());
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Interaction/PlateToolManagerTests.cs b/OpenNest.Desktop.Tests/Interaction/PlateToolManagerTests.cs
new file mode 100644
index 00000000..6077cafa
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/PlateToolManagerTests.cs
@@ -0,0 +1,187 @@
+using OpenNest.Desktop.Interaction;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+public class PlateToolManagerTests
+{
+ private readonly TestToolHost host = new(InteractionFixtures.Plate());
+
+ private PlateToolManager Tools => host.Tools;
+
+ private sealed class FakeTool : PlateTool
+ {
+ private string hint;
+
+ public FakeTool(IPlateToolHost host, bool resume = true, bool survives = false)
+ : base(host)
+ {
+ ResumeOnEscape = resume;
+ SurvivesPlateChange = survives;
+ }
+
+ public override string Name => "Fake";
+
+ public override string Hint => hint;
+
+ public override bool ResumeOnEscape { get; }
+
+ public override bool SurvivesPlateChange { get; }
+
+ public bool Busy { get; set; }
+
+ public override bool IsBusy => Busy;
+
+ public int Activations { get; private set; }
+
+ public int Deactivations { get; private set; }
+
+ public int Cancels { get; private set; }
+
+ public int PlateChanges { get; private set; }
+
+ public override void Activate() => Activations++;
+
+ public override void Deactivate() => Deactivations++;
+
+ public override void Cancel()
+ {
+ Cancels++;
+ Busy = false;
+ }
+
+ public override void OnPlateChanged() => PlateChanges++;
+
+ public void SetHint(string value)
+ {
+ hint = value;
+ RaiseHintChanged();
+ }
+ }
+
+ [Fact]
+ public void Select_is_active_from_the_start_and_shows_its_name()
+ {
+ Assert.IsType(Tools.Current);
+ Assert.Equal("Select", Tools.Status);
+ }
+
+ [Fact]
+ public void One_tool_is_active_and_its_hint_follows_its_name()
+ {
+ var statuses = new List();
+ Tools.StatusChanged += (_, _) => statuses.Add(Tools.Status);
+ var tool = new FakeTool(host);
+
+ Tools.SetTool(tool);
+ tool.SetHint("pick a point");
+
+ Assert.Same(tool, Tools.Current);
+ Assert.Equal(1, tool.Activations);
+ Assert.Equal(new[] { "Fake", "Fake: pick a point" }, statuses);
+ }
+
+ [Fact]
+ public void Switching_tools_cancels_and_deactivates_the_old_one()
+ {
+ var first = new FakeTool(host);
+ Tools.SetTool(first);
+ Tools.SetTool(new FakeTool(host));
+
+ Assert.Equal(1, first.Cancels);
+ Assert.Equal(1, first.Deactivations);
+
+ first.SetHint("stale");
+ Assert.Equal("Fake", Tools.Status);
+ }
+
+ [Fact]
+ public void Escape_cancels_a_busy_tool_before_ending_it()
+ {
+ var tool = new FakeTool(host) { Busy = true };
+ Tools.SetTool(tool);
+
+ Tools.Escape();
+ Assert.Same(tool, Tools.Current);
+ Assert.Equal(1, tool.Cancels);
+
+ Tools.Escape();
+ Assert.IsType(Tools.Current);
+ Assert.Equal("Select", Tools.Status);
+ }
+
+ [Fact]
+ public void Escape_from_an_idle_Select_resumes_the_tool_Escape_ended()
+ {
+ var tool = new FakeTool(host);
+ Tools.SetTool(tool);
+ Tools.Escape();
+
+ Tools.Escape();
+
+ Assert.Same(tool, Tools.Current);
+ Assert.Equal(2, tool.Activations);
+ }
+
+ [Fact]
+ public void Escape_from_Select_first_clears_the_selection_before_resuming()
+ {
+ var tool = new FakeTool(host);
+ Tools.SetTool(tool);
+ Tools.Escape();
+ host.Click(25, 5);
+
+ Tools.Escape();
+ Assert.IsType(Tools.Current);
+ Assert.True(host.Selection.IsEmpty);
+
+ Tools.Escape();
+ Assert.Same(tool, Tools.Current);
+ }
+
+ [Fact]
+ public void A_tool_that_declines_resuming_is_not_brought_back()
+ {
+ Tools.SetTool(new FakeTool(host, resume: false));
+ Tools.Escape();
+
+ Tools.Escape();
+
+ Assert.IsType(Tools.Current);
+ }
+
+ [Fact]
+ public void Ending_a_tool_explicitly_forgets_it()
+ {
+ Tools.SetTool(new FakeTool(host));
+
+ Tools.EndTool();
+ Tools.Escape();
+
+ Assert.IsType(Tools.Current);
+ }
+
+ [Fact]
+ public void Another_plate_ends_tools_that_do_not_survive_it()
+ {
+ var survivor = new FakeTool(host, survives: true);
+ Tools.SetTool(survivor);
+ Tools.OnPlateChanged();
+ Assert.Same(survivor, Tools.Current);
+ Assert.Equal(1, survivor.PlateChanges);
+
+ Tools.SetTool(new FakeTool(host));
+ Tools.OnPlateChanged();
+ Assert.IsType(Tools.Current);
+ }
+
+ [Fact]
+ public void Escape_with_nothing_selected_in_Select_stays_in_Select()
+ {
+ var before = Tools.Current;
+
+ Tools.Escape();
+
+ Assert.IsType(Tools.Current);
+ Assert.NotSame(before, Tools.Current);
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Interaction/SelectToolTests.cs b/OpenNest.Desktop.Tests/Interaction/SelectToolTests.cs
new file mode 100644
index 00000000..3aadf14b
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/SelectToolTests.cs
@@ -0,0 +1,143 @@
+using OpenNest.Desktop.Interaction;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+public class SelectToolTests
+{
+ private readonly Plate plate = InteractionFixtures.Plate();
+ private readonly TestToolHost host;
+
+ public SelectToolTests()
+ {
+ host = new TestToolHost(plate);
+ }
+
+ private PlateSelection Selection => host.Selection;
+
+ private SelectTool Tool => Assert.IsType(host.Tools.Current);
+
+ [Fact]
+ public void A_click_on_a_part_selects_it_alone()
+ {
+ host.Click(25, 5);
+ host.Click(1, 1);
+
+ Assert.Equal(new[] { plate.Parts[0] }, Selection.Parts);
+ }
+
+ [Fact]
+ public void Ctrl_click_adds_to_the_selection()
+ {
+ host.Click(25, 5);
+ host.Click(1, 1, ToolModifiers.Control);
+
+ Assert.Equal(new[] { plate.Parts[1], plate.Parts[0] }, Selection.Parts);
+ }
+
+ [Fact]
+ public void Ctrl_shift_click_replaces_the_selection_as_in_WinForms()
+ {
+ host.Click(25, 5);
+ host.Click(1, 1, ToolModifiers.Control | ToolModifiers.Shift);
+
+ Assert.Equal(new[] { plate.Parts[0] }, Selection.Parts);
+ }
+
+ [Fact]
+ public void A_click_on_a_selected_part_keeps_the_selection()
+ {
+ host.Click(25, 5);
+ host.Click(1, 1, ToolModifiers.Control);
+ host.Click(25, 5);
+
+ Assert.Equal(2, Selection.Parts.Count);
+ }
+
+ [Fact]
+ public void A_click_on_a_common_line_member_selects_its_group()
+ {
+ host.Click(45, 5);
+
+ Assert.Equal(new[] { plate.Parts[2], plate.Parts[3] }, Selection.Parts);
+ }
+
+ [Fact]
+ public void A_click_in_a_hole_or_on_empty_sheet_clears_the_selection()
+ {
+ host.Click(25, 5);
+ host.Click(5, 5);
+
+ Assert.True(Selection.IsEmpty);
+ Assert.Null(Tool.Window);
+ }
+
+ [Fact]
+ public void A_click_near_a_cut_off_selects_it_alone()
+ {
+ host.Click(25, 5);
+ host.Click(80.3, 50);
+
+ Assert.Empty(Selection.Parts);
+ Assert.Equal(new[] { plate.CutOffs[0] }, Selection.CutOffs);
+
+ host.Click(25, 5);
+ Assert.Empty(Selection.CutOffs);
+ }
+
+ [Fact]
+ public void Dragging_right_selects_what_the_window_contains()
+ {
+ host.Drag(-1, -1, 31, 11);
+
+ Assert.Equal(new[] { plate.Parts[0], plate.Parts[1] }, Selection.Parts);
+ Assert.Null(Tool.Window);
+ }
+
+ [Fact]
+ public void Dragging_left_selects_what_the_window_touches_with_common_line_groups_and_cut_offs()
+ {
+ host.Drag(85, 5, 45, 8);
+
+ Assert.Equal(new[] { plate.Parts[2], plate.Parts[3] }, Selection.Parts);
+ Assert.Equal(new[] { plate.CutOffs[0] }, Selection.CutOffs);
+ }
+
+ [Fact]
+ public void The_window_follows_the_pointer_while_dragging()
+ {
+ host.Tools.Current.PointerPressed(host.At(60, 60));
+ host.Tools.Current.PointerMoved(host.At(70, 65));
+
+ var window = Assert.IsType(Tool.Window);
+ Assert.Equal(WindowSelectionMode.Contains, window.Mode);
+ Assert.Equal(70, window.Right);
+ Assert.True(Tool.IsBusy);
+ }
+
+ [Fact]
+ public void A_right_click_on_an_unselected_part_selects_it_and_elsewhere_clears()
+ {
+ host.Click(25, 5);
+ host.Click(1, 1, ToolModifiers.Control);
+
+ host.Tools.Current.PointerPressed(host.At(25, 5, ToolButton.Right));
+ Assert.Equal(2, Selection.Parts.Count);
+
+ host.Tools.Current.PointerPressed(host.At(25, 25, ToolButton.Right));
+ Assert.Equal(new[] { plate.Parts[4] }, Selection.Parts);
+
+ host.Tools.Current.PointerPressed(host.At(70, 70, ToolButton.Right));
+ Assert.True(Selection.IsEmpty);
+ }
+
+ [Fact]
+ public void A_click_skips_parts_already_removed_from_the_selection_plate()
+ {
+ host.Click(25, 5);
+ plate.Parts.RemoveAt(1);
+
+ host.Click(1, 1, ToolModifiers.Control);
+
+ Assert.Equal(new[] { plate.Parts[0] }, Selection.Parts);
+ }
+}
diff --git a/OpenNest.Desktop.Tests/Interaction/SelectionStatusTests.cs b/OpenNest.Desktop.Tests/Interaction/SelectionStatusTests.cs
new file mode 100644
index 00000000..b9f3cfb4
--- /dev/null
+++ b/OpenNest.Desktop.Tests/Interaction/SelectionStatusTests.cs
@@ -0,0 +1,28 @@
+using System.Globalization;
+using OpenNest.Desktop.Interaction;
+
+namespace OpenNest.Desktop.Tests.Interaction;
+
+public class SelectionStatusTests
+{
+ [Fact]
+ public void One_part_shows_its_box_and_several_show_their_count_and_combined_box()
+ {
+ var previous = CultureInfo.CurrentCulture;
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ try
+ {
+ var plate = InteractionFixtures.Plate();
+
+ Assert.Equal(string.Empty, SelectionStatus.Text(Array.Empty()));
+ Assert.Equal("Selected: [20.0000, 0.0000] 10.0000 x 10.0000",
+ SelectionStatus.Text(new[] { plate.Parts[1] }));
+ Assert.Equal("Selected (2): [20.0000, 0.0000] 30.0000 x 10.0000",
+ SelectionStatus.Text(new[] { plate.Parts[1], plate.Parts[4] }));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = previous;
+ }
+ }
+}
diff --git a/OpenNest.Desktop/Interaction/PlateHitTester.cs b/OpenNest.Desktop/Interaction/PlateHitTester.cs
new file mode 100644
index 00000000..4dc83824
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/PlateHitTester.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using OpenNest.CutOffs;
+using OpenNest.Desktop.Rendering;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Desktop.Interaction;
+
+///
+/// Finds the parts and cut-offs under a point or in a selection window, by the rules of the
+/// WinForms plate view: a part is hit inside its filled material (an etch-only part inside its
+/// closed mark), even-odd so holes are not part of it; cut-offs are hit on the cut strokes they
+/// draw, not their nominal full-length line. Coordinates are plate (world) units.
+///
+///
+/// Cut-off segments are parts on the plate, but they are selected as their
+/// definitions (, ), never as
+/// parts.
+///
+public sealed class PlateHitTester
+{
+ // Degrees per chord when an arc is flattened: well under a pixel of error at working zooms.
+ private const double ArcStepDegrees = 3;
+
+ private readonly Func geometryOf;
+ private readonly Dictionary ownGeometry = new();
+ private readonly ConditionalWeakTable shapes = new();
+
+ ///
+ /// The part's current display geometry, when the view already caches it; by default the hit
+ /// tester builds and keeps its own.
+ ///
+ public PlateHitTester(Func geometryOf = null)
+ {
+ this.geometryOf = geometryOf ?? OwnGeometry;
+ }
+
+ /// The topmost part whose material holds , or null.
+ public Part PartAt(Plate plate, Vector world)
+ {
+ if (plate == null)
+ return null;
+ for (var i = plate.Parts.Count - 1; i >= 0; i--)
+ {
+ var part = plate.Parts[i];
+ if (!IsSelectable(part))
+ continue;
+ var box = part.BoundingBox;
+ if (world.X < box.Left || world.X > box.Right || world.Y < box.Bottom || world.Y > box.Top)
+ continue;
+ if (ShapeOf(part).Contains(world - part.Location))
+ return part;
+ }
+ return null;
+ }
+
+ ///
+ /// The parts selects, in plate order: in
+ /// , those whose material bounds lie inside it; in
+ /// , those whose material it touches.
+ ///
+ public IReadOnlyList PartsIn(Plate plate, SelectionWindow window)
+ {
+ var result = new List();
+ if (plate == null)
+ return result;
+ foreach (var part in plate.Parts)
+ {
+ if (!IsSelectable(part))
+ continue;
+ var shape = ShapeOf(part);
+ if (shape.Bounds == null)
+ continue;
+ var local = window.RelativeTo(part.Location);
+ var hit = window.Mode == WindowSelectionMode.Contains
+ ? local.Contains(shape.Bounds)
+ : shape.Touches(local);
+ if (hit)
+ result.Add(part);
+ }
+ return result;
+ }
+
+ ///
+ /// The first cut-off with a cut stroke within of
+ /// , or null. The WinForms view uses five pixels.
+ ///
+ public CutOff CutOffAt(Plate plate, Vector world, double tolerance)
+ {
+ if (plate?.CutOffs == null)
+ return null;
+ foreach (var cutOff in plate.CutOffs)
+ {
+ foreach (var stroke in CutOff.CutStrokes(cutOff.Drawing?.Program))
+ {
+ if (stroke.ClosestPointTo(world).DistanceTo(world) <= tolerance)
+ return cutOff;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// The cut-offs selects: in ,
+ /// those whose every cut stroke lies inside it; in ,
+ /// those with a stroke it touches. A cut-off with no strokes is never selected.
+ ///
+ public IReadOnlyList CutOffsIn(Plate plate, SelectionWindow window)
+ {
+ var result = new List();
+ if (plate?.CutOffs == null)
+ return result;
+ foreach (var cutOff in plate.CutOffs)
+ {
+ var any = false;
+ var matches = window.Mode == WindowSelectionMode.Contains;
+ foreach (var stroke in CutOff.CutStrokes(cutOff.Drawing?.Program))
+ {
+ any = true;
+ if (window.Mode == WindowSelectionMode.Contains)
+ {
+ if (!window.Contains(stroke.StartPoint) || !window.Contains(stroke.EndPoint))
+ {
+ matches = false;
+ break;
+ }
+ }
+ else if (window.Intersects(stroke.StartPoint, stroke.EndPoint))
+ {
+ matches = true;
+ break;
+ }
+ }
+ if (any && matches)
+ result.Add(cutOff);
+ }
+ return result;
+ }
+
+ /// Whether is selected as a part: everything but cut-off segments.
+ public static bool IsSelectable(Part part) => part?.BaseDrawing is { IsCutOff: false };
+
+ private PartDisplayGeometry OwnGeometry(Part part)
+ {
+ if (!ownGeometry.TryGetValue(part, out var geometry) || !geometry.IsCurrentFor(part))
+ ownGeometry[part] = geometry = PartDisplayGeometry.Build(part);
+ return geometry;
+ }
+
+ private HitShape ShapeOf(Part part) => shapes.GetValue(geometryOf(part), HitShape.Build);
+
+ /// Drops the parts no longer on from the hit tester's own cache.
+ public void Trim(Plate plate)
+ {
+ if (ownGeometry.Count == 0)
+ return;
+ var shown = plate == null ? new HashSet() : new HashSet(plate.Parts);
+ var stale = new List();
+ foreach (var part in ownGeometry.Keys)
+ if (!shown.Contains(part))
+ stale.Add(part);
+ foreach (var part in stale)
+ ownGeometry.Remove(part);
+ }
+
+ /// A part's selection figures flattened to closed rings in its program frame.
+ private sealed class HitShape
+ {
+ private readonly List rings;
+
+ private HitShape(List rings, Box bounds)
+ {
+ this.rings = rings;
+ Bounds = bounds;
+ }
+
+ public Box Bounds { get; }
+
+ public static HitShape Build(PartDisplayGeometry geometry)
+ {
+ var figures = geometry.Selection;
+ var rings = new List();
+ foreach (var figure in figures)
+ {
+ var ring = Flatten(figure);
+ if (ring.Count >= 2)
+ rings.Add(ring.ToArray());
+ }
+ return new HitShape(rings, ProgramGeometry.Bounds(figures));
+ }
+
+ private static List Flatten(PathFigure figure)
+ {
+ var points = new List { figure.Start };
+ foreach (var segment in figure.Segments)
+ {
+ if (segment is ArcTo arc)
+ {
+ var steps = System.Math.Max(2, (int)System.Math.Ceiling(System.Math.Abs(arc.SweepAngle) / ArcStepDegrees));
+ for (var i = 0; i <= steps; i++)
+ {
+ var radians = Angle.ToRadians(arc.StartAngle + arc.SweepAngle * i / steps);
+ points.Add(new Vector(arc.Center.X + arc.Radius * System.Math.Cos(radians),
+ arc.Center.Y + arc.Radius * System.Math.Sin(radians)));
+ }
+ }
+ else
+ {
+ points.Add(segment.End);
+ }
+ }
+ return points;
+ }
+
+ /// Even-odd containment over every ring, each closed back to its start as a fill closes it.
+ public bool Contains(Vector point)
+ {
+ var inside = false;
+ foreach (var ring in rings)
+ {
+ for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
+ {
+ var a = ring[i];
+ var b = ring[j];
+ if ((a.Y > point.Y) != (b.Y > point.Y)
+ && point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
+ inside = !inside;
+ }
+ }
+ return inside;
+ }
+
+ /// Whether the window touches the filled region: crosses an edge or lies within the fill.
+ public bool Touches(SelectionWindow window)
+ {
+ if (!window.Overlaps(Bounds))
+ return false;
+ foreach (var ring in rings)
+ {
+ for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
+ {
+ if (window.Intersects(ring[j], ring[i]))
+ return true;
+ }
+ }
+ // No edge enters the window, so it lies wholly inside or wholly outside the fill.
+ return Contains(new Vector((window.Left + window.Right) / 2, (window.Bottom + window.Top) / 2));
+ }
+ }
+}
diff --git a/OpenNest.Desktop/Interaction/PlateSelection.cs b/OpenNest.Desktop/Interaction/PlateSelection.cs
new file mode 100644
index 00000000..ea757eeb
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/PlateSelection.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.CutOffs;
+
+namespace OpenNest.Desktop.Interaction;
+
+///
+/// The parts and cut-offs selected on one plate view. Selecting a common-line member brings its
+/// whole group, so the joined parts move, rotate and push as one unit and keep their shared spans
+/// (see docs/common-line-cutting.md). Cut-off segments are never selected as parts; cut-offs are
+/// selected as their definitions. is raised once per call that changes it.
+///
+public sealed class PlateSelection
+{
+ private readonly List parts = new();
+ private readonly HashSet partSet = new(ReferenceEqualityComparer.Instance);
+ private readonly List cutOffs = new();
+ private Plate plate;
+
+ /// The plate selected on; changing it clears the selection.
+ public Plate Plate
+ {
+ get => plate;
+ set
+ {
+ if (ReferenceEquals(plate, value))
+ return;
+ plate = value;
+ Clear();
+ }
+ }
+
+ /// The selected parts, in the order they were selected.
+ public IReadOnlyList Parts => parts;
+
+ /// The selected cut-off definitions.
+ public IReadOnlyList CutOffs => cutOffs;
+
+ public bool IsEmpty => parts.Count == 0 && cutOffs.Count == 0;
+
+ public event EventHandler Changed;
+
+ public bool Contains(Part part) => part != null && partSet.Contains(part);
+
+ public bool Contains(CutOff cutOff) => cutOffs.Any(c => ReferenceEquals(c, cutOff));
+
+ /// Deselects every part and cut-off.
+ public void Clear() => Change(() => ClearPartsCore() | ClearCutOffsCore());
+
+ public void ClearParts() => Change(ClearPartsCore);
+
+ public void ClearCutOffs() => Change(ClearCutOffsCore);
+
+ /// Adds and the rest of its common-line group.
+ public void Select(Part part) => Change(() => AddCore(part));
+
+ /// Adds each of with the rest of its common-line group.
+ public void Select(IEnumerable toSelect) =>
+ Change(() => toSelect.Aggregate(false, (changed, part) => AddCore(part) | changed));
+
+ /// Replaces the selection with and its common-line group.
+ public void SelectOnly(Part part) => Change(() => ClearPartsCore() | ClearCutOffsCore() | AddCore(part));
+
+ /// Adds the cut-off definition .
+ public void Select(CutOff cutOff) => Change(() => AddCore(cutOff));
+
+ /// Adds each of .
+ public void Select(IEnumerable toSelect) =>
+ Change(() => toSelect.Aggregate(false, (changed, cutOff) => AddCore(cutOff) | changed));
+
+ /// Replaces the selection with the cut-off definition .
+ public void SelectOnly(CutOff cutOff) => Change(() => ClearPartsCore() | ClearCutOffsCore() | AddCore(cutOff));
+
+ /// Selects every part on the plate (cut-off segments excepted), keeping what is selected.
+ public void SelectAll()
+ {
+ if (plate != null)
+ Select(plate.Parts.ToList());
+ }
+
+ ///
+ /// The selected parts as indices into 's parts, in plate order; parts no
+ /// longer on the plate are left out. With , keeps a selection across
+ /// an edit that replaces the plate's parts (an undo, say).
+ ///
+ public IReadOnlyList PartIndices
+ {
+ get
+ {
+ var indices = new List();
+ if (plate == null || parts.Count == 0)
+ return indices;
+ for (var i = 0; i < plate.Parts.Count; i++)
+ if (partSet.Contains(plate.Parts[i]))
+ indices.Add(i);
+ return indices;
+ }
+ }
+
+ ///
+ /// Replaces the selection with the parts at in 's
+ /// parts (each with its common-line group); indices off the plate are skipped.
+ ///
+ public void SelectIndices(IEnumerable indices) =>
+ Change(() =>
+ {
+ var changed = ClearPartsCore() | ClearCutOffsCore();
+ if (plate == null || indices == null)
+ return changed;
+ foreach (var i in indices)
+ if (i >= 0 && i < plate.Parts.Count)
+ changed |= AddCore(plate.Parts[i]);
+ return changed;
+ });
+
+ ///
+ /// Drops parts and cut-offs that are no longer on the plate, as after an edit that replaced
+ /// them; true when any were dropped.
+ ///
+ public bool Prune()
+ {
+ if (IsEmpty)
+ return false;
+ var onPlate = plate == null ? new HashSet() : new HashSet(plate.Parts);
+ partSet.RemoveWhere(part => !onPlate.Contains(part));
+ var removed = parts.RemoveAll(part => !onPlate.Contains(part))
+ + cutOffs.RemoveAll(cutOff => plate?.CutOffs.Contains(cutOff) != true);
+ if (removed > 0)
+ Changed?.Invoke(this, EventArgs.Empty);
+ return removed > 0;
+ }
+
+ ///
+ /// followed by the other members of its common-line group on
+ /// , in plate order.
+ ///
+ public static IEnumerable WithCommonLineGroup(Plate plate, Part part)
+ {
+ yield return part;
+ var group = plate?.CommonLineGroupOf(part);
+ if (group == null)
+ yield break;
+ foreach (var other in plate.Parts)
+ {
+ if (!ReferenceEquals(other, part) && group.Contains(other))
+ yield return other;
+ }
+ }
+
+ private bool AddCore(Part part)
+ {
+ if (!PlateHitTester.IsSelectable(part))
+ return false;
+ var changed = false;
+ foreach (var member in WithCommonLineGroup(plate, part))
+ {
+ if (!partSet.Add(member))
+ continue;
+ parts.Add(member);
+ changed = true;
+ }
+ return changed;
+ }
+
+ private bool AddCore(CutOff cutOff)
+ {
+ if (cutOff == null || Contains(cutOff))
+ return false;
+ cutOffs.Add(cutOff);
+ return true;
+ }
+
+ private bool ClearPartsCore()
+ {
+ if (parts.Count == 0)
+ return false;
+ parts.Clear();
+ partSet.Clear();
+ return true;
+ }
+
+ private bool ClearCutOffsCore()
+ {
+ if (cutOffs.Count == 0)
+ return false;
+ cutOffs.Clear();
+ return true;
+ }
+
+ private void Change(Func change)
+ {
+ if (change())
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/OpenNest.Desktop/Interaction/PlateTool.cs b/OpenNest.Desktop/Interaction/PlateTool.cs
new file mode 100644
index 00000000..87be86d2
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/PlateTool.cs
@@ -0,0 +1,103 @@
+using System;
+using OpenNest.Desktop.Rendering;
+using OpenNest.Geometry;
+
+namespace OpenNest.Desktop.Interaction;
+
+/// The mouse button behind a pointer event.
+public enum ToolButton
+{
+ None,
+ Left,
+ Middle,
+ Right,
+}
+
+/// Modifier keys held during a pointer event.
+[Flags]
+public enum ToolModifiers
+{
+ None = 0,
+ Shift = 1,
+ Control = 2,
+ Alt = 4,
+}
+
+///
+/// A pointer event as a plate tool sees it: where it is in plate coordinates and on screen, the
+/// button pressed or released (for a move, the button held), and the modifier keys.
+///
+public readonly record struct ToolPointer(Vector World, ScreenPoint Screen, ToolButton Button, ToolModifiers Modifiers);
+
+/// What a plate tool works on: the view that hosts it, in either desktop app.
+public interface IPlateToolHost
+{
+ /// The plate shown, or null.
+ Plate Plate { get; }
+
+ PlateSelection Selection { get; }
+
+ PlateHitTester HitTester { get; }
+
+ /// The view's world/screen mapping, for tolerances given in pixels.
+ ViewTransform View { get; }
+
+ /// Requests a redraw.
+ void Invalidate();
+}
+
+///
+/// One interactive plate-view tool, toolkit-neutral: the view forwards pointer events to the
+/// active tool and draws what it exposes. Exactly one tool is active at a time; see
+/// .
+///
+public abstract class PlateTool
+{
+ protected PlateTool(IPlateToolHost host)
+ {
+ Host = host ?? throw new ArgumentNullException(nameof(host));
+ }
+
+ protected IPlateToolHost Host { get; }
+
+ /// The name shown in the status bar.
+ public abstract string Name { get; }
+
+ /// Status bar guidance shown after the name, or null for the name alone.
+ public virtual string Hint => null;
+
+ /// Whether the tool is mid-task, so Escape cancels that task rather than ending the tool.
+ public abstract bool IsBusy { get; }
+
+ /// Whether Escape from Select may bring this tool back after it ends.
+ public virtual bool ResumeOnEscape => true;
+
+ /// Whether the tool stays active when the view shows another plate.
+ public virtual bool SurvivesPlateChange => false;
+
+ /// Raised when changes.
+ public event EventHandler HintChanged;
+
+ /// Called when the tool becomes active (again, when Escape resumes it).
+ public virtual void Activate() { }
+
+ /// Called when the tool stops being active, after .
+ public virtual void Deactivate() { }
+
+ /// Abandons the task in progress.
+ public abstract void Cancel();
+
+ /// Called when a tool that survives plate changes sees another plate.
+ public virtual void OnPlateChanged() { }
+
+ /// Whether the tool used the event.
+ public virtual bool PointerPressed(ToolPointer pointer) => false;
+
+ /// Whether the tool used the event.
+ public virtual bool PointerMoved(ToolPointer pointer) => false;
+
+ /// Whether the tool used the event.
+ public virtual bool PointerReleased(ToolPointer pointer) => false;
+
+ protected void RaiseHintChanged() => HintChanged?.Invoke(this, EventArgs.Empty);
+}
diff --git a/OpenNest.Desktop/Interaction/PlateToolManager.cs b/OpenNest.Desktop/Interaction/PlateToolManager.cs
new file mode 100644
index 00000000..1d73688f
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/PlateToolManager.cs
@@ -0,0 +1,110 @@
+using System;
+
+namespace OpenNest.Desktop.Interaction;
+
+///
+/// Keeps one plate tool active, as the WinForms view's action manager does: Select by default;
+/// Escape first cancels what the active tool is doing, then ends the tool and returns to Select,
+/// and an Escape from an idle Select brings back the tool Escape ended (when that tool allows it).
+/// is the active tool's name and hint for the status bar.
+///
+public sealed class PlateToolManager
+{
+ private readonly IPlateToolHost host;
+ private PlateTool previous;
+
+ public PlateToolManager(IPlateToolHost host)
+ {
+ this.host = host ?? throw new ArgumentNullException(nameof(host));
+ Current = new SelectTool(host);
+ Current.HintChanged += OnHintChanged;
+ Current.Activate();
+ Status = StatusOf(Current);
+ }
+
+ /// The active tool.
+ public PlateTool Current { get; private set; }
+
+ /// The active tool's name, and its hint when it has one.
+ public string Status { get; private set; }
+
+ /// Raised when the active tool or its status changes.
+ public event EventHandler StatusChanged;
+
+ ///
+ /// Makes active, ending the current one. Ending another tool for
+ /// Select keeps it for Escape to resume; any other switch forgets the resumable tool.
+ ///
+ public void SetTool(PlateTool tool)
+ {
+ ArgumentNullException.ThrowIfNull(tool);
+ previous = tool is SelectTool && Current is not SelectTool && Current.ResumeOnEscape ? Current : null;
+ Switch(tool);
+ }
+
+ /// Returns to Select (as a fresh tool, so nothing selected is kept).
+ public void Select() => SetTool(new SelectTool(host));
+
+ ///
+ /// Ends the active tool at the user's explicit request (a panel's Close button, say). Unlike
+ /// Escape, the ended tool is not kept for Escape to resume.
+ ///
+ public void EndTool()
+ {
+ Select();
+ previous = null;
+ }
+
+ /// Handles Escape; see the class summary.
+ public void Escape()
+ {
+ if (Current.IsBusy)
+ Current.Cancel();
+ else if (Current is SelectTool && previous != null)
+ {
+ var resumed = previous;
+ previous = null;
+ Switch(resumed);
+ }
+ else
+ Select();
+ host.Invalidate();
+ }
+
+ /// Called when the host shows another plate: tools that do not survive it end.
+ public void OnPlateChanged()
+ {
+ if (!Current.SurvivesPlateChange)
+ Select();
+ else
+ Current.OnPlateChanged();
+ }
+
+ private void Switch(PlateTool tool)
+ {
+ var old = Current;
+ old.Cancel();
+ old.Deactivate();
+ old.HintChanged -= OnHintChanged;
+ Current = tool;
+ tool.HintChanged += OnHintChanged;
+ tool.Activate();
+ RefreshStatus();
+ host.Invalidate();
+ }
+
+ private void OnHintChanged(object sender, EventArgs e)
+ {
+ if (ReferenceEquals(sender, Current))
+ RefreshStatus();
+ }
+
+ private void RefreshStatus()
+ {
+ Status = StatusOf(Current);
+ StatusChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private static string StatusOf(PlateTool tool) =>
+ string.IsNullOrEmpty(tool.Hint) ? tool.Name : $"{tool.Name}: {tool.Hint}";
+}
diff --git a/OpenNest.Desktop/Interaction/SelectTool.cs b/OpenNest.Desktop/Interaction/SelectTool.cs
new file mode 100644
index 00000000..0e4f5b2e
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/SelectTool.cs
@@ -0,0 +1,118 @@
+namespace OpenNest.Desktop.Interaction;
+
+///
+/// The default plate tool, with the WinForms Select action's selection rules:
+///
+/// - 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.
+/// - 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.
+///
+public sealed class SelectTool : PlateTool
+{
+ /// How near, in pixels, a click must be to a cut-off's stroke to pick it.
+ public const double CutOffPickPixels = 5;
+
+ public SelectTool(IPlateToolHost host)
+ : base(host)
+ {
+ }
+
+ public override string Name => "Select";
+
+ /// The window being dragged, or null.
+ public SelectionWindow? Window { get; private set; }
+
+ /// 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()
+ {
+ Window = null;
+ Host.Selection.Clear();
+ Host.Invalidate();
+ }
+
+ public override bool PointerPressed(ToolPointer pointer)
+ {
+ var plate = Host.Plate;
+ if (plate == null)
+ return false;
+ var selection = Host.Selection;
+ selection.Prune();
+
+ if (pointer.Button == ToolButton.Left)
+ {
+ Window = null;
+ var tolerance = Host.View.LengthToWorld(CutOffPickPixels);
+ var cutOff = Host.HitTester.CutOffAt(plate, pointer.World, tolerance);
+ if (cutOff != null)
+ {
+ selection.SelectOnly(cutOff);
+ Host.Invalidate();
+ return true;
+ }
+ selection.ClearCutOffs();
+
+ var part = Host.HitTester.PartAt(plate, pointer.World);
+ if (part == null)
+ {
+ selection.Clear();
+ Window = new SelectionWindow(pointer.World, pointer.World);
+ }
+ else
+ {
+ if (pointer.Modifiers != ToolModifiers.Control && !selection.Contains(part))
+ selection.Clear();
+ selection.Select(part);
+ }
+ Host.Invalidate();
+ return true;
+ }
+
+ if (pointer.Button == ToolButton.Right)
+ {
+ var part = Host.HitTester.PartAt(plate, pointer.World);
+ if (part == null)
+ Cancel();
+ else if (!selection.Contains(part))
+ {
+ selection.SelectOnly(part);
+ Host.Invalidate();
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ public override bool PointerMoved(ToolPointer pointer)
+ {
+ if (Window is not { } window || pointer.Button != ToolButton.Left)
+ return false;
+ Window = window with { Second = pointer.World };
+ Host.Invalidate();
+ return true;
+ }
+
+ public override bool PointerReleased(ToolPointer pointer)
+ {
+ if (Window is not { } window || pointer.Button != ToolButton.Left)
+ return false;
+ window = window with { Second = pointer.World };
+ Window = null;
+ var plate = Host.Plate;
+ if (plate != null)
+ {
+ Host.Selection.Select(Host.HitTester.PartsIn(plate, window));
+ Host.Selection.Select(Host.HitTester.CutOffsIn(plate, window));
+ }
+ Host.Invalidate();
+ return true;
+ }
+}
diff --git a/OpenNest.Desktop/Interaction/SelectionStatus.cs b/OpenNest.Desktop/Interaction/SelectionStatus.cs
new file mode 100644
index 00000000..1b7d8112
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/SelectionStatus.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Geometry;
+
+namespace OpenNest.Desktop.Interaction;
+
+/// The status bar's selection text, worded as the WinForms main window words it.
+public static class SelectionStatus
+{
+ ///
+ /// "Selected: [x, y] w x l" for one part, "Selected (n): [x, y] w x l" around several, or an
+ /// empty string when no part is selected. Numbers are in the current culture, to four places.
+ ///
+ public static string Text(IReadOnlyCollection parts)
+ {
+ if (parts == null || parts.Count == 0)
+ return string.Empty;
+ var box = parts.Count == 1 ? parts.First().BoundingBox : parts.ToList().GetBoundingBox();
+ var where = $"[{box.X:n4}, {box.Y:n4}] {box.Width:n4} x {box.Length:n4}";
+ return parts.Count == 1 ? $"Selected: {where}" : $"Selected ({parts.Count}): {where}";
+ }
+}
diff --git a/OpenNest.Desktop/Interaction/SelectionWindow.cs b/OpenNest.Desktop/Interaction/SelectionWindow.cs
new file mode 100644
index 00000000..3dc51a33
--- /dev/null
+++ b/OpenNest.Desktop/Interaction/SelectionWindow.cs
@@ -0,0 +1,88 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Desktop.Interaction;
+
+/// How a dragged selection window picks parts and cut-offs.
+public enum WindowSelectionMode
+{
+ /// Anything the window touches: dragged from right to left.
+ Intersect,
+
+ /// Only what lies wholly inside the window: dragged from left to right.
+ Contains,
+}
+
+///
+/// A selection window between the point a drag started and where it is now, in plate (world)
+/// coordinates. As in the WinForms view, dragging to the right selects what the window contains
+/// and dragging to the left (or straight up or down) selects what it touches.
+///
+public readonly record struct SelectionWindow(Vector First, Vector Second)
+{
+ public WindowSelectionMode Mode =>
+ First.X < Second.X ? WindowSelectionMode.Contains : WindowSelectionMode.Intersect;
+
+ public double Left => System.Math.Min(First.X, Second.X);
+
+ public double Right => System.Math.Max(First.X, Second.X);
+
+ public double Bottom => System.Math.Min(First.Y, Second.Y);
+
+ public double Top => System.Math.Max(First.Y, Second.Y);
+
+ /// The window as a box (a fresh copy each time).
+ public Box Box => new(Left, Bottom, Right - Left, Top - Bottom);
+
+ /// Whether lies in the window, edges included.
+ public bool Contains(Vector point) =>
+ point.X >= Left && point.X <= Right && point.Y >= Bottom && point.Y <= Top;
+
+ /// Whether lies in the window, edges included.
+ public bool Contains(Box box) =>
+ box.Left >= Left && box.Right <= Right && box.Bottom >= Bottom && box.Top <= Top;
+
+ /// Whether and the window overlap, touching edges included.
+ public bool Overlaps(Box box) =>
+ box.Left <= Right && box.Right >= Left && box.Bottom <= Top && box.Top >= Bottom;
+
+ /// Whether the segment from to enters the window.
+ public bool Intersects(Vector start, Vector end)
+ {
+ if (Contains(start) || Contains(end))
+ return true;
+
+ // Clip the segment against all four edges (Liang-Barsky): a segment can cross the window
+ // without ending inside it.
+ var min = 0.0;
+ var max = 1.0;
+ var dx = end.X - start.X;
+ var dy = end.Y - start.Y;
+ return Clip(-dx, start.X - Left, ref min, ref max)
+ && Clip(dx, Right - start.X, ref min, ref max)
+ && Clip(-dy, start.Y - Bottom, ref min, ref max)
+ && Clip(dy, Top - start.Y, ref min, ref max);
+ }
+
+ /// The window in the frame whose origin is at , such as a placed part's program frame.
+ public SelectionWindow RelativeTo(Vector origin) => new(First - origin, Second - origin);
+
+ private static bool Clip(double p, double q, ref double min, ref double max)
+ {
+ if (p == 0)
+ return q >= 0;
+ var t = q / p;
+ if (p < 0)
+ {
+ if (t > max)
+ return false;
+ min = System.Math.Max(min, t);
+ }
+ else
+ {
+ if (t < min)
+ return false;
+ max = System.Math.Min(max, t);
+ }
+ return min <= max;
+ }
+}