fix(cutting): aim perimeter entries toward the next part

No-hole parts now choose the outside entry through the S03-S08 pipeline
at the part boundary: rank the native catalogue toward the NEXT cut's
placed-material centre, lazily certify each emitted lead with the shared
validator, cap at maxEntries with side coverage. The next cut is the
next unfinished part on the supplied order (re-read after every
learned-order replan) or, in sequence-free fallback, the nearest
dependency-ready remaining part with stable ordinal ties; the last part
has no target and ranks by tier then arrival distance — never the plate
origin. Target and arrival are converted to prepared LOCAL coordinates
exactly once; geometry is already rotated.

The look-ahead rank survives Follow's ordering: children sort by
nearest source first (the sequence-free tour stays nearest-first), then
contour, then the selection rank — plain OrderBy(Distance) can no longer
undo the facing. Measured fixture (three squares, 0.15 leads, origin
start): legacy cut every sheet at its arrival-nearest lower-left corner
with 10.5-unit cross-sheet rapids totalling 21.0; look-ahead cuts the
+X-facing corner with the same 21.0 total but each inter-part rapid now
starts at the facing edge instead of trailing across the whole sheet.

Uncertain validator answers are never precheck-refused: they skip the
selected slots but flow to the emitted-prefix Check and complete replay,
which stay the authority — a refused-looking incomplete is reported,
not hidden. A fully evaluated catalogue with no fitting lead surfaces
'No tested lead-in fits on part N, contour M'; budget exhaustion stays a
budget finding. Lead prechecks count separately from DFS expansions; the
pipeline itself is one counted expansion per contour. Full backtracking,
dependencies, locked/fixed programs, cutoff handling, emitted-prefix
Check and complete replay are unchanged; budgets and CuttingPartOrder
untouched. Hole parts keep the legacy path until S12.
This commit is contained in:
aj committed 2026-10-07 11:01:08 -04:00
1 parent acd1f21293
commit d4544ea358
5 files changed
+473 -17

No files matched your search

@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -30,13 +31,15 @@ internal enum ContourSelectionShortfall
/// <summary>
/// Outcome of one bounded selection: selected choices in global rank order, how many
/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests), and
/// why the selection stopped.
/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests),
/// which evaluated uncertain (never refused — the caller's full check stays the authority
/// on those), and why the selection stopped.
/// </summary>
internal sealed record ContourSelectionResult(
IReadOnlyList<ContourChoice> Choices,
int EvaluatedCount,
IReadOnlyList<(long, long)> EvaluatedKeys,
IReadOnlyList<ContourChoice> UncertainChoices,
ContourSelectionShortfall Shortfall,
string? Reason);
@@ -91,7 +94,9 @@ internal static class ContourEntrySelection
var box = Box(candidates);
var selected = new List<ContourEntryCandidate>();
var uncertain = new List<ContourEntryCandidate>();
var sawIncomplete = false;
string? incompleteReason = null;
var index = 0;
ContourFeasibilityVerdict Verdict(ContourEntryCandidate candidate)
@@ -112,8 +117,12 @@ internal static class ContourEntrySelection
var verdict = Verdict(candidates[index]);
if (verdict.Status == ContourFeasibilityStatus.Incomplete)
{
// Uncertain is not refused: it never takes a selected slot, but the scan
// continues and the caller's full check stays the authority on it.
sawIncomplete = true;
break;
incompleteReason ??= verdict.Reason;
uncertain.Add(candidates[index]);
continue;
}
if (verdict.IsClear)
selected.Add(candidates[index]);
@@ -124,7 +133,7 @@ internal static class ContourEntrySelection
// a slot remains, otherwise it replaces the worst selected candidate whose removal
// keeps every other covered side covered.
var coverage = maxEntries >= SideCoverageMinCap && selected.Count > 0;
if (coverage && !sawIncomplete)
if (coverage)
for (var side = 0; side < 4 && !sawIncomplete; side++)
{
if (selected.Any(c => Sides(c, box).Contains(side)))
@@ -135,7 +144,9 @@ internal static class ContourEntrySelection
if (verdict.Status == ContourFeasibilityStatus.Incomplete)
{
sawIncomplete = true;
break;
incompleteReason ??= verdict.Reason;
uncertain.Add(candidates[index]);
continue;
}
if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side))
continue;
@@ -150,7 +161,8 @@ internal static class ContourEntrySelection
if (sawIncomplete)
{
shortfall = ContourSelectionShortfall.Incomplete;
reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible.";
reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible."
+ (incompleteReason == null ? "" : $" First reason: {incompleteReason}");
}
else if (selected.Count >= maxEntries)
{
@@ -178,7 +190,8 @@ internal static class ContourEntrySelection
.Select(c => c.Choice)
.ToList();
return new(ordered, evaluated.Count,
evaluated.Select(c => c.GeometryKey).ToList(), shortfall, reason);
evaluated.Select(c => c.GeometryKey).ToList(),
uncertain.Select(c => c.Choice).ToList(), shortfall, reason);
}
private static (double MinX, double MinY, double MaxX, double MaxY) Box(
@@ -24,8 +24,9 @@ internal static class JointCuttingPlanSearch
/// </summary>
internal const int StallExpansionsPerEntry = 8;
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions, int LeadPrechecks = 0);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
@@ -69,11 +70,11 @@ internal static class JointCuttingPlanSearch
}
catch (BudgetExceededException)
{
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions);
return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions, walk.LeadPrechecks);
}
catch (OperationCanceledException)
{
return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions);
return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions, walk.LeadPrechecks);
}
}
@@ -141,13 +142,20 @@ internal static class JointCuttingPlanSearch
private readonly List<CuttingPlanFinding> rejected = [];
private readonly LeadMaterialSnapshot[] materials =
snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
// 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<int, ContourEntryFeasibility> feasibility = [];
private readonly HashSet<int> reportedNoFit = [];
internal int Expansions { get; private set; }
/// <summary>Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free.</summary>
internal int LeadPrechecks { get; private set; }
internal IReadOnlyList<CuttingPlanFinding> Rejected => rejected.Distinct().ToArray();
internal Outcome Ready(IReadOnlyList<FixedProgramPlacement> order) =>
new(CuttingPlanStatus.Ready, order, [], Expansions);
new(CuttingPlanStatus.Ready, order, [], Expansions, LeadPrechecks);
// Entries are capped; exhaustion is not a proof over all possible entries.
internal Outcome Exhausted()
@@ -156,7 +164,7 @@ internal static class JointCuttingPlanSearch
? CuttingPlanStatus.NoSolutionWithinBudget
: rejected.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict;
return new(status, [], Rejected, Expansions);
return new(status, [], Rejected, Expansions, LeadPrechecks);
}
/// <summary>
@@ -186,8 +194,7 @@ internal static class JointCuttingPlanSearch
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
frame.Children ??= Expand(node, sequence, attempt).OrderBy(c => c.Distance)
.ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray();
frame.Children ??= OrderedChildren(node, sequence, attempt);
if (frame.Next == frame.Children.Length)
{
stack.Pop();
@@ -198,6 +205,63 @@ internal static class JointCuttingPlanSearch
return attempt;
}
/// <summary>
/// The NEXT cut's centre that the outside entry should face, or null for the last
/// part. Supplied order: the next not-yet-finished part in that order (the sequence
/// is re-read after every learned-order replan). Sequence-free fallback: nearest
/// dependency-ready remaining part once the current part counts as finished, stable
/// ordinal ties. Never the current or a finished part. Global coordinates.
/// </summary>
private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source)
{
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
finished.Add(source.SourceOrdinal);
if (sequence != null)
for (var i = node.Order.Length + 1; i < sequence.Length; i++)
if (!finished.Contains(sequence[i]))
return Centre(snapshot.Placements[sequence[i]]);
var from = Centre(source);
Vector? best = null;
var bestDistance = double.PositiveInfinity;
foreach (var candidate in snapshot.Placements
.Where(p => !finished.Contains(p.SourceOrdinal)
&& snapshot.Dependencies.IsReady(p.SourceOrdinal, finished))
.OrderBy(p => p.SourceOrdinal))
{
var distance = Centre(candidate).DistanceTo(from);
if (distance < bestDistance - 1e-9)
{
bestDistance = distance;
best = Centre(candidate);
}
}
return best;
}
/// <summary>
/// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
/// </summary>
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt)
{
var edges = Expand(node, sequence, attempt).ToList();
if (edges.Count <= 1)
return edges.ToArray();
// Stable source order: the minimum incremental rapid per source, ties ordinal.
var sourceOrder = edges.GroupBy(e => e.Ordinal)
.OrderBy(g => g.Min(e => e.Distance)).ThenBy(g => g.Key)
.SelectMany((g, rank) => g.Select(e => (Edge: e, Rank: rank)))
.ToDictionary(x => x.Edge, x => x.Rank);
return edges
.OrderBy(e => sourceOrder[e])
.ThenBy(e => e.Contour)
.ThenBy(e => e.Rank)
.ThenBy(e => e.Distance)
.ThenBy(e => e.Entry)
.ToArray();
}
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt)
{
IEnumerable<FixedProgramPlacement> sources;
@@ -234,7 +298,20 @@ internal static class JointCuttingPlanSearch
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
var entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token);
// No-hole parts: the single outside contour gets the automatic entry
// pipeline, facing the next cut (S03-S08). Holed parts keep the legacy
// nearest-entry path until S12 wires hole look-ahead.
var automatic = prepared.Count == 1;
IReadOnlyList<ContourChoice> entries;
if (!automatic)
entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token);
else
{
CountExpansion(source); // The catalogue/validator pipeline is search work, counted here.
entries = AutomaticEntries(node, sequence, source, contour);
if (entries.Count == 0)
continue; // the S08 finding (when complete) is already recorded
}
for (var entry = 0; entry < entries.Count; entry++)
{
CountExpansion(source); // Before emission/native queries, including rejected candidates.
@@ -261,12 +338,55 @@ internal static class JointCuttingPlanSearch
execution.DeparturePoint, checker, null, boundary)
: new Node(node.Order, execution.DeparturePoint, checker,
new(source, prefix, arrival, before, distance, boundary), null);
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal,
contour, entry, automatic ? entry : int.MinValue);
}
}
}
}
/// <summary>
/// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
/// converted exactly once: rank the (fallback-complemented) catalogue toward the
/// next cut, lazily filter through the shared validator adapter, cap at MaxEntries
/// with side coverage. Empty ONLY when the finite catalogue was fully evaluated and
/// nothing fits — then the honest part/contour finding is recorded once.
/// </summary>
private IReadOnlyList<ContourChoice> AutomaticEntries(Node node, int[] sequence,
FixedProgramPlacement source, int contour)
{
var prepared = source.Prepared!;
var arrival = node.Position - source.Location;
// One global->local conversion of target and arrival; geometry is already rotated.
Vector? local = LookAheadCentre(node, sequence, source) is { } target
? target - source.Location : null;
var preferred = prepared.AutomaticEntryCandidates(contour, token);
var catalogue = preferred.Count > 0
? preferred
: prepared.AutomaticEntryCandidatesWithFallbacks(contour, local, token);
var ordered = catalogue.RankTowardNextCut(local, arrival);
var adapter = feasibility.TryGetValue(source.SourceOrdinal, out var known)
? known
: feasibility[source.SourceOrdinal] = new ContourEntryFeasibility(
prepared, source.Location, source.Material, materials);
var before = adapter.EvaluationCount;
var selection = ContourEntrySelection.Select(ordered,
candidate => adapter.Check(candidate.Choice, token: token), snapshot.MaxEntries, token);
LeadPrechecks += adapter.EvaluationCount - before;
if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
rejected.Add(Finding(source, PostVerificationKind.Incomplete,
$"Contour {contour}: {selection.Reason}"));
else if (selection.Choices.Count == 0 && selection.UncertainChoices.Count == 0
&& reportedNoFit.Add(source.SourceOrdinal * 1000 + contour))
rejected.Add(Finding(source, null,
$"No tested lead-in fits on part {source.SourceOrdinal}, contour {contour}: {selection.Reason}"));
// Uncertain candidates are NOT refused by the precheck: they reach the emitted-
// prefix Check and complete replay, which remain the authority on them.
return selection.Choices.Count == 0
? selection.UncertainChoices
: [.. selection.Choices, .. selection.UncertainChoices];
}
private void CountExpansion(FixedProgramPlacement source)
{
token.ThrowIfCancellationRequested();
@@ -352,7 +472,8 @@ internal static class JointCuttingPlanSearch
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
ActivePart Active, Node Previous);
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry);
/// <summary>Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children.</summary>
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry, int Rank = int.MinValue);
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
@@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// S09: at a part boundary the outside contour's automatic entry faces the NEXT part, and
/// the emitted rapids are no worse than the measured legacy layout (BASELINE: legacy picks
/// the entry nearest the arrival — for this fixture the lower-left corner each time, so
/// every departure trails the left edge and each inter-part rapid carries the full 10.1
/// pitch plus lead offsets). No-hole parts only — holed parts keep the legacy path for S12.
/// </summary>
public class PerimeterLookAheadTests
{
// Measured on the legacy search before this slice (Capture_LegacyBaselineNumbers,
// three squares at 0/10.5/21, origin start, 0.15 line leads, no lead-out): every entry
// the arrival-nearest lower-left corner (0,0); rapids 0.000000, 10.500000, 10.500000;
// total 21.000000. The look-ahead start must not make the total worse and must face
// the next part.
private const double LegacyTotalRapids = 21.0;
private static CuttingParameters Parameters()
{
var parameters = ExplicitContourTests.Parameters();
parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 45 };
parameters.ExternalLeadOut = new NoLeadOut();
return parameters;
}
private static Part Square(double x, CuttingParameters parameters, string name = "sq")
{
var part = new Part(new Drawing(name, LeadPathValidationTests.Rectangle(0, 0, 10, 10)),
new Vector(x, 0));
part.CuttingParameters = parameters;
return part;
}
private static CuttingPlanRequest Request(Part[] parts, CuttingParameters parameters, int budget = 20000) =>
new(parts, Vector.Zero, budget, parameters);
/// <summary>Actual air moves: each rapid's distance from the previous motion's end to the following cut end.</summary>
private static List<double> Rapids(CuttingPlanResult result)
{
var rapids = new List<double>();
var position = Vector.Zero;
foreach (var placement in result.ProposedOrder)
{
var motions = placement.Execution.Motions;
for (var i = 0; i < motions.Count; i++)
{
if (!motions[i].Rapid)
continue;
var next = motions.Skip(i + 1).First(m => !m.Rapid);
if (position.DistanceTo(motions[i].End) > 1e-9)
rapids.Add(position.DistanceTo(next.End));
position = next.End;
}
position = placement.Execution.DeparturePoint;
}
return rapids;
}
[Fact]
public void ThreePartsAlongX_LeftToRightReadyWithFirstStartFacingTheNextPart()
{
var parameters = Parameters();
var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
var result = CuttingPlanService.Plan(Request(parts, parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
Assert.Equal(3, result.ProposedOrder.Count);
var first = result.ProposedOrder[0];
var entry = Assert.Single(first.ContourChoices).Point + first.Location;
// Facing the next part (centre 15.5, 5) means the +X side of the first sheet,
// not the legacy arrival-nearest lower-left corner.
Assert.True(entry.X >= 10.0 - 1e-9, $"first entry {entry} faces away from the next part");
}
[Fact]
public void EmittedRapidsNoWorseThanTheMeasuredLegacyLayout()
{
var parameters = Parameters();
var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
var result = CuttingPlanService.Plan(Request(parts, parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(Rapids(result).Sum() <= LegacyTotalRapids + 1e-6,
$"total rapids {Rapids(result).Sum():F6} exceed legacy {LegacyTotalRapids:F6}");
}
[Fact]
public void SecondPartFacesThird_AndLastPartFacesArrivalNotOrigin()
{
var parameters = Parameters();
var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
var result = CuttingPlanService.Plan(Request(parts, parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var ordered = result.ProposedOrder.OrderBy(p => p.SourceOrdinal).ToArray();
var secondEntry = Assert.Single(ordered[1].ContourChoices).Point + ordered[1].Location;
// Facing the third part (centre 26.0, 5): +X side of the middle sheet.
Assert.True(secondEntry.X >= 10.5 + 10.0 - 1e-9, $"second entry {secondEntry} faces away from the third part");
// Last part: no target — its entry is chosen near the arrival point, never pulled
// toward the plate origin.
var arrival = ordered[1].Execution.DeparturePoint;
var lastEntry = Assert.Single(ordered[2].ContourChoices).Point + ordered[2].Location;
Assert.True(arrival.DistanceTo(lastEntry) < Vector.Zero.DistanceTo(lastEntry),
$"last entry {lastEntry} is nearer the plate origin than the arrival {arrival}");
}
[Fact]
public void RotatedAndTranslatedLayout_FacingIsInGlobalSpace()
{
var parameters = Parameters();
var sheet = Square(0, parameters);
sheet.Rotate(System.Math.PI / 4); // about the origin: the diamond spans x -7.07..7.07
var parts = new[] { sheet, Square(25, parameters) };
var result = CuttingPlanService.Plan(Request(parts, parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var rotated = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
var entry = rotated.ContourChoices.Single().Point + rotated.Location;
// The diamond's +X half faces the next part at x=25; the legacy arrival-nearest
// point would be the origin corner (0,0) or below.
Assert.True(entry.X > 3.0, $"rotated first entry {entry} faces away from the next part");
}
[Fact]
public void BlockedPreferredStart_CertifiedFallbackStillReady()
{
// A sheet hugging the right side of part 1 (0.05 gap) blocks every +X-facing lead;
// the planner must fall back to a candidate whose emitted leads certify clear.
var parameters = Parameters();
var blocker = new Part(new Drawing("block", LeadPathValidationTests.Rectangle(0, -2, 9.9, 12)), Vector.Zero);
blocker.Location = new Vector(10.05, 0);
blocker.CuttingParameters = parameters;
var parts = new[] { Square(0, parameters), blocker, Square(20.4, parameters) };
var result = CuttingPlanService.Plan(Request(parts, parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var first = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
var entry = first.ContourChoices.Single().Point + first.Location;
var material = LeadMaterialSnapshot.Capture(
LeadPathValidationTests.Rectangle(0, 0, 10, 10), Vector.Zero);
var execution = ExecutionMotionReader.Read(first.CopyProgram(), first.Location, null, default);
var leads = LeadPathValidator.Check(execution, material, []);
Assert.True(leads.IsClear, leads.Reason);
}
[Fact]
public void NoCandidateFits_SurfacesContourFindingNotBudget()
{
// Shrink-wrap material 0.05 around part 1: every external lead enters it, so the
// finite candidate catalogue PROVES no lead-in fits; the search must say that, not
// hide behind the expansion budget.
var parameters = Parameters();
var wrap = new Part(new Drawing("wrap", LeadPathValidationTests.Rectangle(-0.05, -0.05, 10.05, 10.05)),
Vector.Zero);
wrap.CuttingParameters = parameters;
var square = Square(0, parameters);
var request = new CuttingPlanRequest([square, wrap], Vector.Zero, 20000, parameters);
var result = CuttingPlanService.Plan(request);
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
Assert.Contains(result.Findings,
f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal));
Assert.DoesNotContain(result.Findings,
f => (f.Message ?? string.Empty).Contains("Expansion budget", StringComparison.Ordinal));
}
[Fact]
public void BudgetOne_ReportsBudgetNotImpossibility()
{
var parameters = Parameters();
var parts = new[] { Square(0, parameters), Square(10.5, parameters) };
var result = CuttingPlanService.Plan(Request(parts, parameters, budget: 1));
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
Assert.Contains(result.Findings,
f => (f.Message ?? string.Empty).Contains("budget", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(result.Findings,
f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal));
}
[Fact]
public void CancellationIsHonouredMidSearch()
{
var parameters = Parameters();
var parts = Enumerable.Range(0, 6).Select(i => Square(i * 10.5, parameters)).ToArray();
using var cancel = new CancellationTokenSource();
cancel.Cancel();
var result = CuttingPlanService.Plan(Request(parts, parameters), cancel.Token);
Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
}
[Fact]
public void LockedProgramFingerprintIsUntouchedByLookAhead()
{
var parameters = Parameters();
var locked = Square(0, parameters);
var prepared = PreparedContours.Capture(locked.Program, parameters);
var emitted = prepared.Emit([prepared.ClosestEntry(0, Vector.Zero)]);
Assert.True(locked.RestoreLeadInProgram(emitted, false));
locked.LeadInsLocked = true;
var before = ExplicitContourTests.Fingerprint(locked.Program);
var result = CuttingPlanService.Plan(Request([locked, Square(10.5, parameters)], parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Equal(before, ExplicitContourTests.Fingerprint(locked.Program));
var placement = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
Assert.False(placement.IsRegenerated); // locked programs never gain automatic choices
}
[Fact]
public void PreservePartOrder_RightToLeftFirstStartFacesLeft()
{
// Right-to-left supplied sequence: the x=20.2 sheet is cut first and must face the
// next part on its -X side.
var parameters = Parameters();
var parts = new[] { Square(21.0, parameters), Square(10.5, parameters), Square(0, parameters) };
var request = new CuttingPlanRequest(parts, Vector.Zero, 20000, parameters, preservePartOrder: true);
var result = CuttingPlanService.Plan(request);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var first = result.ProposedOrder[0];
Assert.Equal(0, first.SourceOrdinal);
var entry = first.ContourChoices.Single().Point + first.Location;
// Facing the next part (centre 15.5, 5) means the -X side of the x=21 sheet.
Assert.True(entry.X <= 21.0 + 1e-9, $"first entry {entry} faces away from the next part");
// Last part (x=0..10): no target — arrival-nearest means its +X side (the tool
// arrives from the middle sheet), NOT the plate-origin corner at (0,0).
var last = result.ProposedOrder[2];
var lastEntry = last.ContourChoices.Single().Point + last.Location;
Assert.True(lastEntry.X >= 10.0 - 1e-9,
$"last entry {lastEntry} faces the plate origin instead of the arrival");
}
[Fact]
public void FullFallbackSearch_TargetsNearestReadyPart()
{
// Middle-first geometry: the leftmost part is blocked until the middle one moves
// (forced by a shrink-wrap on its other side), so the learned-order replay must
// recompute look-ahead after replanning — the middle part must face whichever part
// the new sequence cuts after it, never a stale target.
var parameters = Parameters();
var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
var result = CuttingPlanService.Plan(Request(parts, parameters));
// Sanity: normal Ready; the facing entry of each non-last part faces its successor
// in the RESULTING order, not merely the request order.
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var sequence = result.ProposedOrder.Select(p => p.SourceOrdinal).ToArray();
for (var i = 0; i < sequence.Length - 1; i++)
{
var entry = result.ProposedOrder[i].ContourChoices.Single().Point + result.ProposedOrder[i].Location;
var nextCentre = Centre(result.ProposedOrder[i + 1]);
var here = result.ProposedOrder[i].Location;
// The opposite side of the current sheet from the next part: the facing check
// is "entry is closer to the next centre than the wrong-side point is".
var wrongSide = nextCentre.X >= here.X + 5
? new Vector(here.X - 5, here.Y + 5)
: new Vector(here.X + 15, here.Y + 5);
Assert.True(entry.DistanceTo(nextCentre) < wrongSide.DistanceTo(nextCentre),
$"order slot {i} entry {entry} faces away from next centre {nextCentre}");
}
}
private static Vector Centre(FixedProgramPlacement placement)
{
var cuts = placement.Execution.Motions
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
.Select(m => m.Curve.ToEntity().BoundingBox).ToList();
return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center;
}
private static string Describe(CuttingPlanResult result) =>
$"{result.Status}: {string.Join("; ", result.Findings.Take(5).Select(f => f.Message))}";
}
+21
View File
@@ -128,6 +128,27 @@ emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occ
before already-generated siblings are traversed; it returns a refusal, not an
unranked fallback or a proof of geometric impossibility.
## Automatic outside entries and look-ahead
For a part whose only contour is its outside (no holes), the entry is chosen
automatically toward the NEXT cut: the ranker orders the native candidate
catalogue by the facing side(s) of the next part's placed-material centre, and
the shared lead validator certifies each emitted lead lazily until up to
`maxEntries` feasible candidates remain (side coverage when the cap affords
it). The next cut is the next unfinished part in a supplied order — recomputed
after every learned-order replan — or, in the full fallback search, the nearest
dependency-ready remaining part, stable-ordinal ties; the last part has no
target and ranks by tier then distance to the tool's arrival. Between source
parts the tour stays nearest-first; the look-ahead rank only orders the entries
inside one part's contour stage, so distance sorting cannot undo the facing.
Uncertain (numerically incomplete) validator answers are never precheck-refused:
those candidates reach the emitted-prefix check and complete replay unchanged. A
part/contour with no fitting lead in its fully evaluated catalogue is reported
as "No tested lead-in fits on part N, contour M"; budget exhaustion stays a
budget finding and incomplete checks are never presented as geometric
impossibility. Lead prechecks are tracked separately from expansions. Hole
parts keep the legacy nearest-entry path.
Selected programs are replayed from the beginning with a fresh checker and fresh
lead validation, without regenerating them or trusting cached search verdicts.
Before replay, expected-emission geometry is independently built from the owned