mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 23:11:07 -04:00
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:
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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user