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