mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-22 04:21:36 -04:00
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:
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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user