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.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/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.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) 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/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); + } +} 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); + } } 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); + } }