fix(cutting): plan outlines with a seam gap before an arc, and memoize lead checks

A converted outline whose seam closes within the admitted 1e-6 gap could
only be entered at its seam: entered anywhere else, the arc after the
seam started off its own circle and every emission was refused as an
inconsistent radius. The search then spent its whole budget on that
part and the plate never planned. Emission now bridges the gap with a
straight move and cuts the arc as drawn; the verifier already admits
that slack. An entry on the seam vertex is left as before.

Each prefix of a part re-checked its earlier contours' leads against
every other part's material. One search now remembers each verdict by
the lead curve's exact bits and the material's reference, charging the
same native query budget; replay never uses it. On the reported plate
the memo alone gives a byte-identical result in 57 s instead of 12 min 47 s;
with the seam fix the plate plans Ready in about 17 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 21:21:20 -04:00
1 parent d317a90f1e
commit fb505ef0ff
9 files changed
+179 -7

No files matched your search

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using OpenNest.Diagnostics;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Remembers whether a lead curve stays clear of another part's material, so a search that
/// re-checks the same emitted leads (every prefix of a part repeats its earlier contours' leads,
/// and backtracking revisits them) runs each native query set once. A hit returns exactly what
/// <see cref="LeadPathValidator.MaterialContact"/> returned for that curve and material and charges
/// the same native query budget. Curves match by exact bits and materials by reference; the
/// materials must not change while the memo is in use. Scans that threw are not remembered and
/// run again. One instance belongs to one single-threaded search; replay must not use one.
/// </summary>
internal sealed class LeadContactMemo
{
private readonly Dictionary<(PostVerificationGeometry.CurveKey Curve, LeadMaterialSnapshot Material), Scan> scans = new();
/// <summary>Remembered native queries answered without running (Debug cost tests).</summary>
internal long Hits { get; private set; }
internal bool TryGet(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, out Scan scan)
{
if (!scans.TryGetValue((curve.Key, material), out scan))
return false;
Hits++;
return true;
}
internal void Add(PostVerificationGeometry.Curve curve, LeadMaterialSnapshot material, Scan scan) =>
scans[(curve.Key, material)] = scan;
/// <summary>A completed scan: whether it found contact and how many boundary queries it charged.</summary>
internal readonly record struct Scan(bool Contact, int Queries);
}
@@ -24,6 +24,11 @@ public static class LeadPathValidator
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, CancellationToken token = default) =>
Check(execution, target, otherMaterials, null, null, token);
/// <summary>As the public check, remembering other-material results in <paramref name="memo"/>.</summary>
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, LeadContactMemo memo, CancellationToken token) =>
Check(execution, target, otherMaterials, null, null, token, memo);
/// <summary>
/// As the public check, but a lead motion that <paramref name="isFixed"/> owns (by motion
/// index) is part of a fixed emission: its failure is added to <paramref name="fixedFailures"/>
@@ -31,7 +36,7 @@ public static class LeadPathValidator
/// </summary>
internal static LeadPathValidationResult Check(OwnedExecution execution, LeadMaterialSnapshot target,
IReadOnlyList<LeadMaterialSnapshot> otherMaterials, Func<int, bool> isFixed,
List<FixedLeadFailure> fixedFailures, CancellationToken token)
List<FixedLeadFailure> fixedFailures, CancellationToken token, LeadContactMemo memo = null)
{
token.ThrowIfCancellationRequested();
if (execution == null || target == null || otherMaterials == null)
@@ -144,7 +149,7 @@ public static class LeadPathValidator
var material = otherMaterials[other];
if (ReferenceEquals(material, target))
continue;
if (CheckMaterial(material, null) != null)
if (OtherContact(material))
return (new(true, false, "Lead contacts or enters another placed material. "
+ "Try spacing the parts farther apart or reducing the lead-in/lead-out length, then replan. "
+ "For locked parts, edit the leads or unlock the part before replanning."), other);
@@ -153,6 +158,25 @@ public static class LeadPathValidator
string CheckMaterial(LeadMaterialSnapshot material, Vector? permittedJoint) =>
MaterialContact(move.Curve, material, permittedJoint, ref budget, token);
// Exactly the unmemoized check: a remembered scan charges the queries it ran
// and throws where running it again would exhaust the budget.
bool OtherContact(LeadMaterialSnapshot material)
{
if (memo == null)
return CheckMaterial(material, null) != null;
if (memo.TryGet(move.Curve, material, out var scan))
{
if (budget - scan.Queries < 0)
throw new NotSupportedException("Lead validation exceeds the native query limit.");
budget -= scan.Queries;
return scan.Contact;
}
var before = budget;
var contact = CheckMaterial(material, null) != null;
memo.Add(move.Curve, material, new(contact, before - budget));
return contact;
}
}
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
@@ -1059,15 +1059,27 @@ namespace OpenNest.CNC.CuttingStrategy
)
{
var moves = new List<ICode>();
var position = startPoint;
foreach (var entity in shape.Entities)
{
if (entity is Line line)
{
moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
position = line.EndPoint;
}
else if (entity is Arc arc)
{
// A contour entered away from its seam crosses the seam's admitted closure gap
// mid-cut. Starting the arc there would move its start off its own circle, so
// bridge the gap with a straight move and cut the arc exactly as drawn. A lead-in
// must join the contour itself, so an entry on the seam is left as it was.
var arcStart = arc.StartPoint();
var gap = position.DistanceTo(arcStart);
if (moves.Count > 0 && gap > Diagnostics.PostVerificationGeometry.Epsilon
&& CuttingPlanning.NominalContourClosure.IsClosed(position, arcStart))
moves.Add(new LinearMove(arcStart) { Layer = layer });
position = arc.EndPoint();
moves.Add(
new ArcMove(
arc.EndPoint(),
@@ -1084,6 +1096,7 @@ namespace OpenNest.CNC.CuttingStrategy
moves.Add(
new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer }
);
position = startPoint;
}
else
{
@@ -125,6 +125,10 @@ internal static class PostVerificationGeometry
return angle < 0 ? angle + TwoPi : angle;
}
/// <summary>Exact identity of a <see cref="Curve"/>'s defining values (see <see cref="Curve.Key"/>).</summary>
internal readonly record struct CurveKey(long StartX, long StartY, long EndX, long EndY,
bool HasCenter, long CenterX, long CenterY, long Radius, long Sweep);
internal sealed class Curve
{
private Curve(Vector start, Vector end, Vector? center, double radius, double sweep)
@@ -172,6 +176,16 @@ internal static class PostVerificationGeometry
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
: (Start + End) * 0.5;
/// <summary>
/// The exact bits of every value a native query reads, so two curves with equal keys
/// give identical query results. Not a geometric tolerance: equal shapes built from
/// different roundoff get different keys.
/// </summary>
internal CurveKey Key => new(Bits(Start.X), Bits(Start.Y), Bits(End.X), Bits(End.Y),
Center.HasValue, Bits(Center?.X ?? 0), Bits(Center?.Y ?? 0), Bits(Radius), Bits(Sweep));
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
/// <summary>
@@ -48,10 +48,20 @@ public sealed class ContourEntryFeasibility
private readonly LeadMaterialSnapshot ownMaterial;
private readonly LeadMaterialSnapshot[] materials;
private readonly Dictionary<Key, ContourFeasibilityVerdict> cache = new();
private readonly LeadContactMemo? leadContacts;
public ContourEntryFeasibility(PreparedContours prepared, Vector location,
LeadMaterialSnapshot ownMaterial, IReadOnlyList<LeadMaterialSnapshot> otherMaterials)
: this(prepared, location, ownMaterial, otherMaterials, null)
{
}
/// <param name="leadContacts">Shares exact lead/material results with the caller's search.</param>
internal ContourEntryFeasibility(PreparedContours prepared, Vector location,
LeadMaterialSnapshot ownMaterial, IReadOnlyList<LeadMaterialSnapshot> otherMaterials,
LeadContactMemo? leadContacts)
{
this.leadContacts = leadContacts;
this.prepared = prepared ?? throw new ArgumentException("Prepared contours are required.", nameof(prepared));
this.ownMaterial = ownMaterial ?? throw new ArgumentException("Own material snapshot is required.", nameof(ownMaterial));
if (otherMaterials == null)
@@ -110,7 +120,9 @@ public sealed class ContourEntryFeasibility
{
var program = prepared.EmitCandidateForValidation(choice);
var execution = ExecutionMotionReader.Read(program, location, null, token);
var result = LeadPathValidator.Check(execution, ownMaterial, materials, token);
var result = leadContacts == null
? LeadPathValidator.Check(execution, ownMaterial, materials, token)
: LeadPathValidator.Check(execution, ownMaterial, materials, leadContacts, token);
if (!result.IsComplete)
return new(ContourFeasibilityStatus.Incomplete, result.Reason);
return result.IsClear
@@ -195,6 +195,9 @@ internal static class JointCuttingPlanSearch
// One feasibility adapter per source part per captured planning attempt: verdicts
// memoize per owned choice for the attempt, never statically or across attempts.
private readonly Dictionary<int, ContourEntryFeasibility> feasibility = [];
// Lead/material verdicts for exact curves, shared by prechecks and prefix checks of this
// walk: each prefix repeats its earlier contours' leads. Replay never uses it.
private readonly LeadContactMemo leadContacts = new();
private readonly HashSet<int> reportedNoFit = [];
private readonly Dictionary<int, Entity[]> hostContours = [];
@@ -624,7 +627,7 @@ internal static class JointCuttingPlanSearch
private ContourEntryFeasibility Adapter(FixedProgramPlacement source) =>
feasibility.TryGetValue(source.SourceOrdinal, out var known) ? known
: feasibility[source.SourceOrdinal] = new ContourEntryFeasibility(
source.Prepared, source.Location, source.Material, materials);
source.Prepared, source.Location, source.Material, materials, leadContacts);
private ContourFeasibilityVerdict Evaluate(FixedProgramPlacement source, ContourEntryCandidate candidate)
{
@@ -741,7 +744,7 @@ internal static class JointCuttingPlanSearch
attempt.NoteCrossings(source.SourceOrdinal, findings);
// A fixed cutoff has no material or leads to certify; its rapids are still checked.
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
: LeadPathValidator.Check(execution, source.Material, materials, isFixed, [], token);
: LeadPathValidator.Check(execution, source.Material, materials, isFixed, [], token, leadContacts);
if (!lead.IsComplete || !lead.IsClear)
rejected.Add(Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
return findings.Count == 0 && lead.IsComplete && lead.IsClear;
@@ -106,6 +106,35 @@ public class NearbyMaterialCheckTests
Assert.False(result.IsClear);
}
[Fact]
public void MemoizedLeadCheck_ReturnsTheNativeResultOnEveryCall()
{
// The search re-checks the same emitted leads on every prefix of a part. Remembered
// answers must be the native ones, the rounding contact far from a small circle included.
var target = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(0, -2, 2, 0.0021), Vector.Zero);
var circle = new Program();
circle.MoveTo(-1 + 0.0001, 0);
circle.Codes.Add(new ArcMove(new Vector(-1 + 0.0001, 0), new Vector(-1, 0), RotationType.CCW));
var far = LeadMaterialSnapshot.Capture(circle, Vector.Zero);
var clear = LeadMaterialSnapshot.Capture(LeadPathValidationTests.Rectangle(5, 5, 6, 6), Vector.Zero);
var lead = new Program();
lead.MoveTo(-900000, 0.0021);
lead.Codes.Add(new LinearMove(0, 0.0021) { Layer = LayerType.Leadin });
lead.LineTo(0, -2);
var memo = new LeadContactMemo();
foreach (var others in new[] { new[] { target, clear }, [target, clear, far], [far, target] })
{
var expected = LeadPathValidator.Check(Read(lead), target, others);
for (var call = 0; call < 2; call++)
{
var actual = LeadPathValidator.Check(Read(lead), target, others, memo, default);
Assert.Equal(expected, actual);
}
}
Assert.True(memo.Hits > 0);
}
[Fact]
public void RapidJustOutsideASmallCompletedCircle_IsStillACrossing()
{
@@ -149,6 +149,42 @@ public class SeamClosureReplayTests
Assert.False(result.IndependentlyReplayed);
}
[Theory]
[InlineData(5e-7)]
[InlineData(1e-6)]
public void AdmittedClosureSeam_BeforeAnArc_EmitsFromEveryEntry(double seam)
{
// The ring's first entity is an arc and the last edge stops short of its start. Entered
// anywhere but the seam, the path reaches that arc off its circle; bridging the gap with a
// straight move keeps the arc exact instead of refusing it as an inconsistent radius,
// which left a converted outline with only one usable entry.
var program = new Program();
program.MoveTo(0, 0);
program.ArcTo(new Vector(10, 0), new Vector(5, 5), RotationType.CCW);
program.LineTo(10, 10);
program.LineTo(0, 10);
program.LineTo(0, seam);
var settings = ExplicitContourTests.Parameters();
var snapshot = Capture(new Part(new Drawing("seam", program)), settings);
var source = snapshot.Placements[0];
Assert.True(source.Material!.IsComplete, source.Material.Reason);
// An entry on the seam vertex itself puts the gap before the first cut, where no lead-in
// can join it; that one stays refused as before.
var entries = source.Prepared!.Entries(0, new Vector(-5, 5), 64)
.Where(c => c.Point.DistanceTo(new Vector(0, seam)) > 1e-9).ToArray();
Assert.True(entries.Length > 1);
foreach (var choice in entries)
{
var emitted = source.Prepared.Emit([choice]);
var result = CuttingPlanService.ReplayPrograms(snapshot,
[source.Propose(emitted, Read(emitted), [choice])], 0, default);
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
Assert.True(result.IndependentlyReplayed);
}
}
[Fact]
public void GapBeyondAdmittedClosure_StillRefusesAtCapture()
{
+8 -2
View File
@@ -66,7 +66,11 @@ inside both spans. Lead and rapid checks keep their conservative contact queries
Source contour closure
in material capture and emitter preparation accepts endpoint gaps up to 0.000001
model units (inches for an inch nest), with a floating-point roundoff allowance
capped at 1e-12. It does not rewrite source endpoints. Native intersections,
capped at 1e-12. It does not rewrite source endpoints. A contour entered away from its
seam crosses that gap mid-cut; when an arc follows it, emission bridges the gap with a
straight move and cuts the arc as drawn, rather than starting the arc off its own circle
(which every check refuses as an inconsistent radius). An entry on the seam vertex has no
such bridge, since a lead-in must join the contour itself. Native intersections,
executed-motion checks and pre-post verification retain their existing tolerances.
Arcs of one circle whose centres were each derived from their own endpoints agree only
@@ -214,7 +218,9 @@ rapid, ten times the widest band any native contact query allows beyond an
extent; anything closer, touching included, gets the full native check. Lead
checks examine every other part's material: native line/circle queries can report
rounding contacts for long leads far from small circles, and leads keep those
results. An arc's
results. 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
extent is its whole supporting circle widened to the distance at which the native
contact query still counts it as touched (for a very small arc up to 0.0001 beyond
its radius). Nothing is skipped when either extent has a nonfinite bound or reaches