mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 00:31:37 -04:00
fix(cutting): replay rings closed within nominal tolerance
Capture and preparation agree that a nominal contour may close within NominalContourClosure.Tolerance inclusively, which converted drawings rely on: the sampled production corpus carries vertex gaps up to 1e-6. Replay verification still compared those rings at the exact geometry epsilon, so the one joint a rotated tool path legitimately bridges refused the whole plate after the search had already spent its budget. Census of 91 plates from 60 stored nests: every searched plate containing such a seam refused with contour-accounting (4 plates, 76.5s of wasted search, worst 71.7s for one 11-part plate), and none of the 42 ready plates had one. Replay now carries that same admitted endpoint gap as positional and length slack for the affected ring only. Direction sign, coverage order, retrace detection, ring counting and selected-emission binding keep the exact epsilon, and capture's closure admission is unchanged. With the fix the census yields 45 ready plates instead of 42, no plate changed any other way, and expansion counts are identical wherever status matched. P260805-01's 16-part plate plans in 0.76s rather than refusing after 4,129 expansions. Tests: SeamClosureReplayTests covers seams of 0, 5e-7 and 1e-6 plus four accounting faults (missing edge, reversed edge, interior shift beyond the admitted seam, second circuit) that must still refuse, and the beyond-tolerance capture refusal. OpenNest.Tests 3751, OpenNest.Engine.Tests 690 and OpenNest.IO.Tests 79 pass; Windows-only projects cannot build on Linux.
This commit is contained in:
1 parent
9bd5d0dfa7
commit
df94824054
3 files changed
+181
-19
No files matched your search
@@ -55,11 +55,13 @@ internal static class ContourProgramVerifier
|
||||
var ringIndex = -1;
|
||||
var entityIndex = -1;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
{
|
||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first) && curve.Contains(first.Start)
|
||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
ringIndex = r;
|
||||
@@ -67,28 +69,39 @@ internal static class ContourProgramVerifier
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
|
||||
var ring = material.Rings[ringIndex];
|
||||
// Capture admits a nominal ring that closes within NominalContourClosure.Tolerance, and
|
||||
// preparation agrees at that inclusive boundary. A rotated tool path bridges that one
|
||||
// admitted joint, so this ring's positional and length agreement carries exactly that
|
||||
// admitted slack. Direction, coverage order and retrace accounting stay at the epsilon.
|
||||
var tolerance = AdmittedClosureSlack(ring);
|
||||
var nominalLength = ring.Sum(c => c.Length);
|
||||
var cutLength = run.Curves.Sum(c => c.Length);
|
||||
var missing = nominalLength - cutLength;
|
||||
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
|
||||
if (missing > Epsilon)
|
||||
if (missing > tolerance)
|
||||
{
|
||||
// Fixed legacy payloads have no certified selected tab/entry metadata.
|
||||
// A merely open contour or a caller's current tab switch proves nothing.
|
||||
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > tolerance)
|
||||
return false;
|
||||
// SameEmission has already bound this exact terminal gap to the owned
|
||||
// settings' independently emitted, rounded/clamped selected geometry.
|
||||
}
|
||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||
true, token, ref budget)) return false;
|
||||
true, tolerance, token, ref budget)) return false;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
}
|
||||
|
||||
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
||||
// tolerance, and only that admitted endpoint gap relaxes replay's positional agreement.
|
||||
private static double AdmittedClosureSlack(IReadOnlyList<Curve> ring) => ring.Count == 0 ? Epsilon
|
||||
: Epsilon + System.Math.Max(0.0, ring[^1].End.DistanceTo(ring[0].Start));
|
||||
|
||||
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
|
||||
CancellationToken token, ref int budget)
|
||||
{
|
||||
@@ -107,15 +120,17 @@ internal static class ContourProgramVerifier
|
||||
if (a.End.DistanceTo(b.End) > Epsilon) return false;
|
||||
}
|
||||
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, Epsilon, token, ref budget)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consume directed native arc-length from both streams. Subdivision, merged
|
||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
||||
// unless the nominal ring closes only within the nominal closure tolerance).
|
||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||
int index, double offset, bool cyclic, CancellationToken token, ref int budget)
|
||||
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
|
||||
{
|
||||
foreach (var curve in actual)
|
||||
{
|
||||
@@ -137,13 +152,13 @@ internal static class ContourProgramVerifier
|
||||
continue;
|
||||
}
|
||||
var length = System.Math.Min(remaining, curve.Length - consumed);
|
||||
if (!nominal.SameDirection(curve)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
|
||||
if (!nominal.SameDirection(curve, tolerance)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance)
|
||||
return false;
|
||||
consumed += length;
|
||||
offset += length;
|
||||
if (offset >= nominal.Length - Epsilon)
|
||||
if (offset >= nominal.Length - tolerance)
|
||||
{
|
||||
index++;
|
||||
offset = 0;
|
||||
@@ -151,7 +166,7 @@ internal static class ContourProgramVerifier
|
||||
}
|
||||
}
|
||||
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||
&& expected[index].Length - offset <= Epsilon;
|
||||
&& expected[index].Length - offset <= tolerance;
|
||||
}
|
||||
|
||||
private static List<Run> Runs(OwnedExecution execution)
|
||||
|
||||
@@ -167,20 +167,31 @@ internal static class PostVerificationGeometry
|
||||
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
||||
: (Start + End) * 0.5;
|
||||
|
||||
internal bool SameSupport(Curve other)
|
||||
internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
|
||||
|
||||
/// <summary>
|
||||
/// Support agreement with an explicit positional allowance. Used only where an upstream
|
||||
/// stage has already admitted a larger nominal discrepancy — currently the inclusive
|
||||
/// <see cref="NominalContourClosure.Tolerance"/> joint that a replayed ring bridges. It is
|
||||
/// never a collision tolerance: safety queries keep the exact epsilon.
|
||||
/// </summary>
|
||||
internal bool SameSupport(Curve other, double positionTolerance)
|
||||
{
|
||||
if (Center is { } center)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= Epsilon
|
||||
&& System.Math.Abs(Radius - other.Radius) <= Epsilon;
|
||||
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon)
|
||||
return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
|
||||
&& System.Math.Abs(Radius - other.Radius) <= positionTolerance;
|
||||
if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
|
||||
return false;
|
||||
var direction = (End - Start) * (1 / Length);
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
|
||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
|
||||
}
|
||||
|
||||
// Directed native arc-length coordinates for contour replay, not collision queries.
|
||||
internal bool SameDirection(Curve other) => SameSupport(other)
|
||||
internal bool SameDirection(Curve other) => SameDirection(other, Epsilon);
|
||||
|
||||
/// <summary>Directed coverage check with an admitted positional allowance; direction sign stays exact.</summary>
|
||||
internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance)
|
||||
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
||||
: Dot(End - Start, other.End - other.Start) > 0);
|
||||
|
||||
@@ -199,7 +210,11 @@ internal static class PostVerificationGeometry
|
||||
: Start + (End - Start) * (distance / Length);
|
||||
}
|
||||
|
||||
internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||
|
||||
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
||||
internal bool Contains(Vector point, double positionTolerance)
|
||||
=> nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance;
|
||||
|
||||
/// <summary>
|
||||
/// Filters supporting-circle contacts against the actual finite arc span. The
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// A nominal contour may lawfully close within <see cref="NominalContourClosure.Tolerance"/>, and
|
||||
/// capture and preparation already agree on that inclusive boundary. Replay verification must
|
||||
/// agree too: a rotated tool path legitimately bridges that one admitted joint, so refusing the
|
||||
/// whole plate over it makes every such converted drawing unplanable.
|
||||
/// </summary>
|
||||
public class SeamClosureReplayTests
|
||||
{
|
||||
private static readonly Vector Start = new(-2, 5);
|
||||
|
||||
/// <summary>
|
||||
/// Square entering mid-edge so the tool path wraps across the closing seam. The seam offset is
|
||||
/// perpendicular to the edge that follows it, so bridging it shifts that edge's support by the
|
||||
/// admitted gap instead of merely shortening it.
|
||||
/// </summary>
|
||||
private static Program SeamRing(double seam)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(0, 10);
|
||||
program.LineTo(10, 10);
|
||||
program.LineTo(10, 0);
|
||||
program.LineTo(seam, 0);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static (Part Part, CuttingParameters Settings) Fixture(double seam)
|
||||
{
|
||||
var settings = ExplicitContourTests.Parameters();
|
||||
var program = SeamRing(seam);
|
||||
var prepared = PreparedContours.Capture(program, settings);
|
||||
Assert.Equal(1, prepared.Count); // Preparation accepts the same closure boundary as capture.
|
||||
var part = new Part(new Drawing("seam", program));
|
||||
Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.Entry(0, 0, new Vector(0, 5))]), false));
|
||||
return (part, settings);
|
||||
}
|
||||
|
||||
private static CuttingPlanSnapshot Capture(Part part, CuttingParameters settings)
|
||||
=> CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||
|
||||
private static Program Path(params Vector[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(-0.3, 5);
|
||||
program.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
|
||||
foreach (var point in points) program.LineTo(point);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static OwnedExecution Read(Program program) => ExecutionMotionReader.Read(program, Vector.Zero, null, default);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0)] // exactly closed: unchanged behaviour
|
||||
[InlineData(5e-7)] // admitted closure seam
|
||||
[InlineData(1e-6)] // inclusive closure boundary that capture already accepts
|
||||
public void AdmittedClosureSeam_StillReplays(double seam)
|
||||
{
|
||||
var (part, settings) = Fixture(seam);
|
||||
var snapshot = Capture(part, settings);
|
||||
Assert.True(snapshot.Placements[0].Material!.IsComplete, snapshot.Placements[0].Material!.Reason);
|
||||
|
||||
var source = snapshot.Placements[0];
|
||||
var choice = source.Prepared!.Entry(0, 0, new Vector(0, 5)); // path wraps across the seam
|
||||
var result = CuttingPlanService.ReplayPrograms(snapshot,
|
||||
[source.Propose(source.Prepared.Emit([choice]), Read(source.Prepared.Emit([choice])), [choice])],
|
||||
0, default);
|
||||
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdmittedClosureSeam_StillPlansWithGeneratedLeadIns()
|
||||
{
|
||||
// The same plate through the planner entry point: a searched route over an admitted seam
|
||||
// must not be refused at replay.
|
||||
var (part, settings) = Fixture(9e-7);
|
||||
var result = CuttingPlanService.Plan(Capture(part, settings));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("missing-edge")] // a real coverage hole next to an admitted seam still refuses
|
||||
[InlineData("reversed-edge")] // wrong traversal direction still refuses
|
||||
[InlineData("interior-shift")] // displacement larger than the admitted seam still refuses
|
||||
[InlineData("second-circuit")] // retracing the ring still refuses
|
||||
public void AdmittedClosureSeam_KeepsRefusingRealAccountingFaults(string fault)
|
||||
{
|
||||
var (part, settings) = Fixture(9e-7);
|
||||
var snapshot = Capture(part, settings);
|
||||
var source = snapshot.Placements[0];
|
||||
var choice = source.Prepared!.Entry(0, 0, new Vector(0, 5));
|
||||
var actual = fault switch
|
||||
{
|
||||
// Leaves the bottom edge uncut: coverage, not closure slack, is what fails.
|
||||
"missing-edge" => Path(new Vector(0, 10), new Vector(10, 10), new Vector(10, 0)),
|
||||
// Runs the right edge backwards against the nominal direction.
|
||||
"reversed-edge" => Path(new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)),
|
||||
// Closes exactly but shifts an interior vertex by far more than the admitted seam.
|
||||
"interior-shift" => Path(new Vector(0, 5), new(0, 10), new(10, 9.9999), new(10, 0), new(0, 0), new(0, 5)),
|
||||
// Two circuits are never closure slack.
|
||||
"second-circuit" => Path(new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5),
|
||||
new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(fault))
|
||||
};
|
||||
|
||||
var result = CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(actual, Read(actual), [choice])],
|
||||
0, default);
|
||||
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.False(result.IndependentlyReplayed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GapBeyondAdmittedClosure_StillRefusesAtCapture()
|
||||
{
|
||||
// The fix must not widen closure admission itself: beyond tolerance capture still refuses.
|
||||
var program = SeamRing(2e-6);
|
||||
var material = LeadMaterialSnapshot.Capture(program, new Vector(30, 20));
|
||||
Assert.False(material.IsComplete);
|
||||
Assert.Contains("closure tolerance", material.Reason);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user