mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-06 06:55:43 -05:00
feat(common-line): move joined parts as one and add a part right-click menu
Moving one member of a common-line group used to break the group, so a drag or nudge silently undid a join. Selecting a member, by click or window, now selects its whole group, so drag, nudge, push, rotate and align carry the joined parts together and keep their shared spans. Right-clicking a part now offers Join Common Line and Remove Common Line, running the Plate > Arrange commands on the selection. A right-click on a part keeps its selection (or selects it with its group) instead of clearing it, so the menu has something to act on; a right-click on empty plate still clears. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
78a5ad88e2
commit
f09a9ad705
7 files changed
+303
-16
No files matched your search
@@ -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<System.Windows.Forms.ToolStripItem> MenuItems(System.Windows.Forms.ContextMenuStrip? menu)
|
||||
{
|
||||
Assert.NotNull(menu);
|
||||
return menu.Items.Cast<System.Windows.Forms.ToolStripItem>().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.");
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Actions;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
/// <summary>
|
||||
/// Right-click on a part in a <see cref="PlateView"/>: 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 <see cref="PlateView.JoinCommonLineCommand"/>
|
||||
/// and <see cref="PlateView.RemoveCommonLineCommand"/>. Only in plate select mode, never
|
||||
/// while inspecting or previewing a proposal.
|
||||
/// </summary>
|
||||
internal sealed class PartMenuHandler : IDisposable
|
||||
{
|
||||
private readonly PlateView view;
|
||||
private ContextMenuStrip menu;
|
||||
|
||||
public PartMenuHandler(PlateView view)
|
||||
{
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
/// <summary>Shows the menu when <paramref name="location"/> is on a selected part.</summary>
|
||||
public bool TryShow(Point location)
|
||||
{
|
||||
if (BuildMenu(location) == null)
|
||||
return false;
|
||||
menu.Show(view, location);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The menu for the selected part at <paramref name="location"/> (control coordinates),
|
||||
/// or null when there is none, the host offers no commands or parts cannot be edited.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<LayoutPart> 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
|
||||
/// </summary>
|
||||
internal Func<Nest> ClampNest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal System.Action JoinCommonLineCommand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plate > Arrange > Remove Common Line for the selected parts, offered on the part
|
||||
/// right-click menu; null leaves it off the menu.
|
||||
/// </summary>
|
||||
internal System.Action RemoveCommonLineCommand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Opens an undo scope in the nest document this view shows; null when the view is not a
|
||||
/// nest window, and then <see cref="BeginChange"/> 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();
|
||||
|
||||
@@ -55,6 +55,35 @@ namespace OpenNest.Controls
|
||||
SelectionChanged?.Invoke(view, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds <paramref name="part"/> 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).
|
||||
/// </summary>
|
||||
public void Select(LayoutPart part)
|
||||
{
|
||||
foreach (var layout in WithCommonLineGroup(part))
|
||||
{
|
||||
if (selectedParts.Contains(layout))
|
||||
continue;
|
||||
selectedParts.Add(layout);
|
||||
layout.IsSelected = true;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<LayoutPart> 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user