diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index b0af7a20..ed2810ce 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -511,6 +511,17 @@ public sealed class PreparedContours
return centres;
}
+ ///
+ /// Whether lies inside the internal contour .
+ /// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
+ ///
+ internal bool HoleContains(int contourOrdinal, Vector point)
+ {
+ var shape = GetShape(contourOrdinal);
+ return contourOrdinal != PerimeterOrdinal && shape.BoundingBox.Contains(point)
+ && shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
+ }
+
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
diff --git a/OpenNest.Core/CNC/CuttingPlanning/SeamOvercut.cs b/OpenNest.Core/CNC/CuttingPlanning/SeamOvercut.cs
new file mode 100644
index 00000000..e723ffe4
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/SeamOvercut.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+///
+/// A seam overcut baked into a clean contour: the last cut runs on past the contour's own start
+/// along its first cut, then a straight jog returns to the start (converted nests carry a source
+/// CAM's 0.03 overcut this way). The retraced tail encloses no area, so trimming it leaves the
+/// material unchanged; left in, the contour touches itself at its seam and nothing can certify it.
+/// Only that exact shape is recognized. Any other self-contact is still refused by its consumer.
+///
+internal static class SeamOvercut
+{
+ ///
+ /// The same program when it has no seam overcut, otherwise an owned copy without them.
+ /// The source program is never edited.
+ ///
+ internal static Program Trimmed(Program program)
+ {
+ var edits = Find(program);
+ if (edits.Count == 0)
+ return program;
+ var copy = (Program)program.Clone();
+ Apply(copy, edits);
+ return copy;
+ }
+
+ /// Trims seam overcuts from an owned program in place; returns how many were removed.
+ internal static int Trim(Program program)
+ {
+ var edits = Find(program);
+ Apply(program, edits);
+ return edits.Count;
+ }
+
+ private static void Apply(Program program, List edits)
+ {
+ // Last first, so removing a return jog does not shift an earlier edit's indices.
+ for (var i = edits.Count - 1; i >= 0; i--)
+ {
+ var edit = edits[i];
+ var last = (Motion)program.Codes[edit.LastCut];
+ last.EndPoint = edit.EndPoint;
+ if (last is ArcMove arc)
+ arc.CenterPoint = edit.CenterPoint;
+ program.Codes.RemoveAt(edit.ReturnJog);
+ }
+ }
+
+ private static List Find(Program program)
+ {
+ var edits = new List();
+ if (program?.Codes == null)
+ return edits;
+ var incremental = program.Mode == Mode.Incremental;
+ var run = new List();
+ // Null while the position is unknown: after a subprogram call in an absolute program.
+ Vector? position = new Vector();
+ for (var index = 0; index < program.Codes.Count; index++)
+ {
+ var code = program.Codes[index];
+ if (code is not Motion motion)
+ {
+ Finish();
+ if (code is SubProgramCall && !incremental)
+ position = null;
+ continue;
+ }
+ // Incremental coordinates only need a consistent origin within one contour.
+ var reference = incremental ? position ?? new Vector() : new Vector();
+ var end = reference + motion.EndPoint;
+ var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
+ or ArcMove { Layer: LayerType.Cut or LayerType.Display };
+ if (!cut || position == null)
+ {
+ Finish();
+ position = end;
+ continue;
+ }
+ run.Add(new(index, motion, position.Value, end,
+ motion is ArcMove arc ? reference + arc.CenterPoint : null, reference));
+ position = end;
+ }
+ Finish();
+ return edits;
+
+ void Finish()
+ {
+ if (TryTrim(run, out var edit))
+ edits.Add(edit);
+ run.Clear();
+ }
+ }
+
+ private static bool TryTrim(List run, out Edit edit)
+ {
+ edit = default;
+ if (run.Count < 3)
+ return false;
+ var first = run[0];
+ var last = run[^2];
+ var jog = run[^1];
+ var seam = first.Start;
+ var overcut = last.End;
+ if (jog.Motion is not LinearMove || !NominalContourClosure.IsClosed(seam, jog.End)
+ || HasVariables(last.Motion) || HasVariables(jog.Motion)
+ || !IsFinite(seam) || !IsFinite(overcut) || !IsFinite(last.Start)
+ || overcut.DistanceTo(seam) <= Tolerance.Epsilon || last.Start.DistanceTo(seam) <= Tolerance.Epsilon)
+ return false;
+
+ if (first.Center == null && last.Center == null)
+ {
+ var length = first.Start.DistanceTo(first.End);
+ if (length <= Tolerance.Epsilon)
+ return false;
+ var direction = (first.End - first.Start) * (1 / length);
+ // The last cut lies on the first cut's line, passes the seam and stops inside the first cut.
+ var along = Dot(overcut - seam, direction);
+ var before = Dot(seam - last.Start, direction);
+ if (System.Math.Abs(Cross(overcut - seam, direction)) > Tolerance.Epsilon
+ || System.Math.Abs(Cross(last.Start - seam, direction)) > Tolerance.Epsilon
+ || along <= Tolerance.Epsilon || along >= length - Tolerance.Epsilon || before <= Tolerance.Epsilon)
+ return false;
+ edit = new(last.Index, jog.Index, seam - last.Reference, new Vector());
+ return true;
+ }
+
+ if (first.Center is not { } firstCenter || last.Center is not { } lastCenter
+ || !IsFinite(firstCenter) || !IsFinite(lastCenter)
+ || ((ArcMove)first.Motion).Rotation != ((ArcMove)last.Motion).Rotation)
+ return false;
+ var radius = seam.DistanceTo(firstCenter);
+ if (radius <= Tolerance.Epsilon || firstCenter.DistanceTo(lastCenter) > Tolerance.Epsilon
+ || System.Math.Abs(overcut.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon
+ || System.Math.Abs(overcut.DistanceTo(firstCenter) - radius) > Tolerance.Epsilon
+ || System.Math.Abs(seam.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon)
+ return false;
+ var clockwise = ((ArcMove)first.Motion).Rotation == RotationType.CW;
+ var margin = Tolerance.Epsilon / radius;
+ // The overcut end lies inside the first arc's span, and the seam inside the last arc's.
+ var firstSweep = first.Start.DistanceTo(first.End) <= Tolerance.Epsilon
+ ? 2 * System.Math.PI : Travel(firstCenter, seam, first.End, clockwise);
+ var passed = Travel(firstCenter, seam, overcut, clockwise);
+ var lastSweep = Travel(lastCenter, last.Start, overcut, clockwise);
+ var toSeam = Travel(lastCenter, last.Start, seam, clockwise);
+ if (passed <= margin || passed >= System.Math.PI || passed >= firstSweep - margin
+ || toSeam <= margin || toSeam >= lastSweep - margin
+ || System.Math.Abs(lastSweep - toSeam - passed) > margin)
+ return false;
+
+ // Keep the shortened arc's center equidistant from both of its ends.
+ var chord = seam - last.Start;
+ var normal = new Vector(-chord.Y, chord.X) * (1 / last.Start.DistanceTo(seam));
+ var middle = (last.Start + seam) * 0.5;
+ var center = middle + normal * Dot(lastCenter - middle, normal);
+ edit = new(last.Index, jog.Index, seam - last.Reference, center - last.Reference);
+ return true;
+ }
+
+ /// The angle travelled around from one point to another, in [0, 2pi).
+ private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
+ {
+ var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
+ var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X);
+ var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
+ return travel < 0 ? travel + 2 * System.Math.PI : travel;
+ }
+
+ private static bool HasVariables(Motion motion) => motion.VariableRefs is { Count: > 0 };
+ private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
+ private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
+ private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
+
+ /// One cut of a contour in the walk's coordinates; maps them back to its code.
+ private readonly record struct Segment(int Index, Motion Motion, Vector Start, Vector End, Vector? Center, Vector Reference);
+
+ /// The last cut's new code-relative end and center, and the return jog to remove.
+ private readonly record struct Edit(int LastCut, int ReturnJog, Vector EndPoint, Vector CenterPoint);
+}
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
index e3292496..607b30e6 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
using OpenNest.Converters;
using OpenNest.Geometry;
@@ -73,8 +74,9 @@ public static class PlateOverlapAnalyzer
{
ValidateProgram(program, new HashSet(ReferenceEqualityComparer.Instance), false);
// Conversion creates fresh geometry, including expanded shared hole calls;
- // no cloning/rotation of a live program or subprogram is necessary.
- return new OverlapSource(program, ConvertProgram.ToGeometry(program)
+ // no cloning/rotation of a live program or subprogram is necessary. A baked-in
+ // seam overcut is trimmed on an owned copy: it encloses no area.
+ return new OverlapSource(program, ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
&& entity.Layer != SpecialLayers.Leadin
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
diff --git a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
index aefaff5b..5e78685b 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationGeometry.cs
@@ -260,18 +260,21 @@ internal static class PostVerificationGeometry
points.Clear();
// Coincident circles produce NaNs in the native discrete-contact query.
// Their support overlap is handled separately, without inventing crossings.
- overlap = SameSupport(other) && (InteriorWitness(Midpoint, other)
- || InteriorWitness(other.Midpoint, this)
- || InteriorWitness(Start, other) || InteriorWitness(End, other)
- || InteriorWitness(other.Start, this) || InteriorWitness(other.End, this));
+ // Supports that agree only within Epsilon (arcs of one circle whose centers were
+ // each derived from their own endpoints) put a witness up to that far off the other
+ // support. Test those spans at the admitted seam tolerance instead: shared spans
+ // still refuse, while arcs that only meet at a joint or lie apart are not overlap.
+ var witness = Center is { } c && other.Center is { } oc
+ && (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
+ overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness)
+ || InteriorWitness(other.Midpoint, this, witness)
+ || InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
+ || InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
if (SameSupport(other))
points.Clear();
foreach (var point in points)
Validate(point);
var nativeContactCount = points.Count;
- if (Center is { } c && other.Center is { } oc && SameSupport(other)
- && (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius))
- overlap = true; // Nearly coincident supports are uncertain, never clear.
foreach (var point in new[] { Start, End, other.Start, other.End })
if (Contains(point) && other.Contains(point)
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
@@ -284,9 +287,9 @@ internal static class PostVerificationGeometry
throw new NotSupportedException("Native contact query is numerically uncertain.");
return points;
- static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
+ static bool InteriorWitness(Vector point, Curve curve, double tolerance) => curve.Contains(point, tolerance)
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
- || (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
+ || (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
}
///
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs
new file mode 100644
index 00000000..f27bfef4
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// Proposes a whole-part order for plates with parts nested inside other parts' cutouts. A host
+/// is cut after everything inside it, so the tool reaches it from whichever inner part was cut
+/// last; from the middle of a crowded cutout every straight approach crosses a finished
+/// neighbour. Each host's inner parts are therefore toured as one run that ends beside the
+/// host's own contours, immediately before the host. Plates without nested parts get exactly
+/// 's order. Like that order, this is only a proposal.
+///
+internal static class CuttingNestedOrder
+{
+ /// One representative point per part ordinal.
+ /// Ordinals that must come before each part, learned rules included.
+ /// Per part ordinal, the parts nested inside its cutouts at any depth.
+ /// The point on a host's contours nearest a tool position.
+ internal sealed record Context(IReadOnlyList Centres,
+ IReadOnlyList> Prerequisites, IReadOnlyList> Inside,
+ Func HostEntry, CancellationToken Token);
+
+ /// The part ordinals to order; prerequisites outside this set count as done.
+ /// The modeled tool position before the first of these parts.
+ /// Optional position the run should end beside.
+ internal static int[] Plan(IReadOnlyList parts, Vector start, Context context, Vector? endpoint = null)
+ {
+ context.Token.ThrowIfCancellationRequested();
+ var set = parts.ToHashSet();
+ var rootOf = new Dictionary();
+ foreach (var host in parts)
+ foreach (var inner in context.Inside[host])
+ if (set.Contains(inner) && !context.Inside[inner].Contains(host))
+ rootOf[inner] = host;
+ if (rootOf.Count == 0)
+ return Flat();
+ // Follow nested hosts up to the outermost one present.
+ foreach (var inner in rootOf.Keys.ToArray())
+ while (rootOf.TryGetValue(rootOf[inner], out var outer))
+ rootOf[inner] = outer;
+ var roots = parts.Where(p => !rootOf.ContainsKey(p)).ToArray();
+ // A rule between parts of two groups orders the groups.
+ var groupRules = context.Centres.Select(_ => new HashSet()).ToArray();
+ foreach (var part in parts)
+ {
+ var group = rootOf.GetValueOrDefault(part, part);
+ foreach (var prerequisite in context.Prerequisites[part])
+ if (set.Contains(prerequisite) && rootOf.GetValueOrDefault(prerequisite, prerequisite) != group)
+ groupRules[group].Add(rootOf.GetValueOrDefault(prerequisite, prerequisite));
+ }
+ // A finished host is a large obstacle and can only be left across its own outside
+ // edge, so the parts beside it go first. Rules that say otherwise win.
+ var hostsLast = groupRules.Select(rules => new HashSet(rules)).ToArray();
+ var hosts = rootOf.Values.ToHashSet();
+ foreach (var host in hosts)
+ foreach (var root in roots)
+ if (!hosts.Contains(root))
+ hostsLast[host].Add(root);
+ int[] rootOrder;
+ try
+ {
+ rootOrder = CuttingPartOrder.Plan(roots, context.Centres, start, hostsLast, context.Token, endpoint);
+ }
+ catch (InvalidOperationException)
+ {
+ try
+ {
+ rootOrder = CuttingPartOrder.Plan(roots, context.Centres, start, groupRules, context.Token, endpoint);
+ }
+ catch (InvalidOperationException)
+ {
+ return Flat(); // Rules that cross groups both ways cannot keep the groups whole.
+ }
+ }
+ var order = new List(parts.Count);
+ var position = start;
+ foreach (var root in rootOrder)
+ {
+ var inner = parts.Where(p => rootOf.TryGetValue(p, out var host) && host == root).ToArray();
+ if (inner.Length != 0)
+ order.AddRange(Plan(inner, position, context, context.HostEntry(root, position)));
+ order.Add(root);
+ position = context.Centres[root];
+ }
+ var placed = new HashSet();
+ foreach (var part in order)
+ {
+ if (context.Prerequisites[part].Any(p => set.Contains(p) && !placed.Contains(p)))
+ return Flat();
+ placed.Add(part);
+ }
+ return order.ToArray();
+
+ int[] Flat() => CuttingPartOrder.Plan(parts, context.Centres, start, context.Prerequisites, context.Token, endpoint);
+ }
+}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
index 6b3efbd8..b1fbf940 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
@@ -23,8 +23,9 @@ internal static class CuttingPartOrder
/// One representative point per part ordinal.
/// The modeled tool position before the first of these parts.
/// Ordinals that must come before each part; must be acyclic.
+ /// Optional fixed final position after the last of these parts.
internal static int[] Plan(IReadOnlyList parts, IReadOnlyList centres, Vector start,
- IReadOnlyList> prerequisites, CancellationToken token)
+ IReadOnlyList> prerequisites, CancellationToken token, Vector? endpoint = null)
{
var local = new Dictionary();
for (var i = 0; i < parts.Count; i++)
@@ -33,7 +34,7 @@ internal static class CuttingPartOrder
var localPrerequisites = parts
.Select(p => (IReadOnlyCollection)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
.ToArray();
- return Plan(localCentres, start, localPrerequisites, token).Select(i => parts[i]).ToArray();
+ return Plan(localCentres, start, localPrerequisites, token, endpoint).Select(i => parts[i]).ToArray();
}
/// One representative point per part ordinal.
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index 188b388a..5681a728 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -101,6 +101,8 @@ public static class CuttingPlanService
// Exact placed/proposed payloads still use lossless OwnedProgramCopy.
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
+ // A baked-in seam overcut encloses no area; left in, the ring touches itself.
+ SeamOvercut.Trim(ownedClean);
// Material extent only: remote rapids and scribe marks must not widen it, or a
// genuine insert could fail the containment prefilter.
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 7461cc64..e7288bae 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -59,24 +59,27 @@ internal static class JointCuttingPlanSearch
var prerequisites = Enumerable.Range(0, count)
.Select(i => new HashSet(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
- var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres,
- snapshot.StartPoint, prerequisites, token);
+ var nesting = NestedOrder(snapshot, centres, prerequisites, token);
+ var sequence = CuttingNestedOrder.Plan(Enumerable.Range(0, count).ToArray(), snapshot.StartPoint, nesting);
Node resume = null;
while (true)
{
- if (preferEndpoints)
+ // A pass that teaches an order rule ends the round: the heavier passes would
+ // only repeat the same blocked approach along the same order.
+ Attempt attempt = null;
+ foreach (var (preferredOnly, contourRecovery) in preferEndpoints
+ ? new[] { (true, false), (false, true), (false, false) } : [(false, false)])
{
- var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true);
- if (preferred.Order != null)
- return walk.Ready(preferred.Order);
- var recovered = walk.Follow(sequence, stall, null, contourRecovery: true);
- if (recovered.Order != null)
- return walk.Ready(recovered.Order);
+ var pass = walk.Follow(sequence, stall, resume, preferredOnly, contourRecovery);
+ if (pass.Order != null)
+ return walk.Ready(pass.Order);
+ if (Learn(pass, prerequisites))
+ {
+ attempt = pass;
+ break;
+ }
}
- var attempt = walk.Follow(sequence, stall, resume);
- if (attempt.Order != null)
- return walk.Ready(attempt.Order);
- if (!Learn(attempt, prerequisites))
+ if (attempt == null)
{
// "Cut before" rules are a heuristic: a part blocked straight after another can
// still be reachable via a third. Search every order with what budget remains.
@@ -93,8 +96,7 @@ internal static class JointCuttingPlanSearch
// parts cut before it, and re-plan the rest from where the tool is at that point.
var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
resume = attempt.BoundaryAt(back);
- sequence = [.. sequence.Take(back), .. CuttingPartOrder.Plan(sequence[back..], centres,
- resume.Position, prerequisites, token)];
+ sequence = [.. sequence.Take(back), .. CuttingNestedOrder.Plan(sequence[back..], resume.Position, nesting)];
}
}
catch (BudgetExceededException)
@@ -107,6 +109,24 @@ internal static class JointCuttingPlanSearch
}
}
+ // Captured containment (never learned rules) and each host's placed cut contours.
+ private static CuttingNestedOrder.Context NestedOrder(CuttingPlanSnapshot snapshot, Vector[] centres,
+ HashSet[] prerequisites, CancellationToken token)
+ {
+ var inside = snapshot.Placements.Select((placement, host) => placement.IsCutOff ? []
+ : snapshot.Dependencies.PrerequisitesOf(host).Where(p => !snapshot.Placements[p].IsCutOff).ToArray()).ToArray();
+ var contours = new Dictionary();
+ return new(centres, prerequisites, inside, (host, position) =>
+ {
+ if (!contours.TryGetValue(host, out var entities))
+ contours[host] = entities = snapshot.Placements[host].Execution.Motions
+ .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
+ .Select(m => m.Curve.ToEntity()).ToArray();
+ return entities.Length == 0 ? centres[host]
+ : entities.Select(e => e.ClosestPointTo(position)).MinBy(point => point.DistanceTo(position));
+ }, token);
+ }
+
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
// would contradict an order already required. False when nothing new was learned.
private static bool Learn(Attempt attempt, HashSet[] prerequisites)
@@ -175,6 +195,7 @@ internal static class JointCuttingPlanSearch
// memoize per owned choice for the attempt, never statically or across attempts.
private readonly Dictionary feasibility = [];
private readonly HashSet reportedNoFit = [];
+ private readonly Dictionary hostContours = [];
internal int Expansions { get; private set; }
@@ -261,16 +282,19 @@ internal static class JointCuttingPlanSearch
/// dependency-ready remaining part once the current part counts as finished, stable
/// ordinal ties. Never the current or a finished part. Global coordinates.
///
- private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source)
+ private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source) =>
+ NextCut(node, sequence, source) is { } next ? Facing(source, next) : null;
+
+ private FixedProgramPlacement NextCut(Node node, int[] sequence, FixedProgramPlacement source)
{
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
finished.Add(source.SourceOrdinal);
if (sequence != null)
for (var i = node.Order.Length + 1; i < sequence.Length; i++)
if (!finished.Contains(sequence[i]))
- return Centre(snapshot.Placements[sequence[i]]);
+ return snapshot.Placements[sequence[i]];
var from = Centre(source);
- Vector? best = null;
+ FixedProgramPlacement best = null;
var bestDistance = double.PositiveInfinity;
foreach (var candidate in snapshot.Placements
.Where(p => !finished.Contains(p.SourceOrdinal)
@@ -281,12 +305,46 @@ internal static class JointCuttingPlanSearch
if (distance < bestDistance - 1e-9)
{
bestDistance = distance;
- best = Centre(candidate);
+ best = candidate;
}
}
return best;
}
+ // Where the tool may have to go next, in global coordinates: the look-ahead point and,
+ // for an ordinary next part, the corners of its cut extent. Its pierce can be anywhere
+ // on it, so an outside entry that sees all of them leaves the most ways open.
+ private Vector[] SightTargets(Node node, int[] sequence, FixedProgramPlacement source)
+ {
+ if (NextCut(node, sequence, source) is not { } next)
+ return [];
+ var facing = Facing(source, next);
+ var cuts = next.Execution.Motions
+ .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
+ .Select(m => m.Curve.ToEntity().BoundingBox).ToList();
+ if (cuts.Count == 0 || facing != Centre(next))
+ return [facing];
+ var box = cuts.GetBoundingBox();
+ return [facing, new(box.Left, box.Bottom), new(box.Right, box.Bottom), new(box.Left, box.Top), new(box.Right, box.Top)];
+ }
+
+ // The next cut as seen from the current part. A host's centre lies in the middle of
+ // its cutout, among the parts nested there; a part inside it leaves toward the
+ // nearest of the host's own contours instead.
+ private Vector Facing(FixedProgramPlacement source, FixedProgramPlacement next)
+ {
+ if (source.IsCutOff || next.IsCutOff
+ || !snapshot.Dependencies.PrerequisitesOf(next.SourceOrdinal).Contains(source.SourceOrdinal))
+ return Centre(next);
+ if (!hostContours.TryGetValue(next.SourceOrdinal, out var contours))
+ hostContours[next.SourceOrdinal] = contours = next.Execution.Motions
+ .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
+ .Select(m => m.Curve.ToEntity()).ToArray();
+ var from = Centre(source);
+ return contours.Length == 0 ? Centre(next)
+ : contours.Select(c => c.ClosestPointTo(from)).MinBy(point => point.DistanceTo(from));
+ }
+
///
/// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
@@ -559,7 +617,7 @@ internal static class JointCuttingPlanSearch
// One global->local conversion of target and arrival; geometry is already rotated.
Vector? local = LookAheadCentre(node, sequence, source) is { } target
? target - source.Location : null;
- return SelectEntries(source, contour, local, arrival);
+ return SelectEntries(source, contour, local, arrival, SightTargets(node, sequence, source));
}
private ContourEntryFeasibility Adapter(FixedProgramPlacement source) =>
@@ -578,13 +636,23 @@ internal static class JointCuttingPlanSearch
}
private IReadOnlyList SelectEntries(FixedProgramPlacement source, int contour,
- Vector? target, Vector arrival)
+ Vector? target, Vector arrival, Vector[] sightTargets = null)
{
// Last contour has no downstream target, but still needs its exact native
// closest-arrival fallback. Keep target null in the ranker: arrival is not
// a next cut and must not acquire facing-side priority.
var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token);
var ordered = catalogue.RankTowardNextCut(target, arrival);
+ // With no lead-out the tool leaves the part from its outside entry. From inside a
+ // slot or pocket the straight way to the next cut runs back over the part just
+ // cut, so entries with the clearest way out lead. Order only: every entry stays.
+ if (sightTargets is { Length: > 0 } && contour == source.Prepared.PerimeterOrdinal
+ && source.Material is { IsComplete: true } material)
+ {
+ var perimeter = material.Rings[0];
+ ordered = ordered.OrderBy(c => sightTargets.Count(sight => PostVerificationGeometry.Crosses(
+ c.Choice.Point + source.Location, sight, perimeter, token))).ToList();
+ }
var selection = ContourEntrySelection.Select(ordered,
candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
@@ -608,6 +676,12 @@ internal static class JointCuttingPlanSearch
var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray();
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
var localArrival = active.Arrival - source.Location;
+ // The tool arrives inside a cutout from a part nested there. That cutout's centre
+ // says nothing about where its cut can be reached: route it from where the tool is,
+ // before a later hop to it has to cross the other parts nested in it.
+ foreach (var hole in holes)
+ if (prepared.HoleContains(hole, localArrival))
+ centres[hole] = localArrival;
var endpoint = perimeter.Point;
var entries = new Dictionary();
var pierces = holes.ToDictionary(h => h, h => centres[h]);
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingNestedOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingNestedOrderTests.cs
new file mode 100644
index 00000000..cf9e283e
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingNestedOrderTests.cs
@@ -0,0 +1,117 @@
+using OpenNest.CNC;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class CuttingNestedOrderTests
+{
+ // Host 0 with a cutout holding parts 1-4 in a row, and loose parts 5 and 6 beside the host.
+ private static readonly Vector[] Centres =
+ [
+ new(50, 50), new(20, 50), new(40, 50), new(60, 50), new(80, 50), new(120, 10), new(120, 90),
+ ];
+
+ [Fact]
+ public void WithoutNestedParts_TheOrderIsThePlainTour()
+ {
+ var parts = Enumerable.Range(0, Centres.Length).ToArray();
+ var none = Centres.Select(_ => (IReadOnlyCollection)Array.Empty()).ToArray();
+
+ var order = CuttingNestedOrder.Plan(parts, Vector.Zero, Context(none, none));
+
+ Assert.Equal(CuttingPartOrder.Plan(parts, Centres, Vector.Zero, none, default), order);
+ }
+
+ [Fact]
+ public void InnerParts_AreCutAsOneRunEndingBesideTheHost_AfterThePartsAroundIt()
+ {
+ var parts = Enumerable.Range(0, Centres.Length).ToArray();
+ var (prerequisites, inside) = Nested();
+
+ var order = CuttingNestedOrder.Plan(parts, Vector.Zero, Context(prerequisites, inside));
+
+ Assert.Equal(parts, order.Order());
+ // Loose parts first, then the whole inner run, then the host.
+ Assert.Equal([5, 6], order.Take(2).Order());
+ Assert.Equal([1, 2, 3, 4], order.Skip(2).Take(4).Order());
+ Assert.Equal(0, order[^1]);
+ // The tool comes from part 6 on the right, so the run ends at the right-hand inner part.
+ Assert.Equal(4, order[^2]);
+ }
+
+ [Fact]
+ public void LearnedRule_AcrossTheGroupStillHolds()
+ {
+ var parts = Enumerable.Range(0, Centres.Length).ToArray();
+ var (prerequisites, inside) = Nested();
+ // "Cut inner part 2 before loose part 5": the host group can no longer simply go last.
+ prerequisites[5] = [2];
+
+ var order = CuttingNestedOrder.Plan(parts, Vector.Zero, Context(prerequisites, inside)).ToList();
+
+ Assert.Equal(parts, order.Order());
+ Assert.True(order.IndexOf(2) < order.IndexOf(5));
+ Assert.All(new[] { 1, 2, 3, 4 }, inner => Assert.True(order.IndexOf(inner) < order.IndexOf(0)));
+ }
+
+ [Fact]
+ public void RemainingParts_AreOrderedFromTheResumePosition()
+ {
+ var (prerequisites, inside) = Nested();
+
+ var order = CuttingNestedOrder.Plan([0, 2, 3, 4], new Vector(20, 50), Context(prerequisites, inside));
+
+ Assert.Equal(0, order[^1]);
+ Assert.Equal([2, 3, 4], order.Take(3).Order());
+ }
+
+ [Fact]
+ public void CrowdedCutout_IsLeftThroughTheHostsOwnContours()
+ {
+ // A 40x40 frame with a radius-15 cutout packed with nine 5x5 parts, corner bolt holes
+ // and a loose part beside it. From the middle of the pack no straight move reaches the frame.
+ var frame = new Program();
+ frame.MoveTo(35, 20);
+ frame.ArcTo(35, 20, 20, 20, RotationType.CCW);
+ foreach (var (x, y) in new[] { (3.0, 3.0), (37, 3), (37, 37), (3, 37) })
+ {
+ frame.MoveTo(x + 0.5, y);
+ frame.ArcTo(x + 0.5, y, x, y, RotationType.CCW);
+ }
+ frame.MoveTo(0, 0);
+ frame.LineTo(0, 40); frame.LineTo(40, 40); frame.LineTo(40, 0); frame.LineTo(0, 0);
+ var parameters = ExplicitContourTests.Parameters();
+ var small = new Drawing("inner", LeadPathValidationTests.Rectangle(0, 0, 5, 5));
+ var parts = new List { new(new Drawing("frame", frame), new Vector(0, 0)) };
+ foreach (var x in new[] { 10.5, 17.5, 24.5 })
+ foreach (var y in new[] { 10.5, 17.5, 24.5 })
+ parts.Add(new Part(small, new Vector(x, y)));
+ parts.Add(new Part(small, new Vector(45, 10)));
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(parts, confirmedParameters: parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, string.Join("; ", result.Findings.Select(f => f.Message)));
+ Assert.True(result.IndependentlyReplayed);
+ var order = result.ProposedOrder.Select(p => p.SourceOrdinal).ToArray();
+ Assert.Equal(10, order[0]);
+ Assert.Equal(0, order[^1]);
+ // The occupied cutout is the frame's first cut: the tool is already inside it.
+ Assert.Equal(0, result.ProposedOrder[^1].ContourChoices[0].ContourOrdinal);
+ }
+
+ private static (IReadOnlyCollection[] Prerequisites, IReadOnlyCollection[] Inside) Nested()
+ {
+ var prerequisites = Centres.Select(_ => (IReadOnlyCollection)Array.Empty()).ToArray();
+ var inside = Centres.Select(_ => (IReadOnlyCollection)Array.Empty()).ToArray();
+ prerequisites[0] = [1, 2, 3, 4];
+ inside[0] = [1, 2, 3, 4];
+ return (prerequisites, inside);
+ }
+
+ // The host's cutout spans x 5..95 at y 50; its nearest point to a position is that position's x clamped.
+ private static CuttingNestedOrder.Context Context(IReadOnlyCollection[] prerequisites,
+ IReadOnlyCollection[] inside) =>
+ new(Centres, prerequisites, inside,
+ (_, position) => new Vector(System.Math.Clamp(position.X, 5, 95), 50), default);
+}
diff --git a/OpenNest.Tests/CuttingPlanning/SeamOvercutTests.cs b/OpenNest.Tests/CuttingPlanning/SeamOvercutTests.cs
new file mode 100644
index 00000000..7b5cb574
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/SeamOvercutTests.cs
@@ -0,0 +1,165 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class SeamOvercutTests
+{
+ [Theory]
+ [InlineData(Mode.Absolute)]
+ [InlineData(Mode.Incremental)]
+ public void LineOvercut_IsTrimmedBackToTheSeam(Mode mode)
+ {
+ var program = LineOvercut();
+ program.Mode = mode;
+ Assert.False(LeadMaterialSnapshot.Capture(program, Vector.Zero).IsComplete);
+
+ Assert.Equal(1, SeamOvercut.Trim(program));
+
+ var cuts = ExecutionMotionReader.ReadSupported(program, Vector.Zero, null).Motions.Where(m => !m.Rapid).ToArray();
+ Assert.Equal(5, cuts.Length);
+ Assert.Equal(new Vector(2, 0), cuts[^1].End);
+ Assert.Equal(mode, program.Mode);
+ Assert.True(LeadMaterialSnapshot.Capture(program, Vector.Zero).IsComplete);
+ Assert.Equal(0, SeamOvercut.Trim(program));
+ }
+
+ [Theory]
+ [InlineData(Mode.Absolute, 0)]
+ [InlineData(Mode.Incremental, 0)]
+ [InlineData(Mode.Incremental, 0.7)]
+ public void ArcOvercut_IsTrimmedBackToTheSeam(Mode mode, double rotation)
+ {
+ var program = ArcOvercut();
+ program.Rotate(rotation);
+ program.Mode = mode;
+ var location = new Vector(40, 20);
+ Assert.False(LeadMaterialSnapshot.Capture(program, location).IsComplete);
+
+ Assert.Equal(1, SeamOvercut.Trim(program));
+
+ var cuts = ExecutionMotionReader.ReadSupported(program, location, null).Motions.Where(m => !m.Rapid).ToArray();
+ Assert.Equal(2, cuts.Length);
+ Assert.True(cuts[0].Start!.Value.DistanceTo(cuts[^1].End) <= 1e-9);
+ var material = LeadMaterialSnapshot.Capture(program, location);
+ Assert.True(material.IsComplete, material.Reason);
+ }
+
+ [Fact]
+ public void Trimmed_LeavesTheSourceProgramUntouched()
+ {
+ var program = LineOvercut();
+ var before = ExplicitContourTests.Fingerprint(program);
+
+ var trimmed = SeamOvercut.Trimmed(program);
+
+ Assert.NotSame(program, trimmed);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(program));
+ Assert.Equal(program.Codes.Count - 1, trimmed.Codes.Count);
+ var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
+ Assert.Same(clean, SeamOvercut.Trimmed(clean));
+ }
+
+ [Fact]
+ public void OtherSeamContacts_AreNotTrimmed()
+ {
+ // The jog returns from beside the first cut, not along it: a real sliver, not an overcut.
+ var spike = new Program();
+ spike.MoveTo(2, 0);
+ spike.LineTo(10, 0); spike.LineTo(10, 10); spike.LineTo(0, 10); spike.LineTo(0, 0);
+ spike.LineTo(2.03, 0.01);
+ spike.LineTo(2, 0);
+ // The last cut stops short of the seam: an open contour bridged by the jog.
+ var tab = new Program();
+ tab.MoveTo(2, 0);
+ tab.LineTo(10, 0); tab.LineTo(10, 10); tab.LineTo(0, 10); tab.LineTo(0, 0);
+ tab.LineTo(1.97, 0);
+ tab.LineTo(2, 0);
+ // The overcut is a lead-out, which is not part of the clean contour.
+ var leadOut = LineOvercut();
+ ((LinearMove)leadOut.Codes[^1]).Layer = LayerType.Leadout;
+
+ Assert.Equal(0, SeamOvercut.Trim(spike));
+ Assert.Equal(0, SeamOvercut.Trim(tab));
+ Assert.Equal(0, SeamOvercut.Trim(leadOut));
+ }
+
+ [Fact]
+ public void OvercutDrawing_PlansAndPassesTheOverlapCheck()
+ {
+ var parameters = ExplicitContourTests.Parameters();
+ var drawing = new Drawing("overcut", LineOvercut());
+ var first = new Part(drawing, new Vector(0, 0)) { CuttingParameters = parameters };
+ var second = new Part(drawing, new Vector(12, 0)) { CuttingParameters = parameters };
+ var source = ExplicitContourTests.Fingerprint(drawing.Program);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([first, second], confirmedParameters: parameters));
+ var overlap = PlateOverlapAnalyzer.Analyze([first, second]);
+
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Empty(result.Findings);
+ foreach (var proposal in result.ProposedOrder)
+ {
+ var cuts = ExecutionMotionReader.ReadSupported(proposal.CopyProgram(), proposal.Location, null).Motions
+ .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).ToArray();
+ Assert.Equal(40, cuts.Sum(m => m.Length), 9);
+ }
+ Assert.True(overlap.IsComplete, string.Join("; ", overlap.Issues.Select(i => i.Message)));
+ Assert.Empty(overlap.Pairs);
+ Assert.Equal(source, ExplicitContourTests.Fingerprint(drawing.Program));
+ }
+
+ [Fact]
+ public void ArcsOfOneCircle_WithSeparatelyDerivedCenters_OverlapOnlyWhereTheirSpansDo()
+ {
+ // Same circle to within the support tolerance, but not bit for bit.
+ var center = new Vector(3, 4);
+ var other = new Vector(3 + 4e-9, 4 - 3e-9);
+ var first = Arc(center, 0, 80);
+ var apart = Arc(other, 100, 170);
+ var joined = Arc(other, 80, 170);
+ var shared = Arc(other, 40, 170);
+
+ Assert.Empty(first.Contacts(apart, out var apartOverlap));
+ Assert.False(apartOverlap);
+ first.Contacts(joined, out var joinedOverlap);
+ Assert.False(joinedOverlap);
+ first.Contacts(shared, out var sharedOverlap);
+ Assert.True(sharedOverlap);
+ shared.Contacts(first, out sharedOverlap);
+ Assert.True(sharedOverlap);
+
+ static PostVerificationGeometry.Curve Arc(Vector center, double startDegrees, double endDegrees) =>
+ PostVerificationGeometry.Curve.Create(Point(center, startDegrees), Point(center, endDegrees), center, false);
+
+ static Vector Point(Vector center, double degrees) => center + new Vector(
+ System.Math.Cos(degrees * System.Math.PI / 180), System.Math.Sin(degrees * System.Math.PI / 180)) * 5;
+ }
+
+ // A 10x10 square entered at (2,0) whose last cut runs 0.03 past the seam, then jogs back.
+ private static Program LineOvercut()
+ {
+ var program = new Program();
+ program.MoveTo(2, 0);
+ program.LineTo(10, 0); program.LineTo(10, 10); program.LineTo(0, 10); program.LineTo(0, 0);
+ program.LineTo(2.03, 0);
+ program.LineTo(2, 0);
+ return program;
+ }
+
+ // A radius-5 disc as two clockwise arcs; the second runs past the seam, then a chord jogs back.
+ private static Program ArcOvercut()
+ {
+ var past = new Vector(-5 * System.Math.Sin(0.006), -5 * System.Math.Cos(0.006));
+ var program = new Program();
+ program.MoveTo(0, -5);
+ program.ArcTo(0, 5, 0, 0, RotationType.CW);
+ program.ArcTo(past, new Vector(0, 0), RotationType.CW);
+ program.LineTo(0, -5);
+ return program;
+ }
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 8329a183..e28b1d9c 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -69,6 +69,22 @@ 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,
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
+within the 1e-8 support tolerance, not bit for bit. Their spans are compared at the
+1e-6 seam tolerance: arcs that share a span still refuse as overlap, while arcs that
+only meet at a joint, or lie apart on the circle (a notched ring), do not.
+
+A seam overcut baked into a clean contour is trimmed from the planner's owned copy
+before any of this: the last cut runs on past the contour's own start along its first
+cut (same line, or same circle and direction, within `Tolerance.Epsilon`) and a straight
+jog returns to the start. Nests converted from another CAM system carry its overcut
+this way. The retraced tail encloses no area, so material, regenerated programs and
+replay all use the contour closed at its seam; the drawing itself is not edited. Nothing
+else is repaired: a jog from beside the first cut, a last cut that stops short of the
+seam, lead-in/lead-out layers and symbolic moves are left alone and still refuse.
+The overlap analyzer trims the same shape from its own copy (see
+[material-overlap diagnostics](geometry/visual-overlap-check.md)).
+
Capture builds whole-part prerequisites from owned values, and both the search and
the final replay enforce them:
@@ -115,10 +131,22 @@ and no-hole requests keep their existing search order.
A preserved order is followed as given. Otherwise the order is an open
travelling-salesman path over part centres from the start point: nearest neighbour,
then 2-opt reversals and Or-opt moves of one to three parts, never placing a part
-before a cutoff or nested-part prerequisite. If a part on that order cannot be
+before a cutoff or nested-part prerequisite. A part with other parts nested in its
+cutouts (a host) is cut after all of them, so the tool reaches it from whichever was
+cut last, and from the middle of a crowded cutout every straight approach crosses a
+finished neighbour. The nested parts of each host are therefore toured as one run,
+immediately before the host, ending beside the point of the host's contours nearest
+the tool position at the start of the run; nested hosts apply the same rule inside
+the run. Host groups follow the parts beside them, because a finished host is a large
+obstacle that can only be left across its own outside edge. Learned rules win over
+both: a rule between parts of two groups orders the groups, and rules that cannot
+keep a group whole fall back to the plain tour. Plates without nested parts get the
+plain tour unchanged. If a part on that order cannot be
reached without crossing parts already cut, the search learns "cut this part
before those", backs up to just before the earliest of them and re-plans the rest
-from the tool position there; parts cut before that point are kept. An attempt
+from the tool position there; parts cut before that point are kept. The first pass
+along an order that teaches such a rule ends the round; the directed-recovery and
+full passes run only when the lighter pass taught nothing. An attempt
stops backtracking after a stall of 8 x entries x contours expansions without
getting further, so it learns instead of retrying every entry combination of the
parts before it. A rule that would contradict an order already required is skipped.
@@ -215,6 +243,17 @@ that edge's own endpoint, not a neighboring entity's endpoint. This keeps the
candidate on its selected entity when a joined contour has an accepted rounding gap;
no entry-membership or lead-clearance tolerance is widened.
+With no lead-out the tool leaves a part from its outside entry. An entry inside a slot
+or pocket can only be left back over the part just cut, so within the ranked catalogue
+entries are ordered by how many straight sight lines to the next cut clear the part's
+own perimeter: the look-ahead point and, for an ordinary next part, the four corners
+of its cut extent. This is a stable reordering before the lead checks; no entry is
+dropped, and every emitted rapid is still checked.
+
+When the next cut is the host whose cutout the part sits in, the look-ahead point is
+the nearest point of the host's own contours, not its centre (which lies in the middle
+of the cutout, among the other nested parts).
+
The next cut is the next unfinished part in a supplied order — recomputed
after every learned-order replan — or, in the full fallback search, the nearest
dependency-ready remaining part, stable-ordinal ties; the last part has no
@@ -234,6 +273,9 @@ the shared expansion budget before native work.
For a holed part, the search chooses an outside endpoint before cutting any hole.
Each endpoint branch builds an open hole-centre route from the original arrival to
that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement.
+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.
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,
diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md
index c5a89b38..f7820fcd 100644
--- a/docs/geometry/visual-overlap-check.md
+++ b/docs/geometry/visual-overlap-check.md
@@ -140,6 +140,11 @@ triangulation, or `Collision.Check` call is not internally interruptible.
produces an issue. Native contour intersections are checked before polygonal
containment, so sampling cannot hide a circular-hole crossing or tangency.
A gap above `Tolerance.Epsilon` is not silently welded closed.
+ One self-contact is recognized: a seam overcut, where the last cut runs past the
+ contour's own start along its first cut and a straight jog returns to the start
+ (converted nests carry another CAM system's overcut this way). It encloses no area
+ and is trimmed from the analyzer's owned copy, never from the drawing; see
+ [cutting dependencies](../cutting-planner.md#cutting-dependencies).
Open cut marks are conservatively uncheckable; mark them as Scribe instead.
- Some valid curved geometry, such as sub-chord-width thin rings whose sampled
contours cross, is uncheckable at this fixed tolerance and returns incomplete