From 313b5f57d602d974fb08f1f1bd9f98637bbef26c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Nov 2026 19:30:47 -0500 Subject: [PATCH] feat(desktop): add a toolkit-neutral plate tool and selection model PlateToolManager keeps one active tool with the WinForms action rules (Escape cancels, then returns to Select, then resumes the ended tool) and status text. SelectTool selects by click, Ctrl+click and window (contains/intersect), takes common-line groups together and picks cut-offs by their strokes. PlateHitTester hit-tests the shared display geometry; PlateSelection reads and sets by part index. Co-Authored-By: Claude Opus 5.5 --- .../Interaction/InteractionFixtures.cs | 101 +++++++ .../Interaction/PlateHitTesterTests.cs | 128 +++++++++ .../Interaction/PlateSelectionTests.cs | 114 ++++++++ .../Interaction/PlateToolManagerTests.cs | 187 +++++++++++++ .../Interaction/SelectToolTests.cs | 143 ++++++++++ .../Interaction/SelectionStatusTests.cs | 28 ++ .../Interaction/PlateHitTester.cs | 252 ++++++++++++++++++ .../Interaction/PlateSelection.cs | 196 ++++++++++++++ OpenNest.Desktop/Interaction/PlateTool.cs | 103 +++++++ .../Interaction/PlateToolManager.cs | 110 ++++++++ OpenNest.Desktop/Interaction/SelectTool.cs | 118 ++++++++ .../Interaction/SelectionStatus.cs | 22 ++ .../Interaction/SelectionWindow.cs | 88 ++++++ 13 files changed, 1590 insertions(+) create mode 100644 OpenNest.Desktop.Tests/Interaction/InteractionFixtures.cs create mode 100644 OpenNest.Desktop.Tests/Interaction/PlateHitTesterTests.cs create mode 100644 OpenNest.Desktop.Tests/Interaction/PlateSelectionTests.cs create mode 100644 OpenNest.Desktop.Tests/Interaction/PlateToolManagerTests.cs create mode 100644 OpenNest.Desktop.Tests/Interaction/SelectToolTests.cs create mode 100644 OpenNest.Desktop.Tests/Interaction/SelectionStatusTests.cs create mode 100644 OpenNest.Desktop/Interaction/PlateHitTester.cs create mode 100644 OpenNest.Desktop/Interaction/PlateSelection.cs create mode 100644 OpenNest.Desktop/Interaction/PlateTool.cs create mode 100644 OpenNest.Desktop/Interaction/PlateToolManager.cs create mode 100644 OpenNest.Desktop/Interaction/SelectTool.cs create mode 100644 OpenNest.Desktop/Interaction/SelectionStatus.cs create mode 100644 OpenNest.Desktop/Interaction/SelectionWindow.cs 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; + } +}