From 575412a46427dc391639ece0fee690962128956d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 16:59:11 -0400 Subject: [PATCH 01/15] feat(cutouts): propose certified single inserts with inner-fit geometry --- .../Jobs/Cutouts/CutoutNfpProposalTests.cs | 154 ++++++++++++++++++ .../Jobs/Cutouts/CutoutLatticeFill.cs | 8 +- .../Jobs/Cutouts/CutoutNfpProposal.cs | 86 ++++++++++ 3 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs create mode 100644 OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs new file mode 100644 index 0000000..1ddf5cf --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutNfpProposalTests.cs @@ -0,0 +1,154 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Cutouts; +using OpenNest.Engine.Tests.NestingEngines; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +public class CutoutNfpProposalTests +{ + private const double Spacing = 0.25; + + [Fact] + public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2)); + var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0)); + var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing); + Assert.NotNull(pose); + Assert.Equal(insert.Id, pose.PartId); + Assert.Equal(0, pose.InstanceIndex); + Assert.True(insert.Rotation.Allows(pose.Rotation)); + AssertSquareInRoundHole(pose, 6, Spacing); + + var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0); + Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame, + JobPartGeometry.Read(insert.Geometry), pose, Spacing)); + var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2); + var movedInsert = pose with + { + X = framePose.X - pose.Y, + Y = framePose.Y + pose.X, + Rotation = pose.Rotation + framePose.Rotation, + }; + // This insert is fixed at zero for the local proposal; the rotated variant needs + // a correspondingly legal global policy before it can be handed to a job. + var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2)); + Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose, + JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing)); + AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y); + var stock = JobBuilder.Stock("sheet", 22, 22, Spacing); + var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock }); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) }, + new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) }, + new[] { new StockUsage(stock.Id, 1, null) }); + LayoutAssert.Valid(job, result); + } + + [Fact] + public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice() + { + var program = TestShapes.Rectangle(20, 20); + program.MoveTo(5, 5); + program.LineTo(5, 15); + program.LineTo(15, 15); + program.LineTo(15, 5); + program.LineTo(5, 5); + var frame = JobBuilder.Part("square-frame", program, 1); + var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0)); + var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing); + Assert.NotNull(pose); + Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner => + { + Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing); + Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing); + }); + Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), + new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing)); + Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1), + Spacing)); + } + + [Fact] + public void ObliqueFixedInsertRotationIsUsedInInnerFit() + { + var program = TestShapes.Rectangle(20, 20); + program.MoveTo(5, 5); + program.LineTo(5, 15); + program.LineTo(15, 15); + program.LineTo(15, 5); + program.LineTo(5, 5); + var frame = JobBuilder.Part("frame", program, 1); + var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4)); + var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing); + Assert.NotNull(pose); + Assert.True(insert.Rotation.Allows(pose.Rotation)); + foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) }) + { + var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation); + var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation); + Assert.InRange(rx, 5 + Spacing, 15 - Spacing); + Assert.InRange(ry, 5 + Spacing, 15 - Spacing); + } + } + + [Theory] + [InlineData(9.51)] + [InlineData(10.0)] + [InlineData(9.6)] + public void OversizedInsertIsNeverProposed(double side) + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var insert = JobBuilder.Rectangle("insert", side, side, 1); + Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing)); + } + + [Fact] + public void UnsupportedCoordinateScaleFailsClosed() + { + const double scale = 1e15; + var program = TestShapes.Polyline((-scale, -scale), (scale, -scale), + (scale, scale), (-scale, scale)); + program.MoveTo(-scale / 2, -scale / 2); + program.LineTo(-scale / 2, scale / 2); + program.LineTo(scale / 2, scale / 2); + program.LineTo(scale / 2, -scale / 2); + program.LineTo(-scale / 2, -scale / 2); + Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0, + JobBuilder.Rectangle("insert", 6, 6, 1), Spacing)); + } + + [Fact] + public void ImpossibleHugeSpacingFailsBeforeAnyOffset() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var insert = JobBuilder.Rectangle("insert", 6, 6, 1); + Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12)); + } + + private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing, + double centerX = 0, double centerY = 0) + { + var sin = System.Math.Sin(pose.Rotation); + var cos = System.Math.Cos(pose.Rotation); + foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) }) + { + var dx = pose.X + x * cos - y * sin - centerX; + var dy = pose.Y + x * sin + y * cos - centerY; + Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001, + $"corner ({dx}, {dy}) does not clear the circular wall"); + } + } + + [Fact] + public void CancellationIsNotReportedAsNoFit() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var insert = JobBuilder.Rectangle("insert", 6, 6, 1); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.Throws(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing, + cancelled.Token)); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs index 4492906..b3d7b50 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs @@ -30,7 +30,7 @@ internal static class CutoutLatticeFill /// Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets. internal const int DefaultShiftSteps = 8; - private const double FlattenTolerance = 0.001; + internal const double FlattenTolerance = 0.001; /// Extra growth beyond the spacing, covering flattening and Clipper rounding. private const double Margin = FlattenTolerance + 0.001; @@ -134,7 +134,7 @@ internal static class CutoutLatticeFill /// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline /// never meets the cutout boundary (t outside boundary + reflected outline). /// - private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing) + internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing) { var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList(); foreach (var entity in entities) @@ -203,7 +203,7 @@ internal static class CutoutLatticeFill } /// Strictly inside an even-odd region; boundary points count as outside. - private static bool Inside(PathsD region, double x, double y) + internal static bool Inside(PathsD region, double x, double y) { var point = new PointD(x, y); var inside = false; @@ -218,7 +218,7 @@ internal static class CutoutLatticeFill return inside; } - private static PathD Positive(PathD path) + internal static PathD Positive(PathD path) { if (path.Count >= 3 && !Clipper.IsPositive(path)) path.Reverse(); diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs new file mode 100644 index 0000000..b246f37 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs @@ -0,0 +1,86 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Clipper2Lib; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Test-only single-insert inner-fit proposal; never changes a job or a live plate. +internal static class CutoutNfpProposal +{ + // Fixed chord tolerance in the shared round offset otherwise makes extremely large + // spacing values allocate an unbounded number of arc vertices. This conservative + // test-only kernel declines scales it cannot process safely. + private const double MaxCoordinate = 1e8; + private const double MaxSpacing = 100; + + /// Returns one frame-local pose inside a closed cutout, or null if no pose is certified. + /// A null result is not a proof that the insert cannot fit elsewhere in this cutout. + internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert, + double spacing, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(insert); + token.ThrowIfCancellationRequested(); + if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing) + return null; + var frameGeometry = JobPartGeometry.TryRead(frame.Geometry); + var insertGeometry = JobPartGeometry.TryRead(insert.Geometry); + if (frameGeometry == null || insertGeometry == null + || !WithinRange(frameGeometry.Bounds) || !WithinRange(insertGeometry.Bounds)) + return null; + ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count); + var cutout = frameGeometry.Cutouts[cutoutIndex]; + if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox) + || spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width)) + return null; + var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath( + ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false), + new Vector())); + if (hole.Count < 3) + return null; + var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0); + foreach (var rotation in insert.Rotation.EnumerateAngles()) + { + token.ThrowIfCancellationRequested(); + var region = CutoutLatticeFill.InnerFit(insertGeometry, rotation, hole, spacing); + token.ThrowIfCancellationRequested(); + foreach (var (x, y) in Candidates(region)) + { + token.ThrowIfCancellationRequested(); + if (!CutoutLatticeFill.Inside(region, x, y)) + continue; + var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation); + if (NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing)) + return pose; + } + } + token.ThrowIfCancellationRequested(); + return null; + } + + private static bool WithinRange(Box box) => + double.IsFinite(box.Left) && double.IsFinite(box.Right) + && double.IsFinite(box.Bottom) && double.IsFinite(box.Top) + && System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate + && System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate; + + /// Sample component interiors, then points just inside each boundary toward + /// its component's center. This bounded search is not a complete inner-fit solver. + private static IEnumerable<(double X, double Y)> Candidates(PathsD region) + { + foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p))) + { + var bounds = Clipper.GetBounds(new PathsD { path }); + var cx = (bounds.left + bounds.right) / 2; + var cy = (bounds.top + bounds.bottom) / 2; + yield return (cx, cy); + foreach (var point in path) + yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01); + } + } +} From d3e325f36c77cdf32fe639186a622ccf99708299 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 17:06:17 -0400 Subject: [PATCH 02/15] fix(ui): restore manual part sequencing and close control --- .../Sequencing/ManualPartSequencing.cs | 29 ++++ .../Sequencing/ManualPartSequencingTests.cs | 43 +++++ OpenNest/Actions/ActionSetSequence.cs | 164 +++++------------- OpenNest/Forms/SequenceForm.Designer.cs | 22 ++- OpenNest/Forms/SequenceForm.cs | 9 +- docs/automatic-scrap-cutoffs.md | 2 + 6 files changed, 147 insertions(+), 122 deletions(-) create mode 100644 OpenNest.Core/Sequencing/ManualPartSequencing.cs create mode 100644 OpenNest.Tests/Sequencing/ManualPartSequencingTests.cs diff --git a/OpenNest.Core/Sequencing/ManualPartSequencing.cs b/OpenNest.Core/Sequencing/ManualPartSequencing.cs new file mode 100644 index 0000000..8bef8db --- /dev/null +++ b/OpenNest.Core/Sequencing/ManualPartSequencing.cs @@ -0,0 +1,29 @@ +using System; +using System.Linq; + +namespace OpenNest.Sequencing +{ + /// Moves one existing part to a one-based cutting sequence position. + public static class ManualPartSequencing + { + public static bool Move(Plate plate, Part part, int sequenceNumber) + { + ArgumentNullException.ThrowIfNull(plate); + ArgumentNullException.ThrowIfNull(part); + var order = plate.Parts.ToList(); + if (sequenceNumber < 1 || sequenceNumber > order.Count) + return false; + + var oldIndex = order.FindIndex(p => ReferenceEquals(p, part)); + if (oldIndex < 0) + return false; + if (oldIndex == sequenceNumber - 1) + return true; + + order.RemoveAt(oldIndex); + order.Insert(sequenceNumber - 1, part); + plate.Parts.Reorder(order); + return true; + } + } +} diff --git a/OpenNest.Tests/Sequencing/ManualPartSequencingTests.cs b/OpenNest.Tests/Sequencing/ManualPartSequencingTests.cs new file mode 100644 index 0000000..71158de --- /dev/null +++ b/OpenNest.Tests/Sequencing/ManualPartSequencingTests.cs @@ -0,0 +1,43 @@ +using OpenNest.Geometry; +using OpenNest.Sequencing; + +namespace OpenNest.Tests.Sequencing; + +public class ManualPartSequencingTests +{ + [Fact] + public void Move_SetsExactOneBasedSequenceWithoutChangingQuantity() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var first = new Part(drawing, new Vector(0, 0)); + var second = new Part(drawing, new Vector(20, 0)); + var third = new Part(drawing, new Vector(40, 0)); + var plate = TestHelpers.MakePlate(60, 120, first, second, third); + var nested = drawing.Quantity.Nested; + var reorders = 0; + plate.PartsReordered += (_, _) => reorders++; + + Assert.True(ManualPartSequencing.Move(plate, third, 1)); + Assert.Equal(new[] { third, first, second }, plate.Parts); + Assert.True(ManualPartSequencing.Move(plate, first, 3)); + Assert.Equal(new[] { third, second, first }, plate.Parts); + Assert.Equal(nested, drawing.Quantity.Nested); + Assert.Equal(2, reorders); + } + + [Fact] + public void Move_RejectsAbsentPartAndOutOfRangeWithoutChangingPlate() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var part = new Part(drawing, new Vector(0, 0)); + var plate = TestHelpers.MakePlate(60, 120, part); + var reorders = 0; + plate.PartsReordered += (_, _) => reorders++; + + Assert.False(ManualPartSequencing.Move(plate, part, 0)); + Assert.False(ManualPartSequencing.Move(plate, part, 2)); + Assert.False(ManualPartSequencing.Move(plate, new Part(drawing, new Vector(0, 0)), 1)); + Assert.Equal(new[] { part }, plate.Parts); + Assert.Equal(0, reorders); + } +} diff --git a/OpenNest/Actions/ActionSetSequence.cs b/OpenNest/Actions/ActionSetSequence.cs index 73e37c5..e11e789 100644 --- a/OpenNest/Actions/ActionSetSequence.cs +++ b/OpenNest/Actions/ActionSetSequence.cs @@ -1,169 +1,101 @@ -using System.Collections.Generic; +using System; using System.ComponentModel; using System.Drawing; -using System.Linq; using System.Windows.Forms; using OpenNest.Controls; -using OpenNest.Converters; -using OpenNest.Forms; -using OpenNest.Geometry; +using OpenNest.Sequencing; namespace OpenNest.Actions { [DisplayName("Set Sequence")] public class ActionSetSequence : Action { - private readonly SequenceForm SequenceForm; - private readonly List ShapePartPairs; - private readonly Pen pen; + private readonly Forms.SequenceForm sequenceForm; + private readonly Pen pen = new Pen(Color.Blue, 2.0f); + private LayoutPart hoveredPart; + private bool disconnecting; - private Shape ClosestShape; - private Part ClosestPart; - private int sequenceNumber = 1; - - public int SequenceNumber - { - get { return sequenceNumber; } - set - { - if ( - value <= SequenceForm.numericUpDown1.Maximum - && value >= SequenceForm.numericUpDown1.Minimum - ) - { - sequenceNumber = value; - SequenceForm.numericUpDown1.Value = sequenceNumber; - } - } - } + // Escape ends this tool; it must not resurrect its closed window. + public override bool ResumeOnEscape => false; public ActionSetSequence(PlateView plateView) : base(plateView) { - SequenceForm = new Forms.SequenceForm(); - SequenceForm.numericUpDown1.DataBindings.Add( - "Value", - this, - "SequenceNumber", - false, - DataSourceUpdateMode.OnPropertyChanged - ); - SequenceForm.numericUpDown1.Maximum = plateView.Plate.Parts.Count; - SequenceForm.Owner = Application.OpenForms[0]; - SequenceForm.Show(); - plateView.Invalidate(); - - pen = new Pen(Color.Blue, 2.0f); - - ShapePartPairs = new List(); - - foreach (var part in plateView.Plate.Parts) + sequenceForm = new Forms.SequenceForm(); + sequenceForm.numericUpDown1.Maximum = System.Math.Max(1, plateView.Plate.Parts.Count); + sequenceForm.DoneClicked += (_, _) => plateView.EndAction(); + sequenceForm.FormClosed += (_, _) => { - var entities = ConvertProgram - .ToGeometry(part.Program) - .Where(e => e.Layer == SpecialLayers.Cut) - .ToList(); - entities.ForEach(entity => entity.Offset(part.Location)); - var shapes = ShapeBuilder.GetShapes(entities); - var shape = new Shape(); - shape.Entities.AddRange(shapes); - ShapePartPairs.Add(new Pair() { Part = part, Shape = shape }); - } + if (!disconnecting) + plateView.EndAction(); + }; + sequenceForm.Show(plateView.FindForm()); plateView.MouseMove += plateView_MouseMove; + plateView.MouseLeave += plateView_MouseLeave; plateView.MouseClick += plateView_MouseClick; plateView.Paint += plateView_Paint; - - SequenceNumber = 1; } private void plateView_MouseClick(object sender, MouseEventArgs e) { if (e.Button == MouseButtons.Right) - CancelAction(); - + { + plateView.EndAction(); + return; + } if (e.Button != MouseButtons.Left) return; - if (SequenceNumber > plateView.Plate.Parts.Count) - SequenceNumber = plateView.Plate.Parts.Count; - - if (ClosestPart == null) + // Hit-test at the click, not at the previous mouse-move location. + var hit = plateView.GetPartAtControlPoint(e.Location); + var part = hit?.BasePart; + if (part == null || !ManualPartSequencing.Move(plateView.Plate, part, (int)sequenceForm.numericUpDown1.Value)) return; - plateView.Plate.Parts.Remove(ClosestPart); - plateView.Plate.Parts.Insert(SequenceNumber - 1, ClosestPart); - - SequenceNumber++; - + if (sequenceForm.numericUpDown1.Value < sequenceForm.numericUpDown1.Maximum) + sequenceForm.numericUpDown1.Value++; plateView.Invalidate(); } private void plateView_MouseMove(object sender, MouseEventArgs e) { - var pair = GetClosestShape(); - ClosestShape = pair.HasValue ? pair.Value.Shape : null; - ClosestPart = pair.HasValue ? pair.Value.Part : null; + var hit = plateView.GetPartAtControlPoint(e.Location); + if (ReferenceEquals(hit, hoveredPart)) + return; + hoveredPart = hit; + plateView.Invalidate(); + } + + private void plateView_MouseLeave(object sender, EventArgs e) + { + hoveredPart = null; plateView.Invalidate(); } private void plateView_Paint(object sender, PaintEventArgs e) { - if (ClosestShape == null) - return; - - var path = ClosestShape.GetGraphicsPath(); - path.Transform(plateView.Matrix); - e.Graphics.DrawPath(pen, path); - path.Dispose(); - } - - private Pair? GetClosestShape() - { - Pair? closestShape = null; - double distance = double.MaxValue; - - for (int i = 0; i < ShapePartPairs.Count; i++) - { - var shape = ShapePartPairs[i]; - var closestPt = shape.Shape.ClosestPointTo(plateView.CurrentPoint); - var distance2 = closestPt.DistanceTo(plateView.CurrentPoint); - - if (distance2 < distance) - { - closestShape = shape; - distance = distance2; - } - } - - return closestShape; + var path = hoveredPart?.SelectionPath; + if (path != null) + e.Graphics.DrawPath(pen, path); } public override void DisconnectEvents() { + disconnecting = true; plateView.Paint -= plateView_Paint; plateView.MouseMove -= plateView_MouseMove; + plateView.MouseLeave -= plateView_MouseLeave; plateView.MouseClick -= plateView_MouseClick; + hoveredPart = null; + sequenceForm.Close(); + sequenceForm.Dispose(); + pen.Dispose(); plateView.Invalidate(); - - SequenceForm.Close(); } - public override void CancelAction() - { - SequenceNumber = 1; - SequenceForm.numericUpDown1.Value = SequenceNumber + 1; - } + public override void CancelAction() { } - public override bool IsBusy() - { - return false; - } - - private struct Pair - { - public Part Part; - public Shape Shape; - } + public override bool IsBusy() => false; } } diff --git a/OpenNest/Forms/SequenceForm.Designer.cs b/OpenNest/Forms/SequenceForm.Designer.cs index d49cbfe..45ed2d8 100644 --- a/OpenNest/Forms/SequenceForm.Designer.cs +++ b/OpenNest/Forms/SequenceForm.Designer.cs @@ -31,6 +31,7 @@ tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel(); label1 = new System.Windows.Forms.Label(); numericUpDown1 = new OpenNest.Controls.NumericUpDown(); + doneButton = new System.Windows.Forms.Button(); tableLayoutPanel1.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)numericUpDown1).BeginInit(); SuspendLayout(); @@ -42,13 +43,15 @@ tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); tableLayoutPanel1.Controls.Add(label1, 0, 0); tableLayoutPanel1.Controls.Add(numericUpDown1, 1, 0); + tableLayoutPanel1.Controls.Add(doneButton, 1, 1); tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill; tableLayoutPanel1.Location = new System.Drawing.Point(0, 0); tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); tableLayoutPanel1.Name = "tableLayoutPanel1"; - tableLayoutPanel1.RowCount = 1; + tableLayoutPanel1.RowCount = 2; + tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 35F)); tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); - tableLayoutPanel1.Size = new System.Drawing.Size(266, 35); + tableLayoutPanel1.Size = new System.Drawing.Size(266, 74); tableLayoutPanel1.TabIndex = 0; // // label1 @@ -74,20 +77,28 @@ numericUpDown1.TabIndex = 1; numericUpDown1.Value = new decimal(new int[] { 1, 0, 0, 0 }); numericUpDown1.Leave += numericUpDown1_Leave; + // doneButton // + doneButton.Anchor = System.Windows.Forms.AnchorStyles.Right; + doneButton.Location = new System.Drawing.Point(182, 40); + doneButton.Name = "doneButton"; + doneButton.Size = new System.Drawing.Size(80, 28); + doneButton.TabIndex = 2; + doneButton.Text = "Done"; + doneButton.UseVisualStyleBackColor = true; + doneButton.Click += doneButton_Click; // SequenceForm // AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - ClientSize = new System.Drawing.Size(266, 35); - ControlBox = false; + ClientSize = new System.Drawing.Size(266, 74); Controls.Add(tableLayoutPanel1); FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; Location = new System.Drawing.Point(100, 100); Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); MaximizeBox = false; MinimizeBox = false; - MinimumSize = new System.Drawing.Size(268, 74); + MinimumSize = new System.Drawing.Size(268, 113); Name = "SequenceForm"; ShowIcon = false; ShowInTaskbar = false; @@ -105,5 +116,6 @@ private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; private System.Windows.Forms.Label label1; public Controls.NumericUpDown numericUpDown1; + private System.Windows.Forms.Button doneButton; } } \ No newline at end of file diff --git a/OpenNest/Forms/SequenceForm.cs b/OpenNest/Forms/SequenceForm.cs index d800475..b7e3d5b 100644 --- a/OpenNest/Forms/SequenceForm.cs +++ b/OpenNest/Forms/SequenceForm.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Windows.Forms; namespace OpenNest.Forms @@ -10,6 +10,13 @@ namespace OpenNest.Forms InitializeComponent(); } + public event EventHandler DoneClicked; + + private void doneButton_Click(object sender, EventArgs e) + { + DoneClicked?.Invoke(this, EventArgs.Empty); + } + private void numericUpDown1_Leave(object sender, EventArgs e) { numericUpDown1.Validate(); diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md index d4b67ef..e295494 100644 --- a/docs/automatic-scrap-cutoffs.md +++ b/docs/automatic-scrap-cutoffs.md @@ -36,6 +36,8 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal ske ## Part sequencing +For **Plate > Sequence Parts > Manual Sequence**, choose the one-based sequence number and hover a part on the sheet; only the part under the pointer is outlined. Click it to place it at that exact position, then the number advances up to the last part. Clicking empty sheet space changes nothing. Use **Done**, the window close button, right-click on the sheet, or Escape to leave the tool. Manual reordering changes neither geometry nor quantities. + After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route; a tail separator that crosses no parts keeps its normal route position rather than being forced to the front. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged. The dependency check uses the nominal horizontal/vertical line and its start/end limits, not the trimmed cutting segments, which intentionally skip the parts. It conservatively checks placed part bounds (including edge contacts), so a cutoff through a concave recess can also move ahead of that part. Definitions are matched by drawing identity, not their displayed names. A cutoff part with no matching definition is conservatively ordered before all ordinary parts. From 962ae8c82515e09ea8ba40f8b60a2c3989da8078 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 17:20:41 -0400 Subject: [PATCH 03/15] feat(cutouts): propose bounded mixed lattice and NFP inserts --- .../Jobs/Cutouts/CutoutRouterTests.cs | 156 ++++++++++++++++++ .../Jobs/Cutouts/CutoutLatticeFill.cs | 40 ++++- .../Jobs/Cutouts/CutoutNfpProposal.cs | 30 +++- OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs | 95 +++++++++++ docs/nesting-engines.md | 10 ++ 5 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs create mode 100644 OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs new file mode 100644 index 0000000..2111705 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs @@ -0,0 +1,156 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Cutouts; +using OpenNest.Engine.Tests.NestingEngines; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +public class CutoutRouterTests +{ + private static NestJobPart Frame() + { + var program = TestShapes.Rectangle(20, 20); + program.MoveTo(5, 5); + program.LineTo(5, 15); + program.LineTo(15, 15); + program.LineTo(15, 5); + program.LineTo(5, 5); + return JobBuilder.Part("frame", program, 1); + } + + [Fact] + public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3); + var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0)); + Assert.Equal(3, CutoutLatticeFill.Fill(frame, 0, small, 3, 0.25).Count); + var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25); + Assert.Equal(3, poses.Count(p => p.PartId == small.Id)); + Assert.Single(poses, p => p.PartId == large.Id); + Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex)); + AssertPhysical(frame, new[] { small, large }, poses); + } + + [Fact] + public void ImpossibleRemainderNeverDisplacesCertifiedCopies() + { + var frame = Frame(); + var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0)); + var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0)); + var alone = CutoutRouter.Fill(frame, 0, new[] { small }, 0.25); + var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25); + Assert.Equal(alone, together); + Assert.Equal(3, together.Count); + AssertPhysical(frame, new[] { small, impossible }, together); + } + + [Fact] + public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch() + { + var frame = Frame(); + var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0)); + var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0)); + var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25); + Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity); + Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity); + AssertPhysical(frame, new[] { small, large }, poses); + } + + [Fact] + public void ResidualNfpDoesNotOverlapTheFirstLargeInsert() + { + var frame = Frame(); + var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0)); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Single(poses); + AssertPhysical(frame, new[] { insert }, poses); + } + + [Fact] + public void DuplicateRequirementIdsAreRejectedBeforePlacement() + { + var frame = Frame(); + var insert = JobBuilder.Rectangle("same", 1, 1, 3); + Assert.Throws(() => CutoutRouter.Fill(frame, 0, + new[] { insert, insert }, 0.25)); + } + + [Fact] + public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1); + var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3); + var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25); + Assert.Equal(3, early.Count); + var blocked = early[0] with { PartId = first.Id }; + var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) }; + var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25, + CutoutLatticeFill.DefaultShiftSteps, occupied, default); + Assert.Equal(3, later.Count); + Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked, + JobPartGeometry.Read(more.Geometry), p, 0.25))); + } + + [Fact] + public void TinyInsertDoesNotAllocateAnUnboundedLattice() + { + var frame = Frame(); + var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0)); + Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0)); + var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0); + Assert.Equal(3, poses.Count); + } + + [Fact] + public void CancellationIsNotNoFit() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + Assert.Throws(() => CutoutRouter.Fill(Frame(), 0, + new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token)); + } + + private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts, + IReadOnlyList poses) + { + var stock = JobBuilder.Stock("sheet", 22, 22, 0.25); + var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock }); + var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds; + var offsetX = 1 - frameBounds.Left; + var offsetY = 1 - frameBounds.Bottom; + var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) } + .Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray(); + var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, stock, placements) }, + new[] { new PartFulfillment(frame.Id, 1, 1, 0) } + .Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity, + poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id)))) + .ToArray(), + new[] { new StockUsage(stock.Id, 1, null) }); + LayoutAssert.Valid(job, result); + var frameGeometry = JobPartGeometry.Read(frame.Geometry); + var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0); + foreach (var pose in poses) + { + var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry); + Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25)); + // Independent square-wall oracle: every corner of each rectangular insert clears the hole. + var bounds = geometry.Bounds; + foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom), + (bounds.Right, bounds.Top), (bounds.Left, bounds.Top) }) + { + var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation); + var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation); + if (frameBounds.Left >= 0) + { + Assert.InRange(rx, 5.25, 14.75); + Assert.InRange(ry, 5.25, 14.75); + } + else + Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= 4.75 + 0.00001); + } + } + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs index b3d7b50..44b903c 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs @@ -36,6 +36,9 @@ internal static class CutoutLatticeFill private const double Margin = FlattenTolerance + 0.001; private const int Precision = NestTolerances.ClipperPrecision; + // Fill materializes an entire grid before quantity trimming. Bound work even for a + // tiny insert with demand of only three, rather than allocating millions of clones. + private const int MaxLatticePositions = 1000; /// Returns up to insert poses inside the cutout, /// or none when no copy fits. @@ -45,9 +48,17 @@ internal static class CutoutLatticeFill internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token) + => Fill(frame, cutoutIndex, insert, maxQuantity, spacing, shiftSteps, + Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token); + + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, + IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CancellationToken token) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(insert); + ArgumentNullException.ThrowIfNull(occupied); + token.ThrowIfCancellationRequested(); ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps); if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0) return Array.Empty(); @@ -69,6 +80,10 @@ internal static class CutoutLatticeFill var bounds = cutout.BoundingBox; var insertBounds = insertGeometry.Bounds; var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing; + var estimated = (System.Math.Ceiling(bounds.Length / step) + 4) + * (System.Math.Ceiling(bounds.Width / step) + 4); + if (!(step > 0) || !double.IsFinite(estimated) || estimated > MaxLatticePositions) + return Array.Empty(); var lattice = Lattice(insert, bounds, step, spacing, token); if (lattice.Count == 0) @@ -83,7 +98,8 @@ internal static class CutoutLatticeFill { token.ThrowIfCancellationRequested(); var (dx, dy) = Shift(step, shiftSteps, i, j); - var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)); + var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy) + && ClearsOccupied(p, dx, dy)); if (Better(count, i, j, best)) best = (count, i, j); } @@ -91,13 +107,33 @@ internal static class CutoutLatticeFill return Array.Empty(); var (sx, sy) = Shift(step, shiftSteps, best.I, best.J); - var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)) + var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy) + && ClearsOccupied(p, sx, sy)) .Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8), System.Math.Round(p.Y + sy, 8), p.Rotation)) .OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation) .ToList(); return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity) .Select((p, index) => p with { InstanceIndex = index }).ToArray(); + + bool ClearsOccupied(NestJobPlacement pose, double dx, double dy) + { + token.ThrowIfCancellationRequested(); + if (occupied.Count == 0) + return true; + var moved = pose with + { + X = System.Math.Round(pose.X + dx, 8), + Y = System.Math.Round(pose.Y + dy, 8) + }; + foreach (var other in occupied) + { + token.ThrowIfCancellationRequested(); + if (!NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, moved, spacing)) + return false; + } + return true; + } } /// Fill over the cutout's bounds grown by one step on every side, in frame diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs index b246f37..9871e7c 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutNfpProposal.cs @@ -20,10 +20,18 @@ internal static class CutoutNfpProposal /// Returns one frame-local pose inside a closed cutout, or null if no pose is certified. /// A null result is not a proof that the insert cannot fit elsewhere in this cutout. internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert, - double spacing, CancellationToken token = default) + double spacing, CancellationToken token = default) => + Find(frame, cutoutIndex, insert, spacing, + Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token); + + /// Find a pose clearing already accepted inserts as well as the enclosing frame. + internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert, + double spacing, IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, + CancellationToken token = default) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(insert); + ArgumentNullException.ThrowIfNull(occupied); token.ThrowIfCancellationRequested(); if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing) return null; @@ -55,7 +63,19 @@ internal static class CutoutNfpProposal if (!CutoutLatticeFill.Inside(region, x, y)) continue; var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation); - if (NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing)) + if (!NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing)) + continue; + var clears = true; + foreach (var other in occupied) + { + token.ThrowIfCancellationRequested(); + if (NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, pose, spacing)) + continue; + clears = false; + break; + } + token.ThrowIfCancellationRequested(); + if (clears) return pose; } } @@ -81,6 +101,12 @@ internal static class CutoutNfpProposal yield return (cx, cy); foreach (var point in path) yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01); + // Bounded interior samples let a residual insert land beside a partial lattice; + // a center/vertex-only sample often lands directly on an earlier copy. + for (var y = 1; y <= 9; y++) + for (var x = 1; x <= 9; x++) + yield return (bounds.left + (bounds.right - bounds.left) * x / 10, + bounds.top + (bounds.bottom - bounds.top) * y / 10); } } } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs new file mode 100644 index 0000000..c47f939 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -0,0 +1,95 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Internal frame-local cutout proposal. Never changes demand, stock or a live plate. +/// Tries shifted partial Fill for a type with at least three requested copies, +/// prefers it only when it beats a single NFP pose, then fills residual demand +/// with NFP poses. No tuned ratio cutoff is claimed: the +/// historical 0.10/0.20 routing thresholds still require measured acceptance. +/// Fill's equal-score pose nondeterminism is tracked as PM c98c21bd. +internal static class CutoutRouter +{ + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(inserts); + token.ThrowIfCancellationRequested(); + if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100) + return Array.Empty(); + var frameGeometry = JobPartGeometry.TryRead(frame.Geometry); + if (frameGeometry == null) + return Array.Empty(); + ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count); + var hole = frameGeometry.Cutouts[cutoutIndex]; + if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) + || !Supported(hole.BoundingBox)) + return Array.Empty(); + if (inserts.Any(p => p == null || !SupportedGeometry(p))) + return Array.Empty(); + if (inserts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != inserts.Count + || inserts.Any(p => p.Id == frame.Id)) + throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.", + nameof(inserts)); + + var accepted = new List(); + var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(); + // Preserve caller requirement order: priorities are a whole-job concern, not inferred here. + foreach (var part in inserts) + { + token.ThrowIfCancellationRequested(); + var geometry = JobPartGeometry.Read(part.Geometry); + if (part.Quantity >= 3) + { + // Geometric work is bounded inside Fill. Do not infer a ratio threshold + // from material area before measuring cutout jobs; try both proposals. + var usable = CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, + spacing, CutoutLatticeFill.DefaultShiftSteps, occupied, token); + // A one-copy lattice adds no value over the geometry-aware NFP search; + // retain the latter's stable candidate order for that tie. + if (usable.Count > 1 || (usable.Count == 1 + && CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token) == null)) + foreach (var pose in usable) + { + token.ThrowIfCancellationRequested(); + Accept(pose); + } + } + while (accepted.Count(p => p.PartId == part.Id) < part.Quantity) + { + token.ThrowIfCancellationRequested(); + var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token); + if (pose == null) + break; + Accept(pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }); + } + + void Accept(NestJobPlacement pose) + { + var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }; + accepted.Add(indexed); + occupied.Add((geometry, indexed)); + } + } + token.ThrowIfCancellationRequested(); + return accepted.ToArray(); + } + + private static bool SupportedGeometry(NestJobPart part) + { + var geometry = JobPartGeometry.TryRead(part.Geometry); + return geometry != null && Supported(geometry.Bounds); + } + + private static bool Supported(OpenNest.Geometry.Box box) => + double.IsFinite(box.Left) && double.IsFinite(box.Right) + && double.IsFinite(box.Bottom) && double.IsFinite(box.Top) + && System.Math.Abs(box.Left) <= 1e8 && System.Math.Abs(box.Right) <= 1e8 + && System.Math.Abs(box.Bottom) <= 1e8 && System.Math.Abs(box.Top) <= 1e8; +} diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 05ae758..6b92139 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -83,6 +83,16 @@ layout check uses. The method suits many small copies in a large cutout; a few l meant for no-fit-polygon placement. Fill can return different, equally scored lattices on repeated calls for some parts, so results are not yet guaranteed identical between runs. +The internal `CutoutRouter` can propose copies from the shifted lattice, then search bounded +inner-fit/NFP sample points for remaining copies and other insert requirements. It keeps +original requirement IDs, reindexes accepted copies and checks clearance against the frame +and every previously accepted insert. Lattice shifts account for occupied poses before +quantity trimming; a pre-fill work limit declines giant grids and lets bounded NFP sampling +try instead. A null NFP proposal is not proof of geometric impossibility. No material-area +ratio cutoff is enabled: the proposed 0.10/0.20 routing thresholds still need measured +neutral and sanitized-job evidence. The router has no pipeline caller and does not change +stock, live demand, cutting order or posting safety; in-hole production use remains held. + ## Renamed engines The registry maps names used by earlier releases (the first two shipped as plug-ins) so saved From 1d50c6ce6011cda6fb8481d11c81003806552ef0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 17:47:12 -0400 Subject: [PATCH 04/15] feat(cutouts): compare measured ratio routes and certify leftovers --- .../Jobs/Cutouts/CutoutRouterTests.cs | 67 +++++++++++++- OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs | 92 +++++++++++++------ docs/nesting-engines.md | 10 +- 3 files changed, 138 insertions(+), 31 deletions(-) diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs index 2111705..6930a49 100644 --- a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs @@ -67,6 +67,45 @@ public class CutoutRouterTests AssertPhysical(frame, new[] { insert }, poses); } + [Fact] + public void NfpAddsOneMoreCopyToPartialShiftedLattice() + { + // Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted + // lattice keeps 11; an independently certified NFP pose occupies its leftover. + var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1); + var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12); + var lattice = CutoutLatticeFill.Fill(frame, 0, insert, 12, 0.25); + Assert.Equal(11, lattice.Count); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Equal(12, poses.Count); + Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex)); + AssertPhysical(frame, new[] { insert }, poses); + } + + [Fact] + public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy() + { + // 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy. + var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); + var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3); + Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Equal(2, poses.Count); + AssertPhysical(frame, new[] { insert }, poses); + } + + [Fact] + public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice() + { + var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1); + var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0)); + Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio); + Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25)); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + Assert.Single(poses); + AssertPhysical(frame, new[] { insert }, poses); + } + [Fact] public void DuplicateRequirementIdsAreRejectedBeforePlacement() { @@ -149,8 +188,34 @@ public class CutoutRouterTests Assert.InRange(ry, 5.25, 14.75); } else - Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= 4.75 + 0.00001); + { + var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right); + Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001); + } } } + // Independent rectangle-vs-rectangle distance, not the production Clears predicate. + for (var i = 0; i < poses.Count; i++) + for (var j = i + 1; j < poses.Count; j++) + { + var a = Bounds(poses[i]); + var b = Bounds(poses[j]); + var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right)); + var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top)); + Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001, + $"independent material gap too small: {poses[i]} vs {poses[j]}"); + } + + (double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose) + { + var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds; + var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom), + (box.Right, box.Top), (box.Left, box.Top) }; + var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation) + - c.Item2 * System.Math.Sin(pose.Rotation)).ToArray(); + var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation) + + c.Item2 * System.Math.Cos(pose.Rotation)).ToArray(); + return (x.Min(), x.Max(), y.Min(), y.Max()); + } } } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs index c47f939..78fdf9d 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -7,13 +7,17 @@ using System.Threading; namespace OpenNest.Engine.Jobs.Cutouts; /// Internal frame-local cutout proposal. Never changes demand, stock or a live plate. -/// Tries shifted partial Fill for a type with at least three requested copies, -/// prefers it only when it beats a single NFP pose, then fills residual demand -/// with NFP poses. No tuned ratio cutoff is claimed: the -/// historical 0.10/0.20 routing thresholds still require measured acceptance. -/// Fill's equal-score pose nondeterminism is tracked as PM c98c21bd. +/// Area ratio selects search order, not placement permission. The small-insert path +/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search; +/// the large-insert path tries NFP first. All accepted poses are clearance-checked. +/// Fill's equal-score pose nondeterminism remains PM c98c21bd. internal static class CutoutRouter { + // Calibrated as search-order hints, not hard geometry cutoffs. Round-hole neutral tests + // show a Fill win at 0.204, so the original proposed >0.20 NFP-only rule loses copies. + internal const double SmallRatio = 0.10; + internal const double LargeRatio = 0.35; + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, IReadOnlyList inserts, double spacing, CancellationToken token = default) { @@ -28,8 +32,7 @@ internal static class CutoutRouter ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex); ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count); var hole = frameGeometry.Cutouts[cutoutIndex]; - if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) - || !Supported(hole.BoundingBox)) + if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) || !Supported(hole.BoundingBox)) return Array.Empty(); if (inserts.Any(p => p == null || !SupportedGeometry(p))) return Array.Empty(); @@ -40,42 +43,75 @@ internal static class CutoutRouter var accepted = new List(); var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(); + var holeArea = System.Math.Abs(hole.Area()); // Preserve caller requirement order: priorities are a whole-job concern, not inferred here. foreach (var part in inserts) { token.ThrowIfCancellationRequested(); var geometry = JobPartGeometry.Read(part.Geometry); - if (part.Quantity >= 3) + var ratio = geometry.MaterialArea / holeArea; + // Unsupported or degenerate material cannot establish a useful routing ratio; + // NFP retains its own fail-closed checks and the bounded lattice is not tried. + var tryLattice = part.Quantity >= 3 && double.IsFinite(ratio) && ratio > 0; + var nfpFirst = !tryLattice || ratio >= LargeRatio; + var compareBoth = tryLattice && ratio >= SmallRatio && ratio < LargeRatio; + var nfp = nfpFirst ? Nfp(Array.Empty()) : null; + var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity) + ? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing, + CutoutLatticeFill.DefaultShiftSteps, occupied, token) + : Array.Empty(); + IReadOnlyList chosen; + if (compareBoth || (nfpFirst && lattice.Count > 0)) { - // Geometric work is bounded inside Fill. Do not infer a ratio threshold - // from material area before measuring cutout jobs; try both proposals. - var usable = CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, - spacing, CutoutLatticeFill.DefaultShiftSteps, occupied, token); - // A one-copy lattice adds no value over the geometry-aware NFP search; - // retain the latter's stable candidate order for that tie. - if (usable.Count > 1 || (usable.Count == 1 - && CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token) == null)) - foreach (var pose in usable) - { - token.ThrowIfCancellationRequested(); - Accept(pose); - } + nfp ??= Nfp(Array.Empty()); + var fromLattice = Complete(lattice); + chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp; } - while (accepted.Count(p => p.PartId == part.Id) < part.Quantity) + else if (nfpFirst) + chosen = nfp!; + else { - token.ThrowIfCancellationRequested(); - var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, occupied, token); - if (pose == null) - break; - Accept(pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }); + // A single Fill copy ties one NFP pose; prefer NFP's stable candidate + // order, but retain the lattice copy if bounded NFP found nothing. + var seed = lattice.Count > 1 ? lattice : Array.Empty(); + chosen = Complete(seed); + if (chosen.Count == 0 && lattice.Count == 1) + chosen = lattice; } - void Accept(NestJobPlacement pose) + foreach (var pose in chosen) { + token.ThrowIfCancellationRequested(); var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }; accepted.Add(indexed); occupied.Add((geometry, indexed)); } + + IReadOnlyList Complete(IReadOnlyList seed) + { + if (seed.Count >= part.Quantity) + return seed; + var result = new List(seed); + result.AddRange(Nfp(seed)); + return result; + } + + IReadOnlyList Nfp(IReadOnlyList seed) + { + var result = new List(); + var local = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(occupied); + local.AddRange(seed.Select(p => (geometry, p))); + while (seed.Count + result.Count < part.Quantity) + { + token.ThrowIfCancellationRequested(); + var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, local, token); + if (pose == null) + break; + result.Add(pose); + local.Add((geometry, pose)); + } + return result; + } } token.ThrowIfCancellationRequested(); return accepted.ToArray(); diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 6b92139..a81268a 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -89,8 +89,14 @@ original requirement IDs, reindexes accepted copies and checks clearance against and every previously accepted insert. Lattice shifts account for occupied poses before quantity trimming; a pre-fill work limit declines giant grids and lets bounded NFP sampling try instead. A null NFP proposal is not proof of geometric impossibility. No material-area -ratio cutoff is enabled: the proposed 0.10/0.20 routing thresholds still need measured -neutral and sanitized-job evidence. The router has no pipeline caller and does not change +ratio cutoff rejects a possible placement: the measured 0.10 and 0.35 ratios guide +search order only. Below 0.10 it starts with Fill for three or more copies; between +0.10 and 0.35 it compares Fill-plus-NFP with NFP-only counts; above 0.35 it tries +NFP first, then Fill if demand remains, taking the higher-count valid proposal. +An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. +Six geometry-only, anonymized real-job probes and neutral ring fixtures informed +the search-order hints; these limited cases are not a global density guarantee. +The router has no pipeline caller and does not change stock, live demand, cutting order or posting safety; in-hole production use remains held. ## Renamed engines From 71bd9643449d925915f2e0c978af5b0d5bc9ef53 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 17:53:22 -0400 Subject: [PATCH 05/15] test(cutouts): trace accepted lattice and NFP poses in one run --- .../Jobs/Cutouts/CutoutRouterTests.cs | 21 ++++++++++++------- OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs | 15 ++++++++++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs index 6930a49..1f37fe1 100644 --- a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutRouterTests.cs @@ -24,10 +24,14 @@ public class CutoutRouterTests var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1); var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3); var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0)); - Assert.Equal(3, CutoutLatticeFill.Fill(frame, 0, small, 3, 0.25).Count); - var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25); + var origins = new List<(NestJobPlacement Pose, bool Lattice)>(); + var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25, + (pose, lattice) => origins.Add((pose, lattice))); Assert.Equal(3, poses.Count(p => p.PartId == small.Id)); Assert.Single(poses, p => p.PartId == large.Id); + Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice); + Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice); + Assert.Equal(poses, origins.Select(row => row.Pose)); Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex)); AssertPhysical(frame, new[] { small, large }, poses); } @@ -38,10 +42,10 @@ public class CutoutRouterTests var frame = Frame(); var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0)); var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0)); - var alone = CutoutRouter.Fill(frame, 0, new[] { small }, 0.25); var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25); - Assert.Equal(alone, together); Assert.Equal(3, together.Count); + Assert.All(together, p => Assert.Equal(small.Id, p.PartId)); + Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex)); AssertPhysical(frame, new[] { small, impossible }, together); } @@ -74,10 +78,13 @@ public class CutoutRouterTests // lattice keeps 11; an independently certified NFP pose occupies its leftover. var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1); var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12); - var lattice = CutoutLatticeFill.Fill(frame, 0, insert, 12, 0.25); - Assert.Equal(11, lattice.Count); - var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25); + var origins = new List<(NestJobPlacement Pose, bool Lattice)>(); + var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25, + (pose, lattice) => origins.Add((pose, lattice))); Assert.Equal(12, poses.Count); + Assert.Equal(11, origins.Count(row => row.Lattice)); + Assert.Single(origins, row => !row.Lattice); + Assert.Equal(poses, origins.Select(row => row.Pose)); Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex)); AssertPhysical(frame, new[] { insert }, poses); } diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs index 78fdf9d..53caeaa 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs @@ -19,7 +19,14 @@ internal static class CutoutRouter internal const double LargeRatio = 0.35; internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, - IReadOnlyList inserts, double spacing, CancellationToken token = default) + IReadOnlyList inserts, double spacing, CancellationToken token = default) => + Fill(frame, cutoutIndex, inserts, spacing, null, token); + + /// Optional internal diagnostic receives only poses from the chosen proposal, + /// after indexing; true marks a retained lattice seed, false a residual NFP pose. + internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex, + IReadOnlyList inserts, double spacing, + Action? observeAccepted, CancellationToken token = default) { ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(inserts); @@ -61,11 +68,14 @@ internal static class CutoutRouter CutoutLatticeFill.DefaultShiftSteps, occupied, token) : Array.Empty(); IReadOnlyList chosen; + var retainedLattice = 0; if (compareBoth || (nfpFirst && lattice.Count > 0)) { nfp ??= Nfp(Array.Empty()); var fromLattice = Complete(lattice); chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp; + if (ReferenceEquals(chosen, fromLattice)) + retainedLattice = lattice.Count; } else if (nfpFirst) chosen = nfp!; @@ -77,14 +87,17 @@ internal static class CutoutRouter chosen = Complete(seed); if (chosen.Count == 0 && lattice.Count == 1) chosen = lattice; + retainedLattice = ReferenceEquals(chosen, lattice) ? lattice.Count : seed.Count; } + var localIndex = 0; foreach (var pose in chosen) { token.ThrowIfCancellationRequested(); var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) }; accepted.Add(indexed); occupied.Add((geometry, indexed)); + observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice); } IReadOnlyList Complete(IReadOnlyList seed) From 0b29c683b9fbf4d1f64e411a679f6dfbbc5d5bec Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 18:57:31 -0400 Subject: [PATCH 06/15] feat(cutouts): trial physical composite reconciliation in pipeline --- .../Cutouts/CutoutPipelinePreviewTests.cs | 304 ++++++++++++++++++ .../Jobs/Cutouts/CutoutPipelinePrepass.cs | 169 ++++++++++ OpenNest.Engine/Jobs/NestPipeline.cs | 76 ++++- docs/nesting-engines.md | 19 +- 4 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs create mode 100644 OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs new file mode 100644 index 0000000..7bd7e27 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs @@ -0,0 +1,304 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Cutouts; +using OpenNest.Geometry; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +public class CutoutPipelinePreviewTests +{ + private static NestPipelineRequest Request(string engine = "Irregular") + { + var frame = new Drawing("frame", TestShapes.Ring(20, 10)); + var insert = new Drawing("insert", TestShapes.Rectangle(3, 3)); + return new NestPipelineRequest(engine, + new[] { new NestItem { Drawing = frame, Quantity = 1 }, + new NestItem { Drawing = insert, Quantity = 1 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); + } + + [Theory] + [InlineData("Irregular")] + [InlineData("Default")] + public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine) + { + var request = Request(engine); + var result = NestPipeline.RunCutoutPreview(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.True(result.CanKeep); + var plate = Assert.Single(result.Plates); + Assert.Equal(2, plate.Parts.Count); + Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing); + Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing); + Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId)); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced))); + Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested); + Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); + } + + private sealed class Stub(Func solve) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) => solve(job); + } + + private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses) + { + var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count()); + var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity); + return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete, + complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound, + poses.Length == 0 ? Array.Empty() : + new[] { new NestJobPlateResult(0, job.Plates[0], poses) }, + job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, + placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))), + new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1, + job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) }); + } + + [Fact] + public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand() + { + var called = 0; + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + called++; + var proxy = Assert.Single(job.Parts); + Assert.StartsWith("__cutout-proxy-", proxy.Id); + Assert.True(proxy.Rotation.Allows(0)); + Assert.False(proxy.Rotation.Allows(System.Math.PI / 2)); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + }), "stub", request); + Assert.Equal(1, called); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, result.Plates.Single().Parts.Count); + } + + [Fact] + public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("did not complete")); + Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); + AssertCannotCommit(result, request); + } + + [Fact] + public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id; + var pose = new NestJobPlacement(id, 1, 11, 11, 0); + return Result(job, pose); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("accounting")); + } + + [Fact] + public void PartialBundleKeepsIndependentResidualByOriginalRequirement() + { + var request = Request("stub"); + request.Items[1].Quantity = 2; + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + var residual = job.Parts.Single(p => p.Id == "part-1"); + Assert.Equal(1, residual.Quantity); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0), + new NestJobPlacement(residual.Id, 0, 22, 1, 0)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-1", "part-1" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements + .Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex)); + Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing), + p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing)); + } + + [Fact] + public void UntrustedStockOverdrawOrRotationCannotBindAComposite() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, + System.Math.PI / 2)); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("rotation constraint")); + + var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult( + NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, job.Plates[0], new[] { + new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }), + new NestJobPlateResult(1, job.Plates[0], new[] { + new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) }, + Array.Empty(), Array.Empty())), "stub", request); + Assert.False(overdraw.CanKeep); + Assert.Empty(overdraw.Plates); + Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available")); + } + + [Fact] + public void CancellationBeforePreparationDoesNotCallEngine() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var engine = new Stub(_ => throw new Exception("Must not call Solve")); + Assert.Throws(() => + NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token)); + } + + [Fact] + public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion() + { + var request = Request("stub"); + request.Items[1].Quantity = 2; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0), + new NestJobPlacement("part-1", 0, 11, 11, 0)); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("required spacing")); + AssertCannotCommit(result, request); + } + + private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request) + { + var nest = new Nest(); + foreach (var item in request.Items) + nest.Drawings.Add(item.Drawing); + var empty = nest.CreatePlate(); + using var manager = new PlateManager(nest); + Assert.Throws(() => + NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true)); + Assert.Same(empty, Assert.Single(nest.Plates)); + Assert.Empty(empty.Parts); + Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested)); + } + + private sealed class CollectProgress : IProgress + { + public List Events { get; } = new(); + public void Report(NestJobProgress value) => Events.Add(value); + } + + [Fact] + public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape() + { + var request = Request("stub"); + var progress = new CollectProgress(); + var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress); + Assert.False(result.CanKeep); + Assert.Single(progress.Events); + Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage); + Assert.Equal(0, progress.Events[0].CommittedPlates); + Assert.Equal(0, progress.Events[0].CommittedParts); + } + + private sealed class ProgressStub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, + job.Plates[0].Id, 0, 1, 1)); + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, + job.Plates[0].Id, 0, 1, 1)); + return Result(job); + } + } + + [Fact] + public void ReorderedButContiguousEngineIndicesAreAccepted() + { + var request = Request("stub"); + request.Items[1].Quantity = 3; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0), + new NestJobPlacement("part-1", 1, 22, 1, 0), + new NestJobPlacement("part-1", 0, 22, 9, 0)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements + .Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment) + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + return new NestJobResult(good.Status, good.StopReason, good.Plates, + fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment, + fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) }); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("accounting")); + } + + [Fact] + public void SameDrawingInTwoRequirementRowsRetainsSeparateIds() + { + var initial = Request("stub"); + var request = initial with + { + Items = new[] { initial.Items[0], initial.Items[1], + new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } }, + Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + var residual = job.Parts.Where(p => p.Id != proxy.Id) + .Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0)); + return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) } + .Concat(residual).ToArray()); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-1", "part-2" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + Assert.Same(result.Plates.Single().Parts[1].BaseDrawing, + result.Plates.Single().Parts[2].BaseDrawing); + } + + [Fact] + public void OrdinaryPipelineIsStillUnchanged() + { + var request = Request("stub"); + var result = NestPipeline.Run(new Stub(job => + { + Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job); + }), "stub", request); + Assert.True(result.CanKeep); + Assert.Empty(result.Plates); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs new file mode 100644 index 0000000..8d8f998 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -0,0 +1,169 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Internal-only single-frame composite trial. No normal pipeline caller enables this. +internal sealed class CutoutPipelinePrepass +{ + private readonly string proxyId; + private readonly string frameId; + private readonly IReadOnlyList localInserts; + + private CutoutPipelinePrepass(NestJob engineJob, string proxyId, string frameId, + IReadOnlyList localInserts) + { + EngineJob = engineJob; + this.proxyId = proxyId; + this.frameId = frameId; + this.localInserts = localInserts; + } + + internal NestJob EngineJob { get; } + + internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (original.Plates.Count == 0) + return null; + // A single fixed-zero proxy is safe even when a symmetric outer perimeter causes + // an engine to deduplicate rotations. General orientation intersections are deferred. + var spacing = original.Plates.Max(p => p.PartSpacing); + foreach (var frame in original.Parts.Where(p => p.Quantity == 1 && p.Rotation.Allows(0))) + { + var geometry = JobPartGeometry.TryRead(frame.Geometry); + if (geometry == null || geometry.Cutouts.Count == 0) + continue; + var candidates = original.Parts.Where(p => p.Id != frame.Id).ToArray(); + if (candidates.Length == 0) + continue; + for (var hole = 0; hole < geometry.Cutouts.Count; hole++) + { + token.ThrowIfCancellationRequested(); + var poses = CutoutRouter.Fill(frame, hole, candidates, spacing, token); + if (poses.Count == 0) + continue; + var reserved = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count()); + var proxyId = "__cutout-proxy-" + frame.Id; + if (original.Parts.Any(p => p.Id == proxyId)) + throw new InvalidOperationException("Cutout proxy ID collides with a requirement."); + var transformed = new List(); + foreach (var part in original.Parts) + { + if (part.Id == frame.Id) + transformed.Add(new NestJobPart(proxyId, part.Geometry, 1, part.Priority, + RotationPolicy.Fixed(0))); + else + { + var remaining = part.Quantity - reserved.GetValueOrDefault(part.Id); + if (remaining > 0) + transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining, + part.Priority, part.Rotation)); + } + } + return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates, + original.Options), proxyId, frame.Id, poses); + } + } + return null; + } + + /// Reject untrusted accounting before expansion. A failed trial is never bindable, + /// including with allowInvalid. Only actually placed proxies consume reserved inserts. + internal bool TryExpand(NestJob original, NestJobResult result, out NestJobResult? expanded, + out string failure) + { + expanded = null; + failure = "Invalid cutout engine accounting"; + var parts = EngineJob.Parts.ToDictionary(p => p.Id); + var counts = parts.ToDictionary(p => p.Key, _ => 0); + var indices = parts.ToDictionary(p => p.Key, _ => new HashSet()); + var used = EngineJob.Plates.ToDictionary(s => s.Id, _ => 0); + var expectedIndex = 0; + foreach (var sheet in result.Plates) + { + if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0 + || !used.ContainsKey(sheet.StockId)) + return false; + used[sheet.StockId]++; + foreach (var pose in sheet.Placements) + { + if (pose.PartId == null || !parts.ContainsKey(pose.PartId) + || pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex) + || ++counts[pose.PartId] > parts[pose.PartId].Quantity) + return false; + } + } + if (indices.Any(row => row.Value.Any(index => index >= counts[row.Key]))) + return false; + if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count) + return false; + foreach (var row in result.Fulfillment) + if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part) + || row.Requested != part.Quantity || row.Placed != counts[row.PartId] + || row.Unplaced != part.Quantity - counts[row.PartId] + || result.Fulfillment.Count(r => r.PartId == row.PartId) != 1) + return false; + foreach (var row in result.StockUsage) + { + var stock = EngineJob.Plates.FirstOrDefault(s => s.Id == row.StockId); + if (stock == null || row.Used != used[row.StockId] + || row.Remaining != stock.Quantity - row.Used + || result.StockUsage.Count(r => r.StockId == row.StockId) != 1) + return false; + } + var complete = parts.Values.All(p => counts[p.Id] == p.Quantity); + if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete) + || (complete && result.StopReason != NestJobStopReason.Completed) + || (!complete && result.StopReason == NestJobStopReason.Completed)) + return false; + + // An incomplete solve cannot silently reserve inserts or reach the commit boundary. + if (!complete) + { + failure = "Cutout engine did not complete the transformed job"; + return false; + } + var builder = new NestJobResultBuilder(original); + foreach (var sheet in result.Plates) + { + var poses = new List<(string PartId, double X, double Y, double Rotation)>(); + foreach (var pose in sheet.Placements) + { + if (pose.PartId != proxyId) + { + poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation)); + continue; + } + poses.Add((frameId, pose.X, pose.Y, pose.Rotation)); + var sine = System.Math.Sin(pose.Rotation); + var cosine = System.Math.Cos(pose.Rotation); + foreach (var local in localInserts) + poses.Add((local.PartId, + pose.X + local.X * cosine - local.Y * sine, + pose.Y + local.X * sine + local.Y * cosine, + pose.Rotation + local.Rotation)); + } + // Canonical stock reference, not an engine-supplied object with altered settings. + var stock = original.Plates.Single(s => s.Id == sheet.StockId); + try + { + builder.AddSheet(stock, poses); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return false; + } + } + expanded = builder.Build(result.StopReason); + if (expanded.Status != NestJobStatus.Complete) + { + expanded = null; + return false; + } + return true; + } +} diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 858a39b..45507ff 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Cutouts; namespace OpenNest.Engine.Jobs; @@ -72,6 +73,15 @@ public sealed class NestPipelineResult /// public static class NestPipeline { + private sealed class PreviewProgress(IProgress inner) : IProgress + { + public void Report(NestJobProgress value) + { + if (value != null && value.Stage == NestJobStage.EvaluatingCandidate) + inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 }); + } + } + /// Resolves through /// ; unknown names throw . /// Engine cancellation propagates unchanged. @@ -93,6 +103,32 @@ public static class NestPipeline NestPipelineRequest request, IProgress progress = null, CancellationToken token = default + ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false); + + /// Internal test-only integration trial. Normal Run never prepares cutouts; + /// this entry point must not be called by production front ends before cutting/post gates. + internal static NestPipelineResult RunCutoutPreview( + INestingEngine engine, + string engineName, + NestPipelineRequest request, + IProgress progress = null, + CancellationToken token = default + ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true); + + internal static NestPipelineResult RunCutoutPreview( + NestPipelineRequest request, + IProgress progress = null, + CancellationToken token = default + ) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName), + request.EngineName, request, progress, token); + + private static NestPipelineResult RunCore( + INestingEngine engine, + string engineName, + NestPipelineRequest request, + IProgress progress, + CancellationToken token, + bool cutoutPreview ) { ArgumentNullException.ThrowIfNull(engine); @@ -115,10 +151,15 @@ public static class NestPipeline var job = new NestJob(parts, request.Stock, request.Options); NestJobValidator.Validate(job); + var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null; + var engineJob = prepass?.EngineJob ?? job; + if (prepass != null) + NestJobValidator.Validate(engineJob); var clock = Stopwatch.StartNew(); var raw = - engine.Solve(job, progress, token) + engine.Solve(engineJob, prepass == null || progress == null + ? progress : new PreviewProgress(progress), token) ?? throw new InvalidOperationException($"Engine '{engineName}' returned no result."); var solveTime = clock.Elapsed; token.ThrowIfCancellationRequested(); @@ -129,7 +170,27 @@ public static class NestPipeline kv => kv.Value.Name ?? kv.Key, StringComparer.Ordinal ); - var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep); + var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id, + p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal); + var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList(); + if (prepass != null) + { + // A compound may be physically invalid even when its envelope is legal. + // Refuse every unverified trial, including with explicit invalid-result consent. + var failure = "Cutout engine output failed reconciliation"; + if (violations.Count == 0 && prepass.TryExpand(job, raw, out var expanded, out failure)) + { + raw = expanded; + violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep)); + canKeep = physicalKeep && violations.Count == 0; + } + else + { + if (violations.Count == 0) + violations.Add(failure); + canKeep = false; + } + } var validationTime = clock.Elapsed; var plates = canKeep @@ -141,6 +202,17 @@ public static class NestPipeline .ToList() : new List(); + token.ThrowIfCancellationRequested(); + if (prepass != null && canKeep) + { + var count = 0; + for (var i = 0; i < raw.Plates.Count; i++) + { + count += raw.Plates[i].Placements.Count; + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, + raw.Plates[i].StockId, i, i + 1, count)); + } + } token.ThrowIfCancellationRequested(); return new NestPipelineResult( engineName, diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index a81268a..049f068 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -70,8 +70,9 @@ remain separate sequencer/verification work. ## Filling cutouts (not yet in production) Placing parts inside another part's enclosed cutout is being built as a step that runs before any -engine, so every engine benefits. Nothing calls it yet: a part inside a cutout must be cut before -the cutout's contour, and the sequencer does not enforce that order. +engine, so every engine benefits. Only an internal test-only pipeline preview calls it: a part +inside a cutout must be cut before the cutout's contour, and the cutting/post safety gates are +not complete. Ordinary whole-job calls do not enable this path. `CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts @@ -96,8 +97,18 @@ NFP first, then Fill if demand remains, taking the higher-count valid proposal. An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. Six geometry-only, anonymized real-job probes and neutral ring fixtures informed the search-order hints; these limited cases are not a global density guarantee. -The router has no pipeline caller and does not change -stock, live demand, cutting order or posting safety; in-hole production use remains held. +The internal `NestPipeline.RunCutoutPreview` prepares at most one quantity-one frame with one +cutout as a fixed-zero proxy. Reserved inserts are removed from the engine's independent demand; +remaining demand is offered once to the selected engine. Only after a complete, accounted-for +engine result does it expand the proxy back to original requirement IDs, validate every physical +pose, stock and quantity, then bind caller drawings. Incomplete, forged or geometrically invalid +composites return no bindable plates even with invalid-result consent. Candidate evaluation +progress is forwarded with unverified proxy commitment counts zeroed; transformed proxy commits +are hidden. Physical commits are reported only after physical validation. Frames needing other +orientations or repeated frame copies still run through the ordinary unbundled path. This is a +narrow internal integration trial, not a production setting or a cut-ready nest; multi-frame +routing, rotation compatibility, save/reload and cutting/post safety are still pending before +enablement. ## Renamed engines From 07e682a65424690c759a5013f5d8e0cd1837a377 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 19:15:09 -0400 Subject: [PATCH 07/15] test(cutting): add opt-in complete-route performance probe --- .../CuttingPlanning/ReorderSearchTests.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs index 634ee08..0dfecef 100644 --- a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs +++ b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs @@ -34,6 +34,48 @@ public class ReorderSearchTests Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate. } + [SkippableFact] + public void DenseGrid144_PerformanceProbe() + { + Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_CUTTING_PERF") == "1", + "Set OPENNEST_RUN_CUTTING_PERF=1 for the 144-part cutting-planner probe."); + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Size = new Size(100, 100); + foreach (var part in Grid(144, false)) + plate.Parts.Add(part); + var source = plate.Parts.ToArray(); + var timer = System.Diagnostics.Stopwatch.StartNew(); + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 57600)); + timer.Stop(); + // Expanded owned execution includes called hole programs; Program.ToString() does not, + // and rounds coordinates. Hash exact scalar bits in execution order instead. + static string Bits(double value) => BitConverter.DoubleToInt64Bits(value).ToString("X16"); + static string Point(Vector point) => $"{Bits(point.X)},{Bits(point.Y)}"; + var payload = string.Join("\n", result.ProposedOrder.SelectMany(p => + new[] { $"part:{p.SourceOrdinal}:{Point(p.Location)}:{Bits(p.Rotation)}" }.Concat( + p.Execution.Motions.Select(m => + { + var geometry = m.Curve?.ToEntity() switch + { + Arc arc => $"arc:{Point(arc.Center)}:{Bits(arc.Radius)}:{Bits(arc.StartAngle)}:{Bits(arc.EndAngle)}:{arc.Rotation}", + Circle circle => $"circle:{Point(circle.Center)}:{Bits(circle.Radius)}:{circle.Rotation}", + Line => "line", + null => "none", + _ => throw new NotSupportedException("Unexpected execution curve."), + }; + return $"{m.Layer}:{m.Rapid}:{(m.Start is { } start ? Point(start) : "null")}:{Point(m.End)}:{geometry}"; + })))); + var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData( + System.Text.Encoding.UTF8.GetBytes(payload))); + Console.WriteLine($"Cutting144 elapsedMs={timer.Elapsed.TotalMilliseconds:F1} expansions={result.Expansions} status={result.Status} executionSha256={hash}"); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(source.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key)); + Assert.Equal(source, plate.Parts); + } + [Fact] public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst() { From 138c86526a7cad3339b992d867a38b6a7b52e5f9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 19:15:28 -0400 Subject: [PATCH 08/15] perf(cutting): reuse native curve wrappers for lead queries --- .../Diagnostics/PostVerificationGeometry.cs | 11 +++-- .../NativeContactRegressionTests.cs | 44 +++++++++++++++++++ .../cutting-planner-native-cache.md | 26 +++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 docs/performance/cutting-planner-native-cache.md diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index b4106ee..606400f 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -106,8 +106,13 @@ internal static class PostVerificationGeometry Center = center; Radius = radius; Sweep = sweep; + nativeEntity = new Lazy(ToEntity); } + // Never expose this mutable native wrapper; its geometry is read-only after creation. + // Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks. + private readonly Lazy nativeEntity; + internal Vector Start { get; } internal Vector End { get; } private Vector? Center { get; } @@ -170,12 +175,12 @@ internal static class PostVerificationGeometry : Start + (End - Start) * (distance / Length); } - internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon; + internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon; internal IReadOnlyList Contacts(Curve other, out bool overlap) { - var entity = ToEntity(); - var candidate = other.ToEntity(); + var entity = nativeEntity.Value; + var candidate = other.nativeEntity.Value; // Native arc filters discard NaN supporting-circle intersections. Inspect // both unfiltered queries first: roundoff can differ by operand direction. // Coincident supports have separate overlap handling below. diff --git a/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs b/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs index dd81ee2..02bbb68 100644 --- a/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs +++ b/OpenNest.Tests/CuttingPlanning/NativeContactRegressionTests.cs @@ -119,6 +119,50 @@ public class NativeContactRegressionTests Assert.Throws(() => circle.Contacts(line, out _)); } + [Fact] + public void ReusedNativeCurves_KeepContactAndContainmentVerdictsAcrossCallsAndThreads() + { + var lineStart = new Vector(-2, 0); + var lineEnd = new Vector(2, 0); + var arcCenter = new Vector(0, 0.003); + var arcStart = new Vector(0.003, 0.003); + var circleCenter = new Vector(0, 0); + var circleStart = new Vector(0.003, 0); + var line = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false); + var arc = PostVerificationGeometry.Curve.Create(arcStart, new Vector(-0.003, 0.003), arcCenter, false); + var circle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false); + var pairs = new[] { (line, arc), (arc, line), (line, circle), (circle, line), (arc, circle), (circle, arc) }; + var probes = new[] { Vector.Zero, new Vector(0.003, 0), new Vector(0, 0.006) }; + + string Observe(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b) + { + var inside = string.Join(",", probes.Select(p => $"{a.Contains(p)}:{b.Contains(p)}")); + try + { + var contacts = a.Contacts(b, out var overlap); + return $"{inside}|{overlap}|" + string.Join(";", contacts.Select(p => + $"{BitConverter.DoubleToInt64Bits(p.X)}:{BitConverter.DoubleToInt64Bits(p.Y)}")); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + return $"{inside}|{ex.GetType().Name}:{ex.Message}"; + } + } + + var expected = pairs.Select(p => Observe( + PostVerificationGeometry.Curve.Create(p.Item1.Start, p.Item1.End, + ReferenceEquals(p.Item1, arc) ? arcCenter : ReferenceEquals(p.Item1, circle) ? circleCenter : null, false), + PostVerificationGeometry.Curve.Create(p.Item2.Start, p.Item2.End, + ReferenceEquals(p.Item2, arc) ? arcCenter : ReferenceEquals(p.Item2, circle) ? circleCenter : null, false))).ToArray(); + for (var repeat = 0; repeat < 3; repeat++) + Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray()); + Parallel.For(0, 8, _ => Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray())); + // A separate pair has not initialized either native wrapper before the readers race. + var coldLine = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false); + var coldCircle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false); + Parallel.For(0, 8, _ => Assert.Equal(expected[2], Observe(coldLine, coldCircle))); + } + private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at) { var cos = System.Math.Cos(angle); diff --git a/docs/performance/cutting-planner-native-cache.md b/docs/performance/cutting-planner-native-cache.md new file mode 100644 index 0000000..d7d7d43 --- /dev/null +++ b/docs/performance/cutting-planner-native-cache.md @@ -0,0 +1,26 @@ +# Cutting-planner native curve reuse + +The opt-in 144-part synthetic grid probe measures only `CuttingPlanService.Plan`, not build, +fixture creation, assertions, desktop rendering or posting: + +```sh +OPENNEST_RUN_CUTTING_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \ + --filter 'FullyQualifiedName~ReorderSearchTests.DenseGrid144_PerformanceProbe' \ + --logger 'console;verbosity=detailed' +``` + +Run separate fresh processes on the same machine with build and filesystem caches warm; +there is no in-process plan/JIT warmup, so these are first-call measurements. Compare +`Cutting144 elapsedMs`, `expansions`, `status` and `executionSha256`. The hash covers +expanded owned motions (including called hole programs), their layers, rapid flags, +exact coordinate bits, curve type and arc/circle parameters in source order; it does +not claim byte-identical authored G-code flags or post output. The test also requires +a Ready, independently replayed result and exact source-part conservation. Without +the environment flag it skips. Timing is diagnostic, not a deadline assertion. + +The native curve wrapper now lazily owns one private native `Entity` for repeated +contact/containment queries. `ToEntity()` still makes a fresh object for other callers. +No lead or rapid check is skipped: long-lead/tiny-circle numerical contacts make a +fixed-extent material prefilter unsafe. Retest the cutting-planner and native-contact +regressions, especially near tangencies, repeated/concurrent queries and incomplete +material; do not infer posted NC or machine acceptance from this probe. From b750475228816131169fa2409337f4e0dbac9d0c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 20:15:17 -0400 Subject: [PATCH 09/15] fix(pipeline): refuse changed drawings before binding or commit --- .../Jobs/NestPipelineCommitTests.cs | 31 +++++++++++ .../Jobs/NestPipelineTests.cs | 14 +++++ OpenNest.Engine/Jobs/NestPipeline.cs | 25 ++++++++- OpenNest.Engine/Jobs/NestPipelineCommit.cs | 2 + .../Jobs/NestPipelineDrawingFreshness.cs | 51 +++++++++++++++++++ 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs index 2cbbd5f..275f4ee 100644 --- a/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs @@ -75,6 +75,37 @@ public class NestPipelineCommitTests Assert.Equal(2, Assert.Single(applied).Parts.Count); } + [Fact] + public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent() + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + var empty = nest.CreatePlate(); + using var manager = new PlateManager(nest); + var result = NestPipeline.Run(new PreMutationStub(), "stub", + new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, + new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) })); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + drawing.Program = TestDrawingFactory.Rectangle(100, 100); + Assert.Throws(() => + NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true)); + Assert.Same(empty, Assert.Single(nest.Plates)); + Assert.Empty(empty.Parts); + Assert.Equal(0, drawing.Quantity.Nested); + } + + private sealed class PreMutationStub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + } + } + [Fact] public void CancelledCommitDoesNotCreateAnyPlate() { diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs index fab6faf..a4bc077 100644 --- a/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs @@ -258,6 +258,20 @@ public class NestPipelineTests ); } + [Fact] + public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot() + { + var item = Item("bracket", 1); + var result = NestPipeline.Run(new StubEngine(job => + { + item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100); + return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0)); + }), "Mutator", Request("Mutator", item)); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket")); + } + [Fact] public void CancellationPropagatesWithoutAResult() { diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 45507ff..8d9d0e5 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -37,7 +37,8 @@ public sealed class NestPipelineResult IReadOnlyList violations, bool canKeep, TimeSpan solveTime, - TimeSpan validationTime + TimeSpan validationTime, + IReadOnlyDictionary drawingsByPartId ) { EngineName = engineName; @@ -48,6 +49,7 @@ public sealed class NestPipelineResult CanKeep = canKeep; SolveTime = solveTime; ValidationTime = validationTime; + DrawingsByPartId = drawingsByPartId; } public string EngineName { get; } @@ -63,6 +65,7 @@ public sealed class NestPipelineResult public NestJobStopReason StopReason => Raw.StopReason; public TimeSpan SolveTime { get; } public TimeSpan ValidationTime { get; } + internal IReadOnlyDictionary DrawingsByPartId { get; } } /// @@ -191,6 +194,10 @@ public static class NestPipeline canKeep = false; } } + var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId); + violations.AddRange(freshness); + if (freshness.Count > 0) + canKeep = false; var validationTime = clock.Elapsed; var plates = canKeep @@ -202,6 +209,19 @@ public static class NestPipeline .ToList() : new List(); + // Binding clones the caller's current Program; a concurrent edit during binding + // must not return parts whose bytes differ from the already validated snapshot. + if (canKeep) + { + var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId); + if (changes.Count > 0) + { + violations.AddRange(changes); + plates.Clear(); + canKeep = false; + } + } + token.ThrowIfCancellationRequested(); if (prepass != null && canKeep) { @@ -222,7 +242,8 @@ public static class NestPipeline violations, canKeep, solveTime, - validationTime + validationTime, + drawingsByPartId ); } } diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index cd59bb3..dea346a 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -20,6 +20,8 @@ public static class NestPipelineCommit token.ThrowIfCancellationRequested(); if (!result.CanKeep || (!result.IsValid && !allowInvalid)) throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations."); + if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0) + throw new InvalidOperationException("A drawing changed after the nesting proposal was validated; run Auto Nest again."); var applied = new List(); // Commit is synchronous on the caller's owning thread. Cancellation is checked diff --git a/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs b/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs new file mode 100644 index 0000000..de8dfad --- /dev/null +++ b/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Jobs; + +/// Refuse a proposal when a caller drawing no longer matches its checked snapshot. +internal static class NestPipelineDrawingFreshness +{ + internal static IReadOnlyList Changes(NestJob job, + IReadOnlyDictionary drawings) + { + var changed = new List(); + foreach (var requirement in job.Parts) + { + if (!drawings.TryGetValue(requirement.Id, out var drawing) + || drawing?.Program == null) + { + changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot"); + continue; + } + try + { + var current = PartGeometrySnapshot.FromProgram(drawing.Program); + if (Same(requirement.Geometry, current)) + continue; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException + or InvalidOperationException) + { + // A now-unsupported program is not the geometry that was checked. + } + changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot"); + } + return changed; + } + + private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) => + a.Mode == b.Mode && a.Motions.Count == b.Motions.Count + && a.Motions.Zip(b.Motions).All(pair => + pair.First.Type == pair.Second.Type + && Bits(pair.First.X) == Bits(pair.Second.X) + && Bits(pair.First.Y) == Bits(pair.Second.Y) + && Bits(pair.First.CenterX) == Bits(pair.Second.CenterX) + && Bits(pair.First.CenterY) == Bits(pair.Second.CenterY) + && pair.First.Rotation == pair.Second.Rotation + && pair.First.Layer == pair.Second.Layer + && pair.First.Suppressed == pair.Second.Suppressed); + + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); +} From 1b9d8a36d8e3d4be961732baacdd377c9e0c318e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 20:28:05 -0400 Subject: [PATCH 10/15] fix(pipeline): certify bound programs and cancellation before commit --- .../Jobs/NestPipelineCommitTests.cs | 36 ++++++++++++++ .../Jobs/NestPipelineTests.cs | 24 ++++++++++ OpenNest.Engine/Jobs/NestPipeline.cs | 3 +- OpenNest.Engine/Jobs/NestPipelineCommit.cs | 7 ++- .../Jobs/NestPipelineDrawingFreshness.cs | 48 +++++++++++++++++++ 5 files changed, 115 insertions(+), 3 deletions(-) diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs index 275f4ee..071ef46 100644 --- a/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineCommitTests.cs @@ -106,6 +106,42 @@ public class NestPipelineCommitTests } } + private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source) + : OpenNest.CNC.LinearMove(x, y) + { + public override OpenNest.CNC.CodeType Type + { + get + { + source.Cancel(); + return base.Type; + } + } + } + + [Fact] + public void CancellationDuringFreshnessScanDoesNotMutateNest() + { + var drawing = new Drawing("part", TestDrawingFactory.Rectangle()); + var nest = new Nest(); + nest.Drawings.Add(drawing); + var empty = nest.CreatePlate(); + using var manager = new PlateManager(nest); + var result = NestPipeline.Run(new PreMutationStub(), "stub", + new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, + new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) })); + Assert.True(result.IsValid); + using var cts = new CancellationTokenSource(); + var proposed = Assert.Single(Assert.Single(result.Plates).Parts); + proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts); + Assert.Throws(() => + NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token)); + Assert.True(cts.IsCancellationRequested); + Assert.Same(empty, Assert.Single(nest.Plates)); + Assert.Empty(empty.Parts); + Assert.Equal(0, drawing.Quantity.Nested); + } + [Fact] public void CancelledCommitDoesNotCreateAnyPlate() { diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs index a4bc077..b3ff92f 100644 --- a/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs @@ -272,6 +272,30 @@ public class NestPipelineTests Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket")); } + private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y) + { + public override ICode Clone() + { + var before = EndPoint; + EndPoint = new Vector(100, 100); + try { return base.Clone(); } + finally { EndPoint = before; } + } + } + + [Fact] + public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry() + { + var item = Item("bracket", 1); + item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0); + var result = NestPipeline.Run(new StubEngine(job => + OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))), + "Transient", Request("Transient", item)); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket")); + } + [Fact] public void CancellationPropagatesWithoutAResult() { diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 8d9d0e5..7e33e5e 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -213,7 +213,8 @@ public static class NestPipeline // must not return parts whose bytes differ from the already validated snapshot. if (canKeep) { - var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId); + var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList(); + changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId)); if (changes.Count > 0) { violations.AddRange(changes); diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs index dea346a..cf46ac7 100644 --- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs +++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs @@ -20,8 +20,11 @@ public static class NestPipelineCommit token.ThrowIfCancellationRequested(); if (!result.CanKeep || (!result.IsValid && !allowInvalid)) throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations."); - if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0) - throw new InvalidOperationException("A drawing changed after the nesting proposal was validated; run Auto Nest again."); + if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0 + || NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw, + result.Plates, result.DrawingsByPartId).Count > 0) + throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again."); + token.ThrowIfCancellationRequested(); var applied = new List(); // Commit is synchronous on the caller's owning thread. Cancellation is checked diff --git a/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs b/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs index de8dfad..9f81005 100644 --- a/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs +++ b/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; namespace OpenNest.Engine.Jobs; @@ -35,6 +37,52 @@ internal static class NestPipelineDrawingFreshness return changed; } + /// Check the actual cloned programs, not only the drawing before/after + /// binding. A transient edit during Clone may leave the drawing unchanged. + internal static IReadOnlyList BoundChanges(NestJob job, NestJobResult raw, + IReadOnlyList plates, IReadOnlyDictionary drawings) + { + var changed = new List(); + var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); + if (plates.Count != raw.Plates.Count) + return new[] { "Bound plate count changed after validation" }; + for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++) + { + var poses = raw.Plates[sheetIndex].Placements; + var bound = plates[sheetIndex].Parts; + if (bound.Count != poses.Count) + return new[] { "Bound part count changed after validation" }; + for (var index = 0; index < poses.Count; index++) + { + var pose = poses[index]; + var actual = bound[index]; + if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement) + || !drawings.TryGetValue(pose.PartId, out var drawing) + || !ReferenceEquals(actual.BaseDrawing, drawing)) + return new[] { "A bound part no longer matches its requirement" }; + try + { + var expected = new Part(DrawingJobMapper.CreateDrawing(requirement)); + expected.Rotate(pose.Rotation); + expected.Location = new Vector(pose.X, pose.Y); + if (Same(PartGeometrySnapshot.FromProgram(expected.Program), + PartGeometrySnapshot.FromProgram(actual.Program)) + && Bits(expected.Location.X) == Bits(actual.Location.X) + && Bits(expected.Location.Y) == Bits(actual.Location.Y) + && Bits(expected.Rotation) == Bits(actual.Rotation)) + continue; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException + or InvalidOperationException) + { + // A changed/unsupported program cannot represent the checked pose. + } + changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation"); + } + } + return changed; + } + private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) => a.Mode == b.Mode && a.Motions.Count == b.Motions.Count && a.Motions.Zip(b.Motions).All(pair => From e897258a85ad20bba59f241a72f2f720ac37df72 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 20:36:43 -0400 Subject: [PATCH 11/15] feat(cutouts): reconcile repeated frames and inserts through one solve --- .../Cutouts/CutoutPipelinePreviewTests.cs | 445 ++++++++++++++++++ .../Jobs/Cutouts/CutoutLatticeFill.cs | 5 +- .../Jobs/Cutouts/CutoutPipelinePrepass.cs | 197 +++++--- OpenNest.Engine/Jobs/NestPipeline.cs | 4 +- .../Engine/CutoutPipelinePersistenceTests.cs | 73 +++ docs/nesting-engines.md | 32 +- 6 files changed, 679 insertions(+), 77 deletions(-) create mode 100644 OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs index 7bd7e27..9a273e8 100644 --- a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs @@ -6,6 +6,10 @@ using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; namespace OpenNest.Engine.Tests.Jobs.Cutouts; +[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)] +public sealed class CutoutPipelineRegistryCollection { } + +[Collection("Cutout pipeline registry")] public class CutoutPipelinePreviewTests { private static NestPipelineRequest Request(string engine = "Irregular") @@ -84,6 +88,8 @@ public class CutoutPipelinePreviewTests Assert.False(result.CanKeep); Assert.Empty(result.Plates); Assert.Contains(result.Violations, v => v.Contains("did not complete")); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1), + (f.Requested, f.Placed, f.Unplaced))); Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); AssertCannotCommit(result, request); } @@ -289,6 +295,445 @@ public class CutoutPipelinePreviewTests result.Plates.Single().Parts[2].BaseDrawing); } + [Fact] + public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets() + { + var request = Request("stub"); + request.Items[0].Quantity = 2; + request.Items[1].Quantity = 2; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + request = request with + { + Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, + Options = new NestJobOptions(maxPlates: 2) + }; + var calls = 0; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + calls++; + var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray(); + Assert.Equal(2, proxies.Length); + Assert.Equal(2, job.Parts.Count); + var builder = new NestJobResultBuilder(job); + foreach (var proxy in proxies) + builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.Equal(1, calls); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, result.Raw.Plates.Count); + Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" }, + plate.Placements.Select(p => p.PartId))); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0), + (f.Requested, f.Placed, f.Unplaced))); + Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements) + .Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex)); + } + + [Fact] + public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal() + { + var request = Request("stub"); + request.Items[0].Quantity = 2; + request.Items[1].Quantity = 2; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } }; + var calls = 0; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + calls++; + var first = job.Parts.Single(p => p.Id.EndsWith("-0")); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.NoPlacementFound); + }), "stub", request); + Assert.Equal(1, calls); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1), + (f.Requested, f.Placed, f.Unplaced))); + Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + AssertCannotCommit(result, request); + } + + [Fact] + public void DifferentFrameRequirementsShareScarceInsertsByIdentity() + { + var initial = Request("stub"); + var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10)); + initial.Items[1].Quantity = 2; + initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + var request = initial with + { + Items = new[] { initial.Items[0], + new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] }, + Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray(); + Assert.Equal(2, proxies.Length); + Assert.Equal(2, job.Parts.Count); + var builder = new NestJobResultBuilder(job); + foreach (var proxy in proxies) + builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" }, + result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId)); + Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements) + .Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex)); + } + + [Fact] + public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation() + { + var frame = TestShapes.Rectangle(20, 20); + frame.MoveTo(5, 5); + frame.LineTo(5, 15); + frame.LineTo(15, 15); + frame.LineTo(15, 5); + frame.LineTo(5, 5); + var quarter = System.Math.PI / 2; + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1, + StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1, + StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = Assert.Single(job.Parts); + Assert.True(proxy.Rotation.Allows(quarter)); + Assert.False(proxy.Rotation.Allows(0)); + return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var poses = result.Raw.Plates.Single().Placements; + Assert.Equal(quarter, poses[0].Rotation, 8); + Assert.Equal(quarter, poses[1].Rotation, 8); + // Independent square-hole oracle, not the production clearance predicate. + var localX = poses[1].Y - poses[0].Y; + var localY = -(poses[1].X - poses[0].X); + foreach (var dx in new[] { 0.0, 3.0 }) + foreach (var dy in new[] { 0.0, 3.0 }) + { + Assert.InRange(localX + dx, 5.25, 14.75); + Assert.InRange(localY + dy, 5.25, 14.75); + } + } + + [Fact] + public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant() + { + var request = Request("stub") with + { + Stock = new[] { + new NestPlateStock("tight", new Size(22, 22), 1, 0.25), + new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) } + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = Assert.Single(job.Parts); + var builder = new NestJobResultBuilder(job); + builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal("wide", result.Raw.Plates.Single().StockId); + Assert.Equal(3, result.Plates.Single().Stock.Quadrant); + Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing); + Assert.Equal(2, result.Plates.Single().Parts.Count); + } + + [Fact] + public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary() + { + var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N"); + var calls = 0; + NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job => + { + calls++; + var proxy = Assert.Single(job.Parts); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + })); + try + { + var result = NestPipeline.RunCutoutPreview(Request(name)); + Assert.Equal(1, calls); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-1" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + } + finally + { + var registry = Assert.IsType>(NestingEngineRegistry.AvailableEngines); + Assert.Equal(1, registry.RemoveAll(info => info.Name == name)); + } + } + + [Fact] + public void TwoCutoutsReserveDifferentCopiesWithinOneFrame() + { + var program = TestShapes.Rectangle(20, 20); + foreach (var origin in new[] { 2.0, 12.0 }) + { + program.MoveTo(origin, origin); + program.LineTo(origin, origin + 6); + program.LineTo(origin + 6, origin + 6); + program.LineTo(origin + 6, origin); + program.LineTo(origin, origin); + } + var frame = new Drawing("two-hole-frame", program); + var insert = new Drawing("insert", TestShapes.Rectangle(4, 4)); + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = frame, Quantity = 1 }, + new NestItem { Drawing = insert, Quantity = 2 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = Assert.Single(job.Parts); + return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray(); + Assert.Equal(2, inserts.Length); + Assert.All(inserts, p => + { + var localX = p.X - 1; + var localY = p.Y - 1; + Assert.Contains(new[] { 2.0, 12.0 }, origin => + localX >= origin + 0.25 && localX + 4 <= origin + 5.75 + && localY >= origin + 0.25 && localY + 4 <= origin + 5.75); + }); + } + + [Fact] + public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert() + { + var request = Request("stub"); + request.Items[1].Priority = 3; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job); + }), "stub", request); + Assert.True(result.CanKeep); + Assert.Empty(result.Plates); + } + + [Theory] + [InlineData(1, 11.0, 11.0)] + [InlineData(2, -11.0, 11.0)] + [InlineData(3, -11.0, -11.0)] + [InlineData(4, 11.0, -11.0)] + public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y) + { + var request = Request("stub") with + { + Stock = new[] { + new NestPlateStock("sheet", new Size(22, 22), 1, 0.25, + new Spacing(0.5, 0.5), quadrant) }, + Options = new NestJobOptions(maxPlates: 1) + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))), + "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant); + Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X, + result.Raw.Plates.Single().Placements[0].Y)); + } + + [Fact] + public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle() + { + var request = Request("stub"); + using var cts = new CancellationTokenSource(); + Assert.Throws(() => NestPipeline.RunCutoutPreview( + new Stub(job => + { + var proxy = Assert.Single(job.Parts); + cts.Cancel(); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + }), "stub", request, token: cts.Token)); + Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested)); + } + + [Fact] + public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult( + NestJobStatus.Complete, NestJobStopReason.Completed, + Array.Empty(), + job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)), + job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("accounting")); + AssertCannotCommit(result, request); + } + + [Fact] + public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts() + { + var request = Request("stub"); + var progress = new CollectProgress(); + var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted }, + progress.Events.Select(e => e.Stage)); + Assert.Equal(2, progress.Events.Last().CommittedParts); + Assert.Equal(1, progress.Events.Last().CommittedPlates); + } + + private sealed class ReportingStub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + var proxy = Assert.Single(job.Parts); + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, + job.Plates[0].Id, 0, 0, 0)); + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, + job.Plates[0].Id, 0, 1, 1)); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + } + } + + [Fact] + public void AutomaticBundleRetainsStockFeasibleQuarterTurn() + { + var program = TestShapes.Rectangle(20, 10); + program.MoveTo(2, 2); + program.LineTo(2, 8); + program.LineTo(18, 8); + program.LineTo(18, 2); + program.LineTo(2, 2); + var request = new NestPipelineRequest("Irregular", new[] { + new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } }, + new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var frame = result.Raw.Plates.Single().Placements[0]; + Assert.Equal(System.Math.PI / 2, frame.Rotation, 7); + Assert.Equal(new[] { "part-0", "part-1" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + } + + [Fact] + public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40); + var proxy = Assert.Single(job.Parts); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert")); + AssertCannotCommit(result, request); + } + + [Fact] + public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput() + { + var request = Request("stub"); + request.Items[0].Quantity = 2; + request.Items[1].Quantity = 2; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + var options = new NestJobOptions("Strip", maxPlates: 1, + salvageRate: 0.4, minimumSalvageDimension: 2); + request = request with + { + Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, + Options = options + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + Assert.Same(options, job.Options); + Assert.Equal("Strip", job.Options.PlacementStrategy); + Assert.Equal(0.4, job.Options.SalvageRate); + var builder = new NestJobResultBuilder(job); + foreach (var proxy in job.Parts) + builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) }); + return builder.Build(NestJobStopReason.Completed); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("MaxPlates")); + AssertCannotCommit(result, request); + } + + [Fact] + public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance() + { + var program = TestShapes.Rectangle(20, 20); + program.MoveTo(5, 5); + program.LineTo(5, 15); + program.LineTo(15, 15); + program.LineTo(15, 5); + program.LineTo(5, 5); + const double angle = 0.3; + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = new Drawing("frame", program), Quantity = 1, + StepAngle = 1, RotationStart = angle, RotationEnd = angle }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), + Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } }, + new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))), + "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var poses = result.Raw.Plates.Single().Placements; + Assert.Equal(angle, poses[1].Rotation, 8); + var dx = poses[1].X - poses[0].X; + var dy = poses[1].Y - poses[0].Y; + var cosine = System.Math.Cos(angle); + var sine = System.Math.Sin(angle); + var localX = dx * cosine + dy * sine; + var localY = -dx * sine + dy * cosine; + foreach (var x in new[] { 0.0, 3.0 }) + foreach (var y in new[] { 0.0, 3.0 }) + { + Assert.InRange(localX + x, 5.25, 14.75); + Assert.InRange(localY + y, 5.25, 14.75); + } + } + + [Fact] + public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation() + { + var frame = TestShapes.Rectangle(20, 20); + frame.MoveTo(5, 5); + frame.LineTo(5, 15); + frame.LineTo(15, 15); + frame.LineTo(15, 5); + frame.LineTo(5, 5); + var request = new NestPipelineRequest("stub", new[] { + new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40, + StepAngle = 1, RotationStart = 0, RotationEnd = 0 }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)), + Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) }); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-"))); + Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity); + Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity); + return Result(job); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40), + (f.Requested, f.Placed, f.Unplaced))); + } + [Fact] public void OrdinaryPipelineIsStillUnchanged() { diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs index 44b903c..c9a0c1b 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutLatticeFill.cs @@ -22,8 +22,9 @@ namespace OpenNest.Engine.Jobs.Cutouts; /// /// /// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement. -/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into -/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order. +/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the +/// internal pipeline cutout preview, but not enabled for normal whole-job calls; +/// production placement waits on cutting/post safety. /// internal static class CutoutLatticeFill { diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index 8d8f998..f90fc48 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -3,23 +3,23 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using OpenNest.Engine.Jobs.Adapters; namespace OpenNest.Engine.Jobs.Cutouts; -/// Internal-only single-frame composite trial. No normal pipeline caller enables this. +/// Internal-only frame bundles. No normal pipeline caller enables this. internal sealed class CutoutPipelinePrepass { - private readonly string proxyId; - private readonly string frameId; - private readonly IReadOnlyList localInserts; + // A job with thousands of repeats still offers the residual as ordinary demand; + // do not multiply expensive Clipper/Fill preparation by every physical instance. + private const int MaxBundlesPerJob = 32; + private sealed record Bundle(string FrameId, IReadOnlyList LocalInserts); + private readonly IReadOnlyDictionary bundles; - private CutoutPipelinePrepass(NestJob engineJob, string proxyId, string frameId, - IReadOnlyList localInserts) + private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary bundles) { EngineJob = engineJob; - this.proxyId = proxyId; - this.frameId = frameId; - this.localInserts = localInserts; + this.bundles = bundles; } internal NestJob EngineJob { get; } @@ -29,51 +29,121 @@ internal sealed class CutoutPipelinePrepass token.ThrowIfCancellationRequested(); if (original.Plates.Count == 0) return null; - // A single fixed-zero proxy is safe even when a symmetric outer perimeter causes - // an engine to deduplicate rotations. General orientation intersections are deferred. + // One reservation vector works for every offered stock. Current jobs cannot + // represent stock-conditional insert quantities; use the largest spacing. var spacing = original.Plates.Max(p => p.PartSpacing); - foreach (var frame in original.Parts.Where(p => p.Quantity == 1 && p.Rotation.Allows(0))) + var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity); + var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry)); + var bundles = new Dictionary(StringComparer.Ordinal); + var byFrame = new Dictionary>(StringComparer.Ordinal); + foreach (var frame in original.Parts) { - var geometry = JobPartGeometry.TryRead(frame.Geometry); - if (geometry == null || geometry.Cutouts.Count == 0) + token.ThrowIfCancellationRequested(); + if (bundles.Count >= MaxBundlesPerJob) + break; + var profile = geometry[frame.Id]; + if (profile == null || profile.Cutouts.Count == 0) continue; - var candidates = original.Parts.Where(p => p.Id != frame.Id).ToArray(); - if (candidates.Length == 0) - continue; - for (var hole = 0; hole < geometry.Cutouts.Count; hole++) + var proxies = new List(); + byFrame[frame.Id] = proxies; + for (var instance = 0; instance < frame.Quantity; instance++) { token.ThrowIfCancellationRequested(); - var poses = CutoutRouter.Fill(frame, hole, candidates, spacing, token); - if (poses.Count == 0) - continue; - var reserved = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count()); - var proxyId = "__cutout-proxy-" + frame.Id; - if (original.Parts.Any(p => p.Id == proxyId)) - throw new InvalidOperationException("Cutout proxy ID collides with a requirement."); - var transformed = new List(); - foreach (var part in original.Parts) + if (bundles.Count >= MaxBundlesPerJob) + break; + IReadOnlyList? chosen = null; + double frameAngle = 0; + // Outer-perimeter symmetry cannot deduplicate asymmetric cutouts. + foreach (var angle in frame.Rotation.EnumerateAngles(maxSamples: 16)) { - if (part.Id == frame.Id) - transformed.Add(new NestJobPart(proxyId, part.Geometry, 1, part.Priority, - RotationPolicy.Fixed(0))); - else + token.ThrowIfCancellationRequested(); + if (!FitsOfferedStock(frame, angle, original.Plates)) + continue; + var local = new List(); + var available = new Dictionary(remaining); + for (var hole = 0; hole < profile.Cutouts.Count; hole++) { - var remaining = part.Quantity - reserved.GetValueOrDefault(part.Id); - if (remaining > 0) - transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining, - part.Priority, part.Rotation)); + token.ThrowIfCancellationRequested(); + // Flat bundles: frames cannot simultaneously be inserts in another bundle. + var candidates = original.Parts + .Where(p => p.Id != frame.Id && available[p.Id] > 0 + && p.Priority == frame.Priority + && geometry[p.Id] is { Cutouts.Count: 0 }) + .Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id], + p.Priority, LocalRotation(p.Rotation, angle))) + .ToArray(); + if (candidates.Length == 0) + break; + foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token)) + { + token.ThrowIfCancellationRequested(); + var source = geometry[pose.PartId]!; + var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation; + if (!policy.Allows(angle + pose.Rotation) + || local.Any(other => !NestLayoutCheck.Clears( + geometry[other.PartId]!, other, source, pose, spacing))) + continue; + local.Add(pose); + available[pose.PartId]--; + } } + if (local.Count <= (chosen?.Count ?? 0)) + continue; + chosen = local.ToArray(); + frameAngle = angle; } - return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates, - original.Options), proxyId, frame.Id, poses); + if (chosen == null || chosen.Count == 0) + break; + var proxyId = $"__cutout-proxy-{frame.Id}-{instance}"; + if (remaining.ContainsKey(proxyId) || bundles.ContainsKey(proxyId)) + throw new InvalidOperationException("Cutout proxy ID collides with a requirement."); + bundles.Add(proxyId, new Bundle(frame.Id, chosen)); + proxies.Add(new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority, + RotationPolicy.Fixed(frameAngle))); + remaining[frame.Id]--; + foreach (var pose in chosen) + remaining[pose.PartId]--; } } - return null; + if (bundles.Count == 0) + return null; + var transformed = new List(); + foreach (var part in original.Parts) + { + if (byFrame.TryGetValue(part.Id, out var proxies)) + transformed.AddRange(proxies); + if (remaining[part.Id] > 0) + transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining[part.Id], + part.Priority, part.Rotation)); + } + return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates, + original.Options), bundles); + } + + private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) => + original.Kind switch + { + RotationPolicyKind.Automatic => RotationPolicy.Automatic, + RotationPolicyKind.Fixed => RotationPolicy.Fixed(original.Start - frameAngle, + original.Allow180Equivalent), + _ => RotationPolicy.BoundedSweep(original.Start - frameAngle, + original.End - frameAngle, original.Step, original.Allow180Equivalent), + }; + + private static bool FitsOfferedStock(NestJobPart frame, double angle, + IReadOnlyList stock) + { + var part = new Part(DrawingJobMapper.CreateDrawing(frame)); + part.Rotate(angle); + var bounds = NestLayoutCheck.MaterialBounds(part); + return stock.Any(s => s.Quantity != 0 && s.Fits(bounds.Length, bounds.Width, + NestTolerances.WorkAreaSlack)); } /// Reject untrusted accounting before expansion. A failed trial is never bindable, /// including with allowInvalid. Only actually placed proxies consume reserved inserts. - internal bool TryExpand(NestJob original, NestJobResult result, out NestJobResult? expanded, + internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token, + out NestJobResult? expanded, out string failure) { expanded = null; @@ -81,10 +151,12 @@ internal sealed class CutoutPipelinePrepass var parts = EngineJob.Parts.ToDictionary(p => p.Id); var counts = parts.ToDictionary(p => p.Key, _ => 0); var indices = parts.ToDictionary(p => p.Key, _ => new HashSet()); - var used = EngineJob.Plates.ToDictionary(s => s.Id, _ => 0); + var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal); + var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal); var expectedIndex = 0; foreach (var sheet in result.Plates) { + token.ThrowIfCancellationRequested(); if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0 || !used.ContainsKey(sheet.StockId)) return false; @@ -97,22 +169,32 @@ internal sealed class CutoutPipelinePrepass return false; } } - if (indices.Any(row => row.Value.Any(index => index >= counts[row.Key]))) - return false; + foreach (var row in indices) + { + token.ThrowIfCancellationRequested(); + if (row.Value.Any(index => index >= counts[row.Key])) + return false; + } if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count) return false; + var seenParts = new HashSet(StringComparer.Ordinal); foreach (var row in result.Fulfillment) + { + token.ThrowIfCancellationRequested(); if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part) + || !seenParts.Add(row.PartId) || row.Requested != part.Quantity || row.Placed != counts[row.PartId] - || row.Unplaced != part.Quantity - counts[row.PartId] - || result.Fulfillment.Count(r => r.PartId == row.PartId) != 1) + || row.Unplaced != part.Quantity - counts[row.PartId]) return false; + } + var seenStock = new HashSet(StringComparer.Ordinal); foreach (var row in result.StockUsage) { - var stock = EngineJob.Plates.FirstOrDefault(s => s.Id == row.StockId); - if (stock == null || row.Used != used[row.StockId] - || row.Remaining != stock.Quantity - row.Used - || result.StockUsage.Count(r => r.StockId == row.StockId) != 1) + token.ThrowIfCancellationRequested(); + if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock) + || !seenStock.Add(row.StockId) + || row.Used != used[row.StockId] + || row.Remaining != stock.Quantity - row.Used) return false; } var complete = parts.Values.All(p => counts[p.Id] == p.Quantity); @@ -121,27 +203,22 @@ internal sealed class CutoutPipelinePrepass || (!complete && result.StopReason == NestJobStopReason.Completed)) return false; - // An incomplete solve cannot silently reserve inserts or reach the commit boundary. - if (!complete) - { - failure = "Cutout engine did not complete the transformed job"; - return false; - } var builder = new NestJobResultBuilder(original); foreach (var sheet in result.Plates) { + token.ThrowIfCancellationRequested(); var poses = new List<(string PartId, double X, double Y, double Rotation)>(); foreach (var pose in sheet.Placements) { - if (pose.PartId != proxyId) + if (!bundles.TryGetValue(pose.PartId, out var bundle)) { poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation)); continue; } - poses.Add((frameId, pose.X, pose.Y, pose.Rotation)); + poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation)); var sine = System.Math.Sin(pose.Rotation); var cosine = System.Math.Cos(pose.Rotation); - foreach (var local in localInserts) + foreach (var local in bundle.LocalInserts) poses.Add((local.PartId, pose.X + local.X * cosine - local.Y * sine, pose.Y + local.X * sine + local.Y * cosine, @@ -158,12 +235,8 @@ internal sealed class CutoutPipelinePrepass return false; } } + token.ThrowIfCancellationRequested(); expanded = builder.Build(result.StopReason); - if (expanded.Status != NestJobStatus.Complete) - { - expanded = null; - return false; - } return true; } } diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 7e33e5e..9cc231e 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -181,10 +181,12 @@ public static class NestPipeline // A compound may be physically invalid even when its envelope is legal. // Refuse every unverified trial, including with explicit invalid-result consent. var failure = "Cutout engine output failed reconciliation"; - if (violations.Count == 0 && prepass.TryExpand(job, raw, out var expanded, out failure)) + if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure)) { raw = expanded; violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep)); + if (raw.Status != NestJobStatus.Complete) + violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job"); canKeep = physicalKeep && violations.Count == 0; } else diff --git a/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs new file mode 100644 index 0000000..c62a77e --- /dev/null +++ b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs @@ -0,0 +1,73 @@ +using OpenNest.CNC; +using OpenNest.Engine; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.IO; + +namespace OpenNest.Tests.Engine; + +public class CutoutPipelinePersistenceTests +{ + [Fact] + public void ExpandedMultiSheetProposalCommitsAndReloadsAsSeparateDrawings() + { + var frame = new OpenNest.Shapes.RingShape { OuterDiameter = 20, InnerDiameter = 10 } + .GetDrawing(); + frame.Name = "neutral-frame"; + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(6, 0); + program.LineTo(6, 6); + program.LineTo(0, 6); + program.LineTo(0, 0); + var insert = new Drawing("neutral-insert", program); + var request = new NestPipelineRequest("Irregular", new[] { + new NestItem { Drawing = frame, Quantity = 2 }, + new NestItem { Drawing = insert, Quantity = 2 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, + new NestJobOptions(maxPlates: 2)); + var ordinary = NestPipeline.Run(request); + Assert.Contains(ordinary.Raw.Fulfillment, f => f.Unplaced > 0); + var result = NestPipeline.RunCutoutPreview(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, result.Raw.Plates.Count); + Assert.All(result.Raw.Plates, p => Assert.Equal(new[] { "part-0", "part-1" }, + p.Placements.Select(x => x.PartId))); + + var nest = new Nest(); + nest.Drawings.Add(frame); + nest.Drawings.Add(insert); + using (var manager = new PlateManager(nest)) + { + var committed = NestPipelineCommit.ApplyToEmptyPlates(result, manager); + Assert.Equal(2, committed.Count); + Assert.All(committed, plate => Assert.Equal(new[] { frame, insert }, + plate.Parts.Select(p => p.BaseDrawing))); + } + Assert.Equal(2, frame.Quantity.Nested); + Assert.Equal(2, insert.Quantity.Nested); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read(); + Assert.Equal(2, loaded.Plates.Count); + for (var i = 0; i < loaded.Plates.Count; i++) + { + var expected = nest.Plates[i]; + var actual = loaded.Plates[i]; + Assert.Equal(expected.Size, actual.Size); + Assert.Equal(expected.PartSpacing, actual.PartSpacing); + Assert.Equal(expected.Quadrant, actual.Quadrant); + Assert.Equal(expected.EdgeSpacing, actual.EdgeSpacing); + Assert.Equal(new[] { "neutral-frame", "neutral-insert" }, + actual.Parts.Select(p => p.BaseDrawing.Name)); + for (var j = 0; j < actual.Parts.Count; j++) + { + Assert.Equal(expected.Parts[j].Location.X, actual.Parts[j].Location.X, 6); + Assert.Equal(expected.Parts[j].Location.Y, actual.Parts[j].Location.Y, 6); + Assert.Equal(expected.Parts[j].Rotation, actual.Parts[j].Rotation, 6); + } + } + Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-frame").Quantity.Nested); + Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-insert").Quantity.Nested); + } +} diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 049f068..1b95e66 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -97,18 +97,26 @@ NFP first, then Fill if demand remains, taking the higher-count valid proposal. An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. Six geometry-only, anonymized real-job probes and neutral ring fixtures informed the search-order hints; these limited cases are not a global density guarantee. -The internal `NestPipeline.RunCutoutPreview` prepares at most one quantity-one frame with one -cutout as a fixed-zero proxy. Reserved inserts are removed from the engine's independent demand; -remaining demand is offered once to the selected engine. Only after a complete, accounted-for -engine result does it expand the proxy back to original requirement IDs, validate every physical -pose, stock and quantity, then bind caller drawings. Incomplete, forged or geometrically invalid -composites return no bindable plates even with invalid-result consent. Candidate evaluation -progress is forwarded with unverified proxy commitment counts zeroed; transformed proxy commits -are hidden. Physical commits are reported only after physical validation. Frames needing other -orientations or repeated frame copies still run through the ordinary unbundled path. This is a -narrow internal integration trial, not a production setting or a cut-ready nest; multi-frame -routing, rotation compatibility, save/reload and cutting/post safety are still pending before -enablement. +The internal `NestPipeline.RunCutoutPreview` builds quantity-one proxy instances for eligible +closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry +reduction, shifts insert rotation policies into the local frame, and reserves certified router +poses by original requirement ID. It can fill multiple holes and repeated/different frame +requirements; inserts with a different priority or their own cutouts stay independent, so +bundles remain flat. It uses the largest offered spacing for every bundle because the current +job contract cannot condition insert demand on the stock selected later. Remaining demand is +offered to the selected engine once. Only actually placed proxies expand to original frame and +insert identities. An unplaced proxy's reservations return to the original unplaced demand; +they cannot be packed retroactively in that same solve. Engine indices and metadata are checked, +then every physical pose, stock, rotation, quantity and fulfillment is independently validated +before binding caller drawings. Incomplete, forged or geometrically invalid composites return +no bindable plates even with invalid-result consent. Candidate evaluation progress is forwarded +with unverified proxy commitment counts zeroed; transformed proxy commits are hidden. Physical +commits are reported only after physical validation. Preparation is bounded to 32 bundles +and 16 base sweep-angle samples per frame (automatic quarter-turns are all retained); +remaining copies stay ordinary requirements. Fixed legal proxy orientations are a +conservative search choice, not full engine choice among all legal orientations; unsupported +bundles remain ordinary independent requirements. This is an internal pipeline path, not a +production setting or cut-ready nest. Cutting/post safety remains pending before enablement. ## Renamed engines From d328c5f69123cbf6193a0435b13f984e57ffd6f4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 20:33:17 -0400 Subject: [PATCH 12/15] perf(cutting): generate ranked alternatives only when visited --- .../CuttingPlanning/JointCuttingPlanSearch.cs | 63 ++++++++++++------- .../CuttingPlanning/LazyCuttingSearchTests.cs | 60 ++++++++++++++++++ docs/cutting-planner.md | 23 +++++-- 3 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 9a02ad2..466e370 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch var progressExpansions = Expansions; var stack = new Stack(); stack.Push(new(root)); - while (stack.Count != 0) + try { - token.ThrowIfCancellationRequested(); - var frame = stack.Peek(); - var node = frame.Node; - if (node.Order.Length == snapshot.Placements.Count) + while (stack.Count != 0) { - attempt.Order = node.Order; - return attempt; + token.ThrowIfCancellationRequested(); + var frame = stack.Peek(); + var node = frame.Node; + if (node.Order.Length == snapshot.Placements.Count) + { + attempt.Order = node.Order; + return attempt; + } + if (attempt.Advance(node)) + progressExpansions = Expansions; + else if (stall is int limit && Expansions - progressExpansions > limit) + return attempt; + frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator(); + if (!frame.Children.MoveNext()) + { + stack.Pop().Children.Dispose(); + continue; + } + stack.Push(new(frame.Children.Current.Node)); } - if (attempt.Advance(node)) - progressExpansions = Expansions; - else if (stall is int limit && Expansions - progressExpansions > limit) - return attempt; - frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly); - if (frame.Next == frame.Children.Length) - { - stack.Pop(); - continue; - } - stack.Push(new(frame.Children[frame.Next++].Node)); + return attempt; + } + finally + { + // Ready, stalled, cancelled and budget-exhausted searches can all leave + // suspended siblings. Release their captured prefixes on every exit. + foreach (var frame in stack) + frame.Children?.Dispose(); } - return attempt; } /// @@ -259,8 +269,14 @@ internal static class JointCuttingPlanSearch /// one part and contour stage the automatic rank leads — OrderBy(Distance) alone /// would undo the look-ahead facing. Legacy (unranked) children keep distance order. /// - private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) + private IEnumerable OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) { + // A prescribed next part (or an active part's next contour) has a single + // source. Expand already yields its contour/entry rank order, so emit and + // validate a sibling only when DFS reaches it. Free whole-part selection + // still needs every source's distances before it can rank them. + if (sequence != null || node.Active != null) + return Expand(node, sequence, attempt, preferredOnly); var edges = Expand(node, sequence, attempt, preferredOnly).ToList(); if (edges.Count <= 1) return edges.ToArray(); @@ -334,8 +350,10 @@ internal static class JointCuttingPlanSearch // First try one ranked hole chain per endpoint. A later-part failure then // changes the endpoint before replaying all earlier hole combinations. // The retained pass below still searches every entry and hole order. + if (preference != null) + contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c); if (preferredOnly && preference != null) - contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1); + contours = contours.Take(1); foreach (var contour in contours) { token.ThrowIfCancellationRequested(); @@ -593,7 +611,6 @@ internal static class JointCuttingPlanSearch private sealed class Frame(Node node) { internal Node Node { get; } = node; - internal Edge[] Children { get; set; } - internal int Next { get; set; } + internal IEnumerator Children { get; set; } } } diff --git a/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs b/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs new file mode 100644 index 0000000..10c97b9 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs @@ -0,0 +1,60 @@ +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class LazyCuttingSearchTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RankedRouteDoesNotSpendBudgetEmittingUnusedSiblings(bool preserveOrder) + { + var parts = Parts(); + var programs = parts.Select(p => p.Program).ToArray(); + var parameters = ExplicitContourTests.Parameters(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts, new Vector(-2, -2), + expansionBudget: 36, confirmedParameters: parameters, preservePartOrder: preserveOrder)); + var result = CuttingPlanService.Plan(snapshot); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + Assert.InRange(result.Expansions, 1, 36); + var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default); + Assert.Equal(CuttingPlanStatus.Ready, replay.Status); + Assert.True(replay.IndependentlyReplayed); + Assert.Equal(programs, parts.Select(p => p.Program)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LazyTraversalKeepsBudgetAndMidSearchCancellationFailClosed(bool preserveOrder) + { + var parts = Parts(); + var programs = parts.Select(p => p.Program).ToArray(); + var parameters = ExplicitContourTests.Parameters(); + var bounded = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2), + expansionBudget: 1, confirmedParameters: parameters, preservePartOrder: preserveOrder)); + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status); + Assert.Equal(1, bounded.Expansions); + Assert.Empty(bounded.ProposedOrder); + Assert.False(bounded.IndependentlyReplayed); + using var cancellation = new CancellationTokenSource(); + var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2), + confirmedParameters: parameters, preservePartOrder: preserveOrder) + { ExpansionObserver = n => { if (n == 10) cancellation.Cancel(); } }, cancellation.Token); + Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status); + Assert.Equal(10, cancelled.Expansions); + Assert.Empty(cancelled.ProposedOrder); + Assert.False(cancelled.IndependentlyReplayed); + Assert.Equal(programs, parts.Select(p => p.Program)); + } + + private static Part[] Parts() => + [ + new(new Drawing("first", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), Vector.Zero), + new(new Drawing("second", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), new Vector(20, 0)), + ]; +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index a7af77d..7214f7f 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -146,11 +146,24 @@ other curve already touches, while a contact anywhere else on the line still ref Source parts rank by modeled travel (material-centre distance at a holed-part boundary); within a part, preferred contour order and facing-entry rank precede -travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget -counts rejected candidates and frontier ranking as well as accepted moves, before -emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur -before already-generated siblings are traversed; it returns a refusal, not an -unranked fallback or a proof of geometric impossibility. +travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names +are not tie breakers. + +When the next whole part is fixed by the tour or a part is already active, the +search generates and checks contour alternatives on demand in contour/entry rank +order. It does not emit every sibling before trying the first. Backtracking still +visits the remaining alternatives when needed; none is pruned. The free whole-part +fallback still evaluates all ready sources before ranking their travel distances. + +The expansion budget counts evaluated candidates and frontier ranking, including +rejections, before emission; it is not a wall-clock timeout. An unvisited sibling +consumes no additional DFS-prefix emission work or per-sibling expansion charge; +entry-catalogue prechecks still emit isolated contours and consume budget. +Consequently a bounded search can +reach a different frontier and report different rejected approaches than eager +expansion did. All visited candidates retain their lead/rapid checks, and a selected +plan still receives fresh independent replay. Callers can cancel. Exhaustion returns +a refusal, not an unranked fallback or proof of geometric impossibility. ## Automatic outside entries and look-ahead From 1c6d02c480bead6f89ccfb10cee6e72bdcc52b8e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 21:26:40 -0400 Subject: [PATCH 13/15] perf(cutting): try a bounded entry catalogue before full search --- .../CuttingPlanning/CuttingPlanBatch.cs | 22 ++++++- .../CuttingPlanning/CuttingPlanModels.cs | 7 +++ .../CuttingPlanning/CuttingPlanBatchTests.cs | 57 +++++++++++++++++++ docs/cutting-planner.md | 8 +++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index a170297..e91fa5a 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch /// public const int ExpansionsPerPart = 400; + // Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget. + internal const int FirstPassBudget = 1000; + internal const int FirstPassEntries = 8; + private readonly Entry[] entries; private readonly CuttingParameters ownedParameters; @@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch if (entry.Reorder != null) { progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering)); - reorder = CuttingPlanService.Plan(entry.Reorder, token); + reorder = PlanReorder(entry.Reorder, token); if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget) { plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap); @@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch return new(plans, ownedParameters); } + internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token, + int firstPassBudget = FirstPassBudget) + { + // A failed first pass is not a proof of impossibility: retain all original + // full-cap alternatives and the complete original budget for the retry. + if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries + && snapshot.ExpansionBudget > firstPassBudget) + { + var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token); + if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed) + || first.Status == CuttingPlanStatus.Cancelled) + return first; + } + return CuttingPlanService.Plan(snapshot, token); + } + private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token) { if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 59092ce..388266c 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot public int ExpansionBudget { get; } internal CuttingPlanStatus? Failure { get; } internal IReadOnlyList Findings { get; } + + // A worker-only search variant over the same owned capture. Never recapture the live plate: + // both proposals retain the identical freshness record and confirmed settings. + internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) => + new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration, + PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters, + Dependencies, BestEffort); } public sealed class FixedProgramPlacement diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs index d889c4c..c3604a7 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs @@ -144,6 +144,63 @@ public class CuttingPlanBatchTests Assert.Null(proposal.BuildPreview(0)); } + [Fact] + public void PlanReorder_ShortPassReady_RetainsReplayedApplicableProposal() + { + var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1)); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters())); + var full = CuttingPlanService.Plan(snapshot); + var result = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + Assert.True(result.Expansions < full.Expansions, + $"Short pass {result.Expansions}, full {full.Expansions}"); + Assert.InRange(result.Expansions, 1, CuttingPlanBatch.FirstPassBudget - 1); + Assert.Equal(2, result.ProposedOrder.Count); + Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); + Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status); + } + + [Fact] + public void PlanReorder_ShortPassExhausted_UsesFullCapAndOriginalBudget() + { + var plate = Plate(new Nest(), Grid(4)); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters())); + var full = CuttingPlanService.Plan(snapshot); + var recovered = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None, firstPassBudget: 1); + + Assert.Equal(CuttingPlanStatus.Ready, full.Status); + Assert.Equal(CuttingPlanStatus.Ready, recovered.Status); + Assert.True(recovered.IndependentlyReplayed); + Assert.Equal(full.Expansions, recovered.Expansions); + Assert.Equal(full.RapidDistance, recovered.RapidDistance); + Assert.Equal(full.ProposedOrder.Select(p => p.SourceOrdinal), recovered.ProposedOrder.Select(p => p.SourceOrdinal)); + for (var i = 0; i < full.ProposedOrder.Count; i++) + Assert.True(ProgramContent.Equal(full.ProposedOrder[i].CopyProgram(), recovered.ProposedOrder[i].CopyProgram())); + Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); + } + + [Fact] + public void PlanReorder_CancelledDuringShortPass_DoesNotRetryOrMutate() + { + var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1)); + using var cancellation = new CancellationTokenSource(); + var observations = 0; + var request = new CuttingPlanRequest(plate.Parts, confirmedParameters: ExplicitContourTests.Parameters()) + { ExpansionObserver = _ => { observations++; cancellation.Cancel(); } }; + var snapshot = CuttingPlanService.Capture(request); + + var result = CuttingPlanBatch.PlanReorder(snapshot, cancellation.Token); + + Assert.Equal(CuttingPlanStatus.Cancelled, result.Status); + Assert.Equal(1, observations); + Assert.Empty(result.ProposedOrder); + Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); + } + [Fact] public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo() { diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 7214f7f..d2aca1c 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -319,6 +319,14 @@ plans every plate that has parts. Both open one dialog built on obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed fixes: replanning runs the same strict checks; an unverified fallback requires separate warning acceptance and is not approval to post or cut. +- The desktop free-order batch first tries up to eight entries per contour for at most + 1,000 expansions. Only a Ready proposal that passes independent replay is used; + otherwise it runs the original 16-entry search with its entire plate budget. + Cancellation stops both attempts. This short pass can choose a different valid + route, and a hard plate can take up to 1,000 extra expansions before the full + attempt. The current-order retry still uses its original 16-entry budget. + Returned expansion counts describe the retained attempt, not a discarded + short pass plus its full-cap retry. - Every plate is captured on the UI thread and checked and planned on a worker. Clean part material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts still block that plate whatever its route. Incomplete checks remain visible warnings, never From 69304d385180b192d16cdd423b2ff28aa87bd052 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 21:54:10 -0400 Subject: [PATCH 14/15] feat(settings): set nest server URL from Options --- OpenNest.Data/NestStorageSettings.cs | 12 +++++++ OpenNest.Tests/Data/NestStorageTests.cs | 25 ++++++++++++++ OpenNest/Forms/MainForm.cs | 6 ++++ OpenNest/Forms/OptionsForm.Designer.cs | 45 +++++++++++++++++++++++-- OpenNest/Forms/OptionsForm.cs | 25 ++++++++++++++ docs/nest-storage.md | 4 +++ 6 files changed, 114 insertions(+), 3 deletions(-) diff --git a/OpenNest.Data/NestStorageSettings.cs b/OpenNest.Data/NestStorageSettings.cs index f092c5d..f15cf19 100644 --- a/OpenNest.Data/NestStorageSettings.cs +++ b/OpenNest.Data/NestStorageSettings.cs @@ -32,6 +32,18 @@ public sealed class NestStorageSettings public bool IsDatabaseMode => Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl); + /// Validate and normalize a nest server base URL without changing storage mode. + public static bool TryNormalizeServerUrl(string? url, out string normalized) + { + normalized = NormalizeUrl(url); + if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + return true; + + normalized = ""; + return false; + } + /// %APPDATA%\OpenNest\storage.json. public static string DefaultPath => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), diff --git a/OpenNest.Tests/Data/NestStorageTests.cs b/OpenNest.Tests/Data/NestStorageTests.cs index 1a41586..b7a33f9 100644 --- a/OpenNest.Tests/Data/NestStorageTests.cs +++ b/OpenNest.Tests/Data/NestStorageTests.cs @@ -59,6 +59,31 @@ public class NestStorageSettingsTests : IDisposable Assert.False(settings.IsDatabaseMode); } + [Fact] + public void TryNormalizeServerUrl_AcceptsHttpsWithoutChangingMode() + { + var settings = new NestStorageSettings { Mode = NestStorageMode.File }; + + Assert.True(NestStorageSettings.TryNormalizeServerUrl(" https://shop.example:8090/ ", out var url)); + settings.ServerUrl = url; + settings.Save(FilePath); + + var loaded = NestStorageSettings.Load(FilePath); + Assert.Equal("https://shop.example:8090", loaded.ServerUrl); + Assert.Equal(NestStorageMode.File, loaded.Mode); + Assert.False(loaded.IsDatabaseMode); + } + + [Theory] + [InlineData("")] + [InlineData("ftp://shop.example")] + [InlineData("not-a-url")] + public void TryNormalizeServerUrl_RejectsInvalidAddress(string input) + { + Assert.False(NestStorageSettings.TryNormalizeServerUrl(input, out var url)); + Assert.Equal("", url); + } + public void Dispose() { if (Directory.Exists(_directory)) diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 938e7fc..f7bf2da 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -646,6 +646,7 @@ namespace OpenNest.Forms var serverUrl = NestStorage.Settings.ServerUrl; databaseOpenInProgress = true; storageModeMenu.Enabled = false; + mnuToolsOptions.Enabled = false; var file = await repository.GetFileAsync(dlg.SelectedId); if (file == null) throw new FileNotFoundException("This nest no longer exists on the server."); @@ -663,7 +664,10 @@ namespace OpenNest.Forms { databaseOpenInProgress = false; if (!IsDisposed) + { storageModeMenu.Enabled = !databaseSaveInProgress; + mnuToolsOptions.Enabled = !databaseSaveInProgress; + } } } @@ -707,6 +711,7 @@ namespace OpenNest.Forms { databaseSaveInProgress = true; storageModeMenu.Enabled = false; + mnuToolsOptions.Enabled = false; EnableCheck(); try { @@ -729,6 +734,7 @@ namespace OpenNest.Forms if (!IsDisposed) { storageModeMenu.Enabled = !databaseOpenInProgress; + mnuToolsOptions.Enabled = !databaseOpenInProgress; EnableCheck(); } } diff --git a/OpenNest/Forms/OptionsForm.Designer.cs b/OpenNest/Forms/OptionsForm.Designer.cs index 3425726..47e5dd0 100644 --- a/OpenNest/Forms/OptionsForm.Designer.cs +++ b/OpenNest/Forms/OptionsForm.Designer.cs @@ -41,9 +41,13 @@ this.strategyGroupBox = new System.Windows.Forms.GroupBox(); this.colorSchemeLabel = new System.Windows.Forms.Label(); this.colorSchemeCombo = new System.Windows.Forms.ComboBox(); + this.serverGroupBox = new System.Windows.Forms.GroupBox(); + this.serverUrlBox = new System.Windows.Forms.TextBox(); + this.setServerButton = new System.Windows.Forms.Button(); ((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit(); this.tableLayoutPanel1.SuspendLayout(); this.bottomPanel1.SuspendLayout(); + this.serverGroupBox.SuspendLayout(); this.SuspendLayout(); // // checkBox1 @@ -135,7 +139,7 @@ this.bottomPanel1.Controls.Add(this.cancelButton); this.bottomPanel1.Controls.Add(this.saveButton); this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom; - this.bottomPanel1.Location = new System.Drawing.Point(0, 368); + this.bottomPanel1.Location = new System.Drawing.Point(0, 443); this.bottomPanel1.Name = "bottomPanel1"; this.bottomPanel1.Size = new System.Drawing.Size(708, 50); this.bottomPanel1.TabIndex = 1; @@ -158,7 +162,7 @@ // strategyGroupBox // this.strategyGroupBox.Controls.Add(this.strategyGrid); - this.strategyGroupBox.Location = new System.Drawing.Point(12, 178); + this.strategyGroupBox.Location = new System.Drawing.Point(12, 253); this.strategyGroupBox.Name = "strategyGroupBox"; this.strategyGroupBox.Size = new System.Drawing.Size(684, 180); this.strategyGroupBox.TabIndex = 2; @@ -184,13 +188,43 @@ this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24); this.colorSchemeCombo.TabIndex = 11; // + // serverGroupBox + // + this.serverGroupBox.Controls.Add(this.serverUrlBox); + this.serverGroupBox.Controls.Add(this.setServerButton); + this.serverGroupBox.Location = new System.Drawing.Point(12, 178); + this.serverGroupBox.Name = "serverGroupBox"; + this.serverGroupBox.Size = new System.Drawing.Size(684, 69); + this.serverGroupBox.TabIndex = 1; + this.serverGroupBox.TabStop = false; + this.serverGroupBox.Text = "Nest server URL (File / Database mode is selected under File > Storage Mode)"; + // + // serverUrlBox + // + this.serverUrlBox.Location = new System.Drawing.Point(12, 29); + this.serverUrlBox.Name = "serverUrlBox"; + this.serverUrlBox.PlaceholderText = "http://server:8090"; + this.serverUrlBox.Size = new System.Drawing.Size(485, 22); + this.serverUrlBox.TabIndex = 0; + // + // setServerButton + // + this.setServerButton.Location = new System.Drawing.Point(504, 26); + this.setServerButton.Name = "setServerButton"; + this.setServerButton.Size = new System.Drawing.Size(168, 28); + this.setServerButton.TabIndex = 1; + this.setServerButton.Text = "Set / Change Server"; + this.setServerButton.UseVisualStyleBackColor = true; + this.setServerButton.Click += new System.EventHandler(this.SetServer_Click); + // // OptionsForm // this.AcceptButton = this.saveButton; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None; this.CancelButton = this.cancelButton; - this.ClientSize = new System.Drawing.Size(708, 418); + this.ClientSize = new System.Drawing.Size(708, 493); this.Controls.Add(this.strategyGroupBox); + this.Controls.Add(this.serverGroupBox); this.Controls.Add(this.tableLayoutPanel1); this.Controls.Add(this.bottomPanel1); this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0))); @@ -206,6 +240,8 @@ this.tableLayoutPanel1.ResumeLayout(false); this.tableLayoutPanel1.PerformLayout(); this.bottomPanel1.ResumeLayout(false); + this.serverGroupBox.ResumeLayout(false); + this.serverGroupBox.PerformLayout(); this.ResumeLayout(false); } @@ -224,5 +260,8 @@ private System.Windows.Forms.GroupBox strategyGroupBox; private System.Windows.Forms.Label colorSchemeLabel; private System.Windows.Forms.ComboBox colorSchemeCombo; + private System.Windows.Forms.GroupBox serverGroupBox; + private System.Windows.Forms.TextBox serverUrlBox; + private System.Windows.Forms.Button setServerButton; } } \ No newline at end of file diff --git a/OpenNest/Forms/OptionsForm.cs b/OpenNest/Forms/OptionsForm.cs index 1ea4579..95fec44 100644 --- a/OpenNest/Forms/OptionsForm.cs +++ b/OpenNest/Forms/OptionsForm.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Windows.Forms; +using OpenNest.Data; using OpenNest.Engine.Strategies; using OpenNest.Properties; @@ -72,6 +73,7 @@ namespace OpenNest.Forms private void LoadSettings() { + serverUrlBox.Text = NestStorage.Settings.ServerUrl; checkBox1.Checked = Settings.Default.CreateNewNestOnOpen; numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor; @@ -142,5 +144,28 @@ namespace OpenNest.Forms { SaveSettings(); } + + private void SetServer_Click(object sender, EventArgs e) + { + if (!NestStorageSettings.TryNormalizeServerUrl(serverUrlBox.Text, out var url)) + { + MessageBox.Show(this, "Enter a valid http or https server URL.", + "Server URL", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + try + { + NestStorage.Save(NestStorage.Settings.Mode, url); + serverUrlBox.Text = NestStorage.Settings.ServerUrl; + MessageBox.Show(this, "Server URL saved. Storage mode has not changed.", + "Server URL", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not save the server URL: {ex.Message}", + "Server URL", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } } } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index e30b588..e831cb9 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -241,6 +241,10 @@ curl --fail http://:/healthz # {"status":"ok"} In the desktop app choose **File > Storage Mode...**, Database, and enter the base URL `http://:`, without `/healthz` or `/api/nests`. +To set or change just the saved server address without changing File/Database mode, +use **Tools > Options > Nest server URL > Set / Change Server**. This saves the URL +immediately, independently of the Options Save button; it does not migrate nests +or enable Database mode. Canceling Options afterward does not undo that address. ### Backup, restore, and upgrade From 17e504fb702bf52c344a47cb769358a89687ff2f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 10 Oct 2026 11:37:57 -0400 Subject: [PATCH 15/15] feat(cutting): navigate diagnostic rows in enlarged planner --- .../CuttingPlanning/CuttingPlanBatch.cs | 33 ++++ .../CuttingPlanning/CuttingPlanBatchTests.cs | 4 + .../Forms/CuttingPlanFormTests.cs | 17 +- OpenNest/Forms/CuttingPlanForm.cs | 158 ++++++++++++++++-- docs/cutting-planner.md | 14 +- 5 files changed, 211 insertions(+), 15 deletions(-) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index e91fa5a..a7b2353 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -236,6 +236,10 @@ public sealed class CuttingPlanPlateResult Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed); } +/// A diagnostic tied to captured plate and source part positions, not display text. +public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber, + int? OtherPartNumber, string Message); + /// /// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance. /// @@ -263,6 +267,35 @@ public sealed class CuttingPlanProposal public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply; + /// All findings, including ones hidden by a blocked plate's abbreviated summary. + public IReadOnlyList DiagnosticRows() + { + var rows = new List(); + for (var index = 0; index < Plates.Count; index++) + { + var plate = Plates[index]; + if (plate.Overlap != null) + { + foreach (var pair in plate.Overlap.Pairs) + rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1, + $"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part " + + $"{pair.PartBId + 1}{Name(pair.PartBName)}.")); + foreach (var issue in plate.Overlap.Issues) + rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1, + issue.PartBId is int other ? other + 1 : null, + $"Overlap check incomplete for part {issue.PartAId + 1}" + + (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty) + + $": {issue.Message}")); + } + foreach (var finding in plate.Result.Findings) + rows.Add(new(index, plate.PlateNumber, + finding.SourceOrdinal is int part ? part + 1 : null, + finding.OtherSourceOrdinal is int other ? other + 1 : null, + DescribeFinding(finding))); + } + return rows; + } + /// /// Installs every plate's replayed proposal through , on the /// thread that owns the plates. Only after every plate is applied does each one keep an owned copy diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs index c3604a7..2a990de 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs @@ -90,6 +90,10 @@ public class CuttingPlanBatchTests var text = string.Join("\n", proposal.Describe("in")); Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text); Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text); + var row = Assert.Single(proposal.DiagnosticRows()); + Assert.Equal((0, 1, 1, 2), + (row.PlateIndex, row.PlateNumber, row.PartNumber, row.OtherPartNumber)); + Assert.Contains("overlaps part 2", row.Message); Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); Assert.Equal(programs, plate.Parts.Select(p => p.Program)); Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); diff --git a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs index 8e300b0..057e7b3 100644 --- a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs @@ -59,10 +59,12 @@ public class CuttingPlanFormTests Assert.False(Control