mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-15 11:11:54 -04:00
fix(desktop): select cut-offs with plate windows
This commit is contained in:
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.");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user