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:
ajandClaude Fable 5.1 committed 2026-10-29 09:35:33 -04:00
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]);
+74 -7
View File
@@ -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
View File
@@ -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