mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-08 21:35:44 -05:00
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:
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user