From 21d38beb53428531bb88ae358aefc7df25c3589b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 14 Oct 2026 13:56:14 -0400 Subject: [PATCH] fix(desktop): select cut-offs with plate windows --- .../CutOffWindowSelectionTests.cs | 126 ++++++++++++++++++ OpenNest/Actions/ActionSelect.cs | 14 +- OpenNest/Controls/SelectionManager.cs | 94 ++++++++++++- docs/automatic-scrap-cutoffs.md | 2 + 4 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 OpenNest.WinForms.Tests/CutOffWindowSelectionTests.cs diff --git a/OpenNest.WinForms.Tests/CutOffWindowSelectionTests.cs b/OpenNest.WinForms.Tests/CutOffWindowSelectionTests.cs new file mode 100644 index 0000000..0e14d68 --- /dev/null +++ b/OpenNest.WinForms.Tests/CutOffWindowSelectionTests.cs @@ -0,0 +1,126 @@ +using System.Drawing; +using System.Reflection; +using OpenNest.Actions; +using OpenNest.CNC; +using OpenNest.Controls; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests; + +public class CutOffWindowSelectionTests +{ + [Theory] + [InlineData(CutOffAxis.Vertical, 1)] + [InlineData(CutOffAxis.Horizontal, 2)] + [InlineData(CutOffAxis.Vertical, 3)] + [InlineData(CutOffAxis.Horizontal, 4)] + public void WindowSelectsVisibleCutOffSegmentsInBothDirections(CutOffAxis axis, int quadrant) => RunSta(() => + { + var plate = new Plate(20, 20) { Quadrant = quadrant }; + var bounds = plate.BoundingBox(includeParts: false); + var cutoff = new CutOff(bounds.Center, axis); + plate.CutOffs.Add(cutoff); + cutoff.Regenerate(plate, new CutOffSettings()); + using var view = new PlateView { Plate = plate }; + var full = GraphRect(view, new Vector(bounds.Left - 1, bounds.Bottom - 1), + new Vector(bounds.Right + 1, bounds.Top + 1)); + var crossing = GraphRect(view, new Vector(bounds.Center.X - 1, bounds.Center.Y - 1), + new Vector(bounds.Center.X + 1, bounds.Center.Y + 1)); + var missed = GraphRect(view, new Vector(bounds.Left + 1, bounds.Bottom + 1), + new Vector(bounds.Left + 2, bounds.Bottom + 2)); + + Assert.Contains(cutoff, view.Selection.GetCutOffsFromWindow(full, SelectionType.Contains)); + Assert.Empty(view.Selection.GetCutOffsFromWindow(crossing, SelectionType.Contains)); + Assert.Contains(cutoff, view.Selection.GetCutOffsFromWindow(crossing, SelectionType.Intersect)); + Assert.Empty(view.Selection.GetCutOffsFromWindow(missed, SelectionType.Intersect)); + }); + + [Fact] + public void WindowUsesActualSegmentsNotTheNominalLineOrGap() => RunSta(() => + { + using var view = new PlateView(); + var cutoff = new CutOff(new Vector(10, 10), CutOffAxis.Vertical); + view.Plate.CutOffs.Add(cutoff); + cutoff.Drawing.Program = new CNC.Program(); + cutoff.Drawing.Program.Codes.Add(new RapidMove(new Vector(10, 0))); + cutoff.Drawing.Program.Codes.Add(new LinearMove(new Vector(10, 4))); + cutoff.Drawing.Program.Codes.Add(new RapidMove(new Vector(10, 16))); + cutoff.Drawing.Program.Codes.Add(new LinearMove(new Vector(10, 20))); + + var gap = GraphRect(view, new Vector(9, 8), new Vector(11, 12)); + var oneSegment = GraphRect(view, new Vector(9, -1), new Vector(11, 5)); + var full = GraphRect(view, new Vector(9, -1), new Vector(11, 21)); + Assert.Empty(view.Selection.GetCutOffsFromWindow(gap, SelectionType.Intersect)); + Assert.Empty(view.Selection.GetCutOffsFromWindow(oneSegment, SelectionType.Contains)); + Assert.Contains(cutoff, view.Selection.GetCutOffsFromWindow(oneSegment, SelectionType.Intersect)); + Assert.Contains(cutoff, view.Selection.GetCutOffsFromWindow(full, SelectionType.Contains)); + + cutoff.Drawing.Program = new CNC.Program(); + Assert.Empty(view.Selection.GetCutOffsFromWindow(full, SelectionType.Intersect)); + Assert.Empty(view.Selection.GetCutOffsFromWindow(full, SelectionType.Contains)); + }); + + [Fact] + public void ActionSelectsPartsAndCutOffsTogetherAndDeleteRemovesBoth() => RunSta(() => + { + var plate = new Plate(20, 20); + var program = new CNC.Program(); + program.MoveTo(2, 2); + program.LineTo(4, 2); + program.LineTo(4, 4); + program.LineTo(2, 4); + program.LineTo(2, 2); + plate.Parts.Add(new Part(new Drawing("square", program))); + var cutoff = new CutOff(new Vector(10, 10), CutOffAxis.Vertical); + plate.CutOffs.Add(cutoff); + cutoff.Regenerate(plate, new CutOffSettings()); + using var view = new PlateView { Plate = plate }; + var action = new ActionSelect(view); + try + { + action.Point1 = new Vector(0, 0); + action.Point2 = new Vector(20, 20); // left-to-right: contain + SelectWindow(action); + SelectWindow(action); // repeated selection must not duplicate a definition + Assert.Single(view.SelectedParts); + Assert.Same(cutoff, Assert.Single(view.Selection.SelectedCutOffs)); + Assert.True(action.IsBusy()); + + view.Selection.DeleteSelected(); + Assert.Empty(plate.CutOffs); + Assert.Empty(plate.Parts); + Assert.Empty(view.SelectedParts); + Assert.Empty(view.Selection.SelectedCutOffs); + + plate.CutOffs.Add(cutoff); + cutoff.Regenerate(plate, view.CutOffSettings); + action.Point1 = new Vector(12, 12); + action.Point2 = new Vector(8, 8); // right-to-left: intersect + SelectWindow(action); + Assert.Same(cutoff, Assert.Single(view.Selection.SelectedCutOffs)); + Assert.True(action.IsBusy()); + action.CancelAction(); + Assert.Empty(view.Selection.SelectedCutOffs); + Assert.False(action.IsBusy()); + } + finally + { + action.DisconnectEvents(); + } + }); + + private static void SelectWindow(ActionSelect action) => + typeof(ActionSelect).GetMethod("SelectPartsInWindow", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(action, null); + + private static RectangleF GraphRect(PlateView view, Vector first, Vector second) + { + var a = view.PointWorldToGraph(first); + var b = view.PointWorldToGraph(second); + return RectangleF.FromLTRB(System.Math.Min(a.X, b.X), System.Math.Min(a.Y, b.Y), + System.Math.Max(a.X, b.X), System.Math.Max(a.Y, b.Y)); + } + + private static void RunSta(System.Action action) => + StaTestThread.Run(action, TimeSpan.FromSeconds(30), "Cut-off window selection test timed out."); +} diff --git a/OpenNest/Actions/ActionSelect.cs b/OpenNest/Actions/ActionSelect.cs index 1446d51..d2e5410 100644 --- a/OpenNest/Actions/ActionSelect.cs +++ b/OpenNest/Actions/ActionSelect.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Drawing; using System.Windows.Forms; using OpenNest.Controls; @@ -59,7 +59,8 @@ namespace OpenNest.Actions public override bool IsBusy() { - return status != Status.SetFirstPoint || plateView.SelectedParts.Count > 0; + return status != Status.SetFirstPoint || plateView.SelectedParts.Count > 0 + || plateView.Selection.SelectedCutOffs.Count > 0; } private void plateView_MouseMove(object sender, MouseEventArgs e) @@ -183,7 +184,8 @@ namespace OpenNest.Actions private void SelectPartsInWindow() { - var parts = plateView.GetPartsFromWindow(GetRectangle(), SelectionType); + var rect = GetRectangle(); + var parts = plateView.GetPartsFromWindow(rect, SelectionType); foreach (var part in parts) { @@ -194,6 +196,12 @@ namespace OpenNest.Actions part.IsSelected = true; } + foreach (var cutOff in plateView.Selection.GetCutOffsFromWindow(rect, SelectionType)) + { + if (!plateView.Selection.SelectedCutOffs.Contains(cutOff)) + plateView.Selection.SelectedCutOffs.Add(cutOff); + } + plateView.NotifySelectionChanged(); } diff --git a/OpenNest/Controls/SelectionManager.cs b/OpenNest/Controls/SelectionManager.cs index 338fbac..6c440e1 100644 --- a/OpenNest/Controls/SelectionManager.cs +++ b/OpenNest/Controls/SelectionManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Drawing; using System.Linq; +using OpenNest.CNC; using OpenNest.Engine.Fill; using OpenNest.Geometry; @@ -64,14 +65,11 @@ namespace OpenNest.Controls foreach (var cutOff in selectedCutOffs) view.Plate.CutOffs.Remove(cutOff); - selectedCutOffs.Clear(); view.Plate.RegenerateCutOffs(view.CutOffSettings); - view.Invalidate(); - } - else - { - RemoveSelectedParts(); } + + // A window can select both kinds of object; Delete must remove both. + RemoveSelectedParts(); } public void RemoveSelectedParts() @@ -252,6 +250,90 @@ namespace OpenNest.Controls return list; } + public IList GetCutOffsFromWindow(RectangleF rect, SelectionType selectionType) + { + var result = new List(); + foreach (var cutOff in view.Plate.CutOffs) + { + var program = cutOff.Drawing?.Program; + if (program == null) + continue; + + var hasSegment = false; + var matches = selectionType == SelectionType.Contains; + // Match the actual cut strokes drawn by PlateRenderer, not the nominal + // full-length line: a gap around a part is not selectable material. + for (var i = 0; i < program.Codes.Count - 1; i += 2) + { + if (program.Codes[i] is not RapidMove rapid + || program.Codes[i + 1] is not LinearMove linear) + continue; + + hasSegment = true; + var start = view.PointWorldToGraph(rapid.EndPoint); + var end = view.PointWorldToGraph(linear.EndPoint); + var hit = selectionType == SelectionType.Contains + ? ContainsInclusive(rect, start) && ContainsInclusive(rect, end) + : SegmentIntersects(rect, start, end); + if (selectionType == SelectionType.Intersect && hit) + { + matches = true; + break; + } + if (selectionType == SelectionType.Contains && !hit) + { + matches = false; + break; + } + } + + if (hasSegment && matches) + result.Add(cutOff); + } + return result; + } + + private static bool ContainsInclusive(RectangleF rect, PointF point) => + point.X >= rect.Left && point.X <= rect.Right + && point.Y >= rect.Top && point.Y <= rect.Bottom; + + private static bool SegmentIntersects(RectangleF rect, PointF start, PointF end) + { + if (ContainsInclusive(rect, start) || ContainsInclusive(rect, end)) + return true; + + // Clip the segment against all four window edges. Endpoints alone miss + // a stroke that enters and exits the window without ending inside it. + var min = 0.0; + var max = 1.0; + var dx = (double)end.X - start.X; + var dy = (double)end.Y - start.Y; + return Clip(-dx, start.X - rect.Left) + && Clip(dx, rect.Right - start.X) + && Clip(-dy, start.Y - rect.Top) + && Clip(dy, rect.Bottom - start.Y); + + bool Clip(double p, double q) + { + 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; + } + } + public void Clear() { selectedParts.Clear(); diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index 48d7214..cf86219 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -59,6 +59,8 @@ The command is one-shot; spacing, minimum-strip and minimum-tail inputs start at Rerunning unchanged suppresses equivalent existing full-span lines. A limited cutoff at a proposed line is a conflict requiring manual review, not permission to replace it or add overlapping NC moves. Existing definitions are never silently deleted or expanded. +In the plate view, a left-to-right selection window selects a cut-off only when it encloses every visible cut segment; a right-to-left window selects it when it crosses any visible segment (not an empty gap). Cut-offs and parts can be selected together and deleted together. Clicking a cut-off still starts its existing single-line drag. + Saving `.nest` retains ordinary cutoff positions, axes, limits, and sequence. Loading regenerates cutoff programs using the reader's default cutoff settings; exact toolpaths made with nondefault cutoff settings are not guaranteed to round-trip. Review regenerated paths before posting. ## Implementation boundary