mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 08:21:05 -04:00
fix(verify): close contours whose cut runs past its start as a seam overcut
Converted part programs keep a source CAM's overcut: a hole's arc or a slot's last line runs about 0.03 past the pierce along the first cut. The rapid check only counted a contour closed when it ended exactly at its start, so every overcut contour became an open fragment, the open-cut proof refused them, and each part reported "Multiple interrupted/open cutting fragments" and an unverifiable lead-out instead of being checked (P260810-02: all 50 parts). ReleasedContourState now closes such a contour at its seam (matched at the 1e-6 seam tolerance, since a hole's two arcs carry centers ~1e-7 apart) and lets the rapid leaving the overcut end ignore contact within that tolerance of its departure. The nest now validates fully and reports the real crossings it was hiding: rapids that cross the hole just cut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b982e8eeb
commit
455c543501
4 files changed
+181
-6
No files matched your search
@@ -159,7 +159,14 @@ public sealed class ReleasedContourState
|
||||
// A rapid well clear of a contour's extent can neither cross nor touch it.
|
||||
if (reach.IsClearOf(obstacle.Extent))
|
||||
continue;
|
||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||
// A rapid leaving an overcut departs from its retraced tail, which lies on the first
|
||||
// cut only within the seam tolerance its arcs were matched at. Contact within that
|
||||
// much of the departure is the departure itself.
|
||||
var from = start;
|
||||
if (obstacle.OvercutEnd is { } tailEnd && tailEnd.DistanceTo(start) <= PostVerificationGeometry.Epsilon
|
||||
&& start.DistanceTo(move.End) > PostVerificationGeometry.RaySeamTolerance)
|
||||
from = start + (move.End - start) * (PostVerificationGeometry.RaySeamTolerance / start.DistanceTo(move.End));
|
||||
if (PostVerificationGeometry.Crosses(from, move.End, obstacle.Curves, token))
|
||||
Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||
$"of part {obstacle.Part}.")
|
||||
@@ -167,7 +174,7 @@ public sealed class ReleasedContourState
|
||||
Location = (start + move.End) * 0.5,
|
||||
RapidStart = start,
|
||||
RapidEnd = move.End,
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
|
||||
ContactPoints = PostVerificationGeometry.ContactPoints(from, move.End, obstacle.Curves, token)
|
||||
}, owned);
|
||||
}
|
||||
}
|
||||
@@ -233,6 +240,12 @@ public sealed class ReleasedContourState
|
||||
// A completed contour becomes an obstacle immediately, not at part end.
|
||||
if (PostVerificationGeometry.Closed(current.Curves))
|
||||
Finish();
|
||||
else if (OvercutClosure(current.Curves) is { } closing)
|
||||
{
|
||||
current.OvercutEnd = current.Curves[^1].End;
|
||||
current.Curves[^1] = closing;
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
Finish();
|
||||
@@ -279,9 +292,9 @@ public sealed class ReleasedContourState
|
||||
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(current.Curves))
|
||||
if (!cutoff && (current.OvercutEnd.HasValue || PostVerificationGeometry.Closed(current.Curves)))
|
||||
{
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray()));
|
||||
obstacles.Add(new(part, contourNumber, current.Curves.ToArray(), current.OvercutEnd));
|
||||
closed.Add(current);
|
||||
}
|
||||
else if (!cutoff)
|
||||
@@ -290,6 +303,31 @@ public sealed class ReleasedContourState
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The closing piece of a contour cut on past its own start along its first cut, as a source
|
||||
/// CAM's seam overcut is (see <see cref="SeamOvercut"/> for the clean-contour form): the last
|
||||
/// cut passes back through the start and its tail retraces the first cut. The tail encloses
|
||||
/// nothing, so the contour is closed by the last cut up to its start. Null for any other shape.
|
||||
/// Arcs of one circle may carry centers derived from their own rounded endpoints, so the seam
|
||||
/// is matched at <see cref="PostVerificationGeometry.RaySeamTolerance"/>, the closure seam
|
||||
/// ray parity already admits; the obstacle keeps that seam, never the retraced tail.
|
||||
/// </summary>
|
||||
private static PostVerificationGeometry.Curve OvercutClosure(IReadOnlyList<PostVerificationGeometry.Curve> curves)
|
||||
{
|
||||
if (curves.Count < 2)
|
||||
return null;
|
||||
const double tolerance = PostVerificationGeometry.RaySeamTolerance;
|
||||
var first = curves[0];
|
||||
var last = curves[^1];
|
||||
if (!last.SameDirection(first, tolerance) || !last.Contains(first.Start, tolerance)
|
||||
|| !first.Contains(last.End, tolerance))
|
||||
return null;
|
||||
var seam = last.ClosestPoint(first.Start);
|
||||
var along = last.DistanceAlong(seam);
|
||||
var tail = last.Length - along;
|
||||
return along > tolerance && tail > tolerance && tail <= first.Length + tolerance ? last.Until(seam) : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
@@ -431,6 +469,9 @@ public sealed class ReleasedContourState
|
||||
internal List<PostVerificationGeometry.Curve> Curves { get; } = new();
|
||||
internal List<PostVerificationGeometry.Curve> Leads { get; } = new();
|
||||
internal bool Fixed { get; set; }
|
||||
// Where the cut really ended when OvercutClosure closed it: the retraced tail's end, on the
|
||||
// first cut. Its last curve now ends at the seam instead.
|
||||
internal Vector? OvercutEnd { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DisjointSet(int count)
|
||||
@@ -447,7 +488,8 @@ public sealed class ReleasedContourState
|
||||
internal void Union(int a, int b) => parents[Find(a)] = Find(b);
|
||||
}
|
||||
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves)
|
||||
private sealed record Obstacle(int Part, int Contour, IReadOnlyList<PostVerificationGeometry.Curve> Curves,
|
||||
Vector? OvercutEnd)
|
||||
{
|
||||
internal PostVerificationGeometry.Extent Extent { get; } =
|
||||
Curves.Aggregate(PostVerificationGeometry.Extent.None, (extent, curve) => extent.Union(curve.Extent));
|
||||
|
||||
@@ -279,6 +279,18 @@ internal static class PostVerificationGeometry
|
||||
/// <summary>Exact native distance from <paramref name="point"/> to this finite curve.</summary>
|
||||
internal double DistanceTo(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point);
|
||||
|
||||
/// <summary>The native point of this finite curve closest to <paramref name="point"/>.</summary>
|
||||
internal Vector ClosestPoint(Vector point) => nativeEntity.Value.ClosestPointTo(point);
|
||||
|
||||
/// <summary>
|
||||
/// This curve from its start up to <paramref name="point"/>, which must be one of its own
|
||||
/// interior points (see <see cref="ClosestPoint"/>); an arc keeps its center and radius.
|
||||
/// </summary>
|
||||
internal Curve Until(Vector point) => Center is { } center
|
||||
? new Curve(Start, point, center, Radius,
|
||||
System.Math.Sign(Sweep) * Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)))
|
||||
: new Curve(Start, point, null, 0, 0);
|
||||
|
||||
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||
|
||||
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.Diagnostics;
|
||||
|
||||
@@ -220,6 +222,122 @@ public class PostVerificationAnalyzerTests
|
||||
Assert.Empty(Find(Analyze(part, Scribe(-2, 2), Scribe(6, 2)), PostVerificationKind.RapidCrossing));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2.5, true)] // The overcut retraces part of the first cut: the square is cut free.
|
||||
[InlineData(3.5, false)] // It runs on past the whole first cut: not a recognized overcut.
|
||||
public void Rapid_SeamOvercutAlongTheFirstCutClosesTheContour(double overcutEnd, bool closed)
|
||||
{
|
||||
var part = Rectangle(0, 0, 4);
|
||||
var program = new Program();
|
||||
program.MoveTo(2, 0);
|
||||
program.LineTo(3, 0);
|
||||
program.LineTo(4, 0);
|
||||
program.LineTo(4, 4);
|
||||
program.LineTo(0, 4);
|
||||
program.LineTo(0, 0);
|
||||
program.LineTo(overcutEnd, 0);
|
||||
part.RestoreLeadInProgram(program, false);
|
||||
var report = Analyze(part, Scribe(-2, 2), Scribe(6, 2));
|
||||
Assert.Equal(closed, Find(report, PostVerificationKind.RapidCrossing).Any(f => f.PartNumber == 3));
|
||||
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
|
||||
}
|
||||
|
||||
// P260810-02's SULLYS-009, as converted from a source CAM: each hole's arc runs about 0.03 past
|
||||
// its pierce, the slot's last line runs 0.03 along its first, and the perimeter keeps a 0.02 tab.
|
||||
// Arc centers carry their own endpoint rounding, so a hole's two halves disagree by about 1e-7.
|
||||
[Fact]
|
||||
public void Rapid_ConvertedSeamOvercutsAreCheckedNotLeftIncomplete()
|
||||
{
|
||||
var part = new Part(new Drawing("SULLYS-009", ReadProgram(Sullys009Clean)));
|
||||
Assert.True(part.RestoreLeadInProgram(ReadProgram(Sullys009Cut), true));
|
||||
var report = Analyze(part);
|
||||
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
|
||||
// Holes 2 and 4 really are cut free and then crossed by the rapid leaving them; holes 1
|
||||
// and 3 are left from their overcut end without touching them again.
|
||||
Assert.Equal(new[] { 2, 4 }, Find(report, PostVerificationKind.RapidCrossing)
|
||||
.Select(f => int.Parse(f.Message.Split("contour ")[1].Split(' ')[0])).Order());
|
||||
}
|
||||
|
||||
private static Program ReadProgram(string text)
|
||||
{
|
||||
var reader = new ProgramReader(new MemoryStream(Encoding.UTF8.GetBytes(text)));
|
||||
var program = reader.Read();
|
||||
reader.Close();
|
||||
return program;
|
||||
}
|
||||
|
||||
private const string Sullys009Clean = """
|
||||
G91
|
||||
G00X0.07Y0
|
||||
G03X-0.14Y0I-0.07J0
|
||||
G03X0.133669Y0.02909I0.0699998916J-0.0000000236
|
||||
G01X0.006331Y-0.02909
|
||||
G00X0.11Y0
|
||||
G03X0.14Y0I0.07J0
|
||||
G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236
|
||||
G01X-0.006331Y0.02909
|
||||
G00X0Y0.5
|
||||
G03X0.14Y0I0.07J0
|
||||
G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236
|
||||
G01X-0.006331Y0.02909
|
||||
G00X-0.18Y-0.07
|
||||
G03X0Y0.14I0J0.07
|
||||
G03X0.02909Y-0.133669I-0.0000000236J-0.0699998916
|
||||
G01X-0.02909Y-0.006331
|
||||
G00X-1.3285Y-0.394
|
||||
G01X0.4765Y0
|
||||
G03X0Y0.428I0J0.214
|
||||
G01X-0.953Y0
|
||||
G03X0Y-0.428I0J-0.214
|
||||
G01X0.5065Y0
|
||||
G01X-0.03Y0
|
||||
G00X-0.2665Y-0.411
|
||||
G01X-0.03Y0
|
||||
G02X0Y1.25I0J0.625
|
||||
G01X2.1875Y0
|
||||
G02X0.0625Y-0.0625I0J-0.0625
|
||||
G01X0Y-1.125
|
||||
G02X-0.0625Y-0.0625I-0.0625J0
|
||||
G01X-2.1375Y0
|
||||
G01X-0.02Y0
|
||||
""";
|
||||
|
||||
private const string Sullys009Cut = """
|
||||
G91
|
||||
G01X0.07Y0:LEADIN
|
||||
G03X-0.14Y0I-0.07J0
|
||||
G03X0.133669Y0.02909I0.0699998916J-0.0000000236
|
||||
G00X0.186331Y-0.02909
|
||||
G01X-0.07Y0:LEADIN
|
||||
G03X0.14Y0I0.07J0
|
||||
G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236
|
||||
G00X0.063669Y0.52909
|
||||
G01X-0.07Y0:LEADIN
|
||||
G03X0.14Y0I0.07J0
|
||||
G03X-0.133669Y-0.02909I-0.0699998916J0.0000000236
|
||||
G00X-0.186331Y0.02909
|
||||
G01X0Y-0.07:LEADIN
|
||||
G03X0Y0.14I0J0.07
|
||||
G03X0.02909Y-0.133669I-0.0000000236J-0.0699998916
|
||||
G00X-1.35759Y-0.275331
|
||||
G01X0Y-0.125:LEADIN
|
||||
G01X0.4765Y0
|
||||
G03X0Y0.428I0J0.214
|
||||
G01X-0.953Y0
|
||||
G03X0Y-0.428I0J-0.214
|
||||
G01X0.5065Y0
|
||||
G00X-0.3715Y-0.540904
|
||||
G01X0.075Y0.129904:LEADIN
|
||||
G01X-0.03Y0
|
||||
G02X0Y1.25I0J0.625
|
||||
G01X2.1875Y0
|
||||
G02X0.0625Y-0.0625I0J-0.0625
|
||||
G01X0Y-1.125
|
||||
G02X-0.0625Y-0.0625I-0.0625J0
|
||||
G01X-2.1375Y0
|
||||
G01X0.03Y-0.051961:LEADOUT
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Rapid_CutoffTravelStillCrossesEarlierRealCuts()
|
||||
{
|
||||
|
||||
@@ -79,7 +79,10 @@ shows the first three check categories, even when there are no findings:
|
||||
contours in cutting order, or an active clamp's physical footprint. This includes
|
||||
earlier holes within the same part and previously cut parts. A future cut is not
|
||||
an obstacle yet. Actual uncut gaps are respected; enabling tabs in settings alone
|
||||
is not evidence that a gap exists. Clamp-rapid warnings are conservative: head
|
||||
is not evidence that a gap exists. A cut that runs on past its own start along its
|
||||
first cut (a converted program's seam overcut, matched within the 1e-6 seam
|
||||
tolerance) is closed at its seam; the rapid leaving its overcut end ignores contact
|
||||
within that tolerance of the departure. Clamp-rapid warnings are conservative: head
|
||||
height/retracts are not modeled, so inspect the emitted route and machine setup.
|
||||
A clamp whose footprint covers the program start point warns on every posting
|
||||
attempt by design; there is no exemption for the first rapid and no persisted
|
||||
|
||||
Reference in new issue
Block a user