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