diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
new file mode 100644
index 0000000..fa4073a
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
@@ -0,0 +1,53 @@
+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,
+
+ ///
+ /// Tier 3 (fallback): a point on a straight edge meeting a convex corner, back from the
+ /// corner by about twice the applicable lead-in length, strictly inside the edge.
+ ///
+ NearCorner = 3,
+
+ ///
+ /// Tier 3 (fallback): the exact native closest point facing the caller's look-ahead
+ /// position (pass the arrival there when there is no next cut). Never a reflex/cusp vertex.
+ ///
+ TargetFacing = 4,
+
+ /// Tier 3 (fallback): the native midpoint of an arc entity.
+ ArcMidpoint = 5,
+
+ /// Tier 3 (fallback): one of the eight compass points of a whole circle.
+ CircleCompass = 6,
+}
+
+///
+/// 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/ContourEntryRanking.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs
new file mode 100644
index 0000000..56e7a28
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+///
+/// Pure deterministic ordering of the automatic entry catalogue toward the next cut:
+/// circles rank by outgoing distance; other contours use facing sides, tier and travel,
+/// then a stable geometric key. It adds no
+/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility
+/// filtering and the bounded selection belong to S07/S08, the wiring to S09.
+///
+internal static class ContourEntryRanking
+{
+ ///
+ /// Orders for one contour in local coordinates. With a
+ /// (the next cut's look-ahead point): the number of matched
+ /// facing sides of the candidate bounding rectangle descending (a corner on both facing
+ /// sides is ideal), then rank tier ascending, then arrival->entry + entry->target
+ /// ascending, then the stable geometric key. With no target (the last part): tier first,
+ /// then distance to — never toward the plate origin. The
+ /// input list is returned untouched as a new list; entity order is never meaningful.
+ ///
+ internal static IReadOnlyList RankTowardNextCut(
+ this IReadOnlyList candidates,
+ Vector? target = null,
+ Vector? arrival = null)
+ {
+ if (target.HasValue) PostVerificationGeometry.Validate(target.Value);
+ if (arrival.HasValue) PostVerificationGeometry.Validate(arrival.Value);
+ if (candidates.Count == 0)
+ return new List();
+
+ // Whole circles have no corners. A diagonal compass point is equally near two
+ // bounding-box sides, but must not gain the two-side bonus of a real corner.
+ // Rank outgoing travel first so arrival cannot pull the start away from the next
+ // cut. Retain every compass/polar alternative for feasibility and rapid checks;
+ // configured angle rounding remains the emitter's responsibility.
+ if (target is { } next && candidates.Any(c => c.Kind == AutomaticEntryKind.CircleCompass)
+ && candidates.All(c => c.Kind is AutomaticEntryKind.CircleCompass or AutomaticEntryKind.TargetFacing))
+ return candidates.OrderBy(c => c.Choice.Point.DistanceTo(next))
+ .ThenBy(c => arrival is { } from ? c.Choice.Point.DistanceTo(from) : 0.0)
+ .ThenBy(c => c.GeometryKey.X)
+ .ThenBy(c => c.GeometryKey.Y)
+ .ToList();
+
+ // Candidate bounding rectangle in the contour's local coordinates; every candidate
+ // lies on the contour, so distances to the four side lines order side proximity.
+ var minX = double.PositiveInfinity;
+ var minY = double.PositiveInfinity;
+ var maxX = double.NegativeInfinity;
+ var maxY = double.NegativeInfinity;
+ foreach (var candidate in candidates)
+ {
+ var p = candidate.Choice.Point;
+ if (p.X < minX) minX = p.X;
+ if (p.X > maxX) maxX = p.X;
+ if (p.Y < minY) minY = p.Y;
+ if (p.Y > maxY) maxY = p.Y;
+ }
+
+ // Facing sides from the target relative to the centre, matching the source plan:
+ // horizontal right when the target is right of centre else left; vertical likewise.
+ var centreX = minX + (maxX - minX) * 0.5;
+ var centreY = minY + (maxY - minY) * 0.5;
+ var facingRight = target != null && target.Value.X > centreX;
+ var facingTop = target != null && target.Value.Y > centreY;
+
+ return candidates
+ .Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival)))
+ .OrderByDescending(x => x.Score.Facing)
+ .ThenBy(x => x.Score.Tier)
+ .ThenBy(x => x.Score.Travel)
+ .ThenBy(x => x.Candidate.GeometryKey.X)
+ .ThenBy(x => x.Candidate.GeometryKey.Y)
+ .Select(x => x.Candidate)
+ .ToList();
+ }
+
+ ///
+ /// The ranking tier — coarser than the preference kind: outside corners first, then
+ /// straight midpoints and tangent joints as peers, then every fallback kind.
+ ///
+ internal static int RankTier(this AutomaticEntryKind kind) => kind switch
+ {
+ AutomaticEntryKind.ConvexCorner => 0,
+ AutomaticEntryKind.StraightMidpoint or AutomaticEntryKind.TangentJoint => 1,
+ _ => 2,
+ };
+
+ private static (int Facing, int Tier, double Travel) Score(
+ ContourEntryCandidate candidate,
+ double minX,
+ double minY,
+ double maxX,
+ double maxY,
+ bool facingRight,
+ bool facingTop,
+ Vector? target,
+ Vector? arrival)
+ {
+ var p = candidate.Choice.Point;
+ var facing = 0;
+ if (target != null)
+ {
+ // Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
+ // sides within tolerance); count how many of them are facing sides.
+ var left = p.X - minX;
+ var right = maxX - p.X;
+ var bottom = p.Y - minY;
+ var top = maxY - p.Y;
+ var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
+ if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
+ if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
+ if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
+ if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
+ }
+ var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ + (target != null ? p.DistanceTo(target.Value) : 0.0);
+ return (facing, candidate.Kind.RankTier(), travel);
+ }
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
index c354417..7ce0854 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
@@ -90,7 +90,9 @@ public static class LeadPathValidator
if (ReferenceEquals(material, target))
continue;
if (CheckMaterial(material, null) != null)
- return new(true, false, "Lead contacts or enters another placed material.");
+ return new(true, false, "Lead contacts or enters another placed material. "
+ + "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. "
+ + "For locked parts, edit the leads or unlock the part before replanning.");
}
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint)
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 377ba77..ef1e206 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -156,6 +156,268 @@ 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 — supplies
+ /// those. Manual entry through / is
+ /// unaffected.
+ ///
+ internal IReadOnlyList AutomaticEntryCandidates(int contourOrdinal, CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ var shape = GetShape(contourOrdinal);
+ if (IsSingleCircle(shape))
+ throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
+ return MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
+ }
+
+ ///
+ /// The complete uncapped automatic start catalogue: the preferred points of
+ /// followed by tier-3 fallbacks — native arc
+ /// midpoints, near-convex-corner points on straight edges, the eight compass points of a
+ /// whole circle, and the exact target-facing closest point toward
+ /// (pass the arrival point there when there is no next cut).
+ /// A pure-circle contour therefore yields compass points instead of refusing. Every
+ /// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
+ /// preferred tier; fallbacks never replace a preferred point at the same geometry.
+ ///
+ internal IReadOnlyList AutomaticEntryCandidatesWithFallbacks(
+ int contourOrdinal,
+ Vector? lookAhead = null,
+ CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ var shape = GetShape(contourOrdinal);
+ var all = IsSingleCircle(shape)
+ ? new List()
+ : PreferredCandidates(shape, contourOrdinal, token);
+ all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
+ return MergeByGeometry(all);
+ }
+
+ private static bool IsSingleCircle(Shape shape) =>
+ shape.Entities.Count == 1 && shape.Entities[0] is Circle;
+
+ /// Preferred tier: convex corners, straight midpoints, tangent joints.
+ private List PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
+ => CataloguePoints(shape, token)
+ .Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
+ .ToList();
+
+ ///
+ /// Tier-3 fallbacks for one contour, each already run through the reflex/cusp exclusion:
+ /// native arc midpoints, the eight compass points of whole circles, near-convex-corner
+ /// insets on straight edges, and the exact target-facing closest point toward
+ /// . No ranking and no lead-safety verdict here.
+ ///
+ private List FallbackCandidates(
+ Shape shape,
+ int contourOrdinal,
+ Vector? lookAhead,
+ CancellationToken token)
+ {
+ var fallbacks = new List();
+ var lead = ApplicableLeadInLength(contourOrdinal);
+ var count = shape.Entities.Count;
+
+ for (var i = 0; i < count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ switch (shape.Entities[i])
+ {
+ case Arc arc:
+ // The native midpoint of an arc (exact native API, never tessellation).
+ fallbacks.Add(Fallback(i, arc.MidPoint(), AutomaticEntryKind.ArcMidpoint));
+ break;
+ case Circle circle:
+ // The legacy eight compass points, same native construction.
+ for (var angle = 0; angle < 8; angle++)
+ fallbacks.Add(Fallback(i, circle.Center + new Vector(
+ System.Math.Cos(angle * System.Math.PI / 4),
+ System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius,
+ AutomaticEntryKind.CircleCompass));
+ break;
+ }
+ }
+
+ // Near-convex-corner fallbacks: about twice the applicable lead-in length back from
+ // each convex corner along each incident STRAIGHT edge, only when strictly inside
+ // that edge. Short edges simply omit the point; it never extrapolates past an edge
+ // endpoint and so never lands on the reflex/cusp vertex at the far end.
+ if (lead > 0 && !(count == 1 && shape.Entities[0] is Circle))
+ {
+ foreach (var corner in CataloguePoints(shape, token)
+ .Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
+ {
+ token.ThrowIfCancellationRequested();
+ var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
+ // Back INTO each incident edge from the corner: the edge ending at the
+ // corner retreats against its own travel, the edge starting at the corner
+ // advances along its own travel.
+ AddInset(corner.EntityOrdinal, cornerPoint, inward: false);
+ AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true);
+ }
+ }
+
+ if (lookAhead != null)
+ {
+ token.ThrowIfCancellationRequested();
+ PostVerificationGeometry.Validate(lookAhead.Value);
+ var facing = ClosestEntry(contourOrdinal, lookAhead.Value);
+ // A raw closest point may land exactly on a reflex/cusp vertex; automatic
+ // selection must never sneak a forbidden inside corner back in, so drop it.
+ if (!IsForbiddenVertex(shape, facing.Point))
+ fallbacks.Add(Fallback(facing.EntityOrdinal, facing.Point, AutomaticEntryKind.TargetFacing));
+ }
+ token.ThrowIfCancellationRequested();
+ return fallbacks;
+
+ void AddInset(int entityOrdinal, Vector corner, bool inward)
+ {
+ // `inward` selects along the edge's own travel from its start; without it the
+ // point retreats against travel. Both ways move BACK INTO the edge from the
+ // corner, which sits at the edge's end (inward=false) or start (inward=true).
+ if (shape.Entities[entityOrdinal] is not Line line)
+ return;
+ var direction = (line.EndPoint - line.StartPoint).Normalize();
+ var offset = direction * (2 * lead);
+ var point = inward ? corner + offset : corner - offset;
+ // Strictly inside the edge by projection parameter (distance alone loses the
+ // sign when a short edge is overshoot): never the corner, never the far
+ // endpoint (where a reflex vertex might sit). Short edges omit the point.
+ var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
+ if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
+ return;
+ fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
+ }
+
+ ContourEntryCandidate Fallback(int entityOrdinal, Vector point, AutomaticEntryKind kind)
+ => new(Entry(contourOrdinal, entityOrdinal, point), kind);
+ }
+
+ ///
+ /// At equal geometric points the most preferred kind wins, independent of which entity
+ /// supplied it; the winner keeps its own ordinal and point. Result order: preference,
+ /// then entity ordinal.
+ ///
+ private static IReadOnlyList MergeByGeometry(List candidates)
+ {
+ var byPoint = new Dictionary<(long, long), ContourEntryCandidate>();
+ var order = new List<(long, long)>();
+ foreach (var candidate in candidates)
+ {
+ 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();
+ }
+
+ ///
+ /// True when sits on a vertex of the contour that is reflex or
+ /// cusp from either travel direction — a point automatic selection must never emit.
+ ///
+ private bool IsForbiddenVertex(Shape shape, Vector point)
+ {
+ for (var i = 0; i < shape.Entities.Count; i++)
+ {
+ if (shape.Entities[i] is Circle)
+ continue; // A whole circle has no vertex.
+ var vertex = End(shape.Entities[i]);
+ if (vertex.DistanceTo(point) > PostVerificationGeometry.Epsilon)
+ continue;
+ if (ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, shape.Entities[i], out var corner)
+ && corner.Kind is ContourCuttingStrategy.CornerKind.Reflex or ContourCuttingStrategy.CornerKind.Cusp)
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// The applicable lead-in length for a contour following the emitter's own selection:
+ /// the perimeter (last shape) is external, anything else internal; non-length lead-in
+ /// styles contribute 0, which omits the near-corner fallback instead of approximating.
+ ///
+ private double ApplicableLeadInLength(int contourOrdinal)
+ => (contourOrdinal == PerimeterOrdinal
+ ? parameters.ExternalLeadIn
+ : parameters.InternalLeadIn) switch
+ {
+ LineLeadIn line => line.Length,
+ LineLineLeadIn lineLine => lineLine.Length1,
+ _ => 0,
+ };
+
+ ///
+ /// The preferred catalogue points for one contour: for each entity, its convex corner
+ /// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
+ /// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
+ /// nothing; a single whole circle yields no points at all.
+ ///
+ private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
+ {
+ var found = new List<(int, Vector, AutomaticEntryKind)>();
+ var count = shape.Entities.Count;
+ if (count == 1 && shape.Entities[0] is Circle)
+ {
+ // A whole circle has no corners or joints; only the fallback tier applies.
+ return found;
+ }
+
+ if (count < 2)
+ throw new ArgumentException("Contour has no vertex to classify.");
+ 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:
+ found.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.
+ found.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:
+ found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
+ break;
+ }
+ }
+
+ if (entity is Line line)
+ found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
+ }
+ return found;
+
+ Entity Next(int index) => shape.Entities[(index + 1) % count];
+ }
+
/// Emits every contour once in caller order, holes before perimeter, with scribes once.
public Program Emit(IReadOnlyList choices)
{
@@ -163,6 +425,25 @@ public sealed class PreparedContours
return EmitPrefix(choices);
}
+ ///
+ /// Diagnostic seam: emit exactly ONE owned contour — with its normal lead-in and
+ /// lead-out and its ORIGINAL contour type (the perimeter keeps External even when no
+ /// holes precede it) — so a candidate's emitted leads can be validated before the hole
+ /// choices exist. The result is a throwaway probe, not a plan: it must never be
+ /// installed on a Part or accepted as complete output. Normal and
+ /// keep the perimeter-last rule untouched.
+ ///
+ internal Program EmitCandidateForValidation(ContourChoice choice)
+ {
+ if (choice == null || !ReferenceEquals(choice.Owner, this))
+ throw new ArgumentException("Foreign contour choice.");
+ ValidateChoice(choice);
+ // Same owned clones as a real emission; the source shapes/settings are never used
+ // directly, so the probe cannot drift the preparation or mutate it.
+ return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
+ shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice);
+ }
+
// Each prefix is a standalone owned program, including the same scribes once.
internal Program EmitPrefix(IReadOnlyList choices)
{
@@ -212,6 +493,23 @@ public sealed class PreparedContours
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
+ ///
+ /// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
+ /// only — the S10 CuttingHoleOrder proxy. The perimeter has no entry; it is the
+ /// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
+ ///
+ internal IReadOnlyList HoleCentres(CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ var centres = new Vector?[Count];
+ for (var contour = 0; contour < PerimeterOrdinal; contour++)
+ {
+ token.ThrowIfCancellationRequested();
+ centres[contour] = GetShape(contour).BoundingBox.Center;
+ }
+ return centres;
+ }
+
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
index 1bbd239..450906b 100644
--- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
+++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
@@ -239,6 +239,32 @@ namespace OpenNest.CNC.CuttingStrategy
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
+ EmitChosenContours(result, shapes, choices);
+ result.Mode = Mode.Incremental;
+ return result;
+ }
+
+ // Diagnostic seam for candidate lead validation (PreparedContours
+ // .EmitCandidateForValidation): emit a single already-validated choice with its
+ // ORIGINAL contour type — the last shape is External even when emitted alone, so a
+ // perimeter never degrades to a hole because it was isolated. Output must not be
+ // installed on a Part or accepted as a complete plan.
+ internal Program EmitCandidateIsolated(Shape[] shapes, List scribes,
+ CuttingPlanning.ContourChoice choice)
+ {
+ var result = new Program(Mode.Absolute);
+ EmitScribeContours(result, scribes);
+ EmitChosenContours(result, shapes, new[] { choice });
+ result.Mode = Mode.Incremental;
+ return result;
+ }
+
+ // One contour per choice in order; the perimeter (last shape) is always External.
+ // Contour emission itself reads only the contour's own geometry and settings, never
+ // prior choices — the differential tests in ContourCandidateEmissionTests pin this.
+ private void EmitChosenContours(Program result, Shape[] shapes,
+ IReadOnlyList choices)
+ {
foreach (var choice in choices)
{
var shape = shapes[choice.ContourOrdinal];
@@ -246,8 +272,6 @@ namespace OpenNest.CNC.CuttingStrategy
choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
exactCirclePrograms: true);
}
- result.Mode = Mode.Incremental;
- return result;
}
private void EmitRawContour(Program program, Shape shape)
@@ -636,7 +660,7 @@ namespace OpenNest.CNC.CuttingStrategy
}
}
- private enum CornerKind
+ internal enum CornerKind
{
Convex,
Reflex,
@@ -644,6 +668,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.Core/PlateDisplayNumbering.cs b/OpenNest.Core/PlateDisplayNumbering.cs
new file mode 100644
index 0000000..e6b761b
--- /dev/null
+++ b/OpenNest.Core/PlateDisplayNumbering.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+
+namespace OpenNest
+{
+ ///
+ /// Display-only plate numbering for labels. The editor keeps one trailing empty plate as the
+ /// "new plate" workspace (PlateManager.EnsureSentinel), and that sentinel is excluded from the
+ /// shown total so a one-plate nest reads "Plate 1 of 1". Navigation, storage indexes, exported
+ /// names and batch selection keep using the real collection positions; an interior empty plate
+ /// keeps its slot and its number.
+ ///
+ public static class PlateDisplayNumbering
+ {
+ ///
+ /// The number shown for the plate at : real collection
+ /// positions count as 1-based plate numbers. Returns null for the trailing "new plate"
+ /// sentinel and for indexes outside the collection.
+ ///
+ public static int? DisplayedPlateNumber(IList plates, int storageIndex)
+ {
+ if (plates == null || storageIndex < 0 || storageIndex >= plates.Count)
+ return null;
+ if (IsTrailingSentinel(plates, storageIndex))
+ return null;
+ return storageIndex + 1;
+ }
+
+ ///
+ /// The number shown in an "of M" total: the plates minus only a trailing empty sentinel.
+ /// An all-empty collection shows nothing (the caller says "No plates").
+ ///
+ public static int DisplayedPlateCount(IList plates)
+ {
+ if (plates == null || plates.Count == 0)
+ return 0;
+ var last = plates[plates.Count - 1];
+ return plates.Count - (last != null && last.Parts.Count == 0 ? 1 : 0);
+ }
+
+ /// True when is the trailing empty new-plate sentinel.
+ public static bool IsTrailingSentinel(IList plates, int storageIndex)
+ {
+ if (plates == null || plates.Count == 0)
+ return false;
+ if (storageIndex != plates.Count - 1)
+ return false;
+ var plate = plates[storageIndex];
+ return plate != null && plate.Parts.Count == 0;
+ }
+
+ /// Header text for the plate at .
+ public static string FormatHeader(IList plates, int storageIndex, string plateSizeText)
+ {
+ var displayed = DisplayedPlateNumber(plates, storageIndex);
+ if (displayed == null)
+ return IsTrailingSentinel(plates, storageIndex)
+ ? "New plate (empty)"
+ : "No plates";
+ return string.IsNullOrEmpty(plateSizeText)
+ ? string.Format("Plate {0} of {1}", displayed.Value, DisplayedPlateCount(plates))
+ : string.Format("Plate {0} of {1} | {2}", displayed.Value, DisplayedPlateCount(plates), plateSizeText);
+ }
+ }
+}
diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
new file mode 100644
index 0000000..87ee195
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
@@ -0,0 +1,122 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+/// The three verdicts a candidate entry can receive from the shared lead validator.
+public enum ContourFeasibilityStatus
+{
+ /// Every emitted lead of this contour is complete AND clear.
+ Clear,
+
+ /// A complete check found a lead contact/overlap — the reason is preserved.
+ Blocked,
+
+ /// The check could not complete (incomplete material, malformed emission) — never clear.
+ Incomplete,
+}
+
+/// Verdict for one candidate entry. Complete/blocked/incomplete stay distinct.
+public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status, string? Reason)
+{
+ public bool IsClear => Status == ContourFeasibilityStatus.Clear;
+}
+
+///
+/// Feasibility adapter over the EXISTING : emits one owned
+/// candidate contour through the S06 diagnostic seam, reads it at the placement position,
+/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a
+/// new collision implementation and not a plan approval: a Clear verdict certifies this
+/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the
+/// complete plan, and rapids/pierce clearance belong to their existing checkers. A
+/// candidate rejected here is not proven infeasible by anything else: this adapter only
+/// reports what the validator reported.
+/// One instance is one captured planning attempt: verdicts cache per exact choice and node
+/// context for the instance's lifetime — settings and placement are fixed per instance —
+/// and nothing is cached across instances or statically. Evaluation is lazy: only the
+/// choice handed to is ever emitted or validated, and
+/// counts validator executions (not cache hits) for cost tests.
+///
+public sealed class ContourEntryFeasibility
+{
+ private readonly PreparedContours prepared;
+ private readonly Vector location;
+ private readonly LeadMaterialSnapshot ownMaterial;
+ private readonly LeadMaterialSnapshot[] materials;
+ private readonly Dictionary cache = new();
+
+ public ContourEntryFeasibility(PreparedContours prepared, Vector location,
+ LeadMaterialSnapshot ownMaterial, IReadOnlyList otherMaterials)
+ {
+ this.prepared = prepared ?? throw new ArgumentException("Prepared contours are required.", nameof(prepared));
+ this.ownMaterial = ownMaterial ?? throw new ArgumentException("Own material snapshot is required.", nameof(ownMaterial));
+ if (otherMaterials == null)
+ throw new ArgumentException("Placed-material snapshots are required.", nameof(otherMaterials));
+ this.location = location;
+ // Immutable copy; the validator's own-material-first convention is preserved.
+ var all = new List { this.ownMaterial };
+ foreach (var material in otherMaterials)
+ {
+ if (material == null)
+ throw new ArgumentException("Placed-material snapshots must not be null.", nameof(otherMaterials));
+ if (!ReferenceEquals(material, this.ownMaterial))
+ all.Add(material);
+ }
+ materials = all.ToArray();
+ }
+
+ /// Validator executions performed (cache misses only) during this attempt.
+ public int EvaluationCount { get; private set; }
+
+ ///
+ /// The verdict for one owned candidate. Same choice + node context within this attempt
+ /// is answered from cache. Cancellation propagates; a malformed emission is an
+ /// Incomplete verdict with the emission's own reason, never a crash and never Clear.
+ ///
+ public ContourFeasibilityVerdict Check(ContourChoice choice, string nodeContext = "",
+ CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ if (choice == null)
+ throw new ArgumentException("A contour choice is required.", nameof(choice));
+ // Ownership is an input boundary, not part of a memoized geometric verdict.
+ // Reject before lookup so neither a warm hit nor a foreign first call can
+ // substitute for the isolated emitter's owned-choice check.
+ if (!ReferenceEquals(choice.Owner, prepared))
+ return new(ContourFeasibilityStatus.Incomplete, "Foreign contour choice.");
+ var key = new Key(choice.ContourOrdinal, choice.EntityOrdinal,
+ choice.Point.X, choice.Point.Y, nodeContext ?? string.Empty);
+ if (cache.TryGetValue(key, out var known))
+ return known;
+ var verdict = Probe(choice, token);
+ cache[key] = verdict;
+ EvaluationCount++;
+ return verdict;
+ }
+
+ private ContourFeasibilityVerdict Probe(ContourChoice choice, CancellationToken token)
+ {
+ try
+ {
+ var program = prepared.EmitCandidateForValidation(choice);
+ var execution = ExecutionMotionReader.Read(program, location, null, token);
+ var result = LeadPathValidator.Check(execution, ownMaterial, materials, token);
+ if (!result.IsComplete)
+ return new(ContourFeasibilityStatus.Incomplete, result.Reason);
+ return result.IsClear
+ ? new(ContourFeasibilityStatus.Clear, null)
+ : new(ContourFeasibilityStatus.Blocked, result.Reason);
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
+ {
+ // Malformed emission or foreign choice: refused, reason preserved, never cached as clear.
+ return new(ContourFeasibilityStatus.Incomplete, ex.Message);
+ }
+ }
+
+ private readonly record struct Key(int ContourOrdinal, int EntityOrdinal, double X, double Y, string NodeContext);
+}
diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs
new file mode 100644
index 0000000..f070bda
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs
@@ -0,0 +1,266 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+/// Why a selection ended the way it did. Honest metadata, never a geometric overclaim.
+internal enum ContourSelectionShortfall
+{
+ /// Selection ended at the cap with the side scan satisfied: a complete verdict.
+ None,
+
+ ///
+ /// The finite catalogue was fully evaluated and the selection is all there is. With an
+ /// empty selection this is exactly the sentence "no tested lead-in fits on part N,
+ /// contour M" — and ONLY here may that sentence be spoken.
+ ///
+ Exhausted,
+
+ ///
+ /// At least one check could not complete (incomplete material or emission). Never
+ /// present this as geometric impossibility and never as "nothing fits".
+ ///
+ Incomplete,
+}
+
+///
+/// Outcome of one bounded selection: selected choices in global rank order, how many
+/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests),
+/// which evaluated uncertain (never refused — the caller's full check stays the authority
+/// on those), and why the selection stopped.
+///
+internal sealed record ContourSelectionResult(
+ IReadOnlyList Choices,
+ int EvaluatedCount,
+ IReadOnlyList<(long, long)> EvaluatedKeys,
+ IReadOnlyList UncertainChoices,
+ ContourSelectionShortfall Shortfall,
+ string? Reason);
+
+///
+/// Bounded, lazy selection of lead-feasible entry candidates for ONE contour. Greedy global
+/// rank order until the cap (default 16) fills or the finite catalogue ends — rejected and
+/// incomplete candidates never consume a slot. When the cap can afford it (4+), one
+/// corrective scan makes sure every side of the candidate bounding rectangle that HAS a
+/// feasible candidate is represented, replacing the worst selected candidate only when
+/// every other covered side survives; one corner may cover two sides. The cap is never
+/// exceeded and points are never manufactured on infeasible sides — a capped selection may
+/// truthfully omit a feasible side. Sides are exactly the ranker's side geometry (the
+/// candidate-set bounding rectangle). Verdicts are memoized per attempt (the adapter
+/// memoizes too): no candidate is ever evaluated twice, and once selection settles the
+/// untouched tail is never evaluated. No search/DFS changes and no larger search budget.
+///
+internal static class ContourEntrySelection
+{
+ internal const int DefaultMaxEntries = 16;
+
+ /// Below this cap all-side coverage is not promised; the cap and ranking bind first (caps 1-3).
+ internal const int SideCoverageMinCap = 4;
+
+ ///
+ /// Selects up to feasible choices from one contour's
+ /// globally ranked catalogue. is the S07 adapter verdict,
+ /// called lazily at most once per distinct candidate.
+ ///
+ internal static ContourSelectionResult Select(
+ IReadOnlyList rankedCandidates,
+ Func evaluate,
+ int maxEntries = DefaultMaxEntries,
+ CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ if (rankedCandidates == null)
+ throw new ArgumentException("Ranked candidates are required.", nameof(rankedCandidates));
+ if (evaluate == null)
+ throw new ArgumentException("A feasibility evaluation is required.", nameof(evaluate));
+ if (maxEntries <= 0)
+ throw new ArgumentException("The cap must be positive.", nameof(maxEntries));
+
+ // Distinct points only — the cap must not double-count a geometric duplicate.
+ var candidates = new List();
+ var seen = new HashSet<(long, long)>();
+ foreach (var candidate in rankedCandidates)
+ if (seen.Add(candidate.GeometryKey))
+ candidates.Add(candidate);
+
+ var evaluated = new List();
+ var verdicts = new Dictionary<(long, long), ContourFeasibilityVerdict>();
+ var box = Box(candidates);
+
+ var selected = new List();
+ var uncertain = new List();
+ var sawIncomplete = false;
+ string? incompleteReason = null;
+ var index = 0;
+
+ ContourFeasibilityVerdict Verdict(ContourEntryCandidate candidate)
+ {
+ token.ThrowIfCancellationRequested();
+ if (!verdicts.TryGetValue(candidate.GeometryKey, out var known))
+ {
+ known = evaluate(candidate);
+ verdicts[candidate.GeometryKey] = known;
+ evaluated.Add(candidate);
+ }
+ return known;
+ }
+
+ // Phase 1: greedy global order until the cap fills or the catalogue ends.
+ for (; index < candidates.Count && selected.Count < maxEntries; index++)
+ {
+ var verdict = Verdict(candidates[index]);
+ if (verdict.Status == ContourFeasibilityStatus.Incomplete)
+ {
+ // Uncertain is not refused: it never takes a selected slot, but the scan
+ // continues and the caller's full check stays the authority on it.
+ sawIncomplete = true;
+ incompleteReason ??= verdict.Reason;
+ uncertain.Add(candidates[index]);
+ continue;
+ }
+ if (verdict.IsClear)
+ selected.Add(candidates[index]);
+ }
+
+ // Phase 2: reconsider the globally ranked catalogue for each missing side, reusing
+ // verdicts passed while chasing earlier sides and evaluating the tail lazily.
+ // A clear candidate on that side is appended when
+ // a slot remains, otherwise it replaces the worst selected candidate whose removal
+ // keeps every other covered side covered.
+ var coverage = maxEntries >= SideCoverageMinCap && selected.Count > 0;
+ if (coverage)
+ for (var side = 0; side < 4 && !sawIncomplete; side++)
+ {
+ if (selected.Any(c => Sides(c, box).Contains(side)))
+ continue;
+ for (index = 0; index < candidates.Count; index++)
+ {
+ var verdict = Verdict(candidates[index]);
+ if (verdict.Status == ContourFeasibilityStatus.Incomplete)
+ {
+ sawIncomplete = true;
+ incompleteReason ??= verdict.Reason;
+ uncertain.Add(candidates[index]);
+ continue;
+ }
+ if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side))
+ continue;
+ TryPlace(selected, candidates[index], box, maxEntries);
+ // A reused candidate can outrank an earlier replacement. Keep the
+ // safe-victim scan in global rank order for the next missing side.
+ selected.Sort((a, b) => candidates.IndexOf(a).CompareTo(candidates.IndexOf(b)));
+ break;
+ }
+ }
+
+ var exhausted = index >= candidates.Count;
+ ContourSelectionShortfall shortfall;
+ string? reason;
+ if (sawIncomplete)
+ {
+ shortfall = ContourSelectionShortfall.Incomplete;
+ reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible."
+ + (incompleteReason == null ? "" : $" First reason: {incompleteReason}");
+ }
+ else if (selected.Count >= maxEntries)
+ {
+ shortfall = ContourSelectionShortfall.None;
+ reason = null;
+ }
+ else if (exhausted)
+ {
+ shortfall = ContourSelectionShortfall.Exhausted;
+ reason = selected.Count == 0
+ ? "No tested lead-in fits on this contour."
+ : null;
+ }
+ else
+ {
+ // Unreachable: phase 1 ends at cap or catalogue end and phase 2 chases every
+ // side to the end; a cancellation throws before this point.
+ shortfall = ContourSelectionShortfall.Incomplete;
+ reason = "Selection stopped before the catalogue ended.";
+ }
+
+ var ordered = selected
+ .OrderBy(c => candidates.IndexOf(c))
+ .Select(c => c.Choice)
+ .ToList();
+ return new(ordered, evaluated.Count,
+ evaluated.Select(c => c.GeometryKey).ToList(),
+ uncertain.Select(c => c.Choice).ToList(), shortfall, reason);
+ }
+
+ private static (double MinX, double MinY, double MaxX, double MaxY) Box(
+ List candidates)
+ {
+ double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
+ maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
+ foreach (var c in candidates)
+ {
+ var p = c.Choice.Point;
+ if (p.X < minX) minX = p.X;
+ if (p.X > maxX) maxX = p.X;
+ if (p.Y < minY) minY = p.Y;
+ if (p.Y > maxY) maxY = p.Y;
+ }
+ if (!double.IsFinite(minX))
+ return (0, 0, 0, 0);
+ return (minX, minY, maxX, maxY);
+ }
+
+ /// Sides 0 left, 1 right, 2 bottom, 3 top of the candidate bounding rectangle that the point sits on.
+ private static List Sides(ContourEntryCandidate candidate,
+ (double MinX, double MinY, double MaxX, double MaxY) box)
+ {
+ var p = candidate.Choice.Point;
+ var left = p.X - box.MinX;
+ var right = box.MaxX - p.X;
+ var bottom = p.Y - box.MinY;
+ var top = box.MaxY - p.Y;
+ var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
+ var sides = new List(2);
+ if (left <= min + PostVerificationGeometry.Epsilon) sides.Add(0);
+ if (right <= min + PostVerificationGeometry.Epsilon) sides.Add(1);
+ if (bottom <= min + PostVerificationGeometry.Epsilon) sides.Add(2);
+ if (top <= min + PostVerificationGeometry.Epsilon) sides.Add(3);
+ return sides;
+ }
+
+ /// Appends when a slot remains, else replaces the worst (latest-ranked) candidate whose removal preserves every other covered side.
+ private static void TryPlace(List selected, ContourEntryCandidate candidate,
+ (double MinX, double MinY, double MaxX, double MaxY) box, int maxEntries)
+ {
+ if (selected.Count < maxEntries)
+ {
+ selected.Add(candidate);
+ return;
+ }
+ var incoming = Sides(candidate, box);
+ for (var i = selected.Count - 1; i >= 0; i--)
+ {
+ var removalSafe = true;
+ for (var side = 0; side < 4 && removalSafe; side++)
+ {
+ if (incoming.Contains(side))
+ continue; // the replacement covers it
+ var covers = selected.Where((c, at) => at != i && Sides(c, box).Contains(side)).Any();
+ var wasCovered = selected.Any(c => Sides(c, box).Contains(side));
+ if (wasCovered && !covers)
+ removalSafe = false;
+ }
+ if (removalSafe)
+ {
+ selected[i] = candidate;
+ return;
+ }
+ }
+ // No safe victim: the side stays truthfully unrepresented at this cap.
+ }
+}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs
new file mode 100644
index 0000000..e716ea4
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// Proposes the ORDER in which a part's remaining holes are cut when the perimeter entry is
+/// already chosen: an open path from the tool's arrival through every hole centre and ON to
+/// the selected perimeter entry — the terminal edge is real rapid travel and is optimised,
+/// not scored after the fact. It reuses 's bounded nearest-
+/// neighbour + 2-opt/Or-opt machinery (no exhaustive permutations) with an optional fixed
+/// endpoint, so whole-part routing without an endpoint keeps byte-identical behaviour.
+/// This is a proposal only: the search still certifies every rapid and emitted lead along
+/// it, exactly as it does for part orders.
+///
+internal static class CuttingHoleOrder
+{
+ ///
+ /// Orders (stable ordinals, each appearing exactly once in the
+ /// result, never the perimeter) to minimize arrival -> holes -> distance. supplies one
+ /// representative point per hole ordinal. Empty holes give an empty order.
+ ///
+ internal static IReadOnlyList Plan(IReadOnlyList holes,
+ IReadOnlyList centresByOrdinal, Vector arrival, Vector perimeterEntry,
+ CancellationToken token = default)
+ {
+ if (holes == null)
+ throw new ArgumentException("Hole ordinals are required.", nameof(holes));
+ if (centresByOrdinal == null)
+ throw new ArgumentException("Hole centres are required.", nameof(centresByOrdinal));
+ token.ThrowIfCancellationRequested();
+ if (holes.Count == 0)
+ return Array.Empty();
+
+ var none = Array.Empty();
+ var prerequisites = holes.Select(_ => (IReadOnlyCollection)none).ToList();
+ var centres = holes.Select(hole => centresByOrdinal[hole]).ToList();
+ var route = CuttingPartOrder.Plan(centres, arrival, prerequisites, token, perimeterEntry);
+ return route.Select(index => holes[index]).ToArray();
+ }
+}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
index 2c4b280..6b3efbd 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
@@ -39,8 +39,13 @@ internal static class CuttingPartOrder
/// One representative point per part ordinal.
/// The modeled tool position before the first part.
/// Ordinals that must come before each part; must be acyclic.
+ ///
+ /// Optional fixed final position AFTER the last visited point (an open path with a closed
+ /// terminal edge). The terminal edge joins EVERY improvement delta, not just final
+ /// scoring. Null keeps the previous whole-part open-route semantics exactly.
+ ///
internal static int[] Plan(IReadOnlyList centres, Vector start,
- IReadOnlyList> prerequisites, CancellationToken token)
+ IReadOnlyList> prerequisites, CancellationToken token, Vector? endpoint = null)
{
var count = centres.Count;
var order = NearestNeighbour(centres, start, prerequisites, token);
@@ -48,8 +53,8 @@ internal static class CuttingPartOrder
for (var pass = 0; pass < MaxPasses; pass++)
{
token.ThrowIfCancellationRequested();
- var improved = TwoOpt(order, centres, start, prerequisites, position, token);
- improved |= OrOpt(order, centres, start, prerequisites, position, token);
+ var improved = TwoOpt(order, centres, start, prerequisites, position, token, endpoint);
+ improved |= OrOpt(order, centres, start, prerequisites, position, token, endpoint);
if (!improved)
break;
}
@@ -90,19 +95,23 @@ internal static class CuttingPartOrder
// Reverses order[i..j] when that shortens the open path and keeps every prerequisite earlier.
private static bool TwoOpt(int[] order, IReadOnlyList centres, Vector start,
- IReadOnlyList> prerequisites, int[] position, CancellationToken token)
+ IReadOnlyList> prerequisites, int[] position, CancellationToken token,
+ Vector? endpoint = null)
{
var improved = false;
var count = order.Length;
+ // The terminal edge belongs to every delta: reversing the route's tail swaps which
+ // endpoint-side centre faces the fixed final position.
+ double Tail(Vector from) => endpoint is { } e ? from.DistanceTo(e) : 0;
for (var i = 0; i < count - 1; i++)
{
token.ThrowIfCancellationRequested();
for (var j = i + 1; j < count; j++)
{
var before = Point(i - 1).DistanceTo(centres[order[i]])
- + (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : 0);
+ + (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : Tail(centres[order[j]]));
var after = Point(i - 1).DistanceTo(centres[order[j]])
- + (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : 0);
+ + (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : Tail(centres[order[i]]));
if (after >= before - Epsilon || !CanReverse(order, i, j, prerequisites, position))
continue;
Array.Reverse(order, i, j - i + 1);
@@ -129,7 +138,8 @@ internal static class CuttingPartOrder
// Moves a run of one to three parts to a later or earlier gap when that shortens the path.
private static bool OrOpt(int[] order, IReadOnlyList centres, Vector start,
- IReadOnlyList> prerequisites, int[] position, CancellationToken token)
+ IReadOnlyList> prerequisites, int[] position, CancellationToken token,
+ Vector? endpoint = null)
{
var improved = false;
var count = order.Length;
@@ -157,12 +167,15 @@ internal static class CuttingPartOrder
}
return improved;
- // Path length between order[a] and order[b] (a == -1 is the start; b == count is the open end).
+ // Path length between order[a] and order[b] (a == -1 is the start; b == count is the open
+ // end — the fixed endpoint when one was supplied, so the terminal edge is in every delta).
double Gap(int a, int b)
{
- if (b >= count || b < 0)
- return 0;
var from = a < 0 ? start : centres[order[a]];
+ if (b >= count)
+ return endpoint is { } e ? from.DistanceTo(e) : 0;
+ if (b < 0)
+ return 0;
return from.DistanceTo(centres[order[b]]);
}
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
index 341d4d3..2e180d7 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
@@ -307,14 +307,22 @@ public sealed class CuttingPlanProposal
yield break;
}
+ var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Cancelled
+ && result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
yield return heading + (plate.IsRouteReady
? "blocked: parts overlap or could not be checked for overlap."
- : StatusText(result.Status));
+ : missingLead ? "blocked: missing or zero-length lead-in." : StatusText(result.Status));
foreach (var line in Limit(DescribeOverlap(plate.Overlap)))
yield return line;
if (plate.IsRouteReady)
yield break;
- if (plate.KeptCurrentOrder)
+ if (missingLead)
+ {
+ yield return " Open Cutting Settings... and select a lead-in type other than None with "
+ + "nonzero length for the affected contour (External, Internal, or Arc / Circle), then replan.";
+ yield return " If the affected part is locked, edit its lead-ins or unlock it before replanning.";
+ }
+ else if (plate.KeptCurrentOrder)
yield return " No new part order was found within the search limit, and planning with the "
+ "current order was refused:";
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 1ee9e2b..9a02ad2 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -10,8 +10,9 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
///
-/// Plans whole parts and their emitted contour prefixes along a part order. A preserved order is
-/// followed with full backtracking. Otherwise the order comes from ;
+/// Plans whole parts and their emitted contour prefixes along a part order. Multi-part holed
+/// requests first try one ranked hole chain per endpoint, then retain full backtracking.
+/// A preserved order stays fixed. Otherwise the order comes from ;
/// when a part on it cannot be reached without crossing parts already cut, the search learns
/// "cut this part before those", keeps the parts cut before them and re-plans the rest. Once
/// nothing new can be learned, the remaining budget goes to a full search over every ready part.
@@ -24,31 +25,47 @@ internal static class JointCuttingPlanSearch
///
internal const int StallExpansionsPerEntry = 8;
+ /// LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order,
- IReadOnlyList Findings, int Expansions);
+ IReadOnlyList Findings, int Expansions, int LeadPrechecks = 0);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
var walk = new Walk(snapshot, token);
var count = snapshot.Placements.Count;
+ var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
+ var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
+ var preferEndpoints = count > 1 && maxContours > 1;
try
{
if (snapshot.PreservePartOrder)
{
- var kept = walk.Follow(Enumerable.Range(0, count).ToArray(), null, null);
+ var keptSequence = Enumerable.Range(0, count).ToArray();
+ if (preferEndpoints)
+ {
+ var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true);
+ if (preferred.Order != null)
+ return walk.Ready(preferred.Order);
+ }
+ var kept = walk.Follow(keptSequence, null, null);
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
}
var centres = snapshot.Placements.Select(Centre).ToArray();
var prerequisites = Enumerable.Range(0, count)
.Select(i => new HashSet(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
- var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
- var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
+
var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres,
snapshot.StartPoint, prerequisites, token);
Node resume = null;
while (true)
{
+ if (preferEndpoints)
+ {
+ var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true);
+ if (preferred.Order != null)
+ return walk.Ready(preferred.Order);
+ }
var attempt = walk.Follow(sequence, stall, resume);
if (attempt.Order != null)
return walk.Ready(attempt.Order);
@@ -69,11 +86,11 @@ internal static class JointCuttingPlanSearch
}
catch (BudgetExceededException)
{
- return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions);
+ return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions, walk.LeadPrechecks);
}
catch (OperationCanceledException)
{
- return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions);
+ return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions, walk.LeadPrechecks);
}
}
@@ -141,13 +158,20 @@ internal static class JointCuttingPlanSearch
private readonly List rejected = [];
private readonly LeadMaterialSnapshot[] materials =
snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
+ // One feasibility adapter per source part per captured planning attempt: verdicts
+ // memoize per owned choice for the attempt, never statically or across attempts.
+ private readonly Dictionary feasibility = [];
+ private readonly HashSet reportedNoFit = [];
internal int Expansions { get; private set; }
+ /// Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free.
+ internal int LeadPrechecks { get; private set; }
+
internal IReadOnlyList Rejected => rejected.Distinct().ToArray();
internal Outcome Ready(IReadOnlyList order) =>
- new(CuttingPlanStatus.Ready, order, [], Expansions);
+ new(CuttingPlanStatus.Ready, order, [], Expansions, LeadPrechecks);
// Entries are capped; exhaustion is not a proof over all possible entries.
internal Outcome Exhausted()
@@ -156,7 +180,7 @@ internal static class JointCuttingPlanSearch
? CuttingPlanStatus.NoSolutionWithinBudget
: rejected.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict;
- return new(status, [], Rejected, Expansions);
+ return new(status, [], Rejected, Expansions, LeadPrechecks);
}
///
@@ -165,7 +189,7 @@ internal static class JointCuttingPlanSearch
/// With a stall limit the attempt ends once that many expansions pass without getting
/// further along the order; it never backtracks behind its starting node.
///
- internal Attempt Follow(int[] sequence, int? stall, Node resume)
+ internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false)
{
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
var attempt = new Attempt(sequence);
@@ -186,8 +210,7 @@ internal static class JointCuttingPlanSearch
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
- frame.Children ??= Expand(node, sequence, attempt).OrderBy(c => c.Distance)
- .ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray();
+ frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
if (frame.Next == frame.Children.Length)
{
stack.Pop();
@@ -198,7 +221,65 @@ internal static class JointCuttingPlanSearch
return attempt;
}
- private IEnumerable Expand(Node node, int[] sequence, Attempt attempt)
+ ///
+ /// The NEXT cut's centre that the outside entry should face, or null for the last
+ /// part. Supplied order: the next not-yet-finished part in that order (the sequence
+ /// is re-read after every learned-order replan). Sequence-free fallback: nearest
+ /// dependency-ready remaining part once the current part counts as finished, stable
+ /// ordinal ties. Never the current or a finished part. Global coordinates.
+ ///
+ private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source)
+ {
+ var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
+ finished.Add(source.SourceOrdinal);
+ if (sequence != null)
+ for (var i = node.Order.Length + 1; i < sequence.Length; i++)
+ if (!finished.Contains(sequence[i]))
+ return Centre(snapshot.Placements[sequence[i]]);
+ var from = Centre(source);
+ Vector? best = null;
+ var bestDistance = double.PositiveInfinity;
+ foreach (var candidate in snapshot.Placements
+ .Where(p => !finished.Contains(p.SourceOrdinal)
+ && snapshot.Dependencies.IsReady(p.SourceOrdinal, finished))
+ .OrderBy(p => p.SourceOrdinal))
+ {
+ var distance = Centre(candidate).DistanceTo(from);
+ if (distance < bestDistance - 1e-9)
+ {
+ bestDistance = distance;
+ best = Centre(candidate);
+ }
+ }
+ return best;
+ }
+
+ ///
+ /// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside
+ /// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
+ /// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
+ ///
+ private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
+ {
+ var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
+ if (edges.Count <= 1)
+ return edges.ToArray();
+ // Stable source order: the minimum incremental rapid per source, ties ordinal.
+ var sourceOrder = edges.GroupBy(e => e.Ordinal)
+ .OrderBy(g => g.Min(e => e.Distance)).ThenBy(g => g.Key)
+ .SelectMany((g, rank) => g.Select(e => (Edge: e, Rank: rank)))
+ .ToDictionary(x => x.Edge, x => x.Rank);
+ return edges
+ .OrderBy(e => sourceOrder[e])
+ .ThenBy(e => e.ContourRank)
+ .ThenBy(e => e.Contour)
+ .ThenBy(e => e.Rank)
+ .ThenBy(e => e.Distance)
+ .ThenBy(e => e.Entry)
+ .ToArray();
+ }
+
+ private IEnumerable Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
{
IEnumerable sources;
if (node.Active is { } active)
@@ -225,17 +306,69 @@ internal static class JointCuttingPlanSearch
continue;
}
var prepared = source.Prepared;
+ if (prepared.Count > 1 && node.Active == null)
+ {
+ // Selecting an endpoint does not cut it. Each endpoint owns a separate
+ // branch, whose holes are still emitted first and checked normally.
+ CountExpansion(source);
+ var perimeters = AutomaticEntries(node, sequence, source, prepared.PerimeterOrdinal);
+ for (var index = 0; index < perimeters.Count; index++)
+ {
+ CountExpansion(source);
+ var state = new ActivePart(source, [], node.Position, node.Checker, 0, node,
+ perimeters[index]);
+ yield return new(new(node.Order, node.Position, node.Checker, state, null),
+ Centre(source).DistanceTo(node.Position), source.SourceOrdinal, -1, index, index);
+ }
+ continue;
+ }
+ var preference = node.Active?.Preference;
+ if (node.Active?.Perimeter is { } outside && preference == null)
+ preference = PlanHoles(node.Active, outside);
var choices = node.Active?.Choices ?? [];
var arrival = node.Active?.Arrival ?? node.Position;
var before = node.Active?.Before ?? node.Checker;
var boundary = node.Active?.Boundary ?? node;
var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
+ // First try one ranked hole chain per endpoint. A later-part failure then
+ // changes the endpoint before replaying all earlier hole combinations.
+ // The retained pass below still searches every entry and hole order.
+ if (preferredOnly && preference != null)
+ contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
- var entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token);
- for (var entry = 0; entry < entries.Count; entry++)
+ IReadOnlyList entries;
+ var contourRank = 0;
+ if (node.Active?.Perimeter is { } perimeter)
+ {
+ if (contour == prepared.PerimeterOrdinal)
+ entries = [perimeter];
+ else
+ {
+ contourRank = Array.IndexOf(preference.Route, contour);
+ var downstream = preference.Route.Skip(contourRank + 1)
+ .FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
+ var target = downstream < 0 ? preference.PerimeterPierce
+ : preference.Pierces[downstream];
+ entries = SelectEntries(source, contour, target, node.Position - source.Location);
+ // Preference only reorders retained candidates. Neither a failed
+ // preferred entry nor an alternate hole order prunes this branch.
+ if (preference.Entries.TryGetValue(contour, out var preferred))
+ entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point)
+ <= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray();
+ }
+ }
+ else
+ {
+ CountExpansion(source);
+ entries = AutomaticEntries(node, sequence, source, contour);
+ }
+ if (entries.Count == 0)
+ continue;
+ var entryCount = preferredOnly && node.Active?.Perimeter != null ? System.Math.Min(1, entries.Count) : entries.Count;
+ for (var entry = 0; entry < entryCount; entry++)
{
CountExpansion(source); // Before emission/native queries, including rejected candidates.
var prefix = choices.Append(entries[entry]).ToArray();
@@ -260,13 +393,111 @@ internal static class JointCuttingPlanSearch
? new Node([.. node.Order, source.Propose(program, execution, prefix, token)],
execution.DeparturePoint, checker, null, boundary)
: new Node(node.Order, execution.DeparturePoint, checker,
- new(source, prefix, arrival, before, distance, boundary), null);
- yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
+ new(source, prefix, arrival, before, distance, boundary,
+ node.Active?.Perimeter, preference), null);
+ yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal,
+ contour, entry, entry, contourRank);
}
}
}
}
+ ///
+ /// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
+ /// converted exactly once: rank the (fallback-complemented) catalogue toward the
+ /// next cut, lazily filter through the shared validator adapter, cap at MaxEntries
+ /// with side coverage. Empty ONLY when the finite catalogue was fully evaluated and
+ /// nothing fits — then the honest part/contour finding is recorded once.
+ ///
+ private IReadOnlyList AutomaticEntries(Node node, int[] sequence,
+ FixedProgramPlacement source, int contour)
+ {
+ var prepared = source.Prepared!;
+ var arrival = node.Position - source.Location;
+ // One global->local conversion of target and arrival; geometry is already rotated.
+ Vector? local = LookAheadCentre(node, sequence, source) is { } target
+ ? target - source.Location : null;
+ return SelectEntries(source, contour, local, arrival);
+ }
+
+ private ContourEntryFeasibility Adapter(FixedProgramPlacement source) =>
+ feasibility.TryGetValue(source.SourceOrdinal, out var known) ? known
+ : feasibility[source.SourceOrdinal] = new ContourEntryFeasibility(
+ source.Prepared, source.Location, source.Material, materials);
+
+ private ContourFeasibilityVerdict Evaluate(FixedProgramPlacement source, ContourEntryCandidate candidate)
+ {
+ CountExpansion(source);
+ var adapter = Adapter(source);
+ var before = adapter.EvaluationCount;
+ var verdict = adapter.Check(candidate.Choice, token: token);
+ LeadPrechecks += adapter.EvaluationCount - before;
+ return verdict;
+ }
+
+ private IReadOnlyList SelectEntries(FixedProgramPlacement source, int contour,
+ Vector? target, Vector arrival)
+ {
+ // Last contour has no downstream target, but still needs its exact native
+ // closest-arrival fallback. Keep target null in the ranker: arrival is not
+ // a next cut and must not acquire facing-side priority.
+ var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token);
+ var ordered = catalogue.RankTowardNextCut(target, arrival);
+ var selection = ContourEntrySelection.Select(ordered,
+ candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
+ if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
+ rejected.Add(Finding(source, PostVerificationKind.Incomplete,
+ $"Contour {contour}: {selection.Reason}"));
+ else if (selection.Choices.Count == 0 && selection.UncertainChoices.Count == 0
+ && reportedNoFit.Add(source.SourceOrdinal * 1000 + contour))
+ rejected.Add(Finding(source, null,
+ $"No tested lead-in fits on cutting contour {contour + 1}: {selection.Reason} "
+ + "Try reducing the lead-in length in Cutting Settings...; if nearby parts obstruct the lead-in, "
+ + "space the parts farther apart. Replan to check the changes."));
+ // Uncertain candidates are NOT refused by the precheck: they reach the emitted-
+ // prefix Check and complete replay, which remain the authority on them.
+ return selection.Choices.Concat(selection.UncertainChoices).Take(snapshot.MaxEntries).ToArray();
+ }
+
+ private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter)
+ {
+ var source = active.Source;
+ var prepared = source.Prepared;
+ var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray();
+ var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
+ var localArrival = active.Arrival - source.Location;
+ var endpoint = perimeter.Point;
+ var entries = new Dictionary();
+ var pierces = holes.ToDictionary(h => h, h => centres[h]);
+ var route = holes;
+ try
+ {
+ CountExpansion(source);
+ endpoint = PreferredContourEntries.Pierce(prepared, perimeter, token);
+ route = CuttingHoleOrder.Plan(holes, centres, localArrival, endpoint, token).ToArray();
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeter, route, centres,
+ localArrival, candidate => Evaluate(source, candidate), token);
+ if (proposal.IsPreferred)
+ foreach (var choice in proposal.HoleChoices)
+ {
+ CountExpansion(source);
+ entries.Add(choice.ContourOrdinal, choice);
+ pierces[choice.ContourOrdinal] = PreferredContourEntries.Pierce(prepared, choice, token);
+ }
+ else
+ rejected.Add(Finding(source, proposal.Shortfall == ContourSelectionShortfall.Incomplete
+ ? PostVerificationKind.Incomplete : null, proposal.Reason ?? "No preferred hole path."));
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
+ {
+ // A proposal is not a gate. Unknown emissions still reach prefix/final
+ // replay through retained candidates; never label an uncertain probe clear.
+ rejected.Add(Finding(source, PostVerificationKind.Incomplete,
+ $"Preferred hole path unavailable: {ex.Message}"));
+ }
+ return new(route, entries, pierces, endpoint);
+ }
+
private void CountExpansion(FixedProgramPlacement source)
{
token.ThrowIfCancellationRequested();
@@ -347,12 +578,18 @@ internal static class JointCuttingPlanSearch
}
private sealed class BudgetExceededException : Exception;
+ // Every endpoint branch owns its preference; all retained entries/orders remain searchable.
+ private sealed record HolePreference(int[] Route, IReadOnlyDictionary Entries,
+ IReadOnlyDictionary Pierces, Vector PerimeterPierce);
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
- ReleasedContourState Before, double Distance, Node Boundary);
+ ReleasedContourState Before, double Distance, Node Boundary,
+ ContourChoice Perimeter = null, HolePreference Preference = null);
/// A search state; Previous links a whole-part boundary to the boundary before it.
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
ActivePart Active, Node Previous);
- private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry);
+ /// Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children.
+ private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry,
+ int Rank = int.MinValue, int ContourRank = 0);
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
diff --git a/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs
new file mode 100644
index 0000000..fcb5818
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs
@@ -0,0 +1,101 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// One preferred sequence of owned contour choices for a holed part: every remaining hole
+/// gets exactly one lead-feasible entry, resolved BACKWARD from the already-chosen
+/// perimeter entry — the last hole faces the perimeter's actual emitted pierce, each earlier
+/// hole faces the next hole's actual emitted pierce — so the cut chain flows toward the
+/// outside start. This is a deterministic recommended v1 proposal, not an optimal joint
+/// tour and not an installed program: the search still certifies every rapid, lead and
+/// crossing. A blocked preferred rapid is the search's problem (S12), never ignored here.
+///
+internal static class PreferredContourEntries
+{
+ /// Proposed hole choices in cut order, or an explicit no-preference verdict.
+ internal sealed record Proposal(
+ IReadOnlyList HoleChoices,
+ ContourSelectionShortfall Shortfall,
+ int? BlockedContour,
+ string? Reason)
+ {
+ public bool IsPreferred => Shortfall != ContourSelectionShortfall.Incomplete
+ && HoleChoices.Count > 0 && BlockedContour is null;
+ }
+
+ ///
+ /// Resolves one preferred entry per hole on (cut order,
+ /// from ), ending at .
+ /// Arrival proxies avoid circularity: the actual (local) part arrival for the first
+ /// hole, otherwise the previous hole's centre. Facing target (the downstream actual
+ /// pierce) and tier still rank first. is the S07 adapter
+ /// verdict; candidates it refuses or leaves uncertain are not preferred.
+ ///
+ internal static Proposal TryPlan(
+ PreparedContours prepared,
+ ContourChoice perimeterChoice,
+ IReadOnlyList holeRoute,
+ IReadOnlyList centresByOrdinal,
+ Vector arrival,
+ Func evaluate,
+ CancellationToken token = default)
+ {
+ if (prepared == null)
+ throw new ArgumentException("Prepared contours are required.", nameof(prepared));
+ if (perimeterChoice == null || !ReferenceEquals(perimeterChoice.Owner, prepared))
+ throw new ArgumentException("The perimeter choice must belong to this preparation.", nameof(perimeterChoice));
+ if (holeRoute == null || centresByOrdinal == null || evaluate == null)
+ throw new ArgumentException("Hole route, centres and evaluation are required.");
+ token.ThrowIfCancellationRequested();
+ if (holeRoute.Count == 0)
+ return new(Array.Empty(), ContourSelectionShortfall.Exhausted, null, null);
+
+ // The downstream target is where the tool ACTUALLY arrives next: the emitted
+ // contour's first cut/lead motion start (native rounding/clamping included).
+ var target = Pierce(prepared, perimeterChoice, token);
+ var choices = new ContourChoice[holeRoute.Count];
+ for (var index = holeRoute.Count - 1; index >= 0; index--)
+ {
+ token.ThrowIfCancellationRequested();
+ var contour = holeRoute[index];
+ var arrivalProxy = index == 0 ? arrival : centresByOrdinal[holeRoute[index - 1]];
+ // The S04 merged catalogue: preferred kinds when present, fallback kinds
+ // (compass points, arc midpoints, ...) otherwise — pure circles included.
+ var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token);
+ var ranked = catalogue.RankTowardNextCut(target, arrivalProxy);
+ var selection = ContourEntrySelection.Select(ranked, evaluate, 1, token);
+ if (selection.Choices.Count == 0)
+ return new(Array.Empty(), selection.Shortfall, contour,
+ $"Hole contour {contour} has no preferred lead-feasible entry: {selection.Reason}");
+ choices[index] = selection.Choices[0];
+ target = Pierce(prepared, choices[index], token);
+ }
+
+ if (choices.Any(c => c == null!))
+ throw new InvalidOperationException("Preferred hole resolution left a gap.");
+ return new(choices, ContourSelectionShortfall.None, null, null);
+ }
+
+ ///
+ /// The actual emitted pierce of one owned choice in local coordinates: the start of the
+ /// first non-rapid motion (lead-in when present — that is where the next rapid must
+ /// arrive), which reflects native rounding and clamping of a nominal entry.
+ ///
+ internal static Vector Pierce(PreparedContours prepared, ContourChoice choice, CancellationToken token)
+ {
+ var program = prepared.EmitCandidateForValidation(choice);
+ var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, token);
+ // The diagnostic emission includes the part's scribes before the selected contour.
+ // Those marks are not the downstream cut's pierce.
+ var first = execution.Motions.First(m => !m.Rapid && m.Layer != LayerType.Scribe);
+ return first.Start ?? first.End;
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs b/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs
new file mode 100644
index 0000000..980a629
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs
@@ -0,0 +1,139 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class CircleNextCutTests
+{
+ private static Program WithHoles()
+ {
+ var program = LeadPathValidationTests.Rectangle(0, 0, 16, 12);
+ foreach (var centre in new[] { new Vector(3, 3), new Vector(9, 4), new Vector(11, 8) })
+ {
+ program.MoveTo(centre.X + 1, centre.Y);
+ program.ArcTo(centre.X + 1, centre.Y, centre.X, centre.Y, RotationType.CCW);
+ }
+ return program;
+ }
+
+ [Theory]
+ [InlineData(13, 4)]
+ [InlineData(3, 13)]
+ [InlineData(-7, 2)]
+ [InlineData(4, -7)]
+ public void CirclesPreferTheExactNextCutDirectionWithoutDroppingPolarOrDiagonalOptions(double x, double y)
+ {
+ var prepared = PreparedContours.Capture(WithHoles(), HoleLookAheadTests.Parameters());
+ var target = new Vector(x, y);
+ var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(0, target);
+ var ranked = catalogue.RankTowardNextCut(target, new Vector(-4, -3));
+ var expected = prepared.ClosestEntry(0, target).Point;
+
+ Assert.True(ranked[0].Choice.Point.DistanceTo(expected) < 1e-8,
+ $"Expected next-facing {expected}, got {ranked[0].Choice.Point}");
+ Assert.Equal(catalogue.Count, ranked.Count);
+ Assert.All(catalogue, candidate => Assert.Contains(candidate, ranked));
+ for (var angle = 0; angle < 8; angle++)
+ {
+ var point = new Vector(3, 3) + new Vector(System.Math.Cos(angle * System.Math.PI / 4),
+ System.Math.Sin(angle * System.Math.PI / 4));
+ Assert.Contains(ranked, c => c.Choice.Point.DistanceTo(point) < 1e-8);
+ }
+ }
+
+ [Fact]
+ public void BlockedExactPointRetainsAFeasiblePolarAlternative()
+ {
+ var prepared = PreparedContours.Capture(WithHoles(), HoleLookAheadTests.Parameters());
+ var target = new Vector(13, 4);
+ var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(0, target);
+ var ranked = catalogue.RankTowardNextCut(target, new Vector(-4, -3));
+ var exact = prepared.ClosestEntry(0, target).Point;
+ var material = LeadMaterialSnapshot.Capture(WithHoles(), Vector.Zero);
+ var feasibility = new ContourEntryFeasibility(prepared, Vector.Zero, material, []);
+ var blocked = 0;
+ var selection = ContourEntrySelection.Select(ranked, candidate =>
+ {
+ if (candidate.Choice.Point.DistanceTo(exact) < 1e-8)
+ {
+ blocked++;
+ return new(ContourFeasibilityStatus.Blocked, "Exact direction unavailable in this control.");
+ }
+ return feasibility.Check(candidate.Choice);
+ }, 1);
+
+ Assert.True(blocked > 0);
+ var selected = Assert.Single(selection.Choices);
+ Assert.True(selected.Point.DistanceTo(new Vector(4, 3)) < 1e-8,
+ $"Expected east polar fallback, got {selected.Point}");
+ Assert.Equal(ContourFeasibilityStatus.Clear, feasibility.Check(selected).Status);
+ }
+
+ [Theory]
+ [InlineData(45)]
+ [InlineData(90)]
+ public void ConfiguredAngleRoundingStillSnapsTheActualCircleCuts(double increment)
+ {
+ var parameters = HoleLookAheadTests.Parameters();
+ parameters.RoundLeadInAngles = true;
+ parameters.LeadInAngleIncrement = increment;
+ var part = new Part(new Drawing("rounded starts", WithHoles()));
+ var next = new Part(new Drawing("next part", LeadPathValidationTests.Rectangle(0, 0, 2, 2)),
+ new Vector(22, 1));
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part, next], new Vector(-2, 3),
+ confirmedParameters: parameters, preservePartOrder: true));
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ var proposed = result.ProposedOrder[0];
+ var runs = HoleLookAheadTests.CutRuns(proposed.Execution);
+ var centres = new[] { new Vector(3, 3), new Vector(9, 4), new Vector(11, 8) };
+ for (var i = 0; i < 3; i++)
+ {
+ var radial = runs[i].End - centres[proposed.ContourChoices[i].ContourOrdinal];
+ var steps = System.Math.Atan2(radial.Y, radial.X) * 180 / System.Math.PI / increment;
+ Assert.True(System.Math.Abs(steps - System.Math.Round(steps)) < 1e-8);
+ }
+ Assert.True(parameters.RoundLeadInAngles);
+ Assert.Equal(increment, parameters.LeadInAngleIncrement);
+ }
+
+ [Theory]
+ [InlineData(0, 0)]
+ [InlineData(23, 17)]
+ public void PlannedCircleStartsFaceTheImmediateNextPierceNotTwoFeaturesAhead(double x, double y)
+ {
+ var location = new Vector(x, y);
+ var part = new Part(new Drawing("three round cutouts", WithHoles()), location);
+ var next = new Part(new Drawing("next part", LeadPathValidationTests.Rectangle(0, 0, 2, 2)),
+ location + new Vector(22, 1));
+ var before = ExplicitContourTests.Fingerprint(part.Program);
+ var arrival = location + new Vector(-2, 3);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, next], arrival,
+ confirmedParameters: HoleLookAheadTests.Parameters(), preservePartOrder: true));
+ var result = CuttingPlanService.Plan(snapshot);
+ Assert.True(result.Status == CuttingPlanStatus.Ready,
+ $"{result.Status}: {string.Join("; ", result.Findings.Select(f => f.Message))}");
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
+ var proposal = result.ProposedOrder[0];
+ var choices = proposal.ContourChoices;
+ var runs = HoleLookAheadTests.CutRuns(proposal.Execution);
+ Assert.Equal(4, runs.Count);
+ Assert.Equal(new[] { 0, 1, 2, 3 }, choices.Select(c => c.ContourOrdinal));
+ var prepared = snapshot.Placements[0].Prepared;
+ for (var i = 0; i < choices.Count - 1; i++)
+ {
+ var target = runs[i + 1].Pierce - location;
+ var expected = prepared.ClosestEntry(choices[i].ContourOrdinal, target).Point;
+ Assert.True(choices[i].Point.DistanceTo(expected) < 1e-8,
+ $"Hole {i}: expected immediate-next-facing {expected}, got {choices[i].Point}");
+ // The emitter rounds circle start angles; verify actual output too, allowing
+ // only that existing rounding rather than a snapped compass direction.
+ Assert.True((runs[i].End - location).DistanceTo(expected) < 0.01);
+ }
+ Assert.Empty(new ReleasedContourState().Check(proposal.Execution, arrival, 1));
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs b/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs
new file mode 100644
index 0000000..52d23f1
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ContourCandidateEmissionTests.cs
@@ -0,0 +1,303 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// The single-contour diagnostic emitter: identical emitted lead groups in isolation and
+/// inside a valid complete program (holes first), with the perimeter keeping its External
+/// identity — and the normal perimeter-last gate left untouched.
+///
+public class ContourCandidateEmissionTests
+{
+ private static PreparedContours Capture(Program program, CuttingParameters? parameters = null) =>
+ PreparedContours.Capture(program, parameters ?? ExplicitContourTests.Parameters());
+
+ // --- fixtures ---------------------------------------------------------------
+
+ /// Square perimeter with two circular holes; Capture lists holes 0-1, perimeter 2.
+ private static Program TwoHoles(bool reversed = false)
+ {
+ var p = ExplicitContourTests.Square(reversed);
+ foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) })
+ {
+ p.MoveTo(center.X + 0.5, center.Y);
+ p.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW);
+ }
+ return p;
+ }
+
+ private static Program WithScribe(Program p)
+ {
+ p.MoveTo(1, 1);
+ p.Codes.Add(new LinearMove(new Vector(2, 1)) { Layer = LayerType.Scribe });
+ return p;
+ }
+
+ private static ContourChoice PerimeterCorner(PreparedContours prepared)
+ {
+ var corner = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal)
+ .First(c => c.Kind == AutomaticEntryKind.ConvexCorner).Choice;
+ return corner;
+ }
+
+ private static Program CompleteWithHolesFirst(PreparedContours prepared, ContourChoice perimeterChoice) =>
+ prepared.Emit(new[]
+ {
+ prepared.ClosestEntry(0, new Vector(3, 3)),
+ prepared.ClosestEntry(1, new Vector(7, 7)),
+ perimeterChoice,
+ });
+
+ // --- differential fidelity ------------------------------------------------------
+
+ [Fact]
+ public void PerimeterInIsolation_MatchesCompleteProgramWithHolesFirst()
+ {
+ // Distinct External/Internal leads: if isolation misclassified the perimeter as a
+ // hole, its emitted lead geometry would differ from the complete program's.
+ var parameters = ExplicitContourTests.Parameters();
+ parameters.ExternalLeadIn = new LineLeadIn { Length = 1.5, ApproachAngle = 45 };
+ parameters.InternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 };
+ var prepared = Capture(TwoHoles(), parameters);
+ var corner = PerimeterCorner(prepared);
+
+ var isolated = prepared.EmitCandidateForValidation(corner);
+ var complete = CompleteWithHolesFirst(prepared, corner);
+
+ // The perimeter is the LAST contour of the complete program and alone in the probe.
+ AssertIsolatedMatchesTrailing(isolated, complete);
+ }
+
+ [Theory]
+ [InlineData("line")]
+ [InlineData("arc")]
+ [InlineData("linearc")]
+ [InlineData("lineline")]
+ [InlineData("clean")]
+ public void EveryLeadStyle_StillIsolatedEqualComplete(string style)
+ {
+ var parameters = ExplicitContourTests.Parameters(style);
+ var prepared = Capture(TwoHoles(), parameters);
+ var corner = PerimeterCorner(prepared);
+
+ var isolated = prepared.EmitCandidateForValidation(corner);
+ var complete = CompleteWithHolesFirst(prepared, corner);
+ AssertIsolatedMatchesTrailing(isolated, complete);
+ }
+
+ [Fact]
+ public void HoleInIsolation_MatchesItsSliceOfTheCompleteProgram()
+ {
+ var prepared = Capture(TwoHoles());
+ var hole = prepared.ClosestEntry(0, new Vector(3, 3));
+
+ var isolated = prepared.EmitCandidateForValidation(hole);
+ var complete = CompleteWithHolesFirst(prepared, PerimeterCorner(prepared));
+ Assert.Equal(BlockHashes(isolated).Single(), BlockHashes(complete).First());
+ }
+
+ [Fact]
+ public void EarlierHoleChoices_DoNotChangePerimeterEmission()
+ {
+ // Two different hole-entry choices/orders: the perimeter block is invariant.
+ var prepared = Capture(TwoHoles());
+ var corner = PerimeterCorner(prepared);
+
+ var orderA = prepared.Emit(new[]
+ {
+ prepared.ClosestEntry(0, new Vector(3, 3)),
+ prepared.ClosestEntry(1, new Vector(7, 7)),
+ corner,
+ });
+ var orderB = prepared.Emit(new[]
+ {
+ prepared.ClosestEntry(1, new Vector(7, 7.5)),
+ prepared.ClosestEntry(0, new Vector(3, 3.5)),
+ corner,
+ });
+ var isolated = prepared.EmitCandidateForValidation(corner);
+
+ Assert.Equal(BlockHashes(orderA)[^1..], BlockHashes(orderB)[^1..]);
+ AssertIsolatedMatchesTrailing(isolated, orderA);
+ }
+
+ [Fact]
+ public void Scribes_AppearOnceInIsolation()
+ {
+ var prepared = Capture(WithScribe(TwoHoles()));
+ var corner = PerimeterCorner(prepared);
+
+ var isolated = prepared.EmitCandidateForValidation(corner);
+ var scribes = NonRapid(isolated).Where(m => m.Layer == LayerType.Scribe).ToList();
+ Assert.Single(scribes);
+ }
+
+ [Fact]
+ public void ReversedWinding_IsolatedMatchesComplete()
+ {
+ var prepared = Capture(TwoHoles(reversed: true));
+ var corner = PerimeterCorner(prepared);
+
+ var isolated = prepared.EmitCandidateForValidation(corner);
+ var complete = CompleteWithHolesFirst(prepared, corner);
+ AssertIsolatedMatchesTrailing(isolated, complete);
+ }
+
+ [Fact]
+ public void RotatedCapture_IsolatedMatchesComplete()
+ {
+ var source = TwoHoles();
+ source.Rotate(System.Math.PI / 7);
+ var prepared = Capture(source);
+ var corner = PerimeterCorner(prepared);
+
+ var isolated = prepared.EmitCandidateForValidation(corner);
+ var complete = prepared.Emit(new[]
+ {
+ prepared.ClosestEntry(0, new Vector(3, 3)),
+ prepared.ClosestEntry(1, new Vector(7, 7)),
+ corner,
+ });
+ AssertIsolatedMatchesTrailing(isolated, complete);
+ }
+
+ [Fact]
+ public void CircleRoundingAndClamping_AreNotSharedState()
+ {
+ // Two circles at different normals with 90-degree rounding and clamping: the seam
+ // must not leak rounded/clamped state between contours.
+ var parameters = ExplicitContourTests.Parameters();
+ parameters.RoundLeadInAngles = true;
+ parameters.LeadInAngleIncrement = 90;
+ parameters.ArcCircleLeadIn = new LineLeadIn { Length = 2 };
+ var p = ExplicitContourTests.Square(false);
+ foreach (var center in new[] { new Vector(3, 3), new Vector(7, 7) })
+ {
+ p.MoveTo(center.X + 0.5, center.Y);
+ p.ArcTo(center.X + 0.5, center.Y, center.X, center.Y, RotationType.CCW);
+ }
+ var prepared = Capture(p, parameters);
+ var hole0 = prepared.ClosestEntry(0, new Vector(3.5, 3));
+ var hole1 = prepared.ClosestEntry(1, new Vector(7, 6.5));
+
+ var isolated0 = prepared.EmitCandidateForValidation(hole0);
+ var isolated1 = prepared.EmitCandidateForValidation(hole1);
+ var complete = prepared.Emit(new[] { hole0, hole1, PerimeterCorner(prepared) });
+
+ var blocks = BlockHashes(complete);
+ // Each isolated circle matches its own slice; different normals give different
+ // emitted circle programs — no cross-contour sharing of rounded state.
+ Assert.Equal(blocks[0], BlockHashes(isolated0).Single());
+ Assert.Equal(blocks[1], BlockHashes(isolated1).Single());
+ Assert.NotEqual(blocks[0], blocks[1]);
+ }
+
+ // --- safety gates ---------------------------------------------------------------
+
+ [Fact]
+ public void NormalEmit_StillRefusesPerimeterBeforeHoles()
+ {
+ var prepared = Capture(TwoHoles());
+ var corner = PerimeterCorner(prepared);
+
+ // The diagnostic seam does not loosen the ordinary perimeter-last gate.
+ Assert.Throws(() => prepared.Emit(new[] { corner }));
+ Assert.Throws(() => prepared.EmitPrefix(new[] { corner }));
+ }
+
+ [Fact]
+ public void ForeignOrForgedChoice_IsRejected()
+ {
+ var prepared = Capture(TwoHoles());
+ var other = Capture(TwoHoles());
+ var foreign = other.Entry(other.PerimeterOrdinal, 0, new Vector(0, 0));
+ Assert.Throws(() => prepared.EmitCandidateForValidation(foreign));
+
+ // A copy re-stamped with this preparation as owner is still rejected when its
+ // contour or point does not exist on this preparation: ownership alone is not enough.
+ var forgedContour = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, ContourOrdinal = 99 };
+ Assert.Throws(() => prepared.EmitCandidateForValidation(forgedContour));
+ var forgedEntity = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, EntityOrdinal = 99 };
+ Assert.Throws(() => prepared.EmitCandidateForValidation(forgedEntity));
+ }
+
+ [Fact]
+ public void SourceProgramAndSettings_AreNotMutated()
+ {
+ var source = TwoHoles();
+ var before = ExplicitContourTests.Fingerprint(source);
+ var parameters = ExplicitContourTests.Parameters();
+ var prepared = Capture(source, parameters);
+ var corner = PerimeterCorner(prepared);
+
+ prepared.EmitCandidateForValidation(corner);
+
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(source));
+ Assert.Equal(ExplicitContourTests.Parameters().ExternalLeadIn.GetType(),
+ parameters.ExternalLeadIn.GetType());
+ }
+
+ // --- helpers --------------------------------------------------------------------
+
+ private static List NonRapid(Program program) =>
+ ExecutionMotionReader.Read(program, Vector.Zero, null, default).Motions
+ .Where(m => !m.Rapid).ToList();
+
+ ///
+ /// One hash per emitted contour block: cut/lead motion runs in emission order, split at
+ /// each first lead-in that follows cut motion (every lead style in Parameters() emits
+ /// lead-in motions; scribe motions precede all blocks and are excluded). Each block is
+ /// normalised to its own first motion's start and quantised to 1e-9 because the emitted
+ /// programs are incremental — the reader resolves absolute positions by accumulating
+ /// from the program head, so the same code chain differs from a fresh head by ulps.
+ ///
+ /// The complete program's LAST contour is the perimeter: assert the isolated
+ /// probe's blocks equal the trailing block sequence of the complete program.
+ private static void AssertIsolatedMatchesTrailing(Program isolated, Program complete)
+ {
+ var iso = BlockHashes(isolated);
+ var blocks = BlockHashes(complete);
+ Assert.True(blocks.Count >= iso.Count);
+ Assert.Equal(iso, blocks.Skip(blocks.Count - iso.Count).ToList());
+ }
+
+ private static List BlockHashes(Program program)
+ {
+ var motions = NonRapid(program).Where(m => m.Layer != LayerType.Scribe).ToList();
+ var blocks = new List>();
+ var current = new List();
+ var sawCut = true;
+ foreach (var motion in motions)
+ {
+ var isLead = motion.Layer == LayerType.Leadin || motion.Layer == LayerType.Leadout;
+ if (motion.Layer == LayerType.Leadin && sawCut && current.Count > 0)
+ {
+ blocks.Add(current);
+ current = new List();
+ }
+ current.Add(motion);
+ if (!isLead)
+ sawCut = true;
+ }
+ if (current.Count > 0)
+ blocks.Add(current);
+ return blocks.Select(Hash).ToList();
+ }
+
+ private static string Hash(List block)
+ {
+ var origin = block[0].Start ?? block[0].End;
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("\n", block.Select(m =>
+ string.Join("|", m.Layer,
+ Q((m.Start ?? m.End).X - origin.X), Q((m.Start ?? m.End).Y - origin.Y),
+ Q(m.End.X - origin.X), Q(m.End.Y - origin.Y)))))));
+
+ static string Q(double value) => (value / 1e-9).ToString("F0", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs
new file mode 100644
index 0000000..28e6258
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs
@@ -0,0 +1,421 @@
+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());
+ }
+
+ // --- S04 fallback tier ------------------------------------------------------
+
+ private static IReadOnlyList WithFallbacks(
+ Program program, Vector? lookAhead = null, string style = "line") =>
+ PreparedContours.Capture(program, ExplicitContourTests.Parameters(style))
+ .AutomaticEntryCandidatesWithFallbacks(0, lookAhead);
+
+ [Fact]
+ public void Circle_FallbacksAreCompassPointsAndTargetFacing()
+ {
+ var p = new Program();
+ p.MoveTo(5, 3);
+ p.ArcTo(5, 3, 3, 3, RotationType.CCW); // end (5,3), center (3,3), radius 2
+
+ // The preferred catalogue still refuses a whole circle...
+ Assert.Throws(() => Capture(p).AutomaticEntryCandidates(0));
+
+ // ...but the fallback catalogue makes it usable: the eight native compass points.
+ var plain = WithFallbacks(p);
+ Assert.Equal(8, plain.Count);
+ Assert.All(plain, c => Assert.Equal(AutomaticEntryKind.CircleCompass, c.Kind));
+ for (var angle = 0; angle < 8; angle++)
+ {
+ var expected = new Vector(3, 3) +
+ new Vector(System.Math.Cos(angle * System.Math.PI / 4), System.Math.Sin(angle * System.Math.PI / 4)) * 2;
+ Assert.Contains(plain, c => Distance(c.Choice.Point, expected) < 1e-9);
+ }
+
+ // With a look-ahead the exact target-facing closest point joins the set. The
+ // look-ahead is off-compass so the merge cannot deduplicate it away.
+ var ahead = new Vector(4, -1);
+ var facing = WithFallbacks(p, ahead);
+ Assert.Equal(9, facing.Count);
+ var target = Assert.Single(facing, c => c.Kind == AutomaticEntryKind.TargetFacing);
+ var facingPoint = new Vector(3, 3) + (ahead - new Vector(3, 3)).Normalize() * 2;
+ Assert.Equal(facingPoint.X, target.Choice.Point.X, 6);
+ Assert.Equal(facingPoint.Y, target.Choice.Point.Y, 6);
+ }
+
+ [Fact]
+ public void FilletArcMidpoint_IsTier3_KeepsJointPreferred()
+ {
+ // Half-circle bump on the top edge: joints (0,10),(10,10); arc midpoint (5,15).
+ 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);
+
+ // The preferred tier has no arc midpoint at all.
+ Assert.DoesNotContain(Capture(p).AutomaticEntryCandidates(0),
+ c => c.Kind == AutomaticEntryKind.ArcMidpoint);
+
+ var withFallbacks = WithFallbacks(p);
+ var mid = Assert.Single(withFallbacks, c => c.Kind == AutomaticEntryKind.ArcMidpoint);
+ Assert.Equal(5, mid.Choice.Point.X, 9);
+ Assert.Equal(15, mid.Choice.Point.Y, 9);
+ // Tier 3: strictly after every preferred kind in catalogue order.
+ var lastPreferred = withFallbacks.Select(c => c.Kind).ToList()
+ .FindLastIndex(k => k <= AutomaticEntryKind.TangentJoint);
+ var midpointIndex = withFallbacks.ToList().IndexOf(mid);
+ Assert.True(midpointIndex > lastPreferred);
+ }
+
+ [Fact]
+ public void LongEdge_GetsNearCornerPointsAtTwoTimesLeadIn()
+ {
+ // 10x10 square, default LineLeadIn Length 0.3 -> inset 0.6 on each incident edge.
+ var candidates = WithFallbacks(ExplicitContourTests.Square(false));
+ var near = candidates.Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList();
+
+ Assert.Equal(8, near.Count); // 4 convex corners x 2 incident straight edges
+ Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(0.6, 0)) < 1e-9);
+ Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(9.4, 0)) < 1e-9);
+ Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(10, 9.4)) < 1e-9);
+ Assert.All(near, c => Assert.True(c.Kind > AutomaticEntryKind.TangentJoint));
+ }
+
+ [Fact]
+ public void ShortEdge_OmitsNearCornerInsteadOfExtrapolating()
+ {
+ // 10 x 0.4 rectangle: every convex corner's vertical edge (0.4) is shorter than
+ // 2 x lead-in (0.6), so only the horizontal edges carry near-corner points.
+ var p = ClosedContour(new[]
+ {
+ new Vector(0, 0), new Vector(10, 0), new Vector(10, 0.4), new Vector(0, 0.4),
+ });
+ var near = WithFallbacks(p).Where(c => c.Kind == AutomaticEntryKind.NearCorner).ToList();
+
+ Assert.Equal(4, near.Count);
+ // Every point stays strictly inside a horizontal edge of the rectangle — never
+ // extrapolated onto a vertical edge or past an endpoint.
+ Assert.All(near, c =>
+ {
+ var onBottom = Distance(c.Choice.Point, new Vector(c.Choice.Point.X, 0)) < 1e-9
+ && c.Choice.Point.X > 0 && c.Choice.Point.X < 10;
+ var onTop = Distance(c.Choice.Point, new Vector(c.Choice.Point.X, 0.4)) < 1e-9
+ && c.Choice.Point.X > 0 && c.Choice.Point.X < 10;
+ Assert.True(onBottom || onTop);
+ });
+ Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(0.6, 0)) < 1e-9);
+ Assert.Contains(near, c => Distance(c.Choice.Point, new Vector(9.4, 0.4)) < 1e-9);
+ }
+
+ [Fact]
+ public void InsetExactlyTwoLeadInsFromAReflexCorner_IsOmitted()
+ {
+ // Boundary notch 0.6 deep — exactly 2 x lead-in. The inset from each convex
+ // opening corner lands exactly on the reflex inner corner, so neither inner
+ // corner may appear in the automatic catalogue at all.
+ var p = ClosedContour(new[]
+ {
+ new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10),
+ new Vector(0, 4.6), new Vector(0.6, 4.6), new Vector(0.6, 4), new Vector(0, 4),
+ });
+ var candidates = WithFallbacks(p);
+
+ Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(0.6, 4.6)) < 1e-9);
+ Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, new Vector(0.6, 4)) < 1e-9);
+ }
+
+ [Fact]
+ public void TargetExactlyAtReflexVertex_IsNotAnAutomaticStart()
+ {
+ // L-outline; (5,5) is reflex for its own travel. The raw closest point from that
+ // position lands exactly on the reflex vertex and must be dropped.
+ 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 reflex = new Vector(5, 5);
+ var candidates = WithFallbacks(p, reflex);
+
+ // The forbidden closest point is dropped entirely...
+ Assert.DoesNotContain(candidates, c => Distance(c.Choice.Point, reflex) < 1e-9);
+ Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TargetFacing);
+ // ...and the catalogue stays usable through the other fallbacks.
+ Assert.Contains(candidates, c => c.Kind == AutomaticEntryKind.NearCorner);
+ }
+
+ [Fact]
+ public void TargetFacingAtSharedPoint_KeepsThePreferredCorner()
+ {
+ // Look-ahead straight at corner (10,10): closest point IS the convex corner, so
+ // the geometric merge keeps the more preferred kind at that single point.
+ var candidates = WithFallbacks(ExplicitContourTests.Square(false), new Vector(10, 10));
+
+ var atCorner = candidates.Where(c => Distance(c.Choice.Point, new Vector(10, 10)) < 1e-9).ToList();
+ var corner = Assert.Single(atCorner);
+ Assert.Equal(AutomaticEntryKind.ConvexCorner, corner.Kind);
+ }
+
+ [Fact]
+ public void FallbackCatalogue_IsNonEmptyForEveryShape_AndHonoursCancellation()
+ {
+ // All-rounded contour (bump square) stays usable; cancellation is honoured.
+ var bump = new Program();
+ bump.MoveTo(0, 0);
+ bump.LineTo(10, 0);
+ bump.LineTo(10, 10);
+ bump.ArcTo(new Vector(0, 10), new Vector(5, 10), RotationType.CCW);
+ bump.LineTo(0, 0);
+ Assert.NotEmpty(WithFallbacks(bump));
+
+ var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters());
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ Assert.Throws(() =>
+ prepared.AutomaticEntryCandidatesWithFallbacks(0, new Vector(1, 1), cancelled.Token));
+ }
+
+ [Theory]
+ [InlineData("arc")]
+ [InlineData("linearc")]
+ [InlineData("clean")]
+ public void NonLengthLeadIns_OmitNearCornerInsteadOfApproximating(string style)
+ {
+ // Only lead-in styles with a straight length feed the inset; others contribute 0
+ // and the near-corner fallback is omitted entirely — no invented setting.
+ var candidates = WithFallbacks(ExplicitContourTests.Square(false), style: style);
+ Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.NearCorner);
+ }
+
+ private static double Distance(Vector a, Vector b) => a.DistanceTo(b);
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs
new file mode 100644
index 0000000..3ff8c63
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs
@@ -0,0 +1,248 @@
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// S07: the feasibility adapter certifies EXACTLY what LeadPathValidator certifies for one
+/// emitted candidate contour — no more (NoLeadIn is not a plan approval) and no less
+/// (incomplete checks never read as clear), cached per captured attempt only.
+///
+public class ContourEntryFeasibilityTests
+{
+ private static readonly Vector At = Vector.Zero;
+
+ private static (PreparedContours Prepared, ContourChoice Choice, LeadMaterialSnapshot Own)
+ PreparedSquare(string style = "line")
+ {
+ var clean = ExplicitContourTests.Square(false);
+ var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters(style));
+ var choice = prepared.Entry(0, 0, new Vector(0, 5)); // left edge, mid-side
+ return (prepared, choice, LeadMaterialSnapshot.Capture(clean, At));
+ }
+
+ private static LeadMaterialSnapshot Box(double x1, double y1, double x2, double y2)
+ {
+ var p = new Program();
+ p.MoveTo(x1, y1);
+ p.LineTo(x2, y1); p.LineTo(x2, y2); p.LineTo(x1, y2); p.LineTo(x1, y1);
+ var snapshot = LeadMaterialSnapshot.Capture(p, Vector.Zero);
+ Assert.True(snapshot.IsComplete, snapshot.Reason);
+ return snapshot;
+ }
+
+ [Fact]
+ public void FittingStraightLeadIsClear()
+ {
+ var (prepared, choice, own) = PreparedSquare();
+ var feasibility = new ContourEntryFeasibility(prepared, At, own, []);
+
+ var verdict = feasibility.Check(choice);
+
+ Assert.True(verdict.IsClear, verdict.Reason);
+ Assert.Equal(ContourFeasibilityStatus.Clear, verdict.Status);
+ Assert.Equal(1, feasibility.EvaluationCount);
+ }
+
+ [Fact]
+ public void ArcLeadCandidateVerdictsComeFromTheSharedValidator()
+ {
+ var (prepared, choice, own) = PreparedSquare("arc");
+ var feasibility = new ContourEntryFeasibility(prepared, At, own, []);
+ Assert.True(feasibility.Check(choice).IsClear);
+ }
+
+ [Fact]
+ public void NeighbourContactRejectsKeepingReason()
+ {
+ var (prepared, choice, own) = PreparedSquare();
+ // A neighbouring sheet covering the lead-in approach region.
+ var neighbour = Box(-1.5, 4.4, -0.05, 5.6);
+ var feasibility = new ContourEntryFeasibility(prepared, At, own, [neighbour]);
+
+ var verdict = feasibility.Check(choice);
+
+ Assert.Equal(ContourFeasibilityStatus.Blocked, verdict.Status);
+ Assert.False(verdict.IsClear);
+ Assert.False(string.IsNullOrEmpty(verdict.Reason));
+ }
+
+ [Fact]
+ public void OwnMaterialCrossingRejects()
+ {
+ var clean = ExplicitContourTests.Square(false);
+ var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters());
+ var choice = prepared.Entry(0, 0, new Vector(0, 5));
+ // Own snapshot is where the sheet actually is; the placement drifts 0.4 east, so the
+ // emitted external lead (approach x=-0.21..0 relative, entry at x=0.4 absolute)
+ // crosses into the sheet's own material.
+ var own = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
+ var feasibility = new ContourEntryFeasibility(prepared, new Vector(0.4, 0), own, []);
+
+ var verdict = feasibility.Check(choice);
+
+ Assert.Equal(ContourFeasibilityStatus.Blocked, verdict.Status);
+ Assert.False(verdict.IsClear);
+ }
+
+ [Fact]
+ public void IncompleteMaterialNeverReadsAsClear()
+ {
+ var (prepared, choice, _) = PreparedSquare();
+ // Two touching rings: the capture itself refuses, producing an incomplete snapshot.
+ var joined = ExplicitContourTests.Square(false);
+ var touching = new Program();
+ touching.MoveTo(0, 10);
+ touching.LineTo(10, 10); touching.LineTo(10, 20); touching.LineTo(0, 20); touching.LineTo(0, 10);
+ joined.Codes.AddRange(touching.Codes);
+ var incomplete = LeadMaterialSnapshot.Capture(joined, Vector.Zero);
+ Assert.False(incomplete.IsComplete);
+ Assert.NotNull(incomplete.Reason);
+
+ var ownVerdict = new ContourEntryFeasibility(prepared, At, incomplete, []).Check(choice);
+ Assert.Equal(ContourFeasibilityStatus.Incomplete, ownVerdict.Status);
+ var otherVerdict = new ContourEntryFeasibility(prepared, At,
+ LeadMaterialSnapshot.Capture(ExplicitContourTests.Square(false), At), [incomplete]).Check(choice);
+ Assert.Equal(ContourFeasibilityStatus.Incomplete, otherVerdict.Status);
+ }
+
+ [Fact]
+ public void NoLeadInClearVerdictIsNotAPlanApproval()
+ {
+ var parameters = ExplicitContourTests.Parameters();
+ parameters.ExternalLeadIn = new NoLeadIn();
+ parameters.InternalLeadIn = new NoLeadIn();
+ parameters.ArcCircleLeadIn = new NoLeadIn();
+ var clean = ExplicitContourTests.Square(false);
+ var prepared = PreparedContours.Capture(clean, parameters);
+ var choice = prepared.Entry(0, 0, new Vector(0, 5));
+ var feasibility = new ContourEntryFeasibility(prepared, At, LeadMaterialSnapshot.Capture(clean, At), []);
+
+ var verdict = feasibility.Check(choice);
+
+ // Nothing to certify means the validator passes vacuously — the emitted program
+ // carries no lead motion at all, so this verdict certifies no lead and the
+ // complete-plan missing-lead check still has to run later. The adapter must not
+ // silently drop the distinction: consumers can see it is vacuous by counting leads.
+ Assert.True(verdict.IsClear);
+ var motions = ExecutionMotionReader.Read(prepared.Emit(new[] { choice }), At, null, default).Motions;
+ Assert.Empty(motions.Where(m => !m.Rapid
+ && (m.Layer == LayerType.Leadin || m.Layer == LayerType.Leadout)));
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public void ForeignChoiceCannotReuseOrPoisonAnOwnedVerdict(bool foreignFirst, bool unowned)
+ {
+ var (prepared, choice, own) = PreparedSquare();
+ var (_, otherChoice, _) = PreparedSquare();
+ var foreign = unowned
+ ? new ContourChoice(choice.ContourOrdinal, choice.EntityOrdinal, choice.Point)
+ : otherChoice;
+ Assert.Throws(() => prepared.Emit([foreign]));
+ var adapter = new ContourEntryFeasibility(prepared, At, own, []);
+ var cold = new ContourEntryFeasibility(prepared, At, own, []).Check(foreign);
+ Assert.Equal(ContourFeasibilityStatus.Incomplete, cold.Status);
+
+ if (foreignFirst)
+ Assert.Equal(cold, adapter.Check(foreign));
+ else
+ Assert.True(adapter.Check(choice).IsClear);
+
+ var refused = adapter.Check(foreign);
+ Assert.Equal(cold, refused);
+ Assert.Contains("Foreign contour choice", refused.Reason);
+ Assert.True(adapter.Check(choice).IsClear);
+ Assert.True(adapter.Check(choice with { }).IsClear);
+ Assert.Equal(1, adapter.EvaluationCount);
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ Assert.ThrowsAny(() => adapter.Check(foreign, token: cancelled.Token));
+ Assert.True(adapter.Check(choice).IsClear);
+ Assert.Equal(1, adapter.EvaluationCount);
+ }
+
+ [Fact]
+ public void MalformedEmissionRefusesWithReasonInsteadOfCrashing()
+ {
+ var (prepared, choice, own) = PreparedSquare();
+ var (other, _, _) = PreparedSquare();
+ var forgedContour = choice with { Owner = other, ContourOrdinal = 99 };
+ var forgedEntity = choice with { Owner = prepared, EntityOrdinal = 99 };
+ var feasibility = new ContourEntryFeasibility(prepared, At, own, []);
+
+ var verdict = feasibility.Check(forgedContour);
+ Assert.Equal(ContourFeasibilityStatus.Incomplete, verdict.Status);
+ Assert.False(string.IsNullOrEmpty(verdict.Reason));
+ Assert.Equal(ContourFeasibilityStatus.Incomplete, feasibility.Check(forgedEntity).Status);
+ }
+
+ [Fact]
+ public void CancellationPropagatesAndPoisonsNothing()
+ {
+ var (prepared, choice, own) = PreparedSquare();
+ var feasibility = new ContourEntryFeasibility(prepared, At, own, []);
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+
+ Assert.ThrowsAny(() => feasibility.Check(choice, token: cancelled.Token));
+ Assert.Equal(0, feasibility.EvaluationCount);
+ Assert.True(feasibility.Check(choice).IsClear);
+ }
+
+ [Fact]
+ public void SameChoiceAndNodeContextIsCachedWithinTheAttemptOnly()
+ {
+ var (prepared, choice, own) = PreparedSquare();
+ var feasibility = new ContourEntryFeasibility(prepared, At, own, []);
+
+ Assert.True(feasibility.Check(choice, "A").IsClear);
+ Assert.True(feasibility.Check(choice, "A").IsClear);
+ Assert.Equal(1, feasibility.EvaluationCount);
+
+ // A different node context is a different key (the cache is deliberately conservative).
+ Assert.True(feasibility.Check(choice, "B").IsClear);
+ Assert.Equal(2, feasibility.EvaluationCount);
+
+ // Nothing survives into a fresh planning attempt: no static or cross-instance cache.
+ var fresh = new ContourEntryFeasibility(prepared, At, own, []);
+ Assert.True(fresh.Check(choice, "A").IsClear);
+ Assert.Equal(1, fresh.EvaluationCount);
+ }
+
+ [Fact]
+ public void EvaluationIsLazyPerCandidate()
+ {
+ var (prepared, choice, own) = PreparedSquare();
+ var feasibility = new ContourEntryFeasibility(prepared, At, own, []);
+ var other = prepared.Entry(0, 1, new Vector(5, 10));
+
+ // One Check evaluates exactly that candidate — never every point of every contour.
+ Assert.True(feasibility.Check(other).IsClear);
+ Assert.Equal(1, feasibility.EvaluationCount);
+ Assert.NotSame(choice, other);
+ }
+
+ [Fact]
+ public void VerdictsAreTheValidatorsVerdictsExactly()
+ {
+ // Adapter verdict == LeadPathValidator outcome for the same emitted program.
+ var (prepared, choice, own) = PreparedSquare();
+ var materials = new[] { own };
+ var execution = ExecutionMotionReader.Read(
+ prepared.EmitCandidateForValidation(choice), At, null, default);
+ var direct = LeadPathValidator.Check(execution, own, materials, default);
+
+ var verdict = new ContourEntryFeasibility(prepared, At, own, materials).Check(choice);
+
+ Assert.Equal(direct.IsClear, verdict.IsClear);
+ Assert.Equal(direct.Reason, verdict.Reason);
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs
new file mode 100644
index 0000000..b91440e
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs
@@ -0,0 +1,207 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// The pure facing/tier/travel ranking of the entry catalogue: deterministic lexicographic
+/// order toward a look-ahead target (or toward arrival alone for the last part), with the
+/// catalogue itself untouched and entity order never meaningful.
+///
+public class ContourEntryRankingTests
+{
+ private static IReadOnlyList Catalogue(Program program) =>
+ PreparedContours.Capture(program, ExplicitContourTests.Parameters())
+ .AutomaticEntryCandidatesWithFallbacks(0);
+
+ // --- fixtures ---------------------------------------------------------------
+
+ /// Square 0..10 in travel order, optionally cyclically reindexed or reversed.
+ private static Program Square(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;
+ }
+
+ private static readonly Vector[] SquareVertices =
+ {
+ new(0, 0), new(10, 0), new(10, 10), new(0, 10),
+ };
+
+ private static Program BumpSquare()
+ {
+ // Half-circle bump on the top edge; tangent 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);
+ return p;
+ }
+
+ private static bool At(ContourEntryCandidate c, double x, double y) =>
+ c.Choice.Point.DistanceTo(new Vector(x, y)) < 1e-6;
+
+ private static List<(AutomaticEntryKind Kind, double X, double Y)> Shape(IReadOnlyList ranked) =>
+ ranked.Select(c => (c.Kind, System.Math.Round(c.Choice.Point.X, 6), System.Math.Round(c.Choice.Point.Y, 6))).ToList();
+
+ // --- facing target ------------------------------------------------------------
+
+ [Fact]
+ public void LowerRightTarget_PrefersTheBottomRightCorner()
+ {
+ var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(new Vector(14, -2));
+
+ // Facing sides right and bottom; the shared corner is the ideal start.
+ var first = Assert.Single(ranked.Take(1));
+ Assert.Equal(AutomaticEntryKind.ConvexCorner, first.Kind);
+ Assert.True(At(first, 10, 0));
+ // It precedes every other corner and midpoint.
+ Assert.True(ranked.ToList().FindIndex(c => At(c, 10, 0)) < ranked.ToList().FindIndex(c => At(c, 0, 10)));
+ Assert.True(ranked.ToList().FindIndex(c => At(c, 10, 0)) < ranked.ToList().FindIndex(c => At(c, 5, 0)));
+ }
+
+ [Fact]
+ public void FacingMidpoint_BeatsANonFacingCorner()
+ {
+ // Target due right: only the right side faces it; the left corners face away.
+ var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(new Vector(14, 5));
+ var order = ranked.ToList();
+
+ var rightMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
+ var farCorner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 10));
+ Assert.True(rightMid < farCorner);
+ }
+
+ [Fact]
+ public void SameFacingClass_CornerBeatsMidpoint()
+ {
+ var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(new Vector(14, -2));
+ var order = ranked.ToList();
+
+ var corner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 10, 0));
+ var bottomMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 5, 0));
+ var rightMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
+ // The shared facing corner beats both facing midpoints; tier breaks the facing tie.
+ Assert.True(corner < bottomMid && corner < rightMid);
+ // Between the two facing midpoints travel decides: right-mid is nearer the target.
+ Assert.True(rightMid < bottomMid);
+ }
+
+ [Fact]
+ public void TangentAndMidpoint_ShareARankTier()
+ {
+ Assert.Equal(AutomaticEntryKind.StraightMidpoint.RankTier(), AutomaticEntryKind.TangentJoint.RankTier());
+ Assert.True(AutomaticEntryKind.ConvexCorner.RankTier() < AutomaticEntryKind.TangentJoint.RankTier());
+ Assert.True(AutomaticEntryKind.TangentJoint.RankTier() < AutomaticEntryKind.NearCorner.RankTier());
+ Assert.True(AutomaticEntryKind.NearCorner.RankTier() <= AutomaticEntryKind.TargetFacing.RankTier());
+ Assert.Equal(AutomaticEntryKind.NearCorner.RankTier(), AutomaticEntryKind.CircleCompass.RankTier());
+
+ // Facing still dominates tier: a joint on both facing sides outranks a non-facing corner.
+ var ranked = Catalogue(BumpSquare()).RankTowardNextCut(new Vector(14, 14));
+ var joint = Assert.Single(ranked.Where(c => c.Kind == AutomaticEntryKind.TangentJoint && At(c, 10, 10)));
+ Assert.True(ranked.ToList().IndexOf(joint)
+ < ranked.ToList().FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 0)));
+ }
+
+ // --- stability ------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void ReversedOrReindexedDrawing_SameGeometricOrdering(bool reversed)
+ {
+ // Same square geometry, different entity travel order: entity order must never be
+ // the meaningful tie-break — the geometric ordering is identical.
+ var vertices = reversed
+ ? new[] { SquareVertices[3], SquareVertices[2], SquareVertices[1], SquareVertices[0] }
+ : new[] { SquareVertices[1], SquareVertices[2], SquareVertices[3], SquareVertices[0] };
+ var target = new Vector(14, -2);
+
+ Assert.Equal(
+ Shape(Catalogue(Square(SquareVertices)).RankTowardNextCut(target, new Vector(-1, -1))),
+ Shape(Catalogue(Square(vertices)).RankTowardNextCut(target, new Vector(-1, -1))));
+ }
+
+ [Fact]
+ public void InputCatalogue_IsNotMutated_AndNothingIsIntroduced()
+ {
+ var catalogue = Catalogue(BumpSquare());
+ var before = Shape(catalogue);
+ var snapshot = catalogue.ToList();
+
+ var ranked = catalogue.RankTowardNextCut(new Vector(14, -2), new Vector(-1, -1));
+
+ Assert.Equal(before, Shape(snapshot)); // input untouched
+ Assert.Equal(catalogue.Count, ranked.Count); // permutation only
+ Assert.True(ranked.All(catalogue.Contains));
+ }
+
+ [Fact]
+ public void Ranking_NeverIntroducesReflexCandidates()
+ {
+ // L-outline: (5,5) is reflex for its own travel and absent from the catalogue.
+ var p = 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),
+ });
+
+ foreach (var target in new[] { new Vector(14, -2), new Vector(-4, 14), new Vector(14, 14) })
+ {
+ var ranked = Catalogue(p).RankTowardNextCut(target);
+ Assert.DoesNotContain(ranked, c => At(c, 5, 5));
+ }
+ }
+
+ [Fact]
+ public void NonFiniteTarget_IsRejected()
+ {
+ var catalogue = Catalogue(Square(SquareVertices));
+ Assert.Throws(() =>
+ catalogue.RankTowardNextCut(new Vector(double.NaN, 5)));
+ }
+
+ // --- no target (last part) --------------------------------------------------------
+
+ [Fact]
+ public void NoTarget_TierFirstThenDistanceToArrival()
+ {
+ var ranked = Catalogue(Square(SquareVertices)).RankTowardNextCut(arrival: new Vector(5, 10));
+ var order = ranked.ToList();
+
+ // Tier first: an outside corner outranks the midpoint the arrival sits on.
+ var firstCorner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 10));
+ var touchedMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 5, 10));
+ Assert.True(firstCorner < touchedMid);
+ Assert.Equal(AutomaticEntryKind.ConvexCorner, order[0].Kind);
+ // Within corners, distance to arrival decides (tie broken by stable key): (0,10) and
+ // (10,10) are equidistant from (5,10), so the lower X key wins.
+ Assert.True(At(order[0], 0, 10));
+ }
+
+ [Theory]
+ [InlineData(0.1, 0.1, 0.0, 0.0)]
+ [InlineData(9.9, 9.9, 10.0, 10.0)]
+ public void NoTarget_StartsNearTheArrival_NotBackAtTheOrigin(double arrivalX, double arrivalY, double expectedX, double expectedY)
+ {
+ // The last part faces where the head already is; the ranking must not drag it to
+ // the plate origin when the arrival is elsewhere.
+ var ranked = Catalogue(Square(SquareVertices))
+ .RankTowardNextCut(arrival: new Vector(arrivalX, arrivalY));
+
+ Assert.True(At(ranked[0], expectedX, expectedY));
+ }
+
+ [Fact]
+ public void EmptyCatalogue_RanksToEmpty()
+ {
+ var empty = new List();
+ Assert.Empty(empty.RankTowardNextCut(new Vector(3, 4), new Vector(-1, -1)));
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs
new file mode 100644
index 0000000..d3e0577
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// The bounded, lazy selection over ranked candidates and the S07 verdict: cap first, side
+/// coverage as a corrective scan, honest shortfall metadata — and the untouched tail is
+/// never evaluated. Counts, not elapsed time.
+///
+public class ContourEntrySelectionTests
+{
+ private static readonly PreparedContours Owner =
+ PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters());
+
+ private static ContourEntryCandidate Cand(double x, double y, AutomaticEntryKind kind = AutomaticEntryKind.ConvexCorner) =>
+ new(new ContourChoice(0, 0, new Vector(x, y)) { Owner = Owner }, kind);
+
+ private static Func Feasible(params (double X, double Y)[] clearAt)
+ {
+ var set = new HashSet<(long, long)>(clearAt.Select(p => Cand(p.X, p.Y).GeometryKey));
+ return c => set.Contains(c.GeometryKey)
+ ? new(ContourFeasibilityStatus.Clear, null)
+ : new(ContourFeasibilityStatus.Blocked, "test block");
+ }
+
+ private static Func AllClear() =>
+ _ => new(ContourFeasibilityStatus.Clear, null);
+
+ private static bool Selected(ContourSelectionResult result, double x, double y) =>
+ result.Choices.Any(c => c.Point.DistanceTo(new Vector(x, y)) < 1e-9);
+
+ [Fact]
+ public void LaterSafeCandidateSurvivesTwentyRejectedRivals()
+ {
+ // 18 blocked facing candidates cannot crowd out the one safe point at the tail.
+ var candidates = new List();
+ for (var i = 0; i < 18; i++)
+ candidates.Add(Cand(i * 0.001, 0));
+ candidates.Add(Cand(100, 100));
+
+ var result = ContourEntrySelection.Select(candidates, Feasible((100, 100)));
+
+ Assert.Single(result.Choices);
+ Assert.True(Selected(result, 100, 100));
+ Assert.Equal(19, result.EvaluatedCount);
+ Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
+ }
+
+ [Fact]
+ public void EveryFeasibleSideIsRepresented()
+ {
+ // Corners on left/bottom and right/bottom, plus one point each on top-left and
+ // top-right; a wide tail of blocked points so coverage must chase the sides lazily.
+ var candidates = new List
+ {
+ Cand(0, 0), Cand(10, 0), // bottom corners
+ Cand(0, 10, AutomaticEntryKind.StraightMidpoint), // top-left
+ Cand(10, 10, AutomaticEntryKind.TangentJoint), // top-right
+ };
+ for (var i = 1; i < 30; i++)
+ candidates.Add(Cand(i * 0.3, 0, AutomaticEntryKind.NearCorner)); // blocked bottom tail
+
+ var result = ContourEntrySelection.Select(candidates,
+ Feasible((0, 0), (10, 0), (0, 10), (10, 10)), maxEntries: 6);
+
+ Assert.Equal(4, result.Choices.Count);
+ Assert.True(Selected(result, 0, 10));
+ Assert.True(Selected(result, 10, 10));
+ // The blocked tail was chased to its end exactly once per missing side scan.
+ Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
+ }
+
+ [Fact]
+ public void BlockedSideIsOmittedWithoutManufacturingPoints()
+ {
+ // No feasible candidate touches the top side.
+ var candidates = new List
+ {
+ Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
+ };
+
+ var result = ContourEntrySelection.Select(candidates, Feasible((0, 0), (10, 0)));
+
+ Assert.Equal(2, result.Choices.Count);
+ Assert.False(Selected(result, 0, 10));
+ Assert.False(Selected(result, 10, 10));
+ // Every selected choice was feasible: nothing was manufactured.
+ Assert.All(result.Choices, c => Assert.InRange(c.Point.Y, 0, 0));
+ }
+
+ [Fact]
+ public void CapIsNeverExceededAndRankingComesFirst()
+ {
+ var candidates = new List();
+ for (var i = 0; i < 10; i++)
+ candidates.Add(Cand(10 - i * 0.01, 0)); // first-ranked points on the right/bottom
+
+ var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3);
+
+ Assert.Equal(3, result.Choices.Count);
+ Assert.Equal(3, result.EvaluatedCount); // stopped the moment the cap filled
+ Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
+ Assert.Equal(10.0, result.Choices[0].Point.X, 6); // rank-first point kept
+ }
+
+ [Fact]
+ public void NormalCaseNeverEvaluatesTheUntouchedTail()
+ {
+ // Four corners first (all sides covered), then a long clear tail. The cap fills at
+ // 16 with full coverage, so the chase runs empty and the tail stays untouched.
+ var candidates = new List
+ {
+ Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
+ };
+ for (var i = 0; i < 36; i++)
+ candidates.Add(Cand(1 + i * 0.1, 1));
+
+ var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 16);
+
+ Assert.Equal(16, result.Choices.Count);
+ Assert.Equal(16, result.EvaluatedCount);
+ Assert.DoesNotContain(candidates[16].GeometryKey, result.EvaluatedKeys);
+ Assert.DoesNotContain(candidates[^1].GeometryKey, result.EvaluatedKeys);
+ Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
+ }
+
+ [Fact]
+ public void AllBlockedIsExhaustedGeometricImpossibility()
+ {
+ var candidates = new[] { Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10) };
+
+ var result = ContourEntrySelection.Select(candidates,
+ _ => new(ContourFeasibilityStatus.Blocked, "test block"));
+
+ Assert.Empty(result.Choices);
+ Assert.Equal(4, result.EvaluatedCount);
+ Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
+ Assert.Contains("No tested lead-in fits", result.Reason);
+ }
+
+ [Fact]
+ public void IncompleteCheckIsNeverGeometricImpossibility()
+ {
+ var candidates = new[] { Cand(0, 0), Cand(10, 0) };
+ var first = true;
+
+ var result = ContourEntrySelection.Select(candidates, _ =>
+ {
+ ContourFeasibilityVerdict verdict = first
+ ? new(ContourFeasibilityStatus.Incomplete, "incomplete material")
+ : new(ContourFeasibilityStatus.Clear, null);
+ first = false;
+ return verdict;
+ });
+
+ Assert.Equal(ContourSelectionShortfall.Incomplete, result.Shortfall);
+ Assert.DoesNotContain("No tested lead-in fits", result.Reason ?? string.Empty);
+ Assert.Contains("not a geometric verdict", result.Reason);
+ }
+
+ [Fact]
+ public void SmallCapsObeyTheCapNotCoverage()
+ {
+ var candidates = new List
+ {
+ Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10),
+ };
+
+ var cap1 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 1);
+ var cap3 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3);
+
+ Assert.Single(cap1.Choices);
+ Assert.Equal(3, cap3.Choices.Count);
+ Assert.Equal(1, cap1.EvaluatedCount); // cap fills before any coverage scan
+ Assert.Equal(3, cap3.EvaluatedCount);
+ }
+
+ [Fact]
+ public void InvalidCapStaysInvalid()
+ {
+ Assert.Throws(() =>
+ ContourEntrySelection.Select(Array.Empty(), _ => null!, 0));
+ Assert.Throws(() =>
+ ContourEntrySelection.Select(Array.Empty(), _ => null!, -1));
+ }
+
+ [Fact]
+ public void DuplicatePointsNeverConsumeTwoSlots()
+ {
+ var candidates = new[] { Cand(5, 5), Cand(5, 5), Cand(5, 5) };
+
+ var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 2);
+
+ Assert.Single(result.Choices);
+ Assert.Equal(1, result.EvaluatedCount); // deduplicated before evaluation
+ }
+
+ [Fact]
+ public void EmptyCatalogueIsExhaustedWithoutEvaluation()
+ {
+ var result = ContourEntrySelection.Select(Array.Empty(), _ => null!);
+
+ Assert.Empty(result.Choices);
+ Assert.Equal(0, result.EvaluatedCount);
+ Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall);
+ }
+
+ [Fact]
+ public void CancellationStopsBeforeAnyEvaluation()
+ {
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ var candidates = new[] { Cand(0, 0) };
+
+ Assert.ThrowsAny(() =>
+ ContourEntrySelection.Select(candidates, _ => new(ContourFeasibilityStatus.Clear, null),
+ token: cancelled.Token));
+ }
+
+ [Fact]
+ public void CoverageReplacementDropsTheWorstAndResorts()
+ {
+ // Cap 5 fills on points covering left/bottom/top; the right side is chased to (6,6),
+ // which must displace the worst selected point whose removal preserves the others
+ // ((3,4), the top-only cover is re-covered by (6,6)) and settle at its true rank slot.
+ var candidates = new List
+ {
+ Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(0, 4), Cand(3, 4), Cand(6, 6),
+ };
+
+ var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 5);
+
+ Assert.Equal(5, result.Choices.Count);
+ Assert.True(Selected(result, 6, 6));
+ Assert.False(Selected(result, 3, 4));
+ Assert.Equal(6, result.EvaluatedCount);
+ // Evaluation order IS global rank order; the late chaser sorts to the end.
+ Assert.Equal(6, result.Choices[^1].Point.X);
+ Assert.Equal(0, result.Choices[0].Point.X);
+ Assert.Equal(0, result.Choices[0].Point.Y);
+ }
+
+ [Fact]
+ public void CoverageNeverDestroysTheOnlyCoverOfAnotherSide()
+ {
+ // (2,0) is the ONLY cover of bottom; chasing the right side must not remove it even
+ // though it outranks nothing. The chaser replaces (2,4) (top, re-covered by (6,6)).
+ var candidates = new List
+ {
+ Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(2, 4), Cand(6, 6),
+ };
+
+ var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 4);
+
+ // Cap 4: phase 1 keeps the first four (right not covered: (2,4) left+top only).
+ Assert.True(Selected(result, 2, 0)); // sole bottom cover survives
+ Assert.True(Selected(result, 6, 6)); // right-side chaser placed
+ Assert.False(Selected(result, 2, 4)); // safely displaced (top re-covered)
+ Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs
new file mode 100644
index 0000000..692a975
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// The pure hole-path proposal: an OPEN path arrival -> every hole exactly once -> the fixed
+/// perimeter entry, reusing the bounded whole-part routing machinery. Optimum comparisons
+/// are computed from coordinates by brute force in the test — never hand-estimated.
+///
+public class CuttingHoleOrderTests
+{
+ private static readonly Vector Arrival = new(0, 0);
+
+ // Asymmetric fixture: without the terminal edge the route should end at C (the far
+ // cluster point); with the perimeter entry at (6,0) the route must end at B instead,
+ // so the endpoint changes the ORDER, not merely the total.
+ private static readonly Vector A = new(1, 0);
+ private static readonly Vector B = new(5, 0);
+ private static readonly Vector C = new(3, 10);
+ private static readonly Vector Entry = new(6, 0);
+
+ private static double Cost(IReadOnlyList route, IReadOnlyList centres,
+ Vector arrival, Vector? endpoint)
+ {
+ var total = 0.0;
+ var position = arrival;
+ foreach (var hole in route)
+ {
+ total += position.DistanceTo(centres[hole]);
+ position = centres[hole];
+ }
+ return endpoint is { } e ? total + position.DistanceTo(e) : total;
+ }
+
+ private static double Minimum(IReadOnlyList holes, IReadOnlyList centres,
+ Vector arrival, Vector? endpoint)
+ {
+ double Best(IEnumerable remaining, Vector from, double soFar)
+ {
+ if (!remaining.Any())
+ return soFar + (endpoint is { } e ? from.DistanceTo(e) : 0);
+ return remaining.Min(next => Best(remaining.Where(h => h != next),
+ centres[next], soFar + from.DistanceTo(centres[next])));
+ }
+ return Best(holes, arrival, 0);
+ }
+
+ [Fact]
+ public void EmptyHoleListReturnsEmptyOrder()
+ {
+ Assert.Empty(CuttingHoleOrder.Plan(Array.Empty(), new[] { Arrival }, Arrival, Entry));
+ }
+
+ [Fact]
+ public void SingleHoleIsTheOnlyOrder()
+ {
+ var centres = new[] { A };
+ var order = CuttingHoleOrder.Plan(new[] { 0 }, centres, Arrival, Entry);
+ Assert.Equal(new[] { 0 }, order);
+ }
+
+ [Fact]
+ public void PerimeterEntryChangesTheRouteWhereBruteForceSaysItShould()
+ {
+ var centres = new[] { A, B, C };
+ var holes = new[] { 0, 1, 2 };
+
+ var open = CuttingPartOrder.Plan(centres, Arrival,
+ holes.Select(_ => (IReadOnlyCollection)Array.Empty()).ToList(), default);
+ var faced = CuttingHoleOrder.Plan(holes, centres, Arrival, Entry);
+
+ // The endpoint genuinely changes the proposal: the open path ends at C (away from
+ // the entry), the faced path ends at B (next to the entry).
+ Assert.Equal(2, open[^1]);
+ Assert.Equal(1, faced[^1]);
+
+ // Each is the brute-force optimum for its own cost function (computed here).
+ Assert.Equal(Minimum(holes, centres, Arrival, null), Cost(open, centres, Arrival, null), 9);
+ Assert.Equal(Minimum(holes, centres, Arrival, Entry), Cost(faced, centres, Arrival, Entry), 9);
+ }
+
+ [Fact]
+ public void EveryRemainingHoleAppearsExactlyOnceAndNeverThePerimeter()
+ {
+ // Ordinals are the part's holes; the perimeter (say 7) is not among them.
+ var centres = new[] { A, B, C, new Vector(-4, 2), new Vector(2, -3), new Vector(8, 8),
+ new Vector(0, 6), new Vector(6, -6) };
+ var holes = new[] { 0, 1, 2, 3, 4, 5, 6 };
+
+ var order = CuttingHoleOrder.Plan(holes, centres, Arrival, Entry);
+
+ Assert.Equal(holes.Length, order.Count);
+ Assert.Equal(holes.OrderBy(h => h), order.OrderBy(h => h));
+ Assert.DoesNotContain(7, order); // the perimeter ordinal is never visited
+ }
+
+ [Fact]
+ public void OptimalForRandomSetAgainstBruteForce()
+ {
+ var random = new Random(20261006);
+ var centres = Enumerable.Range(0, 7)
+ .Select(_ => new Vector(random.NextDouble() * 20 - 10, random.NextDouble() * 20 - 10))
+ .ToList();
+ var entry = new Vector(12.5, -11.25);
+ var holes = Enumerable.Range(0, 7).ToArray();
+
+ var order = CuttingHoleOrder.Plan(holes, centres, Arrival, entry);
+
+ // The bounded heuristic may not always equal the true optimum, so assert it is no
+ // worse than the nearest-neighbour baseline and within 1.05x brute force.
+ var brute = Minimum(holes, centres, Arrival, entry);
+ Assert.True(Cost(order, centres, Arrival, entry) <= brute * 1.05 + 1e-9,
+ $"proposed {Cost(order, centres, Arrival, entry):F4} > 1.05 x brute {brute:F4}");
+ }
+
+ [Fact]
+ public void TiesBreakByStableOrdinal()
+ {
+ // Exact-distance pair from the arrival: nearest-neighbour takes the lower ordinal,
+ // and strict-improvement-only passes never swap an equal-cost order arbitrarily.
+ var centres = new[] { new Vector(5, 0), new Vector(-5, 0) };
+ var noEndpoint = Array.Empty();
+
+ var first = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, new Vector(0, 100));
+ var second = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, new Vector(0, 100));
+
+ Assert.Equal(new[] { 0, 1 }, first);
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public void CancellationPropagates()
+ {
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ var centres = new[] { A, B, C };
+
+ Assert.ThrowsAny(() =>
+ CuttingHoleOrder.Plan(new[] { 0, 1, 2 }, centres, Arrival, Entry, cancelled.Token));
+ }
+
+ [Fact]
+ public void NoEndpointMatchesLegacyWholePartRouteExactly()
+ {
+ // The whole-part overload without an endpoint must be untouched by the extension.
+ var random = new Random(7);
+ var centres = Enumerable.Range(0, 8)
+ .Select(_ => new Vector(random.NextDouble() * 30, random.NextDouble() * 30))
+ .ToList();
+ var none = Array.Empty();
+ var prerequisites = centres.Select(_ => (IReadOnlyCollection)none).ToList();
+
+ var legacy = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default);
+ var extended = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default, endpoint: null);
+
+ Assert.Equal(legacy, extended);
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
new file mode 100644
index 0000000..5992e23
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
@@ -0,0 +1,151 @@
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class CuttingPlanMessageTests
+{
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public void Describe_MissingOrZeroLengthLead_ExplainsSettingsWithoutBlamingSearch(bool keepOrder, bool zeroLength)
+ {
+ var part = new Part(new Drawing("sample", ExplicitContourTests.Square(false)), new Vector(1, 1));
+ var plate = new Nest().CreatePlate();
+ plate.Size = new Size(100, 100);
+ plate.Parts.Add(part);
+ var program = part.Program;
+ var original = OwnedProgramCopy.Copy(program);
+ var settings = new CuttingParameters();
+ if (zeroLength)
+ settings.ExternalLeadIn = new LineLeadIn { Length = 0, ApproachAngle = 90 };
+
+ var proposal = CuttingPlanBatch.Capture([plate], settings, keepOrder).Plan();
+
+ Assert.False(proposal.CanApply);
+ var result = Assert.Single(proposal.Plates).Result;
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
+ Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
+ var text = string.Join("\n", proposal.Describe("in"));
+ Assert.Contains("Plate 1: blocked: missing or zero-length lead-in.", text);
+ Assert.Contains("Open Cutting Settings...", text);
+ Assert.Contains("other than None", text);
+ Assert.Contains("nonzero length", text);
+ Assert.Contains("Part 1 (sample):", text);
+ Assert.Contains("Cutting contour 1", text);
+ Assert.DoesNotContain("search limit", text);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ Assert.Same(program, part.Program);
+ Assert.True(ProgramContent.Equal(original, part.Program));
+ Assert.Null(plate.CuttingParameters);
+ Assert.False(part.HasManualLeadIns);
+
+ // Choosing valid settings fixes the refusal; reporting never changes settings or bypasses checks.
+ var ready = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan();
+ Assert.True(ready.CanApply, string.Join("\n", ready.Describe("in")));
+ Assert.True(Assert.Single(ready.Plates).Result.IndependentlyReplayed);
+ Assert.DoesNotContain("missing or zero-length", string.Join("\n", ready.Describe("in")));
+ Assert.Same(program, part.Program);
+ Assert.True(ProgramContent.Equal(original, part.Program));
+ }
+
+ [Fact]
+ public void Describe_LockedProgramWithoutLead_ExplainsThatSettingsCannotRegenerateIt()
+ {
+ var part = new Part(new Drawing("locked sample", ExplicitContourTests.Square(false)), new Vector(1, 1))
+ {
+ LeadInsLocked = true,
+ };
+ var plate = new Nest().CreatePlate();
+ plate.Size = new Size(100, 100);
+ plate.Parts.Add(part);
+ var program = part.Program;
+
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
+
+ Assert.Equal(CuttingPlanStatus.ConstraintConflict, Assert.Single(proposal.Plates).Result.Status);
+ Assert.False(proposal.CanApply);
+ var text = string.Join("\n", proposal.Describe("in"));
+ Assert.Contains("missing or zero-length lead-in", text);
+ Assert.Contains("If the affected part is locked, edit its lead-ins or unlock it before replanning.", text);
+ Assert.Contains("Part 1 (locked sample):", text);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ Assert.True(part.LeadInsLocked);
+ Assert.Same(program, part.Program);
+ }
+
+ [Fact]
+ public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply()
+ {
+ var settings = ExplicitContourTests.Parameters();
+ var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
+ var prepared = PreparedContours.Capture(clean, settings);
+ var part = new Part(new Drawing("lead blocked", clean));
+ Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(-1, 5))]), true));
+ var obstacle = new Part(new Drawing("neighbour", LeadPathValidationTests.Rectangle(-0.3, 4.5, -0.1, 5.5)));
+ var plate = new Nest().CreatePlate();
+ plate.Parts.Add(part);
+ plate.Parts.Add(obstacle);
+ var original = OwnedProgramCopy.Copy(part.Program);
+
+ var proposal = CuttingPlanBatch.Capture([plate], settings, true).Plan();
+ var text = string.Join("\n", proposal.Describe("in"));
+
+ Assert.False(proposal.CanApply);
+ Assert.Contains("another placed material", text);
+ Assert.Contains("spacing the parts farther apart", text);
+ Assert.Contains("reducing the lead-in", text);
+ Assert.Contains("then replan", text);
+ Assert.DoesNotContain("other than None", text);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ Assert.True(ProgramContent.Equal(original, part.Program));
+ Assert.True(part.LeadInsLocked);
+ }
+
+ [Fact]
+ public void Describe_NoTestedLeadFits_SuggestsChangesButDoesNotClaimTheyWillWork()
+ {
+ var settings = ExplicitContourTests.Parameters();
+ var part = new Part(new Drawing("inside", LeadPathValidationTests.Rectangle(0, 0, 10, 10)));
+ var wrap = new Part(new Drawing("wrap", LeadPathValidationTests.Rectangle(-0.05, -0.05, 10.05, 10.05)));
+ var plate = new Nest().CreatePlate();
+ plate.Parts.Add(part);
+ plate.Parts.Add(wrap);
+
+ var proposal = CuttingPlanBatch.Capture([plate], settings, false).Plan();
+ var text = string.Join("\n", proposal.Describe("in"));
+
+ Assert.Contains("No tested lead-in fits", text);
+ Assert.Contains("Try reducing the lead-in length", text);
+ Assert.Contains("if nearby parts obstruct the lead-in, space the parts farther apart", text);
+ Assert.Contains("Replan to check the changes.", text);
+ Assert.False(proposal.CanApply);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData(PostVerificationKind.RapidCrossing)]
+ public void Describe_OtherRefusals_KeepTheirStatusAndFindings(PostVerificationKind? kind)
+ {
+ // The presentation uses the typed finding, not matching words in a diagnostic.
+ var finding = new CuttingPlanFinding(null, null, null, null, kind, "MissingLeadIn lookalike text");
+ var result = new CuttingPlanResult(CuttingPlanStatus.NoSolutionWithinBudget, findings: [finding]);
+ var proposal = new CuttingPlanProposal([new(new Nest().CreatePlate(), 7, result, result, null)],
+ ExplicitContourTests.Parameters());
+
+ var text = string.Join("\n", proposal.Describe("in"));
+
+ Assert.Contains("Plate 7: no complete plan was found within the search limit.", text);
+ Assert.Contains("planning with the current order was refused:", text);
+ Assert.Contains(finding.Message, text);
+ Assert.DoesNotContain("Open Cutting Settings...", text);
+ Assert.DoesNotContain("missing or zero-length lead-in", text);
+ Assert.False(proposal.CanApply);
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs
new file mode 100644
index 0000000..b188e2c
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs
@@ -0,0 +1,85 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class HoleEndpointBudgetTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void LaterFixedApproachTriesOtherEndpointsBeforeHoleCombinations(bool preserveOrder)
+ {
+ var parameters = HoleLookAheadTests.Parameters();
+ var clean = HoleLookAheadTests.MixedHoles();
+ foreach (var centre in new[] { new Vector(4, 8), new Vector(8, 8) })
+ {
+ clean.MoveTo(centre.X + 0.75, centre.Y);
+ clean.ArcTo(centre.X + 0.75, centre.Y, centre.X, centre.Y, RotationType.CCW);
+ }
+ var first = new Part(new Drawing("four holes", clean));
+ var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
+ nextClean.MoveTo(-22, 10);
+ nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe });
+ nextClean.MoveTo(0, 10);
+ nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe });
+ var next = new Part(new Drawing("fixed marked part", nextClean), new Vector(20, 2));
+ var nextPrepared = PreparedContours.Capture(nextClean, parameters);
+ Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit(
+ [nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true));
+ var before = new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray();
+ var start = new Vector(-2, 4);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], start,
+ confirmedParameters: parameters, preservePartOrder: preserveOrder));
+ var source = snapshot.Placements[0];
+ var prepared = source.Prepared;
+ var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material,
+ snapshot.Placements.Select(p => p.Material).ToArray());
+ var target = new Vector(21, 3);
+ var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(4, target)
+ .RankTowardNextCut(target, start), c => adapter.Check(c.Choice)).Choices[0];
+ var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray();
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start,
+ PreferredContourEntries.Pierce(prepared, outside, default));
+ var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start,
+ c => adapter.Check(c.Choice));
+ Assert.True(preferred.IsPreferred, preferred.Reason);
+ var execution = ExecutionMotionReader.Read(prepared.Emit(preferred.HoleChoices.Append(outside).ToArray()),
+ Vector.Zero, start, default);
+ var checker = new ReleasedContourState();
+ Assert.Empty(checker.Check(execution, start, 1));
+ Assert.Contains(checker.Check(snapshot.Placements[1].Execution, execution.DeparturePoint, 2),
+ f => f.Kind == PostVerificationKind.RapidCrossing);
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready,
+ $"{result.Status}, {result.Expansions} expansions: " + string.Join("; ", result.Findings.Select(f => f.Message)));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
+ Assert.Equal(5, result.ProposedOrder[0].ContourChoices.Count);
+ Assert.Equal(4, result.ProposedOrder[0].ContourChoices[^1].ContourOrdinal);
+ Assert.NotEqual(outside.Point, result.ProposedOrder[0].ContourChoices[^1].Point);
+ Assert.InRange(result.Expansions, 1, 20000);
+ var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
+ Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
+ Assert.True(replay.IndependentlyReplayed);
+ Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
+ Assert.Equal(before[1], ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram()));
+
+ var bounded = CuttingPlanService.Plan(new CuttingPlanRequest([first, next], start,
+ expansionBudget: 1, confirmedParameters: parameters, preservePartOrder: preserveOrder));
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
+ Assert.Equal(1, bounded.Expansions);
+ Assert.Empty(bounded.ProposedOrder);
+ Assert.False(bounded.IndependentlyReplayed);
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancelled.Token).Status);
+ Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs
new file mode 100644
index 0000000..4b01bd8
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs
@@ -0,0 +1,215 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class HoleLookAheadTests
+{
+ private static readonly Vector Start = new(-2, 4);
+
+ internal static Program MixedHoles()
+ {
+ var clean = LeadPathValidationTests.Rectangle(0, 0, 12, 10);
+ clean.MoveTo(3, 3);
+ clean.LineTo(5, 3); clean.LineTo(5, 5); clean.LineTo(3, 5); clean.LineTo(3, 3);
+ clean.MoveTo(9, 4);
+ clean.ArcTo(9, 4, 8, 4, RotationType.CCW);
+ return clean;
+ }
+
+ internal static CuttingParameters Parameters()
+ {
+ var p = ExplicitContourTests.Parameters();
+ p.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
+ p.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
+ p.PierceClearance = 0;
+ return p;
+ }
+
+ [Fact]
+ public void MixedHolesFaceTheNextContourAndRectanglesUseFeasibleCorners()
+ {
+ var parameters = Parameters();
+ var part = new Part(new Drawing("mixed holes", MixedHoles()));
+ var next = new Part(new Drawing("next", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), new Vector(20, 2));
+ var fingerprint = ExplicitContourTests.Fingerprint(part.Program);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, next], Start,
+ confirmedParameters: parameters, preservePartOrder: true));
+ var result = CuttingPlanService.Plan(snapshot);
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ var proposal = result.ProposedOrder[0];
+ Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal));
+ var rectangle = proposal.ContourChoices[0].Point;
+ Assert.True(rectangle.X > 4.9 && (rectangle.Y < 3.1 || rectangle.Y > 4.9),
+ $"Expected a right-facing rectangle corner, got {rectangle}");
+ Assert.True(proposal.ContourChoices[1].Point.X > 8, "Circle must finish toward the outside start.");
+ Assert.True(proposal.ContourChoices[^1].Point.X > 11.9, "Outside start must face the next part.");
+ var runs = CutRuns(proposal.Execution);
+ Assert.Equal(3, runs.Count);
+ for (var i = 0; i < runs.Count - 1; i++)
+ Assert.True(runs[i].End.X < runs[i + 1].Pierce.X, "Actual emitted cuts must flow rightward.");
+ Assert.Empty(new ReleasedContourState().Check(proposal.Execution, Start, 1));
+ Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program));
+ }
+
+ [Fact]
+ public void CrossingPreferredHolePathBacktracksToACheckedAlternative()
+ {
+ // Calibrated for next-facing circle entries: the preferred rapid from hole 1
+ // to hole 2 touches already-cut hole 3; retain checked entry backtracking.
+ var centres = new[] { new Vector(3, 3), new Vector(6, 15), new Vector(3, 7), new Vector(3, 11) };
+ var clean = LeadPathValidationTests.Rectangle(0, 0, 18, 18);
+ foreach (var c in centres)
+ {
+ clean.MoveTo(c.X + 1.75, c.Y);
+ clean.ArcTo(c.X + 1.75, c.Y, c.X, c.Y, RotationType.CCW);
+ }
+ var part = new Part(new Drawing("crossing preference", clean));
+ var unchanged = ExplicitContourTests.Fingerprint(part.Program);
+ var start = new Vector(-2, 9);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], start,
+ confirmedParameters: Parameters(), preservePartOrder: true));
+ var source = snapshot.Placements[0];
+ var prepared = source.Prepared;
+ var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, []);
+ var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(4)
+ .RankTowardNextCut(null, start), c => adapter.Check(c.Choice)).Choices[0];
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start,
+ PreferredContourEntries.Pierce(prepared, outside, default));
+ var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start,
+ c => adapter.Check(c.Choice));
+ Assert.True(preferred.IsPreferred, preferred.Reason);
+ var unsafeChoices = preferred.HoleChoices.Append(outside).ToArray();
+ var unsafeProgram = prepared.Emit(unsafeChoices);
+ var unsafeExecution = ExecutionMotionReader.Read(unsafeProgram, Vector.Zero, start, default);
+ Assert.Contains(new ReleasedContourState().Check(unsafeExecution, start, 1),
+ f => f.Kind == PostVerificationKind.RapidCrossing);
+ var rejected = CuttingPlanService.ReplayPrograms(snapshot,
+ [source.Propose(unsafeProgram, unsafeExecution, unsafeChoices)], 0, default);
+ Assert.NotEqual(CuttingPlanStatus.Ready, rejected.Status);
+ Assert.False(rejected.IndependentlyReplayed);
+
+ var result = CuttingPlanService.Plan(snapshot);
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ var actual = result.ProposedOrder[0];
+ Assert.Equal(outside.Point, actual.ContourChoices[^1].Point);
+ Assert.Equal(route, actual.ContourChoices.Take(4).Select(c => c.ContourOrdinal));
+ Assert.NotEqual(unsafeChoices.Single(c => c.ContourOrdinal == 2).Point,
+ actual.ContourChoices.Single(c => c.ContourOrdinal == 2).Point);
+ Assert.Empty(new ReleasedContourState().Check(actual.Execution, start, 1));
+ Assert.Equal(unchanged, ExplicitContourTests.Fingerprint(part.Program));
+ }
+
+ [Fact]
+ public void LaterLockedPartForcesNewPerimeterAndRecomputedHolePreference()
+ {
+ var parameters = Parameters();
+ var first = new Part(new Drawing("holes", MixedHoles()));
+ var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
+ // The fixed next program starts above-left, then moves above the first part before
+ // cutting on the right. Its material centre alone cannot predict that approach.
+ nextClean.MoveTo(-22, 10);
+ nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe });
+ nextClean.MoveTo(0, 10);
+ nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe });
+ var next = new Part(new Drawing("locked marked part", nextClean), new Vector(20, 2));
+ var nextPrepared = PreparedContours.Capture(nextClean, parameters);
+ Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit(
+ [nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true));
+ var lockedFingerprint = ExplicitContourTests.Fingerprint(next.Program);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], Start,
+ confirmedParameters: parameters, preservePartOrder: true));
+ var source = snapshot.Placements[0];
+ var prepared = source.Prepared;
+ var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material,
+ snapshot.Placements.Select(p => p.Material).ToArray());
+ var preferredOutside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(2, new Vector(21, 3))
+ .RankTowardNextCut(new Vector(21, 3), Start), c => adapter.Check(c.Choice)).Choices[0];
+ var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray();
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Start,
+ PreferredContourEntries.Pierce(prepared, preferredOutside, default));
+ var preferred = PreferredContourEntries.TryPlan(prepared, preferredOutside, route, centres, Start,
+ c => adapter.Check(c.Choice));
+ Assert.True(preferred.IsPreferred, preferred.Reason);
+ var firstExecution = ExecutionMotionReader.Read(prepared.Emit(
+ preferred.HoleChoices.Append(preferredOutside).ToArray()), Vector.Zero, Start, default);
+ var state = new ReleasedContourState();
+ Assert.Empty(state.Check(firstExecution, Start, 1));
+ Assert.Contains(state.Check(snapshot.Placements[1].Execution, firstExecution.DeparturePoint, 2),
+ f => f.Kind == PostVerificationKind.RapidCrossing);
+
+ var result = CuttingPlanService.Plan(snapshot);
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.NotEqual(preferredOutside.Point, result.ProposedOrder[0].ContourChoices[^1].Point);
+ Assert.NotEqual(preferred.HoleChoices.Select(c => c.Point).ToArray(),
+ result.ProposedOrder[0].ContourChoices.Take(2).Select(c => c.Point).ToArray());
+ Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram()));
+ Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(next.Program));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void HoleProgramsRetainScribesOnceAndNeverInstallOnFailure(bool tabs)
+ {
+ var clean = MixedHoles();
+ clean.MoveTo(-2, -2);
+ clean.Codes.Add(new LinearMove(-1, -1) { Layer = LayerType.Scribe });
+ var parameters = Parameters();
+ parameters.TabsEnabled = tabs;
+ parameters.TabConfig = new NormalTab { Size = 0.2 };
+ parameters.ExternalLeadOut = new ArcLeadOut { Radius = 0.2 };
+ var part = new Part(new Drawing("marked", clean));
+ var fingerprint = ExplicitContourTests.Fingerprint(part.Program);
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start,
+ confirmedParameters: parameters, preservePartOrder: true));
+ if (tabs)
+ {
+ Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
+ Assert.Empty(result.ProposedOrder);
+ Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.Incomplete);
+ }
+ else
+ {
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ var actual = result.ProposedOrder[0];
+ Assert.Equal(2, actual.ContourChoices[^1].ContourOrdinal);
+ Assert.Equal(3, actual.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count());
+ Assert.Single(actual.Execution.Motions.Where(m => !m.Rapid && m.Layer == LayerType.Scribe));
+ }
+ Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program));
+ }
+
+ internal static List<(Vector Pierce, Vector End)> CutRuns(OwnedExecution execution)
+ {
+ var runs = new List<(Vector Pierce, Vector End)>();
+ Vector? pierce = null;
+ var end = Vector.Zero;
+ foreach (var motion in execution.Motions)
+ {
+ if (motion.Rapid)
+ {
+ if (pierce is { } p) runs.Add((p, end));
+ pierce = null;
+ }
+ else if (motion.Layer != LayerType.Scribe)
+ {
+ pierce ??= motion.Start ?? motion.End;
+ end = motion.End;
+ }
+ }
+ if (pierce is { } last) runs.Add((last, end));
+ return runs;
+ }
+
+ private static string Describe(CuttingPlanResult result) =>
+ $"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message));
+}
diff --git a/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs
new file mode 100644
index 0000000..499594c
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs
@@ -0,0 +1,373 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// S09: at a part boundary the outside contour's automatic entry faces the NEXT part, and
+/// the emitted rapids are no worse than the measured legacy layout (BASELINE: legacy picks
+/// the entry nearest the arrival — for this fixture the lower-left corner each time, so
+/// every departure trails the left edge and each inter-part rapid carries the full 10.1
+/// pitch plus lead offsets). No-hole parts only — holed parts keep the legacy path for S12.
+///
+public class PerimeterLookAheadTests
+{
+ // Measured on the legacy search before this slice (Capture_LegacyBaselineNumbers,
+ // three squares at 0/10.5/21, origin start, 0.15 line leads, no lead-out): every entry
+ // the arrival-nearest lower-left corner (0,0); rapids 0.000000, 10.500000, 10.500000;
+ // total 21.000000. The look-ahead start must not make the total worse and must face
+ // the next part.
+ private const double LegacyTotalRapids = 21.0;
+
+ private static CuttingParameters Parameters()
+ {
+ var parameters = ExplicitContourTests.Parameters();
+ parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 45 };
+ parameters.ExternalLeadOut = new NoLeadOut();
+ return parameters;
+ }
+
+ private static Part Square(double x, CuttingParameters parameters, string name = "sq")
+ {
+ var part = new Part(new Drawing(name, LeadPathValidationTests.Rectangle(0, 0, 10, 10)),
+ new Vector(x, 0));
+ part.CuttingParameters = parameters;
+ return part;
+ }
+
+ private static CuttingPlanRequest Request(Part[] parts, CuttingParameters parameters, int budget = 20000) =>
+ new(parts, Vector.Zero, budget, parameters);
+
+ /// Actual air moves: each rapid's distance from the previous motion's end to the following cut end.
+ private static List Rapids(CuttingPlanResult result)
+ {
+ var rapids = new List();
+ var position = Vector.Zero;
+ foreach (var placement in result.ProposedOrder)
+ {
+ var motions = placement.Execution.Motions;
+ for (var i = 0; i < motions.Count; i++)
+ {
+ if (!motions[i].Rapid)
+ continue;
+ var next = motions.Skip(i + 1).First(m => !m.Rapid);
+ if (position.DistanceTo(motions[i].End) > 1e-9)
+ rapids.Add(position.DistanceTo(next.End));
+ position = next.End;
+ }
+ position = placement.Execution.DeparturePoint;
+ }
+ return rapids;
+ }
+
+ [Fact]
+ public void ThreePartsAlongX_LeftToRightReadyWithFirstStartFacingTheNextPart()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(3, result.ProposedOrder.Count);
+
+ var first = result.ProposedOrder[0];
+ var entry = Assert.Single(first.ContourChoices).Point + first.Location;
+ // Facing the next part (centre 15.5, 5) means the +X side of the first sheet,
+ // not the legacy arrival-nearest lower-left corner.
+ Assert.True(entry.X >= 10.0 - 1e-9, $"first entry {entry} faces away from the next part");
+ }
+
+ [Fact]
+ public void ManyVertices_FacingCornerSurvivesBeyondTheDrawingOrderEntryCap()
+ {
+ // Start at the leftmost vertex and travel clockwise. The initial sixteen
+ // drawing-order catalogue entries only cover the upper-left part of the ring;
+ // the useful lower-right corner appears later and must survive until ranking.
+ var source = new Program();
+ var vertices = Enumerable.Range(0, 32).Select(i =>
+ {
+ var angle = System.Math.PI - i * 2 * System.Math.PI / 32;
+ return new Vector(10 + 10 * System.Math.Cos(angle), 10 + 10 * System.Math.Sin(angle));
+ }).ToArray();
+ source.MoveTo(vertices[0].X, vertices[0].Y);
+ foreach (var point in vertices.Skip(1).Append(vertices[0]))
+ source.LineTo(point.X, point.Y);
+ var parameters = Parameters();
+ var polygon = new Part(new Drawing("many-vertex", source)) { CuttingParameters = parameters };
+ var next = Square(25, parameters);
+ var before = ExplicitContourTests.Fingerprint(polygon.Program);
+
+ var result = CuttingPlanService.Plan(Request([polygon, next], parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
+ var chosen = result.ProposedOrder[0];
+ var entry = Assert.Single(chosen.ContourChoices).Point + chosen.Location;
+ Assert.True(entry.X > 15 && entry.Y < 5, $"entry {entry} lost the lower-right facing corner to an early cap");
+ Assert.Contains(vertices, vertex => vertex.DistanceTo(entry) < 1e-6);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(polygon.Program));
+ }
+
+ [Fact]
+ public void EmittedRapidsNoWorseThanTheMeasuredLegacyLayout()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(Rapids(result).Sum() <= LegacyTotalRapids + 1e-6,
+ $"total rapids {Rapids(result).Sum():F6} exceed legacy {LegacyTotalRapids:F6}");
+ }
+
+ [Fact]
+ public void SecondPartFacesThird_AndLastPartFacesArrivalNotOrigin()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var ordered = result.ProposedOrder.OrderBy(p => p.SourceOrdinal).ToArray();
+ var secondEntry = Assert.Single(ordered[1].ContourChoices).Point + ordered[1].Location;
+ // Facing the third part (centre 26.0, 5): +X side of the middle sheet.
+ Assert.True(secondEntry.X >= 10.5 + 10.0 - 1e-9, $"second entry {secondEntry} faces away from the third part");
+ // Last part: no target — its entry is chosen near the arrival point, never pulled
+ // toward the plate origin.
+ var arrival = ordered[1].Execution.DeparturePoint;
+ var lastEntry = Assert.Single(ordered[2].ContourChoices).Point + ordered[2].Location;
+ Assert.True(arrival.DistanceTo(lastEntry) < Vector.Zero.DistanceTo(lastEntry),
+ $"last entry {lastEntry} is nearer the plate origin than the arrival {arrival}");
+ }
+
+ [Theory]
+ [InlineData(0, 0)]
+ [InlineData(100, -50)]
+ public void LastCircleIncludesTheExactClosestArrivalFallback(double x, double y)
+ {
+ var source = new Program();
+ source.MoveTo(2, 0);
+ source.ArcTo(2, 0, 0, 0, RotationType.CW);
+ var location = new Vector(x, y);
+ var part = new Part(new Drawing("circle", source), location);
+ var parameters = Parameters();
+ parameters.RoundLeadInAngles = false;
+ parameters.PierceClearance = 0;
+ parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
+ var arrival = location + new Vector(20, 8);
+ var before = ExplicitContourTests.Fingerprint(part.Program);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], arrival,
+ confirmedParameters: parameters, preservePartOrder: true));
+ var captured = snapshot.Placements[0];
+ var prepared = captured.Prepared;
+ var closest = prepared.ClosestEntry(0, arrival - location);
+ Assert.True(closest.Point.DistanceTo(new Vector(2, 0)) > 0.5);
+ var adapter = new ContourEntryFeasibility(prepared, location, captured.Material, []);
+ Assert.True(adapter.Check(closest).IsClear);
+ var witnessProgram = prepared.Emit([closest]);
+ var execution = ExecutionMotionReader.Read(witnessProgram, location, arrival, default);
+ var witness = CuttingPlanService.ReplayPrograms(snapshot,
+ [captured.Propose(witnessProgram, execution, [closest])], 0, default);
+ Assert.Equal(CuttingPlanStatus.Ready, witness.Status);
+ Assert.True(witness.IndependentlyReplayed);
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ var chosen = Assert.Single(Assert.Single(result.ProposedOrder).ContourChoices);
+ Assert.True(chosen.Point.DistanceTo(closest.Point) < 1e-6,
+ $"last entry {chosen.Point} omitted the feasible closest-arrival point {closest.Point}");
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
+ }
+
+ [Fact]
+ public void RotatedAndTranslatedLayout_FacingIsInGlobalSpace()
+ {
+ var parameters = Parameters();
+ var sheet = Square(0, parameters);
+ sheet.Rotate(System.Math.PI / 4); // about the origin: the diamond spans x -7.07..7.07
+ var parts = new[] { sheet, Square(25, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var rotated = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
+ var entry = rotated.ContourChoices.Single().Point + rotated.Location;
+ // The diamond's +X half faces the next part at x=25; the legacy arrival-nearest
+ // point would be the origin corner (0,0) or below.
+ Assert.True(entry.X > 3.0, $"rotated first entry {entry} faces away from the next part");
+ }
+
+ [Fact]
+ public void BlockedPreferredStart_CertifiedFallbackStillReady()
+ {
+ // A sheet hugging the right side of part 1 (0.05 gap) blocks every +X-facing lead;
+ // the planner must fall back to a candidate whose emitted leads certify clear.
+ var parameters = Parameters();
+ var blocker = new Part(new Drawing("block", LeadPathValidationTests.Rectangle(0, -2, 9.9, 12)), Vector.Zero);
+ blocker.Location = new Vector(10.05, 0);
+ blocker.CuttingParameters = parameters;
+ var parts = new[] { Square(0, parameters), blocker, Square(20.4, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var first = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
+ var entry = first.ContourChoices.Single().Point + first.Location;
+ var material = LeadMaterialSnapshot.Capture(
+ LeadPathValidationTests.Rectangle(0, 0, 10, 10), Vector.Zero);
+ var execution = ExecutionMotionReader.Read(first.CopyProgram(), first.Location, null, default);
+ var leads = LeadPathValidator.Check(execution, material, []);
+ Assert.True(leads.IsClear, leads.Reason);
+ }
+
+ [Fact]
+ public void NoCandidateFits_SurfacesContourFindingNotBudget()
+ {
+ // Shrink-wrap material 0.05 around part 1: every external lead enters it, so the
+ // finite candidate catalogue PROVES no lead-in fits; the search must say that, not
+ // hide behind the expansion budget.
+ var parameters = Parameters();
+ var wrap = new Part(new Drawing("wrap", LeadPathValidationTests.Rectangle(-0.05, -0.05, 10.05, 10.05)),
+ Vector.Zero);
+ wrap.CuttingParameters = parameters;
+ var square = Square(0, parameters);
+ var request = new CuttingPlanRequest([square, wrap], Vector.Zero, 20000, parameters);
+
+ var result = CuttingPlanService.Plan(request);
+
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
+ Assert.Contains(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal));
+ Assert.DoesNotContain(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("Expansion budget", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void BudgetOne_ReportsBudgetNotImpossibility()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters, budget: 1));
+
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
+ Assert.Contains(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("budget", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void CancellationIsHonouredMidSearch()
+ {
+ var parameters = Parameters();
+ var parts = Enumerable.Range(0, 6).Select(i => Square(i * 10.5, parameters)).ToArray();
+ using var cancel = new CancellationTokenSource();
+ cancel.Cancel();
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters), cancel.Token);
+
+ Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
+ }
+
+ [Fact]
+ public void LockedProgramFingerprintIsUntouchedByLookAhead()
+ {
+ var parameters = Parameters();
+ var locked = Square(0, parameters);
+ var prepared = PreparedContours.Capture(locked.Program, parameters);
+ var emitted = prepared.Emit([prepared.ClosestEntry(0, Vector.Zero)]);
+ Assert.True(locked.RestoreLeadInProgram(emitted, false));
+ locked.LeadInsLocked = true;
+ var before = ExplicitContourTests.Fingerprint(locked.Program);
+
+ var result = CuttingPlanService.Plan(Request([locked, Square(10.5, parameters)], parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(locked.Program));
+ var placement = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
+ Assert.False(placement.IsRegenerated); // locked programs never gain automatic choices
+ }
+
+ [Fact]
+ public void PreservePartOrder_RightToLeftFirstStartFacesLeft()
+ {
+ // Right-to-left supplied sequence: the x=20.2 sheet is cut first and must face the
+ // next part on its -X side.
+ var parameters = Parameters();
+ var parts = new[] { Square(21.0, parameters), Square(10.5, parameters), Square(0, parameters) };
+ var request = new CuttingPlanRequest(parts, Vector.Zero, 20000, parameters, preservePartOrder: true);
+
+ var result = CuttingPlanService.Plan(request);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var first = result.ProposedOrder[0];
+ Assert.Equal(0, first.SourceOrdinal);
+ var entry = first.ContourChoices.Single().Point + first.Location;
+ // Facing the next part (centre 15.5, 5) means the -X side of the x=21 sheet.
+ Assert.True(entry.X <= 21.0 + 1e-9, $"first entry {entry} faces away from the next part");
+
+ // Last part (x=0..10): no target — arrival-nearest means its +X side (the tool
+ // arrives from the middle sheet), NOT the plate-origin corner at (0,0).
+ var last = result.ProposedOrder[2];
+ var lastEntry = last.ContourChoices.Single().Point + last.Location;
+ Assert.True(lastEntry.X >= 10.0 - 1e-9,
+ $"last entry {lastEntry} faces the plate origin instead of the arrival");
+ }
+
+ [Fact]
+ public void FullFallbackSearch_TargetsNearestReadyPart()
+ {
+ // Middle-first geometry: the leftmost part is blocked until the middle one moves
+ // (forced by a shrink-wrap on its other side), so the learned-order replay must
+ // recompute look-ahead after replanning — the middle part must face whichever part
+ // the new sequence cuts after it, never a stale target.
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ // Sanity: normal Ready; the facing entry of each non-last part faces its successor
+ // in the RESULTING order, not merely the request order.
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var sequence = result.ProposedOrder.Select(p => p.SourceOrdinal).ToArray();
+ for (var i = 0; i < sequence.Length - 1; i++)
+ {
+ var entry = result.ProposedOrder[i].ContourChoices.Single().Point + result.ProposedOrder[i].Location;
+ var nextCentre = Centre(result.ProposedOrder[i + 1]);
+ var here = result.ProposedOrder[i].Location;
+ // The opposite side of the current sheet from the next part: the facing check
+ // is "entry is closer to the next centre than the wrong-side point is".
+ var wrongSide = nextCentre.X >= here.X + 5
+ ? new Vector(here.X - 5, here.Y + 5)
+ : new Vector(here.X + 15, here.Y + 5);
+ Assert.True(entry.DistanceTo(nextCentre) < wrongSide.DistanceTo(nextCentre),
+ $"order slot {i} entry {entry} faces away from next centre {nextCentre}");
+ }
+ }
+
+ private static Vector Centre(FixedProgramPlacement placement)
+ {
+ var cuts = placement.Execution.Motions
+ .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
+ .Select(m => m.Curve.ToEntity().BoundingBox).ToList();
+ return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center;
+ }
+
+ private static string Describe(CuttingPlanResult result) =>
+ $"{result.Status}: {string.Join("; ", result.Findings.Take(5).Select(f => f.Message))}";
+}
diff --git a/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs b/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs
new file mode 100644
index 0000000..f8476ae
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs
@@ -0,0 +1,286 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// The preferred hole-entry proposal: walk the proposed hole route BACKWARD from the chosen
+/// perimeter entry, each hole facing the downstream contour's ACTUAL emitted pierce (native
+/// rounding included), corners outranking midpoints, with an explicit no-preference result
+/// when a hole cannot flow. Proposal only — nothing installed, nothing certified here.
+/// (Contour layout: holes take ordinals 0..N-1, the perimeter is the last ordinal.)
+///
+public class PreferredContourEntriesTests
+{
+ private static readonly Vector Arrival = new(0, 5);
+
+ private static Program CirclesProgram(params (double X, double Y)[] centres)
+ {
+ var p = ExplicitContourTests.Square(false);
+ foreach (var (x, y) in centres)
+ {
+ // Unit circle centred at (x, y): two half-arcs, CCW.
+ p.MoveTo(x + 1, y);
+ p.ArcTo(new Vector(x - 1, y), new Vector(x, y), RotationType.CCW);
+ p.ArcTo(new Vector(x + 1, y), new Vector(x, y), RotationType.CCW);
+ }
+ return p;
+ }
+
+ private static Program RectHolesProgram(params (double X1, double Y1, double X2, double Y2)[] boxes)
+ {
+ var p = ExplicitContourTests.Square(false);
+ foreach (var (x1, y1, x2, y2) in boxes)
+ {
+ p.MoveTo(x1, y1);
+ p.LineTo(x2, y1); p.LineTo(x2, y2); p.LineTo(x1, y2); p.LineTo(x1, y1);
+ }
+ return p;
+ }
+
+ private static (PreparedContours Prepared, ContourEntryFeasibility Feasibility) Capture(Program clean,
+ Vector location)
+ {
+ var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters());
+ var material = LeadMaterialSnapshot.Capture(clean, location);
+ var feasibility = new ContourEntryFeasibility(prepared, location, material, []);
+ return (prepared, feasibility);
+ }
+
+ private static bool At(Vector point, double x, double y) =>
+ point.DistanceTo(new Vector(x, y)) < 1e-6;
+
+ private static Vector Pierce(PreparedContours prepared, ContourChoice choice)
+ {
+ var program = prepared.EmitCandidateForValidation(choice);
+ var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, default);
+ var first = execution.Motions.First(m => !m.Rapid);
+ return first.Start ?? first.End;
+ }
+
+ /// The owned compass candidate of a circle contour at an exact point.
+ private static ContourChoice Compass(PreparedContours prepared, int contour, double x, double y,
+ Vector lookAhead)
+ {
+ var match = prepared.AutomaticEntryCandidatesWithFallbacks(contour, lookAhead)
+ .FirstOrDefault(c => At(c.Choice.Point, x, y));
+ Assert.NotNull(match);
+ return match.Choice;
+ }
+
+ [Fact]
+ public void TwoCircularHoles_FlowToTheSelectedOutsideEntry()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); // outside right edge
+ Assert.True(At(perimeterEntry.Point, 10, 5));
+
+ // Route: arrival (0,5) -> left circle -> right circle -> the outside entry.
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival,
+ Pierce(prepared, perimeterEntry));
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route,
+ centres, Arrival, c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(new[] { 0, 1 }, proposal.HoleChoices.Select(c => c.ContourOrdinal));
+
+ // The last hole's ACTUAL pierce flows to the perimeter pierce: strictly closer to
+ // it than the opposite (west) compass pierce would be.
+ var perimeterPierce = Pierce(prepared, perimeterEntry);
+ var last = Pierce(prepared, proposal.HoleChoices[1]);
+ var opposite = Pierce(prepared, Compass(prepared, 1, 6, 3, new Vector(6, 3)));
+ Assert.True(last.DistanceTo(perimeterPierce) < opposite.DistanceTo(perimeterPierce),
+ $"right hole pierce {last} does not flow to {perimeterPierce} (west pierce {opposite})");
+
+ // The first hole faces the SECOND hole's actual pierce — not the perimeter and not
+ // the downstream nominal point: closer to it than the west compass pierce would be.
+ var downstream = Pierce(prepared, proposal.HoleChoices[1]);
+ var first = Pierce(prepared, proposal.HoleChoices[0]);
+ var firstWest = Pierce(prepared, Compass(prepared, 0, 2, 3, new Vector(2, 3)));
+ Assert.True(first.DistanceTo(downstream) < firstWest.DistanceTo(downstream),
+ $"left hole pierce {first} faces away from downstream {downstream} (west {firstWest})");
+ }
+
+ [Fact]
+ public void RectangularHoles_CornersOutrankMidpoints()
+ {
+ var clean = RectHolesProgram((3, 3, 5, 5), (7, 3, 9, 5));
+ var (prepared, _) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 4));
+ var centres = new[] { new Vector(4, 4), new Vector(8, 4), new Vector(5, 5) };
+
+ // All-clear evaluation isolates pure ranking: a corner on a facing side wins over
+ // any midpoint, walking backward from the perimeter entry.
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ // Right hole faces the perimeter: a RIGHT-edge corner of the (7,3)-(9,5) hole,
+ // never the (9,4) midpoint nor a left-edge corner.
+ var right = proposal.HoleChoices[1].Point;
+ Assert.True(At(right, 9, 3) || At(right, 9, 5),
+ $"right hole entry {right} is not a right-facing corner");
+ // Left hole faces the right hole's actual pierce (eastward): a right-edge corner of
+ // the (3,3)-(5,5) hole, never its (5,4) midpoint.
+ var left = proposal.HoleChoices[0].Point;
+ Assert.True(At(left, 5, 3) || At(left, 5, 5),
+ $"left hole entry {left} is not a right-facing corner");
+ }
+
+ [Fact]
+ public void BlockedIdealLead_TakesTheNextFeasibleCandidate()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+
+ // The ideal east compass point of the right circle is refused: fall back to its
+ // next-ranked candidate rather than fail or skip the hole.
+ ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) =>
+ c.Choice.ContourOrdinal == 1 && At(c.Choice.Point, 8, 3)
+ ? new(ContourFeasibilityStatus.Blocked, "test block")
+ : feasibility.Check(c.Choice);
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, Pierce(prepared, perimeterEntry));
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival, Evaluate);
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(1, proposal.HoleChoices[1].ContourOrdinal); // never skipped or swapped
+ Assert.False(At(proposal.HoleChoices[1].Point, 8, 3), "the blocked candidate was used anyway");
+ Assert.True(proposal.HoleChoices[1].Point.X >= 7.0 - 1e-9,
+ $"fallback entry {proposal.HoleChoices[1].Point} abandoned the facing side entirely");
+ }
+
+ [Fact]
+ public void BlockedHole_GivesAnExplicitNoPreferenceFinding()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var (prepared, _) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+
+ ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) =>
+ c.Choice.ContourOrdinal == 0
+ ? new(ContourFeasibilityStatus.Blocked, "nothing fits")
+ : new(ContourFeasibilityStatus.Clear, null);
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, Evaluate);
+
+ Assert.False(proposal.IsPreferred);
+ Assert.Empty(proposal.HoleChoices);
+ Assert.Equal(0, proposal.BlockedContour);
+ Assert.Contains("no preferred lead-feasible entry", proposal.Reason);
+ }
+
+ [Fact]
+ public void EveryHoleExactlyOnceInRouteOrder()
+ {
+ var clean = CirclesProgram((2.5, 3), (5, 3), (7.5, 3));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(3, 2, new Vector(10, 5)); // perimeter is last
+ var centres = new[]
+ {
+ new Vector(2.5, 3), new Vector(5, 3), new Vector(7.5, 3), new Vector(5, 5),
+ };
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2 }, centres, Arrival, Pierce(prepared, perimeterEntry));
+
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival,
+ c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(route, proposal.HoleChoices.Select(c => c.ContourOrdinal));
+ Assert.Equal(route.Count, proposal.HoleChoices.Select(c => c.ContourOrdinal).Distinct().Count());
+ Assert.All(proposal.HoleChoices, c => Assert.NotEqual(3, c.ContourOrdinal)); // never the perimeter
+ }
+
+ [Fact]
+ public void ScribesDoNotBecomeTheDownstreamPierceTarget()
+ {
+ var clean = RectHolesProgram((3, 3, 5, 5), (7, 3, 9, 5));
+ var centres = new[] { new Vector(4, 4), new Vector(8, 4), new Vector(5, 5) };
+ var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters());
+ var entry = prepared.Entry(2, 2, new Vector(10, 4));
+ var baseline = PreferredContourEntries.TryPlan(prepared, entry, new[] { 0, 1 },
+ centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null));
+ clean.MoveTo(-20, -20);
+ clean.Codes.Add(new LinearMove(-19, -19) { Layer = LayerType.Scribe });
+ var marked = PreparedContours.Capture(clean, ExplicitContourTests.Parameters());
+ var actual = PreferredContourEntries.TryPlan(marked, marked.Entry(2, 2, new Vector(10, 4)),
+ new[] { 0, 1 }, centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null));
+ Assert.True(actual.IsPreferred, actual.Reason);
+ Assert.Equal(baseline.HoleChoices.Select(c => c.Point), actual.HoleChoices.Select(c => c.Point));
+ }
+
+ [Fact]
+ public void ChainingIsDeterministic()
+ {
+ // Whatever native rounding a nominal circle candidate emits, backward chaining must
+ // reproduce the same earlier-hole choice on a second run: the chain is a pure
+ // function of the ACTUAL pierce, not of iteration order or object identity.
+ var clean = CirclesProgram((3, 3), (7.4, 4.6));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7.4, 4.6), new Vector(5, 5) };
+
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, c => feasibility.Check(c.Choice));
+ var again = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(proposal.HoleChoices.Select(c => c.Point), again.HoleChoices.Select(c => c.Point));
+ }
+
+ [Fact]
+ public void ForeignPerimeterChoiceIsRefused()
+ {
+ var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero);
+ var (other, _) = Capture(ExplicitContourTests.Square(false), Vector.Zero);
+ var foreign = other.Entry(0, 0, new Vector(0, 5));
+
+ Assert.Throws(() => PreferredContourEntries.TryPlan(prepared, foreign,
+ new[] { 0 }, new[] { new Vector(3, 3), new Vector(7, 3) }, Arrival,
+ c => feasibility.Check(c.Choice)));
+ }
+
+ [Fact]
+ public void CancellationPropagates()
+ {
+ var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+
+ Assert.ThrowsAny(() => PreferredContourEntries.TryPlan(
+ prepared, perimeterEntry, new[] { 0, 1 },
+ new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }, Arrival,
+ c => feasibility.Check(c.Choice), cancelled.Token));
+ }
+
+ [Fact]
+ public void SourceProgramAndChoicesAreUntouched()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var before = ExplicitContourTests.Fingerprint(clean);
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(clean)); // caller program untouched
+ Assert.All(proposal.HoleChoices, c => Assert.True(ReferenceEquals(c.Owner, prepared)));
+ Assert.NotSame(prepared.EmitCandidateForValidation(proposal.HoleChoices[0]),
+ prepared.EmitCandidateForValidation(proposal.HoleChoices[0])); // fresh programs
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs b/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs
new file mode 100644
index 0000000..9224cbd
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs
@@ -0,0 +1,248 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class SideCoverageRegressionTests
+{
+ [Fact]
+ public void DefaultCap_ReusesClearBottomCandidatePassedWhileSeekingLeft()
+ {
+ var clean = new Program();
+ clean.MoveTo(0, 0);
+ clean.LineTo(0, 10);
+ for (var i = 1; i <= 12; i++)
+ clean.LineTo(i * 10.0 / 12, 10);
+ for (var i = 1; i <= 12; i++)
+ clean.LineTo(10, 10 - i * 10.0 / 12);
+ clean.LineTo(0, 0);
+ var parameters = Parameters(0.15, 90, 0);
+ var prepared = PreparedContours.Capture(clean, parameters);
+ var blockers = new[]
+ {
+ Rectangle(-0.5, -0.5, 1.5, 0.4), Rectangle(9, -0.5, 1.05, 0.4),
+ Rectangle(10.1, -0.3, 0.5, 0.6), Rectangle(-0.5, 9.8, 0.4, 0.4),
+ };
+ var target = new Vector(20, 20);
+ var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(0, target)
+ .RankTowardNextCut(target, new Vector(10, -2));
+ var adapter = Adapter(prepared, clean, blockers);
+ var calls = new HashSet<(long, long)>();
+
+ var selected = ContourEntrySelection.Select(ranked, candidate =>
+ {
+ Assert.True(calls.Add(candidate.GeometryKey), "A candidate was evaluated twice.");
+ return adapter.Check(candidate.Choice);
+ });
+
+ Assert.Equal(16, selected.Choices.Count);
+ var bottom = ranked.Single(c => c.Choice.Point.DistanceTo(new Vector(5, 0)) < 1e-9);
+ Assert.Equal(32, ranked.ToList().IndexOf(bottom));
+ Assert.True(adapter.Check(bottom.Choice).IsClear);
+ Assert.Equal(new[] { 0, 1, 2, 3 }, ClearSides(ranked, adapter));
+ Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedSides(selected, ranked));
+ Assert.Contains(selected.Choices, c => c.Point.DistanceTo(bottom.Choice.Point) < 1e-9);
+ Assert.All(selected.Choices, c => Assert.True(adapter.Check(c).IsClear));
+ Assert.Equal(ContourSelectionShortfall.None, selected.Shortfall);
+ AssertGlobalOrder(selected, ranked);
+ Assert.Equal(calls.Count, selected.EvaluatedCount);
+ }
+
+ [Fact]
+ public void SupportedCapFour_ReusesClearTopCandidatePassedWhileSeekingBottom()
+ {
+ var clean = Polygon();
+ var prepared = PreparedContours.Capture(clean, Parameters());
+ var target = new Vector(100 * System.Math.Cos(System.Math.PI), 100 * System.Math.Sin(System.Math.PI));
+ var arrival = new Vector(40 * System.Math.Cos(3 * System.Math.PI / 2), 40 * System.Math.Sin(3 * System.Math.PI / 2));
+ var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(0, target).RankTowardNextCut(target, arrival);
+ var adapter = Adapter(prepared, clean, []);
+
+ var selected = ContourEntrySelection.Select(ranked, c => adapter.Check(c.Choice), 4);
+
+ Assert.Equal(4, selected.Choices.Count);
+ Assert.True(adapter.Check(ranked[4].Choice).IsClear);
+ Assert.Contains(3, Sides(ranked[4].Choice.Point, ranked));
+ Assert.Equal(new[] { 0, 1, 2, 3 }, ClearSides(ranked, adapter));
+ Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedSides(selected, ranked));
+ Assert.All(selected.Choices, c => Assert.True(adapter.Check(c).IsClear));
+ AssertGlobalOrder(selected, ranked);
+ }
+
+ [Theory]
+ [InlineData(4)]
+ [InlineData(16)]
+ public void FullCheckedRoute_RetainsTopDepartureToLockedMarkedSquare(int cap)
+ {
+ var parameters = Parameters();
+ var first = new Part(new Drawing("convex polygon", Polygon()));
+ var nextClean = Rectangle(0, 0, 2, 2);
+ nextClean.MoveTo(101, 21);
+ nextClean.Codes.Add(new LinearMove(102, 21) { Layer = LayerType.Scribe });
+ var next = new Part(new Drawing("locked marked square", nextClean), new Vector(-101, -1));
+ var nextPrepared = PreparedContours.Capture(nextClean, parameters);
+ Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit([nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true));
+ var before = new[] { ExplicitContourTests.Fingerprint(first.Program), ExplicitContourTests.Fingerprint(next.Program) };
+ var start = new Vector(-7.347880794884118e-15, -40);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], start,
+ confirmedParameters: parameters, preservePartOrder: true, maxEntries: cap));
+ var source = snapshot.Placements[0];
+ var prepared = source.Prepared;
+ var witness = prepared.AutomaticEntryCandidatesWithFallbacks(0, new Vector(-100, 0))
+ .First(c => c.Choice.Point.DistanceTo(new Vector(-4.99220639970189, 8.664749001718139)) < 1e-9);
+ var witnessProgram = prepared.Emit([witness.Choice]);
+ var witnessExecution = ExecutionMotionReader.Read(witnessProgram, source.Location, start, default);
+ var witnessProposal = source.Propose(witnessProgram, witnessExecution, [witness.Choice], default);
+ var witnessReplay = CuttingPlanService.ReplayPrograms(snapshot, [witnessProposal, snapshot.Placements[1]], 0, default);
+ Assert.Equal(CuttingPlanStatus.Ready, witnessReplay.Status);
+ Assert.True(witnessReplay.IndependentlyReplayed);
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready,
+ $"{result.Status}, {result.Expansions} expansions: {string.Join("; ", result.Findings.Select(f => f.Message))}");
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
+ Assert.Single(result.ProposedOrder[0].ContourChoices);
+ Assert.False(result.ProposedOrder[1].IsRegenerated);
+ var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
+ Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
+ Assert.True(replay.IndependentlyReplayed);
+ Assert.Equal(before, new[] { ExplicitContourTests.Fingerprint(first.Program), ExplicitContourTests.Fingerprint(next.Program) });
+ }
+
+ [Fact]
+ public void CorrectiveRescan_PreservesGlobalRankAndLeavesTailUnevaluated()
+ {
+ var candidates = CorrectiveCandidates();
+ var calls = new HashSet<(long, long)>();
+ var result = ContourEntrySelection.Select(candidates, c =>
+ {
+ Assert.True(calls.Add(c.GeometryKey));
+ return new(ContourFeasibilityStatus.Clear, null);
+ }, 4);
+
+ Assert.Equal(new[] { candidates[0].Choice, candidates[1].Choice, candidates[4].Choice, candidates[5].Choice }, result.Choices);
+ Assert.Equal(6, result.EvaluatedCount);
+ Assert.DoesNotContain(candidates[6].GeometryKey, result.EvaluatedKeys);
+ Assert.Equal(ContourSelectionShortfall.None, result.Shortfall);
+ }
+
+ [Theory]
+ [InlineData(ContourFeasibilityStatus.Blocked)]
+ [InlineData(ContourFeasibilityStatus.Incomplete)]
+ public void CorrectiveRescan_DoesNotPromoteUnclearSide(ContourFeasibilityStatus status)
+ {
+ var candidates = CorrectiveCandidates();
+ var calls = new HashSet<(long, long)>();
+ var result = ContourEntrySelection.Select(candidates, c =>
+ {
+ Assert.True(calls.Add(c.GeometryKey));
+ return c.GeometryKey == candidates[4].GeometryKey
+ ? new(status, "unclear bottom") : new(ContourFeasibilityStatus.Clear, null);
+ }, 4);
+
+ Assert.DoesNotContain(candidates[4].Choice, result.Choices);
+ Assert.Equal(4, result.Choices.Count);
+ Assert.Equal(status == ContourFeasibilityStatus.Incomplete ? ContourSelectionShortfall.Incomplete : ContourSelectionShortfall.None, result.Shortfall);
+ if (status == ContourFeasibilityStatus.Incomplete)
+ {
+ Assert.Contains(candidates[4].Choice, result.UncertainChoices);
+ Assert.DoesNotContain("No tested lead-in fits", result.Reason);
+ }
+ Assert.Equal(calls.Count, result.EvaluatedCount);
+ }
+
+ [Fact]
+ public void CorrectiveRescan_HonoursCancellationBeforeReusingCachedVerdict()
+ {
+ var candidates = CorrectiveCandidates();
+ using var cancellation = new CancellationTokenSource();
+ Assert.ThrowsAny(() => ContourEntrySelection.Select(candidates, c =>
+ {
+ if (c.GeometryKey == candidates[5].GeometryKey)
+ cancellation.Cancel();
+ return new(ContourFeasibilityStatus.Clear, null);
+ }, 4, cancellation.Token));
+ }
+
+ private static ContourEntryCandidate[] CorrectiveCandidates()
+ {
+ var prepared = PreparedContours.Capture(Rectangle(0, 0, 10, 10), Parameters());
+ return new[] { (10, 5), (5, 10), (9, 5), (5, 9), (5, 0), (0, 5), (6, 1) }
+ .Select(p => new ContourEntryCandidate(new ContourChoice(0, 0, new Vector(p.Item1, p.Item2)) { Owner = prepared }, AutomaticEntryKind.ConvexCorner)).ToArray();
+ }
+
+ private static ContourEntryFeasibility Adapter(PreparedContours prepared, Program clean, Program[] blockers)
+ {
+ var own = LeadMaterialSnapshot.Capture(clean, Vector.Zero);
+ var others = blockers.Select(p => LeadMaterialSnapshot.Capture(p, Vector.Zero)).ToArray();
+ Assert.True(own.IsComplete, own.Reason);
+ Assert.All(others, m => Assert.True(m.IsComplete, m.Reason));
+ return new(prepared, Vector.Zero, own, others);
+ }
+
+ private static int[] ClearSides(IReadOnlyList ranked, ContourEntryFeasibility adapter) =>
+ ranked.Where(c => adapter.Check(c.Choice).IsClear).SelectMany(c => Sides(c.Choice.Point, ranked)).Distinct().Order().ToArray();
+
+ private static int[] SelectedSides(ContourSelectionResult selected, IReadOnlyList ranked) =>
+ selected.Choices.SelectMany(c => Sides(c.Point, ranked)).Distinct().Order().ToArray();
+
+ private static IEnumerable Sides(Vector point, IReadOnlyList ranked)
+ {
+ var gaps = new[]
+ {
+ point.X - ranked.Min(c => c.Choice.Point.X), ranked.Max(c => c.Choice.Point.X) - point.X,
+ point.Y - ranked.Min(c => c.Choice.Point.Y), ranked.Max(c => c.Choice.Point.Y) - point.Y,
+ };
+ return Enumerable.Range(0, 4).Where(side => gaps[side] <= gaps.Min() + 1e-6);
+ }
+
+ private static void AssertGlobalOrder(ContourSelectionResult selected, IReadOnlyList ranked)
+ {
+ var ranks = selected.Choices.Select(c => ranked.ToList().FindIndex(candidate => ReferenceEquals(candidate.Choice, c))).ToArray();
+ Assert.Equal(ranks.Order(), ranks);
+ }
+
+ private static Program Polygon()
+ {
+ var points = Enumerable.Range(0, 16).Select(i => new Vector(
+ 10 * System.Math.Cos(0.13 - i * 2 * System.Math.PI / 16),
+ 10 * System.Math.Sin(0.13 - i * 2 * System.Math.PI / 16))).ToArray();
+ var program = new Program();
+ program.MoveTo(points[0].X, points[0].Y);
+ foreach (var point in points.Skip(1).Append(points[0]))
+ program.LineTo(point.X, point.Y);
+ return program;
+ }
+
+ private static Program Rectangle(double x, double y, double width, double height)
+ {
+ var program = new Program();
+ program.MoveTo(x, y);
+ program.LineTo(x, y + height);
+ program.LineTo(x + width, y + height);
+ program.LineTo(x + width, y);
+ program.LineTo(x, y);
+ return program;
+ }
+
+ private static CuttingParameters Parameters(double length = 0.3, double angle = 45, double clearance = 0.05) => new()
+ {
+ ExternalLeadIn = new LineLeadIn { Length = length, ApproachAngle = angle },
+ InternalLeadIn = new LineLeadIn { Length = length, ApproachAngle = angle },
+ ArcCircleLeadIn = new LineLeadIn { Length = length },
+ ExternalLeadOut = new NoLeadOut(),
+ InternalLeadOut = new NoLeadOut(),
+ TabsEnabled = false,
+ RoundLeadInAngles = false,
+ PierceClearance = clearance,
+ };
+}
diff --git a/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs b/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs
new file mode 100644
index 0000000..3976f84
--- /dev/null
+++ b/OpenNest.Tests/CuttingStrategy/AutomaticCornerClassificationTests.cs
@@ -0,0 +1,282 @@
+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