diff --git a/OpenNest.Core/CanonicalAngle.cs b/OpenNest.Core/CanonicalAngle.cs
new file mode 100644
index 0000000..31d1314
--- /dev/null
+++ b/OpenNest.Core/CanonicalAngle.cs
@@ -0,0 +1,78 @@
+using OpenNest.Converters;
+using OpenNest.Geometry;
+using System.Linq;
+
+namespace OpenNest
+{
+ ///
+ /// Computes the rotation that maps a drawing to its canonical (MBR-axis-aligned) frame.
+ /// Lives in OpenNest.Core so Drawing.Program setter can invoke it directly without
+ /// a circular dependency on OpenNest.Engine.
+ ///
+ public static class CanonicalAngle
+ {
+ /// Angles with |v| below this (radians) are snapped to 0.
+ public const double SnapToZero = 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
+ /// across both callers.
+ ///
+ public static double FromMbr(BoundingRectangleResult mbr)
+ {
+ if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
+ return 0.0;
+
+ // The MBR edge angle can represent any of four equivalent orientations
+ // (edge-i, edge-i + π/2, edge-i + π, edge-i - π/2) depending on which hull
+ // edge the algorithm happened to pick. Normalize -mbr.Angle to the
+ // representative in [-π/4, π/4] so snap-to-zero works for inputs near
+ // ANY of the equivalent orientations.
+ var angle = -mbr.Angle;
+ const double halfPi = System.Math.PI / 2.0;
+ angle -= halfPi * System.Math.Round(angle / halfPi);
+
+ if (System.Math.Abs(angle) < SnapToZero)
+ return 0.0;
+
+ return angle;
+ }
+
+ public static double Compute(Drawing drawing)
+ {
+ if (drawing?.Program == null)
+ return 0.0;
+
+ var entities = ConvertProgram.ToGeometry(drawing.Program)
+ .Where(e => e.Layer != SpecialLayers.Rapid);
+
+ var shapes = ShapeBuilder.GetShapes(entities);
+ if (shapes.Count == 0)
+ return 0.0;
+
+ var perimeter = shapes[0];
+ var perimeterArea = perimeter.Area();
+ for (var i = 1; i < shapes.Count; i++)
+ {
+ var area = shapes[i].Area();
+ if (area > perimeterArea)
+ {
+ perimeter = shapes[i];
+ perimeterArea = area;
+ }
+ }
+
+ var polygon = perimeter.ToPolygonWithTolerance(0.1);
+ if (polygon == null || polygon.Vertices.Count < 3)
+ return 0.0;
+
+ var hull = ConvexHull.Compute(polygon.Vertices);
+ if (hull.Vertices.Count < 3)
+ return 0.0;
+
+ var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
+ return FromMbr(mbr);
+ }
+ }
+}
diff --git a/OpenNest.Tests/Engine/CanonicalAngleTests.cs b/OpenNest.Tests/Engine/CanonicalAngleTests.cs
new file mode 100644
index 0000000..3b23e98
--- /dev/null
+++ b/OpenNest.Tests/Engine/CanonicalAngleTests.cs
@@ -0,0 +1,99 @@
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Converters;
+using OpenNest.Engine;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Tests.Engine;
+
+public class CanonicalAngleTests
+{
+ private const double AngleTol = 0.002; // ~0.11°
+
+ private static Drawing MakeRect(double w, double h)
+ {
+ var pgm = new OpenNest.CNC.Program();
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(w, h)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, h)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+ return new Drawing("rect", pgm);
+ }
+
+ private static Drawing RotateCopy(Drawing src, double angle)
+ {
+ var pgm = src.Program.Clone() as OpenNest.CNC.Program;
+ pgm.Rotate(angle, pgm.BoundingBox().Center);
+ return new Drawing("rotated", pgm);
+ }
+
+ [Fact]
+ public void AxisAlignedRectangle_ReturnsZero()
+ {
+ var d = MakeRect(100, 50);
+ Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
+ }
+
+ // Program.BoundingBox() has a pre-existing bug where minX/minY initialize to 0 and can
+ // only decrease, so programs whose extents stay in the positive half-plane report a
+ // too-large AABB. To validate MBR-axis-alignment without tripping that bug, extract the
+ // outer perimeter polygon and compute its true AABB from vertices.
+ private static (double length, double width) TrueAabb(OpenNest.CNC.Program pgm)
+ {
+ var entities = ConvertProgram.ToGeometry(pgm).Where(e => e.Layer != SpecialLayers.Rapid);
+ var shapes = ShapeBuilder.GetShapes(entities);
+ var outer = shapes.OrderByDescending(s => s.Area()).First();
+ var poly = outer.ToPolygonWithTolerance(0.1);
+ var minX = poly.Vertices.Min(v => v.X);
+ var maxX = poly.Vertices.Max(v => v.X);
+ var minY = poly.Vertices.Min(v => v.Y);
+ var maxY = poly.Vertices.Max(v => v.Y);
+ return (maxX - minX, maxY - minY);
+ }
+
+ [Theory]
+ [InlineData(0.3)]
+ [InlineData(0.7)]
+ [InlineData(1.2)]
+ public void Rectangle_ReturnsNegatedRotation_Modulo90(double theta)
+ {
+ var rotated = RotateCopy(MakeRect(100, 50), theta);
+ var angle = CanonicalAngle.Compute(rotated);
+
+ // Applying the returned angle should leave MBR axis-aligned.
+ var canonical = rotated.Program.Clone() as OpenNest.CNC.Program;
+ canonical.Rotate(angle, canonical.BoundingBox().Center);
+
+ var (length, width) = TrueAabb(canonical);
+ var longer = System.Math.Max(length, width);
+ var shorter = System.Math.Min(length, width);
+ Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
+ Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
+ }
+
+ [Fact]
+ public void NearZeroInput_SnapsToZero()
+ {
+ var rotated = RotateCopy(MakeRect(100, 50), 0.0005);
+ Assert.Equal(0.0, CanonicalAngle.Compute(rotated), precision: 6);
+ }
+
+ [Fact]
+ public void DegeneratePolygon_ReturnsZero()
+ {
+ var pgm = new OpenNest.CNC.Program();
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
+ var d = new Drawing("line", pgm);
+ Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
+ }
+
+ [Fact]
+ public void EmptyProgram_ReturnsZero()
+ {
+ var d = new Drawing("empty", new OpenNest.CNC.Program());
+ Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
+ }
+}