mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 17:57:06 -05:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
09357ca95d
commit
313b5f57d6
13 files changed
+1590
No files matched your search
@@ -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;
|
||||
|
||||
/// <summary>A plate view without a toolkit: the tools' host in tests.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A 10x10 square with a 3-unit-radius round hole at its centre.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<SelectTool>(Tools.Current);
|
||||
Assert.Equal("Select", Tools.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void One_tool_is_active_and_its_hint_follows_its_name()
|
||||
{
|
||||
var statuses = new List<string>();
|
||||
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<SelectTool>(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<SelectTool>(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<SelectTool>(Tools.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ending_a_tool_explicitly_forgets_it()
|
||||
{
|
||||
Tools.SetTool(new FakeTool(host));
|
||||
|
||||
Tools.EndTool();
|
||||
Tools.Escape();
|
||||
|
||||
Assert.IsType<SelectTool>(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<SelectTool>(Tools.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Escape_with_nothing_selected_in_Select_stays_in_Select()
|
||||
{
|
||||
var before = Tools.Current;
|
||||
|
||||
Tools.Escape();
|
||||
|
||||
Assert.IsType<SelectTool>(Tools.Current);
|
||||
Assert.NotSame(before, Tools.Current);
|
||||
}
|
||||
}
|
||||
@@ -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<SelectTool>(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<SelectionWindow>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<Part>()));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cut-off segments are parts on the plate, but they are selected as their
|
||||
/// <see cref="CutOff"/> definitions (<see cref="CutOffAt"/>, <see cref="CutOffsIn"/>), never as
|
||||
/// parts.
|
||||
/// </remarks>
|
||||
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<Part, PartDisplayGeometry> geometryOf;
|
||||
private readonly Dictionary<Part, PartDisplayGeometry> ownGeometry = new();
|
||||
private readonly ConditionalWeakTable<PartDisplayGeometry, HitShape> shapes = new();
|
||||
|
||||
/// <param name="geometryOf">
|
||||
/// The part's current display geometry, when the view already caches it; by default the hit
|
||||
/// tester builds and keeps its own.
|
||||
/// </param>
|
||||
public PlateHitTester(Func<Part, PartDisplayGeometry> geometryOf = null)
|
||||
{
|
||||
this.geometryOf = geometryOf ?? OwnGeometry;
|
||||
}
|
||||
|
||||
/// <summary>The topmost part whose material holds <paramref name="world"/>, or null.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The parts <paramref name="window"/> selects, in plate order: in
|
||||
/// <see cref="WindowSelectionMode.Contains"/>, those whose material bounds lie inside it; in
|
||||
/// <see cref="WindowSelectionMode.Intersect"/>, those whose material it touches.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Part> PartsIn(Plate plate, SelectionWindow window)
|
||||
{
|
||||
var result = new List<Part>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first cut-off with a cut stroke within <paramref name="tolerance"/> of
|
||||
/// <paramref name="world"/>, or null. The WinForms view uses five pixels.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cut-offs <paramref name="window"/> selects: in <see cref="WindowSelectionMode.Contains"/>,
|
||||
/// those whose every cut stroke lies inside it; in <see cref="WindowSelectionMode.Intersect"/>,
|
||||
/// those with a stroke it touches. A cut-off with no strokes is never selected.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CutOff> CutOffsIn(Plate plate, SelectionWindow window)
|
||||
{
|
||||
var result = new List<CutOff>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="part"/> is selected as a part: everything but cut-off segments.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>Drops the parts no longer on <paramref name="plate"/> from the hit tester's own cache.</summary>
|
||||
public void Trim(Plate plate)
|
||||
{
|
||||
if (ownGeometry.Count == 0)
|
||||
return;
|
||||
var shown = plate == null ? new HashSet<Part>() : new HashSet<Part>(plate.Parts);
|
||||
var stale = new List<Part>();
|
||||
foreach (var part in ownGeometry.Keys)
|
||||
if (!shown.Contains(part))
|
||||
stale.Add(part);
|
||||
foreach (var part in stale)
|
||||
ownGeometry.Remove(part);
|
||||
}
|
||||
|
||||
/// <summary>A part's selection figures flattened to closed rings in its program frame.</summary>
|
||||
private sealed class HitShape
|
||||
{
|
||||
private readonly List<Vector[]> rings;
|
||||
|
||||
private HitShape(List<Vector[]> 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<Vector[]>();
|
||||
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<Vector> Flatten(PathFigure figure)
|
||||
{
|
||||
var points = new List<Vector> { 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;
|
||||
}
|
||||
|
||||
/// <summary>Even-odd containment over every ring, each closed back to its start as a fill closes it.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Whether the window touches the filled region: crosses an edge or lies within the fill.</summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CutOffs;
|
||||
|
||||
namespace OpenNest.Desktop.Interaction;
|
||||
|
||||
/// <summary>
|
||||
/// 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. <see cref="Changed"/> is raised once per call that changes it.
|
||||
/// </summary>
|
||||
public sealed class PlateSelection
|
||||
{
|
||||
private readonly List<Part> parts = new();
|
||||
private readonly HashSet<Part> partSet = new(ReferenceEqualityComparer.Instance);
|
||||
private readonly List<CutOff> cutOffs = new();
|
||||
private Plate plate;
|
||||
|
||||
/// <summary>The plate selected on; changing it clears the selection.</summary>
|
||||
public Plate Plate
|
||||
{
|
||||
get => plate;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(plate, value))
|
||||
return;
|
||||
plate = value;
|
||||
Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The selected parts, in the order they were selected.</summary>
|
||||
public IReadOnlyList<Part> Parts => parts;
|
||||
|
||||
/// <summary>The selected cut-off definitions.</summary>
|
||||
public IReadOnlyList<CutOff> 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));
|
||||
|
||||
/// <summary>Deselects every part and cut-off.</summary>
|
||||
public void Clear() => Change(() => ClearPartsCore() | ClearCutOffsCore());
|
||||
|
||||
public void ClearParts() => Change(ClearPartsCore);
|
||||
|
||||
public void ClearCutOffs() => Change(ClearCutOffsCore);
|
||||
|
||||
/// <summary>Adds <paramref name="part"/> and the rest of its common-line group.</summary>
|
||||
public void Select(Part part) => Change(() => AddCore(part));
|
||||
|
||||
/// <summary>Adds each of <paramref name="toSelect"/> with the rest of its common-line group.</summary>
|
||||
public void Select(IEnumerable<Part> toSelect) =>
|
||||
Change(() => toSelect.Aggregate(false, (changed, part) => AddCore(part) | changed));
|
||||
|
||||
/// <summary>Replaces the selection with <paramref name="part"/> and its common-line group.</summary>
|
||||
public void SelectOnly(Part part) => Change(() => ClearPartsCore() | ClearCutOffsCore() | AddCore(part));
|
||||
|
||||
/// <summary>Adds the cut-off definition <paramref name="cutOff"/>.</summary>
|
||||
public void Select(CutOff cutOff) => Change(() => AddCore(cutOff));
|
||||
|
||||
/// <summary>Adds each of <paramref name="toSelect"/>.</summary>
|
||||
public void Select(IEnumerable<CutOff> toSelect) =>
|
||||
Change(() => toSelect.Aggregate(false, (changed, cutOff) => AddCore(cutOff) | changed));
|
||||
|
||||
/// <summary>Replaces the selection with the cut-off definition <paramref name="cutOff"/>.</summary>
|
||||
public void SelectOnly(CutOff cutOff) => Change(() => ClearPartsCore() | ClearCutOffsCore() | AddCore(cutOff));
|
||||
|
||||
/// <summary>Selects every part on the plate (cut-off segments excepted), keeping what is selected.</summary>
|
||||
public void SelectAll()
|
||||
{
|
||||
if (plate != null)
|
||||
Select(plate.Parts.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The selected parts as indices into <see cref="Plate"/>'s parts, in plate order; parts no
|
||||
/// longer on the plate are left out. With <see cref="SelectIndices"/>, keeps a selection across
|
||||
/// an edit that replaces the plate's parts (an undo, say).
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> PartIndices
|
||||
{
|
||||
get
|
||||
{
|
||||
var indices = new List<int>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the selection with the parts at <paramref name="indices"/> in <see cref="Plate"/>'s
|
||||
/// parts (each with its common-line group); indices off the plate are skipped.
|
||||
/// </summary>
|
||||
public void SelectIndices(IEnumerable<int> 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;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Drops parts and cut-offs that are no longer on the plate, as after an edit that replaced
|
||||
/// them; true when any were dropped.
|
||||
/// </summary>
|
||||
public bool Prune()
|
||||
{
|
||||
if (IsEmpty)
|
||||
return false;
|
||||
var onPlate = plate == null ? new HashSet<Part>() : new HashSet<Part>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="part"/> followed by the other members of its common-line group on
|
||||
/// <paramref name="plate"/>, in plate order.
|
||||
/// </summary>
|
||||
public static IEnumerable<Part> 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<bool> change)
|
||||
{
|
||||
if (change())
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using OpenNest.Desktop.Rendering;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Desktop.Interaction;
|
||||
|
||||
/// <summary>The mouse button behind a pointer event.</summary>
|
||||
public enum ToolButton
|
||||
{
|
||||
None,
|
||||
Left,
|
||||
Middle,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// <summary>Modifier keys held during a pointer event.</summary>
|
||||
[Flags]
|
||||
public enum ToolModifiers
|
||||
{
|
||||
None = 0,
|
||||
Shift = 1,
|
||||
Control = 2,
|
||||
Alt = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public readonly record struct ToolPointer(Vector World, ScreenPoint Screen, ToolButton Button, ToolModifiers Modifiers);
|
||||
|
||||
/// <summary>What a plate tool works on: the view that hosts it, in either desktop app.</summary>
|
||||
public interface IPlateToolHost
|
||||
{
|
||||
/// <summary>The plate shown, or null.</summary>
|
||||
Plate Plate { get; }
|
||||
|
||||
PlateSelection Selection { get; }
|
||||
|
||||
PlateHitTester HitTester { get; }
|
||||
|
||||
/// <summary>The view's world/screen mapping, for tolerances given in pixels.</summary>
|
||||
ViewTransform View { get; }
|
||||
|
||||
/// <summary>Requests a redraw.</summary>
|
||||
void Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="PlateToolManager"/>.
|
||||
/// </summary>
|
||||
public abstract class PlateTool
|
||||
{
|
||||
protected PlateTool(IPlateToolHost host)
|
||||
{
|
||||
Host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
}
|
||||
|
||||
protected IPlateToolHost Host { get; }
|
||||
|
||||
/// <summary>The name shown in the status bar.</summary>
|
||||
public abstract string Name { get; }
|
||||
|
||||
/// <summary>Status bar guidance shown after the name, or null for the name alone.</summary>
|
||||
public virtual string Hint => null;
|
||||
|
||||
/// <summary>Whether the tool is mid-task, so Escape cancels that task rather than ending the tool.</summary>
|
||||
public abstract bool IsBusy { get; }
|
||||
|
||||
/// <summary>Whether Escape from Select may bring this tool back after it ends.</summary>
|
||||
public virtual bool ResumeOnEscape => true;
|
||||
|
||||
/// <summary>Whether the tool stays active when the view shows another plate.</summary>
|
||||
public virtual bool SurvivesPlateChange => false;
|
||||
|
||||
/// <summary>Raised when <see cref="Hint"/> changes.</summary>
|
||||
public event EventHandler HintChanged;
|
||||
|
||||
/// <summary>Called when the tool becomes active (again, when Escape resumes it).</summary>
|
||||
public virtual void Activate() { }
|
||||
|
||||
/// <summary>Called when the tool stops being active, after <see cref="Cancel"/>.</summary>
|
||||
public virtual void Deactivate() { }
|
||||
|
||||
/// <summary>Abandons the task in progress.</summary>
|
||||
public abstract void Cancel();
|
||||
|
||||
/// <summary>Called when a tool that survives plate changes sees another plate.</summary>
|
||||
public virtual void OnPlateChanged() { }
|
||||
|
||||
/// <returns>Whether the tool used the event.</returns>
|
||||
public virtual bool PointerPressed(ToolPointer pointer) => false;
|
||||
|
||||
/// <returns>Whether the tool used the event.</returns>
|
||||
public virtual bool PointerMoved(ToolPointer pointer) => false;
|
||||
|
||||
/// <returns>Whether the tool used the event.</returns>
|
||||
public virtual bool PointerReleased(ToolPointer pointer) => false;
|
||||
|
||||
protected void RaiseHintChanged() => HintChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.Desktop.Interaction;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// <see cref="Status"/> is the active tool's name and hint for the status bar.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The active tool.</summary>
|
||||
public PlateTool Current { get; private set; }
|
||||
|
||||
/// <summary>The active tool's name, and its hint when it has one.</summary>
|
||||
public string Status { get; private set; }
|
||||
|
||||
/// <summary>Raised when the active tool or its status changes.</summary>
|
||||
public event EventHandler StatusChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Makes <paramref name="tool"/> active, ending the current one. Ending another tool for
|
||||
/// Select keeps it for Escape to resume; any other switch forgets the resumable tool.
|
||||
/// </summary>
|
||||
public void SetTool(PlateTool tool)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tool);
|
||||
previous = tool is SelectTool && Current is not SelectTool && Current.ResumeOnEscape ? Current : null;
|
||||
Switch(tool);
|
||||
}
|
||||
|
||||
/// <summary>Returns to Select (as a fresh tool, so nothing selected is kept).</summary>
|
||||
public void Select() => SetTool(new SelectTool(host));
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void EndTool()
|
||||
{
|
||||
Select();
|
||||
previous = null;
|
||||
}
|
||||
|
||||
/// <summary>Handles Escape; see the class summary.</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();
|
||||
}
|
||||
|
||||
/// <summary>Called when the host shows another plate: tools that do not survive it end.</summary>
|
||||
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}";
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace OpenNest.Desktop.Interaction;
|
||||
|
||||
/// <summary>
|
||||
/// The default plate tool, with the WinForms Select action's selection rules:
|
||||
/// <list type="bullet">
|
||||
/// <item>A left click within <see cref="CutOffPickPixels"/> of a cut-off's stroke selects that cut-off alone.</item>
|
||||
/// <item>A left click on a part selects it (with its common-line group) in place of the selection;
|
||||
/// with Ctrl alone held, or on a part already selected, it adds to the selection instead.</item>
|
||||
/// <item>A left press on an empty area clears the selection and starts a selection window; on
|
||||
/// release the window adds the parts and cut-offs it selects (<see cref="SelectionWindow"/>).</item>
|
||||
/// <item>A right click on an unselected part selects it alone, for its menu; on an empty area it
|
||||
/// clears the selection.</item>
|
||||
/// </list>
|
||||
/// Moving the selection by dragging belongs to the move tools built on this one.
|
||||
/// </summary>
|
||||
public sealed class SelectTool : PlateTool
|
||||
{
|
||||
/// <summary>How near, in pixels, a click must be to a cut-off's stroke to pick it.</summary>
|
||||
public const double CutOffPickPixels = 5;
|
||||
|
||||
public SelectTool(IPlateToolHost host)
|
||||
: base(host)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "Select";
|
||||
|
||||
/// <summary>The window being dragged, or null.</summary>
|
||||
public SelectionWindow? Window { get; private set; }
|
||||
|
||||
/// <summary>Busy while dragging a window or while anything is selected, so Escape first deselects.</summary>
|
||||
public override bool IsBusy => Window != null || !Host.Selection.IsEmpty;
|
||||
|
||||
public override void Cancel()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Desktop.Interaction;
|
||||
|
||||
/// <summary>The status bar's selection text, worded as the WinForms main window words it.</summary>
|
||||
public static class SelectionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// "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.
|
||||
/// </summary>
|
||||
public static string Text(IReadOnlyCollection<Part> 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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Desktop.Interaction;
|
||||
|
||||
/// <summary>How a dragged selection window picks parts and cut-offs.</summary>
|
||||
public enum WindowSelectionMode
|
||||
{
|
||||
/// <summary>Anything the window touches: dragged from right to left.</summary>
|
||||
Intersect,
|
||||
|
||||
/// <summary>Only what lies wholly inside the window: dragged from left to right.</summary>
|
||||
Contains,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>The window as a box (a fresh copy each time).</summary>
|
||||
public Box Box => new(Left, Bottom, Right - Left, Top - Bottom);
|
||||
|
||||
/// <summary>Whether <paramref name="point"/> lies in the window, edges included.</summary>
|
||||
public bool Contains(Vector point) =>
|
||||
point.X >= Left && point.X <= Right && point.Y >= Bottom && point.Y <= Top;
|
||||
|
||||
/// <summary>Whether <paramref name="box"/> lies in the window, edges included.</summary>
|
||||
public bool Contains(Box box) =>
|
||||
box.Left >= Left && box.Right <= Right && box.Bottom >= Bottom && box.Top <= Top;
|
||||
|
||||
/// <summary>Whether <paramref name="box"/> and the window overlap, touching edges included.</summary>
|
||||
public bool Overlaps(Box box) =>
|
||||
box.Left <= Right && box.Right >= Left && box.Bottom <= Top && box.Top >= Bottom;
|
||||
|
||||
/// <summary>Whether the segment from <paramref name="start"/> to <paramref name="end"/> enters the window.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The window in the frame whose origin is at <paramref name="origin"/>, such as a placed part's program frame.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user