From 9fa7a96dc425405afc054b19f07c211c2e091c08 Mon Sep 17 00:00:00 2001 From: ajisaacs Date: Thu, 1 Oct 2026 20:43:44 -0400 Subject: [PATCH] test: prevent selected-area fill from crossing sheet cutoffs --- .../CutOffBoundedFillTests.cs | 45 ++++++++ .../ActionSelectAreaCutOffTests.cs | 104 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 OpenNest.Engine.Tests/CutOffBoundedFillTests.cs create mode 100644 OpenNest.WinForms.Tests/ActionSelectAreaCutOffTests.cs diff --git a/OpenNest.Engine.Tests/CutOffBoundedFillTests.cs b/OpenNest.Engine.Tests/CutOffBoundedFillTests.cs new file mode 100644 index 0000000..a92ae20 --- /dev/null +++ b/OpenNest.Engine.Tests/CutOffBoundedFillTests.cs @@ -0,0 +1,45 @@ +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Engine.Tests.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests; + +public class CutOffBoundedFillTests +{ + public static IEnumerable Cases() + { + foreach (var strategy in PlateFillService.BuiltInStrategies) + foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal }) + foreach (var quadrant in new[] { 1, 3 }) + foreach (var farSide in new[] { false, true }) + yield return new object[] { strategy, axis, quadrant, farSide }; + } + + [Theory] + [MemberData(nameof(Cases))] + public void FillItemCannotCrossSelectedCutOffBoundary( + string strategy, CutOffAxis axis, int quadrant, bool farSide) + { + var plate = new Plate(new Size(20, 20)) { Quadrant = quadrant, PartSpacing = 0.5 }; + var bounds = plate.WorkArea(); + var position = new Vector(bounds.Left + 10, bounds.Bottom + 10); + plate.CutOffs.Add(new CutOff(position, axis)); + // Independent expected selection box: one side of the cutoff, with spacing. + var area = axis == CutOffAxis.Vertical + ? new Box(farSide ? position.X + 0.5 : bounds.Left, bounds.Bottom, 9.5, bounds.Width) + : new Box(bounds.Left, farSide ? position.Y + 0.5 : bounds.Bottom, bounds.Length, 9.5); + var drawing = new Drawing("square", TestDrawingFactory.Rectangle(3, 3)); + var parts = PlateFillService.FillItem(strategy, plate, + new NestItem { Drawing = drawing }, area, null, CancellationToken.None); + + Assert.NotEmpty(parts); + Assert.Empty(plate.Parts); + Assert.All(parts, part => + { + Assert.Same(drawing, part.BaseDrawing); + var box = part.BoundingBox; + Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6); + Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6); + }); + } +} diff --git a/OpenNest.WinForms.Tests/ActionSelectAreaCutOffTests.cs b/OpenNest.WinForms.Tests/ActionSelectAreaCutOffTests.cs new file mode 100644 index 0000000..ba9010e --- /dev/null +++ b/OpenNest.WinForms.Tests/ActionSelectAreaCutOffTests.cs @@ -0,0 +1,104 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using OpenNest.Actions; +using OpenNest.Controls; +using OpenNest.Engine; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests; + +public class ActionSelectAreaCutOffTests +{ + public static IEnumerable Cases() + { + foreach (var axis in new[] { CutOffAxis.Vertical, CutOffAxis.Horizontal }) + foreach (var quadrant in new[] { 1, 3 }) + foreach (var farSide in new[] { false, true }) + foreach (var alternate in new[] { false, true }) + yield return new object[] { axis, quadrant, farSide, alternate }; + } + + [Theory] + [MemberData(nameof(Cases))] + public void SelectedAreaAndFillStayOnSelectedSideOfCutOff( + CutOffAxis axis, int quadrant, bool farSide, bool alternate) => RunSta(() => + { + var plate = new Plate(new OpenNest.Geometry.Size(20, 20)) + { + Quadrant = quadrant, + PartSpacing = 0.5, + }; + var bounds = plate.WorkArea(); + var position = new Vector(bounds.Left + 10, bounds.Bottom + 10); + // Definitions alone must constrain selection, before any cutoff parts exist. + plate.CutOffs.Add(new CutOff(position, axis)); + using var view = new SelectionView { Plate = plate }; + var action = new ActionSelectArea(view); + try + { + var offset = farSide ? 5 : -5; + view.SetPoint(axis == CutOffAxis.Vertical + ? new Vector(position.X + offset, position.Y) + : new Vector(position.X, position.Y + offset)); + if (alternate) + view.ToggleSelectionDirection(); + // Invoke the same update used by mouse movement without pixel rounding. + typeof(ActionSelectArea).GetMethod("UpdateSelectedArea", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(action, null); + var area = action.SelectedArea; + Assert.NotEqual(Box.Empty, area); + var limit = (axis == CutOffAxis.Vertical ? position.X : position.Y) + + (farSide ? plate.PartSpacing : -plate.PartSpacing); + Assert.Equal(limit, axis == CutOffAxis.Vertical + ? (farSide ? area.Left : area.Right) + : (farSide ? area.Bottom : area.Top), 8); + Assert.Equal(axis == CutOffAxis.Vertical ? bounds.Width : bounds.Length, + axis == CutOffAxis.Vertical ? area.Width : area.Length, 8); + + var program = new OpenNest.CNC.Program(); + program.MoveTo(0, 0); + program.LineTo(0, 3); + program.LineTo(3, 3); + program.LineTo(3, 0); + program.LineTo(0, 0); + // ActionFillArea passes SelectedArea directly to this service. + var parts = PlateFillService.FillItem("Default", plate, + new NestItem { Drawing = new Drawing("square", program) }, area, null, CancellationToken.None); + Assert.NotEmpty(parts); + Assert.All(parts, part => + { + var box = part.BoundingBox; + Assert.True(box.Left >= area.Left - 1e-6 && box.Right <= area.Right + 1e-6); + Assert.True(box.Bottom >= area.Bottom - 1e-6 && box.Top <= area.Top + 1e-6); + }); + } + finally + { + action.DisconnectEvents(); + } + }); + + private sealed class SelectionView : PlateView + { + public void SetPoint(Vector point) => CurrentPoint = point; + public void ToggleSelectionDirection() => OnKeyUp(new KeyEventArgs(Keys.Space)); + } + + private static void RunSta(System.Action action) + { + Exception? failure = null; + var thread = new Thread(() => + { + try { action(); } + catch (Exception ex) { failure = ex; } + }) + { IsBackground = true }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The selection/fill test did not complete."); + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } +}