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