mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 16:44:13 -04:00
feat(cutting): chain preferred hole entries to the actual pierce
PreferredContourEntries resolves one entry per hole by walking the proposed hole route BACKWARD from the already-chosen perimeter entry. Each hole ranks against the downstream contour's ACTUAL emitted pierce (first non-rapid motion of the owned probe emission, native rounding included), so the cut chain flows toward the outside start rather than chaining on nominal points that rounding may move. Catalogue is the S04 merged list (preferred kinds where present, compass/arc fallbacks otherwise — pure circles included); ordering is the S05 facing/tier/travel ranker; feasibility is one S08-bounded pick per hole through caller-supplied S07 verdicts. A hole with no feasible candidate yields an explicit no-preference proposal naming the contour and reason — never a silent skip, never a partial chain. Arrival proxies (part arrival for the first hole, previous hole centre otherwise) keep the pass deterministic and circularity-free. Deterministic (repeat runs identical), cancellation-aware, ownership- checked, source program untouched, fresh owned probe programs only. This is a recommended-v1 proposal: nothing is installed into the search and no rapid is certified here — S12 wires it and must handle a blocked preferred rapid. Tests: circle pair flows hole->hole->outside via actual pierces (each pierce strictly nearer downstream than the opposite compass pierce); rectangular holes pick right-facing corners under all-clear evaluation; blocked ideal candidate falls back to next-ranked on the same hole; blocked hole yields honest no-preference finding; permutation integrity in route order excluding the perimeter; determinism; foreign perimeter refused; cancellation; fingerprint/ownership/fresh-program invariants. Gate 9/0/0; full Core 3580/25skip/0; cross-build 0 errors; scoped format verify 0; facing-drop and silent-skip mutants killed; nominal- vs-actual-pierce mutant documented near-equivalent.
This commit is contained in:
1 parent
fa3c5e348e
commit
6b7d0d7f90
2 files changed
+367
No files matched your search
@@ -0,0 +1,99 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// One preferred sequence of owned contour choices for a holed part: every remaining hole
|
||||
/// gets exactly one lead-feasible entry, resolved BACKWARD from the already-chosen
|
||||
/// perimeter entry — the last hole faces the perimeter's actual emitted pierce, each earlier
|
||||
/// hole faces the next hole's actual emitted pierce — so the cut chain flows toward the
|
||||
/// outside start. This is a deterministic recommended v1 proposal, not an optimal joint
|
||||
/// tour and not an installed program: the search still certifies every rapid, lead and
|
||||
/// crossing. A blocked preferred rapid is the search's problem (S12), never ignored here.
|
||||
/// </summary>
|
||||
internal static class PreferredContourEntries
|
||||
{
|
||||
/// <summary>Proposed hole choices in cut order, or an explicit no-preference verdict.</summary>
|
||||
internal sealed record Proposal(
|
||||
IReadOnlyList<ContourChoice> HoleChoices,
|
||||
ContourSelectionShortfall Shortfall,
|
||||
int? BlockedContour,
|
||||
string? Reason)
|
||||
{
|
||||
public bool IsPreferred => Shortfall != ContourSelectionShortfall.Incomplete
|
||||
&& HoleChoices.Count > 0 && BlockedContour is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one preferred entry per hole on <paramref name="holeRoute"/> (cut order,
|
||||
/// from <see cref="CuttingHoleOrder"/>), ending at <paramref name="perimeterChoice"/>.
|
||||
/// Arrival proxies avoid circularity: the actual (local) part arrival for the first
|
||||
/// hole, otherwise the previous hole's centre. Facing target (the downstream actual
|
||||
/// pierce) and tier still rank first. <paramref name="evaluate"/> is the S07 adapter
|
||||
/// verdict; candidates it refuses or leaves uncertain are not preferred.
|
||||
/// </summary>
|
||||
internal static Proposal TryPlan(
|
||||
PreparedContours prepared,
|
||||
ContourChoice perimeterChoice,
|
||||
IReadOnlyList<int> holeRoute,
|
||||
IReadOnlyList<Vector> centresByOrdinal,
|
||||
Vector arrival,
|
||||
Func<ContourEntryCandidate, ContourFeasibilityVerdict> evaluate,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (prepared == null)
|
||||
throw new ArgumentException("Prepared contours are required.", nameof(prepared));
|
||||
if (perimeterChoice == null || !ReferenceEquals(perimeterChoice.Owner, prepared))
|
||||
throw new ArgumentException("The perimeter choice must belong to this preparation.", nameof(perimeterChoice));
|
||||
if (holeRoute == null || centresByOrdinal == null || evaluate == null)
|
||||
throw new ArgumentException("Hole route, centres and evaluation are required.");
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (holeRoute.Count == 0)
|
||||
return new(Array.Empty<ContourChoice>(), ContourSelectionShortfall.Exhausted, null, null);
|
||||
|
||||
// The downstream target is where the tool ACTUALLY arrives next: the emitted
|
||||
// contour's first cut/lead motion start (native rounding/clamping included).
|
||||
var target = Pierce(prepared, perimeterChoice, token);
|
||||
var choices = new ContourChoice[holeRoute.Count];
|
||||
for (var index = holeRoute.Count - 1; index >= 0; index--)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var contour = holeRoute[index];
|
||||
var arrivalProxy = index == 0 ? arrival : centresByOrdinal[holeRoute[index - 1]];
|
||||
// The S04 merged catalogue: preferred kinds when present, fallback kinds
|
||||
// (compass points, arc midpoints, ...) otherwise — pure circles included.
|
||||
var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token);
|
||||
var ranked = catalogue.RankTowardNextCut(target, arrivalProxy);
|
||||
var selection = ContourEntrySelection.Select(ranked, evaluate, 1, token);
|
||||
if (selection.Choices.Count == 0)
|
||||
return new(Array.Empty<ContourChoice>(), selection.Shortfall, contour,
|
||||
$"Hole contour {contour} has no preferred lead-feasible entry: {selection.Reason}");
|
||||
choices[index] = selection.Choices[0];
|
||||
target = Pierce(prepared, choices[index], token);
|
||||
}
|
||||
|
||||
if (choices.Any(c => c == null!))
|
||||
throw new InvalidOperationException("Preferred hole resolution left a gap.");
|
||||
return new(choices, ContourSelectionShortfall.None, null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The actual emitted pierce of one owned choice in local coordinates: the start of the
|
||||
/// first non-rapid motion (lead-in when present — that is where the next rapid must
|
||||
/// arrive), which reflects native rounding and clamping of a nominal entry.
|
||||
/// </summary>
|
||||
private static Vector Pierce(PreparedContours prepared, ContourChoice choice, CancellationToken token)
|
||||
{
|
||||
var program = prepared.EmitCandidateForValidation(choice);
|
||||
var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, token);
|
||||
var first = execution.Motions.First(m => !m.Rapid);
|
||||
return first.Start ?? first.End;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// The preferred hole-entry proposal: walk the proposed hole route BACKWARD from the chosen
|
||||
/// perimeter entry, each hole facing the downstream contour's ACTUAL emitted pierce (native
|
||||
/// rounding included), corners outranking midpoints, with an explicit no-preference result
|
||||
/// when a hole cannot flow. Proposal only — nothing installed, nothing certified here.
|
||||
/// (Contour layout: holes take ordinals 0..N-1, the perimeter is the last ordinal.)
|
||||
/// </summary>
|
||||
public class PreferredContourEntriesTests
|
||||
{
|
||||
private static readonly Vector Arrival = new(0, 5);
|
||||
|
||||
private static Program CirclesProgram(params (double X, double Y)[] centres)
|
||||
{
|
||||
var p = ExplicitContourTests.Square(false);
|
||||
foreach (var (x, y) in centres)
|
||||
{
|
||||
// Unit circle centred at (x, y): two half-arcs, CCW.
|
||||
p.MoveTo(x + 1, y);
|
||||
p.ArcTo(new Vector(x - 1, y), new Vector(x, y), RotationType.CCW);
|
||||
p.ArcTo(new Vector(x + 1, y), new Vector(x, y), RotationType.CCW);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Program RectHolesProgram(params (double X1, double Y1, double X2, double Y2)[] boxes)
|
||||
{
|
||||
var p = ExplicitContourTests.Square(false);
|
||||
foreach (var (x1, y1, x2, y2) in boxes)
|
||||
{
|
||||
p.MoveTo(x1, y1);
|
||||
p.LineTo(x2, y1); p.LineTo(x2, y2); p.LineTo(x1, y2); p.LineTo(x1, y1);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private static (PreparedContours Prepared, ContourEntryFeasibility Feasibility) Capture(Program clean,
|
||||
Vector location)
|
||||
{
|
||||
var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters());
|
||||
var material = LeadMaterialSnapshot.Capture(clean, location);
|
||||
var feasibility = new ContourEntryFeasibility(prepared, location, material, []);
|
||||
return (prepared, feasibility);
|
||||
}
|
||||
|
||||
private static bool At(Vector point, double x, double y) =>
|
||||
point.DistanceTo(new Vector(x, y)) < 1e-6;
|
||||
|
||||
private static Vector Pierce(PreparedContours prepared, ContourChoice choice)
|
||||
{
|
||||
var program = prepared.EmitCandidateForValidation(choice);
|
||||
var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, default);
|
||||
var first = execution.Motions.First(m => !m.Rapid);
|
||||
return first.Start ?? first.End;
|
||||
}
|
||||
|
||||
/// <summary>The owned compass candidate of a circle contour at an exact point.</summary>
|
||||
private static ContourChoice Compass(PreparedContours prepared, int contour, double x, double y,
|
||||
Vector lookAhead)
|
||||
{
|
||||
var match = prepared.AutomaticEntryCandidatesWithFallbacks(contour, lookAhead)
|
||||
.FirstOrDefault(c => At(c.Choice.Point, x, y));
|
||||
Assert.NotNull(match);
|
||||
return match.Choice;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoCircularHoles_FlowToTheSelectedOutsideEntry()
|
||||
{
|
||||
var clean = CirclesProgram((3, 3), (7, 3));
|
||||
var (prepared, feasibility) = Capture(clean, Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); // outside right edge
|
||||
Assert.True(At(perimeterEntry.Point, 10, 5));
|
||||
|
||||
// Route: arrival (0,5) -> left circle -> right circle -> the outside entry.
|
||||
var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
|
||||
var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival,
|
||||
Pierce(prepared, perimeterEntry));
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route,
|
||||
centres, Arrival, c => feasibility.Check(c.Choice));
|
||||
|
||||
Assert.True(proposal.IsPreferred, proposal.Reason);
|
||||
Assert.Equal(new[] { 0, 1 }, proposal.HoleChoices.Select(c => c.ContourOrdinal));
|
||||
|
||||
// The last hole's ACTUAL pierce flows to the perimeter pierce: strictly closer to
|
||||
// it than the opposite (west) compass pierce would be.
|
||||
var perimeterPierce = Pierce(prepared, perimeterEntry);
|
||||
var last = Pierce(prepared, proposal.HoleChoices[1]);
|
||||
var opposite = Pierce(prepared, Compass(prepared, 1, 6, 3, new Vector(6, 3)));
|
||||
Assert.True(last.DistanceTo(perimeterPierce) < opposite.DistanceTo(perimeterPierce),
|
||||
$"right hole pierce {last} does not flow to {perimeterPierce} (west pierce {opposite})");
|
||||
|
||||
// The first hole faces the SECOND hole's actual pierce — not the perimeter and not
|
||||
// the downstream nominal point: closer to it than the west compass pierce would be.
|
||||
var downstream = Pierce(prepared, proposal.HoleChoices[1]);
|
||||
var first = Pierce(prepared, proposal.HoleChoices[0]);
|
||||
var firstWest = Pierce(prepared, Compass(prepared, 0, 2, 3, new Vector(2, 3)));
|
||||
Assert.True(first.DistanceTo(downstream) < firstWest.DistanceTo(downstream),
|
||||
$"left hole pierce {first} faces away from downstream {downstream} (west {firstWest})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RectangularHoles_CornersOutrankMidpoints()
|
||||
{
|
||||
var clean = RectHolesProgram((3, 3, 5, 5), (7, 3, 9, 5));
|
||||
var (prepared, _) = Capture(clean, Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 4));
|
||||
var centres = new[] { new Vector(4, 4), new Vector(8, 4), new Vector(5, 5) };
|
||||
|
||||
// All-clear evaluation isolates pure ranking: a corner on a facing side wins over
|
||||
// any midpoint, walking backward from the perimeter entry.
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
|
||||
centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null));
|
||||
|
||||
Assert.True(proposal.IsPreferred, proposal.Reason);
|
||||
// Right hole faces the perimeter: a RIGHT-edge corner of the (7,3)-(9,5) hole,
|
||||
// never the (9,4) midpoint nor a left-edge corner.
|
||||
var right = proposal.HoleChoices[1].Point;
|
||||
Assert.True(At(right, 9, 3) || At(right, 9, 5),
|
||||
$"right hole entry {right} is not a right-facing corner");
|
||||
// Left hole faces the right hole's actual pierce (eastward): a right-edge corner of
|
||||
// the (3,3)-(5,5) hole, never its (5,4) midpoint.
|
||||
var left = proposal.HoleChoices[0].Point;
|
||||
Assert.True(At(left, 5, 3) || At(left, 5, 5),
|
||||
$"left hole entry {left} is not a right-facing corner");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockedIdealLead_TakesTheNextFeasibleCandidate()
|
||||
{
|
||||
var clean = CirclesProgram((3, 3), (7, 3));
|
||||
var (prepared, feasibility) = Capture(clean, Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
|
||||
var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
|
||||
|
||||
// The ideal east compass point of the right circle is refused: fall back to its
|
||||
// next-ranked candidate rather than fail or skip the hole.
|
||||
ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) =>
|
||||
c.Choice.ContourOrdinal == 1 && At(c.Choice.Point, 8, 3)
|
||||
? new(ContourFeasibilityStatus.Blocked, "test block")
|
||||
: feasibility.Check(c.Choice);
|
||||
var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, Pierce(prepared, perimeterEntry));
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival, Evaluate);
|
||||
|
||||
Assert.True(proposal.IsPreferred, proposal.Reason);
|
||||
Assert.Equal(1, proposal.HoleChoices[1].ContourOrdinal); // never skipped or swapped
|
||||
Assert.False(At(proposal.HoleChoices[1].Point, 8, 3), "the blocked candidate was used anyway");
|
||||
Assert.True(proposal.HoleChoices[1].Point.X >= 7.0 - 1e-9,
|
||||
$"fallback entry {proposal.HoleChoices[1].Point} abandoned the facing side entirely");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockedHole_GivesAnExplicitNoPreferenceFinding()
|
||||
{
|
||||
var clean = CirclesProgram((3, 3), (7, 3));
|
||||
var (prepared, _) = Capture(clean, Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
|
||||
var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
|
||||
|
||||
ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) =>
|
||||
c.Choice.ContourOrdinal == 0
|
||||
? new(ContourFeasibilityStatus.Blocked, "nothing fits")
|
||||
: new(ContourFeasibilityStatus.Clear, null);
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
|
||||
centres, Arrival, Evaluate);
|
||||
|
||||
Assert.False(proposal.IsPreferred);
|
||||
Assert.Empty(proposal.HoleChoices);
|
||||
Assert.Equal(0, proposal.BlockedContour);
|
||||
Assert.Contains("no preferred lead-feasible entry", proposal.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryHoleExactlyOnceInRouteOrder()
|
||||
{
|
||||
var clean = CirclesProgram((2.5, 3), (5, 3), (7.5, 3));
|
||||
var (prepared, feasibility) = Capture(clean, Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(3, 2, new Vector(10, 5)); // perimeter is last
|
||||
var centres = new[]
|
||||
{
|
||||
new Vector(2.5, 3), new Vector(5, 3), new Vector(7.5, 3), new Vector(5, 5),
|
||||
};
|
||||
var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2 }, centres, Arrival, Pierce(prepared, perimeterEntry));
|
||||
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival,
|
||||
c => feasibility.Check(c.Choice));
|
||||
|
||||
Assert.True(proposal.IsPreferred, proposal.Reason);
|
||||
Assert.Equal(route, proposal.HoleChoices.Select(c => c.ContourOrdinal));
|
||||
Assert.Equal(route.Count, proposal.HoleChoices.Select(c => c.ContourOrdinal).Distinct().Count());
|
||||
Assert.All(proposal.HoleChoices, c => Assert.NotEqual(3, c.ContourOrdinal)); // never the perimeter
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChainingIsDeterministic()
|
||||
{
|
||||
// Whatever native rounding a nominal circle candidate emits, backward chaining must
|
||||
// reproduce the same earlier-hole choice on a second run: the chain is a pure
|
||||
// function of the ACTUAL pierce, not of iteration order or object identity.
|
||||
var clean = CirclesProgram((3, 3), (7.4, 4.6));
|
||||
var (prepared, feasibility) = Capture(clean, Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
|
||||
var centres = new[] { new Vector(3, 3), new Vector(7.4, 4.6), new Vector(5, 5) };
|
||||
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
|
||||
centres, Arrival, c => feasibility.Check(c.Choice));
|
||||
var again = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
|
||||
centres, Arrival, c => feasibility.Check(c.Choice));
|
||||
|
||||
Assert.True(proposal.IsPreferred, proposal.Reason);
|
||||
Assert.Equal(proposal.HoleChoices.Select(c => c.Point), again.HoleChoices.Select(c => c.Point));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForeignPerimeterChoiceIsRefused()
|
||||
{
|
||||
var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero);
|
||||
var (other, _) = Capture(ExplicitContourTests.Square(false), Vector.Zero);
|
||||
var foreign = other.Entry(0, 0, new Vector(0, 5));
|
||||
|
||||
Assert.Throws<ArgumentException>(() => PreferredContourEntries.TryPlan(prepared, foreign,
|
||||
new[] { 0 }, new[] { new Vector(3, 3), new Vector(7, 3) }, Arrival,
|
||||
c => feasibility.Check(c.Choice)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationPropagates()
|
||||
{
|
||||
var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => PreferredContourEntries.TryPlan(
|
||||
prepared, perimeterEntry, new[] { 0, 1 },
|
||||
new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }, Arrival,
|
||||
c => feasibility.Check(c.Choice), cancelled.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SourceProgramAndChoicesAreUntouched()
|
||||
{
|
||||
var clean = CirclesProgram((3, 3), (7, 3));
|
||||
var before = ExplicitContourTests.Fingerprint(clean);
|
||||
var (prepared, feasibility) = Capture(clean, Vector.Zero);
|
||||
var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
|
||||
var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
|
||||
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
|
||||
centres, Arrival, c => feasibility.Check(c.Choice));
|
||||
|
||||
Assert.True(proposal.IsPreferred, proposal.Reason);
|
||||
Assert.Equal(before, ExplicitContourTests.Fingerprint(clean)); // caller program untouched
|
||||
Assert.All(proposal.HoleChoices, c => Assert.True(ReferenceEquals(c.Owner, prepared)));
|
||||
Assert.NotSame(prepared.EmitCandidateForValidation(proposal.HoleChoices[0]),
|
||||
prepared.EmitCandidateForValidation(proposal.HoleChoices[0])); // fresh programs
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user