fix(planning): learn a door's hole order when a hop crosses its own cut holes

Rapids may not cross holes already cut. On an instrument panel, mounting
holes either side of a cutout become unreachable from each other once
the cutout is cut, so the holes must come first. The contour-by-contour
search backtracks chronologically through every entry combination of
the last holes before it changes their order, and ran out of budget.
Supplied-blank doors fell to an unverified safest route.

Three changes:
- Contour recovery, the part solver that memoizes failed states, now
  runs for display-perimeter parts too: their holes are the whole part,
  with no outside cut to finish on.
- A door alone on its sheet gets the recovery pass first. Any other
  lone part keeps the plain search, whose routes are shorter.
- Recovery learns, as whole-part ordering does: a hop onto a hole that
  crosses holes of the chain teaches "cut it before them" (skipping any
  rule that closes a cycle) and restarts the chain, with holes still
  waiting for a prerequisite ranked last. Rules only order candidates;
  no hole is skipped. Until a rule is learned the chain is unchanged.
This commit is contained in:
aj committed 2026-10-23 07:43:39 -04:00
1 parent 45592a811c
commit 0785bb84c9
2 files changed
+191 -21

No files matched your search

@@ -70,6 +70,10 @@ internal static class JointCuttingPlanSearch
var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
var preferEndpoints = count > 1 && maxContours > 1;
// A supplied blank alone on its sheet (its holes are the whole part) goes to contour
// recovery first; any other lone part keeps the plain search, whose routes are shorter.
var doorAlone = count == 1 && maxContours > 1
&& snapshot.Placements[0].Material is { IsComplete: true, HasDisplayPerimeter: true };
var planned = Enumerable.Range(0, count).Except(excluded).ToArray();
try
{
@@ -108,7 +112,8 @@ internal static class JointCuttingPlanSearch
// only repeat the same blocked approach along the same order.
Attempt attempt = null;
foreach (var (preferredOnly, contourRecovery) in preferEndpoints
? new[] { (true, false), (false, true), (false, false) } : [(false, false)])
? new[] { (true, false), (false, true), (false, false) }
: doorAlone ? [(false, true), (false, false)] : [(false, false)])
{
var pass = walk.Follow(sequence, stall, resume, preferredOnly, contourRecovery, prerequisites);
if (pass.Order != null)
@@ -518,9 +523,9 @@ internal static class JointCuttingPlanSearch
continue;
}
var prepared = source.Prepared;
// Recovery and endpoint branches are led by the cut perimeter; a display perimeter
// is never cut, so its holes take the plain contour-by-contour search below.
if (contourRecovery && prepared.HasCutPerimeter && prepared.Count > 1
// Recovery solves the part's hole transitions, its outside last. A supplied blank's
// display perimeter is never cut, so its holes alone are the part.
if (contourRecovery && HasRecoverableHoles(source) && prepared.Count > 1
&& prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
{
foreach (var edge in RecoverPart(node, sequence, source, attempt))
@@ -645,6 +650,11 @@ internal static class JointCuttingPlanSearch
}
}
// A part whose holes recovery can order: one with a cut perimeter, which it cuts last, or
// a supplied blank whose display perimeter is never cut.
private static bool HasRecoverableHoles(FixedProgramPlacement source) =>
source.Prepared.HasCutPerimeter || source.Material is { IsComplete: true, HasDisplayPerimeter: true };
// 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.
@@ -655,14 +665,21 @@ internal static class JointCuttingPlanSearch
{
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 ranked = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token)
.OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.RoutePosition - source.Location)).ToArray();
var perimeters = ranked.Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear)
.Select(c => c.Choice).ToArray();
if (perimeters.Length == 0)
perimeters = [.. ScrapPierceEntries(source, ranked, out _)];
ContourChoice[] perimeters;
if (prepared.HasCutPerimeter)
{
// Placement origins are authored program coordinates, not a downstream cut.
// Use the same material-centre look-ahead as the retained entry search.
var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token)
.OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.RoutePosition - source.Location)).ToArray();
perimeters = ranked.Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear)
.Select(c => c.Choice).ToArray();
if (perimeters.Length == 0)
perimeters = [.. ScrapPierceEntries(source, ranked, out _)];
}
else
// No outside to cut: one pass, complete once every hole is cut.
perimeters = [null];
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();
@@ -712,7 +729,22 @@ internal static class JointCuttingPlanSearch
// 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, start, released)) continue;
// Learned order, as for whole parts: a hop onto a hole that crosses holes already cut
// teaches "cut it before them" (never a rule that closes a cycle). Each new rule
// restarts the chain, so the order changes before entry combinations are retried.
// Rules only order candidates (a hole still waiting for one goes last); no hole is
// ever skipped. Until the first rule the chain is nearest-first, as before.
var prerequisites = holes.Select(_ => new HashSet<int>()).ToArray();
var learnedNew = false;
bool solved;
do
{
learnedNew = false;
chain.Clear();
solved = Solve(0, start, released);
}
while (!solved && learnedNew && Expansions < limit);
if (!solved) continue;
var program = prepared.Emit(chain);
var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token);
var checker = node.Checker.Copy();
@@ -723,7 +755,7 @@ internal static class JointCuttingPlanSearch
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)
bool Solve(int mask, Vector position, ReleasedContourState released)
{
token.ThrowIfCancellationRequested();
if (Expansions >= limit) return false;
@@ -732,8 +764,10 @@ internal static class JointCuttingPlanSearch
if (failed.Contains(key)) return false;
if (mask == (1 << prepared.PerimeterOrdinal) - 1)
{
if (outside == null)
return true;
CountExpansion(source);
var findings = before.Copy().Check(Motion(outside), position, source.SourceOrdinal + 1, token);
var findings = released.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; }
@@ -745,7 +779,9 @@ internal static class JointCuttingPlanSearch
// 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)))
// A hole still waiting for a learned prerequisite goes last.
.OrderBy(c => prerequisites[c].Any(p => (mask & (1 << p)) == 0))
.ThenBy(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))
@@ -756,21 +792,49 @@ internal static class JointCuttingPlanSearch
if (Expansions >= limit) return false;
CountExpansion(source);
var execution = Motion(choice);
var after = before.Copy();
var after = released.Copy();
var findings = after.Check(execution, position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
// The first contour's rapid is the arrival unless the part opens with scribes.
if (findings.Count != 0 && !(arrivalMayCross && mask == 0 && openingProgram.Length == 0
&& OnlyCrossings(findings))) continue;
&& OnlyCrossings(findings)))
{
if (Learn(choice.ContourOrdinal, position, execution)) return false;
continue;
}
chain.Add(choice);
if (Solve(mask | (1 << choice.ContourOrdinal), execution.DeparturePoint, after)) return true;
chain.RemoveAt(chain.Count - 1);
// A new rule restarts the chain from the part's first hole.
if (learnedNew) return false;
}
failed.Add(key);
if (!learnedNew)
failed.Add(key);
return false;
}
// Rules from a hop that crossed holes of this chain; true when one is new.
bool Learn(int hole, Vector position, OwnedExecution execution)
{
if (execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
return false;
var pierce = execution.Motions[0].End;
foreach (var cut in chain)
{
var curves = Motion(cut).Motions.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display)
.Select(m => m.Curve).ToArray();
if (curves.Length == 0 || !PostVerificationGeometry.Crosses(position, pierce, curves, token))
continue;
var crossed = cut.ContourOrdinal;
if (prerequisites[crossed].Contains(hole) || Precedes(crossed, hole, prerequisites))
continue;
prerequisites[crossed].Add(hole);
learnedNew = true;
}
return learnedNew;
}
// 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)
{
@@ -778,7 +842,11 @@ internal static class JointCuttingPlanSearch
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;
if (!double.IsPositiveInfinity(onward))
return onward;
// The last hole: toward the outside entry, or with none, toward the next part.
return outside != null ? departure.DistanceTo(Motion(outside).Motions[0].End)
: target is { } next ? departure.DistanceTo(next + source.Location) : 0;
}
}
}
@@ -1093,7 +1161,7 @@ internal static class JointCuttingPlanSearch
}
// Recovery's contour solver first: its first route whose only violations, if any, are
// where the arrival rapid crosses cut contours. It passes every check when it can.
if (prepared.HasCutPerimeter && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles
if (HasRecoverableHoles(source) && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles
&& RecoverPart(node, KeptSequence, source, new Attempt(null), arrivalMayCross: true).FirstOrDefault() is { } recovered)
{
var proposal = recovered.Node.Order[^1];
@@ -0,0 +1,102 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
// A supplied blank's holes are its whole program. On an instrument panel, mounting holes either
// side of a cutout become unreachable from each other once the cutout is cut: a straight rapid
// between them would cross it. The planner has to find a hole order that avoids that for a door
// (no cut perimeter) as it does for an ordinary part.
public class DisplayPerimeterHoleOrderTests
{
private const double Width = 36.5, Height = 75;
internal static Program Door(bool mirrored)
{
double X(double x) => mirrored ? Width - x : x;
var p = new Program();
var turn = mirrored ? RotationType.CW : RotationType.CCW;
// A rounded rectangle, corner radius 0.5, traced counterclockwise before mirroring.
void Cutout(double x0, double y0, double x1, double y1)
{
const double r = 0.5;
Vector V(double x, double y) => new(X(x), y);
p.MoveTo(X(x0 + r), y0);
p.LineTo(V(x1 - r, y0)); p.ArcTo(V(x1, y0 + r), V(x1 - r, y0 + r), turn);
p.LineTo(V(x1, y1 - r)); p.ArcTo(V(x1 - r, y1), V(x1 - r, y1 - r), turn);
p.LineTo(V(x0 + r, y1)); p.ArcTo(V(x0, y1 - r), V(x0 + r, y1 - r), turn);
p.LineTo(V(x0, y0 + r)); p.ArcTo(V(x0 + r, y0), V(x0 + r, y0 + r), turn);
}
void Hole(double x, double y)
{
var c = new Vector(X(x), y);
p.MoveTo(c.X + 0.1385, y);
p.ArcTo(new Vector(c.X - 0.1385, y), c, RotationType.CCW);
p.ArcTo(new Vector(c.X + 0.1385, y), c, RotationType.CCW);
}
// Two columns of 0.277 mounting holes either side of two stacked rounded cutouts.
Hole(13.4, 19.75); Hole(23, 19.8);
Hole(13.4, 22.92); Hole(23, 22.87);
Hole(13.4, 26.07); Hole(23, 26.02);
Hole(13.4, 32.03); Hole(23, 32.03);
Hole(13.4, 38.45); Hole(23, 38.45);
Cutout(14.64, 16, 20.48, 25.5);
Cutout(14.92, 26.66, 21.65, 35.35);
p.MoveTo(0, 0);
foreach (var (x, y) in new[] { (0.0, Height), (Width, Height), (Width, 0.0), (0.0, 0.0) })
p.Codes.Add(new LinearMove(x, y) { Layer = LayerType.DisplayPerimeter });
return p;
}
// Control: with the default budget the plan is Ready and applies, either way round.
[Theory]
[InlineData(false)]
[InlineData(true)]
public void PanelDoor_PlansAReadyRouteAndApplies(bool mirrored)
{
var plate = new Plate(new Size(Width, Height));
plate.Parts.Add(new Part(new Drawing("door", Door(mirrored)), Vector.Zero));
var proposal = CuttingPlanBatch.Capture([plate], ImportedArcHoleTests.SweepParameters(), false).Plan();
var result = Assert.Single(proposal.Plates).Result;
Assert.True(result.Status == CuttingPlanStatus.Ready, string.Join("\n", proposal.Describe("in")));
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
}
// The contour solver learns "cut this hole first" from a hop that crosses holes already cut,
// so a door alone on its sheet finds its hole order well within a budget the hole-by-hole
// search alone exhausts (a display perimeter is never cut: the holes are the whole part).
[Fact]
public void DoorHoleOrder_IsLearnedWithinABudgetTheHoleSearchAloneExhausts()
{
var result = PlanAlone(LayerType.DisplayPerimeter, 1000);
Assert.True(result.Status == CuttingPlanStatus.Ready, $"{result.Status} after {result.Expansions} expansions");
Assert.True(result.IndependentlyReplayed);
}
// Any other part alone on its plate keeps the plain hole search, which plans this panel in a
// small budget with a shorter route than contour recovery would.
[Fact]
public void OrdinaryPanelAlone_KeepsThePlainHoleSearch()
{
var result = PlanAlone(LayerType.Cut, 200);
Assert.True(result.Status == CuttingPlanStatus.Ready, $"{result.Status} after {result.Expansions} expansions");
Assert.True(result.IndependentlyReplayed);
}
private static CuttingPlanResult PlanAlone(LayerType outline, int budget)
{
var program = Door(false);
foreach (var code in program.Codes.OfType<LinearMove>().Where(m => m.Layer == LayerType.DisplayPerimeter))
code.Layer = outline;
var plate = new Plate(new Size(Width, Height));
plate.Parts.Add(new Part(new Drawing("panel", program), Vector.Zero));
return CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, expansionBudget: budget,
confirmedParameters: ImportedArcHoleTests.SweepParameters()));
}
}