From 9a6b656e3ca394e295575f7417b553b5d24bd877 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 20 Apr 2026 08:57:03 -0400 Subject: [PATCH] feat(core): add CanonicalAngle helper for MBR-aligning angle --- OpenNest.Core/CanonicalAngle.cs | 78 +++++++++++++++ OpenNest.Tests/Engine/CanonicalAngleTests.cs | 99 ++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 OpenNest.Core/CanonicalAngle.cs create mode 100644 OpenNest.Tests/Engine/CanonicalAngleTests.cs 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); + } +}