mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 03:14:14 -04:00
refactor(cutting): share corner classification with entry planning
Expose TryClassifyAutomaticStartCorner: an internal read-only query over the emitter's existing TryGetCorner/ClassifyCorner with the same winding derivation EmitContour uses, so start-point planning can prefer convex corners without copying tangent math or touching lead generation. Corner- kind characterization covers convex, reflex, tangent-smooth and cusp vertices from either adjacent edge, both windings, under rotation, and rejects midpoints and open contours.
This commit is contained in:
1 parent
b8f319d3e8
commit
e75f68dbb6
2 files changed
+303
-1
No files matched your search
@@ -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,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="EmitContour"/> derives it, so the kind matches actual emission.
|
||||
/// </summary>
|
||||
internal readonly record struct AutomaticCorner(CornerKind Kind, Vector TangentIn, Vector TangentOut);
|
||||
|
||||
/// <summary>
|
||||
/// Classification query shared with entry planning: the turn at <paramref name="point"/>
|
||||
/// on a closed line/arc contour, using the same corner geometry and winding derivation
|
||||
/// as emission. False when <paramref name="point"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
|
||||
private readonly record struct ContourCorner(
|
||||
Entity Incoming,
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingStrategy;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>CCW square; the shape's own winding derivation says so.</summary>
|
||||
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),
|
||||
});
|
||||
|
||||
/// <summary>Same square traversed CW.</summary>
|
||||
private static Shape CwSquare(double size = 10)
|
||||
{
|
||||
var s = CcwSquare(size);
|
||||
s.Reverse();
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>CCW L-shape: the concave vertex (5,5) turns right — reflex.</summary>
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>CCW square whose top side is a half-circle bump; the tangent joints are at (0,10) and (10,10).</summary>
|
||||
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<object[]> 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<Line>(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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user