diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index 56640d25..8c867a26 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -121,11 +121,16 @@ public sealed class LeadMaterialSnapshot ? "A display perimeter must enclose every other contour." : $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); } + // A ring encloses a point only inside the union of its curves' extents (each at least + // the curve's own reach), so a part with thousands of holes asks parity only where it can hold. + var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None, + (extent, curve) => extent.Union(curve.Extent))).ToArray(); + bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token); var outer = -1; for (var i = 0; i < rings.Count; i++) { token.ThrowIfCancellationRequested(); - if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token))) + if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start))) { if (outer >= 0) throw new ArgumentException("Material perimeter is ambiguous."); @@ -142,10 +147,11 @@ public sealed class LeadMaterialSnapshot throw new ArgumentException("A display perimeter must enclose every other contour."); if (ringDisplay[outer] && rings.Count == 1) throw new ArgumentException("A display perimeter with no cutouts has nothing to cut."); - var holes = rings.Where((_, i) => i != outer).ToArray(); - for (var i = 0; i < holes.Length; i++) - for (var j = i + 1; j < holes.Length; j++) - if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token)) + var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray(); + var holes = holeRings.Select(i => rings[i]).ToArray(); + for (var i = 0; i < holeRings.Length; i++) + for (var j = i + 1; j < holeRings.Length; j++) + if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start)) throw new ArgumentException("Nested cutouts are not supported material."); return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(), new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]); diff --git a/OpenNest.Core/Diagnostics/OverlapMaterial.cs b/OpenNest.Core/Diagnostics/OverlapMaterial.cs index 37f66150..e44daa3a 100644 --- a/OpenNest.Core/Diagnostics/OverlapMaterial.cs +++ b/OpenNest.Core/Diagnostics/OverlapMaterial.cs @@ -31,11 +31,17 @@ internal sealed record OverlapMaterial(Polygon Outer, List Holes) } // Sampling can hide a crossing or tangency between curves. Reject native // contour contact before asking the polygon approximation about containment. + // A contact lies within both entities' bounds, so shapes whose bounds are clear by more + // than any native query's allowance cannot touch: a part with thousands of holes does + // not query every pair. + var boxes = shapes.Select(shape => shape.BoundingBox).ToList(); for (var i = 0; i < shapes.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); for (var j = 0; j < i; j++) { + if (ClearOfNativeQueries(boxes[i], boxes[j])) + continue; shapes[i].Intersects(shapes[j], out var intersections); if (intersections.Count > 0) throw new ArgumentException("Native material contours cross or touch."); @@ -44,23 +50,70 @@ internal sealed record OverlapMaterial(Polygon Outer, List Holes) var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList(); var outer = ordered[0]; var holes = ordered.Skip(1).ToList(); + var outerBounds = BoundsOf(outer); + var holeBounds = holes.Select(BoundsOf).ToList(); for (var i = 0; i < holes.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); - if (BoundariesTouch(outer, holes[i], cancellationToken) - || !Inside(outer, holes[i].Vertices[0])) + if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken) + || !Inside(outer, outerBounds, holes[i].Vertices[0])) throw new ArgumentException("Contours must have one outer with strictly internal holes."); for (var j = 0; j < i; j++) { - if (BoundariesTouch(holes[i], holes[j], cancellationToken) - || Inside(holes[i], holes[j].Vertices[0]) - || Inside(holes[j], holes[i].Vertices[0])) + // Exact polygon tests: holes whose vertex bounds are apart can neither touch + // nor contain each other's vertices. + if (!Overlap(holeBounds[i], holeBounds[j])) + continue; + if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken) + || Inside(holes[i], holeBounds[i], holes[j].Vertices[0]) + || Inside(holes[j], holeBounds[j], holes[i].Vertices[0])) throw new ArgumentException("Intersecting holes and nested material islands are unsupported."); } } return new OverlapMaterial(outer, holes); } + /// + /// The gap beyond which no native intersection query counts two entities as touching: ten + /// times the 0.00001 bounding-box allowance of native intersections, as the planner's + /// PostVerificationGeometry.Extent.ClearMargin. + /// + private const double NativeClearMargin = 1e-3; + + // True only when both boxes are finite and apart by more than the native allowance on some axis. + private static bool ClearOfNativeQueries(Box a, Box b) => + IsFinite(a) && IsFinite(b) + && (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left + || a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom); + + private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right) + && double.IsFinite(box.Bottom) && double.IsFinite(box.Top); + + /// Exact vertex bounds of a sampled polygon, for skipping segment and winding tests. + private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY) + { + internal bool Contains(Vector point) => + point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY; + } + + private static Bounds BoundsOf(Polygon polygon) + { + double minX = double.PositiveInfinity, minY = double.PositiveInfinity, + maxX = double.NegativeInfinity, maxY = double.NegativeInfinity; + foreach (var vertex in polygon.Vertices) + { + minX = System.Math.Min(minX, vertex.X); + minY = System.Math.Min(minY, vertex.Y); + maxX = System.Math.Max(maxX, vertex.X); + maxY = System.Math.Max(maxY, vertex.Y); + } + return new(minX, minY, maxX, maxY); + } + + // Closed-interval overlap: two segments that touch share a point inside both bounds. + private static bool Overlap(Bounds a, Bounds b) => + a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY; + internal OverlapMaterial Transform(double rotation, Vector offset) => new(TransformPolygon(Outer, rotation, offset), Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList()); @@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List Holes) throw new ArgumentException("Material contour could not be completely triangulated."); } - private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken) + // Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b. + private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds, + CancellationToken cancellationToken) { + if (!Overlap(aBounds, bBounds)) + return false; for (var i = 0; i + 1 < a.Vertices.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); + var start = a.Vertices[i]; + var end = a.Vertices[i + 1]; + var segment = new Bounds(System.Math.Min(start.X, end.X), System.Math.Min(start.Y, end.Y), + System.Math.Max(start.X, end.X), System.Math.Max(start.Y, end.Y)); + if (!Overlap(segment, bBounds)) + continue; for (var j = 0; j + 1 < b.Vertices.Count; j++) - if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1])) + if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1])) return true; } return false; @@ -200,6 +263,10 @@ internal sealed record OverlapMaterial(Polygon Outer, List Holes) && point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y); // Boundary contact is rejected before this winding-number test is used for topology. + // A point outside the polygon's vertex bounds has winding zero. + private static bool Inside(Polygon polygon, Bounds bounds, Vector point) => + bounds.Contains(point) && Inside(polygon, point); + private static bool Inside(Polygon polygon, Vector point) { var winding = 0; diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 23a2b0c8..be82e0f5 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -68,6 +68,10 @@ internal static class PostVerificationGeometry return IsClearOf(other, ClearMargin + reach + SpanRounding * span); } + /// True when lies within the extent, its edges included. + internal bool Contains(Vector point) => + point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY; + /// True when lies more than outside the Y range. internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit && (y > MaxY + ClearMargin || y < MinY - ClearMargin); diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index 44fb06a9..a15c95b5 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -121,11 +121,14 @@ public sealed class CuttingPlanBatch { var plate = plates[index]; var budget = PlateBudget(plate.Parts.Count, budgetMultiplier); - var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + // One capture serves both searches: the order flag and budget are search settings + // over the same owned geometry, programs and freshness record. + var captured = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, expansionBudget: budget, confirmedParameters: confirmedParameters, - preservePartOrder: true), token); - var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, - expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token); + preservePartOrder: preservePartOrder), token); + var keepOrder = preservePartOrder ? captured : captured.WithPreservedOrder(); + var reorder = preservePartOrder ? null + : reorderBudget is { } reorderLimit ? captured.WithSearchLimits(reorderLimit, captured.MaxEntries) : captured; var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token); var fallback = reorder ?? keepOrder; if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index c3c1028d..38575794 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -120,6 +120,13 @@ public sealed class CuttingPlanSnapshot PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters, Dependencies, BestEffort) { SupportTabsSkipped = SupportTabsSkipped }; + + /// The same capture planned along its current part order. + internal CuttingPlanSnapshot WithPreservedOrder() => + new(Placements, StartPoint, ExpansionBudget, Failure, Findings, Regeneration, + preservePartOrder: true, MaxEntries, ExpansionObserver, PlateState, OwnedParameters, + Dependencies, BestEffort) + { SupportTabsSkipped = SupportTabsSkipped }; } public sealed class FixedProgramPlacement diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index ea7dfa1b..39fdf36f 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -28,6 +28,14 @@ internal static class JointCuttingPlanSearch /// the contours are those of the blocked part and of the part cut just before it. /// internal const int StallExpansionsPerEntry = 8; + + /// + /// Stalls' worth of expansions each search that backtracks without a stall limit (the full + /// pass along a kept order, and the sequence-free recovery and full passes once an order + /// teaches nothing) may spend before it ends. Beyond that they only retry entry combinations + /// of parts already cut, so the budget they would take goes to the lighter fallbacks instead. + /// + internal const int FallbackStalls = 2; private const int MaximumRecoveryHoles = 30; // The most holes recovery's search alone served; parts with more try the reading-order sweep first. private const int SearchedRecoveryHoles = 16; @@ -71,6 +79,10 @@ internal static class JointCuttingPlanSearch var count = snapshot.Placements.Count; var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1); var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours; + // Fixed programs have no entries to retry, so their search space is small and running + // it out is a genuine refusal; only a search that regenerates programs is bounded. + int? FallbackLimit() => snapshot.Placements.Any(p => p.Prepared != null) + ? (int)System.Math.Min(int.MaxValue, walk.Expansions + (long)FallbackStalls * stall) : null; 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. @@ -91,7 +103,7 @@ internal static class JointCuttingPlanSearch if (recovered.Order != null) return walk.Ready(recovered.Order); } - var kept = walk.Follow(keptSequence, null, null); + var kept = walk.Follow(keptSequence, null, null, expansionLimit: FallbackLimit()); return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted(); } @@ -133,11 +145,12 @@ internal static class JointCuttingPlanSearch // A lone holed part has no part order to learn: its holes still get recovery. if (preferEndpoints || maxContours > 1) { - var recovery = walk.Follow(null, stall, null, contourRecovery: true); + var recovery = walk.Follow(null, stall, null, contourRecovery: true, + expansionLimit: FallbackLimit()); if (recovery.Order != null) return walk.Ready(recovery.Order); } - var full = walk.Follow(null, null, null); + var full = walk.Follow(null, null, null, expansionLimit: FallbackLimit()); return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted(); } // Back up to just before the earliest part the blocked approach crossed, keep the @@ -327,9 +340,10 @@ internal static class JointCuttingPlanSearch /// further along the order; it never backtracks behind its starting node. /// With , an approach into a cutoff that crosses parts /// already cut ends the attempt at once when that teaches "cut it before them". + /// With , the attempt ends once the walk's expansions reach it. /// internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false, - HashSet[] prerequisites = null) + HashSet[] prerequisites = null, int? expansionLimit = null) { var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null); var attempt = new Attempt(sequence); @@ -348,6 +362,9 @@ internal static class JointCuttingPlanSearch attempt.Order = node.Order; return attempt; } + // A pass held to a share of the budget ends there, as a stalled one does. + if (expansionLimit is int end && Expansions >= end) + return attempt; // A cutoff is a fixed program cut the same way wherever it goes, so replanning // the parts before it can only change where its approach starts. Cutting it // earlier never makes its own cut harder, and a sheet separator that cannot come diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs index 6f47a10c..6a7447d1 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs @@ -227,6 +227,48 @@ public class CuttingPlanBatchTests Assert.True(proposal.CanApply); } + [Fact] + public void Plan_FreeSearchOutOfBudget_RetriesTheSameCaptureAlongTheCurrentOrder() + { + var nest = new Nest(); + var plate = Plate(nest, Grid(4)); + + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false, null, + reorderBudget: 50, CancellationToken.None).Plan(); + + // One capture serves both searches: the kept-order retry plans the placements the + // free search planned, with their freshness record, under the plate budget. + var planned = Assert.Single(proposal.Plates); + Assert.True(planned.KeptCurrentOrder); + Assert.Same(planned.ReorderAttempt!.Snapshot!.PlateState, planned.Result.Snapshot!.PlateState); + Assert.Equal(CuttingPlanBatch.PlateBudget(plate.Parts.Count), planned.Result.Snapshot.ExpansionBudget); + Assert.Equal(50, planned.ReorderAttempt.Snapshot!.ExpansionBudget); + Assert.True(planned.Result.Snapshot.PreservePartOrder); + Assert.False(planned.ReorderAttempt.Snapshot.PreservePartOrder); + Assert.Equal(planned.ReorderAttempt.Snapshot.Placements, planned.Result.Snapshot.Placements, + ReferenceEqualityComparer.Instance); + } + + [Fact] + public void WithPreservedOrder_SharesTheCaptureAndFixesTheOrder() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1)); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 123)); + + var kept = snapshot.WithPreservedOrder(); + + Assert.False(snapshot.PreservePartOrder); + Assert.True(kept.PreservePartOrder); + Assert.Equal(snapshot.Placements, kept.Placements, ReferenceEqualityComparer.Instance); + Assert.Same(snapshot.PlateState, kept.PlateState); + Assert.Same(snapshot.Dependencies, kept.Dependencies); + Assert.Same(snapshot.OwnedParameters, kept.OwnedParameters); + Assert.Equal(123, kept.ExpansionBudget); + Assert.Equal(snapshot.MaxEntries, kept.MaxEntries); + } + [Fact] public void Plan_PreservedOrder_DoesNotSearchForANewOrder() { diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 3467cf45..d3099e75 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -241,10 +241,15 @@ reachable via a third), so once nothing new can be learned the remaining budget 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 from the service, never a best-effort upgrade. Only the batch then -looks for the separate [safest route](#safest-route-fallback-when-the-search-runs-out). +on that branch's completed prefix. The retained full search runs after it. Each of +these two passes, and the full pass along a kept order, is held to two stalls' worth +of expansions (`JointCuttingPlanSearch.FallbackStalls`): past that they only retry entry +combinations of parts already cut (on 140 production plates neither found a plan the +order passes had missed), so the rest of the budget is left unspent rather than taken +from the kept-order retry and the safest route. Recovery shares the original total +expansion budget; exhausting it still returns a refusal from the service, never a +best-effort upgrade. Only the batch then looks for the separate +[safest route](#safest-route-fallback-when-the-search-runs-out). ### Cutoff segments @@ -699,7 +704,8 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer. attempt. The current-order retry still uses its original 16-entry budget. Returned expansion counts describe the retained attempt, not a discarded short pass plus its full-cap retry. -- Every plate is captured on the UI thread and checked and planned on a worker. Clean part +- Every plate is captured on the UI thread, once (the free-order and kept-order searches plan + the same owned capture), and checked and planned on a worker. Clean part material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts still block that plate whatever its route. Incomplete checks remain visible warnings, never a clear result (see [pre-post verification](post-verification.md)). A free-order search that ends