merge: integrate reviewed cutting-start planner fixes (#10)

Preserve reviewed cutting-start and plate-label commits; operator accepted A1-A4 and B1-B7. Excludes best-effort cutting and color trials.
This commit is contained in:
aj authored and GitHub committed 2026-10-08 11:01:21 -04:00
commit b83518feac
37 files changed
+5353 -78

No files matched your search

@@ -0,0 +1,53 @@
using System;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Automatic start preference, ordered: a lower value is more preferred.</summary>
internal enum AutomaticEntryKind
{
/// <summary>A convex turn of the contour's own travel, classified exactly as emission classifies it.</summary>
ConvexCorner = 0,
/// <summary>The midpoint of a straight edge.</summary>
StraightMidpoint = 1,
/// <summary>A tangent line/arc joint. A collinear line/line split is not a joint and never appears.</summary>
TangentJoint = 2,
/// <summary>
/// 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.
/// </summary>
NearCorner = 3,
/// <summary>
/// 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.
/// </summary>
TargetFacing = 4,
/// <summary>Tier 3 (fallback): the native midpoint of an arc entity.</summary>
ArcMidpoint = 5,
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
CircleCompass = 6,
}
/// <summary>
/// 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.
/// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
{
/// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
/// equal points rank together regardless of the entity that produced them.
/// </summary>
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);
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
internal static class ContourEntryRanking
{
/// <summary>
/// Orders <paramref name="candidates"/> for one contour in local coordinates. With a
/// <paramref name="target"/> (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-&gt;entry + entry-&gt;target
/// ascending, then the stable geometric key. With no target (the last part): tier first,
/// then distance to <paramref name="arrival"/> — never toward the plate origin. The
/// input list is returned untouched as a new list; entity order is never meaningful.
/// </summary>
internal static IReadOnlyList<ContourEntryCandidate> RankTowardNextCut(
this IReadOnlyList<ContourEntryCandidate> 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<ContourEntryCandidate>();
// 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();
}
/// <summary>
/// The ranking tier — coarser than the preference kind: outside corners first, then
/// straight midpoints and tangent joints as peers, then every fallback kind.
/// </summary>
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);
}
}
@@ -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)
@@ -156,6 +156,268 @@ public sealed class PreparedContours
return choice;
}
/// <summary>
/// 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 — <see cref="AutomaticEntryCandidatesWithFallbacks"/> supplies
/// those. Manual entry through <see cref="Entry"/> / <see cref="ClosestEntry"/> is
/// unaffected.
/// </summary>
internal IReadOnlyList<ContourEntryCandidate> 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));
}
/// <summary>
/// The complete uncapped automatic start catalogue: the preferred points of
/// <see cref="AutomaticEntryCandidates"/> 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
/// <paramref name="lookAhead"/> (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.
/// </summary>
internal IReadOnlyList<ContourEntryCandidate> AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal,
Vector? lookAhead = null,
CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
var all = IsSingleCircle(shape)
? new List<ContourEntryCandidate>()
: 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;
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token)
.Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
.ToList();
/// <summary>
/// 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
/// <paramref name="lookAhead"/>. No ranking and no lead-safety verdict here.
/// </summary>
private List<ContourEntryCandidate> FallbackCandidates(
Shape shape,
int contourOrdinal,
Vector? lookAhead,
CancellationToken token)
{
var fallbacks = new List<ContourEntryCandidate>();
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);
}
/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<ContourEntryCandidate> MergeByGeometry(List<ContourEntryCandidate> 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();
}
/// <summary>
/// True when <paramref name="point"/> sits on a vertex of the contour that is reflex or
/// cusp from either travel direction — a point automatic selection must never emit.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private double ApplicableLeadInLength(int contourOrdinal)
=> (contourOrdinal == PerimeterOrdinal
? parameters.ExternalLeadIn
: parameters.InternalLeadIn) switch
{
LineLeadIn line => line.Length,
LineLineLeadIn lineLine => lineLine.Length1,
_ => 0,
};
/// <summary>
/// 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.
/// </summary>
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];
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
public Program Emit(IReadOnlyList<ContourChoice> choices)
{
@@ -163,6 +425,25 @@ public sealed class PreparedContours
return EmitPrefix(choices);
}
/// <summary>
/// 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 <see cref="Emit"/> and
/// <see cref="EmitPrefix"/> keep the perimeter-last rule untouched.
/// </summary>
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<ContourChoice> 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];
/// <summary>
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
/// </summary>
internal IReadOnlyList<Vector?> 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();
@@ -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<Entity> 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<CuttingPlanning.ContourChoice> 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,
}
/// <summary>
/// A vertex classified for automatic start-point planning: what kind of turn the
/// contour makes there, and the travel tangents of the two edges it joins. Read-only
/// so callers cannot mutate the contour; winding is derived the same way
/// <see cref="EmitContour"/> derives it, so the kind matches actual emission.
/// </summary>
internal readonly record struct AutomaticCorner(CornerKind Kind, Vector TangentIn, Vector TangentOut);
/// <summary>
/// Classification query shared with entry planning: the turn at <paramref name="point"/>
/// on a closed line/arc contour, using the same corner geometry and winding derivation
/// as emission. False when <paramref name="point"/> is not a shared vertex of two
/// chainable entities (an interior point, an open contour, a degenerate or non-finite
/// corner). Lead generation itself is not involved.
/// </summary>
internal static bool TryClassifyAutomaticStartCorner(
Shape shape,
Vector point,
Entity entity,
out AutomaticCorner corner)
{
if (!TryGetCorner(shape, point, entity, out var raw))
{
corner = default;
return false;
}
corner = new AutomaticCorner(
ClassifyCorner(raw, DetermineWinding(shape)), raw.TangentIn, raw.TangentOut);
return true;
}
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
private readonly record struct ContourCorner(
Entity Incoming,
+64
View File
@@ -0,0 +1,64 @@
using System.Collections.Generic;
namespace OpenNest
{
/// <summary>
/// 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.
/// </summary>
public static class PlateDisplayNumbering
{
/// <summary>
/// The number shown for the plate at <paramref name="storageIndex"/>: real collection
/// positions count as 1-based plate numbers. Returns null for the trailing "new plate"
/// sentinel and for indexes outside the collection.
/// </summary>
public static int? DisplayedPlateNumber(IList<Plate> plates, int storageIndex)
{
if (plates == null || storageIndex < 0 || storageIndex >= plates.Count)
return null;
if (IsTrailingSentinel(plates, storageIndex))
return null;
return storageIndex + 1;
}
/// <summary>
/// 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").
/// </summary>
public static int DisplayedPlateCount(IList<Plate> 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);
}
/// <summary>True when <paramref name="storageIndex"/> is the trailing empty new-plate sentinel.</summary>
public static bool IsTrailingSentinel(IList<Plate> 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;
}
/// <summary>Header text for the plate at <paramref name="storageIndex"/>.</summary>
public static string FormatHeader(IList<Plate> 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);
}
}
}
@@ -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;
/// <summary>The three verdicts a candidate entry can receive from the shared lead validator.</summary>
public enum ContourFeasibilityStatus
{
/// <summary>Every emitted lead of this contour is complete AND clear.</summary>
Clear,
/// <summary>A complete check found a lead contact/overlap — the reason is preserved.</summary>
Blocked,
/// <summary>The check could not complete (incomplete material, malformed emission) — never clear.</summary>
Incomplete,
}
/// <summary>Verdict for one candidate entry. Complete/blocked/incomplete stay distinct.</summary>
public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status, string? Reason)
{
public bool IsClear => Status == ContourFeasibilityStatus.Clear;
}
/// <summary>
/// Feasibility adapter over the EXISTING <see cref="LeadPathValidator"/>: 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 <see cref="Check"/> is ever emitted or validated, and
/// <see cref="EvaluationCount"/> counts validator executions (not cache hits) for cost tests.
/// </summary>
public sealed class ContourEntryFeasibility
{
private readonly PreparedContours prepared;
private readonly Vector location;
private readonly LeadMaterialSnapshot ownMaterial;
private readonly LeadMaterialSnapshot[] materials;
private readonly Dictionary<Key, ContourFeasibilityVerdict> cache = new();
public ContourEntryFeasibility(PreparedContours prepared, Vector location,
LeadMaterialSnapshot ownMaterial, IReadOnlyList<LeadMaterialSnapshot> 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<LeadMaterialSnapshot> { 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();
}
/// <summary>Validator executions performed (cache misses only) during this attempt.</summary>
public int EvaluationCount { get; private set; }
/// <summary>
/// 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.
/// </summary>
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);
}
@@ -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;
/// <summary>Why a selection ended the way it did. Honest metadata, never a geometric overclaim.</summary>
internal enum ContourSelectionShortfall
{
/// <summary>Selection ended at the cap with the side scan satisfied: a complete verdict.</summary>
None,
/// <summary>
/// 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.
/// </summary>
Exhausted,
/// <summary>
/// At least one check could not complete (incomplete material or emission). Never
/// present this as geometric impossibility and never as "nothing fits".
/// </summary>
Incomplete,
}
/// <summary>
/// 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.
/// </summary>
internal sealed record ContourSelectionResult(
IReadOnlyList<ContourChoice> Choices,
int EvaluatedCount,
IReadOnlyList<(long, long)> EvaluatedKeys,
IReadOnlyList<ContourChoice> UncertainChoices,
ContourSelectionShortfall Shortfall,
string? Reason);
/// <summary>
/// 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.
/// </summary>
internal static class ContourEntrySelection
{
internal const int DefaultMaxEntries = 16;
/// <summary>Below this cap all-side coverage is not promised; the cap and ranking bind first (caps 1-3).</summary>
internal const int SideCoverageMinCap = 4;
/// <summary>
/// Selects up to <paramref name="maxEntries"/> feasible choices from one contour's
/// globally ranked catalogue. <paramref name="evaluate"/> is the S07 adapter verdict,
/// called lazily at most once per distinct candidate.
/// </summary>
internal static ContourSelectionResult Select(
IReadOnlyList<ContourEntryCandidate> rankedCandidates,
Func<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourEntryCandidate>();
var seen = new HashSet<(long, long)>();
foreach (var candidate in rankedCandidates)
if (seen.Add(candidate.GeometryKey))
candidates.Add(candidate);
var evaluated = new List<ContourEntryCandidate>();
var verdicts = new Dictionary<(long, long), ContourFeasibilityVerdict>();
var box = Box(candidates);
var selected = new List<ContourEntryCandidate>();
var uncertain = new List<ContourEntryCandidate>();
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<ContourEntryCandidate> 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);
}
/// <summary>Sides 0 left, 1 right, 2 bottom, 3 top of the candidate bounding rectangle that the point sits on.</summary>
private static List<int> 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<int>(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;
}
/// <summary>Appends when a slot remains, else replaces the worst (latest-ranked) candidate whose removal preserves every other covered side.</summary>
private static void TryPlace(List<ContourEntryCandidate> 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.
}
}
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// 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 <see cref="CuttingPartOrder"/>'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.
/// </summary>
internal static class CuttingHoleOrder
{
/// <summary>
/// Orders <paramref name="holes"/> (stable ordinals, each appearing exactly once in the
/// result, never the perimeter) to minimize arrival -&gt; holes -&gt; <paramref
/// name="perimeterEntry"/> distance. <paramref name="centresByOrdinal"/> supplies one
/// representative point per hole ordinal. Empty holes give an empty order.
/// </summary>
internal static IReadOnlyList<int> Plan(IReadOnlyList<int> holes,
IReadOnlyList<Vector> 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<int>();
var none = Array.Empty<int>();
var prerequisites = holes.Select(_ => (IReadOnlyCollection<int>)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();
}
}
@@ -39,8 +39,13 @@ internal static class CuttingPartOrder
/// <param name="centres">One representative point per part ordinal.</param>
/// <param name="start">The modeled tool position before the first part.</param>
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
/// <param name="endpoint">
/// 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.
/// </param>
internal static int[] Plan(IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
IReadOnlyList<IReadOnlyCollection<int>> 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]]);
}
}
@@ -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)))
@@ -10,8 +10,9 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// 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 <see cref="CuttingPartOrder"/>;
/// 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 <see cref="CuttingPartOrder"/>;
/// 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
/// </summary>
internal const int StallExpansionsPerEntry = 8;
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
IReadOnlyList<CuttingPlanFinding> 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<int>(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<CuttingPlanFinding> 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<int, ContourEntryFeasibility> feasibility = [];
private readonly HashSet<int> reportedNoFit = [];
internal int Expansions { get; private set; }
/// <summary>Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free.</summary>
internal int LeadPrechecks { get; private set; }
internal IReadOnlyList<CuttingPlanFinding> Rejected => rejected.Distinct().ToArray();
internal Outcome Ready(IReadOnlyList<FixedProgramPlacement> 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);
}
/// <summary>
@@ -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.
/// </summary>
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<Edge> Expand(Node node, int[] sequence, Attempt attempt)
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
{
IEnumerable<FixedProgramPlacement> 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<ContourChoice> 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);
}
}
}
}
/// <summary>
/// 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.
/// </summary>
private IReadOnlyList<ContourChoice> 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<ContourChoice> 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<int, ContourChoice>();
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<int, ContourChoice> Entries,
IReadOnlyDictionary<int, Vector> 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);
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
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);
/// <summary>Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children.</summary>
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;
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal static class PreferredContourEntries
{
/// <summary>Proposed hole choices in cut order, or an explicit no-preference verdict.</summary>
internal sealed record Proposal(
IReadOnlyList<ContourChoice> HoleChoices,
ContourSelectionShortfall Shortfall,
int? BlockedContour,
string? Reason)
{
public bool IsPreferred => Shortfall != ContourSelectionShortfall.Incomplete
&& HoleChoices.Count > 0 && BlockedContour is null;
}
/// <summary>
/// Resolves one preferred entry per hole on <paramref name="holeRoute"/> (cut order,
/// from <see cref="CuttingHoleOrder"/>), ending at <paramref name="perimeterChoice"/>.
/// 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. <paramref name="evaluate"/> is the S07 adapter
/// verdict; candidates it refuses or leaves uncertain are not preferred.
/// </summary>
internal static Proposal TryPlan(
PreparedContours prepared,
ContourChoice perimeterChoice,
IReadOnlyList<int> holeRoute,
IReadOnlyList<Vector> centresByOrdinal,
Vector arrival,
Func<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourChoice>(), 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<ContourChoice>(), 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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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));
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public class ContourCandidateEmissionTests
{
private static PreparedContours Capture(Program program, CuttingParameters? parameters = null) =>
PreparedContours.Capture(program, parameters ?? ExplicitContourTests.Parameters());
// --- fixtures ---------------------------------------------------------------
/// <summary>Square perimeter with two circular holes; Capture lists holes 0-1, perimeter 2.</summary>
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<ArgumentException>(() => prepared.Emit(new[] { corner }));
Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => prepared.EmitCandidateForValidation(forgedContour));
var forgedEntity = other.Entry(0, 0, new Vector(3.5, 3)) with { Owner = prepared, EntityOrdinal = 99 };
Assert.Throws<ArgumentException>(() => 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<ExecutionMotion> NonRapid(Program program) =>
ExecutionMotionReader.Read(program, Vector.Zero, null, default).Motions
.Where(m => !m.Rapid).ToList();
/// <summary>
/// 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.
/// </summary>
/// <summary>The complete program's LAST contour is the perimeter: assert the isolated
/// probe's blocks equal the trailing block sequence of the complete program.</summary>
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<string> BlockHashes(Program program)
{
var motions = NonRapid(program).Where(m => m.Layer != LayerType.Scribe).ToList();
var blocks = new List<List<ExecutionMotion>>();
var current = new List<ExecutionMotion>();
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<ExecutionMotion>();
}
current.Add(motion);
if (!isLead)
sawCut = true;
}
if (current.Count > 0)
blocks.Add(current);
return blocks.Select(Hash).ToList();
}
private static string Hash(List<ExecutionMotion> 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);
}
}
@@ -0,0 +1,421 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
public class ContourEntryCandidateTests
{
private static PreparedContours Capture(Program program) =>
PreparedContours.Capture(program, ExplicitContourTests.Parameters());
// --- fixtures ---------------------------------------------------------------
private static Program ClosedContour(IEnumerable<Vector> 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;
}
/// <summary>Square perimeter with an L-notch hole slug; its concave vertex (3,3.5) is reflex for the slug's own travel.</summary>
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<ArgumentException>(() => 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<OperationCanceledException>(() =>
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<ArgumentException>(() => 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<ContourEntryCandidate> 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<ArgumentException>(() => 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<OperationCanceledException>(() =>
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);
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ArgumentException>(() => 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<OperationCanceledException>(() => 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<OperationCanceledException>(() => 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);
}
}
@@ -0,0 +1,207 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
public class ContourEntryRankingTests
{
private static IReadOnlyList<ContourEntryCandidate> Catalogue(Program program) =>
PreparedContours.Capture(program, ExplicitContourTests.Parameters())
.AutomaticEntryCandidatesWithFallbacks(0);
// --- fixtures ---------------------------------------------------------------
/// <summary>Square 0..10 in travel order, optionally cyclically reindexed or reversed.</summary>
private static Program Square(IEnumerable<Vector> 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<ContourEntryCandidate> 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<ArgumentException>(() =>
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<ContourEntryCandidate>();
Assert.Empty(empty.RankTowardNextCut(new Vector(3, 4), new Vector(-1, -1)));
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourEntryCandidate, ContourFeasibilityVerdict> 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<ContourEntryCandidate>();
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<ContourEntryCandidate>
{
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<ContourEntryCandidate>
{
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<ContourEntryCandidate>();
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<ContourEntryCandidate>
{
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<ContourEntryCandidate>
{
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<ArgumentException>(() =>
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => null!, 0));
Assert.Throws<ArgumentException>(() =>
ContourEntrySelection.Select(Array.Empty<ContourEntryCandidate>(), _ => 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<ContourEntryCandidate>(), _ => 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<OperationCanceledException>(() =>
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<ContourEntryCandidate>
{
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<ContourEntryCandidate>
{
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);
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<int> route, IReadOnlyList<Vector> 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<int> holes, IReadOnlyList<Vector> centres,
Vector arrival, Vector? endpoint)
{
double Best(IEnumerable<int> 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<int>(), 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<int>)Array.Empty<int>()).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<int>();
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<OperationCanceledException>(() =>
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<int>();
var prerequisites = centres.Select(_ => (IReadOnlyCollection<int>)none).ToList();
var legacy = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default);
var extended = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default, endpoint: null);
Assert.Equal(legacy, extended);
}
}
@@ -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);
}
}
@@ -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)));
}
}
@@ -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));
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>Actual air moves: each rapid's distance from the previous motion's end to the following cut end.</summary>
private static List<double> Rapids(CuttingPlanResult result)
{
var rapids = new List<double>();
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))}";
}
@@ -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;
/// <summary>
/// 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.)
/// </summary>
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;
}
/// <summary>The owned compass candidate of a circle contour at an exact point.</summary>
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<ArgumentException>(() => 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<OperationCanceledException>(() => 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
}
}
@@ -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<OperationCanceledException>(() => 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<ContourEntryCandidate> 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<ContourEntryCandidate> ranked) =>
selected.Choices.SelectMany(c => Sides(c.Point, ranked)).Distinct().Order().ToArray();
private static IEnumerable<int> Sides(Vector point, IReadOnlyList<ContourEntryCandidate> 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<ContourEntryCandidate> 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,
};
}
@@ -0,0 +1,282 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingStrategy;
/// <summary>
/// The shared classification query for automatic start-point planning reuses the emitter's
/// own corner geometry and winding derivation: convex, reflex, smooth and cusp vertices
/// classify identically from either adjacent entity, in both windings, and rotations of the
/// shape do not change the kind. Midpoints are not corners.
/// </summary>
public class AutomaticCornerClassificationTests
{
private const double ToleranceDegrees = 1e-6;
// --- fixtures -------------------------------------------------------------
private static Shape ClosedShape(params Entity[] entities)
{
var shape = new Shape();
shape.Entities.AddRange(entities);
Assert.True(shape.IsClosed());
return shape;
}
private static Shape Square(Vector[] corners)
{
var e = new Entity[corners.Length];
for (var i = 0; i < corners.Length; i++)
e[i] = new Line(corners[i], corners[(i + 1) % corners.Length]);
return ClosedShape(e);
}
/// <summary>CCW square; the shape's own winding derivation says so.</summary>
private static Shape CcwSquare(double size = 10) =>
Square(new[]
{
new Vector(0, 0), new Vector(size, 0), new Vector(size, size), new Vector(0, size),
});
/// <summary>Same square traversed CW.</summary>
private static Shape CwSquare(double size = 10)
{
var s = CcwSquare(size);
s.Reverse();
return s;
}
/// <summary>CCW L-shape: the concave vertex (5,5) turns right — reflex.</summary>
private static Shape Notched()
{
return Square(new[]
{
new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(5, 10),
new Vector(5, 5), new Vector(0, 5),
});
}
/// <summary>CCW square whose top side is a half-circle bump; the tangent joints are at (0,10) and (10,10).</summary>
private static Shape ArcBumpSquare()
{
return ClosedShape(
new Line(new Vector(0, 0), new Vector(10, 0)),
new Line(new Vector(10, 0), new Vector(10, 10)),
new Arc(new Vector(5, 10), 5, 0, System.Math.PI),
new Line(new Vector(0, 10), new Vector(0, 0)));
}
// --- positive cases --------------------------------------------------------
public static IEnumerable<object[]> CcwSquareCorners()
{
var corners = new[]
{
new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10),
};
for (var i = 0; i < corners.Length; i++)
{
yield return new object[] { corners[i], i };
yield return new object[] { corners[i], (i + corners.Length - 1) % corners.Length };
}
}
[Theory]
[MemberData(nameof(CcwSquareCorners))]
public void SquareCorners_AreConvexFromEitherEdge(Vector corner, int entityIndex)
{
var shape = CcwSquare();
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(
shape, corner, shape.Entities[entityIndex], out var found));
Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, found.Kind);
}
[Theory]
[MemberData(nameof(CcwSquareCorners))]
public void TraversedBackwards_SquareCornersStayConvex(Vector corner, int entityIndex)
{
var shape = CwSquare();
// Same geometric corners; entity order is reversed, so look the vertex up by position
// and alternate between the two entities adjacent to it.
var (entity, point) = AdjacentToVertex(shape, corner, entityIndex);
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(
shape, point, entity, out var found));
Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, found.Kind);
}
[Fact]
public void NotchVertex_IsReflexFromEitherEdge()
{
var shape = Notched();
var notch = new Vector(5, 5);
var incoming = shape.Entities.Single(e => EndOf(e) == notch);
var outgoing = shape.Entities.Single(e => StartOf(e) == notch);
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, incoming, out var a));
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, outgoing, out var b));
Assert.Equal(ContourCuttingStrategy.CornerKind.Reflex, a.Kind);
Assert.Equal(a.Kind, b.Kind);
Assert.Equal(ContourCuttingStrategy.CornerKind.Reflex,
ClassifyAtVertex(CwShaped(Notched()), notch));
}
[Theory]
[InlineData(0, 10)]
[InlineData(10, 10)]
public void TangentLineArcJoints_AreSmoothNotCorners(double x, double y)
{
var shape = ArcBumpSquare();
var point = new Vector(x, y);
var incoming = shape.Entities.Single(e => EndOf(e) == point);
var outgoing = shape.Entities.Single(e => StartOf(e) == point);
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, incoming, out var a));
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, outgoing, out var b));
Assert.Equal(ContourCuttingStrategy.CornerKind.Smooth, a.Kind);
Assert.Equal(b.Kind, a.Kind);
}
[Fact]
public void ReversalVertex_IsCusp()
{
// The emitter's own rule: equal-and-opposite travel tangents (turn ≈ 0, dot < 0).
var shape = Square(new[]
{
new Vector(0, 0), new Vector(5, 0), new Vector(0, 0), new Vector(0, 10), new Vector(10, 10),
new Vector(10, 0),
});
var cusp = new Vector(0, 0);
var entity = shape.Entities.First(e => StartOf(e) == cusp);
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, cusp, entity, out var found));
Assert.Equal(ContourCuttingStrategy.CornerKind.Cusp, found.Kind);
}
// --- invariants -------------------------------------------------------------
[Theory]
[InlineData(0.37)]
[InlineData(1.9)]
[InlineData(4.71)]
public void RotatingTheShape_DoesNotChangeTheKind(double angle)
{
var shape = Notched();
var notch = new Vector(5, 5);
var entity = shape.Entities.Single(e => StartOf(e) == notch);
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, notch, entity, out var before));
shape.Rotate(angle);
var (rotatedEntity, rotatedPoint) = NearestVertex(shape, notch.Rotate(angle));
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(
shape, rotatedPoint, rotatedEntity, out var after));
Assert.Equal(before.Kind, after.Kind);
}
[Fact]
public void Midpoint_IsNotACorner()
{
var shape = CcwSquare();
var edge = Assert.IsType<Line>(shape.Entities[1]);
Assert.False(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(
shape, edge.MidPoint, edge, out _));
}
[Fact]
public void OpenContour_HasNoClassifiableCorners()
{
var shape = CcwSquare();
shape.Entities.RemoveAt(2);
Assert.False(shape.IsClosed());
Assert.False(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(
shape, new Vector(10, 10), shape.Entities[1], out _));
}
[Fact]
public void KindMatchesTheExistingEmitterClassification()
{
// Cross-check against the lead-in path actually used by emission: an outside
// square corner is where ResolveLeadIn extends the outgoing edge (convex), and
// the notch is where the internal-style lead bisects (reflex). The wrapper must
// agree with what EmitContour/ResolveLeadIn already do, not invent a third rule.
var square = CcwSquare();
var corner = new Vector(10, 0);
var outgoing = square.Entities.Single(e => StartOf(e) == corner);
Assert.True(ContourCuttingStrategy.TryClassifyAutomaticStartCorner(
square, corner, outgoing, out var kind));
Assert.Equal(ContourCuttingStrategy.CornerKind.Convex, kind.Kind);
// Tangents are travel directions of the two joined edges.
Assert.Equal(1.0, kind.TangentIn.X, 9);
Assert.Equal(0.0, kind.TangentIn.Y, 9);
Assert.Equal(0.0, kind.TangentOut.X, 9);
Assert.Equal(1.0, kind.TangentOut.Y, 9);
}
// --- helpers ----------------------------------------------------------------
private static Shape CwShaped(Shape shape)
{
shape.Reverse();
return shape;
}
private static ContourCuttingStrategy.CornerKind? ClassifyAtVertex(Shape shape, Vector vertex)
{
var (entity, point) = NearestVertex(shape, vertex);
return ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, point, entity, out var found)
? found.Kind
: null;
}
private static (Entity Entity, Vector Point) AdjacentToVertex(Shape shape, Vector vertex, int entityIndex)
{
// The two entities of the reversed contour that touch this geometric vertex;
// entityIndex alternates between them so both edge selections are covered.
var adjacent = shape.Entities
.Where(e => StartOf(e).DistanceTo(vertex) <= 1e-9 || EndOf(e).DistanceTo(vertex) <= 1e-9)
.ToList();
Assert.Equal(2, adjacent.Count);
return (adjacent[entityIndex % 2], vertex);
}
private static (Entity Entity, Vector Point) NearestVertex(Shape shape, Vector approximate)
{
Entity? best = null;
var bestPoint = Vector.Zero;
var bestDistance = double.MaxValue;
foreach (var entity in shape.Entities)
{
foreach (var point in new[] { StartOf(entity), EndOf(entity) })
{
var d = point.DistanceTo(approximate);
if (d < bestDistance)
{
bestDistance = d;
best = entity;
bestPoint = point;
}
}
}
Assert.NotNull(best);
return (best!, bestPoint);
}
private static Vector StartOf(Entity entity) => entity switch
{
Line line => line.StartPoint,
Arc arc => arc.StartPoint(),
_ => throw new NotSupportedException(),
};
private static Vector EndOf(Entity entity) => entity switch
{
Line line => line.EndPoint,
Arc arc => arc.EndPoint(),
_ => throw new NotSupportedException(),
};
}
@@ -0,0 +1,138 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests;
public class PlateDisplayNumberingTests
{
private static Nest CreateNest() => new("test");
private static Part MakePart()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Part(new Drawing("test", pgm));
}
[Fact]
public void PopulatedThenSentinel_TotalIsOneAndRealPlateDisplaysAsOne()
{
var nest = CreateNest();
var real = nest.CreatePlate();
real.Parts.Add(MakePart());
nest.CreatePlate(); // The trailing empty sentinel.
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0));
Assert.Equal("Plate 1 of 1", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null));
}
[Fact]
public void Sentinel_IsLabeledAsNewPlateNotNumbered()
{
var nest = CreateNest();
var real = nest.CreatePlate();
real.Parts.Add(MakePart());
nest.CreatePlate();
var sentinel = nest.Plates.Count - 1;
Assert.True(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, sentinel));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, sentinel));
Assert.Equal("New plate (empty)", PlateDisplayNumbering.FormatHeader(nest.Plates, sentinel, null));
}
[Fact]
public void TwoRealPlatesThenSentinel_TotalIsTwo()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
nest.Plates.Add(WithPart());
nest.CreatePlate(); // sentinel
Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0));
Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 1));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 2));
}
[Fact]
public void NoPlates_TotalIsZeroAndHeaderSaysNoPlates()
{
var nest = CreateNest();
Assert.Equal(0, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 0));
Assert.Equal("No plates", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null));
}
[Fact]
public void SingleEmptyPlate_IsTheNewPlateView_NotNumbered()
{
var nest = CreateNest();
nest.CreatePlate(); // only the sentinel
Assert.Equal(0, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.True(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 0));
Assert.Equal("New plate (empty)", PlateDisplayNumbering.FormatHeader(nest.Plates, 0, null));
}
[Fact]
public void InteriorEmptyPlate_KeepsItsSlotAndNumber()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
nest.CreatePlate(); // An empty interior plate: EnsureSentinel only trims at the tail.
nest.Plates.Add(WithPart());
nest.CreatePlate(); // sentinel
Assert.Equal(3, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.Equal(2, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 1));
Assert.Equal(3, PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 2));
Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 1));
}
[Fact]
public void LastPlateWithParts_IsNotASentinel()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
Assert.Equal(1, PlateDisplayNumbering.DisplayedPlateCount(nest.Plates));
Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 0));
}
[Fact]
public void IndexOutsideCollection_HasNoNumberAndIsNotASentinel()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, 5));
Assert.Null(PlateDisplayNumbering.DisplayedPlateNumber(nest.Plates, -1));
Assert.False(PlateDisplayNumbering.IsTrailingSentinel(nest.Plates, 5));
}
[Fact]
public void Helper_DoesNotMutateTheNest()
{
var nest = CreateNest();
nest.Plates.Add(WithPart());
nest.CreatePlate();
var before = nest.Plates.Count;
PlateDisplayNumbering.DisplayedPlateCount(nest.Plates);
PlateDisplayNumbering.FormatHeader(nest.Plates, 1, "100 x 100");
Assert.Equal(before, nest.Plates.Count);
}
private static Plate WithPart()
{
var plate = new Plate();
plate.Parts.Add(MakePart());
return plate;
}
}
@@ -202,6 +202,31 @@ public class CuttingPlanFormTests
Assert.All(first.Parts, part => Assert.False(part.HasManualLeadIns));
});
[Fact]
public void OnePlateDialog_ExcludesTheSentinelFromTheShownTotal() => RunSta(() =>
{
var (nest, view) = CreateView(Square("a", 1, 1));
nest.CreatePlate(); // The editor's trailing empty new-plate sentinel.
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
Assert.StartsWith("Plate 1 of 1, numbered in cutting order:", Control<Label>(form, "previewLabel").Text);
});
[Fact]
public void SentinelActivePlate_IsLabeledNotNumberedBeyondTheTotal() => RunSta(() =>
{
var (nest, _) = CreateView(Square("a", 1, 1));
var sentinel = nest.CreatePlate(); // trailing empty new-plate sentinel
using var view = new PlateView { Plate = sentinel };
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
var text = Control<Label>(form, "previewLabel").Text;
Assert.StartsWith("New plate (empty)", text);
Assert.DoesNotContain("Plate 2", text);
});
private static (Nest Nest, PlateView View) CreateView(params Part[] parts)
{
var nest = new Nest();
@@ -0,0 +1,96 @@
using System.Reflection;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
public class PlateHeaderNumberingTests
{
[Fact]
public void OnePopulatedPlateWithSentinel_HeaderSaysOneOfOne() => RunSta(() =>
{
var nest = new Nest("one plate");
var plate = nest.CreatePlate();
plate.Parts.Add(Square("a"));
using var form = new EditNestForm(nest);
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
// The constructor's EnsureSentinel keeps the trailing empty new-plate workspace.
Assert.Equal(2, nest.Plates.Count);
Assert.Equal("Plate 1 of 1", Header(form).Split(" | ")[0]);
});
[Fact]
public void NavigatingOntoTheSentinelAndBack_RelabelsWithoutTouchingNavigation() => RunSta(() =>
{
var nest = new Nest("sentinel navigation");
var plate = nest.CreatePlate();
plate.Parts.Add(Square("a"));
using var form = new EditNestForm(nest);
form.PlateView.SetOverlapAutoCheck(null);
form.Show();
Assert.True(form.PlateManager.LoadNext()); // onto the sentinel
Assert.Equal("New plate (empty)", Header(form));
Assert.Equal(1, form.PlateManager.CurrentIndex); // navigation itself is unchanged
Assert.True(form.PlateManager.LoadPrevious());
Assert.Equal("Plate 1 of 1", Header(form).Split(" | ")[0]);
Assert.Equal(2, nest.Plates.Count); // the collection was never mutated for display
});
[Fact]
public void SingleEmptyPlate_EditorShowsTheNewPlateView() => RunSta(() =>
{
var nest = new Nest("empty");
using var form = new EditNestForm(nest); // EnsureSentinel creates the single empty plate.
form.PlateView.SetOverlapAutoCheck(null);
form.Show();
Assert.Single(nest.Plates);
Assert.Equal("New plate (empty)", Header(form));
});
[Fact]
public void InteriorEmptyPlate_KeepsItsNumberAndTheTotalCountsIt() => RunSta(() =>
{
var nest = new Nest("interior empty");
nest.CreatePlate().Parts.Add(Square("a"));
nest.CreatePlate(); // An empty interior plate survives EnsureSentinel (only the tail trims).
nest.CreatePlate().Parts.Add(Square("b"));
using var form = new EditNestForm(nest);
form.PlateView.SetOverlapAutoCheck(null);
form.Show();
Assert.Equal(4, nest.Plates.Count); // three real + sentinel
form.PlateManager.LoadAt(1);
Assert.Equal("Plate 2 of 3", Header(form).Split(" | ")[0]);
form.PlateManager.LoadAt(2);
Assert.Equal("Plate 3 of 3", Header(form).Split(" | ")[0]);
form.PlateManager.LoadAt(3);
Assert.Equal("New plate (empty)", Header(form));
Assert.Equal(4, nest.Plates.Count);
});
private static string Header(EditNestForm form) =>
((Label)typeof(EditNestForm)
.GetField("plateInfoLabel", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(form)!).Text;
private static Part Square(string name)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(0, 10);
program.LineTo(10, 10);
program.LineTo(10, 0);
program.LineTo(0, 0);
return new Part(new Drawing(name, program), new Vector(1, 1));
}
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromMinutes(3), "The STA test did not complete.");
}
@@ -0,0 +1,155 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.WinForms.Tests;
public class GraphicsHelperPlacementTests
{
[Theory]
[InlineData(Mode.Absolute, false)]
[InlineData(Mode.Absolute, true)]
[InlineData(Mode.Incremental, false)]
[InlineData(Mode.Incremental, true)]
public void PlacementTranslatesEveryOutlineAndLeadPointWithoutChangingProgram(Mode mode, bool splitPaths)
{
var program = new CNC.Program(Mode.Absolute);
program.Codes.AddRange(new ICode[]
{
new RapidMove(-1, 0),
new LinearMove(0, 0) { Layer = LayerType.Leadin },
new LinearMove(0, 4),
new LinearMove(3, 4),
new ArcMove(4, 3, 3, 3, RotationType.CW),
new LinearMove(4, 0),
new LinearMove(0, 0),
new ArcMove(-1, -1, -1, 0, RotationType.CW) { Layer = LayerType.Leadout },
new RapidMove(3, 2),
new ArcMove(3, 2, 2, 2),
});
program.Mode = mode;
var before = NestWriter.GetProgramText(program);
var instructions = program.Codes.ToArray();
using var local = program.GetGraphicsPath();
program.GetGraphicsPaths(Vector.Zero, out var localCut, out var localLead);
using (localCut)
using (localLead)
{
Assert.True(local.PointCount > 0);
Assert.True(localCut.PointCount > 0);
Assert.True(localLead.PointCount > 0);
foreach (var origin in new[] { new Vector(10.25, 6.5), new Vector(-7.5, 12.25), Vector.Zero })
{
if (!splitPaths)
{
using var placed = program.GetGraphicsPath(origin);
AssertTranslated(local, placed, origin);
}
else
{
program.GetGraphicsPaths(origin, out var cut, out var lead);
using (cut)
using (lead)
{
AssertTranslated(localCut, cut, origin);
AssertTranslated(localLead, lead, origin);
}
}
}
}
Assert.Equal(before, NestWriter.GetProgramText(program));
Assert.Equal(instructions, program.Codes);
Assert.Equal(mode, program.Mode);
}
[Fact]
public void IncrementalSubprogramOffsetsStayRelativeToProgramOrigin()
{
var hole = new CNC.Program(Mode.Incremental);
hole.Codes.AddRange(new ICode[]
{
new RapidMove(1, 0),
new ArcMove(0, 0, -1, 0),
});
var program = new CNC.Program(Mode.Incremental);
program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(3, 4) });
program.Codes.Add(new SubProgramCall(hole, 0) { Offset = new Vector(9, 4) });
var before = NestWriter.GetSubProgramsText(program);
using var local = program.GetGraphicsPath();
using var placed = program.GetGraphicsPath(new Vector(20, 30));
AssertTranslated(local, placed, new Vector(20, 30));
Assert.Equal(new RectangleF(22, 33, 8, 2), placed.GetBounds());
program.GetGraphicsPaths(new Vector(20, 30), out var cut, out var lead);
using (cut)
using (lead)
{
AssertTranslated(local, cut, new Vector(20, 30));
Assert.Equal(0, lead.PointCount);
}
Assert.Same(hole, ((SubProgramCall)program.Codes[0]).Program);
Assert.Same(hole, ((SubProgramCall)program.Codes[1]).Program);
Assert.Equal(before, NestWriter.GetSubProgramsText(program));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SavedAbsolutePartsFollowRepeatedMovesAndViewTransform(bool splitPaths) => StaTestThread.Run(() =>
{
var fixture = Path.Combine(AppContext.BaseDirectory, "Fixtures", "absolute-coordinate-parts.nest");
var nest = new NestReader(fixture).Read();
Assert.Equal(2, nest.Plates.Count);
Assert.Equal(3, nest.Plates.Sum(plate => plate.Parts.Count));
using var view = new PlateView();
view.Matrix.Reset();
view.Matrix.Scale(2, -2);
view.Matrix.Translate(40, 80, MatrixOrder.Append);
foreach (var part in nest.Plates.SelectMany(plate => plate.Parts))
{
Assert.Equal(Mode.Absolute, part.Program.Mode);
part.HasManualLeadIns = splitPaths;
var before = NestWriter.GetProgramText(part.Program);
var initial = part.Location;
var layout = LayoutPart.Create(part, view);
try
{
foreach (var location in new[] { initial, new Vector(4.25, 10.75), new Vector(15.5, 3.25), initial })
{
part.Location = location;
layout.Path.Dispose();
layout.Update(view);
using var expected = part.Program.GetGraphicsPath();
using var translation = new Matrix();
translation.Translate((float)location.X, (float)location.Y);
expected.Transform(translation);
expected.Transform(view.Matrix);
AssertTranslated(expected, layout.Path, Vector.Zero);
if (splitPaths)
Assert.Equal(0, layout.LeadInPath.PointCount);
Assert.Equal(before, NestWriter.GetProgramText(part.Program));
}
}
finally
{
layout.Path.Dispose();
layout.LeadInPath?.Dispose();
}
}
}, TimeSpan.FromMinutes(1), "Absolute-coordinate placement test did not complete.");
private static void AssertTranslated(GraphicsPath local, GraphicsPath placed, Vector offset)
{
Assert.Equal(local.PointCount, placed.PointCount);
Assert.Equal(local.PathTypes, placed.PathTypes);
var expected = local.PathPoints;
var actual = placed.PathPoints;
for (var i = 0; i < expected.Length; i++)
{
Assert.InRange(actual[i].X - (expected[i].X + offset.X), -0.00001, 0.00001);
Assert.InRange(actual[i].Y - (expected[i].Y + offset.Y), -0.00001, 0.00001);
}
}
}
@@ -15,5 +15,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest\OpenNest.csproj" />
<Content Include="Fixtures\absolute-coordinate-parts.nest" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+9 -4
View File
@@ -85,10 +85,15 @@ public partial class CuttingPlanForm : Form
Text = "Plan Cutting — All Plates";
applyButton.Text = "&Apply to All Plates";
}
var activeNumber = all.IndexOf(activePlate) + 1;
previewText = plates.Contains(activePlate)
? $"Plate {activeNumber} of {all.Count}, numbered in cutting order:"
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
// "of M" total; plateNumbers keep real collection positions for the batch summary.
var displayCount = PlateDisplayNumbering.DisplayedPlateCount(all);
var activeNumber = PlateDisplayNumbering.DisplayedPlateNumber(all, all.IndexOf(activePlate));
previewText = activeNumber == null
? "New plate (empty) has no parts; plates with parts are listed on the right."
: plates.Contains(activePlate)
? $"Plate {activeNumber} of {displayCount}, numbered in cutting order:"
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
previewLabel.Text = previewText;
ShowSettings();
}
+4 -13
View File
@@ -781,19 +781,10 @@ namespace OpenNest.Forms
{
var plate = PlateManager.CurrentPlate;
if (plate != null)
{
plateInfoLabel.Text = string.Format(
"Plate {0} of {1} | {2}",
PlateManager.CurrentIndex + 1,
PlateManager.Count,
plate.Size
);
}
else
{
plateInfoLabel.Text = "No plates";
}
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
// total (and labeled as such); navigation and storage indexes are untouched.
plateInfoLabel.Text = PlateDisplayNumbering.FormatHeader(
Nest.Plates, PlateManager.CurrentIndex, plate.Size.ToString());
btnFirstPlate.Enabled = !PlateManager.IsFirst;
btnPreviousPlate.Enabled = !PlateManager.IsFirst;
+28 -21
View File
@@ -1,4 +1,4 @@
using System.Drawing;
using System.Drawing;
using System.Drawing.Drawing2D;
using OpenNest.CNC;
using OpenNest.Geometry;
@@ -20,10 +20,12 @@ namespace OpenNest
public static GraphicsPath GetGraphicsPath(this Program pgm, Vector origin)
{
var path = new GraphicsPath();
var curpos = origin;
AddProgram(path, pgm, pgm.Mode, ref curpos);
// Program coordinates are drawing-local in either mode. Translate the
// finished path so absolute moves cannot discard the part placement.
var path = pgm.GetGraphicsPath();
using var translation = new Matrix();
translation.Translate((float)origin.X, (float)origin.Y);
path.Transform(translation);
return path;
}
@@ -100,9 +102,14 @@ namespace OpenNest
{
cutPath = new GraphicsPath();
leadPath = new GraphicsPath();
var curpos = origin;
var curpos = Vector.Zero;
AddProgramSplit(cutPath, leadPath, pgm, pgm.Mode, ref curpos);
using var translation = new Matrix();
translation.Translate((float)origin.X, (float)origin.Y);
cutPath.Transform(translation);
leadPath.Transform(translation);
}
private static void AddProgramSplit(
@@ -354,23 +361,23 @@ namespace OpenNest
break;
case CodeType.SubProgramCall:
{
Flush();
var tmpmode = mode;
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
{
curpos = new Vector(
frameOrigin.X + subpgm.Offset.X,
frameOrigin.Y + subpgm.Offset.Y
);
AddProgram(path, subpgm.Program, mode, ref curpos);
}
Flush();
var tmpmode = mode;
var subpgm = (SubProgramCall)code;
mode = tmpmode;
break;
}
if (subpgm.Program != null)
{
curpos = new Vector(
frameOrigin.X + subpgm.Offset.X,
frameOrigin.Y + subpgm.Offset.Y
);
AddProgram(path, subpgm.Program, mode, ref curpos);
}
mode = tmpmode;
break;
}
}
}
+82 -4
View File
@@ -77,7 +77,14 @@ the final replay enforce them:
With regeneration, the bounded deterministic search plans internal contour order
and native entry candidates part by part along a whole-part order. Internal
contours precede their own perimeter; parts remain contiguous. Backtracking can
revisit an earlier entry when a later part cannot be reached safely.
revisit an earlier entry when a later part cannot be reached safely. For multi-part
requests containing regenerated holes, it first tries one ranked hole chain for
each outside endpoint. This lets a later blocked approach change the previous
part's departure without first exhausting combinations of its earlier holes.
This preferred pass uses the same emitted-motion checks, shared expansion budget
and stall limit as the retained search; on failure the full entry/hole-order
backtracking pass remains available. Single-part and no-hole requests keep their
existing search order.
A preserved order is followed as given. Otherwise the order is an open
travelling-salesman path over part centres from the start point: nearest neighbour,
@@ -100,6 +107,22 @@ and tab trimming happen during emission. Validation uses the actual emitted
motions, never the nominal entry point alone. Existing lead styles are not
shortened, disabled or substituted as a search fallback.
Whole-circle candidates rank by distance to their next-cut target; for a hole,
that is the next contour's actual pierce. They do not reward a diagonal point as
if it were a bounding-box corner. All eight
compass options remain available, including the four polar points at 0°, 90°,
180° and 270°, and every alternative still passes the emitted-lead and rapid checks.
Without a next-cut target, the arrival-based rule is unchanged. Polygon corner
preferences are unchanged.
`Round Lead-In Angles` remains an explicit cutting setting: a 90° increment snaps
circular-hole starts to the four polar directions; 45° also permits diagonals.
Snapping can increase reuse of identical hole subprograms and reduce output for
posts that support that reuse, at the cost of a less direct departure. It is not
a clearance exemption or a guaranteed file-size reduction. The planner does not
silently enable rounding, change the increment, or bypass checks on the rounded
motions.
Every candidate rapid is checked against contours already completed, including
earlier holes in the same part. Future contours are not yet obstacles. Rapid
checks skip completed contours whose extents are more than 0.001 clear of the
@@ -121,13 +144,57 @@ tangent arc at a shared vertex, such as a fillet, is an ordinary joint: an exact
contact that the native query rounds away is not uncertain at a line endpoint the
other curve already touches, while a contact anywhere else on the line still refuses.
Candidates rank by actual modeled rapid distance with stable source/contour/entry
ordinals. Hash values and drawing names are not tie breakers. The expansion budget
Source parts rank by modeled travel (material-centre distance at a holed-part
boundary); within a part, preferred contour order and facing-entry rank precede
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget
counts rejected candidates and frontier ranking as well as accepted moves, before
emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur
before already-generated siblings are traversed; it returns a refusal, not an
unranked fallback or a proof of geometric impossibility.
## Automatic outside entries and look-ahead
An unlocked part's outside entry is chosen automatically toward the NEXT cut: the ranker orders the native candidate
catalogue by the facing side(s) of the next part's placed-material centre, and
the shared lead validator certifies each emitted lead lazily until up to
`maxEntries` feasible candidates remain. At caps of four or more, a corrective
scan reconsiders memoized clear candidates for each missing side before evaluating
more of the catalogue; a later side cannot lose a usable point merely because
an earlier side's scan passed it. Replacements preserve other covered sides and
global rank, never exceed the cap, and stop evaluating the tail once coverage
settles. Smaller caps retain rank priority rather than promising all-side coverage.
The next cut is the next unfinished part in a supplied order — recomputed
after every learned-order replan — or, in the full fallback search, the nearest
dependency-ready remaining part, stable-ordinal ties; the last part has no
target and ranks by tier then distance to the tool's arrival. Between source
parts the tour stays nearest-first; the look-ahead rank only orders the entries
inside one part's contour stage, so distance sorting cannot undo the facing.
Uncertain (numerically incomplete) validator answers are not geometric refusals:
those candidates can fill remaining retained slots for emitted-prefix checking and
complete replay. The total retained entry count stays within `maxEntries`. A
part/contour with no fitting lead in its fully evaluated catalogue is reported
as "No tested lead-in fits on part N, contour M"; budget exhaustion stays a
budget finding and incomplete checks are never presented as geometric
impossibility. Lead prechecks are reported separately and their requests also consume
the shared expansion budget before native work.
For a holed part, the search chooses an outside endpoint before cutting any hole.
Each endpoint branch builds an open hole-centre route from the original arrival to
that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement.
Preferred hole entries are then resolved backward from the outside's actual emitted
pierce, ignoring scribe marks: each hole faces the following contour's actual pierce.
Convex corners lead the preference tiers, then straight midpoints/tangent joints,
then native fallbacks. Reflex/cusp corners remain manual-only.
This preference orders the search; it never certifies a rapid or prunes alternate
retained entries or hole orders. A different outside endpoint recomputes its hole
preference. Every standalone emitted prefix is replayed from the original part
arrival and a copy of the checker from before that part, not from the previous
prefix (which would double-consume holes and scribes). Holes are cut once, the
outside last, and scribes once. Locked programs remain exact. A crossed preferred
route must recover through checked backtracking or return a refusal, never unsafe
`Ready`.
Selected programs are replayed from the beginning with a fresh checker and fresh
lead validation, without regenerating them or trusting cached search verdicts.
Before replay, expected-emission geometry is independently built from the owned
@@ -228,6 +295,14 @@ plans every plate that has parts. Both open one dialog built on
whole-part order; either change replans. The settings are confirmed parameters: every
unlocked part's lead-ins are regenerated, and locked parts keep programs that must
already pass the checks.
- A missing or zero-length lead-in is reported directly, rather than as a search-limit
failure. Open `Cutting Settings...`, choose a lead-in other than `None` with a nonzero
length on the affected `External`, `Internal`, or `Arc / Circle` tab, then replan.
Locked programs require manual lead editing or unlocking before regeneration.
When a lead hits another part, the finding suggests more spacing or a shorter lead;
when no tested entry fits, it suggests reducing lead-in length and, if neighbours
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
fixes: replanning runs the same checks, and Apply stays blocked until every plate is ready.
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
material is checked for overlaps with the pre-post overlap analyzer; overlapping parts or
an incomplete check (see [pre-post verification](post-verification.md)) block that plate
@@ -252,7 +327,10 @@ plans every plate that has parts. Both open one dialog built on
the thread it was created on rather than to whichever context is current.
- `PlateView` follows `Plate.PartsReordered`: it redraws parts in the plate's order (the
numbers it draws are the cutting order), rebuilds their graphics and marks the overlap
check out of date.
check out of date. Both the editor and preview build outlines and lead paths in the
drawing-local frame, then apply the part placement once. Absolute (G90) programs
therefore follow moves and clones just like incremental (G91) programs; displaying a
part does not rewrite its program or coordinate mode.
## Remaining integration boundaries