mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-13 00:11:55 -04:00
fix(cutting): prefer hole entries facing the next contour
This commit is contained in:
1 parent
76f73f7a87
commit
414e9b4e75
4 files changed
+372
-34
No files matched your search
@@ -493,6 +493,23 @@ public sealed class PreparedContours
|
||||
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
|
||||
? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal];
|
||||
|
||||
/// <summary>
|
||||
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
|
||||
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
|
||||
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<Vector?> HoleCentres(CancellationToken token = default)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var centres = new Vector?[Count];
|
||||
for (var contour = 0; contour < PerimeterOrdinal; contour++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
centres[contour] = GetShape(contour).BoundingBox.Center;
|
||||
}
|
||||
return centres;
|
||||
}
|
||||
|
||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||
|
||||
|
||||
@@ -255,6 +255,7 @@ internal static class JointCuttingPlanSearch
|
||||
.ToDictionary(x => x.Edge, x => x.Rank);
|
||||
return edges
|
||||
.OrderBy(e => sourceOrder[e])
|
||||
.ThenBy(e => e.ContourRank)
|
||||
.ThenBy(e => e.Contour)
|
||||
.ThenBy(e => e.Rank)
|
||||
.ThenBy(e => e.Distance)
|
||||
@@ -289,6 +290,25 @@ internal static class JointCuttingPlanSearch
|
||||
continue;
|
||||
}
|
||||
var prepared = source.Prepared;
|
||||
if (prepared.Count > 1 && node.Active == null)
|
||||
{
|
||||
// Selecting an endpoint does not cut it. Each endpoint owns a separate
|
||||
// branch, whose holes are still emitted first and checked normally.
|
||||
CountExpansion(source);
|
||||
var perimeters = AutomaticEntries(node, sequence, source, prepared.PerimeterOrdinal);
|
||||
for (var index = 0; index < perimeters.Count; index++)
|
||||
{
|
||||
CountExpansion(source);
|
||||
var state = new ActivePart(source, [], node.Position, node.Checker, 0, node,
|
||||
perimeters[index]);
|
||||
yield return new(new(node.Order, node.Position, node.Checker, state, null),
|
||||
Centre(source).DistanceTo(node.Position), source.SourceOrdinal, -1, index, index);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
var preference = node.Active?.Preference;
|
||||
if (node.Active?.Perimeter is { } outside && preference == null)
|
||||
preference = PlanHoles(node.Active, outside);
|
||||
var choices = node.Active?.Choices ?? [];
|
||||
var arrival = node.Active?.Arrival ?? node.Position;
|
||||
var before = node.Active?.Before ?? node.Checker;
|
||||
@@ -298,20 +318,34 @@ internal static class JointCuttingPlanSearch
|
||||
foreach (var contour in contours)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// 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);
|
||||
var contourRank = 0;
|
||||
if (node.Active?.Perimeter is { } perimeter)
|
||||
{
|
||||
if (contour == prepared.PerimeterOrdinal)
|
||||
entries = [perimeter];
|
||||
else
|
||||
{
|
||||
contourRank = Array.IndexOf(preference.Route, contour);
|
||||
var downstream = preference.Route.Skip(contourRank + 1)
|
||||
.FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
|
||||
var target = downstream < 0 ? preference.PerimeterPierce
|
||||
: preference.Pierces[downstream];
|
||||
entries = SelectEntries(source, contour, target, node.Position - source.Location);
|
||||
// Preference only reorders retained candidates. Neither a failed
|
||||
// preferred entry nor an alternate hole order prunes this branch.
|
||||
if (preference.Entries.TryGetValue(contour, out var preferred))
|
||||
entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point)
|
||||
<= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
CountExpansion(source); // The catalogue/validator pipeline is search work, counted here.
|
||||
CountExpansion(source);
|
||||
entries = AutomaticEntries(node, sequence, source, contour);
|
||||
if (entries.Count == 0)
|
||||
continue; // the S08 finding (when complete) is already recorded
|
||||
}
|
||||
if (entries.Count == 0)
|
||||
continue;
|
||||
for (var entry = 0; entry < entries.Count; entry++)
|
||||
{
|
||||
CountExpansion(source); // Before emission/native queries, including rejected candidates.
|
||||
@@ -337,9 +371,10 @@ internal static class JointCuttingPlanSearch
|
||||
? new Node([.. node.Order, source.Propose(program, execution, prefix, token)],
|
||||
execution.DeparturePoint, checker, null, boundary)
|
||||
: new Node(node.Order, execution.DeparturePoint, checker,
|
||||
new(source, prefix, arrival, before, distance, boundary), null);
|
||||
new(source, prefix, arrival, before, distance, boundary,
|
||||
node.Active?.Perimeter, preference), null);
|
||||
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal,
|
||||
contour, entry, automatic ? entry : int.MinValue);
|
||||
contour, entry, entry, contourRank);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -360,19 +395,31 @@ internal static class JointCuttingPlanSearch
|
||||
// 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
|
||||
return SelectEntries(source, contour, local, arrival);
|
||||
}
|
||||
|
||||
private ContourEntryFeasibility Adapter(FixedProgramPlacement source) =>
|
||||
feasibility.TryGetValue(source.SourceOrdinal, out var known) ? known
|
||||
: feasibility[source.SourceOrdinal] = new ContourEntryFeasibility(
|
||||
prepared, source.Location, source.Material, materials);
|
||||
source.Prepared, source.Location, source.Material, materials);
|
||||
|
||||
private ContourFeasibilityVerdict Evaluate(FixedProgramPlacement source, ContourEntryCandidate candidate)
|
||||
{
|
||||
CountExpansion(source);
|
||||
var adapter = Adapter(source);
|
||||
var before = adapter.EvaluationCount;
|
||||
var selection = ContourEntrySelection.Select(ordered,
|
||||
candidate => adapter.Check(candidate.Choice, token: token), snapshot.MaxEntries, token);
|
||||
var verdict = adapter.Check(candidate.Choice, token: token);
|
||||
LeadPrechecks += adapter.EvaluationCount - before;
|
||||
return verdict;
|
||||
}
|
||||
|
||||
private IReadOnlyList<ContourChoice> SelectEntries(FixedProgramPlacement source, int contour,
|
||||
Vector? target, Vector arrival)
|
||||
{
|
||||
var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token);
|
||||
var ordered = catalogue.RankTowardNextCut(target, arrival);
|
||||
var selection = ContourEntrySelection.Select(ordered,
|
||||
candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
|
||||
if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
|
||||
rejected.Add(Finding(source, PostVerificationKind.Incomplete,
|
||||
$"Contour {contour}: {selection.Reason}"));
|
||||
@@ -382,9 +429,46 @@ internal static class JointCuttingPlanSearch
|
||||
$"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];
|
||||
return selection.Choices.Concat(selection.UncertainChoices).Take(snapshot.MaxEntries).ToArray();
|
||||
}
|
||||
|
||||
private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter)
|
||||
{
|
||||
var source = active.Source;
|
||||
var prepared = source.Prepared;
|
||||
var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray();
|
||||
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
|
||||
var localArrival = active.Arrival - source.Location;
|
||||
var endpoint = perimeter.Point;
|
||||
var entries = new Dictionary<int, ContourChoice>();
|
||||
var pierces = holes.ToDictionary(h => h, h => centres[h]);
|
||||
var route = holes;
|
||||
try
|
||||
{
|
||||
CountExpansion(source);
|
||||
endpoint = PreferredContourEntries.Pierce(prepared, perimeter, token);
|
||||
route = CuttingHoleOrder.Plan(holes, centres, localArrival, endpoint, token).ToArray();
|
||||
var proposal = PreferredContourEntries.TryPlan(prepared, perimeter, route, centres,
|
||||
localArrival, candidate => Evaluate(source, candidate), token);
|
||||
if (proposal.IsPreferred)
|
||||
foreach (var choice in proposal.HoleChoices)
|
||||
{
|
||||
CountExpansion(source);
|
||||
entries.Add(choice.ContourOrdinal, choice);
|
||||
pierces[choice.ContourOrdinal] = PreferredContourEntries.Pierce(prepared, choice, token);
|
||||
}
|
||||
else
|
||||
rejected.Add(Finding(source, proposal.Shortfall == ContourSelectionShortfall.Incomplete
|
||||
? PostVerificationKind.Incomplete : null, proposal.Reason ?? "No preferred hole path."));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
|
||||
{
|
||||
// A proposal is not a gate. Unknown emissions still reach prefix/final
|
||||
// replay through retained candidates; never label an uncertain probe clear.
|
||||
rejected.Add(Finding(source, PostVerificationKind.Incomplete,
|
||||
$"Preferred hole path unavailable: {ex.Message}"));
|
||||
}
|
||||
return new(route, entries, pierces, endpoint);
|
||||
}
|
||||
|
||||
private void CountExpansion(FixedProgramPlacement source)
|
||||
@@ -467,13 +551,18 @@ internal static class JointCuttingPlanSearch
|
||||
}
|
||||
|
||||
private sealed class BudgetExceededException : Exception;
|
||||
// Every endpoint branch owns its preference; all retained entries/orders remain searchable.
|
||||
private sealed record HolePreference(int[] Route, IReadOnlyDictionary<int, ContourChoice> Entries,
|
||||
IReadOnlyDictionary<int, Vector> Pierces, Vector PerimeterPierce);
|
||||
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
|
||||
ReleasedContourState Before, double Distance, Node Boundary);
|
||||
ReleasedContourState Before, double Distance, Node Boundary,
|
||||
ContourChoice Perimeter = null, HolePreference Preference = null);
|
||||
/// <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);
|
||||
/// <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 record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry,
|
||||
int Rank = int.MinValue, int ContourRank = 0);
|
||||
private sealed class Frame(Node node)
|
||||
{
|
||||
internal Node Node { get; } = node;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class HoleLookAheadTests
|
||||
{
|
||||
private static readonly Vector Start = new(-2, 4);
|
||||
|
||||
internal static Program MixedHoles()
|
||||
{
|
||||
var clean = LeadPathValidationTests.Rectangle(0, 0, 12, 10);
|
||||
clean.MoveTo(3, 3);
|
||||
clean.LineTo(5, 3); clean.LineTo(5, 5); clean.LineTo(3, 5); clean.LineTo(3, 3);
|
||||
clean.MoveTo(9, 4);
|
||||
clean.ArcTo(9, 4, 8, 4, RotationType.CCW);
|
||||
return clean;
|
||||
}
|
||||
|
||||
internal static CuttingParameters Parameters()
|
||||
{
|
||||
var p = ExplicitContourTests.Parameters();
|
||||
p.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
|
||||
p.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
|
||||
p.PierceClearance = 0;
|
||||
return p;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedHolesFaceTheNextContourAndRectanglesUseFeasibleCorners()
|
||||
{
|
||||
var parameters = Parameters();
|
||||
var part = new Part(new Drawing("mixed holes", MixedHoles()));
|
||||
var next = new Part(new Drawing("next", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), new Vector(20, 2));
|
||||
var fingerprint = ExplicitContourTests.Fingerprint(part.Program);
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, next], Start,
|
||||
confirmedParameters: parameters, preservePartOrder: true));
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
var proposal = result.ProposedOrder[0];
|
||||
Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal));
|
||||
var rectangle = proposal.ContourChoices[0].Point;
|
||||
Assert.True(rectangle.X > 4.9 && (rectangle.Y < 3.1 || rectangle.Y > 4.9),
|
||||
$"Expected a right-facing rectangle corner, got {rectangle}");
|
||||
Assert.True(proposal.ContourChoices[1].Point.X > 8, "Circle must finish toward the outside start.");
|
||||
Assert.True(proposal.ContourChoices[^1].Point.X > 11.9, "Outside start must face the next part.");
|
||||
var runs = CutRuns(proposal.Execution);
|
||||
Assert.Equal(3, runs.Count);
|
||||
for (var i = 0; i < runs.Count - 1; i++)
|
||||
Assert.True(runs[i].End.X < runs[i + 1].Pierce.X, "Actual emitted cuts must flow rightward.");
|
||||
Assert.Empty(new ReleasedContourState().Check(proposal.Execution, Start, 1));
|
||||
Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossingPreferredHolePathBacktracksToACheckedAlternative()
|
||||
{
|
||||
// Calibrated: the preferred rapid from hole 1 to hole 2 touches already-cut hole 3.
|
||||
var centres = new[] { new Vector(3, 3), new Vector(7, 15), new Vector(3, 7), new Vector(3, 11) };
|
||||
var clean = LeadPathValidationTests.Rectangle(0, 0, 18, 18);
|
||||
foreach (var c in centres)
|
||||
{
|
||||
clean.MoveTo(c.X + 1.75, c.Y);
|
||||
clean.ArcTo(c.X + 1.75, c.Y, c.X, c.Y, RotationType.CCW);
|
||||
}
|
||||
var part = new Part(new Drawing("crossing preference", clean));
|
||||
var unchanged = ExplicitContourTests.Fingerprint(part.Program);
|
||||
var start = new Vector(-2, 9);
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], start,
|
||||
confirmedParameters: Parameters(), preservePartOrder: true));
|
||||
var source = snapshot.Placements[0];
|
||||
var prepared = source.Prepared;
|
||||
var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, []);
|
||||
var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(4)
|
||||
.RankTowardNextCut(null, start), c => adapter.Check(c.Choice)).Choices[0];
|
||||
var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start,
|
||||
PreferredContourEntries.Pierce(prepared, outside, default));
|
||||
var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start,
|
||||
c => adapter.Check(c.Choice));
|
||||
Assert.True(preferred.IsPreferred, preferred.Reason);
|
||||
var unsafeChoices = preferred.HoleChoices.Append(outside).ToArray();
|
||||
var unsafeProgram = prepared.Emit(unsafeChoices);
|
||||
var unsafeExecution = ExecutionMotionReader.Read(unsafeProgram, Vector.Zero, start, default);
|
||||
Assert.Contains(new ReleasedContourState().Check(unsafeExecution, start, 1),
|
||||
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
var rejected = CuttingPlanService.ReplayPrograms(snapshot,
|
||||
[source.Propose(unsafeProgram, unsafeExecution, unsafeChoices)], 0, default);
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, rejected.Status);
|
||||
Assert.False(rejected.IndependentlyReplayed);
|
||||
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
var actual = result.ProposedOrder[0];
|
||||
Assert.Equal(outside.Point, actual.ContourChoices[^1].Point);
|
||||
Assert.Equal(route, actual.ContourChoices.Take(4).Select(c => c.ContourOrdinal));
|
||||
Assert.NotEqual(unsafeChoices.Single(c => c.ContourOrdinal == 2).Point,
|
||||
actual.ContourChoices.Single(c => c.ContourOrdinal == 2).Point);
|
||||
Assert.Empty(new ReleasedContourState().Check(actual.Execution, start, 1));
|
||||
Assert.Equal(unchanged, ExplicitContourTests.Fingerprint(part.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LaterLockedPartForcesNewPerimeterAndRecomputedHolePreference()
|
||||
{
|
||||
var parameters = Parameters();
|
||||
var first = new Part(new Drawing("holes", MixedHoles()));
|
||||
var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
|
||||
// The fixed next program starts above-left, then moves above the first part before
|
||||
// cutting on the right. Its material centre alone cannot predict that approach.
|
||||
nextClean.MoveTo(-22, 10);
|
||||
nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe });
|
||||
nextClean.MoveTo(0, 10);
|
||||
nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe });
|
||||
var next = new Part(new Drawing("locked marked part", nextClean), new Vector(20, 2));
|
||||
var nextPrepared = PreparedContours.Capture(nextClean, parameters);
|
||||
Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit(
|
||||
[nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true));
|
||||
var lockedFingerprint = ExplicitContourTests.Fingerprint(next.Program);
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], Start,
|
||||
confirmedParameters: parameters, preservePartOrder: true));
|
||||
var source = snapshot.Placements[0];
|
||||
var prepared = source.Prepared;
|
||||
var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material,
|
||||
snapshot.Placements.Select(p => p.Material).ToArray());
|
||||
var preferredOutside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(2, new Vector(21, 3))
|
||||
.RankTowardNextCut(new Vector(21, 3), Start), c => adapter.Check(c.Choice)).Choices[0];
|
||||
var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray();
|
||||
var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Start,
|
||||
PreferredContourEntries.Pierce(prepared, preferredOutside, default));
|
||||
var preferred = PreferredContourEntries.TryPlan(prepared, preferredOutside, route, centres, Start,
|
||||
c => adapter.Check(c.Choice));
|
||||
Assert.True(preferred.IsPreferred, preferred.Reason);
|
||||
var firstExecution = ExecutionMotionReader.Read(prepared.Emit(
|
||||
preferred.HoleChoices.Append(preferredOutside).ToArray()), Vector.Zero, Start, default);
|
||||
var state = new ReleasedContourState();
|
||||
Assert.Empty(state.Check(firstExecution, Start, 1));
|
||||
Assert.Contains(state.Check(snapshot.Placements[1].Execution, firstExecution.DeparturePoint, 2),
|
||||
f => f.Kind == PostVerificationKind.RapidCrossing);
|
||||
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
Assert.NotEqual(preferredOutside.Point, result.ProposedOrder[0].ContourChoices[^1].Point);
|
||||
Assert.NotEqual(preferred.HoleChoices.Select(c => c.Point).ToArray(),
|
||||
result.ProposedOrder[0].ContourChoices.Take(2).Select(c => c.Point).ToArray());
|
||||
Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram()));
|
||||
Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(next.Program));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void HoleProgramsRetainScribesOnceAndNeverInstallOnFailure(bool tabs)
|
||||
{
|
||||
var clean = MixedHoles();
|
||||
clean.MoveTo(-2, -2);
|
||||
clean.Codes.Add(new LinearMove(-1, -1) { Layer = LayerType.Scribe });
|
||||
var parameters = Parameters();
|
||||
parameters.TabsEnabled = tabs;
|
||||
parameters.TabConfig = new NormalTab { Size = 0.2 };
|
||||
parameters.ExternalLeadOut = new ArcLeadOut { Radius = 0.2 };
|
||||
var part = new Part(new Drawing("marked", clean));
|
||||
var fingerprint = ExplicitContourTests.Fingerprint(part.Program);
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start,
|
||||
confirmedParameters: parameters, preservePartOrder: true));
|
||||
if (tabs)
|
||||
{
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.Incomplete);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
var actual = result.ProposedOrder[0];
|
||||
Assert.Equal(2, actual.ContourChoices[^1].ContourOrdinal);
|
||||
Assert.Equal(3, actual.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count());
|
||||
Assert.Single(actual.Execution.Motions.Where(m => !m.Rapid && m.Layer == LayerType.Scribe));
|
||||
}
|
||||
Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program));
|
||||
}
|
||||
|
||||
internal static List<(Vector Pierce, Vector End)> CutRuns(OwnedExecution execution)
|
||||
{
|
||||
var runs = new List<(Vector Pierce, Vector End)>();
|
||||
Vector? pierce = null;
|
||||
var end = Vector.Zero;
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
if (motion.Rapid)
|
||||
{
|
||||
if (pierce is { } p) runs.Add((p, end));
|
||||
pierce = null;
|
||||
}
|
||||
else if (motion.Layer != LayerType.Scribe)
|
||||
{
|
||||
pierce ??= motion.Start ?? motion.End;
|
||||
end = motion.End;
|
||||
}
|
||||
}
|
||||
if (pierce is { } last) runs.Add((last, end));
|
||||
return runs;
|
||||
}
|
||||
|
||||
private static string Describe(CuttingPlanResult result) =>
|
||||
$"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message));
|
||||
}
|
||||
+26
-8
@@ -121,8 +121,9 @@ tangent arc at a shared vertex, such as a fillet, is an ordinary joint: an exact
|
||||
contact that the native query rounds away is not uncertain at a line endpoint the
|
||||
other curve already touches, while a contact anywhere else on the line still refuses.
|
||||
|
||||
Candidates rank by actual modeled rapid distance with stable source/contour/entry
|
||||
ordinals. Hash values and drawing names are not tie breakers. The expansion budget
|
||||
Source parts rank by modeled travel (material-centre distance at a holed-part
|
||||
boundary); within a part, preferred contour order and facing-entry rank precede
|
||||
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget
|
||||
counts rejected candidates and frontier ranking as well as accepted moves, before
|
||||
emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur
|
||||
before already-generated siblings are traversed; it returns a refusal, not an
|
||||
@@ -130,8 +131,7 @@ 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
|
||||
An unlocked part's outside 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
|
||||
@@ -141,13 +141,31 @@ 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
|
||||
Uncertain (numerically incomplete) validator answers are not geometric refusals:
|
||||
those candidates can fill remaining retained slots for emitted-prefix checking and
|
||||
complete replay. The total retained entry count stays within `maxEntries`. 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.
|
||||
impossibility. Lead prechecks are reported separately and their requests also consume
|
||||
the shared expansion budget before native work.
|
||||
|
||||
For a holed part, the search chooses an outside endpoint before cutting any hole.
|
||||
Each endpoint branch builds an open hole-centre route from the original arrival to
|
||||
that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement.
|
||||
Preferred hole entries are then resolved backward from the outside's actual emitted
|
||||
pierce, ignoring scribe marks: each hole faces the following contour's actual pierce.
|
||||
Convex corners lead the preference tiers, then straight midpoints/tangent joints,
|
||||
then native fallbacks. Reflex/cusp corners remain manual-only.
|
||||
|
||||
This preference orders the search; it never certifies a rapid or prunes alternate
|
||||
retained entries or hole orders. A different outside endpoint recomputes its hole
|
||||
preference. Every standalone emitted prefix is replayed from the original part
|
||||
arrival and a copy of the checker from before that part, not from the previous
|
||||
prefix (which would double-consume holes and scribes). Holes are cut once, the
|
||||
outside last, and scribes once. Locked programs remain exact. A crossed preferred
|
||||
route must recover through checked backtracking or return a refusal, never unsafe
|
||||
`Ready`.
|
||||
|
||||
Selected programs are replayed from the beginning with a fresh checker and fresh
|
||||
lead validation, without regenerating them or trusting cached search verdicts.
|
||||
|
||||
Reference in new issue
Block a user