diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 1e8757d0..962ee8c2 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -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(); 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(); // 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)); + } + /// /// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates diff --git a/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs index a1a9fe3c..78984e19 100644 --- a/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs +++ b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs @@ -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(); diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 8842eedd..1dd0a8d1 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -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