From b9c5240a4350a7163c8ebed07129c73ae615d1f1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 18:48:25 -0400 Subject: [PATCH] fix(planning): close incremental rounding drift at contour seams PEP-converted incremental programs round every move, so a long contour can end 1-1.4e-6 off its start. Material capture refused it as open ("blocked: unsupported geometry"), and a gap right at the 1e-6 boundary could pass material capture yet fail preparation. On the planner's owned clean copy only, close incremental contours whose gap exceeds 1e-8 but stays within what their rounded moves could accumulate (capped at Tolerance.Epsilon); the next motion absorbs the correction so nothing later moves. Absolute programs, larger gaps and the drawing itself are untouched. Co-Authored-By: Claude Opus 5.5 --- .../CNC/CuttingPlanning/FixedLeadReview.cs | 3 +- .../IncrementalClosureDrift.cs | 96 +++++++++++++++ .../CuttingPlanning/CuttingPlanService.cs | 2 + .../IncrementalClosureDriftTests.cs | 115 ++++++++++++++++++ docs/cutting-planner.md | 11 +- 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs create mode 100644 OpenNest.Tests/CuttingPlanning/IncrementalClosureDriftTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs b/OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs index 5d62d7f3..6bc3e623 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/FixedLeadReview.cs @@ -208,11 +208,12 @@ internal static class FixedLeadReview static LayerType Kind(LayerType layer) => layer == LayerType.Display ? LayerType.Cut : layer; } - // The same clean, rotation-baked, seam-trimmed program that planning captures. + // The same clean, rotation-baked, drift-closed, seam-trimmed program that planning captures. internal static Program Clean(Part part) { var clean = (Program)part.BaseDrawing.Program.Clone(); clean.Rotate(part.Rotation - part.BaseDrawing.Program.Rotation); + IncrementalClosureDrift.Close(clean); SeamOvercut.Trim(clean); return clean; } diff --git a/OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs b/OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs new file mode 100644 index 00000000..dc097df7 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/IncrementalClosureDrift.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using OpenNest.Diagnostics; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingPlanning; + +/// +/// Rounding drift at an incremental contour's seam. Each incremental move is written at the source +/// CAM's precision, so a contour's end is a sum of rounded moves and can miss its start by more than +/// (converted PEP perimeters of a hundred polyline +/// moves written at 1e-6 miss by 1.4e-6). An absolute program has no such sum. A contour that misses +/// by no more than its moves could accumulate at that precision, and never more than +/// , ends its last cut on its start; the next motion absorbs the +/// correction, so nothing after the contour moves. Gaps inside the closure tolerance are closed too: +/// material capture and preparation sum the moves differently, so a gap at that boundary can pass +/// one and fail the other. Contours closed within the native epsilon are left alone. +/// +internal static class IncrementalClosureDrift +{ + /// Closes drifted contours of an owned program in place; returns how many were closed. + internal static int Close(Program program) + { + if (program?.Codes == null || program.Mode != Mode.Incremental) + return 0; + var closed = 0; + var run = new List(); + var start = new Vector(); + var position = new Vector(); + for (var index = 0; index <= program.Codes.Count; index++) + { + var code = index < program.Codes.Count ? program.Codes[index] : null; + if (code is Motion motion && IsContour(motion)) + { + if (run.Count == 0) + start = position; + run.Add(motion); + position += motion.EndPoint; + continue; + } + if (run.Count > 0 && TryClose(program, run, start, position, index)) + closed++; + run.Clear(); + if (code is Motion other) + position += other.EndPoint; + } + return closed; + } + + // The chains material capture closes: cut, display and tab moves between rapids and other layers. + private static bool IsContour(Motion motion) => !motion.Suppressed + && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab } + or ArcMove { Layer: LayerType.Cut or LayerType.Display or LayerType.Tab }; + + private static bool TryClose(Program program, List run, Vector start, Vector end, int next) + { + var gap = start.DistanceTo(end); + // Each rounded move is off by at most half a quantum per axis. + var drift = System.Math.Min(Tolerance.Epsilon, + run.Count * NominalContourClosure.Tolerance * System.Math.Sqrt(0.5)); + if (run.Count < 2 || !(gap > PostVerificationGeometry.Epsilon) || !(gap <= drift)) + return false; + var last = run[^1]; + if (last.VariableRefs is { Count: > 0 }) + return false; + // The first motion after the contour starts where the contour used to end. A subprogram call + // first would run from the corrected end instead, so that contour is left alone. + Motion absorber = null; + for (; next < program.Codes.Count && absorber == null; next++) + { + if (program.Codes[next] is SubProgramCall) + return false; + absorber = program.Codes[next] as Motion; + } + if (absorber != null && (absorber is ArcMove || absorber.VariableRefs is { Count: > 0 })) + return false; + + var correction = start - end; + var endPoint = last.EndPoint + correction; + if (last is ArcMove arc) + { + // Keep the arc's centre (relative to its start) equidistant from its moved end. + var chord = endPoint.DistanceTo(new Vector()); + if (chord <= Tolerance.Epsilon) + return false; + var normal = new Vector(-endPoint.Y, endPoint.X) * (1 / chord); + var middle = endPoint * 0.5; + var offset = arc.CenterPoint - middle; + arc.CenterPoint = middle + normal * (offset.X * normal.X + offset.Y * normal.Y); + } + last.EndPoint = endPoint; + if (absorber != null) + absorber.EndPoint -= correction; + return true; + } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 90658782..6f75c31f 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -104,6 +104,8 @@ public static class CuttingPlanService // Exact placed/proposed payloads still use lossless OwnedProgramCopy. var ownedClean = (Program)source.BaseDrawing.Program.Clone(); ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation); + // Incremental rounding can leave a converted contour just short of its seam. + IncrementalClosureDrift.Close(ownedClean); // A baked-in seam overcut encloses no area; left in, the ring touches itself. SeamOvercut.Trim(ownedClean); // Material extent only: remote rapids and scribe marks must not widen it, or a diff --git a/OpenNest.Tests/CuttingPlanning/IncrementalClosureDriftTests.cs b/OpenNest.Tests/CuttingPlanning/IncrementalClosureDriftTests.cs new file mode 100644 index 00000000..6bc145bd --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/IncrementalClosureDriftTests.cs @@ -0,0 +1,115 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class IncrementalClosureDriftTests +{ + // Converted incremental programs round every move, so a hole can end (1e-6, 1e-6) off its start. + private const double Drift = 0.000001; + + [Theory] + [InlineData(Drift, Drift)] // Beyond the closure tolerance: material capture refuses it. + [InlineData(Drift * 0.6, Drift * 0.6)] // Inside it: closed too, so no stage disagrees at the boundary. + public void DriftedHole_EndsOnItsSeamWithoutMovingLaterMotions(double dx, double dy) + { + var program = Drifted(dx, dy); + var before = Ends(program); + + Assert.Equal(1, IncrementalClosureDrift.Close(program)); + + var after = Ends(program); + var hole = Cuts(program).Take(8).ToArray(); + Assert.True(hole[0].Start!.Value.DistanceTo(hole[^1].End) <= 1e-12); + // The rapid absorbs the correction: the perimeter and everything after it stay put. + Assert.All(before.Zip(after).Skip(9), pair => Assert.True(pair.First.DistanceTo(pair.Second) <= 1e-12)); + Assert.Equal(Mode.Incremental, program.Mode); + var material = LeadMaterialSnapshot.Capture(program, new Vector(30, 20)); + Assert.True(material.IsComplete, material.Reason); + Assert.Equal(2, PreparedContours.Capture(program, ExplicitContourTests.Parameters()).Count); + Assert.Equal(0, IncrementalClosureDrift.Close(program)); + } + + [Fact] + public void ArcLastMove_IsMovedOntoTheSeamOnItsOwnCircle() + { + var program = new Program(); + program.MoveTo(14, 3); + program.LineTo(20, 3); + program.LineTo(20, 7); + program.LineTo(14, 7); + program.ArcTo(new Vector(14 + Drift, 3 + Drift), new Vector(14, 5), RotationType.CCW); + program.Mode = Mode.Incremental; + Assert.False(LeadMaterialSnapshot.Capture(program, Vector.Zero).IsComplete); + + Assert.Equal(1, IncrementalClosureDrift.Close(program)); + + var arc = Assert.IsType(program.Codes[^1]); + var start = new Vector(14, 7); + var centre = start + arc.CenterPoint; + var end = start + arc.EndPoint; + Assert.True(end.DistanceTo(new Vector(14, 3)) <= 1e-12); + Assert.True(System.Math.Abs(centre.DistanceTo(start) - centre.DistanceTo(end)) <= 1e-12); + var material = LeadMaterialSnapshot.Capture(program, Vector.Zero); + Assert.True(material.IsComplete, material.Reason); + } + + [Fact] + public void AbsoluteOrFarOpenContours_AreLeftAlone() + { + var absolute = Drifted(Drift, Drift, Mode.Absolute); + var far = Drifted(0.00002, 0); + var absoluteBefore = OwnedProgramCopy.Copy(absolute); + var farBefore = OwnedProgramCopy.Copy(far); + + Assert.Equal(0, IncrementalClosureDrift.Close(absolute)); + Assert.Equal(0, IncrementalClosureDrift.Close(far)); + + Assert.True(ProgramContent.Equal(absoluteBefore, absolute)); + Assert.True(ProgramContent.Equal(farBefore, far)); + } + + [Fact] + public void Planner_ClosesDriftOnItsOwnedCopyOnly() + { + var part = new Part(new Drawing("drifted hole", Drifted(Drift, Drift)), new Vector(10, 10)); + var original = OwnedProgramCopy.Copy(part.BaseDrawing.Program); + + var proposal = CuttingPlanBatch.Capture([BestEffortCuttingPlanTests.Plate(part)], + ExplicitContourTests.Parameters(), false).Plan(); + + Assert.True(proposal.CanApply, string.Join(Environment.NewLine, proposal.Describe("in"))); + Assert.True(ProgramContent.Equal(original, part.BaseDrawing.Program)); + } + + // An eight-move hole whose last move misses its start by (dx, dy), then the perimeter. + private static Program Drifted(double dx, double dy, Mode mode = Mode.Incremental) + { + var program = new Program(); + program.MoveTo(4, 4); + program.LineTo(5, 4); + program.LineTo(6, 4); + program.LineTo(6, 5); + program.LineTo(6, 6); + program.LineTo(5, 6); + program.LineTo(4, 6); + program.LineTo(4, 5); + program.LineTo(4 + dx, 4 + dy); + program.MoveTo(0, 0); + program.LineTo(10, 0); + program.LineTo(10, 10); + program.LineTo(0, 10); + program.LineTo(0, 0); + program.Mode = mode; + return program; + } + + private static Vector[] Ends(Program program) => + ExecutionMotionReader.ReadSupported(program, Vector.Zero, null).Motions.Select(m => m.End).ToArray(); + + private static IEnumerable Cuts(Program program) => + ExecutionMotionReader.ReadSupported(program, Vector.Zero, null).Motions.Where(m => !m.Rapid); +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 53535ec0..596f002e 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -66,7 +66,16 @@ inside both spans. Lead and rapid checks keep their conservative contact queries Source contour closure in material capture and emitter preparation accepts endpoint gaps up to 0.000001 model units (inches for an inch nest), with a floating-point roundoff allowance -capped at 1e-12. It does not rewrite source endpoints. A contour entered away from its +capped at 1e-12. It does not rewrite source endpoints. The one exception is rounding drift +in an incremental program (converted nests): each move is rounded at the source CAM's +precision, so a contour's end is a sum of rounded moves and can miss its start by more +than that tolerance. On the owned clean copy that planning and its lead reviews capture +(never the drawing), a contour that misses by more than +1e-8 but no more than its moves could accumulate (half of 0.000001 per axis per move, and +never more than `Tolerance.Epsilon`) ends its last cut on its start, and the next motion +absorbs the correction so nothing after it moves. Gaps inside the closure tolerance are +closed too, so material capture and preparation cannot disagree at that boundary. Absolute +programs and larger gaps are left alone and still refuse. A contour entered away from its seam crosses that gap mid-cut; when an arc follows it, emission bridges the gap with a straight move and cuts the arc as drawn, rather than starting the arc off its own circle (which every check refuses as an inconsistent radius). An entry on the seam vertex has no