From fc300705a8aa6e049282804b84a92cae8bd5eb71 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 20:55:40 -0400 Subject: [PATCH] fix(cutting): aim round cutouts toward the next cut --- .../CuttingPlanning/ContourEntryRanking.cs | 16 +- .../CuttingPlanning/CircleNextCutTests.cs | 139 ++++++++++++++++++ .../CuttingPlanning/HoleLookAheadTests.cs | 5 +- docs/cutting-planner.md | 16 ++ 4 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs index 37ae8e6..56e7a28 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs @@ -8,7 +8,8 @@ namespace OpenNest.CNC.CuttingPlanning; /// /// Pure deterministic ordering of the automatic entry catalogue toward the next cut: -/// facing sides first, then tier, then travel, then a stable geometric key. It adds no +/// circles rank by outgoing distance; other contours use facing sides, tier and travel, +/// then a stable geometric key. It adds no /// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility /// filtering and the bounded selection belong to S07/S08, the wiring to S09. /// @@ -33,6 +34,19 @@ internal static class ContourEntryRanking if (candidates.Count == 0) return new List(); + // Whole circles have no corners. A diagonal compass point is equally near two + // bounding-box sides, but must not gain the two-side bonus of a real corner. + // Rank outgoing travel first so arrival cannot pull the start away from the next + // cut. Retain every compass/polar alternative for feasibility and rapid checks; + // configured angle rounding remains the emitter's responsibility. + if (target is { } next && candidates.Any(c => c.Kind == AutomaticEntryKind.CircleCompass) + && candidates.All(c => c.Kind is AutomaticEntryKind.CircleCompass or AutomaticEntryKind.TargetFacing)) + return candidates.OrderBy(c => c.Choice.Point.DistanceTo(next)) + .ThenBy(c => arrival is { } from ? c.Choice.Point.DistanceTo(from) : 0.0) + .ThenBy(c => c.GeometryKey.X) + .ThenBy(c => c.GeometryKey.Y) + .ToList(); + // Candidate bounding rectangle in the contour's local coordinates; every candidate // lies on the contour, so distances to the four side lines order side proximity. var minX = double.PositiveInfinity; diff --git a/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs b/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs new file mode 100644 index 0000000..980a629 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CircleNextCutTests.cs @@ -0,0 +1,139 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class CircleNextCutTests +{ + private static Program WithHoles() + { + var program = LeadPathValidationTests.Rectangle(0, 0, 16, 12); + foreach (var centre in new[] { new Vector(3, 3), new Vector(9, 4), new Vector(11, 8) }) + { + program.MoveTo(centre.X + 1, centre.Y); + program.ArcTo(centre.X + 1, centre.Y, centre.X, centre.Y, RotationType.CCW); + } + return program; + } + + [Theory] + [InlineData(13, 4)] + [InlineData(3, 13)] + [InlineData(-7, 2)] + [InlineData(4, -7)] + public void CirclesPreferTheExactNextCutDirectionWithoutDroppingPolarOrDiagonalOptions(double x, double y) + { + var prepared = PreparedContours.Capture(WithHoles(), HoleLookAheadTests.Parameters()); + var target = new Vector(x, y); + var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(0, target); + var ranked = catalogue.RankTowardNextCut(target, new Vector(-4, -3)); + var expected = prepared.ClosestEntry(0, target).Point; + + Assert.True(ranked[0].Choice.Point.DistanceTo(expected) < 1e-8, + $"Expected next-facing {expected}, got {ranked[0].Choice.Point}"); + Assert.Equal(catalogue.Count, ranked.Count); + Assert.All(catalogue, candidate => Assert.Contains(candidate, ranked)); + for (var angle = 0; angle < 8; angle++) + { + var point = new Vector(3, 3) + new Vector(System.Math.Cos(angle * System.Math.PI / 4), + System.Math.Sin(angle * System.Math.PI / 4)); + Assert.Contains(ranked, c => c.Choice.Point.DistanceTo(point) < 1e-8); + } + } + + [Fact] + public void BlockedExactPointRetainsAFeasiblePolarAlternative() + { + var prepared = PreparedContours.Capture(WithHoles(), HoleLookAheadTests.Parameters()); + var target = new Vector(13, 4); + var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(0, target); + var ranked = catalogue.RankTowardNextCut(target, new Vector(-4, -3)); + var exact = prepared.ClosestEntry(0, target).Point; + var material = LeadMaterialSnapshot.Capture(WithHoles(), Vector.Zero); + var feasibility = new ContourEntryFeasibility(prepared, Vector.Zero, material, []); + var blocked = 0; + var selection = ContourEntrySelection.Select(ranked, candidate => + { + if (candidate.Choice.Point.DistanceTo(exact) < 1e-8) + { + blocked++; + return new(ContourFeasibilityStatus.Blocked, "Exact direction unavailable in this control."); + } + return feasibility.Check(candidate.Choice); + }, 1); + + Assert.True(blocked > 0); + var selected = Assert.Single(selection.Choices); + Assert.True(selected.Point.DistanceTo(new Vector(4, 3)) < 1e-8, + $"Expected east polar fallback, got {selected.Point}"); + Assert.Equal(ContourFeasibilityStatus.Clear, feasibility.Check(selected).Status); + } + + [Theory] + [InlineData(45)] + [InlineData(90)] + public void ConfiguredAngleRoundingStillSnapsTheActualCircleCuts(double increment) + { + var parameters = HoleLookAheadTests.Parameters(); + parameters.RoundLeadInAngles = true; + parameters.LeadInAngleIncrement = increment; + var part = new Part(new Drawing("rounded starts", WithHoles())); + var next = new Part(new Drawing("next part", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), + new Vector(22, 1)); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part, next], new Vector(-2, 3), + confirmedParameters: parameters, preservePartOrder: true)); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + var proposed = result.ProposedOrder[0]; + var runs = HoleLookAheadTests.CutRuns(proposed.Execution); + var centres = new[] { new Vector(3, 3), new Vector(9, 4), new Vector(11, 8) }; + for (var i = 0; i < 3; i++) + { + var radial = runs[i].End - centres[proposed.ContourChoices[i].ContourOrdinal]; + var steps = System.Math.Atan2(radial.Y, radial.X) * 180 / System.Math.PI / increment; + Assert.True(System.Math.Abs(steps - System.Math.Round(steps)) < 1e-8); + } + Assert.True(parameters.RoundLeadInAngles); + Assert.Equal(increment, parameters.LeadInAngleIncrement); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(23, 17)] + public void PlannedCircleStartsFaceTheImmediateNextPierceNotTwoFeaturesAhead(double x, double y) + { + var location = new Vector(x, y); + var part = new Part(new Drawing("three round cutouts", WithHoles()), location); + var next = new Part(new Drawing("next part", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), + location + new Vector(22, 1)); + var before = ExplicitContourTests.Fingerprint(part.Program); + var arrival = location + new Vector(-2, 3); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, next], arrival, + confirmedParameters: HoleLookAheadTests.Parameters(), preservePartOrder: true)); + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}: {string.Join("; ", result.Findings.Select(f => f.Message))}"); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + var proposal = result.ProposedOrder[0]; + var choices = proposal.ContourChoices; + var runs = HoleLookAheadTests.CutRuns(proposal.Execution); + Assert.Equal(4, runs.Count); + Assert.Equal(new[] { 0, 1, 2, 3 }, choices.Select(c => c.ContourOrdinal)); + var prepared = snapshot.Placements[0].Prepared; + for (var i = 0; i < choices.Count - 1; i++) + { + var target = runs[i + 1].Pierce - location; + var expected = prepared.ClosestEntry(choices[i].ContourOrdinal, target).Point; + Assert.True(choices[i].Point.DistanceTo(expected) < 1e-8, + $"Hole {i}: expected immediate-next-facing {expected}, got {choices[i].Point}"); + // The emitter rounds circle start angles; verify actual output too, allowing + // only that existing rounding rather than a snapped compass direction. + Assert.True((runs[i].End - location).DistanceTo(expected) < 0.01); + } + Assert.Empty(new ReleasedContourState().Check(proposal.Execution, arrival, 1)); + Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program)); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs index 617a625..4b01bd8 100644 --- a/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs +++ b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs @@ -60,8 +60,9 @@ public class HoleLookAheadTests [Fact] public void CrossingPreferredHolePathBacktracksToACheckedAlternative() { - // Calibrated: the preferred rapid from hole 1 to hole 2 touches already-cut hole 3. - var centres = new[] { new Vector(3, 3), new Vector(7, 15), new Vector(3, 7), new Vector(3, 11) }; + // Calibrated for next-facing circle entries: the preferred rapid from hole 1 + // to hole 2 touches already-cut hole 3; retain checked entry backtracking. + var centres = new[] { new Vector(3, 3), new Vector(6, 15), new Vector(3, 7), new Vector(3, 11) }; var clean = LeadPathValidationTests.Rectangle(0, 0, 18, 18); foreach (var c in centres) { diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index d4d953f..6120b3b 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -107,6 +107,22 @@ and tab trimming happen during emission. Validation uses the actual emitted motions, never the nominal entry point alone. Existing lead styles are not shortened, disabled or substituted as a search fallback. +Whole-circle candidates rank by distance to their next-cut target; for a hole, +that is the next contour's actual pierce. They do not reward a diagonal point as +if it were a bounding-box corner. All eight +compass options remain available, including the four polar points at 0°, 90°, +180° and 270°, and every alternative still passes the emitted-lead and rapid checks. +Without a next-cut target, the arrival-based rule is unchanged. Polygon corner +preferences are unchanged. + +`Round Lead-In Angles` remains an explicit cutting setting: a 90° increment snaps +circular-hole starts to the four polar directions; 45° also permits diagonals. +Snapping can increase reuse of identical hole subprograms and reduce output for +posts that support that reuse, at the cost of a less direct departure. It is not +a clearance exemption or a guaranteed file-size reduction. The planner does not +silently enable rounding, change the increment, or bypass checks on the rounded +motions. + Every candidate rapid is checked against contours already completed, including earlier holes in the same part. Future contours are not yet obstacles. Rapid checks skip completed contours whose extents are more than 0.001 clear of the