mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 13:24:12 -04:00
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:
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))}";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user