diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs index d053251b..fbbc53b6 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourProgramVerifier.cs @@ -55,20 +55,19 @@ internal static class ContourProgramVerifier var first = run.Curves[0]; var ringIndex = -1; var entityIndex = -1; - for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++) + // The admitted slack is far below any extent margin, so only boundaries near the run's + // start can contain it; they come in ring order, as the full scan met them. + foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y))) { + Query(token, ref budget); var admitted = AdmittedClosureSlack(material.Rings[r]); - for (var e = 0; e < material.Rings[r].Length; e++) + var curve = material.Rings[r][e]; + if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted) + && curve.DistanceAlong(first.Start) < curve.Length - Epsilon) { - Query(token, ref budget); - var curve = material.Rings[r][e]; - if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted) - && curve.DistanceAlong(first.Start) < curve.Length - Epsilon) - { - ringIndex = r; - entityIndex = e; - break; - } + ringIndex = r; + entityIndex = e; + break; } } if (ringIndex < 0) return false; diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs index 43c4d488..d1093f82 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs @@ -91,39 +91,36 @@ public sealed class LeadMaterialSnapshot throw new ArgumentException("Material has no closed contour."); var budget = 1000000; // Certify simple rings and mutually disjoint boundaries before containment. - for (var r = 0; r < rings.Count; r++) - for (var s = r; s < rings.Count; s++) - for (var i = 0; i < rings[r].Length; i++) - for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++) - { - token.ThrowIfCancellationRequested(); - if (--budget < 0) - throw new NotSupportedException("Material validation exceeds the native query limit."); - var a = rings[r][i]; - var b = rings[s][j]; - var contacts = a.Contacts(b, out var overlap); - var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); - var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc - && (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon - || a.Start.DistanceTo(b.End) <= Tolerance.Epsilon); - // Native arc queries pad angles by 1e-5 radians, admitting - // intersections on an extension (distance grows with radius). - // At a shared arc joint require both actual finite spans; - // overlap and genuine second intersections still refuse. - if (arcJoint) - contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray(); - // Native contacts at rounded arc/line joints can drift from the - // authored endpoint. Use the shared geometry tolerance only to - // recognize that joint; overlaps and other contacts still refuse. - if (overlap || contacts.Any(p => !adjacent - || !(p.DistanceTo(a.End) <= Tolerance.Epsilon - && p.DistanceTo(b.Start) <= Tolerance.Epsilon) - && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon - && p.DistanceTo(b.End) <= Tolerance.Epsilon))) - throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s]) - ? "A display perimeter must enclose every other contour." - : $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); - } + foreach (var (r, i, s, j) in ContactCandidates(rings, token)) + { + token.ThrowIfCancellationRequested(); + if (--budget < 0) + throw new NotSupportedException("Material validation exceeds the native query limit."); + var a = rings[r][i]; + var b = rings[s][j]; + var contacts = a.Contacts(b, out var overlap); + var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1)); + var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc + && (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon + || a.Start.DistanceTo(b.End) <= Tolerance.Epsilon); + // Native arc queries pad angles by 1e-5 radians, admitting + // intersections on an extension (distance grows with radius). + // At a shared arc joint require both actual finite spans; + // overlap and genuine second intersections still refuse. + if (arcJoint) + contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray(); + // Native contacts at rounded arc/line joints can drift from the + // authored endpoint. Use the shared geometry tolerance only to + // recognize that joint; overlaps and other contacts still refuse. + if (overlap || contacts.Any(p => !adjacent + || !(p.DistanceTo(a.End) <= Tolerance.Epsilon + && p.DistanceTo(b.Start) <= Tolerance.Epsilon) + && !(p.DistanceTo(a.Start) <= Tolerance.Epsilon + && p.DistanceTo(b.End) <= Tolerance.Epsilon))) + throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s]) + ? "A display perimeter must enclose every other contour." + : $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap})."); + } var outer = -1; for (var i = 0; i < rings.Count; i++) { @@ -180,8 +177,181 @@ public sealed class LeadMaterialSnapshot } } - internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token) - && !Rings.Skip(1).Any(hole => Inside(point, hole, token)); + /// + /// The boundary pairs certification queries natively, in the order of the full pairwise scan + /// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks' + /// margin () can report no + /// native contact, so it is left out; a sweep over X finds the rest without visiting every pair. + /// Material that is not well conditioned keeps every pair. + /// + private static List<(int R, int I, int S, int J)> ContactCandidates( + IReadOnlyList rings, CancellationToken token) + { + var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>(); + var whole = PostVerificationGeometry.Extent.None; + for (var r = 0; r < rings.Count; r++) + for (var i = 0; i < rings[r].Length; i++) + { + curves.Add((r, i, rings[r][i].Extent)); + whole = whole.Union(rings[r][i].Extent); + } + var pairs = new List<(int R, int I, int S, int J)>(); + if (!whole.IsWellConditioned) + { + for (var a = 0; a < curves.Count; a++) + for (var b = a + 1; b < curves.Count; b++) + pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index)); + } + else + { + // No pair spans more than the whole material, so this bounds every pair's margin. + var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding + * System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY); + var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray(); + for (var a = 0; a < byMinX.Length; a++) + { + token.ThrowIfCancellationRequested(); + var first = byMinX[a]; + for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++) + { + var second = byMinX[b]; + if (first.Extent.IsClearOfQueriesFrom(second.Extent)) + continue; + pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0 + ? (first.Ring, first.Index, second.Ring, second.Index) + : (second.Ring, second.Index, first.Ring, first.Index)); + } + } + } + pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J))); + return pairs; + } + + /// Material with fewer boundaries is scanned whole, exactly as before the index. + private const int IndexedBoundaries = 64; + + private BoundaryIndex index; + + /// + /// The boundaries a native query from can report a contact with, in + /// ring order: every boundary but those whose extents are clear of it by the lead checks' + /// margin plus (). + /// Small material returns every boundary. + /// + internal IEnumerable BoundariesNear(PostVerificationGeometry.Extent query, + double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]); + + /// As , each boundary as its ring and its index in that ring. + internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0) + { + if (BoundaryCount < IndexedBoundaries) + return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))); + return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach); + } + + internal bool ContainsMaterial(Vector point, CancellationToken token) + { + if (BoundaryCount < IndexedBoundaries) + return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token)); + var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y); + return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token)); + } + + /// + /// A uniform grid of boundary extents over the material, for material with many holes. Read + /// only once built, so concurrent searches may share it. + /// + private sealed class BoundaryIndex + { + private readonly (int Ring, int Index)[] positions; + private readonly PostVerificationGeometry.Extent[] extents; + private readonly List[] cells; + private readonly List[] bands; + private readonly PostVerificationGeometry.Extent[] ringExtents; + private readonly int ringCount; + private readonly PostVerificationGeometry.Extent whole; + private readonly int columns; + private readonly int rows; + private readonly double width; + private readonly double height; + + internal BoundaryIndex(IReadOnlyList rings, PostVerificationGeometry.Extent whole) + { + positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray(); + extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray(); + ringCount = rings.Count; + ringExtents = new PostVerificationGeometry.Extent[rings.Count]; + this.whole = whole; + var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length))); + columns = side; + rows = side; + width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns; + height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows; + cells = new List[columns * rows]; + bands = new List[rows]; + if (!whole.IsWellConditioned) + return; + for (var ring = 1; ring < rings.Count; ring++) + { + var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent)); + ringExtents[ring] = extent; + for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin); + band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++) + (bands[band] ??= new List()).Add(ring); + } + for (var b = 0; b < positions.Length; b++) + { + var (x0, y0, x1, y1) = Cells(extents[b], 0); + for (var y = y0; y <= y1; y++) + for (var x = x0; x <= x1; x++) + (cells[y * columns + x] ??= new List()).Add(b); + } + } + + /// + /// The holes, in ring order, whose curves a ray along row can cross: + /// every hole but those whose extents are clear of the row (see + /// ). + /// + internal IEnumerable HolesOnRow(double y) + { + if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit)) + return Enumerable.Range(1, ringCount - 1); + return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y)); + } + + internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach) + { + // The margin covers every pair's span, and each skip is then decided for the pair alone. + // Material or a query that is not well conditioned (whose cells were never built, for + // the material) keeps every boundary. + var union = whole.Union(query); + var margin = PostVerificationGeometry.Extent.ClearMargin + reach + + PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY); + if (!union.IsWellConditioned || !double.IsFinite(margin)) + return positions; + var (x0, y0, x1, y1) = Cells(query, margin); + var found = new List(); + for (var y = y0; y <= y1; y++) + for (var x = x0; x <= x1; x++) + if (cells[y * columns + x] is { } cell) + found.AddRange(cell); + found.Sort(); + var near = new List<(int Ring, int Index)>(); + for (var i = 0; i < found.Count; i++) + if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach)) + near.Add(positions[found[i]]); + return near; + } + + // The cells an extent widened by margin covers, clamped to the grid. + private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => ( + Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin)); + + private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1); + + private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1); + } internal static bool Inside(Vector point, IReadOnlyList ring, CancellationToken token) { diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index 98b96497..c442d441 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -125,7 +125,8 @@ public static class LeadPathValidator var edge = execution.Motions[groupEdge]; var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start; var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End; - foreach (var boundary in target.Rings.SelectMany(r => r)) + // A boundary clear of the contour's first motion contains none of its points. + foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent)) { token.ThrowIfCancellationRequested(); if (--budget < 0) @@ -190,10 +191,7 @@ public static class LeadPathValidator // Every boundary of material this far away is farther than the zone. if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y) .IsClearOfQueriesFrom(material.Extent, pierceRadius)) - { - Charge(material.BoundaryCount); continue; - } if (Clearance(material) is { } distance) return (Short(distance, "another placed part"), other); } @@ -232,11 +230,11 @@ public static class LeadPathValidator } // Material too far from the lead for any native boundary query to report a contact - // (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran. - // Ray parity is still asked natively unless the lead lies beside the material's rows. + // (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run + // are charged, so many far parts or holes do not exhaust the budget. Ray parity is + // still asked natively unless the lead lies beside the material's rows. bool FarInside(LeadMaterialSnapshot material) { - Charge(material.BoundaryCount); var midpoint = move.Curve.Midpoint; return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token); } @@ -289,7 +287,8 @@ public static class LeadPathValidator internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false) { - foreach (var boundary in material.Rings.SelectMany(r => r)) + // Boundaries no native query from the curve can reach report no contact; only the others run. + foreach (var boundary in material.BoundariesNear(curve.Extent)) { token.ThrowIfCancellationRequested(); if (--budget < 0) diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 03586dc7..d9205369 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -781,10 +781,12 @@ public sealed class PreparedContours throw new ArgumentException("Foreign contour choice."); ValidateChoice(choice); // Same owned clones as a real emission; the source shapes/settings are never used - // directly, so the probe cannot drift the preparation or mutate it. + // directly, so the probe cannot drift the preparation or mutate it. Isolated emission + // reads only the chosen contour and the contour count, so only that contour is cloned. + var owned = new Shape[shapes.Length]; + owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone(); return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated( - shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice, - contourParameters, PerimeterOrdinal); + owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal); } // Each prefix is a standalone owned program, including the same scribes and break cuts once. diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs index 70570b65..48653550 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -12,6 +12,11 @@ public sealed class ReleasedContourState { private readonly List obstacles = new(); private readonly bool reportMissingLeadIns; + // How the last analysed part ended: the contours it numbered, and whether nothing it left + // (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how + // motions appended to it are judged. See Continue. + private int partContours; + private bool partClosed; public ReleasedContourState() : this(true) { } @@ -24,7 +29,11 @@ public sealed class ReleasedContourState public ReleasedContourState Copy() { - var copy = new ReleasedContourState(reportMissingLeadIns); + var copy = new ReleasedContourState(reportMissingLeadIns) + { + partContours = partContours, + partClosed = partClosed, + }; copy.obstacles.AddRange(obstacles); return copy; } @@ -62,10 +71,33 @@ public sealed class ReleasedContourState return (findings.AsReadOnly(), fixedFindings.AsReadOnly()); } + /// + /// Checks as the continuation of the part the last check ended + /// in, as if that part's whole execution had been checked with these motions appended: the + /// findings are those the appended motions would add, and the state advances the same way. + /// It needs that part to have ended with every cut closed and nothing deferred, and the + /// continuation to open with a rapid and end the same way; otherwise the result is null and + /// this state must be discarded, and the caller checks the whole execution instead. Fixed + /// emissions and cutoffs are not continued. + /// + internal IReadOnlyList Continue(OwnedExecution execution, Vector arrival, + int partNumber, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(execution); + PostVerificationGeometry.Validate(arrival); + if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid) + return null; + var moves = execution.Motions.ToArray(); + moves[0] = moves[0].WithStart(arrival); + var findings = new List(); + AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours); + return partClosed ? findings.AsReadOnly() : null; + } + internal void AnalyzeMoves(IReadOnlyList moves, bool cutoff, List findings, int plate, int part, CancellationToken token, Func isFixed = null, List fixedFindings = null, - bool proveOpenCuts = false) + bool proveOpenCuts = false, int contoursBefore = 0) { var current = new CutGroup(); var unfinished = new List(); @@ -78,7 +110,7 @@ public sealed class ReleasedContourState CutGroup previous = null; var deferred = new List<(List Target, int Index, PostVerificationFinding Finding)>(); var hasLead = false; - var contourNumber = 0; + var contourNumber = contoursBefore; var inBreakCut = false; var reportedTab = false; for (var index = 0; index < moves.Count; index++) @@ -222,6 +254,9 @@ public sealed class ReleasedContourState "Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " + "they cannot be assumed to be retained by tabs. Review rapid travel manually."), unfinished.Any(chain => chain.Fixed)); + partContours = contourNumber; + partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab + && !inBreakCut && loose.Leads.Count == 0; void Add(PostVerificationFinding finding, bool fixedEmission) { diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index c35b7eef..8cbd4b1d 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -78,7 +78,7 @@ internal static class PostVerificationGeometry // Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds // (including the empty extent's) fail this test too. - private bool IsWellConditioned => + internal bool IsWellConditioned => System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)), System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit; } diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 8b0a4762..e3795940 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -550,13 +550,14 @@ internal static class JointCuttingPlanSearch var arrival = node.Active?.Arrival ?? node.Position; var before = node.Active?.Before ?? node.Checker; var boundary = node.Active?.Boundary ?? node; + var chosen = choices.Select(e => e.ContourOrdinal).ToHashSet(); var contours = prepared.HasCutPerimeter && choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal } - : Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c)); + : Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !chosen.Contains(c)); // First try one ranked hole chain per endpoint. A later-part failure then // changes the endpoint before replaying all earlier hole combinations. // The retained pass below still searches every entry and hole order. if (preference != null) - contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c); + contours = contours.OrderBy(preference.RankOf).ThenBy(c => c); if (preferredOnly && preference != null) contours = contours.Take(1); foreach (var contour in contours) @@ -570,9 +571,9 @@ internal static class JointCuttingPlanSearch entries = [perimeter]; else { - contourRank = Array.IndexOf(preference.Route, contour); + contourRank = preference.RankOf(contour); var downstream = preference.Route.Skip(contourRank + 1) - .FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1); + .FirstOrDefault(c => !chosen.Contains(c), -1); var target = downstream < 0 ? preference.PerimeterPierce : preference.Pierces[downstream]; // The coarse pass already checked one owned, lead-feasible entry @@ -601,6 +602,19 @@ internal static class JointCuttingPlanSearch { CountExpansion(source); // Before emission/native queries, including rejected candidates. var prefix = choices.Append(entries[entry]).ToArray(); + // A many-holed part's middle steps check only the contour they add; the full + // prefix runs when that cannot be done exactly, and always for the last contour. + if (Continues(source, choices, prefix) + && ContinueContour(source, entries[entry], node, attempt) is { } step) + { + if (step.Checker == null) continue; + var added = node.Active.Distance + step.Execution.RapidDistanceFrom(node.Position); + yield return new(new Node(node.Order, step.Execution.DeparturePoint, step.Checker, + new(source, prefix, arrival, before, added, boundary, + node.Active.Perimeter, preference), null), + added - node.Active.Distance, source.SourceOrdinal, contour, entry, entry, contourRank); + continue; + } Program program; OwnedExecution execution; try @@ -1147,6 +1161,54 @@ internal static class JointCuttingPlanSearch execution.DeparturePoint, checker, null, node), severity, execution.RapidDistanceFrom(arrival)); } + /// Parts with fewer contours always check the whole prefix, as they always have. + internal const int ContinuedContours = 64; + + // Whether a step may check only the contour it adds: a middle contour (not the first, + // which also cuts the part's scribes and open cuts, nor the last, whose full prefix is + // the part's certified program) of a many-holed part, with no drawing-pinned contour. + private static bool Continues(FixedProgramPlacement source, ContourChoice[] choices, ContourChoice[] prefix) => + !source.IsCutOff && source.Prepared.Count >= ContinuedContours && choices.Length > 0 + && prefix.Length < source.Prepared.Count && !prefix.Any(c => source.Prepared.IsPinned(c.ContourOrdinal)); + + // Each choice's own motions, read once; the rapid into it starts where each check says. + private readonly Dictionary contourMotions = []; + + /// + /// Checks one added contour against the state its prefix left, as judges + /// it inside the whole prefix: earlier contours' rapids and leads were checked when they were + /// added, and contour emission never reads other choices. Null when that cannot be done + /// exactly (the isolated emission refused, or the checker cannot continue the part); the + /// caller then checks the full prefix. A step whose checks fail has a null checker. + /// + private ContinuedStep ContinueContour(FixedProgramPlacement source, ContourChoice choice, Node node, Attempt attempt) + { + if (!contourMotions.TryGetValue(choice, out var execution)) + { + try + { + execution = WithoutScribes(ExecutionMotionReader.Read( + source.Prepared.EmitCandidateForValidation(choice), source.Location, null, token)); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException) + { + return null; + } + contourMotions[choice] = execution; + } + var checker = node.Checker.Copy(); + var findings = checker.Continue(execution, node.Position, source.SourceOrdinal + 1, token); + if (findings == null) + return null; + rejected.AddRange(Map(snapshot, findings)); + attempt.NoteCrossings(source.SourceOrdinal, findings); + var lead = LeadPathValidator.Check(execution, source.Material, materials, null, [], token, leadContacts, + snapshot.PierceZoneRadius); + if (!lead.IsComplete || !lead.IsClear) + rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason)); + return new(execution, findings.Count == 0 && lead.IsComplete && lead.IsClear ? checker : null); + } + // The strict checks of Check, counted instead of refused. private RouteSeverity Score(FixedProgramPlacement source, OwnedExecution execution, Vector arrival, ReleasedContourState checker, IReadOnlyList choices = null) @@ -1249,7 +1311,14 @@ internal static class JointCuttingPlanSearch private sealed class BudgetExceededException : Exception; // Every endpoint branch owns its preference; all retained entries/orders remain searchable. private sealed record HolePreference(int[] Route, IReadOnlyDictionary Entries, - IReadOnlyDictionary Pierces, Vector PerimeterPierce); + IReadOnlyDictionary Pierces, Vector PerimeterPierce) + { + private readonly Dictionary ranks = Route.Select((contour, rank) => (contour, rank)) + .DistinctBy(r => r.contour).ToDictionary(r => r.contour, r => r.rank); + + /// The contour's place along , or -1 when it is not on it. + internal int RankOf(int contour) => ranks.TryGetValue(contour, out var rank) ? rank : -1; + } private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival, ReleasedContourState Before, double Distance, Node Boundary, ContourChoice Perimeter = null, HolePreference Preference = null); @@ -1267,6 +1336,7 @@ internal static class JointCuttingPlanSearch private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry, int Rank = int.MinValue, int ContourRank = 0); private sealed record SafestStep(Node Node, RouteSeverity Severity, double Distance); + private sealed record ContinuedStep(OwnedExecution Execution, ReleasedContourState Checker); private sealed class Frame(Node node) { internal Node Node { get; } = node; diff --git a/OpenNest.Tests/CuttingPlanning/ContinuedContourCheckTests.cs b/OpenNest.Tests/CuttingPlanning/ContinuedContourCheckTests.cs new file mode 100644 index 00000000..d86f944f --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContinuedContourCheckTests.cs @@ -0,0 +1,123 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// A many-holed part's middle search steps check only the contour they add, continuing the +/// state its prefix left. That must judge each prefix exactly as checking the whole prefix +/// from before the part does: the same findings, numbered the same way, from the same place. +/// +public class ContinuedContourCheckTests +{ + private const int Side = 9; + private static readonly Vector Location = new(5.3, 2.7); + private static readonly Vector Arrival = new(-1, -1); + + [Fact] + public void ContinuedChecks_MatchFullPrefixChecks_IncludingACrossing() + { + var prepared = PreparedContours.Capture(Plate(), Parameters()); + // Row by row, except that hole 1 is cut before hole 0, so the rapid from hole 0 on to + // hole 2 crosses the completed hole 1 and every later prefix repeats that finding. + var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToList(); + (holes[0], holes[1]) = (holes[1], holes[0]); + var choices = new List(); + var position = Arrival - Location; + foreach (var hole in holes) + { + var choice = prepared.ClosestEntry(hole, position); + choices.Add(choice); + position = prepared.HoleCentres()[hole]!.Value; + } + + ReleasedContourState continued = null; + Vector departure = default; + var accumulated = new List(); + for (var length = 1; length <= choices.Count; length++) + { + var prefix = choices.Take(length).ToArray(); + var full = ExecutionMotionReader.Read(prepared.EmitPrefix(prefix), Location, Arrival, default); + var fullChecker = new ReleasedContourState(reportMissingLeadIns: false); + var fullFindings = fullChecker.Check(full, Arrival, 1).Select(Describe).ToList(); + + if (continued == null) + { + continued = fullChecker; + accumulated.AddRange(fullFindings); + } + else + { + var step = Isolated(prepared, prefix[^1]); + var added = continued.Continue(step, departure, 1, default); + Assert.NotNull(added); + accumulated.AddRange(added.Select(Describe)); + Assert.True(step.DeparturePoint.DistanceTo(full.DeparturePoint) < 1e-9, + $"Prefix {length} departs from {step.DeparturePoint}, not {full.DeparturePoint}."); + } + Assert.Equal(fullFindings, accumulated); + departure = full.DeparturePoint; + } + Assert.Contains(accumulated, f => f.Contains("contour 1 of part 1")); + } + + [Fact] + public void Continue_NeedsAPartLeftClosed() + { + var prepared = PreparedContours.Capture(Plate(), Parameters()); + + Assert.Null(new ReleasedContourState(reportMissingLeadIns: false) + .Continue(Isolated(prepared, prepared.ClosestEntry(0, Vector.Zero)), Arrival, 1, default)); + } + + [Fact] + public void ManyHoledPart_PlansReady_AndReplays() + { + var plate = new Nest().CreatePlate(); + plate.Size = new Size(20, 20); + plate.Parts.Add(new Part(new Drawing("holes", Plate()), Location)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + expansionBudget: CuttingPlanBatch.PlateBudget(1), confirmedParameters: Parameters())); + + Assert.True(Side * Side + 1 >= 64, "The fixture must take the continued path."); + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}: " + string.Join("; ", result.Findings.Take(3).Select(f => f.Message))); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(Side * Side + 1, result.ProposedOrder.Single().ContourChoices.Count); + } + + private static OwnedExecution Isolated(PreparedContours prepared, ContourChoice choice) + { + var execution = ExecutionMotionReader.Read(prepared.EmitCandidateForValidation(choice), Location, null, default); + return execution; + } + + private static string Describe(OpenNest.Diagnostics.PostVerificationFinding finding) => + $"{finding.Kind}: {finding.Message}"; + + // A 10 x 10 square with a 9 x 9 grid of holes 0.5 across, one apart. + private static Program Plate() + { + var p = ExplicitContourTests.Square(false); + for (var row = 1; row <= Side; row++) + for (var column = 1; column <= Side; column++) + { + p.MoveTo(column + 0.25, row); + p.ArcTo(column + 0.25, row, column, row, RotationType.CCW); + } + return p; + } + + private static CuttingParameters Parameters() => new() + { + ExternalLeadIn = new LineLeadIn { Length = 0.2, ApproachAngle = 90 }, + InternalLeadIn = new LineLeadIn { Length = 0.1, ApproachAngle = 90 }, + ArcCircleLeadIn = new LineLeadIn { Length = 0.1, ApproachAngle = 90 }, + ExternalLeadOut = new NoLeadOut(), + InternalLeadOut = new NoLeadOut(), + }; +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 0316a3ad..c74630e1 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -285,9 +285,15 @@ circles, and leads keep those results: a lead check skips another part's materia only when their extents are clear by that 0.001 plus 1e-6 times the span of both extents. The discriminant's rounding error is bounded by about 1e-7 times that span (about 2e-9 is seen), so a skipped material has no native contact; a 900000-long lead -beside a small circle is still checked. A skipped scan is charged to the native query -budget as if it ran, and ray parity is still asked natively unless the lead's midpoint -lies outside the material's Y range. A pierce zone skips material farther than its +beside a small circle is still checked. Only native queries that run are charged to +the native query budget, so a skipped material costs nothing, and ray parity is still +asked natively unless the lead's midpoint lies outside the material's Y range. Material +with at least 64 boundaries keeps a grid of its boundary extents: a lead check, a +contour-joint match or replay's contour accounting queries only the boundaries that the +same per-pair clearance cannot skip, in ring order, and ray parity reads only the holes +whose extents reach the point's row. Material capture certifies its boundaries the same +way, sweeping extents along X instead of pairing every boundary with every other, so a +part with thousands of holes stays within the limit. A pierce zone skips material farther than its radius by the same gap. Every prefix of a part repeats its earlier contours' leads, so one search remembers each other-material verdict by the lead curve's exact bits and the material's reference, and charges the native query budget the scan ran; replay never uses it. An arc's @@ -375,6 +381,17 @@ that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement. A cutout that contains the arrival point (the tool comes from a part nested in it) is routed from the arrival point itself, not its centre, so it is cut first, before a later hop to it would have to cross the other parts nested there. +On a part with at least 64 contours, a middle step of that route (neither the first +contour, which also cuts the part's scribes and open cuts, nor the last) checks only the +contour it adds, continuing the completed-contour state its prefix left rather than emitting +and replaying the whole prefix from before the part. Contour emission reads only its own +contour, so the added rapid and leads are the ones the prefix would cut, and the continued +check numbers contours as the full check does. It continues only when the prefix ended with +every cut closed and nothing deferred, and never for a drawing-pinned contour, a cutoff or +a fixed program; otherwise, and whenever the isolated emission refuses, the step checks its +full prefix. The step that adds the last contour always emits and checks the whole part +from before it, so each completed part's program is certified exactly as before. Parts with +fewer contours check every prefix in full. Preferred hole entries are then resolved backward from the outside's actual emitted pierce, ignoring scribe marks: each hole faces the following contour's actual pierce. Convex corners lead the preference tiers, then straight midpoints/tangent joints,