mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-05 19:35:44 -05:00
perf(cutting): plan plates faster with bounded fallbacks and prefiltered checks
Plan Cutting spent most of its time on work that never changed the outcome: many-holed parts paid quadratic shape-pair and ring-containment costs before any search, every plate was captured twice, and failing plates burned their whole budget in exhaustive fallback passes that never found a plan on 127 production plates. - OverlapMaterial: skip native shape pairs whose bounds are clear by more than the query allowance; gate polygon touch and winding tests on vertex bounds. - LeadMaterialSnapshot: gate ring enclosure tests on ring extents when finding the perimeter and rejecting nested cutouts. - CuttingPlanBatch: capture each plate once; the kept-order retry derives from the same capture through CuttingPlanSnapshot.WithPreservedOrder. - JointCuttingPlanSearch: end the kept-order full pass and the sequence-free recovery and full passes after FallbackStalls (2) stalls' worth of expansions when programs are regenerated; plates with only fixed programs keep the exhaustive search so a refusal stays genuine. On 127 archive plates (lead-ins cleared, 35" scrap cut-offs) total time fell from 164 s to 102 s with identical outcomes; the problem-nest corpus gives the same status and expansion count on every plate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a85593bd4
commit
07a9739774
8 files changed
+177
-25
No files matched your search
@@ -121,11 +121,16 @@ public sealed class LeadMaterialSnapshot
|
||||
? "A display perimeter must enclose every other contour."
|
||||
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||
}
|
||||
// A ring encloses a point only inside the union of its curves' extents (each at least
|
||||
// the curve's own reach), so a part with thousands of holes asks parity only where it can hold.
|
||||
var ringExtents = rings.Select(ring => ring.Aggregate(PostVerificationGeometry.Extent.None,
|
||||
(extent, curve) => extent.Union(curve.Extent))).ToArray();
|
||||
bool Encloses(int ring, Vector point) => ringExtents[ring].Contains(point) && Inside(point, rings[ring], token);
|
||||
var outer = -1;
|
||||
for (var i = 0; i < rings.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Inside(rings[j][0].Start, rings[i], token)))
|
||||
if (Enumerable.Range(0, rings.Count).All(j => i == j || Encloses(i, rings[j][0].Start)))
|
||||
{
|
||||
if (outer >= 0)
|
||||
throw new ArgumentException("Material perimeter is ambiguous.");
|
||||
@@ -142,10 +147,11 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("A display perimeter must enclose every other contour.");
|
||||
if (ringDisplay[outer] && rings.Count == 1)
|
||||
throw new ArgumentException("A display perimeter with no cutouts has nothing to cut.");
|
||||
var holes = rings.Where((_, i) => i != outer).ToArray();
|
||||
for (var i = 0; i < holes.Length; i++)
|
||||
for (var j = i + 1; j < holes.Length; j++)
|
||||
if (Inside(holes[i][0].Start, holes[j], token) || Inside(holes[j][0].Start, holes[i], token))
|
||||
var holeRings = Enumerable.Range(0, rings.Count).Where(i => i != outer).ToArray();
|
||||
var holes = holeRings.Select(i => rings[i]).ToArray();
|
||||
for (var i = 0; i < holeRings.Length; i++)
|
||||
for (var j = i + 1; j < holeRings.Length; j++)
|
||||
if (Encloses(holeRings[j], holes[i][0].Start) || Encloses(holeRings[i], holes[j][0].Start))
|
||||
throw new ArgumentException("Nested cutouts are not supported material.");
|
||||
return new LeadMaterialSnapshot(new[] { rings[outer] }.Concat(holes).ToArray(),
|
||||
new[] { ringTabs[outer] }.Concat(ringTabs.Where((_, i) => i != outer)).ToArray(), null, ringDisplay[outer]);
|
||||
|
||||
@@ -31,11 +31,17 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
}
|
||||
// Sampling can hide a crossing or tangency between curves. Reject native
|
||||
// contour contact before asking the polygon approximation about containment.
|
||||
// A contact lies within both entities' bounds, so shapes whose bounds are clear by more
|
||||
// than any native query's allowance cannot touch: a part with thousands of holes does
|
||||
// not query every pair.
|
||||
var boxes = shapes.Select(shape => shape.BoundingBox).ToList();
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (ClearOfNativeQueries(boxes[i], boxes[j]))
|
||||
continue;
|
||||
shapes[i].Intersects(shapes[j], out var intersections);
|
||||
if (intersections.Count > 0)
|
||||
throw new ArgumentException("Native material contours cross or touch.");
|
||||
@@ -44,23 +50,70 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
var ordered = polygons.OrderByDescending(polygon => Area(polygon.Vertices)).ToList();
|
||||
var outer = ordered[0];
|
||||
var holes = ordered.Skip(1).ToList();
|
||||
var outerBounds = BoundsOf(outer);
|
||||
var holeBounds = holes.Select(BoundsOf).ToList();
|
||||
for (var i = 0; i < holes.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (BoundariesTouch(outer, holes[i], cancellationToken)
|
||||
|| !Inside(outer, holes[i].Vertices[0]))
|
||||
if (BoundariesTouch(outer, outerBounds, holes[i], holeBounds[i], cancellationToken)
|
||||
|| !Inside(outer, outerBounds, holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Contours must have one outer with strictly internal holes.");
|
||||
for (var j = 0; j < i; j++)
|
||||
{
|
||||
if (BoundariesTouch(holes[i], holes[j], cancellationToken)
|
||||
|| Inside(holes[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holes[i].Vertices[0]))
|
||||
// Exact polygon tests: holes whose vertex bounds are apart can neither touch
|
||||
// nor contain each other's vertices.
|
||||
if (!Overlap(holeBounds[i], holeBounds[j]))
|
||||
continue;
|
||||
if (BoundariesTouch(holes[i], holeBounds[i], holes[j], holeBounds[j], cancellationToken)
|
||||
|| Inside(holes[i], holeBounds[i], holes[j].Vertices[0])
|
||||
|| Inside(holes[j], holeBounds[j], holes[i].Vertices[0]))
|
||||
throw new ArgumentException("Intersecting holes and nested material islands are unsupported.");
|
||||
}
|
||||
}
|
||||
return new OverlapMaterial(outer, holes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gap beyond which no native intersection query counts two entities as touching: ten
|
||||
/// times the 0.00001 bounding-box allowance of native intersections, as the planner's
|
||||
/// <c>PostVerificationGeometry.Extent.ClearMargin</c>.
|
||||
/// </summary>
|
||||
private const double NativeClearMargin = 1e-3;
|
||||
|
||||
// True only when both boxes are finite and apart by more than the native allowance on some axis.
|
||||
private static bool ClearOfNativeQueries(Box a, Box b) =>
|
||||
IsFinite(a) && IsFinite(b)
|
||||
&& (a.Right + NativeClearMargin < b.Left || b.Right + NativeClearMargin < a.Left
|
||||
|| a.Top + NativeClearMargin < b.Bottom || b.Top + NativeClearMargin < a.Bottom);
|
||||
|
||||
private static bool IsFinite(Box box) => double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top);
|
||||
|
||||
/// <summary>Exact vertex bounds of a sampled polygon, for skipping segment and winding tests.</summary>
|
||||
private readonly record struct Bounds(double MinX, double MinY, double MaxX, double MaxY)
|
||||
{
|
||||
internal bool Contains(Vector point) =>
|
||||
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
|
||||
}
|
||||
|
||||
private static Bounds BoundsOf(Polygon polygon)
|
||||
{
|
||||
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
|
||||
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
|
||||
foreach (var vertex in polygon.Vertices)
|
||||
{
|
||||
minX = System.Math.Min(minX, vertex.X);
|
||||
minY = System.Math.Min(minY, vertex.Y);
|
||||
maxX = System.Math.Max(maxX, vertex.X);
|
||||
maxY = System.Math.Max(maxY, vertex.Y);
|
||||
}
|
||||
return new(minX, minY, maxX, maxY);
|
||||
}
|
||||
|
||||
// Closed-interval overlap: two segments that touch share a point inside both bounds.
|
||||
private static bool Overlap(Bounds a, Bounds b) =>
|
||||
a.MaxX >= b.MinX && b.MaxX >= a.MinX && a.MaxY >= b.MinY && b.MaxY >= a.MinY;
|
||||
|
||||
internal OverlapMaterial Transform(double rotation, Vector offset) =>
|
||||
new(TransformPolygon(Outer, rotation, offset),
|
||||
Holes.Select(hole => TransformPolygon(hole, rotation, offset)).ToList());
|
||||
@@ -171,13 +224,23 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
throw new ArgumentException("Material contour could not be completely triangulated.");
|
||||
}
|
||||
|
||||
private static bool BoundariesTouch(Polygon a, Polygon b, CancellationToken cancellationToken)
|
||||
// Exact: a segment of a whose bounds are clear of b's bounds touches no segment of b.
|
||||
private static bool BoundariesTouch(Polygon a, Bounds aBounds, Polygon b, Bounds bBounds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Overlap(aBounds, bBounds))
|
||||
return false;
|
||||
for (var i = 0; i + 1 < a.Vertices.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var start = a.Vertices[i];
|
||||
var end = a.Vertices[i + 1];
|
||||
var segment = new Bounds(System.Math.Min(start.X, end.X), System.Math.Min(start.Y, end.Y),
|
||||
System.Math.Max(start.X, end.X), System.Math.Max(start.Y, end.Y));
|
||||
if (!Overlap(segment, bBounds))
|
||||
continue;
|
||||
for (var j = 0; j + 1 < b.Vertices.Count; j++)
|
||||
if (SegmentsTouch(a.Vertices[i], a.Vertices[i + 1], b.Vertices[j], b.Vertices[j + 1]))
|
||||
if (SegmentsTouch(start, end, b.Vertices[j], b.Vertices[j + 1]))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -200,6 +263,10 @@ internal sealed record OverlapMaterial(Polygon Outer, List<Polygon> Holes)
|
||||
&& point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y);
|
||||
|
||||
// Boundary contact is rejected before this winding-number test is used for topology.
|
||||
// A point outside the polygon's vertex bounds has winding zero.
|
||||
private static bool Inside(Polygon polygon, Bounds bounds, Vector point) =>
|
||||
bounds.Contains(point) && Inside(polygon, point);
|
||||
|
||||
private static bool Inside(Polygon polygon, Vector point)
|
||||
{
|
||||
var winding = 0;
|
||||
|
||||
@@ -68,6 +68,10 @@ internal static class PostVerificationGeometry
|
||||
return IsClearOf(other, ClearMargin + reach + SpanRounding * span);
|
||||
}
|
||||
|
||||
/// <summary>True when <paramref name="point"/> lies within the extent, its edges included.</summary>
|
||||
internal bool Contains(Vector point) =>
|
||||
point.X >= MinX && point.X <= MaxX && point.Y >= MinY && point.Y <= MaxY;
|
||||
|
||||
/// <summary>True when <paramref name="y"/> lies more than <see cref="ClearMargin"/> outside the Y range.</summary>
|
||||
internal bool IsClearOfRow(double y) => IsWellConditioned && System.Math.Abs(y) <= WellConditionedLimit
|
||||
&& (y > MaxY + ClearMargin || y < MinY - ClearMargin);
|
||||
|
||||
@@ -121,11 +121,14 @@ public sealed class CuttingPlanBatch
|
||||
{
|
||||
var plate = plates[index];
|
||||
var budget = PlateBudget(plate.Parts.Count, budgetMultiplier);
|
||||
var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
// One capture serves both searches: the order flag and budget are search settings
|
||||
// over the same owned geometry, programs and freshness record.
|
||||
var captured = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
expansionBudget: budget, confirmedParameters: confirmedParameters,
|
||||
preservePartOrder: true), token);
|
||||
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
||||
preservePartOrder: preservePartOrder), token);
|
||||
var keepOrder = preservePartOrder ? captured : captured.WithPreservedOrder();
|
||||
var reorder = preservePartOrder ? null
|
||||
: reorderBudget is { } reorderLimit ? captured.WithSearchLimits(reorderLimit, captured.MaxEntries) : captured;
|
||||
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
||||
var fallback = reorder ?? keepOrder;
|
||||
if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry)
|
||||
|
||||
@@ -120,6 +120,13 @@ public sealed class CuttingPlanSnapshot
|
||||
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
|
||||
Dependencies, BestEffort)
|
||||
{ SupportTabsSkipped = SupportTabsSkipped };
|
||||
|
||||
/// <summary>The same capture planned along its current part order.</summary>
|
||||
internal CuttingPlanSnapshot WithPreservedOrder() =>
|
||||
new(Placements, StartPoint, ExpansionBudget, Failure, Findings, Regeneration,
|
||||
preservePartOrder: true, MaxEntries, ExpansionObserver, PlateState, OwnedParameters,
|
||||
Dependencies, BestEffort)
|
||||
{ SupportTabsSkipped = SupportTabsSkipped };
|
||||
}
|
||||
|
||||
public sealed class FixedProgramPlacement
|
||||
|
||||
@@ -28,6 +28,14 @@ internal static class JointCuttingPlanSearch
|
||||
/// the contours are those of the blocked part and of the part cut just before it.
|
||||
/// </summary>
|
||||
internal const int StallExpansionsPerEntry = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Stalls' worth of expansions each search that backtracks without a stall limit (the full
|
||||
/// pass along a kept order, and the sequence-free recovery and full passes once an order
|
||||
/// teaches nothing) may spend before it ends. Beyond that they only retry entry combinations
|
||||
/// of parts already cut, so the budget they would take goes to the lighter fallbacks instead.
|
||||
/// </summary>
|
||||
internal const int FallbackStalls = 2;
|
||||
private const int MaximumRecoveryHoles = 30;
|
||||
// The most holes recovery's search alone served; parts with more try the reading-order sweep first.
|
||||
private const int SearchedRecoveryHoles = 16;
|
||||
@@ -71,6 +79,10 @@ internal static class JointCuttingPlanSearch
|
||||
var count = snapshot.Placements.Count;
|
||||
var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
|
||||
var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
|
||||
// Fixed programs have no entries to retry, so their search space is small and running
|
||||
// it out is a genuine refusal; only a search that regenerates programs is bounded.
|
||||
int? FallbackLimit() => snapshot.Placements.Any(p => p.Prepared != null)
|
||||
? (int)System.Math.Min(int.MaxValue, walk.Expansions + (long)FallbackStalls * stall) : null;
|
||||
var preferEndpoints = count > 1 && maxContours > 1;
|
||||
// A supplied blank alone on its sheet (its holes are the whole part) goes to contour
|
||||
// recovery first; any other lone part keeps the plain search, whose routes are shorter.
|
||||
@@ -91,7 +103,7 @@ internal static class JointCuttingPlanSearch
|
||||
if (recovered.Order != null)
|
||||
return walk.Ready(recovered.Order);
|
||||
}
|
||||
var kept = walk.Follow(keptSequence, null, null);
|
||||
var kept = walk.Follow(keptSequence, null, null, expansionLimit: FallbackLimit());
|
||||
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
|
||||
}
|
||||
|
||||
@@ -133,11 +145,12 @@ internal static class JointCuttingPlanSearch
|
||||
// A lone holed part has no part order to learn: its holes still get recovery.
|
||||
if (preferEndpoints || maxContours > 1)
|
||||
{
|
||||
var recovery = walk.Follow(null, stall, null, contourRecovery: true);
|
||||
var recovery = walk.Follow(null, stall, null, contourRecovery: true,
|
||||
expansionLimit: FallbackLimit());
|
||||
if (recovery.Order != null)
|
||||
return walk.Ready(recovery.Order);
|
||||
}
|
||||
var full = walk.Follow(null, null, null);
|
||||
var full = walk.Follow(null, null, null, expansionLimit: FallbackLimit());
|
||||
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
|
||||
}
|
||||
// Back up to just before the earliest part the blocked approach crossed, keep the
|
||||
@@ -327,9 +340,10 @@ internal static class JointCuttingPlanSearch
|
||||
/// further along the order; it never backtracks behind its starting node.
|
||||
/// With <paramref name="prerequisites"/>, an approach into a cutoff that crosses parts
|
||||
/// already cut ends the attempt at once when that teaches "cut it before them".
|
||||
/// With <paramref name="expansionLimit"/>, the attempt ends once the walk's expansions reach it.
|
||||
/// </summary>
|
||||
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false,
|
||||
HashSet<int>[] prerequisites = null)
|
||||
HashSet<int>[] prerequisites = null, int? expansionLimit = null)
|
||||
{
|
||||
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
|
||||
var attempt = new Attempt(sequence);
|
||||
@@ -348,6 +362,9 @@ internal static class JointCuttingPlanSearch
|
||||
attempt.Order = node.Order;
|
||||
return attempt;
|
||||
}
|
||||
// A pass held to a share of the budget ends there, as a stalled one does.
|
||||
if (expansionLimit is int end && Expansions >= end)
|
||||
return attempt;
|
||||
// A cutoff is a fixed program cut the same way wherever it goes, so replanning
|
||||
// the parts before it can only change where its approach starts. Cutting it
|
||||
// earlier never makes its own cut harder, and a sheet separator that cannot come
|
||||
|
||||
@@ -227,6 +227,48 @@ public class CuttingPlanBatchTests
|
||||
Assert.True(proposal.CanApply);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_FreeSearchOutOfBudget_RetriesTheSameCaptureAlongTheCurrentOrder()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Grid(4));
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false, null,
|
||||
reorderBudget: 50, CancellationToken.None).Plan();
|
||||
|
||||
// One capture serves both searches: the kept-order retry plans the placements the
|
||||
// free search planned, with their freshness record, under the plate budget.
|
||||
var planned = Assert.Single(proposal.Plates);
|
||||
Assert.True(planned.KeptCurrentOrder);
|
||||
Assert.Same(planned.ReorderAttempt!.Snapshot!.PlateState, planned.Result.Snapshot!.PlateState);
|
||||
Assert.Equal(CuttingPlanBatch.PlateBudget(plate.Parts.Count), planned.Result.Snapshot.ExpansionBudget);
|
||||
Assert.Equal(50, planned.ReorderAttempt.Snapshot!.ExpansionBudget);
|
||||
Assert.True(planned.Result.Snapshot.PreservePartOrder);
|
||||
Assert.False(planned.ReorderAttempt.Snapshot.PreservePartOrder);
|
||||
Assert.Equal(planned.ReorderAttempt.Snapshot.Placements, planned.Result.Snapshot.Placements,
|
||||
ReferenceEqualityComparer.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithPreservedOrder_SharesTheCaptureAndFixesTheOrder()
|
||||
{
|
||||
var nest = new Nest();
|
||||
var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1));
|
||||
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 123));
|
||||
|
||||
var kept = snapshot.WithPreservedOrder();
|
||||
|
||||
Assert.False(snapshot.PreservePartOrder);
|
||||
Assert.True(kept.PreservePartOrder);
|
||||
Assert.Equal(snapshot.Placements, kept.Placements, ReferenceEqualityComparer.Instance);
|
||||
Assert.Same(snapshot.PlateState, kept.PlateState);
|
||||
Assert.Same(snapshot.Dependencies, kept.Dependencies);
|
||||
Assert.Same(snapshot.OwnedParameters, kept.OwnedParameters);
|
||||
Assert.Equal(123, kept.ExpansionBudget);
|
||||
Assert.Equal(snapshot.MaxEntries, kept.MaxEntries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_PreservedOrder_DoesNotSearchForANewOrder()
|
||||
{
|
||||
|
||||
+11
-5
@@ -241,10 +241,15 @@ reachable via a third), so once nothing new can be learned the remaining budget
|
||||
first tries sequence-free recovery, visiting dependency-ready parts lazily by
|
||||
material-centre distance. When a branch stalls, it disposes the deeper search
|
||||
frames and resumes alternatives at the root instead of spending the remainder
|
||||
on that branch's completed prefix. Any budget left then goes to the retained full
|
||||
search. Recovery shares the original total expansion budget; exhausting it still
|
||||
returns a refusal from the service, never a best-effort upgrade. Only the batch then
|
||||
looks for the separate [safest route](#safest-route-fallback-when-the-search-runs-out).
|
||||
on that branch's completed prefix. The retained full search runs after it. Each of
|
||||
these two passes, and the full pass along a kept order, is held to two stalls' worth
|
||||
of expansions (`JointCuttingPlanSearch.FallbackStalls`): past that they only retry entry
|
||||
combinations of parts already cut (on 140 production plates neither found a plan the
|
||||
order passes had missed), so the rest of the budget is left unspent rather than taken
|
||||
from the kept-order retry and the safest route. Recovery shares the original total
|
||||
expansion budget; exhausting it still returns a refusal from the service, never a
|
||||
best-effort upgrade. Only the batch then looks for the separate
|
||||
[safest route](#safest-route-fallback-when-the-search-runs-out).
|
||||
|
||||
### Cutoff segments
|
||||
|
||||
@@ -699,7 +704,8 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer.
|
||||
attempt. The current-order retry still uses its original 16-entry budget.
|
||||
Returned expansion counts describe the retained attempt, not a discarded
|
||||
short pass plus its full-cap retry.
|
||||
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
|
||||
- Every plate is captured on the UI thread, once (the free-order and kept-order searches plan
|
||||
the same owned capture), and checked and planned on a worker. Clean part
|
||||
material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts
|
||||
still block that plate whatever its route. Incomplete checks remain visible warnings, never
|
||||
a clear result (see [pre-post verification](post-verification.md)). A free-order search that ends
|
||||
|
||||
Reference in new issue
Block a user