diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
new file mode 100644
index 0000000..9bb63cd
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
@@ -0,0 +1,35 @@
+using System;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// Automatic start preference, ordered: a lower value is more preferred.
+internal enum AutomaticEntryKind
+{
+ /// A convex turn of the contour's own travel, classified exactly as emission classifies it.
+ ConvexCorner = 0,
+
+ /// The midpoint of a straight edge.
+ StraightMidpoint = 1,
+
+ /// A tangent line/arc joint. A collinear line/line split is not a joint and never appears.
+ TangentJoint = 2,
+}
+
+///
+/// One automatic start candidate: the owned contour choice plus its preference kind and a
+/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
+/// owner; nothing here exposes or mutates the underlying shape.
+///
+internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
+{
+ ///
+ /// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
+ /// equal points rank together regardless of the entity that produced them.
+ ///
+ internal (long X, long Y) GeometryKey => (Quantize(Choice.Point.X), Quantize(Choice.Point.Y));
+
+ private static long Quantize(double value) =>
+ (long)System.Math.Round(value / PostVerificationGeometry.Epsilon);
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 377ba77..47f6d27 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -156,6 +156,93 @@ public sealed class PreparedContours
return choice;
}
+ ///
+ /// The uncapped preferred automatic start catalogue for one contour, in preference then
+ /// contour-travel order: convex corners of the contour's own winding, then straight-edge
+ /// midpoints, then tangent line/arc joints. Reflex and cusp vertices, collinear
+ /// line/line splits, circles and interior points never appear; each geometric point is
+ /// reported once, keeping the most preferred kind. A pure-arc contour can have no
+ /// preferred point at all — the S04 fallback catalogue supplies those. Manual entry
+ /// through / is unaffected.
+ ///
+ internal IReadOnlyList AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ var shape = GetShape(contourOrdinal);
+ if (shape.Entities.Count == 1 && shape.Entities[0] is Circle)
+ throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
+ if (shape.Entities.Count < 2)
+ throw new ArgumentException("Contour has no vertex to classify.");
+
+ // Collect EVERY eligible candidate first, then deduplicate geometrically; at equal
+ // points the most preferred kind wins regardless of which entity supplied it.
+ var found = new List();
+ var count = shape.Entities.Count;
+ for (var i = 0; i < count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ var entity = shape.Entities[i];
+
+ // Vertex reached by travelling along entity i (its end point), reported under
+ // entity i. The closed contour guarantees every vertex appears exactly once this
+ // way; each is classified from the contour's own winding.
+ var vertex = End(entity);
+ if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner))
+ {
+ switch (corner.Kind)
+ {
+ case ContourCuttingStrategy.CornerKind.Convex:
+ Add(i, vertex, AutomaticEntryKind.ConvexCorner);
+ break;
+ case ContourCuttingStrategy.CornerKind.Smooth
+ when entity is Line && Next(i) is Arc:
+ // A line leaving into an arc: the tangent joint. The reverse travel
+ // order classifies the same joint from the arc, matched below.
+ Add(i, vertex, AutomaticEntryKind.TangentJoint);
+ break;
+ // Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
+ // of a plain straight edge are not preferred automatic starts here.
+ case ContourCuttingStrategy.CornerKind.Smooth
+ when entity is Arc && Next(i) is Line:
+ Add(i, vertex, AutomaticEntryKind.TangentJoint);
+ break;
+ }
+ }
+
+ if (entity is Line line)
+ Add(i, line.MidPoint, AutomaticEntryKind.StraightMidpoint);
+ }
+ token.ThrowIfCancellationRequested();
+
+ // Deduplicate: at equal points the most preferred metadata wins, independent of
+ // which entity supplied it; the winner's own ordinal and point are kept.
+ var byPoint = new Dictionary<(long, long), ContourEntryCandidate>();
+ var order = new List<(long, long)>();
+ foreach (var candidate in found)
+ {
+ var key = candidate.GeometryKey;
+ if (!byPoint.TryGetValue(key, out var existing))
+ {
+ byPoint[key] = candidate;
+ order.Add(key);
+ }
+ else if (candidate.Kind < existing.Kind)
+ byPoint[key] = candidate;
+ }
+ return order
+ .OrderBy(key => byPoint[key].Kind)
+ .ThenBy(key => byPoint[key].Choice.EntityOrdinal)
+ .Select(key => byPoint[key])
+ .ToList();
+
+ Entity Next(int index) => shape.Entities[(index + 1) % count];
+
+ void Add(int entityOrdinal, Vector point, AutomaticEntryKind kind)
+ {
+ found.Add(new ContourEntryCandidate(Entry(contourOrdinal, entityOrdinal, point), kind));
+ }
+ }
+
/// Emits every contour once in caller order, holes before perimeter, with scribes once.
public Program Emit(IReadOnlyList choices)
{
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs
new file mode 100644
index 0000000..797ed38
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs
@@ -0,0 +1,235 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// The uncapped preferred automatic start catalogue: convex corners first, then straight
+/// midpoints, then line/arc tangent joints — from the contour's own winding, geometrically
+/// deduplicated, never reflex/cusp vertices or collinear splits, and owned immutably.
+///
+public class ContourEntryCandidateTests
+{
+ private static PreparedContours Capture(Program program) =>
+ PreparedContours.Capture(program, ExplicitContourTests.Parameters());
+
+ // --- fixtures ---------------------------------------------------------------
+
+ private static Program ClosedContour(IEnumerable vertices)
+ {
+ var p = new Program();
+ p.MoveTo(vertices.First().X, vertices.First().Y);
+ foreach (var v in vertices.Skip(1).Append(vertices.First()))
+ p.LineTo(v.X, v.Y);
+ return p;
+ }
+
+ /// Square perimeter with an L-notch hole slug; its concave vertex (3,3.5) is reflex for the slug's own travel.
+ private static Program NotchedHole()
+ {
+ var p = ExplicitContourTests.Square(false); // CCW outer square 10x10
+ p.MoveTo(2, 2);
+ p.LineTo(4, 2); p.LineTo(4, 3.5); p.LineTo(3, 3.5); p.LineTo(3, 4); p.LineTo(2, 4);
+ p.LineTo(2, 2); // closing leg
+ return p;
+ }
+
+ // --- positive catalogue -------------------------------------------------------
+
+ [Fact]
+ public void Square_ExposesFourCornersThenFourMidpoints()
+ {
+ var prepared = Capture(ExplicitContourTests.Square(false));
+
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+
+ Assert.Equal(8, candidates.Count);
+ Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner));
+ Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint));
+ // Preference order: every corner precedes every midpoint.
+ var kinds = candidates.Select(c => c.Kind).ToList();
+ Assert.Equal(kinds.OrderBy(k => k).ToList(), kinds);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void ReversedWinding_SameCornersAndMidpoints(bool reversed)
+ {
+ static List<(AutomaticEntryKind Kind, double X, double Y)> Catalogue(Program program) =>
+ Capture(program).AutomaticEntryCandidates(0)
+ .Select(c => (Kind: c.Kind, X: System.Math.Round(c.Choice.Point.X, 6), Y: System.Math.Round(c.Choice.Point.Y, 6)))
+ .OrderBy(x => x.Kind).ThenBy(x => x.X).ThenBy(x => x.Y).ToList();
+
+ var forward = Catalogue(ExplicitContourTests.Square(false));
+ Assert.Equal(forward, Catalogue(ExplicitContourTests.Square(reversed)));
+ Assert.Equal(8, forward.Count);
+ }
+
+ [Fact]
+ public void TangentLineArcJoint_IsTangentJoint_NotACorner()
+ {
+ // A half-circle bump on the top edge of a square: joints at (0,10) and (10,10).
+ var p = new Program();
+ p.MoveTo(0, 0);
+ p.LineTo(10, 0);
+ p.LineTo(10, 10);
+ p.ArcTo(new Vector(0, 10), new Vector(5, 10), RotationType.CCW);
+ p.LineTo(0, 0);
+ var prepared = Capture(p);
+
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+ var joints = candidates.Where(c => c.Kind == AutomaticEntryKind.TangentJoint).ToList();
+
+ Assert.Equal(2, joints.Count);
+ Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(10, 10)) < 1e-6);
+ Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(0, 10)) < 1e-6);
+ // The joint point is never also reported as a convex corner.
+ Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.ConvexCorner
+ && Distance(c.Choice.Point, new Vector(10, 10)) < 1e-6);
+ }
+
+ [Fact]
+ public void CollinearSplit_IsNotATangentJoint_ButBothMidpointsRemain()
+ {
+ // Bottom edge split into two collinear lines at (5,0).
+ var p = ClosedContour(new[]
+ {
+ new Vector(0, 0), new Vector(5, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10),
+ });
+ var prepared = Capture(p);
+
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+
+ Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TangentJoint);
+ Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner)); // (10,0),(10,10),(0,10),(0,0)
+ Assert.Contains(candidates, c => Distance(c.Choice.Point, new Vector(2.5, 0)) < 1e-6);
+ Assert.Contains(candidates, c => Distance(c.Choice.Point, new Vector(7.5, 0)) < 1e-6);
+ }
+
+ // --- exclusions ---------------------------------------------------------------
+
+ [Fact]
+ public void HoleSlugClassifiesCornersFromItsOwnWinding_NotInverted()
+ {
+ var prepared = Capture(NotchedHole());
+ var hole = 0; // Capture lists holes first; the outer square is the perimeter.
+ Assert.NotEqual(prepared.PerimeterOrdinal, hole);
+
+ var candidates = prepared.AutomaticEntryCandidates(hole);
+
+ // The slug's five convex corners come from its own travel; the concave vertex (3,3.5)
+ // is reflex for the slug and must never be enumerated for automatic placement.
+ Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(3, 3.5)) < 1e-6);
+ var corners = candidates.Where(c => c.Kind == AutomaticEntryKind.ConvexCorner).ToList();
+ Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(2, 2)) < 1e-6);
+ Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(4, 2)) < 1e-6);
+ Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(2, 4)) < 1e-6);
+ Assert.Equal(5, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner));
+ Assert.Equal(6, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint));
+ }
+
+ [Fact]
+ public void ReflexVertexOfOuterOutline_IsNeverEnumerated()
+ {
+ // L-shaped outline: (5,5) is reflex for the part's own CCW travel.
+ var p = ClosedContour(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),
+ });
+ var prepared = Capture(p);
+
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+
+ Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(5, 5)) < 1e-6);
+ Assert.Equal(5, candidates.Count(c => c.Kind == AutomaticEntryKind.ConvexCorner));
+ }
+
+ [Fact]
+ public void Circle_HasNoPreferredCatalogue()
+ {
+ var p = new Program();
+ p.MoveTo(5, 3);
+ p.ArcTo(5, 3, 3, 3, RotationType.CCW);
+ var prepared = Capture(p);
+
+ Assert.Throws(() => prepared.AutomaticEntryCandidates(0));
+ }
+
+ // --- ownership / stability ------------------------------------------------------
+
+ [Fact]
+ public void ManualEntry_StillAcceptsAReflexPoint()
+ {
+ // The manual path is unchanged: an explicit reflex vertex is a valid choice.
+ var prepared = Capture(ClosedContour(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),
+ }));
+ var reflex = new Vector(5, 5);
+ var choice = prepared.ClosestEntry(prepared.PerimeterOrdinal, reflex + new Vector(0.0001, 0.0001));
+
+ // ClosestEntry lands exactly on the reflex vertex and Entry validates it.
+ var manual = prepared.Entry(prepared.PerimeterOrdinal, choice.EntityOrdinal, reflex);
+ Assert.Equal(reflex.X, manual.Point.X, 9);
+ Assert.Equal(reflex.Y, manual.Point.Y, 9);
+ }
+
+ [Fact]
+ public void MutatingSourceProgramAfterCapture_CannotChangeTheCatalogue()
+ {
+ var source = ExplicitContourTests.Square(false);
+ var prepared = Capture(source);
+ var before = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal)
+ .Select(c => (c.Kind, c.Choice.EntityOrdinal, c.Choice.Point.X, c.Choice.Point.Y)).ToList();
+
+ source.Codes.Clear();
+
+ var after = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal)
+ .Select(c => (c.Kind, c.Choice.EntityOrdinal, c.Choice.Point.X, c.Choice.Point.Y)).ToList();
+ Assert.Equal(before, after);
+ }
+
+ [Fact]
+ public void CancelledToken_Throws()
+ {
+ var prepared = Capture(ExplicitContourTests.Square(false));
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+
+ Assert.Throws(() =>
+ prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal, cancelled.Token));
+ }
+
+ [Fact]
+ public void CatalogueCandidates_BelongToThisPreparation_AndEmit()
+ {
+ var prepared = Capture(ExplicitContourTests.Square(false));
+ var corner = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal)
+ .First(c => c.Kind == AutomaticEntryKind.ConvexCorner);
+
+ // The owned choice is accepted by this preparation's emit path...
+ var program = prepared.Emit(new[] { corner.Choice });
+ Assert.NotEmpty(program.Codes);
+
+ // ...and a foreign preparation rejects it.
+ var other = Capture(ExplicitContourTests.Square(false));
+ Assert.Throws(() => other.Emit(new[] { corner.Choice }));
+ }
+
+ [Fact]
+ public void GeometryKey_DeduplicatesTheSamePointAcrossEntities()
+ {
+ // Every convex corner is discovered from the entity ending at it; no two candidates
+ // share a geometric point.
+ var prepared = Capture(ExplicitContourTests.Square(false));
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+
+ Assert.Equal(candidates.Count, candidates.Select(c => c.GeometryKey).Distinct().Count());
+ }
+
+ private static double Distance(Vector a, Vector b) => a.DistanceTo(b);
+}