diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs index 6c192fb..7ccd615 100644 --- a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs +++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Threading; diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs index a1964bb..7945678 100644 --- a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs +++ b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Linq; @@ -30,13 +31,15 @@ internal enum ContourSelectionShortfall /// /// 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. /// internal sealed record ContourSelectionResult( IReadOnlyList Choices, int EvaluatedCount, IReadOnlyList<(long, long)> EvaluatedKeys, + IReadOnlyList UncertainChoices, ContourSelectionShortfall Shortfall, string? Reason); @@ -91,7 +94,9 @@ internal static class ContourEntrySelection var box = Box(candidates); var selected = new List(); + var uncertain = new List(); 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( diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 1ee9e2b..cf69414 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -24,8 +24,9 @@ internal static class JointCuttingPlanSearch /// internal const int StallExpansionsPerEntry = 8; + /// LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions. internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order, - IReadOnlyList Findings, int Expansions); + IReadOnlyList 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 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 feasibility = []; + private readonly HashSet reportedNoFit = []; internal int Expansions { get; private set; } + /// Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free. + internal int LeadPrechecks { get; private set; } + internal IReadOnlyList Rejected => rejected.Distinct().ToArray(); internal Outcome Ready(IReadOnlyList 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); } /// @@ -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; } + /// + /// 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. + /// + 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; + } + + /// + /// 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. + /// + 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 Expand(Node node, int[] sequence, Attempt attempt) { IEnumerable 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 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); } } } } + /// + /// 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. + /// + private IReadOnlyList 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 /// A search state; Previous links a whole-part boundary to the boundary before it. 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); + /// Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children. + 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; diff --git a/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs new file mode 100644 index 0000000..cd00268 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs @@ -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; + +/// +/// 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. +/// +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); + + /// Actual air moves: each rapid's distance from the previous motion's end to the following cut end. + private static List Rapids(CuttingPlanResult result) + { + var rapids = new List(); + 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))}"; +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 3d52e2f..ffb4e70 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -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