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:
ajandClaude Opus 5.5 committed 2026-11-05 22:52:54 -05:00
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.");
}
+17 -13
View File
@@ -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))
{
+75
View File
@@ -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 &gt; 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();
}
}
+21 -2
View File
@@ -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 &gt; Arrange &gt; 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 &gt; Arrange &gt; 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();
+29
View File
@@ -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);
+2
View File
@@ -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;
+7 -1
View File
@@ -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