diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs
index d053251b..fbbc53b6 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs
@@ -55,20 +55,19 @@ internal static class ContourProgramVerifier
var first = run.Curves[0];
var ringIndex = -1;
var entityIndex = -1;
- for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
+ // The admitted slack is far below any extent margin, so only boundaries near the run's
+ // start can contain it; they come in ring order, as the full scan met them.
+ foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
{
+ Query(token, ref budget);
var admitted = AdmittedClosureSlack(material.Rings[r]);
- for (var e = 0; e < material.Rings[r].Length; e++)
+ var curve = material.Rings[r][e];
+ if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
+ && curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
{
- Query(token, ref budget);
- var curve = material.Rings[r][e];
- if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
- && curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
- {
- ringIndex = r;
- entityIndex = e;
- break;
- }
+ ringIndex = r;
+ entityIndex = e;
+ break;
}
}
if (ringIndex < 0) return false;
diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs
index 43c4d488..d1093f82 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs
@@ -91,39 +91,36 @@ public sealed class LeadMaterialSnapshot
throw new ArgumentException("Material has no closed contour.");
var budget = 1000000;
// Certify simple rings and mutually disjoint boundaries before containment.
- for (var r = 0; r < rings.Count; r++)
- for (var s = r; s < rings.Count; s++)
- for (var i = 0; i < rings[r].Length; i++)
- for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
- {
- token.ThrowIfCancellationRequested();
- if (--budget < 0)
- throw new NotSupportedException("Material validation exceeds the native query limit.");
- var a = rings[r][i];
- var b = rings[s][j];
- var contacts = a.Contacts(b, out var overlap);
- var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
- var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
- && (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
- || a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
- // Native arc queries pad angles by 1e-5 radians, admitting
- // intersections on an extension (distance grows with radius).
- // At a shared arc joint require both actual finite spans;
- // overlap and genuine second intersections still refuse.
- if (arcJoint)
- contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
- // Native contacts at rounded arc/line joints can drift from the
- // authored endpoint. Use the shared geometry tolerance only to
- // recognize that joint; overlaps and other contacts still refuse.
- if (overlap || contacts.Any(p => !adjacent
- || !(p.DistanceTo(a.End) <= Tolerance.Epsilon
- && p.DistanceTo(b.Start) <= Tolerance.Epsilon)
- && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
- && p.DistanceTo(b.End) <= Tolerance.Epsilon)))
- throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
- ? "A display perimeter must enclose every other contour."
- : $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
- }
+ foreach (var (r, i, s, j) in ContactCandidates(rings, token))
+ {
+ token.ThrowIfCancellationRequested();
+ if (--budget < 0)
+ throw new NotSupportedException("Material validation exceeds the native query limit.");
+ var a = rings[r][i];
+ var b = rings[s][j];
+ var contacts = a.Contacts(b, out var overlap);
+ var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
+ var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
+ && (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
+ || a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
+ // Native arc queries pad angles by 1e-5 radians, admitting
+ // intersections on an extension (distance grows with radius).
+ // At a shared arc joint require both actual finite spans;
+ // overlap and genuine second intersections still refuse.
+ if (arcJoint)
+ contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
+ // Native contacts at rounded arc/line joints can drift from the
+ // authored endpoint. Use the shared geometry tolerance only to
+ // recognize that joint; overlaps and other contacts still refuse.
+ if (overlap || contacts.Any(p => !adjacent
+ || !(p.DistanceTo(a.End) <= Tolerance.Epsilon
+ && p.DistanceTo(b.Start) <= Tolerance.Epsilon)
+ && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
+ && p.DistanceTo(b.End) <= Tolerance.Epsilon)))
+ throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
+ ? "A display perimeter must enclose every other contour."
+ : $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
+ }
var outer = -1;
for (var i = 0; i < rings.Count; i++)
{
@@ -180,8 +177,181 @@ public sealed class LeadMaterialSnapshot
}
}
- internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
- && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
+ ///
+ /// The boundary pairs certification queries natively, in the order of the full pairwise scan
+ /// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
+ /// margin () can report no
+ /// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
+ /// Material that is not well conditioned keeps every pair.
+ ///
+ private static List<(int R, int I, int S, int J)> ContactCandidates(
+ IReadOnlyList rings, CancellationToken token)
+ {
+ var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
+ var whole = PostVerificationGeometry.Extent.None;
+ for (var r = 0; r < rings.Count; r++)
+ for (var i = 0; i < rings[r].Length; i++)
+ {
+ curves.Add((r, i, rings[r][i].Extent));
+ whole = whole.Union(rings[r][i].Extent);
+ }
+ var pairs = new List<(int R, int I, int S, int J)>();
+ if (!whole.IsWellConditioned)
+ {
+ for (var a = 0; a < curves.Count; a++)
+ for (var b = a + 1; b < curves.Count; b++)
+ pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
+ }
+ else
+ {
+ // No pair spans more than the whole material, so this bounds every pair's margin.
+ var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
+ * System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
+ var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
+ for (var a = 0; a < byMinX.Length; a++)
+ {
+ token.ThrowIfCancellationRequested();
+ var first = byMinX[a];
+ for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
+ {
+ var second = byMinX[b];
+ if (first.Extent.IsClearOfQueriesFrom(second.Extent))
+ continue;
+ pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
+ ? (first.Ring, first.Index, second.Ring, second.Index)
+ : (second.Ring, second.Index, first.Ring, first.Index));
+ }
+ }
+ }
+ pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
+ return pairs;
+ }
+
+ /// Material with fewer boundaries is scanned whole, exactly as before the index.
+ private const int IndexedBoundaries = 64;
+
+ private BoundaryIndex index;
+
+ ///
+ /// The boundaries a native query from can report a contact with, in
+ /// ring order: every boundary but those whose extents are clear of it by the lead checks'
+ /// margin plus ().
+ /// Small material returns every boundary.
+ ///
+ internal IEnumerable BoundariesNear(PostVerificationGeometry.Extent query,
+ double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
+
+ /// As , each boundary as its ring and its index in that ring.
+ internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
+ {
+ if (BoundaryCount < IndexedBoundaries)
+ return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
+ return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
+ }
+
+ internal bool ContainsMaterial(Vector point, CancellationToken token)
+ {
+ if (BoundaryCount < IndexedBoundaries)
+ return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
+ var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
+ return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
+ }
+
+ ///
+ /// A uniform grid of boundary extents over the material, for material with many holes. Read
+ /// only once built, so concurrent searches may share it.
+ ///
+ private sealed class BoundaryIndex
+ {
+ private readonly (int Ring, int Index)[] positions;
+ private readonly PostVerificationGeometry.Extent[] extents;
+ private readonly List[] cells;
+ private readonly List[] bands;
+ private readonly PostVerificationGeometry.Extent[] ringExtents;
+ private readonly int ringCount;
+ private readonly PostVerificationGeometry.Extent whole;
+ private readonly int columns;
+ private readonly int rows;
+ private readonly double width;
+ private readonly double height;
+
+ internal BoundaryIndex(IReadOnlyList rings, PostVerificationGeometry.Extent whole)
+ {
+ positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
+ extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
+ ringCount = rings.Count;
+ ringExtents = new PostVerificationGeometry.Extent[rings.Count];
+ this.whole = whole;
+ var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
+ columns = side;
+ rows = side;
+ width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
+ height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
+ cells = new List[columns * rows];
+ bands = new List[rows];
+ if (!whole.IsWellConditioned)
+ return;
+ for (var ring = 1; ring < rings.Count; ring++)
+ {
+ var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
+ ringExtents[ring] = extent;
+ for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
+ band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
+ (bands[band] ??= new List()).Add(ring);
+ }
+ for (var b = 0; b < positions.Length; b++)
+ {
+ var (x0, y0, x1, y1) = Cells(extents[b], 0);
+ for (var y = y0; y <= y1; y++)
+ for (var x = x0; x <= x1; x++)
+ (cells[y * columns + x] ??= new List()).Add(b);
+ }
+ }
+
+ ///
+ /// The holes, in ring order, whose curves a ray along row can cross:
+ /// every hole but those whose extents are clear of the row (see
+ /// ).
+ ///
+ internal IEnumerable HolesOnRow(double y)
+ {
+ if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
+ return Enumerable.Range(1, ringCount - 1);
+ return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
+ }
+
+ internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
+ {
+ // The margin covers every pair's span, and each skip is then decided for the pair alone.
+ // Material or a query that is not well conditioned (whose cells were never built, for
+ // the material) keeps every boundary.
+ var union = whole.Union(query);
+ var margin = PostVerificationGeometry.Extent.ClearMargin + reach
+ + PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
+ if (!union.IsWellConditioned || !double.IsFinite(margin))
+ return positions;
+ var (x0, y0, x1, y1) = Cells(query, margin);
+ var found = new List();
+ for (var y = y0; y <= y1; y++)
+ for (var x = x0; x <= x1; x++)
+ if (cells[y * columns + x] is { } cell)
+ found.AddRange(cell);
+ found.Sort();
+ var near = new List<(int Ring, int Index)>();
+ for (var i = 0; i < found.Count; i++)
+ if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
+ near.Add(positions[found[i]]);
+ return near;
+ }
+
+ // The cells an extent widened by margin covers, clamped to the grid.
+ private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
+ Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
+
+ private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
+
+ private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
+ }
internal static bool Inside(Vector point, IReadOnlyList ring, CancellationToken token)
{
diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
index 98b96497..c442d441 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
@@ -125,7 +125,8 @@ public static class LeadPathValidator
var edge = execution.Motions[groupEdge];
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
- foreach (var boundary in target.Rings.SelectMany(r => r))
+ // A boundary clear of the contour's first motion contains none of its points.
+ foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
@@ -190,10 +191,7 @@ public static class LeadPathValidator
// Every boundary of material this far away is farther than the zone.
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
- {
- Charge(material.BoundaryCount);
continue;
- }
if (Clearance(material) is { } distance)
return (Short(distance, "another placed part"), other);
}
@@ -232,11 +230,11 @@ public static class LeadPathValidator
}
// Material too far from the lead for any native boundary query to report a contact
- // (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
- // Ray parity is still asked natively unless the lead lies beside the material's rows.
+ // (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
+ // are charged, so many far parts or holes do not exhaust the budget. Ray parity is
+ // still asked natively unless the lead lies beside the material's rows.
bool FarInside(LeadMaterialSnapshot material)
{
- Charge(material.BoundaryCount);
var midpoint = move.Curve.Midpoint;
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
}
@@ -289,7 +287,8 @@ public static class LeadPathValidator
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
{
- foreach (var boundary in material.Rings.SelectMany(r => r))
+ // Boundaries no native query from the curve can reach report no contact; only the others run.
+ foreach (var boundary in material.BoundariesNear(curve.Extent))
{
token.ThrowIfCancellationRequested();
if (--budget < 0)
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 03586dc7..d9205369 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -781,10 +781,12 @@ public sealed class PreparedContours
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.
+ // directly, so the probe cannot drift the preparation or mutate it. Isolated emission
+ // reads only the chosen contour and the contour count, so only that contour is cloned.
+ var owned = new Shape[shapes.Length];
+ owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
- shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
- contourParameters, PerimeterOrdinal);
+ owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
}
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
index 70570b65..48653550 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
@@ -12,6 +12,11 @@ public sealed class ReleasedContourState
{
private readonly List obstacles = new();
private readonly bool reportMissingLeadIns;
+ // How the last analysed part ended: the contours it numbered, and whether nothing it left
+ // (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
+ // motions appended to it are judged. See Continue.
+ private int partContours;
+ private bool partClosed;
public ReleasedContourState() : this(true) { }
@@ -24,7 +29,11 @@ public sealed class ReleasedContourState
public ReleasedContourState Copy()
{
- var copy = new ReleasedContourState(reportMissingLeadIns);
+ var copy = new ReleasedContourState(reportMissingLeadIns)
+ {
+ partContours = partContours,
+ partClosed = partClosed,
+ };
copy.obstacles.AddRange(obstacles);
return copy;
}
@@ -62,10 +71,33 @@ public sealed class ReleasedContourState
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
}
+ ///
+ /// Checks as the continuation of the part the last check ended
+ /// in, as if that part's whole execution had been checked with these motions appended: the
+ /// findings are those the appended motions would add, and the state advances the same way.
+ /// It needs that part to have ended with every cut closed and nothing deferred, and the
+ /// continuation to open with a rapid and end the same way; otherwise the result is null and
+ /// this state must be discarded, and the caller checks the whole execution instead. Fixed
+ /// emissions and cutoffs are not continued.
+ ///
+ internal IReadOnlyList Continue(OwnedExecution execution, Vector arrival,
+ int partNumber, CancellationToken token)
+ {
+ ArgumentNullException.ThrowIfNull(execution);
+ PostVerificationGeometry.Validate(arrival);
+ if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
+ return null;
+ var moves = execution.Motions.ToArray();
+ moves[0] = moves[0].WithStart(arrival);
+ var findings = new List();
+ AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
+ return partClosed ? findings.AsReadOnly() : null;
+ }
+
internal void AnalyzeMoves(IReadOnlyList moves, bool cutoff,
List findings, int plate, int part, CancellationToken token,
Func isFixed = null, List fixedFindings = null,
- bool proveOpenCuts = false)
+ bool proveOpenCuts = false, int contoursBefore = 0)
{
var current = new CutGroup();
var unfinished = new List();
@@ -78,7 +110,7 @@ public sealed class ReleasedContourState
CutGroup previous = null;
var deferred = new List<(List Target, int Index, PostVerificationFinding Finding)>();
var hasLead = false;
- var contourNumber = 0;
+ var contourNumber = contoursBefore;
var inBreakCut = false;
var reportedTab = false;
for (var index = 0; index < moves.Count; index++)
@@ -222,6 +254,9 @@ public sealed class ReleasedContourState
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
unfinished.Any(chain => chain.Fixed));
+ partContours = contourNumber;
+ partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
+ && !inBreakCut && loose.Leads.Count == 0;
void Add(PostVerificationFinding finding, bool fixedEmission)
{
diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
index c35b7eef..8cbd4b1d 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
@@ -78,7 +78,7 @@ internal static class PostVerificationGeometry
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
// (including the empty extent's) fail this test too.
- private bool IsWellConditioned =>
+ internal bool IsWellConditioned =>
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
}
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 8b0a4762..e3795940 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -550,13 +550,14 @@ internal static class JointCuttingPlanSearch
var arrival = node.Active?.Arrival ?? node.Position;
var before = node.Active?.Before ?? node.Checker;
var boundary = node.Active?.Boundary ?? node;
+ var chosen = choices.Select(e => e.ContourOrdinal).ToHashSet();
var contours = prepared.HasCutPerimeter && choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
- : Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
+ : Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !chosen.Contains(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 (preference != null)
- contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
+ contours = contours.OrderBy(preference.RankOf).ThenBy(c => c);
if (preferredOnly && preference != null)
contours = contours.Take(1);
foreach (var contour in contours)
@@ -570,9 +571,9 @@ internal static class JointCuttingPlanSearch
entries = [perimeter];
else
{
- contourRank = Array.IndexOf(preference.Route, contour);
+ contourRank = preference.RankOf(contour);
var downstream = preference.Route.Skip(contourRank + 1)
- .FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
+ .FirstOrDefault(c => !chosen.Contains(c), -1);
var target = downstream < 0 ? preference.PerimeterPierce
: preference.Pierces[downstream];
// The coarse pass already checked one owned, lead-feasible entry
@@ -601,6 +602,19 @@ internal static class JointCuttingPlanSearch
{
CountExpansion(source); // Before emission/native queries, including rejected candidates.
var prefix = choices.Append(entries[entry]).ToArray();
+ // A many-holed part's middle steps check only the contour they add; the full
+ // prefix runs when that cannot be done exactly, and always for the last contour.
+ if (Continues(source, choices, prefix)
+ && ContinueContour(source, entries[entry], node, attempt) is { } step)
+ {
+ if (step.Checker == null) continue;
+ var added = node.Active.Distance + step.Execution.RapidDistanceFrom(node.Position);
+ yield return new(new Node(node.Order, step.Execution.DeparturePoint, step.Checker,
+ new(source, prefix, arrival, before, added, boundary,
+ node.Active.Perimeter, preference), null),
+ added - node.Active.Distance, source.SourceOrdinal, contour, entry, entry, contourRank);
+ continue;
+ }
Program program;
OwnedExecution execution;
try
@@ -1147,6 +1161,54 @@ internal static class JointCuttingPlanSearch
execution.DeparturePoint, checker, null, node), severity, execution.RapidDistanceFrom(arrival));
}
+ /// Parts with fewer contours always check the whole prefix, as they always have.
+ internal const int ContinuedContours = 64;
+
+ // Whether a step may check only the contour it adds: a middle contour (not the first,
+ // which also cuts the part's scribes and open cuts, nor the last, whose full prefix is
+ // the part's certified program) of a many-holed part, with no drawing-pinned contour.
+ private static bool Continues(FixedProgramPlacement source, ContourChoice[] choices, ContourChoice[] prefix) =>
+ !source.IsCutOff && source.Prepared.Count >= ContinuedContours && choices.Length > 0
+ && prefix.Length < source.Prepared.Count && !prefix.Any(c => source.Prepared.IsPinned(c.ContourOrdinal));
+
+ // Each choice's own motions, read once; the rapid into it starts where each check says.
+ private readonly Dictionary contourMotions = [];
+
+ ///
+ /// Checks one added contour against the state its prefix left, as judges
+ /// it inside the whole prefix: earlier contours' rapids and leads were checked when they were
+ /// added, and contour emission never reads other choices. Null when that cannot be done
+ /// exactly (the isolated emission refused, or the checker cannot continue the part); the
+ /// caller then checks the full prefix. A step whose checks fail has a null checker.
+ ///
+ private ContinuedStep ContinueContour(FixedProgramPlacement source, ContourChoice choice, Node node, Attempt attempt)
+ {
+ if (!contourMotions.TryGetValue(choice, out var execution))
+ {
+ try
+ {
+ execution = WithoutScribes(ExecutionMotionReader.Read(
+ source.Prepared.EmitCandidateForValidation(choice), source.Location, null, token));
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
+ {
+ return null;
+ }
+ contourMotions[choice] = execution;
+ }
+ var checker = node.Checker.Copy();
+ var findings = checker.Continue(execution, node.Position, source.SourceOrdinal + 1, token);
+ if (findings == null)
+ return null;
+ rejected.AddRange(Map(snapshot, findings));
+ attempt.NoteCrossings(source.SourceOrdinal, findings);
+ var lead = LeadPathValidator.Check(execution, source.Material, materials, null, [], token, leadContacts,
+ snapshot.PierceZoneRadius);
+ if (!lead.IsComplete || !lead.IsClear)
+ rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
+ return new(execution, findings.Count == 0 && lead.IsComplete && lead.IsClear ? checker : null);
+ }
+
// The strict checks of Check, counted instead of refused.
private RouteSeverity Score(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
ReleasedContourState checker, IReadOnlyList choices = null)
@@ -1249,7 +1311,14 @@ internal static class JointCuttingPlanSearch
private sealed class BudgetExceededException : Exception;
// Every endpoint branch owns its preference; all retained entries/orders remain searchable.
private sealed record HolePreference(int[] Route, IReadOnlyDictionary Entries,
- IReadOnlyDictionary Pierces, Vector PerimeterPierce);
+ IReadOnlyDictionary Pierces, Vector PerimeterPierce)
+ {
+ private readonly Dictionary ranks = Route.Select((contour, rank) => (contour, rank))
+ .DistinctBy(r => r.contour).ToDictionary(r => r.contour, r => r.rank);
+
+ /// The contour's place along , or -1 when it is not on it.
+ internal int RankOf(int contour) => ranks.TryGetValue(contour, out var rank) ? rank : -1;
+ }
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
ReleasedContourState Before, double Distance, Node Boundary,
ContourChoice Perimeter = null, HolePreference Preference = null);
@@ -1267,6 +1336,7 @@ internal static class JointCuttingPlanSearch
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry,
int Rank = int.MinValue, int ContourRank = 0);
private sealed record SafestStep(Node Node, RouteSeverity Severity, double Distance);
+ private sealed record ContinuedStep(OwnedExecution Execution, ReleasedContourState Checker);
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
diff --git a/OpenNest.Tests/CuttingPlanning/ContinuedContourCheckTests.cs b/OpenNest.Tests/CuttingPlanning/ContinuedContourCheckTests.cs
new file mode 100644
index 00000000..d86f944f
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ContinuedContourCheckTests.cs
@@ -0,0 +1,123 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// A many-holed part's middle search steps check only the contour they add, continuing the
+/// state its prefix left. That must judge each prefix exactly as checking the whole prefix
+/// from before the part does: the same findings, numbered the same way, from the same place.
+///
+public class ContinuedContourCheckTests
+{
+ private const int Side = 9;
+ private static readonly Vector Location = new(5.3, 2.7);
+ private static readonly Vector Arrival = new(-1, -1);
+
+ [Fact]
+ public void ContinuedChecks_MatchFullPrefixChecks_IncludingACrossing()
+ {
+ var prepared = PreparedContours.Capture(Plate(), Parameters());
+ // Row by row, except that hole 1 is cut before hole 0, so the rapid from hole 0 on to
+ // hole 2 crosses the completed hole 1 and every later prefix repeats that finding.
+ var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToList();
+ (holes[0], holes[1]) = (holes[1], holes[0]);
+ var choices = new List();
+ var position = Arrival - Location;
+ foreach (var hole in holes)
+ {
+ var choice = prepared.ClosestEntry(hole, position);
+ choices.Add(choice);
+ position = prepared.HoleCentres()[hole]!.Value;
+ }
+
+ ReleasedContourState continued = null;
+ Vector departure = default;
+ var accumulated = new List();
+ for (var length = 1; length <= choices.Count; length++)
+ {
+ var prefix = choices.Take(length).ToArray();
+ var full = ExecutionMotionReader.Read(prepared.EmitPrefix(prefix), Location, Arrival, default);
+ var fullChecker = new ReleasedContourState(reportMissingLeadIns: false);
+ var fullFindings = fullChecker.Check(full, Arrival, 1).Select(Describe).ToList();
+
+ if (continued == null)
+ {
+ continued = fullChecker;
+ accumulated.AddRange(fullFindings);
+ }
+ else
+ {
+ var step = Isolated(prepared, prefix[^1]);
+ var added = continued.Continue(step, departure, 1, default);
+ Assert.NotNull(added);
+ accumulated.AddRange(added.Select(Describe));
+ Assert.True(step.DeparturePoint.DistanceTo(full.DeparturePoint) < 1e-9,
+ $"Prefix {length} departs from {step.DeparturePoint}, not {full.DeparturePoint}.");
+ }
+ Assert.Equal(fullFindings, accumulated);
+ departure = full.DeparturePoint;
+ }
+ Assert.Contains(accumulated, f => f.Contains("contour 1 of part 1"));
+ }
+
+ [Fact]
+ public void Continue_NeedsAPartLeftClosed()
+ {
+ var prepared = PreparedContours.Capture(Plate(), Parameters());
+
+ Assert.Null(new ReleasedContourState(reportMissingLeadIns: false)
+ .Continue(Isolated(prepared, prepared.ClosestEntry(0, Vector.Zero)), Arrival, 1, default));
+ }
+
+ [Fact]
+ public void ManyHoledPart_PlansReady_AndReplays()
+ {
+ var plate = new Nest().CreatePlate();
+ plate.Size = new Size(20, 20);
+ plate.Parts.Add(new Part(new Drawing("holes", Plate()), Location));
+
+ var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
+ expansionBudget: CuttingPlanBatch.PlateBudget(1), confirmedParameters: Parameters()));
+
+ Assert.True(Side * Side + 1 >= 64, "The fixture must take the continued path.");
+ Assert.True(result.Status == CuttingPlanStatus.Ready,
+ $"{result.Status}: " + string.Join("; ", result.Findings.Take(3).Select(f => f.Message)));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(Side * Side + 1, result.ProposedOrder.Single().ContourChoices.Count);
+ }
+
+ private static OwnedExecution Isolated(PreparedContours prepared, ContourChoice choice)
+ {
+ var execution = ExecutionMotionReader.Read(prepared.EmitCandidateForValidation(choice), Location, null, default);
+ return execution;
+ }
+
+ private static string Describe(OpenNest.Diagnostics.PostVerificationFinding finding) =>
+ $"{finding.Kind}: {finding.Message}";
+
+ // A 10 x 10 square with a 9 x 9 grid of holes 0.5 across, one apart.
+ private static Program Plate()
+ {
+ var p = ExplicitContourTests.Square(false);
+ for (var row = 1; row <= Side; row++)
+ for (var column = 1; column <= Side; column++)
+ {
+ p.MoveTo(column + 0.25, row);
+ p.ArcTo(column + 0.25, row, column, row, RotationType.CCW);
+ }
+ return p;
+ }
+
+ private static CuttingParameters Parameters() => new()
+ {
+ ExternalLeadIn = new LineLeadIn { Length = 0.2, ApproachAngle = 90 },
+ InternalLeadIn = new LineLeadIn { Length = 0.1, ApproachAngle = 90 },
+ ArcCircleLeadIn = new LineLeadIn { Length = 0.1, ApproachAngle = 90 },
+ ExternalLeadOut = new NoLeadOut(),
+ InternalLeadOut = new NoLeadOut(),
+ };
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 0316a3ad..c74630e1 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -285,9 +285,15 @@ circles, and leads keep those results: a lead check skips another part's materia
only when their extents are clear by that 0.001 plus 1e-6 times the span of both
extents. The discriminant's rounding error is bounded by about 1e-7 times that span
(about 2e-9 is seen), so a skipped material has no native contact; a 900000-long lead
-beside a small circle is still checked. A skipped scan is charged to the native query
-budget as if it ran, and ray parity is still asked natively unless the lead's midpoint
-lies outside the material's Y range. A pierce zone skips material farther than its
+beside a small circle is still checked. Only native queries that run are charged to
+the native query budget, so a skipped material costs nothing, and ray parity is still
+asked natively unless the lead's midpoint lies outside the material's Y range. Material
+with at least 64 boundaries keeps a grid of its boundary extents: a lead check, a
+contour-joint match or replay's contour accounting queries only the boundaries that the
+same per-pair clearance cannot skip, in ring order, and ray parity reads only the holes
+whose extents reach the point's row. Material capture certifies its boundaries the same
+way, sweeping extents along X instead of pairing every boundary with every other, so a
+part with thousands of holes stays within the limit. A pierce zone skips material farther than its
radius by the same gap. Every prefix of a part repeats its earlier contours' leads, so one search
remembers each other-material verdict by the lead curve's exact bits and the material's
reference, and charges the native query budget the scan ran; replay never uses it. An arc's
@@ -375,6 +381,17 @@ that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement.
A cutout that contains the arrival point (the tool comes from a part nested in it) is
routed from the arrival point itself, not its centre, so it is cut first, before a
later hop to it would have to cross the other parts nested there.
+On a part with at least 64 contours, a middle step of that route (neither the first
+contour, which also cuts the part's scribes and open cuts, nor the last) checks only the
+contour it adds, continuing the completed-contour state its prefix left rather than emitting
+and replaying the whole prefix from before the part. Contour emission reads only its own
+contour, so the added rapid and leads are the ones the prefix would cut, and the continued
+check numbers contours as the full check does. It continues only when the prefix ended with
+every cut closed and nothing deferred, and never for a drawing-pinned contour, a cutoff or
+a fixed program; otherwise, and whenever the isolated emission refuses, the step checks its
+full prefix. The step that adds the last contour always emits and checks the whole part
+from before it, so each completed part's program is certified exactly as before. Parts with
+fewer contours check every prefix in full.
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,