fix(cutting): trim baked-in seam overcuts and route parts nested in cutouts

Nests converted from another CAM system carry its seam overcut inside the
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. The ring then touches
itself at its seam, so neither the cutting planner nor the overlap analyzer
could certify it and the whole plate refused.

SeamOvercut recognizes exactly that shape (same line, or same circle and
direction) and trims it from an owned copy; the retraced tail encloses no
area, so material is unchanged and the drawing is never edited. Any other
self-contact still refuses. Arcs of one circle whose centers were each derived
from their own endpoints agree only within Epsilon, so their spans are now
compared at the seam tolerance: shared spans still refuse as overlap, while
arcs that only meet at a joint or lie apart no longer do.

Plates with parts nested in other parts' cutouts then failed to route, since
the host is cut last and every straight approach from the middle of a crowded
cutout crosses a finished neighbour. CuttingNestedOrder tours each host's
inner parts as one run ending beside the host's own contours, hosts follow
the parts beside them, and learned rules still win. Outside entries are
ordered by clear sight lines to the next cut, the look-ahead toward a host is
its nearest contour point rather than its centre, a cutout containing the
arrival point is routed from that point, and a pass that teaches an order
rule ends the round instead of repeating the blocked approach.

Tests: SeamOvercutTests and CuttingNestedOrderTests; the cutting-planning,
diagnostics and overlap tests in OpenNest.Tests pass (1023 passed, 6 skipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-12 13:40:32 -04:00
1 parent b36db41769
commit 311178c15a
12 files changed
+740 -36

No files matched your search

@@ -511,6 +511,17 @@ public sealed class PreparedContours
return centres;
}
/// <summary>
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
/// </summary>
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();
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
internal static class SeamOvercut
{
/// <summary>
/// The same program when it has no seam overcut, otherwise an owned copy without them.
/// The source program is never edited.
/// </summary>
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;
}
/// <summary>Trims seam overcuts from an owned program in place; returns how many were removed.</summary>
internal static int Trim(Program program)
{
var edits = Find(program);
Apply(program, edits);
return edits.Count;
}
private static void Apply(Program program, List<Edit> 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<Edit> Find(Program program)
{
var edits = new List<Edit>();
if (program?.Codes == null)
return edits;
var incremental = program.Mode == Mode.Incremental;
var run = new List<Segment>();
// 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<Segment> 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;
}
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
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;
/// <summary>One cut of a contour in the walk's coordinates; <see cref="Reference"/> maps them back to its code.</summary>
private readonly record struct Segment(int Index, Motion Motion, Vector Start, Vector End, Vector? Center, Vector Reference);
/// <summary>The last cut's new code-relative end and center, and the return jog to remove.</summary>
private readonly record struct Edit(int LastCut, int ReturnJog, Vector EndPoint, Vector CenterPoint);
}
@@ -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<Program>(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);
@@ -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));
}
/// <summary>
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// 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
/// <see cref="CuttingPartOrder"/>'s order. Like that order, this is only a proposal.
/// </summary>
internal static class CuttingNestedOrder
{
/// <param name="Centres">One representative point per part ordinal.</param>
/// <param name="Prerequisites">Ordinals that must come before each part, learned rules included.</param>
/// <param name="Inside">Per part ordinal, the parts nested inside its cutouts at any depth.</param>
/// <param name="HostEntry">The point on a host's contours nearest a tool position.</param>
internal sealed record Context(IReadOnlyList<Vector> Centres,
IReadOnlyList<IReadOnlyCollection<int>> Prerequisites, IReadOnlyList<IReadOnlyCollection<int>> Inside,
Func<int, Vector, Vector> HostEntry, CancellationToken Token);
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
/// <param name="start">The modeled tool position before the first of these parts.</param>
/// <param name="endpoint">Optional position the run should end beside.</param>
internal static int[] Plan(IReadOnlyList<int> parts, Vector start, Context context, Vector? endpoint = null)
{
context.Token.ThrowIfCancellationRequested();
var set = parts.ToHashSet();
var rootOf = new Dictionary<int, int>();
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<int>()).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<int>(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<int>(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<int>();
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);
}
}
@@ -23,8 +23,9 @@ internal static class CuttingPartOrder
/// <param name="centres">One representative point per part ordinal.</param>
/// <param name="start">The modeled tool position before the first of these parts.</param>
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
/// <param name="endpoint">Optional fixed final position after the last of these parts.</param>
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null)
{
var local = new Dictionary<int, int>();
for (var i = 0; i < parts.Count; i++)
@@ -33,7 +34,7 @@ internal static class CuttingPartOrder
var localPrerequisites = parts
.Select(p => (IReadOnlyCollection<int>)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();
}
/// <param name="centres">One representative point per part ordinal.</param>
@@ -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
@@ -59,24 +59,27 @@ internal static class JointCuttingPlanSearch
var prerequisites = Enumerable.Range(0, count)
.Select(i => new HashSet<int>(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<int>[] 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<int, Entity[]>();
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<int>[] prerequisites)
@@ -175,6 +195,7 @@ internal static class JointCuttingPlanSearch
// memoize per owned choice for the attempt, never statically or across attempts.
private readonly Dictionary<int, ContourEntryFeasibility> feasibility = [];
private readonly HashSet<int> reportedNoFit = [];
private readonly Dictionary<int, Entity[]> 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.
/// </summary>
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));
}
/// <summary>
/// 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<ContourChoice> 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<int, ContourChoice>();
var pierces = holes.ToDictionary(h => h, h => centres[h]);
@@ -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<int>)Array.Empty<int>()).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<Part> { 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<int>[] Prerequisites, IReadOnlyCollection<int>[] Inside) Nested()
{
var prerequisites = Centres.Select(_ => (IReadOnlyCollection<int>)Array.Empty<int>()).ToArray();
var inside = Centres.Select(_ => (IReadOnlyCollection<int>)Array.Empty<int>()).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<int>[] prerequisites,
IReadOnlyCollection<int>[] inside) =>
new(Centres, prerequisites, inside,
(_, position) => new Vector(System.Math.Clamp(position.X, 5, 95), 50), default);
}
@@ -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;
}
}
+44 -2
View File
@@ -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,
+5
View File
@@ -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