feat(core): add CanonicalAngle helper for MBR-aligning angle
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class CanonicalAngle
|
||||||
|
{
|
||||||
|
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
|
||||||
|
public const double SnapToZero = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user