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