diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadContactMemo.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadContactMemo.cs new file mode 100644 index 00000000..a0e6c379 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadContactMemo.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using OpenNest.Diagnostics; + +namespace OpenNest.CNC.CuttingPlanning; + +/// +/// Remembers whether a lead curve stays clear of another part's material, so a search that +/// re-checks the same emitted leads (every prefix of a part repeats its earlier contours' leads, +/// and backtracking revisits them) runs each native query set once. A hit returns exactly what +/// returned for that curve and material and charges +/// the same native query budget. Curves match by exact bits and materials by reference; the +/// materials must not change while the memo is in use. Scans that threw are not remembered and +/// run again. One instance belongs to one single-threaded search; replay must not use one. +/// +internal sealed class LeadContactMemo +{ + private readonly Dictionary<(PostVerificationGeometry.CurveKey Curve, LeadMaterialSnapshot Material), Scan> scans = new(); + + /// Remembered native queries answered without running (Debug cost tests). + internal long Hits { get; private set; } + + internal bool TryGet(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, out Scan scan) + { + if (!scans.TryGetValue((curve.Key, material), out scan)) + return false; + Hits++; + return true; + } + + internal void Add(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, Scan scan) => + scans[(curve.Key, material)] = scan; + + /// A completed scan: whether it found contact and how many boundary queries it charged. + internal readonly record struct Scan(bool Contact, int Queries); +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs index 4904cb47..a5815797 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/LeadPathValidator.cs @@ -24,6 +24,11 @@ public static class LeadPathValidator IReadOnlyList otherMaterials, CancellationToken token = default) => Check(execution, target, otherMaterials, null, null, token); + /// As the public check, remembering other-material results in . + internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target, + IReadOnlyList otherMaterials, LeadContactMemo memo, CancellationToken token) => + Check(execution, target, otherMaterials, null, null, token, memo); + /// /// As the public check, but a lead motion that owns (by motion /// index) is part of a fixed emission: its failure is added to @@ -31,7 +36,7 @@ public static class LeadPathValidator /// internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target, IReadOnlyList otherMaterials, Func isFixed, - List fixedFailures, CancellationToken token) + List fixedFailures, CancellationToken token, LeadContactMemo memo = null) { token.ThrowIfCancellationRequested(); if (execution == null || target == null || otherMaterials == null) @@ -144,7 +149,7 @@ public static class LeadPathValidator var material = otherMaterials[other]; if (ReferenceEquals(material, target)) continue; - if (CheckMaterial(material, null) != null) + if (OtherContact(material)) return (new(true, false, "Lead contacts or enters another placed material. " + "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. " + "For locked parts, edit the leads or unlock the part before replanning."), other); @@ -153,6 +158,25 @@ public static class LeadPathValidator string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) => MaterialContact(move.Curve, material, permittedJoint, ref budget, token); + + // Exactly the unmemoized check: a remembered scan charges the queries it ran + // and throws where running it again would exhaust the budget. + bool OtherContact(LeadMaterialSnapshot material) + { + if (memo == null) + return CheckMaterial(material, null) != null; + if (memo.TryGet(move.Curve, material, out var scan)) + { + if (budget - scan.Queries < 0) + throw new NotSupportedException("Lead validation exceeds the native query limit."); + budget -= scan.Queries; + return scan.Contact; + } + var before = budget; + var contact = CheckMaterial(material, null) != null; + memo.Add(move.Curve, material, new(contact, before - budget)); + return contact; + } } } catch (Exception ex) when (ex is ArgumentException or NotSupportedException) diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 57b0d631..cd6549bb 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -1059,15 +1059,27 @@ namespace OpenNest.CNC.CuttingStrategy ) { var moves = new List(); + var position = startPoint; foreach (var entity in shape.Entities) { if (entity is Line line) { moves.Add(new LinearMove(line.EndPoint) { Layer = layer }); + position = line.EndPoint; } else if (entity is Arc arc) { + // A contour entered away from its seam crosses the seam's admitted closure gap + // mid-cut. Starting the arc there would move its start off its own circle, so + // bridge the gap with a straight move and cut the arc exactly as drawn. A lead-in + // must join the contour itself, so an entry on the seam is left as it was. + var arcStart = arc.StartPoint(); + var gap = position.DistanceTo(arcStart); + if (moves.Count > 0 && gap > Diagnostics.PostVerificationGeometry.Epsilon + && CuttingPlanning.NominalContourClosure.IsClosed(position, arcStart)) + moves.Add(new LinearMove(arcStart) { Layer = layer }); + position = arc.EndPoint(); moves.Add( new ArcMove( arc.EndPoint(), @@ -1084,6 +1096,7 @@ namespace OpenNest.CNC.CuttingStrategy moves.Add( new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer } ); + position = startPoint; } else { diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs index b538f014..aac3b463 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs @@ -125,6 +125,10 @@ internal static class PostVerificationGeometry return angle < 0 ? angle + TwoPi : angle; } + /// Exact identity of a 's defining values (see ). + internal readonly record struct CurveKey(long StartX, long StartY, long EndX, long EndY, + bool HasCenter, long CenterX, long CenterY, long Radius, long Sweep); + internal sealed class Curve { private Curve(Vector start, Vector end, Vector? center, double radius, double sweep) @@ -172,6 +176,16 @@ internal static class PostVerificationGeometry center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2)) : (Start + End) * 0.5; + /// + /// The exact bits of every value a native query reads, so two curves with equal keys + /// give identical query results. Not a geometric tolerance: equal shapes built from + /// different roundoff get different keys. + /// + internal CurveKey Key => new(Bits(Start.X), Bits(Start.Y), Bits(End.X), Bits(End.Y), + Center.HasValue, Bits(Center?.X ?? 0), Bits(Center?.Y ?? 0), Bits(Radius), Bits(Sweep)); + + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); + internal bool SameSupport(Curve other) => SameSupport(other, Epsilon); /// diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs index 024782ee..3de238a8 100644 --- a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs +++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs @@ -48,10 +48,20 @@ public sealed class ContourEntryFeasibility private readonly LeadMaterialSnapshot ownMaterial; private readonly LeadMaterialSnapshot[] materials; private readonly Dictionary cache = new(); + private readonly LeadContactMemo? leadContacts; public ContourEntryFeasibility(PreparedContours prepared, Vector location, LeadMaterialSnapshot ownMaterial, IReadOnlyList otherMaterials) + : this(prepared, location, ownMaterial, otherMaterials, null) { + } + + /// Shares exact lead/material results with the caller's search. + internal ContourEntryFeasibility(PreparedContours prepared, Vector location, + LeadMaterialSnapshot ownMaterial, IReadOnlyList otherMaterials, + LeadContactMemo? leadContacts) + { + this.leadContacts = leadContacts; this.prepared = prepared ?? throw new ArgumentException("Prepared contours are required.", nameof(prepared)); this.ownMaterial = ownMaterial ?? throw new ArgumentException("Own material snapshot is required.", nameof(ownMaterial)); if (otherMaterials == null) @@ -110,7 +120,9 @@ public sealed class ContourEntryFeasibility { var program = prepared.EmitCandidateForValidation(choice); var execution = ExecutionMotionReader.Read(program, location, null, token); - var result = LeadPathValidator.Check(execution, ownMaterial, materials, token); + var result = leadContacts == null + ? LeadPathValidator.Check(execution, ownMaterial, materials, token) + : LeadPathValidator.Check(execution, ownMaterial, materials, leadContacts, token); if (!result.IsComplete) return new(ContourFeasibilityStatus.Incomplete, result.Reason); return result.IsClear diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 8a7ec245..9fa4ee60 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -195,6 +195,9 @@ internal static class JointCuttingPlanSearch // One feasibility adapter per source part per captured planning attempt: verdicts // memoize per owned choice for the attempt, never statically or across attempts. private readonly Dictionary feasibility = []; + // Lead/material verdicts for exact curves, shared by prechecks and prefix checks of this + // walk: each prefix repeats its earlier contours' leads. Replay never uses it. + private readonly LeadContactMemo leadContacts = new(); private readonly HashSet reportedNoFit = []; private readonly Dictionary hostContours = []; @@ -624,7 +627,7 @@ internal static class JointCuttingPlanSearch private ContourEntryFeasibility Adapter(FixedProgramPlacement source) => feasibility.TryGetValue(source.SourceOrdinal, out var known) ? known : feasibility[source.SourceOrdinal] = new ContourEntryFeasibility( - source.Prepared, source.Location, source.Material, materials); + source.Prepared, source.Location, source.Material, materials, leadContacts); private ContourFeasibilityVerdict Evaluate(FixedProgramPlacement source, ContourEntryCandidate candidate) { @@ -741,7 +744,7 @@ internal static class JointCuttingPlanSearch attempt.NoteCrossings(source.SourceOrdinal, findings); // A fixed cutoff has no material or leads to certify; its rapids are still checked. var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null) - : LeadPathValidator.Check(execution, source.Material, materials, isFixed, [], token); + : LeadPathValidator.Check(execution, source.Material, materials, isFixed, [], token, leadContacts); if (!lead.IsComplete || !lead.IsClear) rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason)); return findings.Count == 0 && lead.IsComplete && lead.IsClear; diff --git a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs index 5eb2a504..39b184ca 100644 --- a/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs +++ b/OpenNest.Tests/CuttingPlanning/NearbyMaterialCheckTests.cs @@ -106,6 +106,35 @@ public class NearbyMaterialCheckTests Assert.False(result.IsClear); } + [Fact] + public void MemoizedLeadCheck_ReturnsTheNativeResultOnEveryCall() + { + // The search re-checks the same emitted leads on every prefix of a part. Remembered + // answers must be the native ones, the rounding contact far from a small circle included. + var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0, -2, 2, 0.0021), Vector.Zero); + var circle = new Program(); + circle.MoveTo(-1 + 0.0001, 0); + circle.Codes.Add(new ArcMove(new Vector(-1 + 0.0001, 0), new Vector(-1, 0), RotationType.CCW)); + var far = LeadMaterialSnapshot.Capture(circle, Vector.Zero); + var clear = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(5, 5, 6, 6), Vector.Zero); + var lead = new Program(); + lead.MoveTo(-900000, 0.0021); + lead.Codes.Add(new LinearMove(0, 0.0021) { Layer = LayerType.Leadin }); + lead.LineTo(0, -2); + var memo = new LeadContactMemo(); + + foreach (var others in new[] { new[] { target, clear }, [target, clear, far], [far, target] }) + { + var expected = LeadPathValidator.Check(Read(lead), target, others); + for (var call = 0; call < 2; call++) + { + var actual = LeadPathValidator.Check(Read(lead), target, others, memo, default); + Assert.Equal(expected, actual); + } + } + Assert.True(memo.Hits > 0); + } + [Fact] public void RapidJustOutsideASmallCompletedCircle_IsStillACrossing() { diff --git a/OpenNest.Tests/CuttingPlanning/SeamClosureReplayTests.cs b/OpenNest.Tests/CuttingPlanning/SeamClosureReplayTests.cs index 0d88898b..cdc93d4c 100644 --- a/OpenNest.Tests/CuttingPlanning/SeamClosureReplayTests.cs +++ b/OpenNest.Tests/CuttingPlanning/SeamClosureReplayTests.cs @@ -149,6 +149,42 @@ public class SeamClosureReplayTests Assert.False(result.IndependentlyReplayed); } + [Theory] + [InlineData(5e-7)] + [InlineData(1e-6)] + public void AdmittedClosureSeam_BeforeAnArc_EmitsFromEveryEntry(double seam) + { + // The ring's first entity is an arc and the last edge stops short of its start. Entered + // anywhere but the seam, the path reaches that arc off its circle; bridging the gap with a + // straight move keeps the arc exact instead of refusing it as an inconsistent radius, + // which left a converted outline with only one usable entry. + var program = new Program(); + program.MoveTo(0, 0); + program.ArcTo(new Vector(10, 0), new Vector(5, 5), RotationType.CCW); + program.LineTo(10, 10); + program.LineTo(0, 10); + program.LineTo(0, seam); + var settings = ExplicitContourTests.Parameters(); + var snapshot = Capture(new Part(new Drawing("seam", program)), settings); + var source = snapshot.Placements[0]; + Assert.True(source.Material!.IsComplete, source.Material.Reason); + + // An entry on the seam vertex itself puts the gap before the first cut, where no lead-in + // can join it; that one stays refused as before. + var entries = source.Prepared!.Entries(0, new Vector(-5, 5), 64) + .Where(c => c.Point.DistanceTo(new Vector(0, seam)) > 1e-9).ToArray(); + Assert.True(entries.Length > 1); + foreach (var choice in entries) + { + var emitted = source.Prepared.Emit([choice]); + var result = CuttingPlanService.ReplayPrograms(snapshot, + [source.Propose(emitted, Read(emitted), [choice])], 0, default); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + } + } + [Fact] public void GapBeyondAdmittedClosure_StillRefusesAtCapture() { diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 1ef0482f..d667c59c 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -66,7 +66,11 @@ 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, +capped at 1e-12. It does not rewrite source endpoints. A contour entered away from its +seam crosses that gap mid-cut; when an arc follows it, emission bridges the gap with a +straight move and cuts the arc as drawn, rather than starting the arc off its own circle +(which every check refuses as an inconsistent radius). An entry on the seam vertex has no +such bridge, since a lead-in must join the contour itself. Native intersections, executed-motion checks and pre-post verification retain their existing tolerances. Arcs of one circle whose centres were each derived from their own endpoints agree only @@ -214,7 +218,9 @@ rapid, ten times the widest band any native contact query allows beyond an extent; anything closer, touching included, gets the full native check. Lead checks examine every other part's material: native line/circle queries can report rounding contacts for long leads far from small circles, and leads keep those -results. An arc's +results. Every prefix of a part repeats its earlier contours' leads, so one search +remembers each other-material verdict by the lead curve's exact bits and the material's +reference, and charges the native query budget the scan ran; replay never uses it. An arc's extent is its whole supporting circle widened to the distance at which the native contact query still counts it as touched (for a very small arc up to 0.0001 beyond its radius). Nothing is skipped when either extent has a nonfinite bound or reaches