mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 22:01:07 -04:00
perf(planning): plan parts with thousands of holes in seconds
A part with about 1,700 holes (P241017-01) never planned: material capture paired every boundary with every other one and lead checks scanned every boundary per lead, so both hit the 1M native query limit and the plate fell back to an unverified best-effort plan. - Material capture sweeps boundary extents along X and queries only pairs the lead checks' per-pair clearance cannot skip, in the full scan's order. - Material with 64+ boundaries keeps a grid of boundary extents. Lead checks, contour-joint matching and replay's contour accounting query only nearby boundaries; ray parity reads only holes on the point's row. - Only native queries that run are charged to the budget; skipped far material no longer costs its full boundary count. - The search looks up chosen holes and route ranks instead of scanning. With that the plate plans Ready, but in 189 s: every step re-emitted and re-checked all holes already cut. On parts with 64+ contours a middle hole step now checks only the contour it adds, continuing the completed-contour state its prefix left (ReleasedContourState.Continue). It falls back to the full prefix whenever it cannot continue exactly, and the last contour still checks the whole part from before it. Isolated contour emission clones only the chosen contour. P241017-01 now plans Ready in 5.4 s with an identical route, expansions and rapid travel. Core and engine suites pass; the problem-nest corpus matches the unchanged commit's outcomes and expansions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bbadd6aa75
commit
07e7d7edcd
9 files changed
+484
-69
No files matched your search
@@ -55,20 +55,19 @@ internal static class ContourProgramVerifier
|
||||
var first = run.Curves[0];
|
||||
var ringIndex = -1;
|
||||
var entityIndex = -1;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
// The admitted slack is far below any extent margin, so only boundaries near the run's
|
||||
// start can contain it; they come in ring order, as the full scan met them.
|
||||
foreach (var (r, e) in material.PositionsNear(new(first.Start.X, first.Start.Y, first.Start.X, first.Start.Y)))
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
ringIndex = r;
|
||||
entityIndex = e;
|
||||
break;
|
||||
}
|
||||
ringIndex = r;
|
||||
entityIndex = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ringIndex < 0) return false;
|
||||
|
||||
@@ -91,39 +91,36 @@ public sealed class LeadMaterialSnapshot
|
||||
throw new ArgumentException("Material has no closed contour.");
|
||||
var budget = 1000000;
|
||||
// Certify simple rings and mutually disjoint boundaries before containment.
|
||||
for (var r = 0; r < rings.Count; r++)
|
||||
for (var s = r; s < rings.Count; s++)
|
||||
for (var i = 0; i < rings[r].Length; i++)
|
||||
for (var j = s == r ? i + 1 : 0; j < rings[s].Length; j++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||
var a = rings[r][i];
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||
// intersections on an extension (distance grows with radius).
|
||||
// At a shared arc joint require both actual finite spans;
|
||||
// overlap and genuine second intersections still refuse.
|
||||
if (arcJoint)
|
||||
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||
// Native contacts at rounded arc/line joints can drift from the
|
||||
// authored endpoint. Use the shared geometry tolerance only to
|
||||
// recognize that joint; overlaps and other contacts still refuse.
|
||||
if (overlap || contacts.Any(p => !adjacent
|
||||
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
||||
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
||||
throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
|
||||
? "A display perimeter must enclose every other contour."
|
||||
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||
}
|
||||
foreach (var (r, i, s, j) in ContactCandidates(rings, token))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new NotSupportedException("Material validation exceeds the native query limit.");
|
||||
var a = rings[r][i];
|
||||
var b = rings[s][j];
|
||||
var contacts = a.Contacts(b, out var overlap);
|
||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||
// intersections on an extension (distance grows with radius).
|
||||
// At a shared arc joint require both actual finite spans;
|
||||
// overlap and genuine second intersections still refuse.
|
||||
if (arcJoint)
|
||||
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||
// Native contacts at rounded arc/line joints can drift from the
|
||||
// authored endpoint. Use the shared geometry tolerance only to
|
||||
// recognize that joint; overlaps and other contacts still refuse.
|
||||
if (overlap || contacts.Any(p => !adjacent
|
||||
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
||||
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
||||
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
||||
throw new ArgumentException(r != s && (ringDisplay[r] || ringDisplay[s])
|
||||
? "A display perimeter must enclose every other contour."
|
||||
: $"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||
}
|
||||
var outer = -1;
|
||||
for (var i = 0; i < rings.Count; i++)
|
||||
{
|
||||
@@ -180,8 +177,181 @@ public sealed class LeadMaterialSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
internal bool ContainsMaterial(Vector point, CancellationToken token) => Inside(point, Rings[0], token)
|
||||
&& !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||
/// <summary>
|
||||
/// The boundary pairs certification queries natively, in the order of the full pairwise scan
|
||||
/// (ring, other ring, curve, other curve). A pair whose extents are clear by the lead checks'
|
||||
/// margin (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>) can report no
|
||||
/// native contact, so it is left out; a sweep over X finds the rest without visiting every pair.
|
||||
/// Material that is not well conditioned keeps every pair.
|
||||
/// </summary>
|
||||
private static List<(int R, int I, int S, int J)> ContactCandidates(
|
||||
IReadOnlyList<PostVerificationGeometry.Curve[]> rings, CancellationToken token)
|
||||
{
|
||||
var curves = new List<(int Ring, int Index, PostVerificationGeometry.Extent Extent)>();
|
||||
var whole = PostVerificationGeometry.Extent.None;
|
||||
for (var r = 0; r < rings.Count; r++)
|
||||
for (var i = 0; i < rings[r].Length; i++)
|
||||
{
|
||||
curves.Add((r, i, rings[r][i].Extent));
|
||||
whole = whole.Union(rings[r][i].Extent);
|
||||
}
|
||||
var pairs = new List<(int R, int I, int S, int J)>();
|
||||
if (!whole.IsWellConditioned)
|
||||
{
|
||||
for (var a = 0; a < curves.Count; a++)
|
||||
for (var b = a + 1; b < curves.Count; b++)
|
||||
pairs.Add((curves[a].Ring, curves[a].Index, curves[b].Ring, curves[b].Index));
|
||||
}
|
||||
else
|
||||
{
|
||||
// No pair spans more than the whole material, so this bounds every pair's margin.
|
||||
var reach = PostVerificationGeometry.Extent.ClearMargin + PostVerificationGeometry.Extent.SpanRounding
|
||||
* System.Math.Max(whole.MaxX - whole.MinX, whole.MaxY - whole.MinY);
|
||||
var byMinX = curves.OrderBy(c => c.Extent.MinX).ToArray();
|
||||
for (var a = 0; a < byMinX.Length; a++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var first = byMinX[a];
|
||||
for (var b = a + 1; b < byMinX.Length && byMinX[b].Extent.MinX <= first.Extent.MaxX + reach; b++)
|
||||
{
|
||||
var second = byMinX[b];
|
||||
if (first.Extent.IsClearOfQueriesFrom(second.Extent))
|
||||
continue;
|
||||
pairs.Add((first.Ring, first.Index).CompareTo((second.Ring, second.Index)) < 0
|
||||
? (first.Ring, first.Index, second.Ring, second.Index)
|
||||
: (second.Ring, second.Index, first.Ring, first.Index));
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.Sort((x, y) => (x.R, x.S, x.I, x.J).CompareTo((y.R, y.S, y.I, y.J)));
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/// <summary>Material with fewer boundaries is scanned whole, exactly as before the index.</summary>
|
||||
private const int IndexedBoundaries = 64;
|
||||
|
||||
private BoundaryIndex index;
|
||||
|
||||
/// <summary>
|
||||
/// The boundaries a native query from <paramref name="query"/> can report a contact with, in
|
||||
/// ring order: every boundary but those whose extents are clear of it by the lead checks'
|
||||
/// margin plus <paramref name="reach"/> (<see cref="PostVerificationGeometry.Extent.IsClearOfQueriesFrom"/>).
|
||||
/// Small material returns every boundary.
|
||||
/// </summary>
|
||||
internal IEnumerable<PostVerificationGeometry.Curve> BoundariesNear(PostVerificationGeometry.Extent query,
|
||||
double reach = 0) => PositionsNear(query, reach).Select(p => Rings[p.Ring][p.Index]);
|
||||
|
||||
/// <summary>As <see cref="BoundariesNear"/>, each boundary as its ring and its index in that ring.</summary>
|
||||
internal IEnumerable<(int Ring, int Index)> PositionsNear(PostVerificationGeometry.Extent query, double reach = 0)
|
||||
{
|
||||
if (BoundaryCount < IndexedBoundaries)
|
||||
return Rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i)));
|
||||
return LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).Near(query, reach);
|
||||
}
|
||||
|
||||
internal bool ContainsMaterial(Vector point, CancellationToken token)
|
||||
{
|
||||
if (BoundaryCount < IndexedBoundaries)
|
||||
return Inside(point, Rings[0], token) && !Rings.Skip(1).Any(hole => Inside(point, hole, token));
|
||||
var holes = LazyInitializer.EnsureInitialized(ref index, () => new BoundaryIndex(Rings, Extent)).HolesOnRow(point.Y);
|
||||
return Inside(point, Rings[0], token) && !holes.Any(hole => Inside(point, Rings[hole], token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A uniform grid of boundary extents over the material, for material with many holes. Read
|
||||
/// only once built, so concurrent searches may share it.
|
||||
/// </summary>
|
||||
private sealed class BoundaryIndex
|
||||
{
|
||||
private readonly (int Ring, int Index)[] positions;
|
||||
private readonly PostVerificationGeometry.Extent[] extents;
|
||||
private readonly List<int>[] cells;
|
||||
private readonly List<int>[] bands;
|
||||
private readonly PostVerificationGeometry.Extent[] ringExtents;
|
||||
private readonly int ringCount;
|
||||
private readonly PostVerificationGeometry.Extent whole;
|
||||
private readonly int columns;
|
||||
private readonly int rows;
|
||||
private readonly double width;
|
||||
private readonly double height;
|
||||
|
||||
internal BoundaryIndex(IReadOnlyList<PostVerificationGeometry.Curve[]> rings, PostVerificationGeometry.Extent whole)
|
||||
{
|
||||
positions = rings.SelectMany((ring, r) => ring.Select((_, i) => (r, i))).ToArray();
|
||||
extents = rings.SelectMany(r => r).Select(b => b.Extent).ToArray();
|
||||
ringCount = rings.Count;
|
||||
ringExtents = new PostVerificationGeometry.Extent[rings.Count];
|
||||
this.whole = whole;
|
||||
var side = System.Math.Max(1, (int)System.Math.Ceiling(System.Math.Sqrt(positions.Length)));
|
||||
columns = side;
|
||||
rows = side;
|
||||
width = System.Math.Max(whole.MaxX - whole.MinX, double.Epsilon) / columns;
|
||||
height = System.Math.Max(whole.MaxY - whole.MinY, double.Epsilon) / rows;
|
||||
cells = new List<int>[columns * rows];
|
||||
bands = new List<int>[rows];
|
||||
if (!whole.IsWellConditioned)
|
||||
return;
|
||||
for (var ring = 1; ring < rings.Count; ring++)
|
||||
{
|
||||
var extent = rings[ring].Aggregate(PostVerificationGeometry.Extent.None, (e, curve) => e.Union(curve.Extent));
|
||||
ringExtents[ring] = extent;
|
||||
for (var band = Row(extent.MinY - PostVerificationGeometry.Extent.ClearMargin);
|
||||
band <= Row(extent.MaxY + PostVerificationGeometry.Extent.ClearMargin); band++)
|
||||
(bands[band] ??= new List<int>()).Add(ring);
|
||||
}
|
||||
for (var b = 0; b < positions.Length; b++)
|
||||
{
|
||||
var (x0, y0, x1, y1) = Cells(extents[b], 0);
|
||||
for (var y = y0; y <= y1; y++)
|
||||
for (var x = x0; x <= x1; x++)
|
||||
(cells[y * columns + x] ??= new List<int>()).Add(b);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The holes, in ring order, whose curves a ray along row <paramref name="y"/> can cross:
|
||||
/// every hole but those whose extents are clear of the row (see
|
||||
/// <see cref="PostVerificationGeometry.Extent.IsClearOfRow"/>).
|
||||
/// </summary>
|
||||
internal IEnumerable<int> HolesOnRow(double y)
|
||||
{
|
||||
if (!whole.IsWellConditioned || !(System.Math.Abs(y) <= PostVerificationGeometry.Extent.WellConditionedLimit))
|
||||
return Enumerable.Range(1, ringCount - 1);
|
||||
return (bands[Row(y)] ?? []).Where(ring => !ringExtents[ring].IsClearOfRow(y));
|
||||
}
|
||||
|
||||
internal IEnumerable<(int Ring, int Index)> Near(PostVerificationGeometry.Extent query, double reach)
|
||||
{
|
||||
// The margin covers every pair's span, and each skip is then decided for the pair alone.
|
||||
// Material or a query that is not well conditioned (whose cells were never built, for
|
||||
// the material) keeps every boundary.
|
||||
var union = whole.Union(query);
|
||||
var margin = PostVerificationGeometry.Extent.ClearMargin + reach
|
||||
+ PostVerificationGeometry.Extent.SpanRounding * System.Math.Max(union.MaxX - union.MinX, union.MaxY - union.MinY);
|
||||
if (!union.IsWellConditioned || !double.IsFinite(margin))
|
||||
return positions;
|
||||
var (x0, y0, x1, y1) = Cells(query, margin);
|
||||
var found = new List<int>();
|
||||
for (var y = y0; y <= y1; y++)
|
||||
for (var x = x0; x <= x1; x++)
|
||||
if (cells[y * columns + x] is { } cell)
|
||||
found.AddRange(cell);
|
||||
found.Sort();
|
||||
var near = new List<(int Ring, int Index)>();
|
||||
for (var i = 0; i < found.Count; i++)
|
||||
if ((i == 0 || found[i] != found[i - 1]) && !query.IsClearOfQueriesFrom(extents[found[i]], reach))
|
||||
near.Add(positions[found[i]]);
|
||||
return near;
|
||||
}
|
||||
|
||||
// The cells an extent widened by margin covers, clamped to the grid.
|
||||
private (int X0, int Y0, int X1, int Y1) Cells(PostVerificationGeometry.Extent extent, double margin) => (
|
||||
Column(extent.MinX - margin), Row(extent.MinY - margin), Column(extent.MaxX + margin), Row(extent.MaxY + margin));
|
||||
|
||||
private int Column(double x) => (int)System.Math.Clamp(System.Math.Floor((x - whole.MinX) / width), 0, columns - 1);
|
||||
|
||||
private int Row(double y) => (int)System.Math.Clamp(System.Math.Floor((y - whole.MinY) / height), 0, rows - 1);
|
||||
}
|
||||
|
||||
internal static bool Inside(Vector point, IReadOnlyList<PostVerificationGeometry.Curve> ring, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -125,7 +125,8 @@ public static class LeadPathValidator
|
||||
var edge = execution.Motions[groupEdge];
|
||||
var joint = move.Layer == LayerType.Leadin ? edge.End : edge.Curve.Start;
|
||||
var contourJoint = move.Layer == LayerType.Leadin ? adjacent.Curve.Start : adjacent.End;
|
||||
foreach (var boundary in target.Rings.SelectMany(r => r))
|
||||
// A boundary clear of the contour's first motion contains none of its points.
|
||||
foreach (var boundary in target.BoundariesNear(adjacent.Curve.Extent))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
@@ -190,10 +191,7 @@ public static class LeadPathValidator
|
||||
// Every boundary of material this far away is farther than the zone.
|
||||
if (new PostVerificationGeometry.Extent(start.X, start.Y, start.X, start.Y)
|
||||
.IsClearOfQueriesFrom(material.Extent, pierceRadius))
|
||||
{
|
||||
Charge(material.BoundaryCount);
|
||||
continue;
|
||||
}
|
||||
if (Clearance(material) is { } distance)
|
||||
return (Short(distance, "another placed part"), other);
|
||||
}
|
||||
@@ -232,11 +230,11 @@ public static class LeadPathValidator
|
||||
}
|
||||
|
||||
// Material too far from the lead for any native boundary query to report a contact
|
||||
// (see IsClearOfQueriesFrom): the result the full scan gives, charged as if it ran.
|
||||
// Ray parity is still asked natively unless the lead lies beside the material's rows.
|
||||
// (see IsClearOfQueriesFrom): the result the full scan gives. Only queries that run
|
||||
// are charged, so many far parts or holes do not exhaust the budget. Ray parity is
|
||||
// still asked natively unless the lead lies beside the material's rows.
|
||||
bool FarInside(LeadMaterialSnapshot material)
|
||||
{
|
||||
Charge(material.BoundaryCount);
|
||||
var midpoint = move.Curve.Midpoint;
|
||||
return !material.Extent.IsClearOfRow(midpoint.Y) && material.ContainsMaterial(midpoint, token);
|
||||
}
|
||||
@@ -289,7 +287,8 @@ public static class LeadPathValidator
|
||||
internal static string MaterialContact(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material,
|
||||
Vector? permittedJoint, ref int budget, CancellationToken token, bool allowInterior = false)
|
||||
{
|
||||
foreach (var boundary in material.Rings.SelectMany(r => r))
|
||||
// Boundaries no native query from the curve can reach report no contact; only the others run.
|
||||
foreach (var boundary in material.BoundariesNear(curve.Extent))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
|
||||
@@ -781,10 +781,12 @@ public sealed class PreparedContours
|
||||
throw new ArgumentException("Foreign contour choice.");
|
||||
ValidateChoice(choice);
|
||||
// Same owned clones as a real emission; the source shapes/settings are never used
|
||||
// directly, so the probe cannot drift the preparation or mutate it.
|
||||
// directly, so the probe cannot drift the preparation or mutate it. Isolated emission
|
||||
// reads only the chosen contour and the contour count, so only that contour is cloned.
|
||||
var owned = new Shape[shapes.Length];
|
||||
owned[choice.ContourOrdinal] = (Shape)shapes[choice.ContourOrdinal].Clone();
|
||||
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
|
||||
contourParameters, PerimeterOrdinal);
|
||||
owned, scribes.Select(e => e.Clone()).ToList(), choice, contourParameters, PerimeterOrdinal);
|
||||
}
|
||||
|
||||
// Each prefix is a standalone owned program, including the same scribes and break cuts once.
|
||||
|
||||
@@ -12,6 +12,11 @@ public sealed class ReleasedContourState
|
||||
{
|
||||
private readonly List<Obstacle> obstacles = new();
|
||||
private readonly bool reportMissingLeadIns;
|
||||
// How the last analysed part ended: the contours it numbered, and whether nothing it left
|
||||
// (open fragments, deferred findings, a reported tab, a dangling lead-in) can change how
|
||||
// motions appended to it are judged. See Continue.
|
||||
private int partContours;
|
||||
private bool partClosed;
|
||||
|
||||
public ReleasedContourState() : this(true) { }
|
||||
|
||||
@@ -24,7 +29,11 @@ public sealed class ReleasedContourState
|
||||
|
||||
public ReleasedContourState Copy()
|
||||
{
|
||||
var copy = new ReleasedContourState(reportMissingLeadIns);
|
||||
var copy = new ReleasedContourState(reportMissingLeadIns)
|
||||
{
|
||||
partContours = partContours,
|
||||
partClosed = partClosed,
|
||||
};
|
||||
copy.obstacles.AddRange(obstacles);
|
||||
return copy;
|
||||
}
|
||||
@@ -62,10 +71,33 @@ public sealed class ReleasedContourState
|
||||
return (findings.AsReadOnly(), fixedFindings.AsReadOnly());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks <paramref name="execution"/> as the continuation of the part the last check ended
|
||||
/// in, as if that part's whole execution had been checked with these motions appended: the
|
||||
/// findings are those the appended motions would add, and the state advances the same way.
|
||||
/// It needs that part to have ended with every cut closed and nothing deferred, and the
|
||||
/// continuation to open with a rapid and end the same way; otherwise the result is null and
|
||||
/// this state must be discarded, and the caller checks the whole execution instead. Fixed
|
||||
/// emissions and cutoffs are not continued.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<PostVerificationFinding> Continue(OwnedExecution execution, Vector arrival,
|
||||
int partNumber, CancellationToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(execution);
|
||||
PostVerificationGeometry.Validate(arrival);
|
||||
if (!partClosed || execution.Motions.Count == 0 || !execution.Motions[0].Rapid)
|
||||
return null;
|
||||
var moves = execution.Motions.ToArray();
|
||||
moves[0] = moves[0].WithStart(arrival);
|
||||
var findings = new List<PostVerificationFinding>();
|
||||
AnalyzeMoves(moves, false, findings, 1, partNumber, token, contoursBefore: partContours);
|
||||
return partClosed ? findings.AsReadOnly() : null;
|
||||
}
|
||||
|
||||
internal void AnalyzeMoves(IReadOnlyList<ExecutionMotion> moves, bool cutoff,
|
||||
List<PostVerificationFinding> findings, int plate, int part, CancellationToken token,
|
||||
Func<int, bool> isFixed = null, List<PostVerificationFinding> fixedFindings = null,
|
||||
bool proveOpenCuts = false)
|
||||
bool proveOpenCuts = false, int contoursBefore = 0)
|
||||
{
|
||||
var current = new CutGroup();
|
||||
var unfinished = new List<CutGroup>();
|
||||
@@ -78,7 +110,7 @@ public sealed class ReleasedContourState
|
||||
CutGroup previous = null;
|
||||
var deferred = new List<(List<PostVerificationFinding> Target, int Index, PostVerificationFinding Finding)>();
|
||||
var hasLead = false;
|
||||
var contourNumber = 0;
|
||||
var contourNumber = contoursBefore;
|
||||
var inBreakCut = false;
|
||||
var reportedTab = false;
|
||||
for (var index = 0; index < moves.Count; index++)
|
||||
@@ -222,6 +254,9 @@ public sealed class ReleasedContourState
|
||||
"Multiple interrupted/open cutting fragments remain. Their combined cuts may release material; " +
|
||||
"they cannot be assumed to be retained by tabs. Review rapid travel manually."),
|
||||
unfinished.Any(chain => chain.Fixed));
|
||||
partContours = contourNumber;
|
||||
partClosed = !cutoff && isFixed == null && unfinished.Count == 0 && deferred.Count == 0 && !reportedTab
|
||||
&& !inBreakCut && loose.Leads.Count == 0;
|
||||
|
||||
void Add(PostVerificationFinding finding, bool fixedEmission)
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@ internal static class PostVerificationGeometry
|
||||
|
||||
// Math.Max propagates NaN and no comparison with NaN holds, so NaN and infinite bounds
|
||||
// (including the empty extent's) fail this test too.
|
||||
private bool IsWellConditioned =>
|
||||
internal bool IsWellConditioned =>
|
||||
System.Math.Max(System.Math.Max(System.Math.Abs(MinX), System.Math.Abs(MaxX)),
|
||||
System.Math.Max(System.Math.Abs(MinY), System.Math.Abs(MaxY))) <= WellConditionedLimit;
|
||||
}
|
||||
|
||||
@@ -550,13 +550,14 @@ internal static class JointCuttingPlanSearch
|
||||
var arrival = node.Active?.Arrival ?? node.Position;
|
||||
var before = node.Active?.Before ?? node.Checker;
|
||||
var boundary = node.Active?.Boundary ?? node;
|
||||
var chosen = choices.Select(e => e.ContourOrdinal).ToHashSet();
|
||||
var contours = prepared.HasCutPerimeter && choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
|
||||
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
|
||||
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !chosen.Contains(c));
|
||||
// First try one ranked hole chain per endpoint. A later-part failure then
|
||||
// changes the endpoint before replaying all earlier hole combinations.
|
||||
// The retained pass below still searches every entry and hole order.
|
||||
if (preference != null)
|
||||
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
|
||||
contours = contours.OrderBy(preference.RankOf).ThenBy(c => c);
|
||||
if (preferredOnly && preference != null)
|
||||
contours = contours.Take(1);
|
||||
foreach (var contour in contours)
|
||||
@@ -570,9 +571,9 @@ internal static class JointCuttingPlanSearch
|
||||
entries = [perimeter];
|
||||
else
|
||||
{
|
||||
contourRank = Array.IndexOf(preference.Route, contour);
|
||||
contourRank = preference.RankOf(contour);
|
||||
var downstream = preference.Route.Skip(contourRank + 1)
|
||||
.FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
|
||||
.FirstOrDefault(c => !chosen.Contains(c), -1);
|
||||
var target = downstream < 0 ? preference.PerimeterPierce
|
||||
: preference.Pierces[downstream];
|
||||
// The coarse pass already checked one owned, lead-feasible entry
|
||||
@@ -601,6 +602,19 @@ internal static class JointCuttingPlanSearch
|
||||
{
|
||||
CountExpansion(source); // Before emission/native queries, including rejected candidates.
|
||||
var prefix = choices.Append(entries[entry]).ToArray();
|
||||
// A many-holed part's middle steps check only the contour they add; the full
|
||||
// prefix runs when that cannot be done exactly, and always for the last contour.
|
||||
if (Continues(source, choices, prefix)
|
||||
&& ContinueContour(source, entries[entry], node, attempt) is { } step)
|
||||
{
|
||||
if (step.Checker == null) continue;
|
||||
var added = node.Active.Distance + step.Execution.RapidDistanceFrom(node.Position);
|
||||
yield return new(new Node(node.Order, step.Execution.DeparturePoint, step.Checker,
|
||||
new(source, prefix, arrival, before, added, boundary,
|
||||
node.Active.Perimeter, preference), null),
|
||||
added - node.Active.Distance, source.SourceOrdinal, contour, entry, entry, contourRank);
|
||||
continue;
|
||||
}
|
||||
Program program;
|
||||
OwnedExecution execution;
|
||||
try
|
||||
@@ -1147,6 +1161,54 @@ internal static class JointCuttingPlanSearch
|
||||
execution.DeparturePoint, checker, null, node), severity, execution.RapidDistanceFrom(arrival));
|
||||
}
|
||||
|
||||
/// <summary>Parts with fewer contours always check the whole prefix, as they always have.</summary>
|
||||
internal const int ContinuedContours = 64;
|
||||
|
||||
// Whether a step may check only the contour it adds: a middle contour (not the first,
|
||||
// which also cuts the part's scribes and open cuts, nor the last, whose full prefix is
|
||||
// the part's certified program) of a many-holed part, with no drawing-pinned contour.
|
||||
private static bool Continues(FixedProgramPlacement source, ContourChoice[] choices, ContourChoice[] prefix) =>
|
||||
!source.IsCutOff && source.Prepared.Count >= ContinuedContours && choices.Length > 0
|
||||
&& prefix.Length < source.Prepared.Count && !prefix.Any(c => source.Prepared.IsPinned(c.ContourOrdinal));
|
||||
|
||||
// Each choice's own motions, read once; the rapid into it starts where each check says.
|
||||
private readonly Dictionary<ContourChoice, OwnedExecution> contourMotions = [];
|
||||
|
||||
/// <summary>
|
||||
/// Checks one added contour against the state its prefix left, as <see cref="Check"/> judges
|
||||
/// it inside the whole prefix: earlier contours' rapids and leads were checked when they were
|
||||
/// added, and contour emission never reads other choices. Null when that cannot be done
|
||||
/// exactly (the isolated emission refused, or the checker cannot continue the part); the
|
||||
/// caller then checks the full prefix. A step whose checks fail has a null checker.
|
||||
/// </summary>
|
||||
private ContinuedStep ContinueContour(FixedProgramPlacement source, ContourChoice choice, Node node, Attempt attempt)
|
||||
{
|
||||
if (!contourMotions.TryGetValue(choice, out var execution))
|
||||
{
|
||||
try
|
||||
{
|
||||
execution = WithoutScribes(ExecutionMotionReader.Read(
|
||||
source.Prepared.EmitCandidateForValidation(choice), source.Location, null, token));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
contourMotions[choice] = execution;
|
||||
}
|
||||
var checker = node.Checker.Copy();
|
||||
var findings = checker.Continue(execution, node.Position, source.SourceOrdinal + 1, token);
|
||||
if (findings == null)
|
||||
return null;
|
||||
rejected.AddRange(Map(snapshot, findings));
|
||||
attempt.NoteCrossings(source.SourceOrdinal, findings);
|
||||
var lead = LeadPathValidator.Check(execution, source.Material, materials, null, [], token, leadContacts,
|
||||
snapshot.PierceZoneRadius);
|
||||
if (!lead.IsComplete || !lead.IsClear)
|
||||
rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
|
||||
return new(execution, findings.Count == 0 && lead.IsComplete && lead.IsClear ? checker : null);
|
||||
}
|
||||
|
||||
// The strict checks of Check, counted instead of refused.
|
||||
private RouteSeverity Score(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
|
||||
ReleasedContourState checker, IReadOnlyList<ContourChoice> choices = null)
|
||||
@@ -1249,7 +1311,14 @@ 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);
|
||||
IReadOnlyDictionary<int, Vector> Pierces, Vector PerimeterPierce)
|
||||
{
|
||||
private readonly Dictionary<int, int> ranks = Route.Select((contour, rank) => (contour, rank))
|
||||
.DistinctBy(r => r.contour).ToDictionary(r => r.contour, r => r.rank);
|
||||
|
||||
/// <summary>The contour's place along <see cref="Route"/>, or -1 when it is not on it.</summary>
|
||||
internal int RankOf(int contour) => ranks.TryGetValue(contour, out var rank) ? rank : -1;
|
||||
}
|
||||
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
|
||||
ReleasedContourState Before, double Distance, Node Boundary,
|
||||
ContourChoice Perimeter = null, HolePreference Preference = null);
|
||||
@@ -1267,6 +1336,7 @@ internal static class JointCuttingPlanSearch
|
||||
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry,
|
||||
int Rank = int.MinValue, int ContourRank = 0);
|
||||
private sealed record SafestStep(Node Node, RouteSeverity Severity, double Distance);
|
||||
private sealed record ContinuedStep(OwnedExecution Execution, ReleasedContourState Checker);
|
||||
private sealed class Frame(Node node)
|
||||
{
|
||||
internal Node Node { get; } = node;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// A many-holed part's middle search steps check only the contour they add, continuing the
|
||||
/// state its prefix left. That must judge each prefix exactly as checking the whole prefix
|
||||
/// from before the part does: the same findings, numbered the same way, from the same place.
|
||||
/// </summary>
|
||||
public class ContinuedContourCheckTests
|
||||
{
|
||||
private const int Side = 9;
|
||||
private static readonly Vector Location = new(5.3, 2.7);
|
||||
private static readonly Vector Arrival = new(-1, -1);
|
||||
|
||||
[Fact]
|
||||
public void ContinuedChecks_MatchFullPrefixChecks_IncludingACrossing()
|
||||
{
|
||||
var prepared = PreparedContours.Capture(Plate(), Parameters());
|
||||
// Row by row, except that hole 1 is cut before hole 0, so the rapid from hole 0 on to
|
||||
// hole 2 crosses the completed hole 1 and every later prefix repeats that finding.
|
||||
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToList();
|
||||
(holes[0], holes[1]) = (holes[1], holes[0]);
|
||||
var choices = new List<ContourChoice>();
|
||||
var position = Arrival - Location;
|
||||
foreach (var hole in holes)
|
||||
{
|
||||
var choice = prepared.ClosestEntry(hole, position);
|
||||
choices.Add(choice);
|
||||
position = prepared.HoleCentres()[hole]!.Value;
|
||||
}
|
||||
|
||||
ReleasedContourState continued = null;
|
||||
Vector departure = default;
|
||||
var accumulated = new List<string>();
|
||||
for (var length = 1; length <= choices.Count; length++)
|
||||
{
|
||||
var prefix = choices.Take(length).ToArray();
|
||||
var full = ExecutionMotionReader.Read(prepared.EmitPrefix(prefix), Location, Arrival, default);
|
||||
var fullChecker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||
var fullFindings = fullChecker.Check(full, Arrival, 1).Select(Describe).ToList();
|
||||
|
||||
if (continued == null)
|
||||
{
|
||||
continued = fullChecker;
|
||||
accumulated.AddRange(fullFindings);
|
||||
}
|
||||
else
|
||||
{
|
||||
var step = Isolated(prepared, prefix[^1]);
|
||||
var added = continued.Continue(step, departure, 1, default);
|
||||
Assert.NotNull(added);
|
||||
accumulated.AddRange(added.Select(Describe));
|
||||
Assert.True(step.DeparturePoint.DistanceTo(full.DeparturePoint) < 1e-9,
|
||||
$"Prefix {length} departs from {step.DeparturePoint}, not {full.DeparturePoint}.");
|
||||
}
|
||||
Assert.Equal(fullFindings, accumulated);
|
||||
departure = full.DeparturePoint;
|
||||
}
|
||||
Assert.Contains(accumulated, f => f.Contains("contour 1 of part 1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Continue_NeedsAPartLeftClosed()
|
||||
{
|
||||
var prepared = PreparedContours.Capture(Plate(), Parameters());
|
||||
|
||||
Assert.Null(new ReleasedContourState(reportMissingLeadIns: false)
|
||||
.Continue(Isolated(prepared, prepared.ClosestEntry(0, Vector.Zero)), Arrival, 1, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyHoledPart_PlansReady_AndReplays()
|
||||
{
|
||||
var plate = new Nest().CreatePlate();
|
||||
plate.Size = new Size(20, 20);
|
||||
plate.Parts.Add(new Part(new Drawing("holes", Plate()), Location));
|
||||
|
||||
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||
expansionBudget: CuttingPlanBatch.PlateBudget(1), confirmedParameters: Parameters()));
|
||||
|
||||
Assert.True(Side * Side + 1 >= 64, "The fixture must take the continued path.");
|
||||
Assert.True(result.Status == CuttingPlanStatus.Ready,
|
||||
$"{result.Status}: " + string.Join("; ", result.Findings.Take(3).Select(f => f.Message)));
|
||||
Assert.True(result.IndependentlyReplayed);
|
||||
Assert.Equal(Side * Side + 1, result.ProposedOrder.Single().ContourChoices.Count);
|
||||
}
|
||||
|
||||
private static OwnedExecution Isolated(PreparedContours prepared, ContourChoice choice)
|
||||
{
|
||||
var execution = ExecutionMotionReader.Read(prepared.EmitCandidateForValidation(choice), Location, null, default);
|
||||
return execution;
|
||||
}
|
||||
|
||||
private static string Describe(OpenNest.Diagnostics.PostVerificationFinding finding) =>
|
||||
$"{finding.Kind}: {finding.Message}";
|
||||
|
||||
// A 10 x 10 square with a 9 x 9 grid of holes 0.5 across, one apart.
|
||||
private static Program Plate()
|
||||
{
|
||||
var p = ExplicitContourTests.Square(false);
|
||||
for (var row = 1; row <= Side; row++)
|
||||
for (var column = 1; column <= Side; column++)
|
||||
{
|
||||
p.MoveTo(column + 0.25, row);
|
||||
p.ArcTo(column + 0.25, row, column, row, RotationType.CCW);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private static CuttingParameters Parameters() => new()
|
||||
{
|
||||
ExternalLeadIn = new LineLeadIn { Length = 0.2, ApproachAngle = 90 },
|
||||
InternalLeadIn = new LineLeadIn { Length = 0.1, ApproachAngle = 90 },
|
||||
ArcCircleLeadIn = new LineLeadIn { Length = 0.1, ApproachAngle = 90 },
|
||||
ExternalLeadOut = new NoLeadOut(),
|
||||
InternalLeadOut = new NoLeadOut(),
|
||||
};
|
||||
}
|
||||
+20
-3
@@ -285,9 +285,15 @@ circles, and leads keep those results: a lead check skips another part's materia
|
||||
only when their extents are clear by that 0.001 plus 1e-6 times the span of both
|
||||
extents. The discriminant's rounding error is bounded by about 1e-7 times that span
|
||||
(about 2e-9 is seen), so a skipped material has no native contact; a 900000-long lead
|
||||
beside a small circle is still checked. A skipped scan is charged to the native query
|
||||
budget as if it ran, and ray parity is still asked natively unless the lead's midpoint
|
||||
lies outside the material's Y range. A pierce zone skips material farther than its
|
||||
beside a small circle is still checked. Only native queries that run are charged to
|
||||
the native query budget, so a skipped material costs nothing, and ray parity is still
|
||||
asked natively unless the lead's midpoint lies outside the material's Y range. Material
|
||||
with at least 64 boundaries keeps a grid of its boundary extents: a lead check, a
|
||||
contour-joint match or replay's contour accounting queries only the boundaries that the
|
||||
same per-pair clearance cannot skip, in ring order, and ray parity reads only the holes
|
||||
whose extents reach the point's row. Material capture certifies its boundaries the same
|
||||
way, sweeping extents along X instead of pairing every boundary with every other, so a
|
||||
part with thousands of holes stays within the limit. A pierce zone skips material farther than its
|
||||
radius by the same gap. Every prefix of a part repeats its earlier contours' leads, so one search
|
||||
remembers each other-material verdict by the lead curve's exact bits and the material's
|
||||
reference, and charges the native query budget the scan ran; replay never uses it. An arc's
|
||||
@@ -375,6 +381,17 @@ that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement.
|
||||
A cutout that contains the arrival point (the tool comes from a part nested in it) is
|
||||
routed from the arrival point itself, not its centre, so it is cut first, before a
|
||||
later hop to it would have to cross the other parts nested there.
|
||||
On a part with at least 64 contours, a middle step of that route (neither the first
|
||||
contour, which also cuts the part's scribes and open cuts, nor the last) checks only the
|
||||
contour it adds, continuing the completed-contour state its prefix left rather than emitting
|
||||
and replaying the whole prefix from before the part. Contour emission reads only its own
|
||||
contour, so the added rapid and leads are the ones the prefix would cut, and the continued
|
||||
check numbers contours as the full check does. It continues only when the prefix ended with
|
||||
every cut closed and nothing deferred, and never for a drawing-pinned contour, a cutoff or
|
||||
a fixed program; otherwise, and whenever the isolated emission refuses, the step checks its
|
||||
full prefix. The step that adds the last contour always emits and checks the whole part
|
||||
from before it, so each completed part's program is certified exactly as before. Parts with
|
||||
fewer contours check every prefix in full.
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user