feat(cutting): cut Z and L common lines once as one shared path

Straight spans of one pair that meet at a corner chain into one shared
path (CommonLineGeometry.Paths). Plan Cutting cuts it as one open
centerline cut through its corners, pierced and left in scrap along its
end legs; each member's spans run on to its own vertex at a corner, so
the path is one tab run. Join Common Line slides along the main edge so
the step of a Z or L also lands one kerf apart, and the plate draws the
path through its corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-08 15:15:29 -05:00
1 parent 757d366e4a
commit bff11dbc3c
8 files changed
+489 -61

No files matched your search

@@ -31,8 +31,34 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
/// </summary>
public sealed record CommonLinePerimeter(double Kerf, IReadOnlyList<CommonLineCutPath> SharedCuts);
/// <summary>One shared cut on a common line's centerline, in part coordinates and cut direction.</summary>
public sealed record CommonLineCutPath(Vector Start, Vector End);
/// <summary>
/// One shared cut on a common line's centerline, in part coordinates and cut direction: straight,
/// or through the corners of a Z or L shared line.
/// </summary>
public sealed record CommonLineCutPath(IReadOnlyList<Vector> Points)
{
public CommonLineCutPath(Vector start, Vector end) : this(new[] { start, end })
{
}
public Vector Start => Points[0];
public Vector End => Points[^1];
/// <summary>The same path cut from its other end.</summary>
public CommonLineCutPath Reversed() => new(Points.Reverse().ToArray());
/// <summary>Whether the path has two or more points and no degenerate leg.</summary>
internal bool IsNondegenerate(double epsilon)
{
if (Points is not { Count: >= 2 })
return false;
for (var i = 1; i < Points.Count; i++)
if (Points[i - 1].DistanceTo(Points[i]) <= epsilon)
return false;
return true;
}
}
/// <summary>
/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated
@@ -342,7 +368,7 @@ public sealed class PreparedContours
if (displayPerimeter != null || current.Entities.Count != 0)
throw new ArgumentException("A common-line member needs one closed cut perimeter.");
if (!double.IsFinite(commonLine.Kerf) || commonLine.Kerf <= 0 || commonLine.SharedCuts == null
|| commonLine.SharedCuts.Any(c => c == null || c.Start.DistanceTo(c.End) <= PostVerificationGeometry.Epsilon))
|| commonLine.SharedCuts.Any(c => c == null || !c.IsNondegenerate(PostVerificationGeometry.Epsilon)))
throw new ArgumentException("A common-line member needs a positive kerf and nondegenerate shared cuts.");
// The outside carries the shared spans as tabs; every other contour lies inside it.
var outside = tabbedCutouts.Concat(contours).MaxBy(shape => shape.BoundingBox.Width * shape.BoundingBox.Length);
@@ -1629,23 +1629,27 @@ namespace OpenNest.CNC.CuttingStrategy
}
/// <summary>
/// One shared cut, absolute in its part's frame: a rapid to the pierce on the cut's axis
/// before its start, the straight lead-in, the open centerline cut and the straight
/// lead-out on past its end, each lead as long as the configured one reaches.
/// One shared cut, absolute in its part's frame: a rapid to the pierce on the first leg's
/// axis before its start, the straight lead-in, the open centerline cut through every
/// corner of a Z or L path, and the straight lead-out on past its end along the last leg,
/// each lead as long as the configured one reaches.
/// </summary>
internal static List<ICode> CommonLineSharedCutCodes(CuttingPlanning.CommonLineCutPath cut, LeadIn leadIn,
LeadOut leadOut)
{
var codes = new List<ICode>();
var along = (cut.End - cut.Start).Normalize();
var points = cut.Points;
var entry = (points[1] - points[0]).Normalize();
var reach = CommonLineLeadInReach(leadIn);
codes.Add(new RapidMove(cut.Start - along * reach));
codes.Add(new RapidMove(cut.Start - entry * reach));
if (reach > Tolerance.Epsilon)
codes.Add(new LinearMove(cut.Start) { Layer = LayerType.Leadin });
codes.Add(new LinearMove(cut.End) { Layer = LayerType.CommonLine });
for (var i = 1; i < points.Count; i++)
codes.Add(new LinearMove(points[i]) { Layer = LayerType.CommonLine });
var leave = (points[^1] - points[^2]).Normalize();
var exit = CommonLineLeadOutReach(leadOut);
if (exit > Tolerance.Epsilon)
codes.Add(new LinearMove(cut.End + along * exit) { Layer = LayerType.Leadout });
codes.Add(new LinearMove(cut.End + leave * exit) { Layer = LayerType.Leadout });
return codes;
}
+88 -32
View File
@@ -8,10 +8,11 @@ namespace OpenNest
{
/// <summary>
/// One shared cut of a common-line group, cut once on the centerline by <see cref="Owner"/>, the
/// member of the pair cut first. <see cref="Partner"/> is cut later; its remainder ends in the
/// member of the pair cut first: a straight span, or spans chained at corners (a Z or L shared
/// line) cut as one open path. <see cref="Partner"/> is cut later; its remainder ends in the
/// kerf this cut opened.
/// </summary>
public sealed record CommonLineSharedCut(CommonLineSpan Span, Part Owner, Part Partner);
public sealed record CommonLineSharedCut(CommonLinePath Path, Part Owner, Part Partner);
/// <summary>
/// How a common-line group is cut once (see docs/common-line-cutting.md): its members in cutting
@@ -24,10 +25,10 @@ namespace OpenNest
public IEnumerable<CommonLineSharedCut> CutsOwnedBy(Part member) =>
Cuts.Where(cut => ReferenceEquals(cut.Owner, member));
/// <summary>Every span <paramref name="member"/> shares, whoever cuts it.</summary>
public IEnumerable<CommonLineSpan> SpansOf(Part member) => Cuts
/// <summary>Every shared path <paramref name="member"/> shares, whoever cuts it.</summary>
public IEnumerable<CommonLinePath> PathsOf(Part member) => Cuts
.Where(cut => ReferenceEquals(cut.Owner, member) || ReferenceEquals(cut.Partner, member))
.Select(cut => cut.Span);
.Select(cut => cut.Path);
}
/// <summary>
@@ -76,34 +77,49 @@ namespace OpenNest
if (order.Count != members.Count)
throw new ArgumentException("The common-line group has split into separate pieces.", nameof(check));
int Rank(Part part) => order.FindIndex(member => ReferenceEquals(member, part));
var cuts = check.Spans
.Select(span => Rank(span.First) < Rank(span.Second)
? new CommonLineSharedCut(span, span.First, span.Second)
: new CommonLineSharedCut(span, span.Second, span.First))
var cuts = CommonLineGeometry.Paths(check.Spans, group.Kerf)
.Select(path => Rank(path.First) < Rank(path.Second)
? new CommonLineSharedCut(path, path.First, path.Second)
: new CommonLineSharedCut(path, path.Second, path.First))
.OrderBy(cut => Rank(cut.Owner))
.ToList();
return new CommonLineCutOrder(group, order, cuts);
}
/// <summary>
/// A copy of <paramref name="clean"/> (a member's clean program, rotated as placed, in part
/// coordinates; placed at <paramref name="location"/>) whose perimeter edges are split at
/// every end of <paramref name="spans"/> and whose shared portions are on the
/// <see cref="LayerType.Tab"/> layer: material that this member never cuts, because the
/// shared cut does. The result is absolute. Only straight top-level moves can share.
/// <see cref="MarkSharedSpans(Program, Vector, IReadOnlyList{CommonLinePath}, double)"/> with
/// every span a straight path of its own.
/// </summary>
/// <exception cref="ArgumentException">A span does not lie along the program's edges.</exception>
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLineSpan> spans,
double kerf)
{
ArgumentNullException.ThrowIfNull(clean);
ArgumentNullException.ThrowIfNull(spans);
return MarkSharedSpans(clean, location, spans
.Select(span => new CommonLinePath(span.First, span.Second, [span], [span.Start, span.End]))
.ToArray(), kerf);
}
/// <summary>
/// A copy of <paramref name="clean"/> (a member's clean program, rotated as placed, in part
/// coordinates; placed at <paramref name="location"/>) whose perimeter edges are split at
/// every end of the shared <paramref name="paths"/> and whose shared portions are on the
/// <see cref="LayerType.Tab"/> layer: material that this member never cuts, because the
/// shared cut does. At a path's corner each leg's portion runs to the member's own vertex
/// there, so the legs' portions join. The result is absolute. Only straight top-level
/// moves can share.
/// </summary>
/// <exception cref="ArgumentException">A path's leg does not lie along the program's edges.</exception>
public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList<CommonLinePath> paths,
double kerf)
{
ArgumentNullException.ThrowIfNull(clean);
ArgumentNullException.ThrowIfNull(paths);
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
var program = (Program)clean.Clone();
program.Mode = Mode.Absolute;
var local = spans.Select(span => (Start: span.Start - location, End: span.End - location)).ToArray();
var covered = new double[local.Length];
var legs = paths.SelectMany(path => Legs(path, location, kerf)).ToArray();
var covered = new double[legs.Length];
var codes = new List<ICode>();
Vector? position = null;
foreach (var code in program.Codes)
@@ -111,7 +127,7 @@ namespace OpenNest
if (code is LinearMove line && position is { } start
&& line.Layer is LayerType.Cut or LayerType.Display)
{
codes.AddRange(Split(line, start, local, covered, kerf));
codes.AddRange(Split(line, start, legs, covered, kerf));
position = line.EndPoint;
continue;
}
@@ -123,18 +139,46 @@ namespace OpenNest
_ => position,
};
}
for (var i = 0; i < local.Length; i++)
if (System.Math.Abs(covered[i] - local[i].Start.DistanceTo(local[i].End)) > CommonLineGeometry.GapTolerance)
for (var i = 0; i < legs.Length; i++)
{
var leg = legs[i];
// At a corner the member's portion ends at its own vertex, up to the snap off the centerline's.
var slack = leg.StartSnap + leg.EndSnap + CommonLineGeometry.GapTolerance;
if (covered[i] <= Math.Tolerance.Epsilon
|| System.Math.Abs(covered[i] - leg.Start.DistanceTo(leg.End)) > slack)
{
var (a, b) = (leg.Start + location, leg.End + location);
throw new ArgumentException(FormattableString.Invariant(
$"The shared span from ({spans[i].Start.X:0.####}, {spans[i].Start.Y:0.####}) to ({spans[i].End.X:0.####}, {spans[i].End.Y:0.####}) does not lie along one straight edge of the part's program."));
$"The shared span from ({a.X:0.####}, {a.Y:0.####}) to ({b.X:0.####}, {b.Y:0.####}) does not lie along one straight edge of the part's program."));
}
}
program.Codes.Clear();
program.Codes.AddRange(codes);
return program;
}
// The line split where shared spans one half kerf off it begin and end; shared pieces on Tab.
private static IEnumerable<ICode> Split(LinearMove line, Vector start, (Vector Start, Vector End)[] spans,
double[] covered, double kerf)
// One straight leg of a shared path in part coordinates. An end at a corner snaps to the
// edge's own vertex within kerf/2 · tan(turn/2) of the centerline corner's projection.
private readonly record struct Leg(Vector Start, Vector End, double StartSnap, double EndSnap);
private static IEnumerable<Leg> Legs(CommonLinePath path, Vector location, double kerf)
{
var points = path.Points.Select(point => point - location).ToArray();
var snaps = new double[points.Length];
for (var k = 1; k < points.Length - 1; k++)
{
var din = (points[k] - points[k - 1]).Normalize();
var dout = (points[k + 1] - points[k]).Normalize();
var turn = System.Math.Atan2(System.Math.Abs(Cross(din, dout)), Dot(din, dout));
snaps[k] = kerf / 2 * System.Math.Tan(turn / 2) + 2 * CommonLineGeometry.GapTolerance;
}
for (var k = 0; k + 1 < points.Length; k++)
yield return new Leg(points[k], points[k + 1], snaps[k], snaps[k + 1]);
}
// The line split where shared legs one half kerf off it begin and end; shared pieces on Tab.
private static IEnumerable<ICode> Split(LinearMove line, Vector start, Leg[] legs, double[] covered,
double kerf)
{
var end = line.EndPoint;
var length = start.DistanceTo(end);
@@ -142,20 +186,20 @@ namespace OpenNest
return [line];
var direction = new Vector((end.X - start.X) / length, (end.Y - start.Y) / length);
var intervals = new List<(double From, double To)>();
for (var i = 0; i < spans.Length; i++)
for (var i = 0; i < legs.Length; i++)
{
var (a, b) = spans[i];
var spanLength = a.DistanceTo(b);
if (spanLength <= Math.Tolerance.Epsilon)
var (a, b, snapA, snapB) = legs[i];
var legLength = a.DistanceTo(b);
if (legLength <= Math.Tolerance.Epsilon)
continue;
var along = new Vector((b.X - a.X) / spanLength, (b.Y - a.Y) / spanLength);
var along = new Vector((b.X - a.X) / legLength, (b.Y - a.Y) / legLength);
if (System.Math.Abs(Cross(direction, along)) > 1e-6)
continue;
var offset = System.Math.Abs(Cross(direction, a - start));
if (System.Math.Abs(offset - kerf / 2) > CommonLineGeometry.GapTolerance)
continue;
var ta = Dot(a - start, direction);
var tb = Dot(b - start, direction);
var ta = Snap(Dot(a - start, direction), snapA, length);
var tb = Snap(Dot(b - start, direction), snapB, length);
var from = System.Math.Max(0, System.Math.Min(ta, tb));
var to = System.Math.Min(length, System.Math.Max(ta, tb));
if (to - from <= Math.Tolerance.Epsilon)
@@ -190,6 +234,18 @@ namespace OpenNest
Vector Point(double t) => new(start.X + direction.X * t, start.Y + direction.Y * t);
}
// A corner end within snap of either end of the edge is that end.
private static double Snap(double t, double snap, double length)
{
if (snap <= 0)
return t;
if (System.Math.Abs(t) <= snap)
return 0;
if (System.Math.Abs(t - length) <= snap)
return length;
return t;
}
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;
+161 -2
View File
@@ -13,6 +13,31 @@ namespace OpenNest
public double Length => Start.DistanceTo(End);
}
/// <summary>
/// One shared cut's path: the spans of one pair chained end to end at corners (a Z or L shared
/// line), or a single straight span. <see cref="Points"/> runs from the first span's free end
/// through each corner, where the spans' centerlines meet, to the last span's free end.
/// </summary>
public sealed record CommonLinePath(Part First, Part Second, IReadOnlyList<CommonLineSpan> Spans,
IReadOnlyList<Vector> Points)
{
public Vector Start => Points[0];
public Vector End => Points[^1];
/// <summary>The kerf-centerline length of the whole path, corners included.</summary>
public double Length
{
get
{
var length = 0.0;
for (var i = 1; i < Points.Count; i++)
length += Points[i - 1].DistanceTo(Points[i]);
return length;
}
}
}
/// <summary>A member of a group that no longer shares a span, or could not be read.</summary>
public readonly record struct CommonLineProblem(Part Part, string Message);
@@ -115,7 +140,9 @@ namespace OpenNest
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
var cache = new ProfileCache();
var best = FacingEdges(Edges(anchor, cache), Edges(moving, cache), -maxGap, maxGap)
var anchorEdges = Edges(anchor, cache);
var movingEdges = Edges(moving, cache);
var best = FacingEdges(anchorEdges, movingEdges, -maxGap, maxGap)
.OrderByDescending(facing => System.Math.Round(facing.Overlap, 6))
.ThenBy(facing => System.Math.Abs(facing.Gap - kerf))
.Cast<Facing?>()
@@ -123,9 +150,141 @@ namespace OpenNest
if (best is not { } chosen)
return null;
var shift = kerf - chosen.Gap;
return new Vector(chosen.First.Outward.X * shift, chosen.First.Outward.Y * shift);
var offset = new Vector(chosen.First.Outward.X * shift, chosen.First.Outward.Y * shift);
return Slide(anchorEdges, movingEdges, chosen, offset, kerf, maxGap) ?? offset;
}
// The offset slid along the chosen edge pair so that a facing pair across it (the step of a
// Z or L shared line) lands one kerf apart too, keeping the chosen pair's gap and span; null
// when no such pair is within reach.
private static Vector? Slide(IReadOnlyList<Edge> anchor, IReadOnlyList<Edge> moving, Facing chosen,
Vector offset, double kerf, double maxGap)
{
var along = chosen.First.Direction;
var minimum = System.Math.Sin(MinCornerTurn);
var shifted = Shift(moving, offset);
var slides = FacingEdges(anchor, shifted, -maxGap, maxGap)
.Select(facing => (Facing: facing, Across: Dot(along, facing.First.Outward)))
.Where(choice => System.Math.Abs(choice.Across) >= minimum)
.Select(choice => (choice.Facing, Slide: (kerf - choice.Facing.Gap) / choice.Across))
.Where(choice => System.Math.Abs(choice.Slide) <= maxGap)
.OrderByDescending(choice => System.Math.Round(choice.Facing.Overlap, 6))
.ThenBy(choice => System.Math.Abs(choice.Slide));
foreach (var (_, slide) in slides)
{
var candidate = new Vector(offset.X + along.X * slide, offset.Y + along.Y * slide);
var placed = Shift(moving, candidate);
var spans = FacingEdges(anchor, placed, kerf - GapTolerance, kerf + GapTolerance).ToList();
// The chosen pair must still share, and the slid pair must now share across it.
if (spans.Any(facing => System.Math.Abs(Cross(facing.First.Direction, along)) <= ParallelTolerance)
&& spans.Any(facing => System.Math.Abs(Dot(along, facing.First.Outward)) >= minimum))
return candidate;
}
return null;
}
private static IReadOnlyList<Edge> Shift(IReadOnlyList<Edge> edges, Vector offset) =>
edges.Select(edge => edge with { Start = edge.Start + offset, End = edge.End + offset }).ToArray();
/// <summary>The sharpest corner a shared path may turn (radians): 135°.</summary>
public const double MaxCornerTurn = 3 * System.Math.PI / 4;
/// <summary>The gentlest corner a shared path may turn (radians): 10°. Gentler meets are not corners.</summary>
public const double MinCornerTurn = System.Math.PI / 18;
/// <summary>
/// The shared cuts of <paramref name="spans"/> at <paramref name="kerf"/>: spans of one pair
/// whose ends meet at a corner (a Z or L shared line) chain into one path; every other span
/// is a path of its own. Two spans meet when their centerlines cross beyond both ends, each
/// end short of the crossing by what the kerf leaves there, <c>kerf/2 · tan(turn/2)</c>, and
/// the path turns between <see cref="MinCornerTurn"/> and <see cref="MaxCornerTurn"/>. Paths
/// keep the spans' order of first appearance.
/// </summary>
public static IReadOnlyList<CommonLinePath> Paths(IReadOnlyList<CommonLineSpan> spans, double kerf)
{
ArgumentNullException.ThrowIfNull(spans);
if (!double.IsFinite(kerf) || kerf <= 0)
throw new ArgumentOutOfRangeException(nameof(kerf));
// links[i, end]: the span and end met at span i's end (0 = Start, 1 = End), and the corner.
var links = new (int Span, int End, Vector Corner)?[spans.Count, 2];
for (var i = 0; i < spans.Count; i++)
for (var j = i + 1; j < spans.Count; j++)
{
if (!SamePair(spans[i], spans[j]))
continue;
for (var ei = 0; ei < 2; ei++)
for (var ej = 0; ej < 2; ej++)
if (links[i, ei] == null && links[j, ej] == null
&& Corner(spans[i], ei, spans[j], ej, kerf) is { } corner)
{
links[i, ei] = (j, ej, corner);
links[j, ej] = (i, ei, corner);
}
}
var used = new bool[spans.Count];
var paths = new List<CommonLinePath>();
for (var i = 0; i < spans.Count; i++)
{
if (used[i])
continue;
// Walk back to a free end (or round a loop back to this span), then trace forward.
var (span, entry) = (i, links[i, 0] == null ? 0 : links[i, 1] == null ? 1 : 0);
for (var guard = 0; guard < spans.Count && links[span, entry] is { } back && back.Span != i; guard++)
(span, entry) = (back.Span, 1 - back.End);
var chain = new List<CommonLineSpan>();
var points = new List<Vector> { EndOf(spans[span], entry) };
while (true)
{
used[span] = true;
chain.Add(spans[span]);
var exit = 1 - entry;
if (links[span, exit] is not { } next || used[next.Span])
{
points.Add(EndOf(spans[span], exit));
break;
}
points.Add(next.Corner);
(span, entry) = (next.Span, next.End);
}
paths.Add(new CommonLinePath(spans[i].First, spans[i].Second, chain, points));
}
return paths;
}
// Where span a's end ea meets span b's end eb at a corner, or null.
private static Vector? Corner(CommonLineSpan a, int ea, CommonLineSpan b, int eb, double kerf)
{
var aEnd = EndOf(a, ea);
var aOther = EndOf(a, 1 - ea);
var bEnd = EndOf(b, eb);
var bOther = EndOf(b, 1 - eb);
var aLength = aEnd.DistanceTo(aOther);
var bLength = bEnd.DistanceTo(bOther);
if (aLength <= Math.Tolerance.Epsilon || bLength <= Math.Tolerance.Epsilon)
return null;
// Travel along a into its end, then out along b from its end.
var din = new Vector((aEnd.X - aOther.X) / aLength, (aEnd.Y - aOther.Y) / aLength);
var dout = new Vector((bOther.X - bEnd.X) / bLength, (bOther.Y - bEnd.Y) / bLength);
var cross = Cross(din, dout);
var turn = System.Math.Atan2(System.Math.Abs(cross), Dot(din, dout));
if (turn < MinCornerTurn || turn > MaxCornerTurn)
return null;
// aEnd + din·s = bEnd − dout·t: the corner lies s past a's end and t before b's end.
var gap = bEnd - aEnd;
var s = Cross(gap, dout) / cross;
var t = Cross(din, gap) / cross;
var reach = kerf / 2 * System.Math.Tan(turn / 2) + 2 * GapTolerance;
if (s < -GapTolerance || t < -GapTolerance || s > reach || t > reach)
return null;
return new Vector(aEnd.X + din.X * s, aEnd.Y + din.Y * s);
}
private static Vector EndOf(CommonLineSpan span, int end) => end == 0 ? span.Start : span.End;
private static bool SamePair(CommonLineSpan a, CommonLineSpan b) =>
(ReferenceEquals(a.First, b.First) && ReferenceEquals(a.Second, b.Second))
|| (ReferenceEquals(a.First, b.Second) && ReferenceEquals(a.Second, b.First));
private static IReadOnlyList<CommonLineSpan> Spans(Part first, IReadOnlyList<Edge> a, Part second,
IReadOnlyList<Edge> b, double kerf)
{
@@ -48,7 +48,7 @@ internal sealed class CommonLineCapture
var part = order.Members[i];
if (IndexOf(request.Parts, part) < 0)
throw new ArgumentException("A common-line member is not among the planned parts.");
capture.members[part] = new Member(order, part, order.SpansOf(part).ToArray(),
capture.members[part] = new Member(order, part, order.PathsOf(part).ToArray(),
order.CutsOwnedBy(part).ToArray());
}
}
@@ -75,7 +75,7 @@ internal sealed class CommonLineCapture
var member = members[part];
try
{
return CommonLineCutting.MarkSharedSpans(ownedClean, location, member.Spans, member.Order.Group.Kerf);
return CommonLineCutting.MarkSharedSpans(ownedClean, location, member.Paths, member.Order.Group.Kerf);
}
catch (ArgumentException exception)
{
@@ -102,8 +102,8 @@ internal sealed class CommonLineCapture
foreach (var cut in member.Owned)
{
token.ThrowIfCancellationRequested();
var forward = new CommonLineCutPath(cut.Span.Start - location, cut.Span.End - location);
var reverse = new CommonLineCutPath(forward.End, forward.Start);
var forward = new CommonLineCutPath(cut.Path.Points.Select(point => point - location).ToArray());
var reverse = forward.Reversed();
string reason = null;
CommonLineCutPath chosen = null;
foreach (var path in new[] { forward, reverse })
@@ -121,7 +121,7 @@ internal sealed class CommonLineCapture
}
if (chosen == null)
throw new NotSupportedException(FormattableString.Invariant(
$"No lead fits the common line between parts {ordinal + 1} and {IndexOf(parts, cut.Partner) + 1} from ({cut.Span.Start.X:0.####}, {cut.Span.Start.Y:0.####}) to ({cut.Span.End.X:0.####}, {cut.Span.End.Y:0.####}) at either end: ")
$"No lead fits the common line between parts {ordinal + 1} and {IndexOf(parts, cut.Partner) + 1} from ({cut.Path.Start.X:0.####}, {cut.Path.Start.Y:0.####}) to ({cut.Path.End.X:0.####}, {cut.Path.End.Y:0.####}) at either end: ")
+ reason + " Shorten the external lead-in or lead-out, or cut the plate's common lines twice.");
cuts.Add(chosen);
}
@@ -136,6 +136,6 @@ internal sealed class CommonLineCapture
return -1;
}
private sealed record Member(CommonLineCutOrder Order, Part Part, IReadOnlyList<CommonLineSpan> Spans,
private sealed record Member(CommonLineCutOrder Order, Part Part, IReadOnlyList<CommonLinePath> Paths,
IReadOnlyList<CommonLineSharedCut> Owned);
}
@@ -0,0 +1,166 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Engine.CuttingPlanning;
using OpenNest.Geometry;
using static OpenNest.Tests.CommonLines.CommonLinePlanningTests;
namespace OpenNest.Tests.CommonLines;
/// <summary>
/// A Z-shaped common line (docs/common-line-cutting.md): two stepped bars, the second turned half
/// a turn, interlock one kerf apart along three straight legs. The legs' spans chain at their
/// corners into one shared path, cut once as one open centerline cut.
/// </summary>
public class CommonLineZTests
{
private const double K = CommonLinePlanningTests.Kerf;
private const double Length = 12;
private const double Step = 5;
private const double Low = 0.5;
private const double High = 1;
// A bar Length long, Low tall for its first Step and High tall after it.
private static Drawing SteppedBar()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(0, Low));
program.Codes.Add(new LinearMove(Step, Low));
program.Codes.Add(new LinearMove(Step, High));
program.Codes.Add(new LinearMove(Length, High));
program.Codes.Add(new LinearMove(Length, 0));
program.Codes.Add(new LinearMove(0, 0));
return new Drawing("Terminal", program);
}
// The bar at (x, y), and its half-turned copy interlocked over its low end, offset from one
// kerf apart by (dx, dy).
private static (Plate Plate, Part A, Part B) Pair(double dx = 0, double dy = 0, bool join = true)
{
var plate = new Plate(48, 120) { PartSpacing = 0.25 };
var drawing = SteppedBar();
var a = new Part(drawing, new Vector(2, 2));
var b = new Part(drawing);
b.Rotate(System.Math.PI);
b.Offset(2 + Step - K + dx - b.Location.X, 2 + High + K + dy - b.Location.Y);
plate.Parts.Add(a);
plate.Parts.Add(b);
if (join)
plate.JoinCommonLine([a, b], K);
return (plate, a, b);
}
[Fact]
public void Spans_OfTheZ_ChainIntoOnePathThroughItsCorners()
{
var (plate, _, _) = Pair();
var check = CommonLineGeometry.Check(plate.CommonLines[0]);
Assert.True(check.IsIntact);
Assert.Equal(3, check.Spans.Count);
var path = Assert.Single(CommonLineGeometry.Paths(check.Spans, K));
Assert.Equal(3, path.Spans.Count);
Assert.Equal(4, path.Points.Count);
// The corners lie where the legs' centerlines meet, half a kerf off both parts.
var corners = path.Points.Skip(1).Take(2).OrderBy(p => p.X).ToArray();
Assert.Equal(2 - K / 2, corners[0].X, 9);
Assert.Equal(2 + Low + K / 2, corners[0].Y, 9);
Assert.Equal(2 + Step - K / 2, corners[1].X, 9);
Assert.Equal(2 + Low + K / 2, corners[1].Y, 9);
Assert.Equal(Step + 2 * (Low - K / 2), path.Length, 9);
}
[Fact]
public void SeparateStraightSpans_StayPathsOfTheirOwn()
{
var plate = Row(3);
var check = CommonLineGeometry.Check(plate.CommonLines[0]);
var paths = CommonLineGeometry.Paths(check.Spans, K);
Assert.Equal(2, paths.Count);
Assert.All(paths, path => Assert.Equal(2, path.Points.Count));
}
[Fact]
public void Join_ClosesEveryLegOfTheZ()
{
var (plate, a, b) = Pair(dx: 0.06, dy: 0.1, join: false);
var result = CommonLineJoiner.Join(plate, [a, b], K, 1);
Assert.True(result.Succeeded, result.Failure);
var check = CommonLineGeometry.Check(result.Group);
Assert.True(check.IsIntact);
Assert.Equal(3, check.Spans.Count);
Assert.Single(CommonLineGeometry.Paths(check.Spans, K));
}
[Fact]
public void Plan_CutsTheZOnceAsOneOpenPath()
{
var (plate, _, _) = Pair();
var result = Plan(plate);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
var owner = result.ProposedOrder[0];
var motions = Motions(owner).ToList();
var shared = motions.Where(m => m.Layer == LayerType.CommonLine).ToList();
Assert.Equal(3, shared.Count);
// One pierce: the three legs are cut in a row, each starting where the last ended.
var first = motions.IndexOf(shared[0]);
Assert.Equal(LayerType.Leadin, motions[first - 1].Layer);
for (var i = 1; i < shared.Count; i++)
{
Assert.Same(shared[i], motions[first + i]);
Assert.Equal(0, shared[i - 1].End.DistanceTo(shared[i].Curve.Start), 9);
}
Assert.Equal(Step + 2 * (Low - K / 2), shared.Sum(m => m.Length), 9);
// The partner cuts none of it.
Assert.DoesNotContain(Motions(result.ProposedOrder[1]), m => m.Layer == LayerType.CommonLine);
}
[Fact]
public void Members_CutTheirPerimeterExactlyOnce_TheZByTheSharedCut()
{
var (plate, _, _) = Pair();
var order = CommonLineCutting.Order(plate.CommonLines[0], CommonLineGeometry.Check(plate.CommonLines[0]));
var result = Plan(plate);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
foreach (var proposal in result.ProposedOrder)
{
var part = proposal.SourcePart;
var marked = CommonLineCutting.MarkSharedSpans(part.Program, part.Location, order.PathsOf(part).ToArray(), K);
// The Z is one tab run on each member: its legs join at the member's own corners.
var layers = marked.Codes.OfType<LinearMove>().Select(m => m.Layer).ToList();
Assert.Single(layers.Where((layer, i) => layer == LayerType.Tab
&& layers[(i + layers.Count - 1) % layers.Count] != LayerType.Tab));
var material = LeadMaterialSnapshot.Capture(marked, part.Location, default, sharedSpans: true);
Assert.True(material.IsComplete, material.Reason);
var planned = ExecutionMotionReader.Read(proposal.CopyProgram(), part.Location, null, default);
Assert.True(ContourProgramVerifier.Verify(planned, material, null, default));
}
}
[Fact]
public void Applied_PassesPrePostReview()
{
var nest = new Nest("Z");
var (plate, _, _) = Pair();
plate.CuttingParameters = Parameters();
nest.Plates.Add(plate);
var result = Plan(plate);
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
var findings = PostVerificationAnalyzer.Analyze(nest).Findings;
Assert.DoesNotContain(findings, f => f.Kind is PostVerificationKind.CommonLine or PostVerificationKind.Incomplete
or PostVerificationKind.RapidCrossing or PostVerificationKind.MissingLeadIn or PostVerificationKind.PartSpacing);
}
}
+4 -2
View File
@@ -358,8 +358,10 @@ namespace OpenNest.Controls
cached = (state, CommonLineGeometry.Check(group));
commonLineChecks[group] = cached;
}
foreach (var span in cached.Check.Spans)
DrawLine(g, span.Start, span.End, pen);
// A Z or L shared line is drawn through its corners, as it is cut.
foreach (var path in CommonLineGeometry.Paths(cached.Check.Spans, group.Kerf))
for (var i = 1; i < path.Points.Count; i++)
DrawLine(g, path.Points[i - 1], path.Points[i], pen);
foreach (var problem in cached.Check.Problems)
DrawBox(g, problem.Part.BoundingBox, broken);
}
+24 -9
View File
@@ -22,6 +22,13 @@ were placed at.
`MinSharedLength` (0.25). Only that overlap is shared; chamfers and other edges are cut as
usual. A moved or rotated member therefore breaks its group instead of leaving a stale span.
The tolerances are in model units and sized for inch nests.
- **Z and L shared lines.** Spans of one pair whose ends meet at a corner chain into one shared
path (`CommonLineGeometry.Paths`), for example two stepped parts interlocked one kerf apart along
three legs. Two spans meet when their centerlines cross beyond both ends, each end short of the
crossing by what the kerf leaves there (`kerf/2 · tan(turn/2)`, half a kerf at a square corner),
and the path turns between 10° and 135° (`MinCornerTurn`, `MaxCornerTurn`). The path runs from
the first span's free end through each crossing to the last span's free end. Every other span
is a straight path of its own.
- **Group rule:** every member shares at least one span with another member, and the members form
one connected group. Rows, pairs and grids all qualify.
- **Removing a part** from the plate drops it from its group. A group left with one part is
@@ -42,23 +49,29 @@ planned member's open program changes no utilization, quantity, overlap or spaci
- **Each member is cut** as: its holes (and break cuts and tabbed cutouts, as for any part), then
the shared cuts it owns, then its **remainder**: its perimeter minus every span it shares, in
the perimeter's own direction, as one open piece per stretch between spans. Shared cuts are
never all cut first: each is cut where the route passes its owner.
never all cut first: each is cut where the route passes its owner. A Z or L path is one shared
cut, owned and cut as a whole.
- **Shared cut:** an open cut on the centerline (`LayerType.CommonLine`), cut without kerf
compensation. It is pierced in scrap on the span's own axis, the external lead-in's reach
before the span, and entered with a straight lead-in through the end notch; it leaves the same
way at the far end with a straight lead-out as long as the external lead-out's reach (none with
no lead-out). Capture tries each end in turn: the lead-in, cut and lead-out may touch no
compensation. It is pierced in scrap on its first leg's axis, the external lead-in's reach
before the path, and entered with a straight lead-in through the end notch; a Z or L path is
then cut through its corners in one pass, one `CommonLine` move per leg. It leaves the same way
at the far end, along its last leg, with a straight lead-out as long as the external lead-out's
reach (none with no lead-out). At each corner the kerf leaves the member on the outside of the
turn a fillet of half a kerf's radius where its nominal corner is sharp; the member on the
inside keeps a sharp corner. Capture tries each end in turn: the lead-in, cut and lead-out may touch no
material, and the pierce keeps the pierce zone. When no end fits, planning refuses with
`UnsupportedGeometry` naming both parts and the span. Leads are never shortened.
- **Remainder:** each piece starts where a shared span ends. It is pierced in that span's kerf,
on the centerline, the external lead-in's reach back along the span, and enters along a straight
lead-in to the piece's start, so compensation starts inside the kerf the shared cut opened; the
lead-in to the piece's start, so compensation starts inside the kerf the shared cut opened (for
a Z or L path, the kerf of its last leg before the piece); the
pierce meets nothing. The comment `COMMON LINE REMAINDER CW` (or `CCW`) after its rapid names
the perimeter's winding, so posts compensate it on the perimeter's side. It has no lead-out: it
ends in the kerf at the next span. A span shorter than the lead-in's reach, or an external
lead-in with no length, refuses with `UnsupportedGeometry`.
- **Accounting.** The planner marks each member's shared spans on its owned clean copy as `Tab`
spans (`CommonLineCutting.MarkSharedSpans`): material the member never cuts. Replay's contour
spans (`CommonLineCutting.MarkSharedSpans`): material the member never cuts. At a path's corner
each leg's span runs on to the member's own vertex there, so a Z or L is one run of tabs. Replay's contour
accounting proves each stretch between them is cut whole, once, as for
[tabbed cutouts](tabbed-cutouts.md#verification), and refuses a member that also cuts a span.
- **Rapids.** A shared cut is never a rapid obstacle and takes no part in the open-cut retention
@@ -85,7 +98,9 @@ head on the second. Changing the setting after planning makes the plan stale.
- **Plate > Arrange > Join Common Line** slides the selected parts together one kerf apart
(`CommonLineJoiner`). The unit nearest the origin stays put. Each other part moves along the
normal of its best facing edge pair (the longest overlap, then the smallest move) to the part it
is closest to joining. Facing edges up to four part spacings apart (at least 1) qualify. A part
is closest to joining. It then slides along that edge when a facing pair across it (the step of a
Z or L) is within reach, so that pair lands one kerf apart too and the shared line runs through
the corner. Facing edges up to four part spacings apart (at least 1) qualify. A part
already in a group brings its whole group, which moves as one and merges into the new group.
The kerf is the plate's cutting kerf for the nest's material and thickness (the machine's, see
[machine cutting defaults](machine-cutting-defaults.md)). With no kerf, or any part left without
@@ -96,7 +111,7 @@ head on the second. Changing the setting after planning makes the plan stale.
selection. Right-clicking a part keeps the selection it is in, or selects it with its group;
right-clicking empty plate still clears the selection. Join is offered when the selection holds
at least two units (loose parts or groups), Remove when any selected part is in a group.
- The plate draws each shared span as a teal dashed centerline. A member that no longer shares a
- The plate draws each shared path as a teal dashed centerline, through the corners of a Z or L. A member that no longer shares a
span is outlined in red.
- Selecting a member, by click or window, selects its whole group, so dragging, nudging, pushing,
rotating and aligning move the joined parts as one and keep their shared spans. Deleting the