diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 684ad2f9..d01de945 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -31,8 +31,34 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector /// public sealed record CommonLinePerimeter(double Kerf, IReadOnlyList SharedCuts); -/// One shared cut on a common line's centerline, in part coordinates and cut direction. -public sealed record CommonLineCutPath(Vector Start, Vector End); +/// +/// 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. +/// +public sealed record CommonLineCutPath(IReadOnlyList Points) +{ + public CommonLineCutPath(Vector start, Vector end) : this(new[] { start, end }) + { + } + + public Vector Start => Points[0]; + + public Vector End => Points[^1]; + + /// The same path cut from its other end. + public CommonLineCutPath Reversed() => new(Points.Reverse().ToArray()); + + /// Whether the path has two or more points and no degenerate leg. + 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; + } +} /// /// 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); diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index ce351866..136cbcb0 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -1629,23 +1629,27 @@ namespace OpenNest.CNC.CuttingStrategy } /// - /// 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. /// internal static List CommonLineSharedCutCodes(CuttingPlanning.CommonLineCutPath cut, LeadIn leadIn, LeadOut leadOut) { var codes = new List(); - 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; } diff --git a/OpenNest.Core/CommonLineCutting.cs b/OpenNest.Core/CommonLineCutting.cs index cda10a46..7c624bf4 100644 --- a/OpenNest.Core/CommonLineCutting.cs +++ b/OpenNest.Core/CommonLineCutting.cs @@ -8,10 +8,11 @@ namespace OpenNest { /// /// One shared cut of a common-line group, cut once on the centerline by , the - /// member of the pair cut first. 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. is cut later; its remainder ends in the /// kerf this cut opened. /// - public sealed record CommonLineSharedCut(CommonLineSpan Span, Part Owner, Part Partner); + public sealed record CommonLineSharedCut(CommonLinePath Path, Part Owner, Part Partner); /// /// 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 CutsOwnedBy(Part member) => Cuts.Where(cut => ReferenceEquals(cut.Owner, member)); - /// Every span shares, whoever cuts it. - public IEnumerable SpansOf(Part member) => Cuts + /// Every shared path shares, whoever cuts it. + public IEnumerable PathsOf(Part member) => Cuts .Where(cut => ReferenceEquals(cut.Owner, member) || ReferenceEquals(cut.Partner, member)) - .Select(cut => cut.Span); + .Select(cut => cut.Path); } /// @@ -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); } /// - /// A copy of (a member's clean program, rotated as placed, in part - /// coordinates; placed at ) whose perimeter edges are split at - /// every end of and whose shared portions are on the - /// layer: material that this member never cuts, because the - /// shared cut does. The result is absolute. Only straight top-level moves can share. + /// with + /// every span a straight path of its own. /// - /// A span does not lie along the program's edges. public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList 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); + } + + /// + /// A copy of (a member's clean program, rotated as placed, in part + /// coordinates; placed at ) whose perimeter edges are split at + /// every end of the shared and whose shared portions are on the + /// 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. + /// + /// A path's leg does not lie along the program's edges. + public static Program MarkSharedSpans(Program clean, Vector location, IReadOnlyList 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(); 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 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 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 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; diff --git a/OpenNest.Core/CommonLineGeometry.cs b/OpenNest.Core/CommonLineGeometry.cs index 889cfcd0..f56add66 100644 --- a/OpenNest.Core/CommonLineGeometry.cs +++ b/OpenNest.Core/CommonLineGeometry.cs @@ -13,6 +13,31 @@ namespace OpenNest public double Length => Start.DistanceTo(End); } + /// + /// 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. runs from the first span's free end + /// through each corner, where the spans' centerlines meet, to the last span's free end. + /// + public sealed record CommonLinePath(Part First, Part Second, IReadOnlyList Spans, + IReadOnlyList Points) + { + public Vector Start => Points[0]; + + public Vector End => Points[^1]; + + /// The kerf-centerline length of the whole path, corners included. + 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; + } + } + } + /// A member of a group that no longer shares a span, or could not be read. 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() @@ -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 anchor, IReadOnlyList 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 Shift(IReadOnlyList edges, Vector offset) => + edges.Select(edge => edge with { Start = edge.Start + offset, End = edge.End + offset }).ToArray(); + + /// The sharpest corner a shared path may turn (radians): 135°. + public const double MaxCornerTurn = 3 * System.Math.PI / 4; + + /// The gentlest corner a shared path may turn (radians): 10°. Gentler meets are not corners. + public const double MinCornerTurn = System.Math.PI / 18; + + /// + /// The shared cuts of at : 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, kerf/2 · tan(turn/2), and + /// the path turns between and . Paths + /// keep the spans' order of first appearance. + /// + public static IReadOnlyList Paths(IReadOnlyList 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(); + 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(); + var points = new List { 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 Spans(Part first, IReadOnlyList a, Part second, IReadOnlyList b, double kerf) { diff --git a/OpenNest.Engine/CuttingPlanning/CommonLineCapture.cs b/OpenNest.Engine/CuttingPlanning/CommonLineCapture.cs index 33f19d0a..7333671e 100644 --- a/OpenNest.Engine/CuttingPlanning/CommonLineCapture.cs +++ b/OpenNest.Engine/CuttingPlanning/CommonLineCapture.cs @@ -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 Spans, + private sealed record Member(CommonLineCutOrder Order, Part Part, IReadOnlyList Paths, IReadOnlyList Owned); } diff --git a/OpenNest.Tests/CommonLines/CommonLineZTests.cs b/OpenNest.Tests/CommonLines/CommonLineZTests.cs new file mode 100644 index 00000000..fa10db97 --- /dev/null +++ b/OpenNest.Tests/CommonLines/CommonLineZTests.cs @@ -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; + +/// +/// 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. +/// +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().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); + } +} diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs index 674abd1b..ff5cb272 100644 --- a/OpenNest/Controls/PlateRenderer.cs +++ b/OpenNest/Controls/PlateRenderer.cs @@ -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); } diff --git a/docs/common-line-cutting.md b/docs/common-line-cutting.md index c04432cd..6a1b96b5 100644 --- a/docs/common-line-cutting.md +++ b/docs/common-line-cutting.md @@ -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