diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 1bbd239..dfcd375 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -636,7 +636,7 @@ namespace OpenNest.CNC.CuttingStrategy } } - private enum CornerKind + internal enum CornerKind { Convex, Reflex, @@ -644,6 +644,38 @@ namespace OpenNest.CNC.CuttingStrategy Cusp, } + /// + /// A vertex classified for automatic start-point planning: what kind of turn the + /// contour makes there, and the travel tangents of the two edges it joins. Read-only + /// so callers cannot mutate the contour; winding is derived the same way + /// derives it, so the kind matches actual emission. + /// + internal readonly record struct AutomaticCorner(CornerKind Kind, Vector TangentIn, Vector TangentOut); + + /// + /// Classification query shared with entry planning: the turn at + /// on a closed line/arc contour, using the same corner geometry and winding derivation + /// as emission. False when is not a shared vertex of two + /// chainable entities (an interior point, an open contour, a degenerate or non-finite + /// corner). Lead generation itself is not involved. + /// + internal static bool TryClassifyAutomaticStartCorner( + Shape shape, + Vector point, + Entity entity, + out AutomaticCorner corner) + { + if (!TryGetCorner(shape, point, entity, out var raw)) + { + corner = default; + return false; + } + + corner = new AutomaticCorner( + ClassifyCorner(raw, DetermineWinding(shape)), raw.TangentIn, raw.TangentOut); + return true; + } + /// A contour vertex: the entity cut into it and the one cut away from it. private readonly record struct ContourCorner( Entity Incoming, diff --git a/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs b/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs new file mode 100644 index 0000000..989a571 --- /dev/null +++ b/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs @@ -0,0 +1,270 @@ +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingStrategy; + +/// +/// The shared classification query for automatic start-point planning reuses the emitter's +/// own corner geometry and winding derivation: convex, reflex, smooth and cusp vertices +/// classify identically from either adjacent entity, in both windings, and rotations of the +/// shape do not change the kind. Midpoints are not corners. +/// +public class AutomaticCornerClassificationTests +{ + private const double ToleranceDegrees = 1e-6; + + // --- fixtures ------------------------------------------------------------- + + private static Shape ClosedShape(params Entity[] entities) + { + var shape = new Shape(); + shape.Entities.AddRange(entities); + Assert.True(shape.IsClosed()); + return shape; + } + + private static Shape Square(Vector[] corners) + { + var e = new Entity[corners.Length]; + for (var i = 0; i < corners.Length; i++) + e[i] = new Line(corners[i], corners[(i + 1) % corners.Length]); + return ClosedShape(e); + } + + /// CCW square; the shape's own winding derivation says so. + private static Shape CcwSquare(double size = 10) => + Square(new[] + { + new Vector(0, 0), new Vector(size, 0), new Vector(size, size), new Vector(0, size), + }); + + /// Same square traversed CW. + private static Shape CwSquare(double size = 10) + { + var s = CcwSquare(size); + s.Reverse(); + return s; + } + + /// CCW L-shape: the concave vertex (5,5) turns right — reflex. + private static Shape Notched() + { + return Square(new[] + { + new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(5, 10), + new Vector(5, 5), new Vector(0, 5), + }); + } + + /// CCW square whose top side is a half-circle bump; the tangent joints are at (0,10) and (10,10). + private static Shape ArcBumpSquare() + { + return ClosedShape( + new Line(new Vector(0, 0), new Vector(10, 0)), + new Line(new Vector(10, 0), new Vector(10, 10)), + new Arc(new Vector(5, 10), 5, 0, System.Math.PI), + new Line(new Vector(0, 10), new Vector(0, 0))); + } + + // --- positive cases -------------------------------------------------------- + + public static IEnumerable CcwSquareCorners() + { + var corners = new[] + { + new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10), + }; + for (var i = 0; i < corners.Length; i++) + { + yield return new object[] { corners[i], i }; + yield return new object[] { corners[i], (i + corners.Length - 1) % corners.Length }; + } + } + + [Theory] + [MemberData(nameof(CcwSquareCorners))] + public void SquareCorners_AreConvexFromEitherEdge(Vector corner, int entityIndex) + { + var shape = CcwSquare(); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, corner, shape.Entities[entityIndex], out var found)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, found.Kind); + } + + [Theory] + [MemberData(nameof(CcwSquareCorners))] + public void TraversedBackwards_SquareCornersStayConvex(Vector corner) + { + var shape = CwSquare(); + // Same geometric corners; entity order is reversed, so look the vertex up by position. + var (entity, point) = NearestVertex(shape, corner); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, point, entity, out var found)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, found.Kind); + } + + [Fact] + public void NotchVertex_IsReflexFromEitherEdge() + { + var shape = Notched(); + var notch = new Vector(5, 5); + var incoming = shape.Entities.Single(e => EndOf(e) == notch); + var outgoing = shape.Entities.Single(e => StartOf(e) == notch); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, incoming, out var a)); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, outgoing, out var b)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Reflex, a.Kind); + Assert.Equal(a.Kind, b.Kind); + Assert.Equal(ContourCuttingStrategy.CornerKind.Reflex, + ClassifyAtVertex(CwShaped(Notched()), notch)); + } + + [Theory] + [InlineData(0, 10)] + [InlineData(10, 10)] + public void TangentLineArcJoints_AreSmoothNotCorners(double x, double y) + { + var shape = ArcBumpSquare(); + var point = new Vector(x, y); + var incoming = shape.Entities.Single(e => EndOf(e) == point); + var outgoing = shape.Entities.Single(e => StartOf(e) == point); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, incoming, out var a)); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, outgoing, out var b)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Smooth, a.Kind); + Assert.Equal(b.Kind, a.Kind); + } + + [Fact] + public void ReversalVertex_IsCusp() + { + // The emitter's own rule: equal-and-opposite travel tangents (turn ≈ 0, dot < 0). + var shape = Square(new[] + { + new Vector(0, 0), new Vector(5, 0), new Vector(0, 0), new Vector(0, 10), new Vector(10, 10), + new Vector(10, 0), + }); + var cusp = new Vector(0, 0); + var entity = shape.Entities.First(e => StartOf(e) == cusp); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, cusp, entity, out var found)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Cusp, found.Kind); + } + + // --- invariants ------------------------------------------------------------- + + [Theory] + [InlineData(0.37)] + [InlineData(1.9)] + [InlineData(4.71)] + public void RotatingTheShape_DoesNotChangeTheKind(double angle) + { + var shape = Notched(); + var notch = new Vector(5, 5); + var entity = shape.Entities.Single(e => StartOf(e) == notch); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, entity, out var before)); + + shape.Rotate(angle); + var (rotatedEntity, rotatedPoint) = NearestVertex(shape, notch.Rotate(angle)); + + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, rotatedPoint, rotatedEntity, out var after)); + Assert.Equal(before.Kind, after.Kind); + } + + [Fact] + public void Midpoint_IsNotACorner() + { + var shape = CcwSquare(); + var edge = Assert.IsType(shape.Entities[1]); + + Assert.False(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, edge.MidPoint, edge, out _)); + } + + [Fact] + public void OpenContour_HasNoClassifiableCorners() + { + var shape = CcwSquare(); + shape.Entities.RemoveAt(2); + Assert.False(shape.IsClosed()); + + Assert.False(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + shape, new Vector(10, 10), shape.Entities[1], out _)); + } + + [Fact] + public void KindMatchesTheExistingEmitterClassification() + { + // Cross-check against the lead-in path actually used by emission: an outside + // square corner is where ResolveLeadIn extends the outgoing edge (convex), and + // the notch is where the internal-style lead bisects (reflex). The wrapper must + // agree with what EmitContour/ResolveLeadIn already do, not invent a third rule. + var square = CcwSquare(); + var corner = new Vector(10, 0); + var outgoing = square.Entities.Single(e => StartOf(e) == corner); + Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner( + square, corner, outgoing, out var kind)); + Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, kind.Kind); + + // Tangents are travel directions of the two joined edges. + Assert.Equal(1.0, kind.TangentIn.X, 9); + Assert.Equal(0.0, kind.TangentIn.Y, 9); + Assert.Equal(0.0, kind.TangentOut.X, 9); + Assert.Equal(1.0, kind.TangentOut.Y, 9); + } + + // --- helpers ---------------------------------------------------------------- + + private static Shape CwShaped(Shape shape) + { + shape.Reverse(); + return shape; + } + + private static ContourCuttingStrategy.CornerKind? ClassifyAtVertex(Shape shape, Vector vertex) + { + var (entity, point) = NearestVertex(shape, vertex); + return ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, entity, out var found) + ? found.Kind + : null; + } + + private static (Entity Entity, Vector Point) NearestVertex(Shape shape, Vector approximate) + { + Entity? best = null; + var bestPoint = Vector.Zero; + var bestDistance = double.MaxValue; + foreach (var entity in shape.Entities) + { + foreach (var point in new[] { StartOf(entity), EndOf(entity) }) + { + var d = point.DistanceTo(approximate); + if (d < bestDistance) + { + bestDistance = d; + best = entity; + bestPoint = point; + } + } + } + Assert.NotNull(best); + return (best!, bestPoint); + } + + private static Vector StartOf(Entity entity) => entity switch + { + Line line => line.StartPoint, + Arc arc => arc.StartPoint(), + _ => throw new NotSupportedException(), + }; + + private static Vector EndOf(Entity entity) => entity switch + { + Line line => line.EndPoint, + Arc arc => arc.EndPoint(), + _ => throw new NotSupportedException(), + }; +}