fix(cutting): aim round cutouts toward the next cut

This commit is contained in:
aj committed 2026-10-07 20:55:40 -04:00
1 parent 1c923976a8
commit fc300705a8
4 files changed
+173 -3

No files matched your search

@@ -8,7 +8,8 @@ namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
@@ -33,6 +34,19 @@ internal static class ContourEntryRanking
if (candidates.Count == 0)
return new List<ContourEntryCandidate>();
// 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;
@@ -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));
}
}
@@ -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)
{
+16
View File
@@ -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