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