diff --git a/OpenNest.WinForms.Tests/Controls/CommonLineSelectionTests.cs b/OpenNest.WinForms.Tests/Controls/CommonLineSelectionTests.cs new file mode 100644 index 00000000..577d86a3 --- /dev/null +++ b/OpenNest.WinForms.Tests/Controls/CommonLineSelectionTests.cs @@ -0,0 +1,152 @@ +using OpenNest.Controls; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests.Controls; + +public class CommonLineSelectionTests +{ + private const double Kerf = 0.06; + + [Fact] + public void SelectingOneMemberSelectsItsWholeGroup() => RunSta(() => + { + var (plate, left, right, loose) = JoinedPairWithLoosePart(); + using var view = new PlateView { Plate = plate }; + + view.Selection.Select(Layout(view, left)); + + Assert.Equal(new[] { left, right }, view.SelectedParts.Select(p => p.BasePart)); + Assert.All(view.SelectedParts, p => Assert.True(p.IsSelected)); + Assert.False(Layout(view, loose).IsSelected); + }); + + [Fact] + public void SelectingALoosePartSelectsOnlyThatPart() => RunSta(() => + { + var (plate, _, _, loose) = JoinedPairWithLoosePart(); + using var view = new PlateView { Plate = plate }; + + view.Selection.Select(Layout(view, loose)); + + Assert.Equal(new[] { loose }, view.SelectedParts.Select(p => p.BasePart)); + }); + + [Fact] + public void NudgingOneMemberMovesTheJoinedPairAndKeepsItsSharedLine() => RunSta(() => + { + var (plate, left, right, _) = JoinedPairWithLoosePart(); + using var view = new PlateView { Plate = plate }; + var leftStart = left.BoundingBox.Location; + var rightStart = right.BoundingBox.Location; + + view.Selection.Select(Layout(view, right)); + view.Selection.NudgeSelected(1, 2); + + Assert.Equal(leftStart + new Vector(1, 2), left.BoundingBox.Location); + Assert.Equal(rightStart + new Vector(1, 2), right.BoundingBox.Location); + var group = Assert.Single(plate.CommonLines); + Assert.True(CommonLineGeometry.Check(group).IsIntact); + }); + + [Fact] + public void PartMenuOffersRemoveForAGroupAndJoinWhenALoosePartIsAdded() => RunSta(() => + { + var (plate, left, _, loose) = JoinedPairWithLoosePart(); + using var view = new PlateView { Plate = plate }; + var joins = 0; + var removes = 0; + view.JoinCommonLineCommand = () => joins++; + view.RemoveCommonLineCommand = () => removes++; + var onLeft = view.PointWorldToControl(left.BoundingBox.Center); + + // Off the selection there is no menu. + Assert.Null(view.PartMenu.BuildMenu(onLeft)); + + view.Selection.Select(Layout(view, left)); + var items = MenuItems(view.PartMenu.BuildMenu(onLeft)); + Assert.Equal(new[] { "Join Common Line", "Remove Common Line" }, items.Select(i => i.Text)); + Assert.False(items[0].Enabled); + Assert.True(items[1].Enabled); + + view.Selection.Select(Layout(view, loose)); + items = MenuItems(view.PartMenu.BuildMenu(onLeft)); + Assert.True(items[0].Enabled); + items[0].PerformClick(); + items[1].PerformClick(); + Assert.Equal(1, joins); + Assert.Equal(1, removes); + }); + + [Fact] + public void PartMenuOffersJoinButNotRemoveForLooseParts() => RunSta(() => + { + var (plate, _, _, loose) = JoinedPairWithLoosePart(); + var other = new Part(loose.BaseDrawing, new Vector(30, 10)); + plate.Parts.Add(other); + using var view = new PlateView { Plate = plate }; + view.JoinCommonLineCommand = () => { }; + view.RemoveCommonLineCommand = () => { }; + + view.Selection.Select(Layout(view, loose)); + var onLoose = view.PointWorldToControl(loose.BoundingBox.Center); + var items = MenuItems(view.PartMenu.BuildMenu(onLoose)); + Assert.False(items[0].Enabled); + Assert.False(items[1].Enabled); + + view.Selection.Select(Layout(view, other)); + items = MenuItems(view.PartMenu.BuildMenu(onLoose)); + Assert.True(items[0].Enabled); + Assert.False(items[1].Enabled); + + view.InspectionOnly = true; + Assert.Null(view.PartMenu.BuildMenu(onLoose)); + }); + + [Fact] + public void PartMenuIsAbsentWithoutHostCommands() => RunSta(() => + { + var (plate, left, _, _) = JoinedPairWithLoosePart(); + using var view = new PlateView { Plate = plate }; + view.Selection.Select(Layout(view, left)); + + Assert.Null(view.PartMenu.BuildMenu(view.PointWorldToControl(left.BoundingBox.Center))); + }); + + private static List MenuItems(System.Windows.Forms.ContextMenuStrip? menu) + { + Assert.NotNull(menu); + return menu.Items.Cast().ToList(); + } + + private static (Plate Plate, Part Left, Part Right, Part Loose) JoinedPairWithLoosePart() + { + var plate = new Plate(40, 20); + var drawing = new Drawing("square", Square(4)); + var left = new Part(drawing, new Vector(2, 2)); + var right = new Part(drawing, new Vector(6 + Kerf, 2)); + var loose = new Part(drawing, new Vector(20, 10)); + plate.Parts.Add(left); + plate.Parts.Add(right); + plate.Parts.Add(loose); + plate.JoinCommonLine(new[] { left, right }, Kerf); + Assert.True(CommonLineGeometry.Check(plate.CommonLines[0]).IsIntact); + return (plate, left, right, loose); + } + + private static CNC.Program Square(double size) + { + var program = new CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(size, 0); + program.LineTo(size, size); + program.LineTo(0, size); + program.LineTo(0, 0); + return program; + } + + private static LayoutPart Layout(PlateView view, Part part) => + view.LayoutParts.Single(p => ReferenceEquals(p.BasePart, part)); + + private static void RunSta(System.Action action) => + StaTestThread.Run(action, TimeSpan.FromSeconds(30), "Common-line selection test timed out."); +} diff --git a/OpenNest/Actions/ActionSelect.cs b/OpenNest/Actions/ActionSelect.cs index d804bac3..2b9dbf9f 100644 --- a/OpenNest/Actions/ActionSelect.cs +++ b/OpenNest/Actions/ActionSelect.cs @@ -140,7 +140,20 @@ namespace OpenNest.Actions } else if (e.Button == MouseButtons.Right) { - CancelAction(); + // On a part, keep the selection it belongs to (or select it and its group) so the + // part menu acts on it; elsewhere a right-click clears the selection. + var part = plateView.GetPartAtControlPoint(e.Location); + if (part == null) + { + CancelAction(); + } + else if (!plateView.SelectedParts.Contains(part)) + { + plateView.DeselectAll(); + plateView.Selection.Select(part); + plateView.NotifySelectionChanged(); + plateView.Invalidate(); + } } } @@ -199,11 +212,8 @@ namespace OpenNest.Actions ) plateView.DeselectAll(); - if (plateView.SelectedParts.Contains(part) == false) - { - plateView.SelectedParts.Add(part); - part.IsSelected = true; - } + // A common-line member brings its whole group, so a drag moves the joined parts together. + plateView.Selection.Select(part); plateView.NotifySelectionChanged(); plateView.Invalidate(); @@ -216,13 +226,7 @@ namespace OpenNest.Actions var parts = plateView.GetPartsFromWindow(rect, SelectionType); foreach (var part in parts) - { - if (plateView.SelectedParts.Contains(part)) - continue; - - plateView.SelectedParts.Add(part); - part.IsSelected = true; - } + plateView.Selection.Select(part); foreach (var cutOff in plateView.Selection.GetCutOffsFromWindow(rect, SelectionType)) { diff --git a/OpenNest/Controls/PartMenuHandler.cs b/OpenNest/Controls/PartMenuHandler.cs new file mode 100644 index 00000000..cd020642 --- /dev/null +++ b/OpenNest/Controls/PartMenuHandler.cs @@ -0,0 +1,75 @@ +using System; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; +using OpenNest.Actions; + +namespace OpenNest.Controls +{ + /// + /// Right-click on a part in a : joins the selected parts on a common + /// line or removes their groups (see docs/common-line-cutting.md). The commands are the + /// Plate > Arrange ones, supplied by the host through + /// and . Only in plate select mode, never + /// while inspecting or previewing a proposal. + /// + internal sealed class PartMenuHandler : IDisposable + { + private readonly PlateView view; + private ContextMenuStrip menu; + + public PartMenuHandler(PlateView view) + { + this.view = view; + } + + /// Shows the menu when is on a selected part. + public bool TryShow(Point location) + { + if (BuildMenu(location) == null) + return false; + menu.Show(view, location); + return true; + } + + /// + /// The menu for the selected part at (control coordinates), + /// or null when there is none, the host offers no commands or parts cannot be edited. + /// + internal ContextMenuStrip BuildMenu(Point location) + { + var plate = view.Plate; + if ( + plate == null + || view.InspectionOnly + || view.IsFillInProgress + || !view.AllowSelect + || view.HasProposalDisplay + || view.Actions.CurrentAction is not ActionSelect + || (view.JoinCommonLineCommand == null && view.RemoveCommonLineCommand == null) + ) + return null; + + var part = view.GetPartAtControlPoint(location); + if (part == null || !view.SelectedParts.Contains(part)) + return null; + + var selected = view.SelectedParts.Select(p => p.BasePart).ToList(); + var groups = selected.Select(plate.CommonLineGroupOf).ToList(); + // Join needs two units to bring together: two loose parts, or a group and anything outside it. + var units = groups.Where(g => g != null).Distinct().Count() + groups.Count(g => g == null); + var anyGrouped = groups.Any(g => g != null); + + menu ??= new ContextMenuStrip(); + while (menu.Items.Count > 0) + menu.Items[0].Dispose(); + if (view.JoinCommonLineCommand is { } join) + menu.Items.Add("Join Common Line", null, (_, _) => join()).Enabled = units >= 2; + if (view.RemoveCommonLineCommand is { } remove) + menu.Items.Add("Remove Common Line", null, (_, _) => remove()).Enabled = anyGrouped; + return menu; + } + + public void Dispose() => menu?.Dispose(); + } +} diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 625d286c..f9ed2caf 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -38,6 +38,7 @@ namespace OpenNest.Controls private CutOffHandler cutOffHandler; private readonly ClampMenuHandler clampMenu; private readonly CutOffMenuHandler cutOffMenu; + private readonly PartMenuHandler partMenu; private PreviewManager previewManager; protected List parts; private Point middleMouseDownPoint; @@ -79,6 +80,7 @@ namespace OpenNest.Controls internal CutOffHandler CutOffs => cutOffHandler; internal ClampMenuHandler ClampMenu => clampMenu; internal CutOffMenuHandler CutOffMenu => cutOffMenu; + internal PartMenuHandler PartMenu => partMenu; internal ActionManager Actions => actionManager; internal PreviewManager Previews => previewManager; @@ -119,6 +121,7 @@ namespace OpenNest.Controls cutOffHandler = new CutOffHandler(this); clampMenu = new ClampMenuHandler(this); cutOffMenu = new CutOffMenuHandler(this); + partMenu = new PartMenuHandler(this); previewManager = new PreviewManager(this); redrawTimer = new Timer() @@ -178,6 +181,18 @@ namespace OpenNest.Controls /// internal Func ClampNest { get; set; } + /// + /// Plate > Arrange > Join Common Line for the selected parts, offered on the part + /// right-click menu; null leaves it off the menu. The host supplies the kerf. + /// + internal System.Action JoinCommonLineCommand { get; set; } + + /// + /// Plate > Arrange > Remove Common Line for the selected parts, offered on the part + /// right-click menu; null leaves it off the menu. + /// + internal System.Action RemoveCommonLineCommand { get; set; } + /// /// Opens an undo scope in the nest document this view shows; null when the view is not a /// nest window, and then records nothing. @@ -533,8 +548,9 @@ namespace OpenNest.Controls if (e.Button == MouseButtons.Right) { - if (!clampMenu.TryShow(e.Location, 3.0 / ViewScale)) - cutOffMenu.TryShow(e.Location, 5.0 / ViewScale); + if (!clampMenu.TryShow(e.Location, 3.0 / ViewScale) + && !cutOffMenu.TryShow(e.Location, 5.0 / ViewScale)) + partMenu.TryShow(e.Location); } base.OnMouseUp(e); @@ -865,8 +881,11 @@ namespace OpenNest.Controls } clampMenu?.Dispose(); cutOffMenu?.Dispose(); + partMenu?.Dispose(); CanEditClamps = null; ClampNest = null; + JoinCommonLineCommand = null; + RemoveCommonLineCommand = null; overlapOverlay?.Dispose(); validationOverlay?.Dispose(); cutSimulation?.Dispose(); diff --git a/OpenNest/Controls/SelectionManager.cs b/OpenNest/Controls/SelectionManager.cs index e78e439a..cce6c4e3 100644 --- a/OpenNest/Controls/SelectionManager.cs +++ b/OpenNest/Controls/SelectionManager.cs @@ -55,6 +55,35 @@ namespace OpenNest.Controls SelectionChanged?.Invoke(view, EventArgs.Empty); } + /// + /// Adds to the selection with every other member of its + /// common-line group, so the joined parts move, rotate and push as one unit and the + /// group's shared spans survive the edit (see docs/common-line-cutting.md). + /// + public void Select(LayoutPart part) + { + foreach (var layout in WithCommonLineGroup(part)) + { + if (selectedParts.Contains(layout)) + continue; + selectedParts.Add(layout); + layout.IsSelected = true; + } + } + + private IEnumerable WithCommonLineGroup(LayoutPart part) + { + yield return part; + var group = view.Plate?.CommonLineGroupOf(part.BasePart); + if (group == null) + yield break; + foreach (var layout in view.LayoutParts) + { + if (!ReferenceEquals(layout, part) && group.Contains(layout.BasePart)) + yield return layout; + } + } + public void NotifySelectionChanged() { SelectionChanged?.Invoke(view, EventArgs.Empty); diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index cc359a89..3f50e325 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -292,6 +292,8 @@ namespace OpenNest.Forms activeForm.PlateView.PartRemoved += PlateView_PartRemoved; var activeNest = activeForm.Nest; activeForm.PlateView.CanEditClamps = () => !ClampsInUse(activeNest); + activeForm.PlateView.JoinCommonLineCommand = () => JoinCommonLine_Click(this, EventArgs.Empty); + activeForm.PlateView.RemoveCommonLineCommand = () => RemoveCommonLine_Click(this, EventArgs.Empty); mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid; mnuViewDrawPiercePoints.Checked = activeForm.PlateView.DrawPiercePoints; mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds; diff --git a/docs/common-line-cutting.md b/docs/common-line-cutting.md index 09f24861..b3a862c0 100644 --- a/docs/common-line-cutting.md +++ b/docs/common-line-cutting.md @@ -92,9 +92,15 @@ head on the second. Changing the setting after planning makes the plan stale. a facing partner, nothing moves. - **Plate > Arrange > Remove Common Line** removes the selected parts' groups. The parts stay put and are then held to the part spacing again. +- **Right-click a part** for the same Join Common Line and Remove Common Line commands on the + selection. Right-clicking a part keeps the selection it is in, or selects it with its group; + right-clicking empty plate still clears the selection. Join is offered when the selection holds + at least two units (loose parts or groups), Remove when any selected part is in a group. - The plate draws each shared span as a teal dashed centerline. A member that no longer shares a span is outlined in red. -- Moving one member moves only that part and breaks the group. Rejoin, or remove the group. +- Selecting a member, by click or window, selects its whole group, so dragging, nudging, pushing, + rotating and aligning move the joined parts as one and keep their shared spans. Deleting the + selection deletes the whole group. To move one member alone, remove the group first. - Join, Remove and Cut Common Lines Twice are undoable like any other edit. ## Automatic placement