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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 20:16:02 -04:00
1 parent 13d75854c3
commit b9c5240a43
5 files changed
+225 -2

No files matched your search

@@ -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;
}
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// 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
/// <see cref="NominalContourClosure.Tolerance"/> (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
/// <see cref="Tolerance.Epsilon"/>, 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.
/// </summary>
internal static class IncrementalClosureDrift
{
/// <summary>Closes drifted contours of an owned program in place; returns how many were closed.</summary>
internal static int Close(Program program)
{
if (program?.Codes == null || program.Mode != Mode.Incremental)
return 0;
var closed = 0;
var run = new List<Motion>();
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<Motion> 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;
}
}
@@ -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
@@ -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<ArcMove>(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<ExecutionMotion> Cuts(Program program) =>
ExecutionMotionReader.ReadSupported(program, Vector.Zero, null).Motions.Where(m => !m.Rapid);
}
+10 -1
View File
@@ -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