fix(engine): let contour recovery plan scribed and boxed-in holed parts

The cut planner's directed-contour recovery pass gave up on plates the
full search could solve, so they ended "no plan found within the search
limit":

- Each hole was checked as an isolated probe that repeats the part's
  scribes, so every hole-to-hole hop was checked as a detour through
  the scribe start. Scribes and other open cuts are now checked once
  from the arrival, and each hole as a direct hop from the one before.
- Hole entries were ranked only by pierce distance, so the departure
  often ran back across the hole just cut. The nearest hole is still
  tried first, but its entries that leave toward the holes still to
  cut come first.
- The first-ranked outside entry could spend the whole recovery
  allowance. Each outside entry now gets a share of what is left.

The optional private-fixture test now takes its config key, adding
RecoveryCuttingNestPath; it fails on the old search and passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 20:54:27 -04:00
1 parent a0713289fa
commit 04a6e0eb50
3 files changed
+78 -15

No files matched your search

@@ -569,23 +569,51 @@ internal static class JointCuttingPlanSearch
.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;
// The part's scribes and other open cuts come once, before every contour; check
// them from the arrival, then each contour from where the one before it left off.
var openingProgram = prepared.EmitPrefix([]);
var start = node.Position;
var released = node.Checker;
if (openingProgram.Length != 0)
{
CountExpansion(source);
var opening = ExecutionMotionReader.Read(openingProgram, source.Location, node.Position, token);
released = node.Checker.Copy();
var findings = released.Check(opening, node.Position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
if (findings.Count != 0) yield break;
start = opening.DeparturePoint;
}
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);
return motions[choice] = WithoutScribes(ExecutionMotionReader.Read(
prepared.EmitCandidateForValidation(choice), source.Location, null, token));
}
var centres = prepared.HoleCentres(token);
// Ranking hops reads every hole entry's motion: build them before the outside
// entries share what is left of the allowance.
foreach (var entry in holes.SelectMany(entries => entries))
{
if (Expansions >= stop) yield break;
Motion(entry);
}
for (var endpoint = 0; endpoint < perimeters.Length; endpoint++)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) yield break;
var outside = perimeters[endpoint];
// Share what is left between the outside entries still to try: one that no hole
// tour can reach must not spend the allowance of the entries after it.
var limit = System.Math.Min(stop, Expansions + System.Math.Max(
(stop - Expansions) / (perimeters.Length - endpoint), (long)StallExpansionsPerEntry * prepared.Count));
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;
if (!Solve(0, start, released)) continue;
var program = prepared.Emit(chain);
var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token);
var checker = node.Checker.Copy();
@@ -598,7 +626,7 @@ internal static class JointCuttingPlanSearch
bool Solve(int mask, Vector position, ReleasedContourState before)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
if (Expansions >= limit) return false;
var key = (mask, BitConverter.DoubleToInt64Bits(position.X),
BitConverter.DoubleToInt64Bits(position.Y));
if (failed.Contains(key)) return false;
@@ -612,13 +640,20 @@ internal static class JointCuttingPlanSearch
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));
var remaining = Enumerable.Range(0, holes.Length).Where(c => (mask & (1 << c)) == 0).ToArray();
// Nearest hole first, and its entries that leave toward the holes still to
// cut first: a hop must not run back across the hole just cut.
var candidates = remaining
.Where(c => holes[c].Length != 0)
.OrderBy(c => holes[c].Min(e => Motion(e).Motions[0].End.DistanceTo(position)))
.ThenBy(c => c)
.SelectMany(c => holes[c]
.OrderBy(e => Onward(Motion(e).DeparturePoint, c, remaining))
.ThenBy(e => Motion(e).Motions[0].End.DistanceTo(position)));
foreach (var choice in candidates)
{
token.ThrowIfCancellationRequested();
if (Expansions >= stop) return false;
if (Expansions >= limit) return false;
CountExpansion(source);
var execution = Motion(choice);
var after = before.Copy();
@@ -633,9 +668,29 @@ internal static class JointCuttingPlanSearch
failed.Add(key);
return false;
}
// How far a departure is from the nearest hole still to cut, or else the outside entry.
double Onward(Vector departure, int hole, int[] remaining)
{
var onward = double.PositiveInfinity;
foreach (var other in remaining)
if (other != hole && centres[other] is { } centre)
onward = System.Math.Min(onward, departure.DistanceTo(centre + source.Location));
return double.IsPositiveInfinity(onward) ? departure.DistanceTo(Motion(outside).Motions[0].End) : onward;
}
}
}
// An isolated contour probe repeats the part's scribes first; keep the contour alone.
private static OwnedExecution WithoutScribes(OwnedExecution execution)
{
var last = -1;
for (var index = 0; index < execution.Motions.Count; index++)
if (execution.Motions[index].Layer == LayerType.Scribe)
last = index;
return last < 0 ? execution : new(execution.Motions.Skip(last + 1));
}
/// <summary>
/// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
@@ -7,14 +7,17 @@ namespace OpenNest.Tests.CuttingPlanning;
public class ContourRecoveryFileTests
{
// Optional private input: never embed customer geometry or names in the repository.
// Optional private inputs: never embed customer geometry or names in the repository.
// RecoveryCuttingNestPath: scribed parts with aligned slot pairs, and a bolted frame boxed
// in by parts cut before it, that the free search only finishes through contour recovery.
[SkippableTheory]
[InlineData(false)]
[InlineData(true)]
public void InterlockedPlateFinishesThroughDesktopBatchWithoutChangingInput(bool lineLeads)
[InlineData("InterlockedCuttingNestPath", false)]
[InlineData("InterlockedCuttingNestPath", true)]
[InlineData("RecoveryCuttingNestPath", true)]
public void PlateFinishesThroughDesktopBatchWithoutChangingInput(string key, bool lineLeads)
{
var path = TestConfig.GetExistingPath("InterlockedCuttingNestPath");
Skip.If(path == null, "Configure InterlockedCuttingNestPath in ignored test-config.json.");
var path = TestConfig.GetExistingPath(key);
Skip.If(path == null, $"Configure {key} 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();
+6 -1
View File
@@ -157,7 +157,12 @@ preferred tour first gets a directed-contour recovery pass for parts with at mos
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
contours. The part's scribes and other open cuts are checked once from the arrival,
as emitted, and each hole as a direct hop from the one before it. The nearest hole
is tried first, with its entries that leave toward the holes still to cut first, so
a hop does not run back across the hole just cut. Each outside entry gets a share of
the remaining recovery allowance, so one that no hole tour can reach does not starve
the entries after it. 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