From 2a855139d2f9675e87173354699e9869a2c053a9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 11:07:44 -0400 Subject: [PATCH 1/4] fix(core): stop Program.BoundingBox including the origin Min/max were seeded at 0, so any geometry not touching the origin got an inflated box, and the first move only updated max (else-if). Rotated canonical drawings are the common trigger: their origin ends up outside the shape, which skewed Part bounds and bbox-based alignment. Track the real extents and keep returning a zero box for empty programs. Co-Authored-By: Claude Sonnet 5 --- OpenNest.Core/CNC/Program.cs | 37 +++++++++----- OpenNest.Tests/CNC/ProgramBoundingBoxTests.cs | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 OpenNest.Tests/CNC/ProgramBoundingBoxTests.cs diff --git a/OpenNest.Core/CNC/Program.cs b/OpenNest.Core/CNC/Program.cs index 3dfb0a3..de0333b 100644 --- a/OpenNest.Core/CNC/Program.cs +++ b/OpenNest.Core/CNC/Program.cs @@ -305,22 +305,27 @@ namespace OpenNest.CNC return new Vector(0, 0); } + /// + /// Bounding box of the geometry the program visits. The tool's starting position is not + /// part of the geometry, so the origin only contributes when the program reaches it. + /// An empty program returns a zero-size box at the origin. + /// public Box BoundingBox() { var origin = new Vector(0, 0); - return BoundingBox(ref origin); + return BoundingBox(ref origin, out var box) ? box : new Box(0, 0, 0, 0); } - private Box BoundingBox(ref Vector pos) + private bool BoundingBox(ref Vector pos, out Box result) { // Capture the frame origin at entry. Sub-program Offsets and // absolute-mode endpoints are relative to this fixed origin. var frameOrigin = pos; - double minX = 0.0; - double minY = 0.0; - double maxX = 0.0; - double maxY = 0.0; + var minX = double.PositiveInfinity; + var minY = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var maxY = double.NegativeInfinity; for (int i = 0; i < Codes.Count; ++i) { @@ -338,12 +343,12 @@ namespace OpenNest.CNC if (pt.X > maxX) maxX = pt.X; - else if (pt.X < minX) + if (pt.X < minX) minX = pt.X; if (pt.Y > maxY) maxY = pt.Y; - else if (pt.Y < minY) + if (pt.Y < minY) minY = pt.Y; pos = pt; @@ -361,12 +366,12 @@ namespace OpenNest.CNC if (pt.X > maxX) maxX = pt.X; - else if (pt.X < minX) + if (pt.X < minX) minX = pt.X; if (pt.Y > maxY) maxY = pt.Y; - else if (pt.Y < minY) + if (pt.Y < minY) minY = pt.Y; pos = pt; @@ -470,7 +475,8 @@ namespace OpenNest.CNC // Sub-program frame origin in this program's frame // is frameOrigin + Offset, regardless of current pos. pos = frameOrigin + subpgm.Offset; - var box = subpgm.Program.BoundingBox(ref pos); + if (!subpgm.Program.BoundingBox(ref pos, out var box)) + break; if (box.Left < minX) minX = box.Left; @@ -489,7 +495,14 @@ namespace OpenNest.CNC } } - return new Box(minX, minY, maxX - minX, maxY - minY); + if (minX > maxX || minY > maxY) + { + result = new Box(0, 0, 0, 0); + return false; + } + + result = new Box(minX, minY, maxX - minX, maxY - minY); + return true; } public object Clone() diff --git a/OpenNest.Tests/CNC/ProgramBoundingBoxTests.cs b/OpenNest.Tests/CNC/ProgramBoundingBoxTests.cs new file mode 100644 index 0000000..a5650c2 --- /dev/null +++ b/OpenNest.Tests/CNC/ProgramBoundingBoxTests.cs @@ -0,0 +1,50 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CNC; + +public class ProgramBoundingBoxTests +{ + [Fact] + public void GeometryAwayFromOrigin_DoesNotIncludeOrigin() + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new RapidMove(new Vector(10, 10))); + pgm.Codes.Add(new LinearMove(new Vector(20, 10))); + pgm.Codes.Add(new LinearMove(new Vector(20, 30))); + pgm.Codes.Add(new LinearMove(new Vector(10, 30))); + pgm.Codes.Add(new LinearMove(new Vector(10, 10))); + + var box = pgm.BoundingBox(); + + Assert.Equal(10, box.Left, precision: 6); + Assert.Equal(10, box.Bottom, precision: 6); + Assert.Equal(20, box.Right, precision: 6); + Assert.Equal(30, box.Top, precision: 6); + } + + [Fact] + public void GeometryBelowAndLeftOfOrigin_IsTrackedExactly() + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new RapidMove(new Vector(-30, -20))); + pgm.Codes.Add(new LinearMove(new Vector(-10, -20))); + pgm.Codes.Add(new LinearMove(new Vector(-10, -5))); + + var box = pgm.BoundingBox(); + + Assert.Equal(-30, box.Left, precision: 6); + Assert.Equal(-20, box.Bottom, precision: 6); + Assert.Equal(-10, box.Right, precision: 6); + Assert.Equal(-5, box.Top, precision: 6); + } + + [Fact] + public void EmptyProgram_ReturnsZeroBox() + { + var box = new OpenNest.CNC.Program().BoundingBox(); + + Assert.Equal(0, box.Width, precision: 6); + Assert.Equal(0, box.Length, precision: 6); + } +} From a764a70e52717293f2b8a0da06764b298f455b00 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 11:07:44 -0400 Subject: [PATCH 2/4] fix(engine): make canonical-frame fills orientation-invariant Part.Rotation is cumulative, so rebinding canonical parts with CreateAtOrigin(original, p.Rotation) double-counted the drawing's own rotation, and FromCanonical rotated each part about its Location, which moved it off its slot and out of the work area. Add CanonicalFrame.RebindToOriginal (rotation = part - original program rotation, footprint aligned to the canonical part) and use it in the three places that duplicated the old logic. The MBR only fixes the frame modulo 90 degrees and nest results are not 90-degree symmetric (an L gave 56/43/42/42 parts by orientation). CanonicalAngle.Compute now picks one of the four orientations from the centroid offset; symmetric shapes keep the MBR orientation. Fixes the three NestInvarianceTests. Co-Authored-By: Claude Sonnet 5 --- OpenNest.Core/CanonicalAngle.cs | 90 +++++++++++++++++++- OpenNest.Engine/BestFit/BestFitResult.cs | 13 +-- OpenNest.Engine/CanonicalFrame.cs | 32 +++++++ OpenNest.Engine/DefaultNestEngine.cs | 32 ++----- OpenNest.Engine/NestEngineBase.cs | 36 ++------ OpenNest.Tests/Engine/CanonicalAngleTests.cs | 55 ++++++++++++ OpenNest.Tests/Engine/CanonicalFrameTests.cs | 31 +++++++ 7 files changed, 221 insertions(+), 68 deletions(-) diff --git a/OpenNest.Core/CanonicalAngle.cs b/OpenNest.Core/CanonicalAngle.cs index 27493b9..be0898c 100644 --- a/OpenNest.Core/CanonicalAngle.cs +++ b/OpenNest.Core/CanonicalAngle.cs @@ -14,6 +14,12 @@ namespace OpenNest /// Angles with |v| below this (radians) are snapped to 0. public const double SnapToZero = 0.001; + /// Centroid offsets below this fraction of the MBR extent count as symmetric. + private const double SymmetryTolerance = 1e-6; + + /// Angular margin (radians) keeping axis-aligned centroid offsets off the edge of the preferred quadrant. + private const double PreferenceMargin = 0.001; + /// /// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which /// computes the MBR itself) and by PartClassifier (which already has one). Single formula @@ -73,7 +79,89 @@ namespace OpenNest return 0.0; var mbr = RotatingCalipers.MinimumBoundingRectangle(hull); - return FromMbr(mbr); + var angle = FromMbr(mbr); + if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon) + return angle; + + var quarterTurns = PreferredQuarterTurns(polygon, hull, angle); + if (quarterTurns == 0) + return angle; + + return NormalizeSigned(angle + quarterTurns * System.Math.PI / 2.0); + } + + /// + /// The MBR only fixes the frame modulo 90°, leaving four equivalent orientations. Nest + /// results are not 90°-symmetric, so pick one deterministically: the quarter-turn count + /// that puts the perimeter's centroid toward the lower-left of its MBR. Shapes with no + /// centroid offset (rectangles, circles) are symmetric and keep the MBR orientation. + /// + private static int PreferredQuarterTurns(Polygon polygon, Polygon hull, double angle) + { + var minX = double.MaxValue; + var minY = double.MaxValue; + var maxX = double.MinValue; + var maxY = double.MinValue; + foreach (var vertex in hull.Vertices) + { + var rotated = vertex.Rotate(angle); + minX = System.Math.Min(minX, rotated.X); + minY = System.Math.Min(minY, rotated.Y); + maxX = System.Math.Max(maxX, rotated.X); + maxY = System.Math.Max(maxY, rotated.Y); + } + + var centroid = Centroid(polygon).Rotate(angle); + var dx = centroid.X - (minX + maxX) / 2.0; + var dy = centroid.Y - (minY + maxY) / 2.0; + + var extent = System.Math.Max(maxX - minX, maxY - minY); + if (System.Math.Sqrt(dx * dx + dy * dy) <= SymmetryTolerance * extent) + return 0; + + // Choose k so the offset direction lands in [PI - margin, 3PI/2 - margin). The margin + // keeps offsets lying exactly on an axis (mirror-symmetric parts) away from the + // interval edge so floating-point noise cannot flip the choice. + var halfPi = System.Math.PI / 2.0; + var direction = System.Math.Atan2(dy, dx); + for (var turns = 0; turns < 4; turns++) + { + var relative = direction + turns * halfPi - (System.Math.PI - PreferenceMargin); + relative -= 2.0 * System.Math.PI * System.Math.Floor(relative / (2.0 * System.Math.PI)); + if (relative < halfPi) + return turns; + } + + return 0; + } + + private static Vector Centroid(Polygon polygon) + { + var vertices = polygon.Vertices; + var doubleArea = 0.0; + var cx = 0.0; + var cy = 0.0; + for (var i = 0; i < vertices.Count; i++) + { + var p = vertices[i]; + var q = vertices[(i + 1) % vertices.Count]; + var cross = p.X * q.Y - q.X * p.Y; + doubleArea += cross; + cx += (p.X + q.X) * cross; + cy += (p.Y + q.Y) * cross; + } + + if (System.Math.Abs(doubleArea) <= OpenNest.Math.Tolerance.Epsilon) + return new Vector(vertices.Average(v => v.X), vertices.Average(v => v.Y)); + + return new Vector(cx / (3.0 * doubleArea), cy / (3.0 * doubleArea)); + } + + private static double NormalizeSigned(double angle) + { + var twoPi = 2.0 * System.Math.PI; + angle -= twoPi * System.Math.Floor((angle + System.Math.PI) / twoPi); + return angle; } } } diff --git a/OpenNest.Engine/BestFit/BestFitResult.cs b/OpenNest.Engine/BestFit/BestFitResult.cs index 1e5e4f3..6822139 100644 --- a/OpenNest.Engine/BestFit/BestFitResult.cs +++ b/OpenNest.Engine/BestFit/BestFitResult.cs @@ -70,19 +70,8 @@ namespace OpenNest.Engine.BestFit public List BuildSourceParts(Drawing drawing) { var parts = BuildCanonicalParts(); - var sourceAngle = drawing?.Source?.Angle ?? 0.0; - for (var i = 0; i < parts.Count; i++) - { - var p = parts[i]; - var rebound = Part.CreateAtOrigin(drawing, p.Rotation); - var delta = p.BoundingBox.Location - rebound.BoundingBox.Location; - rebound.Offset(delta); - rebound.UpdateBounds(); - parts[i] = rebound; - } - - return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle)); + return NormalizeToCutOrigin(CanonicalFrame.RebindToOriginal(parts, drawing)); } public Box GetCutBounds(List parts) diff --git a/OpenNest.Engine/CanonicalFrame.cs b/OpenNest.Engine/CanonicalFrame.cs index 8b657e2..4ac9f15 100644 --- a/OpenNest.Engine/CanonicalFrame.cs +++ b/OpenNest.Engine/CanonicalFrame.cs @@ -47,6 +47,38 @@ namespace OpenNest.Engine return copy; } + /// + /// Rebinds canonical-frame placed parts to the original drawing while preserving each + /// part's world footprint. + /// + /// is cumulative: it includes the rotation already baked into + /// the drawing's program. The canonical copy carries original + sourceAngle, so a canonical + /// part's rotation minus the original program's rotation is exactly the rotation (engine + /// rotation plus sourceAngle) that turns the original into the same shape. Each part is + /// rebuilt from the original at that rotation and translated to sit where the canonical + /// part did. Rotating a finished part about its Location instead would shift it out of place. + /// + public static List RebindToOriginal(List canonicalParts, Drawing original) + { + if (canonicalParts == null || canonicalParts.Count == 0) + return canonicalParts; + + var baseRotation = original.Program.Rotation; + for (var i = 0; i < canonicalParts.Count; i++) + { + var canonical = canonicalParts[i]; + var rebound = Part.CreateAtOrigin( + original, + Angle.NormalizeRad(canonical.Rotation - baseRotation) + ); + rebound.Offset(canonical.BoundingBox.Location - rebound.BoundingBox.Location); + rebound.UpdateBounds(); + canonicalParts[i] = rebound; + } + + return canonicalParts; + } + /// /// Composes the source drawing's canonical angle onto each placed part so the /// returned list is in the drawing's original (visible) frame. diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs index fb31601..af0b512 100644 --- a/OpenNest.Engine/DefaultNestEngine.cs +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -59,7 +59,6 @@ namespace OpenNest // Replace the item's Drawing with a canonical copy for the duration of this fill. // All internal methods see canonical geometry; this wrapper un-canonicalizes the final result. - var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0; var originalDrawing = item.Drawing; var canonicalItem = new NestItem { @@ -81,7 +80,7 @@ namespace OpenNest $"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}" ); WinnerPhase = NestPhase.Pairs; - fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle); + fast = RebindAndUnCanonicalize(fast, originalDrawing); ReportProgress( progress, new ProgressReport @@ -129,7 +128,7 @@ namespace OpenNest if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity) best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis); - best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle); + best = RebindAndUnCanonicalize(best, originalDrawing); ReportProgress( progress, @@ -150,31 +149,10 @@ namespace OpenNest /// /// Single exit point for canonical -> source frame conversion. Rebinds every Part to the /// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy) - /// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical. + /// and composes the canonical angle onto each Part's rotation via CanonicalFrame.RebindToOriginal. /// - private static List RebindAndUnCanonicalize( - List parts, - Drawing original, - double sourceAngle - ) - { - if (parts == null || parts.Count == 0) - return parts; - - for (var i = 0; i < parts.Count; i++) - { - var p = parts[i]; - // Rebind to `original` while preserving world pose. CreateAtOrigin rotates - // at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox. - var rebound = Part.CreateAtOrigin(original, p.Rotation); - var delta = p.BoundingBox.Location - rebound.BoundingBox.Location; - rebound.Offset(delta); - rebound.UpdateBounds(); - parts[i] = rebound; - } - - return CanonicalFrame.FromCanonical(parts, sourceAngle); - } + private static List RebindAndUnCanonicalize(List parts, Drawing original) => + CanonicalFrame.RebindToOriginal(parts, original); /// /// Fast path for qty 1-2: place a single part or a best-fit pair diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index c5cbb12..a911161 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -393,7 +393,6 @@ namespace OpenNest // from a canonical drawing copy so geometry and coords share a frame; rebind // + un-rotate winning pair to the original drawing's frame before returning. var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing); - var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0; List bestPlacement = null; Box bestTarget = null; @@ -438,9 +437,9 @@ namespace OpenNest if (bestPlacement == null) continue; - // Rebind to the original drawing and compose sourceAngle onto rotation so the - // final placed parts sit in the user's visible frame. - bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle); + // Rebind to the original drawing and compose the canonical angle onto rotation so + // the final placed parts sit in the user's visible frame. + bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing); result.AddRange(bestPlacement); item.Quantity = 0; @@ -460,31 +459,12 @@ namespace OpenNest /// /// Rebinds each canonical-frame Part in the pair to the original Drawing at its current - /// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so - /// the returned list is in the original drawing's visible frame. Mirrors - /// DefaultNestEngine.RebindAndUnCanonicalize. + /// world pose, then composes the canonical angle onto each via + /// CanonicalFrame.RebindToOriginal so the returned list is in the original drawing's + /// visible frame. Mirrors DefaultNestEngine.RebindAndUnCanonicalize. /// - private static List RebindPairToOriginal( - List parts, - Drawing original, - double sourceAngle - ) - { - if (parts == null || parts.Count == 0) - return parts; - - for (var i = 0; i < parts.Count; i++) - { - var p = parts[i]; - var rebound = Part.CreateAtOrigin(original, p.Rotation); - var delta = p.BoundingBox.Location - rebound.BoundingBox.Location; - rebound.Offset(delta); - rebound.UpdateBounds(); - parts[i] = rebound; - } - - return CanonicalFrame.FromCanonical(parts, sourceAngle); - } + private static List RebindPairToOriginal(List parts, Drawing original) => + CanonicalFrame.RebindToOriginal(parts, original); /// /// Determines whether a drawing should use grid-fill (true) or bin-pack (false). diff --git a/OpenNest.Tests/Engine/CanonicalAngleTests.cs b/OpenNest.Tests/Engine/CanonicalAngleTests.cs index d570c54..94ccd73 100644 --- a/OpenNest.Tests/Engine/CanonicalAngleTests.cs +++ b/OpenNest.Tests/Engine/CanonicalAngleTests.cs @@ -96,6 +96,61 @@ public class CanonicalAngleTests var d = new Drawing("empty", new OpenNest.CNC.Program()); Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6); } + + private static Drawing MakeL(double rotation) + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(100, 0))); + pgm.Codes.Add(new LinearMove(new Vector(100, 20))); + pgm.Codes.Add(new LinearMove(new Vector(50, 20))); + pgm.Codes.Add(new LinearMove(new Vector(50, 50))); + pgm.Codes.Add(new LinearMove(new Vector(0, 50))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + if (!OpenNest.Math.Tolerance.IsEqualTo(rotation, 0)) + pgm.Rotate(rotation, pgm.BoundingBox().Center); + return new Drawing("L", pgm); + } + + // Canonical outline, translated to its own corner, as sorted rounded vertices. + private static string Signature(Drawing drawing) + { + var canonical = CanonicalFrame.AsCanonicalCopy(drawing); + var entities = ConvertProgram + .ToGeometry(canonical.Program) + .Where(e => e.Layer != SpecialLayers.Rapid); + var vertices = ShapeBuilder + .GetShapes(entities) + .OrderByDescending(s => s.Area()) + .First() + .ToPolygonWithTolerance(0.1) + .Vertices.ToList(); + var minX = vertices.Min(v => v.X); + var minY = vertices.Min(v => v.Y); + return string.Join( + ";", + vertices + .Select(v => + $"{System.Math.Round(v.X - minX, 2):F2},{System.Math.Round(v.Y - minY, 2):F2}" + ) + .Distinct() + .OrderBy(x => x) + ); + } + + [Theory] + [InlineData(0.3)] + [InlineData(0.8)] + [InlineData(1.2)] + public void AsymmetricShape_CanonicalOrientationIsIndependentOfQuarterTurns(double offset) + { + // The MBR fixes the frame only modulo 90 degrees; an L-shape must still land in one + // deterministic orientation however it was imported. + var baseline = Signature(MakeL(offset)); + + for (var turns = 1; turns < 4; turns++) + Assert.Equal(baseline, Signature(MakeL(offset + turns * System.Math.PI / 2))); + } } public class DrawingCanonicalAngleWiringTests diff --git a/OpenNest.Tests/Engine/CanonicalFrameTests.cs b/OpenNest.Tests/Engine/CanonicalFrameTests.cs index 9facb0e..b7e9278 100644 --- a/OpenNest.Tests/Engine/CanonicalFrameTests.cs +++ b/OpenNest.Tests/Engine/CanonicalFrameTests.cs @@ -81,4 +81,35 @@ public class CanonicalFrameTests Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2); Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2); } + + [Fact] + public void RebindToOriginal_PreservesWorldFootprint_ForRotatedImport() + { + // An L-shape imported at an angle: the canonical copy is rotated, so a canonical part and + // the rebound part must cover exactly the same footprint even though their programs differ. + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(100, 0))); + pgm.Codes.Add(new LinearMove(new Vector(100, 20))); + pgm.Codes.Add(new LinearMove(new Vector(50, 20))); + pgm.Codes.Add(new LinearMove(new Vector(50, 50))); + pgm.Codes.Add(new LinearMove(new Vector(0, 50))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + pgm.Rotate(0.8, pgm.BoundingBox().Center); + var original = new Drawing("L", pgm); + var canonical = CanonicalFrame.AsCanonicalCopy(original); + + var placed = Part.CreateAtOrigin(canonical, 0.35); + placed.Offset(new Vector(40, 25)); + placed.UpdateBounds(); + var expected = placed.BoundingBox; + + var rebound = CanonicalFrame.RebindToOriginal(new List { placed }, original); + + Assert.Same(original, rebound[0].BaseDrawing); + Assert.Equal(expected.Left, rebound[0].BoundingBox.Left, precision: 6); + Assert.Equal(expected.Bottom, rebound[0].BoundingBox.Bottom, precision: 6); + Assert.Equal(expected.Right, rebound[0].BoundingBox.Right, precision: 6); + Assert.Equal(expected.Top, rebound[0].BoundingBox.Top, precision: 6); + } } From 630d514b0ec261168ed54c329f7daa0136b1935a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 11:07:45 -0400 Subject: [PATCH 3/4] fix(engine): don't drop the topmost part when no other drawing is waiting RemnantFiller removes the topmost placed part to keep a clean rectangular obstacle for the next drawing, but the envelope then walls that slot off, so the part was lost for nothing (4 squares on a 9x9 plate became 3). Only remove it while another drawing still has demand. Fixes the mixed-stock NestRunner tests. Co-Authored-By: Claude Sonnet 5 --- OpenNest.Engine/Fill/RemnantFiller.cs | 22 +++++++++++++++++- OpenNest.Tests/Fill/RemnantFillerTests2.cs | 26 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/Fill/RemnantFiller.cs b/OpenNest.Engine/Fill/RemnantFiller.cs index ddf9dee..22b18aa 100644 --- a/OpenNest.Engine/Fill/RemnantFiller.cs +++ b/OpenNest.Engine/Fill/RemnantFiller.cs @@ -114,7 +114,10 @@ namespace OpenNest.Engine.Fill // rectangular obstacle boundary. Without this, gaps between // individual bounding boxes cause the next drawing to fill // into inter-row spaces, producing an interleaved layout. - if (placed.Count > 2) + // Only worthwhile while another drawing is still waiting for + // space; otherwise the removed part's slot is walled off by the + // envelope below and the part is lost for nothing. + if (placed.Count > 2 && HasOtherDemand(items, item, localQty)) RemoveTopmostPart(placed); allParts.AddRange(placed); @@ -132,6 +135,23 @@ namespace OpenNest.Engine.Fill return false; } + private static bool HasOtherDemand( + List items, + NestItem current, + Dictionary localQty + ) + { + foreach (var other in items) + { + if (ReferenceEquals(other.Drawing, current.Drawing)) + continue; + if (localQty[other.Drawing] > 0) + return true; + } + + return false; + } + private static void RemoveTopmostPart(List parts) { var topIdx = 0; diff --git a/OpenNest.Tests/Fill/RemnantFillerTests2.cs b/OpenNest.Tests/Fill/RemnantFillerTests2.cs index 2e37714..498570e 100644 --- a/OpenNest.Tests/Fill/RemnantFillerTests2.cs +++ b/OpenNest.Tests/Fill/RemnantFillerTests2.cs @@ -103,4 +103,30 @@ public class RemnantFillerTests2 // Should not throw, returns whatever was placed Assert.NotNull(result); } + + [Fact] + public void FillItems_SingleDrawing_KeepsEveryPlacedPart() + { + // With no other drawing waiting for space there is nothing to keep clear, so a full + // grid fill must not lose its topmost part. + var workArea = new Box(0, 0, 100, 100); + var filler = new RemnantFiller(workArea, 0); + var items = new List + { + new NestItem { Drawing = MakeSquareDrawing(10), Quantity = 4 }, + }; + + Func> fillFunc = (ni, b) => + new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(10, 0, 10), + TestHelpers.MakePartAt(0, 10, 10), + TestHelpers.MakePartAt(10, 10, 10), + }; + + var placed = filler.FillItems(items, fillFunc); + + Assert.Equal(4, placed.Count); + } } From 64d38c452c397faaa11b50b9288e8c08aed0e72e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 11:07:45 -0400 Subject: [PATCH 4/4] fix(engine): route restricted-rotation requirements to OrderedPlateNester The legacy engine reads RotationStart == RotationEnd == 0 as unconstrained and its Pairs/RectBestFit strategies rotate freely, so it returned poses a Fixed/BoundedSweep RotationPolicy forbids and the job validator threw. DefaultPlateNester now delegates such requests to the policy-aware OrderedPlateNester. Fixes RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation. Co-Authored-By: Claude Sonnet 5 --- OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs b/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs index 857d506..1b884b6 100644 --- a/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs +++ b/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; namespace OpenNest; @@ -20,6 +21,7 @@ namespace OpenNest; public sealed class DefaultPlateNester : IPlateNester { private readonly Func engineFactory; + private readonly OrderedPlateNester restrictedRotationNester = new(); private readonly Dictionary drawingsById = new(StringComparer.Ordinal); private readonly Dictionary idByDrawing = new( ReferenceEqualityComparer.Instance @@ -44,6 +46,13 @@ public sealed class DefaultPlateNester : IPlateNester ArgumentNullException.ThrowIfNull(request); token.ThrowIfCancellationRequested(); + // The legacy engine cannot express a locked or bounded rotation (start == end == 0 reads + // as "unconstrained") and its Pairs/RectBestFit strategies rotate freely, so it can return + // poses the requirement's RotationPolicy forbids. Restricted requirements go to the + // policy-aware ordered nester, which only proposes allowed angles and validates each pose. + if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic)) + return restrictedRotationNester.Place(request, progress, token); + var plate = DrawingJobMapper.CreatePlate(request.Stock); var items = new List(request.Parts.Count); foreach (var requirement in request.Parts)