fix(cutting): recover checked routes through interlocking holed parts

This commit is contained in:
aj committed 2026-10-11 19:51:29 -04:00
1 parent 09dd19ca66
commit 97a468ee2c
4 files changed
+322 -17

No files matched your search

@@ -24,6 +24,7 @@ internal static class JointCuttingPlanSearch
/// along its order before it gives up and learns from the part that blocked it.
/// </summary>
internal const int StallExpansionsPerEntry = 8;
private const int MaximumRecoveryHoles = 16;
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
@@ -46,6 +47,9 @@ internal static class JointCuttingPlanSearch
var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true);
if (preferred.Order != null)
return walk.Ready(preferred.Order);
var recovered = walk.Follow(keptSequence, stall, null, contourRecovery: true);
if (recovered.Order != null)
return walk.Ready(recovered.Order);
}
var kept = walk.Follow(keptSequence, null, null);
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
@@ -65,6 +69,9 @@ internal static class JointCuttingPlanSearch
var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true);
if (preferred.Order != null)
return walk.Ready(preferred.Order);
var recovered = walk.Follow(sequence, stall, null, contourRecovery: true);
if (recovered.Order != null)
return walk.Ready(recovered.Order);
}
var attempt = walk.Follow(sequence, stall, resume);
if (attempt.Order != null)
@@ -73,6 +80,12 @@ internal static class JointCuttingPlanSearch
{
// "Cut before" rules are a heuristic: a part blocked straight after another can
// still be reachable via a third. Search every order with what budget remains.
if (preferEndpoints)
{
var recovery = walk.Follow(null, stall, null, contourRecovery: true);
if (recovery.Order != null)
return walk.Ready(recovery.Order);
}
var full = walk.Follow(null, null, null);
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
}
@@ -189,7 +202,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, bool preferredOnly = false)
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false)
{
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
var attempt = new Attempt(sequence);
@@ -211,8 +224,18 @@ internal static class JointCuttingPlanSearch
if (attempt.Advance(node))
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
{
if (sequence != null || !contourRecovery)
return attempt;
// Diversify the first whole part rather than spending the remainder
// on endpoints of a stalled root's already completed prefix.
while (stack.Count > 1)
stack.Pop().Children?.Dispose();
attempt.Restart();
progressExpansions = Expansions;
continue;
}
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly, contourRecovery).GetEnumerator();
if (!frame.Children.MoveNext())
{
stack.Pop().Children.Dispose();
@@ -269,15 +292,16 @@ internal static class JointCuttingPlanSearch
/// 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 IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
{
// A prescribed next part (or an active part's next contour) has a single
// source. Expand already yields its contour/entry rank order, so emit and
// validate a sibling only when DFS reaches it. Free whole-part selection
// still needs every source's distances before it can rank them.
if (sequence != null || node.Active != null)
return Expand(node, sequence, attempt, preferredOnly);
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
// still needs every source's distances before it can rank them. Recovery
// instead visits dependency-ready sources lazily by their material centres.
if (sequence != null || node.Active != null || contourRecovery)
return Expand(node, sequence, attempt, preferredOnly, contourRecovery);
var edges = Expand(node, sequence, attempt, preferredOnly, contourRecovery).ToList();
if (edges.Count <= 1)
return edges.ToArray();
// Stable source order: the minimum incremental rapid per source, ties ordinal.
@@ -295,7 +319,7 @@ internal static class JointCuttingPlanSearch
.ToArray();
}
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
{
IEnumerable<FixedProgramPlacement> sources;
if (node.Active is { } active)
@@ -309,6 +333,8 @@ internal static class JointCuttingPlanSearch
: snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
? [snapshot.Placements[sequence[node.Order.Length]]] : [];
}
if (contourRecovery)
sources = sources.OrderBy(p => Centre(p).DistanceTo(node.Position)).ThenBy(p => p.SourceOrdinal);
foreach (var source in sources)
{
token.ThrowIfCancellationRequested();
@@ -322,6 +348,12 @@ internal static class JointCuttingPlanSearch
continue;
}
var prepared = source.Prepared;
if (contourRecovery && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
{
foreach (var edge in RecoverPart(node, sequence, source, attempt))
yield return edge;
continue;
}
if (prepared.Count > 1 && node.Active == null)
{
// Selecting an endpoint does not cut it. Each endpoint owns a separate
@@ -420,6 +452,92 @@ internal static class JointCuttingPlanSearch
}
}
// Recovery only: solve directed contour transitions rather than assuming
// that a centre-to-centre hole tour can cross the already released material.
// One complete checked chain per outside endpoint; original DFS remains fallback.
private IEnumerable<Edge> RecoverPart(Node node, int[] sequence,
FixedProgramPlacement source, Attempt attempt)
{
var prepared = source.Prepared;
var target = LookAheadCentre(node, sequence, source) - source.Location;
// Placement origins are authored program coordinates, not a downstream cut.
// Use the same material-centre look-ahead as the retained entry search.
var perimeters = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token)
.OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.Position - source.Location))
.Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear).Select(c => c.Choice).ToArray();
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal)
.Select(c => prepared.AutomaticEntryCandidatesWithFallbacks(c, token: token)
.Where(e => Evaluate(source, e).Status == ContourFeasibilityStatus.Clear).Select(e => e.Choice).ToArray()).ToArray();
var motions = new Dictionary<ContourChoice, OwnedExecution>();
var stop = Expansions + (long)StallExpansionsPerEntry * snapshot.MaxEntries * prepared.Count;
OwnedExecution Motion(ContourChoice choice)
{
if (motions.TryGetValue(choice, out var known)) return known;
CountExpansion(source);
return motions[choice] = ExecutionMotionReader.Read(
prepared.EmitCandidateForValidation(choice), source.Location, null, token);
}
for (var endpoint = 0; endpoint < perimeters.Length; endpoint++)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) yield break;
var outside = perimeters[endpoint];
var chain = new List<ContourChoice>();
// This dominance heuristic is confined to recovery: emitted
// seams can differ for the same released subset. Never prune full DFS.
var failed = new HashSet<(int Mask, long X, long Y)>();
if (!Solve(0, node.Position, node.Checker)) continue;
var program = prepared.Emit(chain);
var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token);
var checker = node.Checker.Copy();
CountExpansion(source);
if (!Check(source, execution, node.Position, checker, attempt)) continue;
var proposal = source.Propose(program, execution, chain, token);
yield return new(new([.. node.Order, proposal], execution.DeparturePoint, checker, null, node),
execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, endpoint, endpoint);
bool Solve(int mask, Vector position, ReleasedContourState before)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
var key = (mask, BitConverter.DoubleToInt64Bits(position.X),
BitConverter.DoubleToInt64Bits(position.Y));
if (failed.Contains(key)) return false;
if (mask == (1 << prepared.PerimeterOrdinal) - 1)
{
CountExpansion(source);
var findings = before.Copy().Check(Motion(outside), position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
if (findings.Count != 0) { failed.Add(key); return false; }
chain.Add(outside);
return true;
}
var candidates = Enumerable.Range(0, holes.Length).Where(c => (mask & (1 << c)) == 0)
.SelectMany(c => holes[c])
.OrderBy(c => Motion(c).Motions[0].End.DistanceTo(position));
foreach (var choice in candidates)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
CountExpansion(source);
var execution = Motion(choice);
var after = before.Copy();
var findings = after.Check(execution, position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
if (findings.Count != 0) continue;
chain.Add(choice);
if (Solve(mask | (1 << choice.ContourOrdinal), execution.DeparturePoint, after)) return true;
chain.RemoveAt(chain.Count - 1);
}
failed.Add(key);
return false;
}
}
}
/// <summary>
/// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
/// converted exactly once: rank the (fallback-complemented) catalogue toward the
@@ -576,6 +694,13 @@ internal static class JointCuttingPlanSearch
return true;
}
internal void Restart()
{
progress = -1;
Deepest = null;
Crossed.Clear();
}
internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings)
{
if (Blocked != ordinal)
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.IO;
namespace OpenNest.Tests.CuttingPlanning;
public class ContourRecoveryFileTests
{
// Optional private input: never embed customer geometry or names in the repository.
[SkippableTheory]
[InlineData(false)]
[InlineData(true)]
public void InterlockedPlateFinishesThroughDesktopBatchWithoutChangingInput(bool lineLeads)
{
var path = TestConfig.GetExistingPath("InterlockedCuttingNestPath");
Skip.If(path == null, "Configure InterlockedCuttingNestPath in ignored test-config.json.");
var bytes = SHA256.HashData(File.ReadAllBytes(path!));
var nest = new NestReader(path).Read();
var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToArray();
Assert.NotEmpty(plates);
var parts = plates.SelectMany(p => p.Parts).ToArray();
var programs = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray();
var poses = parts.Select(p => (p.Location, p.Rotation, p.LeadInsLocked)).ToArray();
var parameters = Parameters(lineLeads);
var proposal = CuttingPlanBatch.Capture(plates, parameters, preservePartOrder: false).Plan();
Assert.True(proposal.CanApply, string.Join(Environment.NewLine, proposal.Describe("in")));
Assert.False(proposal.RequiresWarningAcceptance);
Assert.Equal(plates.Length, proposal.Plates.Count);
foreach (var planned in proposal.Plates)
{
Assert.True(planned.IsOverlapClear);
Assert.True(planned.Result.IndependentlyReplayed);
Assert.Equal(CuttingPlanStatus.Ready, planned.Result.Status);
Assert.InRange(planned.Result.Expansions, 1, CuttingPlanBatch.PlateBudget(planned.Plate.Parts.Count));
Assert.Equal(planned.Plate.Parts.Count, planned.PartCount);
var snapshot = planned.Result.Snapshot;
foreach (var part in planned.Result.ProposedOrder)
{
Assert.Equal(snapshot.Placements[part.SourceOrdinal].Prepared.Count, part.ContourChoices.Count);
Assert.Equal(part.ContourChoices.Count, part.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count());
}
var replay = CuttingPlanService.ReplayPrograms(snapshot, planned.Result.ProposedOrder, 0, default);
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
Assert.True(replay.IndependentlyReplayed);
Assert.Empty(replay.Findings);
var bounded = CuttingPlanService.Plan(snapshot.WithSearchLimits(1, 16));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
Assert.Equal(1, bounded.Expansions);
Assert.Empty(bounded.ProposedOrder);
Assert.False(bounded.IndependentlyReplayed);
}
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var cancelled = CuttingPlanBatch.Capture(plates, parameters, false).Plan(token: cancellation.Token);
Assert.True(cancelled.IsCancelled);
Assert.False(cancelled.CanApplyWithWarnings);
Assert.Equal(parts, plates.SelectMany(p => p.Parts));
Assert.Equal(programs, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
Assert.Equal(poses, parts.Select(p => (p.Location, p.Rotation, p.LeadInsLocked)));
Assert.Equal(bytes, SHA256.HashData(File.ReadAllBytes(path)));
}
private static CuttingParameters Parameters(bool lineLeads)
{
var parameters = new CuttingParameters();
if (lineLeads)
{
parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
}
return parameters;
}
}
@@ -0,0 +1,86 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
public class InterlockingHoleRouteTests
{
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void InterlockingHoledPartsPlanWithinDefaultBudget(bool leads, bool keep)
{
var a = Polygon([new(2.5, 2), new(2.5, 4), new(30, 4), new(30, 10),
new(2.5, 10), new(2.5, 12), new(32, 12), new(32, 2)]);
var b = Polygon([new(0, 4.5), new(0, 14.5), new(20, 14.5), new(20, 12.5),
new(2, 12.5), new(2, 6.5), new(20, 6.5), new(20, 4.5)]);
foreach (var x in new[] { 3.5, 12, 21, 31 })
foreach (var y in new[] { 3.0, 11.0 }) Hole(a, x, y);
foreach (var x in new[] { 1.0, 7, 13, 19 })
foreach (var y in new[] { 5.5, 13.5 }) Hole(b, x, y);
var parts = new[] { new Part(new Drawing("left-open bracket", a)), new Part(new Drawing("right-open bracket", b)) };
var parameters = new CuttingParameters();
if (leads)
{
parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
}
var before = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray();
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts,
confirmedParameters: parameters, preservePartOrder: keep));
var result = CuttingPlanService.Plan(snapshot);
Assert.True(result.Status == CuttingPlanStatus.Ready,
$"{result.Status}: {result.Expansions} " + string.Join(";", result.Findings.Select(f => f.Message)));
Assert.True(result.IndependentlyReplayed);
Assert.Equal(2, result.ProposedOrder.Count);
Assert.All(result.ProposedOrder, p => Assert.Equal(9, p.ContourChoices.Count));
var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
Assert.True(replay.IndependentlyReplayed);
Assert.Equal(before, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
Assert.InRange(result.Expansions, 1, 20000);
if (keep)
Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
var repeated = CuttingPlanService.Plan(snapshot);
Assert.Equal(CuttingPlanStatus.Ready, repeated.Status);
Assert.Equal(result.Expansions, repeated.Expansions);
Assert.Equal(result.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())),
repeated.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())));
var bounded = CuttingPlanService.Plan(snapshot.WithSearchLimits(1, 16));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
Assert.Equal(1, bounded.Expansions);
Assert.Empty(bounded.ProposedOrder);
using var cancellation = new CancellationTokenSource();
var cancelAt = result.Expansions - 1;
var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest(parts,
confirmedParameters: parameters, preservePartOrder: keep)
{
ExpansionObserver = n => { if (n == cancelAt) cancellation.Cancel(); }
}, cancellation.Token);
Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status);
Assert.Equal(cancelAt, cancelled.Expansions);
Assert.Empty(cancelled.ProposedOrder);
Assert.False(cancelled.IndependentlyReplayed);
Assert.Equal(before, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
}
private static OpenNest.CNC.Program Polygon(Vector[] vertices)
{
var program = new OpenNest.CNC.Program();
program.MoveTo(vertices[0].X, vertices[0].Y);
foreach (var point in vertices.Skip(1).Append(vertices[0])) program.LineTo(point.X, point.Y);
return program;
}
private static void Hole(OpenNest.CNC.Program program, double x, double y)
{
program.MoveTo(x + 0.2, y);
program.ArcTo(x + 0.2, y, x, y, RotationType.CCW);
}
}
+24 -8
View File
@@ -99,8 +99,15 @@ 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.
backtracking pass remains available. Before that full pass, a failed preferred tour
also gets a directed-contour recovery pass for parts with at most 16 holes. It
tries native outside entries beyond the ranked shortlist, then builds a hole route
from actual emitted pierces, checking each transition against already released
contours. This reaches inner edges of interlocking concave parts which can be
missing from the facing-side shortlist. A failed released-hole-subset/departure
state is memoized only in recovery; it never prunes the retained full search.
Recovered whole programs are checked again and independently replayed. 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,
@@ -114,11 +121,18 @@ getting further, so it learns instead of retrying every entry combination of the
parts before it. A rule that would contradict an order already required is skipped.
"Cut before" rules are a heuristic (a part blocked straight after another may be
reachable via a third), so once nothing new can be learned the remaining budget
goes to a full search that tries every ready part, nearest first; only when that
also fails is the result a refusal.
first tries sequence-free recovery, visiting dependency-ready parts lazily by
material-centre distance. When a branch stalls, it disposes the deeper search
frames and resumes alternatives at the root instead of spending the remainder
on that branch's completed prefix. Any budget left then goes to the retained full
search. Recovery shares the original total expansion budget; exhausting it still
returns a refusal, never a best-effort upgrade.
Candidates use native closest points, vertices, midpoints and circle angles in
stable order, capped by `maxEntries`. Circle rounding, clamping, corner resolution
stable order. `maxEntries` caps the normal ranked shortlist, not the recovery
catalogue: recovery may inspect the remaining finite native entries, with every
precheck and visited transition charged to the same expansion budget. Circle
rounding, clamping, corner resolution
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.
@@ -168,8 +182,9 @@ are not tie breakers.
When the next whole part is fixed by the tour or a part is already active, the
search generates and checks contour alternatives on demand in contour/entry rank
order. It does not emit every sibling before trying the first. Backtracking still
visits the remaining alternatives when needed; none is pruned. The free whole-part
fallback still evaluates all ready sources before ranking their travel distances.
visits the remaining alternatives when needed; none is pruned. The retained full whole-part
fallback still evaluates all ready sources before ranking their travel distances;
sequence-free recovery instead uses lazy centre-distance order.
The expansion budget counts evaluated candidates and frontier ranking, including
rejections, before emission; it is not a wall-clock timeout. An unvisited sibling
@@ -205,7 +220,8 @@ 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
complete replay. The normal ranked entry count stays within `maxEntries`; directed recovery can
inspect additional native entries as described above. 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