diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs index 7a55f528..c9120236 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -64,11 +64,19 @@ public sealed class ReleasedContourState internal void AnalyzeMoves(IReadOnlyList moves, bool cutoff, List findings, int plate, int part, CancellationToken token, - Func isFixed = null, List fixedFindings = null) + Func isFixed = null, List fixedFindings = null, + bool proveOpenCuts = false) { - var contour = new List(); - var contourFixed = false; - var unfinished = new List<(PostVerificationGeometry.Curve[] Curves, bool Fixed)>(); + var current = new CutGroup(); + var unfinished = new List(); + // Part-local cuts read only by the open-cut proof below: closed contours, break cuts + // and leads no contour owns. Each lead belongs to the cut it enters or leaves. + var closed = new List(); + var otherOpen = new List(); + var loose = new CutGroup(); + var leadIns = new List(); + CutGroup previous = null; + var deferred = new List<(List Target, int Index, PostVerificationFinding Finding)>(); var hasLead = false; var contourNumber = 0; var inBreakCut = false; @@ -90,7 +98,13 @@ public sealed class ReleasedContourState { Location = move.Curve.Start }, owned); inBreakCut = true; hasLead = false; + previous = new CutGroup(); + previous.Leads.AddRange(leadIns); + leadIns.Clear(); + otherOpen.Add(previous); } + if (move.Curve.Length > PostVerificationGeometry.Epsilon) + previous.Curves.Add(move.Curve); continue; } inBreakCut = false; @@ -98,6 +112,9 @@ public sealed class ReleasedContourState { Finish(); hasLead = false; + loose.Leads.AddRange(leadIns); + leadIns.Clear(); + previous = null; if (move.Start is not { } start || start.DistanceTo(move.End) <= PostVerificationGeometry.Epsilon) continue; var reach = new PostVerificationGeometry.Extent(System.Math.Min(start.X, move.End.X), @@ -124,22 +141,30 @@ public sealed class ReleasedContourState else if (move.Layer == LayerType.Leadin) { Finish(); + previous = null; hasLead |= move.Curve.Length > PostVerificationGeometry.Epsilon; + if (move.Curve.Length > PostVerificationGeometry.Epsilon) + leadIns.Add(move.Curve); } else if (move.Layer is LayerType.Leadout or LayerType.Scribe) { - if (move.Layer == LayerType.Leadout && contour.Count > 0 - && !PostVerificationGeometry.Closed(contour) - && move.Curve.Length > PostVerificationGeometry.Epsilon) - Add(new(PostVerificationKind.Incomplete, plate, part, null, - "A lead-out follows an open cutting contour and may cut through its retention gap. " + - "Rapid safety for that contour requires manual review."), owned); + if (move.Layer == LayerType.Leadout && move.Curve.Length > PostVerificationGeometry.Epsilon) + { + // Withheld until the part ends, where the open-cut proof may show it cuts no gap. + if (current.Curves.Count > 0 && !PostVerificationGeometry.Closed(current.Curves)) + Defer(new(PostVerificationKind.Incomplete, plate, part, null, + "A lead-out follows an open cutting contour and may cut through its retention gap. " + + "Rapid safety for that contour requires manual review."), owned); + (previous ?? loose).Leads.Add(move.Curve); + } Finish(); hasLead = false; + if (move.Layer == LayerType.Scribe) + previous = null; } else if (move.Curve.Length > PostVerificationGeometry.Epsilon) { - if (contour.Count == 0) + if (current.Curves.Count == 0) { contourNumber++; if (reportMissingLeadIns && !cutoff && !hasLead) @@ -150,26 +175,37 @@ public sealed class ReleasedContourState // A rapid can pause/reposition without leaving any material gap. // Retain already-cut fragments, but do not turn them into obstacles // until an actually continuous chain closes. - var previous = unfinished.FindIndex(chain => + var resumed = unfinished.FindIndex(chain => chain.Curves[^1].End.DistanceTo(move.Curve.Start) <= PostVerificationGeometry.Epsilon); - if (previous >= 0) + if (resumed >= 0) { - contour.AddRange(unfinished[previous].Curves); - contourFixed |= unfinished[previous].Fixed; - unfinished.RemoveAt(previous); + current = unfinished[resumed]; + unfinished.RemoveAt(resumed); } + current.Leads.AddRange(leadIns); + leadIns.Clear(); } - contour.Add(move.Curve); - contourFixed |= owned; + current.Curves.Add(move.Curve); + current.Fixed |= owned; + previous = current; // A completed contour becomes an obstacle immediately, not at part end. - if (PostVerificationGeometry.Closed(contour)) + if (PostVerificationGeometry.Closed(current.Curves)) Finish(); } } Finish(); + loose.Leads.AddRange(leadIns); + // Open cuts that provably enclose nothing retain all their material, however many + // pieces a tabbed cutout was cut in. Otherwise they need manual review. Only pre-post + // review asks: planning keeps refusing to certify its own tabs followed by a lead-out. + var retained = proveOpenCuts && !cutoff && (unfinished.Count > 1 || deferred.Count > 0) + && unfinished.All(LeadsClearOpenEnds) + && OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token); + for (var i = deferred.Count - 1; i >= 0 && !retained; i--) + deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding); // Only one open fragment per part can be the planner's own certified tab, so a // further fragment that a fixed emission cut makes this uncertainty that emission's. - if (unfinished.Count > 1) + if (unfinished.Count > 1 && !retained) Add(new(PostVerificationKind.Incomplete, plate, part, null, "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."), @@ -183,21 +219,187 @@ public sealed class ReleasedContourState findings.Add(finding); } + // Records the finding's place among the others, in case it is reported after all. + void Defer(PostVerificationFinding finding, bool fixedEmission) + { + var target = fixedEmission && fixedFindings != null ? fixedFindings : findings; + deferred.Add((target, target.Count, finding)); + } + void Finish() { - if (contour.Count == 0) + if (current.Curves.Count == 0) return; // A real uncut gap leaves the contour attached. CuttingParameters can be stale; // no flag or tab configuration is used as evidence of retention. - if (!cutoff && PostVerificationGeometry.Closed(contour)) - obstacles.Add(new(part, contourNumber, contour.ToArray())); + if (!cutoff && PostVerificationGeometry.Closed(current.Curves)) + { + obstacles.Add(new(part, contourNumber, current.Curves.ToArray())); + closed.Add(current); + } else if (!cutoff) - unfinished.Add((contour.ToArray(), contourFixed)); - contour.Clear(); - contourFixed = false; + unfinished.Add(current); + current = new CutGroup(); } } + /// + /// Whether one part's open cuts, with their leads, can release no material. A closed + /// contour with its own leads already encloses whatever it releases and counts as one node. + /// Open cuts may touch any cut only at their own ends, and must form no loop through those + /// nodes or each other: a forest of open paths separates no region of the sheet. Kerf and + /// tab strength are not modelled, exactly as for a single open contour. + /// + private const double JointTolerance = 1e-5; + + /// + /// Whether every lead at an open end of stays out of the uncut gap + /// beyond it: a lead-out never reaches ahead of the end it leaves, and a lead-in never + /// behind the start it enters. A lead that curls into the gap can cut the tab with its kerf + /// even where its centerline does not touch the next cut. + /// + private static bool LeadsClearOpenEnds(CutGroup group) + { + var start = group.Curves[0].Start; + var end = group.Curves[^1].End; + var behindStart = group.Curves[0].Tangent(false) * -1; + var aheadOfEnd = group.Curves[^1].Tangent(true); + foreach (var lead in group.Leads) + { + if (Touches(lead, start) && lead.Reach(start, behindStart) > PostVerificationGeometry.Epsilon) + return false; + if (Touches(lead, end) && lead.Reach(end, aheadOfEnd) > PostVerificationGeometry.Epsilon) + return false; + } + return true; + + static bool Touches(PostVerificationGeometry.Curve lead, Vector point) => + lead.Start.DistanceTo(point) <= PostVerificationGeometry.Epsilon + || lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon; + } + + private static bool OpenCutsEncloseNothing(IReadOnlyList closed, IReadOnlyList open, + CancellationToken token) + { + var edges = open.SelectMany(group => group.Curves.Concat(group.Leads)).ToList(); + var nodes = new DisjointSet(closed.Count + 2 * edges.Count); + int Node(int edge, bool end) => closed.Count + 2 * edge + (end ? 1 : 0); + try + { + for (var i = 0; i < closed.Count; i++) + for (var j = 0; j < i; j++) + if (Touch(closed[i], closed[j], token)) + nodes.Union(i, j); + for (var a = 0; a < edges.Count; a++) + { + var edge = edges[a]; + if (edge.Start.DistanceTo(edge.End) <= PostVerificationGeometry.Epsilon) + return false; + for (var g = 0; g < closed.Count; g++) + { + foreach (var other in closed[g].Curves.Concat(closed[g].Leads)) + { + token.ThrowIfCancellationRequested(); + if (edge.Extent.IsClearOf(other.Extent)) + continue; + foreach (var point in Contacts(edge, other)) + { + if (EndAt(edge, point, other) is not { } end) + return false; + nodes.Union(Node(a, end), g); + } + } + } + for (var b = a + 1; b < edges.Count; b++) + { + token.ThrowIfCancellationRequested(); + var other = edges[b]; + if (edge.Extent.IsClearOf(other.Extent)) + continue; + foreach (var point in Contacts(edge, other)) + { + if (EndAt(edge, point, other) is not { } end || EndAt(other, point, edge) is not { } otherEnd) + return false; + nodes.Union(Node(a, end), Node(b, otherEnd)); + } + } + } + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException + or ArithmeticException) + { + return false; + } + var forest = new DisjointSet(closed.Count + 2 * edges.Count); + for (var a = 0; a < edges.Count; a++) + { + var start = nodes.Find(Node(a, false)); + var end = nodes.Find(Node(a, true)); + if (forest.Find(start) == forest.Find(end)) + return false; + forest.Union(start, end); + } + return true; + + static IReadOnlyList Contacts(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b) + { + var points = a.Contacts(b, out var overlap); + if (overlap) + throw new ArgumentException("Cuts share a span."); + return points; + } + + // Native contacts at a near-tangent joint (a lead or line meeting an arc) can land + // slightly off the vertex the two cuts share; that is still their shared end. + static bool? EndAt(PostVerificationGeometry.Curve curve, Vector point, PostVerificationGeometry.Curve other) + { + foreach (var end in new[] { false, true }) + { + var vertex = end ? curve.End : curve.Start; + if (point.DistanceTo(vertex) <= PostVerificationGeometry.Epsilon + || point.DistanceTo(vertex) <= JointTolerance + && (vertex.DistanceTo(other.Start) <= PostVerificationGeometry.Epsilon + || vertex.DistanceTo(other.End) <= PostVerificationGeometry.Epsilon)) + return end; + } + return null; + } + + static bool Touch(CutGroup a, CutGroup b, CancellationToken token) + { + foreach (var curve in a.Curves.Concat(a.Leads)) + foreach (var other in b.Curves.Concat(b.Leads)) + { + token.ThrowIfCancellationRequested(); + if (!curve.Extent.IsClearOf(other.Extent) && Contacts(curve, other).Count > 0) + return true; + } + return false; + } + } + + /// One contour's cut motions with the leads entering and leaving it. + private sealed class CutGroup + { + internal List Curves { get; } = new(); + internal List Leads { get; } = new(); + internal bool Fixed { get; set; } + } + + private sealed class DisjointSet(int count) + { + private readonly int[] parents = Enumerable.Range(0, count).ToArray(); + + internal int Find(int node) + { + while (parents[node] != node) + node = parents[node] = parents[parents[node]]; + return node; + } + + internal void Union(int a, int b) => parents[Find(a)] = Find(b); + } + private sealed record Obstacle(int Part, int Contour, IReadOnlyList Curves) { internal PostVerificationGeometry.Extent Extent { get; } = diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs index aea4f9a0..e2d99161 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -139,7 +139,8 @@ public static class PostVerificationAnalyzer && move.Layer is LayerType.Cut or LayerType.Display && move.Curve.Length > PostVerificationGeometry.Epsilon)) Incomplete("Placed program has no cutting contour motions for this drawing."); - obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken); + obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken, + proveOpenCuts: true); if (clamps is { Count: > 0 }) foreach (var move in moves) { diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index 5689a515..b538f014 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -205,6 +205,25 @@ internal static class PostVerificationGeometry : Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius : Dot(point - Start, (End - Start) * (1 / Length)); + /// Unit direction of travel at the curve's start or end. + internal Vector Tangent(bool atEnd) + { + if (Center is not { } center) + return (End - Start) * (1 / Length); + var radial = (atEnd ? End : Start) - center; + var turn = System.Math.Sign(Sweep); + return new Vector(-radial.Y * turn, radial.X * turn) * (1 / Radius); + } + + /// How far the curve reaches from along unit . + internal double Reach(Vector origin, Vector direction) + { + var reach = System.Math.Max(Dot(Start - origin, direction), Dot(End - origin, direction)); + if (Center is { } center && OnArc(center + direction * Radius)) + reach = System.Math.Max(reach, Dot(center - origin, direction) + Radius); + return reach; + } + internal Vector PointAtLength(double distance) { if (distance <= 0) return Start; diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs index f95d15ed..ab9dd3db 100644 --- a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs +++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs @@ -426,6 +426,42 @@ public class PostVerificationAnalyzerTests || Find(report, PostVerificationKind.Incomplete).Any()); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Rapid_CutoutCutInPiecesAroundTabsIsRetained(bool leads) + { + var report = Analyze(TabbedCutout(leads), Scribe(-2, 5), Scribe(12, 5)); + Assert.Empty(Find(report, PostVerificationKind.Incomplete)); + } + + [Fact] + public void Rapid_LeadOutBridgingATabGapIsIncomplete() + { + var part = TabbedCutout(true); + var codes = part.Program.Codes; + // The second piece's lead-out runs across its tab to the first piece's start. + codes[codes.FindLastIndex(code => code is LinearMove { Layer: LayerType.Leadout })] = new LinearMove(3, 5.1) { Layer = LayerType.Leadout }; + var report = Analyze(part, Scribe(-2, 5), Scribe(12, 5)); + Assert.NotEmpty(Find(report, PostVerificationKind.Incomplete)); + } + + [Fact] + public void Rapid_OpenCutsAcrossAClosedContourAreIncomplete() + { + var part = Rectangle(0, 0, 10); + var placed = Square(0, 0, 10); + // Two cuts from edge to edge release the strip between them. + placed.MoveTo(0, 3); + placed.LineTo(10, 3); + placed.MoveTo(10, 6); + placed.LineTo(0, 6); + part.RestoreLeadInProgram(placed, false); + var report = Analyze(part, Scribe(-2, 5), Scribe(12, 5)); + Assert.Contains(Find(report, PostVerificationKind.Incomplete), + finding => finding.Message.Contains("Multiple interrupted/open")); + } + [Fact] public void Cancellation_ThrowsWithoutChangingInputs() { @@ -506,6 +542,42 @@ public class PostVerificationAnalyzerTests return program; } + // A 10x10 part whose 4x4 cutout is cut in two pieces, leaving 0.2 tabs on its left and + // right edges; leads enter and leave each piece from inside the slug. + private static Part TabbedCutout(bool leads) + { + var clean = Square(3, 3, 4); + clean.Codes.AddRange(Square(0, 0, 10).Codes); + var part = new Part(new Drawing("tabbed", clean)); + var program = new Program(); + if (leads) + { + program.MoveTo(3.2, 5.3); + program.Codes.Add(new LinearMove(3, 5.1) { Layer = LayerType.Leadin }); + } + else + program.MoveTo(3, 5.1); + program.LineTo(3, 7); + program.LineTo(7, 7); + program.LineTo(7, 5.1); + if (leads) + { + program.Codes.Add(new LinearMove(6.8, 5.3) { Layer = LayerType.Leadout }); + program.MoveTo(6.8, 4.7); + program.Codes.Add(new LinearMove(7, 4.9) { Layer = LayerType.Leadin }); + } + else + program.MoveTo(7, 4.9); + program.LineTo(7, 3); + program.LineTo(3, 3); + program.LineTo(3, 4.9); + if (leads) + program.Codes.Add(new LinearMove(3.2, 4.7) { Layer = LayerType.Leadout }); + program.Codes.AddRange(Square(0, 0, 10).Codes); + part.RestoreLeadInProgram(program, false); + return part; + } + private static Part Scribe(double x, double y) { var program = new Program(); diff --git a/docs/post-verification.md b/docs/post-verification.md index bf7756a2..607b2432 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -112,9 +112,17 @@ scrollable report, cancel to fix the nest, then post again to rerun verification Uncheckable geometry is reported as incomplete, not passed. The overlap check reads clean drawing programs made of the built-in instructions only; a missing instruction list, any other instruction type, or a hole stored as an absolute-mode subprogram -(OpenNest writes them incremental) makes that part's check incomplete. Multiple interrupted -cut fragments, or a lead-out after an open contour that might cut through a tab, -also require manual review rather than being assumed retained. +(OpenNest writes them incremental) makes that part's check incomplete. + +Open cuts, such as a cutout cut in several pieces around tabs (with or without leads, as +PEP programs often are), pass only when they provably release nothing. Within the part, +each open piece and its leads may touch another cut only at their own ends, and together +they may form no loop, counting each closed contour with its leads as one point. A lead at +an open end must also stay out of the gap beyond it: a lead-out never reaches ahead of the +end it leaves, and a lead-in never behind the start it enters. Anything else (pieces that +meet exactly, a lead that bridges or curls into a tab, cuts from edge to edge) is reported +as incomplete, needing manual review. Kerf, tab strength and other parts' cuts are not modelled. Plan +Cutting keeps its stricter rule and does not certify its own tabs followed by a lead-out. ## Explicit override