fix(desktop): select cut-offs with plate windows

This commit is contained in:
aj committed 2026-10-14 14:10:39 -04:00
1 parent 18be8a2c81
commit 21d38beb53
4 files changed
+227 -9

No files matched your search

@@ -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.");
}
+11 -3
View File
@@ -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();
}
+88 -6
View File
@@ -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<CutOff> GetCutOffsFromWindow(RectangleF rect, SelectionType selectionType)
{
var result = new List<CutOff>();
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();
+2
View File
@@ -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