mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-08 06:45:45 -05:00
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:
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()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user