diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs
index de74850..8579d01 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/LeadMaterialSnapshot.cs
@@ -47,7 +47,7 @@ public sealed class LeadMaterialSnapshot
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Material contour is discontinuous.");
chain.Add(curve);
- if (PostVerificationGeometry.Closed(chain))
+ if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
Finish();
}
Finish();
@@ -67,6 +67,15 @@ public sealed class LeadMaterialSnapshot
var b = rings[s][j];
var contacts = a.Contacts(b, out var overlap);
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
+ var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
+ && (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
+ || a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
+ // Native arc queries pad angles by 1e-5 radians, admitting
+ // intersections on an extension (distance grows with radius).
+ // At a shared arc joint require both actual finite spans;
+ // overlap and genuine second intersections still refuse.
+ if (arcJoint)
+ contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
// Native contacts at rounded arc/line joints can drift from the
// authored endpoint. Use the shared geometry tolerance only to
// recognize that joint; overlaps and other contacts still refuse.
@@ -101,8 +110,13 @@ public sealed class LeadMaterialSnapshot
{
if (chain.Count == 0)
return;
- if (!PostVerificationGeometry.Closed(chain))
- throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
+ if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
+ {
+ var gap = chain[0].Start.DistanceTo(chain[^1].End);
+ throw new ArgumentException(FormattableString.Invariant(
+ $"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
+ + " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
+ }
rings.Add(chain.ToArray());
chain.Clear();
}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
index 7ce0854..961fac3 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs
@@ -113,7 +113,8 @@ public static class LeadPathValidator
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
}
}
- // Missing leads are the ReleasedContourState check's responsibility.
+ // No lead motion is valid for direct contour piercing. The caller still
+ // checks rapid travel and contour accounting for the complete program.
return new(true, true, null);
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
diff --git a/OpenNest.Core/CNC/CuttingPlanning/NominalContourClosure.cs b/OpenNest.Core/CNC/CuttingPlanning/NominalContourClosure.cs
new file mode 100644
index 0000000..a232d03
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/NominalContourClosure.cs
@@ -0,0 +1,20 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// Closure of source contours during planner material capture and preparation.
+internal static class NominalContourClosure
+{
+ internal const double Tolerance = 0.000001;
+
+ internal static bool IsClosed(Vector start, Vector end)
+ {
+ // Subtracting translated/rotated coordinates can put an exact boundary gap
+ // a few floating-point ulps above the limit. Bound that allowance to 1e-12.
+ var scale = System.Math.Max(1, System.Math.Max(
+ System.Math.Max(System.Math.Abs(start.X), System.Math.Abs(start.Y)),
+ System.Math.Max(System.Math.Abs(end.X), System.Math.Abs(end.Y))));
+ var roundoff = System.Math.Min(1e-12, 4 * 2.2204460492503131e-16 * scale);
+ return start.DistanceTo(end) <= Tolerance + roundoff;
+ }
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index ef1e206..bda9d6a 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -74,7 +74,7 @@ public sealed class PreparedContours
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Discontinuous native contour.");
current.Entities.Add(entity);
- if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
+ if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
FinishContour();
}
if (current.Entities.Count != 0 || contours.Count == 0)
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
index 1c4400d..c10a81e 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
public sealed class ReleasedContourState
{
private readonly List obstacles = new();
+ private readonly bool reportMissingLeadIns;
+
+ public ReleasedContourState() : this(true) { }
+
+ /// Keep pre-post warnings by default. Planning
+ /// may pierce directly on a contour and checks travel without requiring a lead.
+ public ReleasedContourState(bool reportMissingLeadIns)
+ {
+ this.reportMissingLeadIns = reportMissingLeadIns;
+ }
public ReleasedContourState Copy()
{
- var copy = new ReleasedContourState();
+ var copy = new ReleasedContourState(reportMissingLeadIns);
copy.obstacles.AddRange(obstacles);
return copy;
}
@@ -98,7 +108,7 @@ public sealed class ReleasedContourState
if (contour.Count == 0)
{
contourNumber++;
- if (!cutoff && !hasLead)
+ if (reportMissingLeadIns && !cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
{ Location = move.Curve.Start });
diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
index db29e74..7dca378 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
@@ -201,6 +201,15 @@ internal static class PostVerificationGeometry
internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon;
+ ///
+ /// Filters supporting-circle contacts against the actual finite arc span. The
+ /// native angular padding is larger and must not turn an arc extension into a
+ /// self-intersection at an adjacent nominal joint. This is not a radial test.
+ ///
+ internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
+
+ internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
+
internal IReadOnlyList Contacts(Curve other, out bool overlap)
{
var entity = nativeEntity.Value;
diff --git a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs
index c168909..ffa1c16 100644
--- a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs
+++ b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs
@@ -28,7 +28,7 @@ internal static class BestEffortCuttingPlan
var done = new HashSet();
var position = snapshot.StartPoint;
var distance = 0.0;
- var checker = new ReleasedContourState();
+ var checker = new ReleasedContourState(reportMissingLeadIns: false);
try
{
while (order.Count < snapshot.Placements.Count)
diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
index 87ee195..95c9df3 100644
--- a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
+++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
@@ -31,8 +31,8 @@ public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status,
/// candidate contour through the S06 diagnostic seam, reads it at the placement position,
/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a
/// new collision implementation and not a plan approval: a Clear verdict certifies this
-/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the
-/// complete plan, and rapids/pierce clearance belong to their existing checkers. A
+/// contour's emitted leads only — NoLeadIn is supported, and the complete plan must
+/// still pass rapid travel and contour-accounting checks. A
/// candidate rejected here is not proven infeasible by anything else: this adapter only
/// reports what the validator reported.
/// One instance is one captured planning attempt: verdicts cache per exact choice and node
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
index a7b2353..01d3cbe 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
@@ -349,7 +349,7 @@ public sealed class CuttingPlanProposal
public IReadOnlyList Describe(string unit)
{
var lines = new List();
- var ready = Plates.Count(p => p.IsReady);
+ var blocked = Plates.Count(p => !p.CanApplyWithWarnings);
if (IsCancelled)
lines.Add("Planning was cancelled. Nothing has changed.");
else if (CanApply)
@@ -360,7 +360,7 @@ public sealed class CuttingPlanProposal
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
+ "This is not approval to cut or post CNC output.");
else
- lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
+ lines.Add($"Apply is unavailable: {blocked} of {Count(Plates.Count, "plate")} could not "
+ "be planned. No plate changes until every plate is ready.");
foreach (var plate in Plates)
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index 03b1405..188b388 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -241,7 +241,7 @@ public static class CuttingPlanService
if (placement.Prepared != null)
continue; // An eligible old crossing is precisely what regeneration may repair.
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
- var findings = new ReleasedContourState().Check(placement.Execution, null,
+ var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
placement.SourceOrdinal + 1, placement.IsCutOff, token);
fixedFindings.AddRange(Map(snapshot, findings));
}
@@ -279,7 +279,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
- var checker = new ReleasedContourState();
+ var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List();
@@ -313,7 +313,7 @@ public static class CuttingPlanService
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
return new(CuttingPlanStatus.InvalidInput,
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
- var checker = new ReleasedContourState();
+ var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
var distance = 0.0;
var findings = new List();
diff --git a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
index 95208a7..06c9e7c 100644
--- a/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/FixedProgramSearch.cs
@@ -16,7 +16,7 @@ internal static class FixedProgramSearch
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
var stack = new Stack();
- stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
+ stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false)));
var rejected = new HashSet();
var expansions = 0;
while (stack.Count != 0)
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 466e370..6c644a6 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -191,7 +191,7 @@ internal static class JointCuttingPlanSearch
///
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false)
{
- var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
+ var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
var attempt = new Attempt(sequence);
var progressExpansions = Expansions;
var stack = new Stack();
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs
index 3ff8c63..9f432a4 100644
--- a/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntryFeasibilityTests.cs
@@ -126,7 +126,7 @@ public class ContourEntryFeasibilityTests
// Nothing to certify means the validator passes vacuously — the emitted program
// carries no lead motion at all, so this verdict certifies no lead and the
- // complete-plan missing-lead check still has to run later. The adapter must not
+ // complete-plan rapid/contour checks still have to run later. The adapter must not
// silently drop the distinction: consumers can see it is vacuous by counting leads.
Assert.True(verdict.IsClear);
var motions = ExecutionMotionReader.Read(prepared.Emit(new[] { choice }), At, null, default).Motions;
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
index 2a990de..d0b6329 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
@@ -45,7 +45,9 @@ public class CuttingPlanBatchTests
var nest = new Nest();
var ready = Plate(nest, Clean("open", 1, 1));
var locked = Clean("locked", 1, 1);
- locked.LeadInsLocked = true; // Locked programs never regenerate: no lead-in is a conflict.
+ var crossing = OwnedProgramCopy.Copy(locked.Program);
+ crossing.MoveTo(5, 5); // Rapid enters the already completed square.
+ Assert.True(locked.RestoreLeadInProgram(crossing, true));
var blocked = Plate(nest, locked);
var readyProgram = ready.Parts[0].Program;
@@ -314,7 +316,9 @@ public class CuttingPlanBatchTests
var nest = new Nest();
var ready = Plate(nest, Clean("a", 1, 1));
var locked = Clean("locked", 1, 1);
- locked.LeadInsLocked = true;
+ var crossing = OwnedProgramCopy.Copy(locked.Program);
+ crossing.MoveTo(5, 5); // Rapid enters the already completed square.
+ Assert.True(locked.RestoreLeadInProgram(crossing, true));
var blocked = Plate(nest, locked);
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
Assert.True(proposal.Plates[0].IsReady);
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
index 5992e23..d13d5dd 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs
@@ -8,77 +8,91 @@ namespace OpenNest.Tests.CuttingPlanning;
public class CuttingPlanMessageTests
{
+ [Fact]
+ public void Describe_MixedBestEffortAndOpenContour_CountsOnlyBlockedPlateAndExplainsGap()
+ {
+ var open = new OpenNest.CNC.Program();
+ open.MoveTo(0, 0);
+ open.LineTo(10, 0);
+ open.LineTo(10, 10);
+ open.LineTo(0, 10);
+ open.LineTo(0, 0.000002);
+ var part = new Part(new Drawing("open perimeter", open));
+ var original = OwnedProgramCopy.Copy(part.BaseDrawing.Program);
+ var plates = new[]
+ {
+ BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours()),
+ BestEffortCuttingPlanTests.Plate(part),
+ };
+
+ var proposal = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false).Plan();
+ var text = string.Join("\n", proposal.Describe("in"));
+
+ Assert.StartsWith("Apply is unavailable: 1 of 2 plates could not be planned.", text);
+ Assert.Contains("Plate 1: best-effort, unverified.", text);
+ Assert.Contains("Plate 2: blocked:", text);
+ Assert.Contains("contour 1 is open: endpoint gap 2E-06 model units", text);
+ Assert.Contains("closure tolerance 1E-06", text);
+ Assert.Contains("Review the source contour in the drawing editor", text);
+ Assert.False(proposal.CanApplyWithWarnings);
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status);
+ Assert.True(ProgramContent.Equal(original, part.BaseDrawing.Program));
+ Assert.All(plates.SelectMany(p => p.Parts), p => Assert.False(p.HasManualLeadIns));
+ }
+
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
- public void Describe_MissingOrZeroLengthLead_ExplainsSettingsWithoutBlamingSearch(bool keepOrder, bool zeroLength)
+ public void Describe_NoneIsSupportedButDegenerateLeadMotionStillRefuses(bool keepOrder, bool zeroLength)
{
var part = new Part(new Drawing("sample", ExplicitContourTests.Square(false)), new Vector(1, 1));
- var plate = new Nest().CreatePlate();
- plate.Size = new Size(100, 100);
- plate.Parts.Add(part);
- var program = part.Program;
- var original = OwnedProgramCopy.Copy(program);
+ var plate = BestEffortCuttingPlanTests.Plate(part);
+ var original = OwnedProgramCopy.Copy(part.Program);
var settings = new CuttingParameters();
if (zeroLength)
settings.ExternalLeadIn = new LineLeadIn { Length = 0, ApproachAngle = 90 };
var proposal = CuttingPlanBatch.Capture([plate], settings, keepOrder).Plan();
- Assert.False(proposal.CanApply);
- var result = Assert.Single(proposal.Plates).Result;
- Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
- Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
- var text = string.Join("\n", proposal.Describe("in"));
- Assert.Contains("Plate 1: blocked: missing or zero-length lead-in.", text);
- Assert.Contains("Open Cutting Settings...", text);
- Assert.Contains("other than None", text);
- Assert.Contains("nonzero length", text);
- Assert.Contains("Part 1 (sample):", text);
- Assert.Contains("Cutting contour 1", text);
- Assert.DoesNotContain("search limit", text);
- Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
- Assert.Same(program, part.Program);
- Assert.True(ProgramContent.Equal(original, part.Program));
- Assert.Null(plate.CuttingParameters);
- Assert.False(part.HasManualLeadIns);
+ if (zeroLength)
+ {
+ Assert.False(proposal.CanApply);
+ Assert.Contains("Lead motion is missing, degenerate or inconsistent", string.Join("\n", proposal.Describe("in")));
+ Assert.True(ProgramContent.Equal(original, part.Program));
+ return;
+ }
- // Choosing valid settings fixes the refusal; reporting never changes settings or bypasses checks.
- var ready = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan();
- Assert.True(ready.CanApply, string.Join("\n", ready.Describe("in")));
- Assert.True(Assert.Single(ready.Plates).Result.IndependentlyReplayed);
- Assert.DoesNotContain("missing or zero-length", string.Join("\n", ready.Describe("in")));
- Assert.Same(program, part.Program);
+ Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
+ var result = Assert.Single(proposal.Plates).Result;
+ Assert.True(result.IndependentlyReplayed);
+ Assert.DoesNotContain(result.Findings, f => f.Kind == PostVerificationKind.MissingLeadIn);
+ Assert.Contains("Plate 1: ready.", string.Join("\n", proposal.Describe("in")));
+ var output = Assert.Single(result.ProposedOrder).CopyProgram();
+ Assert.DoesNotContain(ExecutionMotionReader.ReadSupported(output, part.Location, null).Motions,
+ m => m.Layer == OpenNest.CNC.LayerType.Leadin && m.Length > 0);
Assert.True(ProgramContent.Equal(original, part.Program));
+ Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
}
[Fact]
- public void Describe_LockedProgramWithoutLead_ExplainsThatSettingsCannotRegenerateIt()
+ public void Describe_LockedProgramWithoutLead_IsReadyAndKeepsItsProgram()
{
var part = new Part(new Drawing("locked sample", ExplicitContourTests.Square(false)), new Vector(1, 1))
{
LeadInsLocked = true,
};
- var plate = new Nest().CreatePlate();
- plate.Size = new Size(100, 100);
- plate.Parts.Add(part);
+ var plate = BestEffortCuttingPlanTests.Plate(part);
var program = part.Program;
-
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
- Assert.Equal(CuttingPlanStatus.ConstraintConflict, Assert.Single(proposal.Plates).Result.Status);
- Assert.False(proposal.CanApply);
- var text = string.Join("\n", proposal.Describe("in"));
- Assert.Contains("missing or zero-length lead-in", text);
- Assert.Contains("If the affected part is locked, edit its lead-ins or unlock it before replanning.", text);
- Assert.Contains("Part 1 (locked sample):", text);
- Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
+ Assert.False(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated);
+ Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status);
Assert.True(part.LeadInsLocked);
Assert.Same(program, part.Program);
}
-
[Fact]
public void Describe_LeadHitsNeighbour_SuggestsSpacingOrShorterLeadWithoutAllowingApply()
{
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
index ec4313c..3afaa09 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanServiceTests.cs
@@ -126,9 +126,24 @@ public class CuttingPlanServiceTests
Assert.Equal(beforeCodes, program.Codes?.ToArray());
}
+ [Fact]
+ public void FixedProgramWithoutLead_IsReadyAndReplayed()
+ {
+ var part = Fixture()[0];
+ part.Program.Codes.RemoveAt(1);
+ ((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
+ var unchanged = Unchanged([part]);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part]));
+
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ Assert.False(Assert.Single(result.ProposedOrder).IsRegenerated);
+ unchanged();
+ }
+
[Theory]
[InlineData("suppressed")]
- [InlineData("missing-lead")]
[InlineData("retention-unknown")]
[InlineData("cutoff")]
public void Plan_UnsupportedOrIncompleteStateIsRefused(string fault)
@@ -138,10 +153,6 @@ public class CuttingPlanServiceTests
switch (fault)
{
case "suppressed": ((Motion)part.Program.Codes[2]).Suppressed = true; break;
- case "missing-lead":
- part.Program.Codes.RemoveAt(1);
- ((RapidMove)part.Program.Codes[0]).EndPoint = new Vector(4, 2);
- expected = CuttingPlanStatus.ConstraintConflict; break;
case "retention-unknown": ((LinearMove)part.Program.Codes[^2]).EndPoint = new Vector(4, 2.25); break;
case "cutoff": part.BaseDrawing.IsCutOff = true; break;
}
diff --git a/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
index 15469c0..a289325 100644
--- a/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/JointCuttingPlanTests.cs
@@ -4,11 +4,55 @@ using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
+using OpenNest.IO;
namespace OpenNest.Tests.CuttingPlanning;
public class JointCuttingPlanTests
{
+ [Fact]
+ public void JointSearch_NoLeads_RepairsRapidCrossings()
+ {
+ var (part, parameters) = Crossing();
+ parameters.ExternalLeadIn = new NoLeadIn();
+ parameters.InternalLeadIn = new NoLeadIn();
+ parameters.ArcCircleLeadIn = new NoLeadIn();
+ var unchanged = Unchanged(part, parameters);
+ var source = Read(part.Program, part.Location);
+ Assert.Contains(new ReleasedContourState(reportMissingLeadIns: false).Check(source, Vector.Zero, 1),
+ f => f.Kind == PostVerificationKind.RapidCrossing);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], confirmedParameters: parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ var output = Read(Assert.Single(result.ProposedOrder).CopyProgram(), part.Location);
+ Assert.DoesNotContain(output.Motions, m => m.Layer == LayerType.Leadin);
+ Assert.Empty(new ReleasedContourState(reportMissingLeadIns: false).Check(output, Vector.Zero, 1));
+ Assert.Contains(new ReleasedContourState().Check(output, Vector.Zero, 1),
+ f => f.Kind == PostVerificationKind.MissingLeadIn);
+ unchanged();
+ }
+
+ [Fact]
+ public void JointSearch_NoLeads_ChoosesDifferentPiercesForOppositeApproaches()
+ {
+ var part = new Part(new Drawing("direct pierce", ExplicitContourTests.Square(false)));
+ var settings = new CuttingParameters();
+ var pierces = new List();
+ foreach (var start in new[] { new Vector(-5, 5), new Vector(15, 5) })
+ {
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], start, confirmedParameters: settings));
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ var output = Read(Assert.Single(result.ProposedOrder).CopyProgram(), part.Location);
+ Assert.DoesNotContain(output.Motions, m => m.Layer == LayerType.Leadin);
+ pierces.Add(output.Motions.First(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Start);
+ Assert.Empty(new ReleasedContourState(reportMissingLeadIns: false).Check(output, start, 1));
+ }
+ Assert.NotEqual(pierces[0], pierces[1]);
+ }
+
[Fact]
public void JointSearch_RepairsActualCompletedHoleCrossing_WithExactReplayAndNoMutation()
{
@@ -46,7 +90,6 @@ public class JointCuttingPlanTests
[Theory]
[InlineData("locked")]
[InlineData("ineligible")]
- [InlineData("no-valid-entry")]
[InlineData("unsupported")]
public void JointSearch_RefusesWithoutInstallingFallbackOrMutating(string fault)
{
@@ -55,12 +98,6 @@ public class JointCuttingPlanTests
var expected = CuttingPlanStatus.ConstraintConflict;
if (fault == "locked") part.LeadInsLocked = true;
if (fault == "ineligible") eligible = [];
- if (fault == "no-valid-entry")
- {
- parameters.ArcCircleLeadIn = new NoLeadIn();
- parameters.InternalLeadIn = parameters.ArcCircleLeadIn;
- expected = CuttingPlanStatus.NoSolutionWithinBudget;
- }
if (fault == "unsupported")
{
part.BaseDrawing.Program.Codes.AddRange(LeadPathValidationTests.Rectangle(30, 30, 40, 40).Codes);
@@ -418,7 +455,7 @@ public class JointCuttingPlanTests
var manual = part.HasManualLeadIns;
var bounds = part.BoundingBox;
var quantity = part.BaseDrawing.Quantity.Nested;
- var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
+ var settings = CuttingParametersSerializer.Serialize(parameters);
return () =>
{
Assert.Same(program, part.Program); Assert.Same(clean, part.BaseDrawing.Program);
@@ -427,7 +464,7 @@ public class JointCuttingPlanTests
Assert.Equal(locked, part.LeadInsLocked); Assert.Equal(manual, part.HasManualLeadIns);
Assert.Same(bounds, part.BoundingBox); Assert.Same(parameters, part.CuttingParameters);
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
- Assert.Equal(length, ((LineLeadIn)parameters.ExternalLeadIn).Length);
+ Assert.Equal(settings, CuttingParametersSerializer.Serialize(parameters));
foreach (var sub in subs)
{
Assert.Same(sub.Program, sub.c.Program); Assert.Equal(sub.text, sub.c.Program.ToString());
diff --git a/OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs b/OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs
index 54a600f..4691147 100644
--- a/OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/LeadMaterialSnapshotTests.cs
@@ -6,6 +6,61 @@ namespace OpenNest.Tests.CuttingPlanning;
public class LeadMaterialSnapshotTests
{
+ [Theory]
+ [InlineData(false, 0, 0, 0)]
+ [InlineData(true, 0, 0, 0)]
+ [InlineData(false, 0.7, 40, 20)]
+ [InlineData(true, 0.7, 40, 20)]
+ public void AdjacentArcs_IntersectionOnExtensionIsNotASelfIntersection(bool atSeam, double angle, double x, double y)
+ {
+ var program = ArcJoint(0.00005, false, atSeam);
+ program.Rotate(angle);
+ var location = new Vector(x, y);
+ var execution = ExecutionMotionReader.ReadSupported(program, location, null);
+ var arcs = execution.Motions.Where(m => m.Curve?.IsFiniteArc == true).Select(m => m.Curve!).ToArray();
+ var contacts = arcs[0].Contacts(arcs[1], out var overlap);
+ Assert.False(overlap);
+ Assert.Contains(contacts, p => arcs.Any(a => a.IsOutsideArcSpan(p)));
+
+ var material = LeadMaterialSnapshot.Capture(program, location);
+
+ Assert.True(material.IsComplete, material.Reason);
+ }
+
+ [Fact]
+ public void AdjacentArcs_RealSecondIntersectionRemainsRejected()
+ {
+ var program = ArcJoint(-0.00005, true, false);
+ var arcs = ExecutionMotionReader.ReadSupported(program, Vector.Zero, null).Motions
+ .Where(m => m.Curve?.IsFiniteArc == true).Select(m => m.Curve!).ToArray();
+ var contacts = arcs[0].Contacts(arcs[1], out var overlap);
+ Assert.False(overlap);
+ Assert.Contains(contacts, p => p.DistanceTo(Vector.Zero) > 0.00001
+ && !arcs[0].IsOutsideArcSpan(p) && !arcs[1].IsOutsideArcSpan(p));
+ var material = LeadMaterialSnapshot.Capture(program, Vector.Zero);
+
+ Assert.False(material.IsComplete);
+ Assert.Contains("Material boundaries", material.Reason);
+ }
+
+ private static Program ArcJoint(double centerOffset, bool reverseSecond, bool atSeam)
+ {
+ // Near-tangent circles meet at zero and about 0.000106 away. The native
+ // angular band of the radius-18 arc admits its extension beyond zero.
+ var first = new Vector(-18, 18);
+ var center = new Vector(centerOffset, 1);
+ var end = center + new Vector(center.DistanceTo(Vector.Zero), 0);
+ var program = new Program();
+ program.MoveTo(atSeam ? Vector.Zero : first);
+ if (!atSeam)
+ program.ArcTo(0, 0, 0, 18, RotationType.CCW);
+ program.ArcTo(end.X, end.Y, center.X, center.Y, reverseSecond ? RotationType.CW : RotationType.CCW);
+ program.LineTo(first);
+ if (atSeam)
+ program.ArcTo(0, 0, 0, 18, RotationType.CCW);
+ return program;
+ }
+
[Theory]
[InlineData(false)]
[InlineData(true)]
diff --git a/OpenNest.Tests/CuttingPlanning/NominalContourClosureTests.cs b/OpenNest.Tests/CuttingPlanning/NominalContourClosureTests.cs
new file mode 100644
index 0000000..4abf313
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/NominalContourClosureTests.cs
@@ -0,0 +1,36 @@
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class NominalContourClosureTests
+{
+ [Theory]
+ [InlineData(0.0000009, true)]
+ [InlineData(0.000001, true)]
+ [InlineData(0.00000101, false)]
+ [InlineData(0.000002, false)]
+ public void CaptureAndPreparation_AgreeAtClosureBoundary(double gap, bool accepted)
+ {
+ // Nonzero coordinates exercise roundoff at the inclusive boundary.
+ var program = new OpenNest.CNC.Program();
+ program.MoveTo(14, 3);
+ program.LineTo(24, 3);
+ program.LineTo(24, 13);
+ program.LineTo(14, 13);
+ program.LineTo(14, 3 + gap);
+ var original = OwnedProgramCopy.Copy(program);
+
+ var material = LeadMaterialSnapshot.Capture(program, new Vector(30, 20));
+
+ Assert.Equal(accepted, material.IsComplete);
+ if (accepted)
+ Assert.Equal(1, PreparedContours.Capture(program, ExplicitContourTests.Parameters()).Count);
+ else
+ {
+ Assert.Contains("closure tolerance 1E-06", material.Reason);
+ Assert.Throws(() => PreparedContours.Capture(program, ExplicitContourTests.Parameters()));
+ }
+ Assert.True(ProgramContent.Equal(original, program));
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
index 057e7b3..bffce19 100644
--- a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
@@ -49,7 +49,9 @@ public class CuttingPlanFormTests
public void BlockedPlate_KeepsApplyDisabledAndShowsTheCurrentParts() => RunSta(() =>
{
var locked = Square("locked", 1, 1);
- locked.LeadInsLocked = true; // A locked program is never regenerated: no lead-in blocks it.
+ var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
+ crossing.MoveTo(5, 5); // A locked crossing cannot be repaired by regeneration.
+ Assert.True(locked.RestoreLeadInProgram(crossing, true));
var (nest, view) = CreateView(locked);
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
@@ -187,7 +189,9 @@ public class CuttingPlanFormTests
first.Parts.Add(Square("open", 1, 1));
var second = nest.CreatePlate();
var locked = Square("locked", 1, 1);
- locked.LeadInsLocked = true;
+ var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
+ crossing.MoveTo(5, 5);
+ Assert.True(locked.RestoreLeadInProgram(crossing, true));
second.Parts.Add(locked);
nest.CreatePlate(); // An empty plate, like the editor's trailing plate.
using var view = new PlateView { Plate = first };
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 0ef7feb..9c31c87 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -57,8 +57,17 @@ Nominal material validation recognizes contacts at shared endpoints of adjacent
contour segments using `OpenNest.Math.Tolerance.Epsilon` (0.00001 model units).
This avoids false self-intersection warnings from rounding at arc/line joints,
including the contour seam. Overlapping segments and contacts away from those
-joints or between separate contours still fail validation. Contour closure and
-the underlying native intersection queries retain their existing tolerances.
+joints or between separate contours still fail validation. For adjacent finite arcs
+with a shared endpoint, material validation filters native contacts against both
+actual arc spans: the native query's angular padding can otherwise report a
+supporting-circle intersection beyond one arc's endpoint. This does not increase
+the joint-distance tolerance, discard overlapping arcs, or waive a second intersection
+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. Native intersections,
+executed-motion checks and pre-post verification retain their existing tolerances.
Capture builds whole-part prerequisites from owned values, and both the search and
the final replay enforce them:
@@ -232,7 +241,7 @@ Neither obtaining a proposal nor copying its programs installs them on live part
## Results and refusal
`Ready` and `IndependentlyReplayed` describe the modeled proposal only. In the
-no-parameter fixed route, replay checks rapid crossings, missing leads and
+no-parameter fixed route, replay checks rapid crossings and
incomplete retention; it does not add regeneration-mode material/lead checks.
In regeneration mode, replay also checks actual lead paths and contour accounting
against owned clean material. Neither mode certifies final NC, production cutting
@@ -317,10 +326,13 @@ plans every plate that has parts. Both open one dialog built on
whole-part order; either change replans. The settings are confirmed parameters: every
unlocked part's lead-ins are regenerated. Locked parts keep their exact programs;
the strict route checks them, while any best-effort warnings require explicit review below.
-- A missing or zero-length lead-in is reported directly, rather than as a search-limit
- failure. Open `Cutting Settings...`, choose a lead-in other than `None` with a nonzero
- length on the affected `External`, `Internal`, or `Arc / Circle` tab, then replan.
- Locked programs require manual lead editing or unlocking before regeneration.
+- Lead-ins are optional for planning. With `None` (or a style that emits no lead
+ motion), the emitter places the pierce directly on the selected contour point.
+ Unlocked contour starts can still move to improve travel; locked programs keep
+ their existing starts. Search and independent replay still check rapid crossings,
+ emitted lead paths when present, and contour accounting. The separate pre-post
+ missing-lead warning remains available for operator review; a ready plan does not
+ waive posting checks.
When a lead hits another part, the finding suggests more spacing or a shorter lead;
when no tested entry fits, it suggests reducing lead-in length and, if neighbours
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
@@ -360,7 +372,12 @@ plans every plate that has parts. Both open one dialog built on
programs. Ready and best-effort proposals can be previewed only while the plate still
matches capture. Best-effort previews are labelled `UNVERIFIED`. Refused program graphs
are never copied, and changed plates require replanning.
-- Apply requires usable output for every plate and remains all or nothing. A ready batch
+- Apply requires usable output for every plate and remains all or nothing. The summary
+ counts best-effort proposals as usable when reporting how many plates block Apply;
+ their warnings still require acceptance. Open nominal contour findings identify the
+ contour number, endpoint gap in model units (the nest's linear units), and closure
+ tolerance, so the source can be reviewed in the drawing editor. Reporting never
+ closes gaps or changes the validation tolerance. A fully ready batch
can apply immediately; an unverified batch requires the unchecked, per-proposal
`I reviewed the warnings. Apply this unverified plan.` checkbox. Replanning clears it.
Both use the same owned-program, freshness and rollback boundary. After it applies, each plate keeps its own copy of the
diff --git a/docs/post-verification.md b/docs/post-verification.md
index 4320c28..f143336 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -54,6 +54,8 @@ shows all three check categories, even when there are no findings:
- **Missing lead-ins:** cutting contours without an actual, nonzero lead-in in the
placed program. Applying a lead-in to only one contour does not clear the other
contours. Scribe-only work and scrap cutoff lines do not require lead-ins here.
+ The cutting planner supports direct contour piercing without lead-ins; that does
+ not suppress this pre-post review warning or grant posting consent.
- **Rapid crossings:** direct XY moves crossing closed, already-cut, untabbed
contours in cutting order. This includes earlier holes within the same part and
previously cut parts. A future cut is not an obstacle yet. Actual uncut gaps are