From e6d438629f300f9950f74a8e653bcc25eca90ee2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 12 Nov 2026 17:04:16 -0500 Subject: [PATCH] feat(cutting): slice slugs the support grid can't hold level On an enclosed machine a cutout slug that tips once cut free can stand up above the sheet and catch the enclosure door when the pallet changes. The head already keeps off cut cutouts, so this is about clearing the door, not the head. Cutting Settings > Cutouts > "Slice slugs the support grid can't hold level" (CuttingParameters.SliceTippingSlugs, off by default, saved per machine thickness and written only when on) makes Plan Cutting cut each such slug along the bed's contact lines (grid rows and columns, slat center lines) before the cutout, so no piece keeps a contact and the pieces drop between the supports. Each slice stops a kerf short of the cutout, so the pieces stay attached until the cutout's own kerf frees them together. A slug the bed holds level, or one on no contact, is left whole; a cutout holding another part or another of its part's cuts is never sliced. Slices are break cuts emitted right after the drawing's own break cuts, pierced on themselves and led straight back to their start. The released-contour proof now leaves out straight break cuts lying wholly inside one of the part's closed holes, since the grid cells they free are scrap; break cuts in material still take part. The plan fingerprint only changes when the setting is on. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../CuttingPlanning/OwnedCuttingParameters.cs | 1 + .../CNC/CuttingPlanning/PreparedContours.cs | 85 +++++- .../CuttingPlanning/ReleasedContourState.cs | 35 ++- .../CNC/CuttingPlanning/StateFingerprint.cs | 3 + .../CuttingStrategy/ContourCuttingStrategy.cs | 19 +- .../CNC/CuttingStrategy/CuttingParameters.cs | 8 + OpenNest.Core/Support/SlugSlicer.cs | 209 +++++++++++++ OpenNest.Core/Support/SupportBed.cs | 14 + OpenNest.Core/Support/SupportGrid.cs | 16 + OpenNest.Core/Support/SupportSlats.cs | 18 ++ OpenNest.Data/MachineCuttingDefaults.cs | 2 + OpenNest.Data/ThicknessCuttingConfig.cs | 4 + .../CuttingPlanning/CuttingPlanService.cs | 7 + .../CuttingPlanning/SlugSliceCapture.cs | 25 ++ OpenNest.IO/CuttingParametersDto.cs | 3 + OpenNest.IO/CuttingParametersSerializer.cs | 2 + OpenNest.Tests/Support/SlugSliceTests.cs | 285 ++++++++++++++++++ OpenNest/Controls/CuttingPanel.cs | 18 ++ docs/breakoffs.md | 3 + docs/cutting-planner.md | 5 + docs/machine-cutting-defaults.md | 3 +- docs/support-tabs.md | 42 +++ 23 files changed, 801 insertions(+), 8 deletions(-) create mode 100644 OpenNest.Core/Support/SlugSlicer.cs create mode 100644 OpenNest.Engine/CuttingPlanning/SlugSliceCapture.cs create mode 100644 OpenNest.Tests/Support/SlugSliceTests.cs diff --git a/AGENTS.md b/AGENTS.md index 3982975e..1759386b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ Read the relevant contract before changing its behavior: - [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them - [Dimensions](docs/dimensions.md): CAD Converter annotations that store placement only, are measured from the cut geometry when shown and never affect the program - [Common-line cutting](docs/common-line-cutting.md): parts one kerf apart in `Plate.CommonLines` share an edge; spans are derived, never stored, and spacing checks hold group members to the kerf. Plan Cutting cuts each shared span once (the owner, cut first, starts its compensated outside on the common line; partners cut only their remainders) unless the plate is set to cut them twice; only posts implementing `ICommonLinePostProcessor` (CI Fiber) may write it, others refuse. Machine trial pending. Auto Nest and Fill Area pair `AllowCommonLine` drawings at the nest's `CommonLineUsage` (Off by default, byte-identical to no common lines); `NestLayoutCheck` exempts only explicit result groups -- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level +- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, Plan Cutting's tab placement for parts the grid cannot hold level, and its opt-in slicing of slugs the grid cannot hold level - [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md) - [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md) - [Pre-post verification](docs/post-verification.md) diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs index 9999dcdc..bcc7366e 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs @@ -46,6 +46,7 @@ internal static class OwnedCuttingParameters TabSizeMinWidth = Dimension(source.TabSizeMinWidth), TabSizeMaxWidth = Dimension(source.TabSizeMaxWidth), TabHeldMaxLength = Dimension(source.TabHeldMaxLength), + SliceTippingSlugs = source.SliceTippingSlugs, Sequencing = new SequenceParameters { Method = sequence.Method, diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 38f1bb7b..cae9dd64 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -57,6 +57,9 @@ public sealed class PreparedContours // Closed cutouts with tab spans, in program order. Like break cuts they are not choices: // every full or prefix emission cuts their pieces after the break cuts. private readonly List tabbedCutouts; + // Slices of the slugs the support bed cannot hold level, in cutting order: open cuts inside + // their cutouts, cut right after the break cuts so every slug is sliced before its cutout. + private readonly IReadOnlyList slugSlices; // Pierce-zone resolutions by contour, shared with every copy: the contours are the same by ordinal. private readonly PierceZoneMemo pierceZones; // By contour ordinal; all null without pins. A pinned contour has exactly one entry and @@ -86,8 +89,9 @@ public sealed class PreparedContours private PreparedContours(Shape[] shapes, List scribes, List breakCuts, List tabbedCutouts, CuttingParameters parameters, Shape displayOutline, PierceZoneMemo pierceZones = null, - CommonLinePerimeter commonLine = null) + CommonLinePerimeter commonLine = null, IReadOnlyList slugSlices = null) { + this.slugSlices = slugSlices ?? Array.Empty(); this.pierceZones = pierceZones ?? new PierceZoneMemo(); this.displayOutline = displayOutline; this.commonLine = commonLine; @@ -122,7 +126,7 @@ public sealed class PreparedContours private PreparedContours(PreparedContours unpinned, IReadOnlyList pins, CancellationToken token) : this(unpinned.shapes, unpinned.scribes, unpinned.breakCuts, unpinned.tabbedCutouts, unpinned.parameters, - unpinned.displayOutline, unpinned.pierceZones, unpinned.commonLine) + unpinned.displayOutline, unpinned.pierceZones, unpinned.commonLine, unpinned.slugSlices) { contourParameters = new CuttingParameters[shapes.Length]; var rejected = new List(); @@ -161,7 +165,7 @@ public sealed class PreparedContours // a pinned perimeter keeps its pin's settings: the analysis is only kept for review. private PreparedContours(PreparedContours source, PartSupport support, Vector location, Tab tab) : this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters, - source.displayOutline, source.pierceZones, source.commonLine) + source.displayOutline, source.pierceZones, source.commonLine, source.slugSlices) { for (var contour = 0; contour < Count; contour++) { @@ -205,6 +209,79 @@ public sealed class PreparedContours return new PreparedContours(this, support, location, tab); } + // A copy that slices slugs; everything else, pins and support analysis included, is kept. + private PreparedContours(PreparedContours source, IReadOnlyList slices) + : this(source.shapes, source.scribes, source.breakCuts, source.tabbedCutouts, source.parameters, + source.displayOutline, source.pierceZones, source.commonLine, slices) + { + for (var contour = 0; contour < Count; contour++) + { + pinned[contour] = source.pinned[contour] is { } pin ? pin with { Owner = this } : null; + pinNumbers[contour] = source.pinNumbers[contour]; + } + contourParameters = (CuttingParameters[])source.contourParameters?.Clone(); + RejectedPins = source.RejectedPins; + support = source.support; + supportOrigin = source.supportOrigin; + } + + /// + /// A copy that slices the slug of each cutout cannot hold level + /// (), with the part placed at on a + /// sheet in , along the bed's contact lines, each slice stopping the + /// kerf short of its cutout (docs/support-tabs.md#slug-slices). Every full or prefix emission + /// cuts the slices right after the break cuts, so each slug is sliced before its cutout. + /// A cutout that holds another of this part's cuts, or whose box holds one of + /// (world boxes of the plate's other parts), is not sliced: + /// a slice would cut what is nested in it. Without a bed or a kerf, or with nothing to slice, + /// this preparation is returned unchanged. + /// + internal PreparedContours WithSlugSlices(SupportBed bed, int quadrant, Vector location, + IReadOnlyList otherParts, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(otherParts); + PostVerificationGeometry.Validate(location); + var standoff = parameters.Kerf; + if (bed == null || !(standoff > 0) || !double.IsFinite(standoff)) + return this; + // Pierced on the slice where the cutouts' own lead-in would pierce, and at least as far + // from the cutout as any pierce keeps from its contour. + var clearance = parameters.GetEffectivePierceClearance(); + var reach = System.Math.Max(clearance, + ContourCuttingStrategy.CommonLineLeadInReach(parameters.InternalLeadIn ?? new NoLeadIn())); + var slices = new List(); + for (var ordinal = 0; ordinal < PerimeterOrdinal; ordinal++) + { + token.ThrowIfCancellationRequested(); + var hole = shapes[ordinal]; + var box = hole.BoundingBox; + var world = new Box(box.Left + location.X, box.Bottom + location.Y, box.Length, box.Width); + if (otherParts.Any(other => other != null && world.Contains(other))) + continue; + var polygon = hole.ToPolygonWithTolerance(SupportMaterial.ChordTolerance); + if (EnclosesOtherCuts(ordinal, polygon) + || !SlugSlicer.Tips(bed, quadrant, SupportMaterial.FromPolygons(polygon.Vertices.Select(v => v + location)))) + continue; + slices.AddRange(SlugSlicer.Slices(bed, quadrant, polygon.Vertices, location, standoff, reach, clearance)); + } + return slices.Count == 0 ? this : new PreparedContours(this, slices.AsReadOnly()); + } + + // Whether another contour, break cut or tabbed cutout of this part lies inside the cutout. + private bool EnclosesOtherCuts(int ordinal, Polygon cutout) + { + var others = shapes.Where((_, index) => index != ordinal && index != PerimeterOrdinal) + .Concat(breakCuts).Concat(tabbedCutouts); + return others.Any(other => other.Entities.Count > 0 && cutout.ContainsPoint(other.Entities[0] switch + { + Circle circle => circle.Center + new Vector(circle.Radius, 0), + var entity => Start(entity), + })); + } + + /// The slug slices this preparation cuts after its break cuts and before its contours. + public IReadOnlyList SlugSlices => slugSlices; + /// /// The analysis of a part its support bed cannot hold level (see ), /// or null; places its local contours in its world frame. @@ -1028,7 +1105,7 @@ public sealed class PreparedContours shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices, contourParameters, breakCuts.Select(s => (Shape)s.Clone()).ToList(), tabbedCutouts.Select(s => (Shape)s.Clone()).ToList(), PerimeterOrdinal, - complete ? (Shape)displayOutline?.Clone() : null, commonLine); + complete ? (Shape)displayOutline?.Clone() : null, commonLine, slugSlices); /// Open break cuts this preparation cuts before its contours. public int BreakCutCount => breakCuts.Count; diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs index 6b6a19cc..2f61c7a4 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs @@ -316,9 +316,13 @@ public sealed class ReleasedContourState // A common-line member's open cuts are its remainder pieces: they end in shared kerf, not at // tabs, and the planner's contour accounting proves they cut its outside exactly once. var remainders = member && !cutoff && unfinished.Count > 0; + // Slug slices (docs/support-tabs.md#slug-slices) cross one another inside one of the part's + // own holes: what they enclose is that hole's slug, scrap, so they take no part in the proof. var retained = !cutoff && (unfinished.Count > 1 || deferred.Count > 0) && (remainders || unfinished.All(LeadsClearOpenEnds) - && OpenCutsEncloseNothing(closed, unfinished.Concat(otherOpen).Append(loose).ToList(), token)); + && OpenCutsEncloseNothing(closed, unfinished + .Concat(otherOpen.Where(group => !CutsOnlySlug(group, closed, unfinished, token))) + .Append(loose).ToList(), token)); for (var i = deferred.Count - 1; i >= 0 && !retained; i--) deferred[i].Target.Insert(deferred[i].Index, deferred[i].Finding); // Only one open fragment per part can be the planner's own certified tab, so a @@ -461,6 +465,35 @@ public sealed class ReleasedContourState || lead.End.DistanceTo(point) <= PostVerificationGeometry.Epsilon; } + /// + /// Whether break cut , its leads included, lies wholly inside one of + /// the part's closed holes and touches none of it: whatever it frees is that hole's slug. Only + /// straight cuts qualify. The part's outside is its largest cut, never a hole. + /// + private static bool CutsOnlySlug(CutGroup group, IReadOnlyList closed, IReadOnlyList unfinished, + CancellationToken token) + { + var cuts = group.Curves.Concat(group.Leads).ToList(); + if (cuts.Count == 0 || !cuts.TrueForAll(cut => cut.IsLine)) + return false; + var outside = closed.Concat(unfinished).Where(cut => cut.Curves.Count > 0).MaxBy(cut => + { + var extent = cut.Curves.Aggregate(PostVerificationGeometry.Extent.None, (sum, curve) => sum.Union(curve.Extent)); + return (extent.MaxX - extent.MinX) * (extent.MaxY - extent.MinY); + }); + foreach (var hole in closed) + { + token.ThrowIfCancellationRequested(); + if (ReferenceEquals(hole, outside) || !PostVerificationGeometry.Closed(hole.Curves)) + continue; + if (cuts.TrueForAll(cut => PostVerificationGeometry.Inside(cut.Start, hole.Curves, token) + && PostVerificationGeometry.Inside(cut.End, hole.Curves, token) + && PostVerificationGeometry.ContactPoints(cut.Start, cut.End, hole.Curves, token).Count == 0)) + return true; + } + return false; + } + private static bool OpenCutsEncloseNothing(IReadOnlyList closed, IReadOnlyList open, CancellationToken token) { diff --git a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs index 213c1c8b..54d07436 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs @@ -94,6 +94,9 @@ internal static class StateFingerprint Number(p.TabSizeMinWidth); Number(p.TabSizeMaxWidth); Number(p.TabHeldMaxLength); + // Only when on, so fingerprints taken before the setting existed still match. + if (p.SliceTippingSlugs) + text.Append("slugSlices;"); Sequence(p.Sequencing); Assignment(p.Assignment); text.Append('}'); diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 1aec05c0..efb90f82 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -268,11 +268,12 @@ namespace OpenNest.CNC.CuttingStrategy IReadOnlyList choices, IReadOnlyList contourParameters = null, IReadOnlyList breakCuts = null, IReadOnlyList tabbedCutouts = null, int? perimeterOrdinal = null, Shape displayTail = null, - CuttingPlanning.CommonLinePerimeter commonLine = null) + CuttingPlanning.CommonLinePerimeter commonLine = null, IReadOnlyList slugSlices = null) { var result = new Program(Mode.Absolute); EmitScribeContours(result, scribes); EmitBreakCuts(result, breakCuts, shapes); + EmitSlugSlices(result, slugSlices); foreach (var cutout in tabbedCutouts ?? Array.Empty()) EmitTabbedCutout(result, cutout); EmitChosenContours(result, shapes, choices, contourParameters, perimeterOrdinal ?? shapes.Length - 1, @@ -1583,6 +1584,22 @@ namespace OpenNest.CNC.CuttingStrategy } } + // Each slug slice is a break cut inside its cutout's slug: pierced on itself, led straight + // back along itself to its start, then cut. It has no lead-out and keeps the kerf from + // the cutout, so its slug's pieces stay attached until the cutout is cut. + private static void EmitSlugSlices(Program program, IReadOnlyList slices) + { + if (slices == null) + return; + foreach (var slice in slices) + { + program.Codes.Add(new RapidMove(slice.Pierce)); + if (slice.Pierce.DistanceTo(slice.Start) > Tolerance.Epsilon) + program.Codes.Add(new LinearMove(slice.Start) { Layer = LayerType.Leadin }); + program.Codes.Add(new LinearMove(slice.End) { Layer = LayerType.Break }); + } + } + /// /// Where a break cut (traveling with its scrap on the left) is entered: the end with more /// scrap before , the unit direction into the scrap there, and diff --git a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs index 51e7f001..02826cfd 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs @@ -105,6 +105,14 @@ namespace OpenNest.CNC.CuttingStrategy TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength) && length <= TabHeldMaxLength + Math.Tolerance.Epsilon; + /// + /// Slices the slug of each cutout its plate's support bed cannot hold level along the + /// bed's contact lines before the cutout is cut, so the slug drops as pieces between the + /// supports instead of tipping (see docs/support-tabs.md#slug-slices). Each slice stops + /// short of the cutout, so nothing is sliced without a kerf. + /// + public bool SliceTippingSlugs { get; set; } + public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); } diff --git a/OpenNest.Core/Support/SlugSlicer.cs b/OpenNest.Core/Support/SlugSlicer.cs new file mode 100644 index 00000000..5560ac4a --- /dev/null +++ b/OpenNest.Core/Support/SlugSlicer.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Clipper2Lib; +using OpenNest.Geometry; + +namespace OpenNest.Support +{ + /// + /// One straight slice of a cutout's slug, in the part's local coordinates: pierced at + /// on the slice, led back along it to , then cut to + /// . equals when there is no lead. + /// + public sealed record SlugSlice(Vector Pierce, Vector Start, Vector End); + + /// + /// Slices for the slug of a cutout its support bed cannot hold level (see + /// docs/support-tabs.md#slug-slices). The slug is cut along every line the bed's contacts lie + /// on (), so each piece keeps no contact and drops between + /// the supports once the cutout is cut, instead of tipping. Each slice stops a standoff (the + /// kerf) short of the cutout, so the pieces stay attached until the cutout itself is cut and + /// no slice reaches the part. + /// + public static class SlugSlicer + { + private const double RoundingAllowance = 2e-4; + + /// + /// Whether a cutout's slug, (world coordinates), would tip once cut + /// free: it rests on at least one contact of but the bed cannot hold + /// it level. A slug on no contact falls through whole, so it is not sliced. + /// + public static bool Tips(SupportBed bed, int quadrant, SupportMaterial slug) + { + ArgumentNullException.ThrowIfNull(bed); + ArgumentNullException.ThrowIfNull(slug); + var support = SupportAnalyzer.Analyze(bed, quadrant, slug); + return support.IsComplete && !support.IsSupported && support.Contacts.Count > 0; + } + + /// + /// The slices of the slug inside (a closed polygon in the part's + /// local coordinates; world = local + ), along the contact lines + /// of on a sheet in . Each slice keeps at + /// least from the hole, is longer than it, and is pierced + /// along itself from its start (at most halfway) where the pierce + /// keeps from the hole; a slice whose pierce fits at + /// neither end is left out. Rows (lines along X) come first, then columns, each set from + /// its end nearer the last slice, and each slice starts at its end nearer where the one + /// before it ended, so the head runs back and forth. + /// + public static IReadOnlyList Slices(SupportBed bed, int quadrant, IReadOnlyList hole, + Vector location, double standoff, double reach, double pierceClearance) + { + ArgumentNullException.ThrowIfNull(bed); + ArgumentNullException.ThrowIfNull(hole); + if (!(standoff > 0) || !double.IsFinite(standoff)) + throw new ArgumentOutOfRangeException(nameof(standoff), "The standoff must be finite and positive."); + if (!(reach >= 0) || !double.IsFinite(reach)) + throw new ArgumentOutOfRangeException(nameof(reach)); + if (!(pierceClearance >= 0) || !double.IsFinite(pierceClearance)) + throw new ArgumentOutOfRangeException(nameof(pierceClearance)); + + var ring = Ring(hole); + if (ring.Count < 3) + return Array.Empty(); + // Clipper rounds to 1e-4 (ClipperBridge.Precision): erode a little further so no slice + // end comes inside the standoff. + var eroded = Clipper.InflatePaths(new PathsD { ToPath(ring) }, -(standoff + RoundingAllowance), + JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision); + if (eroded.Count == 0) + return Array.Empty(); + + var bounds = Bounds(ring); + var world = new Box(bounds.Left + location.X, bounds.Bottom + location.Y, bounds.Length, bounds.Width); + var lines = bed.ContactLines(world, quadrant); + // Each line's pieces inside the eroded hole, local; lines that miss it are dropped. + var groups = new[] { true, false }.Select(alongX => lines + .Where(line => line.AlongX == alongX) + .OrderBy(line => line.At) + .Select(line => Segments(eroded, line, location, standoff)) + .Where(segments => segments.Count > 0) + .ToList()) + .ToArray(); + + var slices = new List(); + var clearance = System.Math.Max(pierceClearance, standoff); + Vector? position = null; + foreach (var group in groups) + { + if (group.Count == 0) + continue; + // Rows, then columns, each set from the end nearer the last slice, back and forth: + // every slice starts at its end nearer where the one before it ended. + if (position is { } from && Nearest(group[^1], from) < Nearest(group[0], from)) + group.Reverse(); + foreach (var segments in group) + { + if (position is { } here && Nearest([segments[^1]], here) < Nearest([segments[0]], here)) + segments.Reverse(); + foreach (var (a, b) in segments) + { + var (start, end) = position is { } at && b.DistanceTo(at) < a.DistanceTo(at) ? (b, a) : (a, b); + var slice = Pierced(start, end, reach, ring, clearance) + ?? Pierced(end, start, reach, ring, clearance); + if (slice == null) + continue; + slices.Add(slice); + position = slice.End; + } + } + } + return slices; + } + + // The pieces of one contact line inside the eroded hole, local, low to high, each longer + // than the standoff. + private static List<(Vector A, Vector B)> Segments(PathsD eroded, ContactLine line, Vector location, + double standoff) + { + var at = line.At - (line.AlongX ? location.Y : location.X); + return Spans(eroded, line.AlongX, at) + .Where(span => span.High - span.Low > standoff) + .Select(span => line.AlongX + ? (new Vector(span.Low, at), new Vector(span.High, at)) + : (new Vector(at, span.Low), new Vector(at, span.High))) + .ToList(); + } + + private static double Nearest(List<(Vector A, Vector B)> segments, Vector point) => + segments.Min(segment => System.Math.Min(segment.A.DistanceTo(point), segment.B.DistanceTo(point))); + + // A slice from start to end pierced on itself, or null when the pierce is too close to the hole. + private static SlugSlice Pierced(Vector start, Vector end, double reach, IReadOnlyList ring, + double clearance) + { + var length = start.DistanceTo(end); + var pierce = start + (end - start) * (System.Math.Min(reach, length / 2) / length); + return Distance(pierce, ring) >= clearance - Math.Tolerance.Epsilon + ? new SlugSlice(pierce, start, end) + : null; + } + + // Where the line at `at` (Y along X, or X along Y) runs inside the eroded paths, sorted. + private static List<(double Low, double High)> Spans(PathsD paths, bool alongX, double at) + { + var spans = new List<(double, double)>(); + foreach (var path in paths) + { + var crossings = new List(); + for (var i = 0; i < path.Count; i++) + { + var p = path[i]; + var q = path[(i + 1) % path.Count]; + var (pa, pb, qa, qb) = alongX ? (p.y, p.x, q.y, q.x) : (p.x, p.y, q.x, q.y); + if (pa > at == qa > at) + continue; + crossings.Add(pb + (at - pa) * (qb - pb) / (qa - pa)); + } + crossings.Sort(); + for (var i = 0; i + 1 < crossings.Count; i += 2) + spans.Add((crossings[i], crossings[i + 1])); + } + spans.Sort((x, y) => x.Item1.CompareTo(y.Item1)); + return spans; + } + + private static double Distance(Vector point, IReadOnlyList ring) + { + var best = double.PositiveInfinity; + for (var i = 0; i < ring.Count; i++) + { + var a = ring[i]; + var b = ring[(i + 1) % ring.Count]; + var edge = b - a; + var squared = edge.X * edge.X + edge.Y * edge.Y; + var t = squared > 0 + ? System.Math.Clamp(((point.X - a.X) * edge.X + (point.Y - a.Y) * edge.Y) / squared, 0, 1) + : 0; + best = System.Math.Min(best, point.DistanceTo(a + edge * t)); + } + return best; + } + + // The polygon without a repeated closing vertex. + private static List Ring(IReadOnlyList polygon) + { + var ring = polygon.ToList(); + if (ring.Count > 1 && ring[0].DistanceTo(ring[^1]) <= Math.Tolerance.Epsilon) + ring.RemoveAt(ring.Count - 1); + return ring; + } + + private static PathD ToPath(IReadOnlyList ring) + { + var path = new PathD(ring.Count); + foreach (var vertex in ring) + path.Add(new PointD(vertex.X, vertex.Y)); + return path; + } + + private static Box Bounds(IReadOnlyList ring) + { + var left = ring.Min(v => v.X); + var bottom = ring.Min(v => v.Y); + return new Box(left, bottom, ring.Max(v => v.X) - left, ring.Max(v => v.Y) - bottom); + } + } +} diff --git a/OpenNest.Core/Support/SupportBed.cs b/OpenNest.Core/Support/SupportBed.cs index 59f5d0ca..5a6389aa 100644 --- a/OpenNest.Core/Support/SupportBed.cs +++ b/OpenNest.Core/Support/SupportBed.cs @@ -12,6 +12,12 @@ namespace OpenNest.Support /// The laser grid () is the built-in layout; straight or curved /// slats imported from a CAD layout can follow as further subclasses. /// + /// + /// A line a support bed's contacts lie on: along X at Y = when + /// , else along Y at X = (world coordinates). + /// + public readonly record struct ContactLine(bool AlongX, double At); + public abstract class SupportBed : IEquatable { protected SupportBed(double registrationTolerance, double stabilityMargin) @@ -47,6 +53,14 @@ namespace OpenNest.Support public abstract IReadOnlyList Contacts(SupportMaterial material, int quadrant, Vector shift, bool extremesOnly = false); + /// + /// The straight lines every contact lies on across (world + /// coordinates) on a sheet in : a grid's rows and columns, a + /// slat's center line. Material cut along all of them keeps no contact, so it drops + /// between the supports (see ). + /// + public abstract IReadOnlyList ContactLines(Box region, int quadrant); + /// This layout with every length multiplied by (a unit change). public abstract SupportBed Scaled(double factor); diff --git a/OpenNest.Core/Support/SupportGrid.cs b/OpenNest.Core/Support/SupportGrid.cs index 05191589..9dcd305c 100644 --- a/OpenNest.Core/Support/SupportGrid.cs +++ b/OpenNest.Core/Support/SupportGrid.cs @@ -91,6 +91,22 @@ namespace OpenNest.Support return points; } + /// Every row and column of contacts that crosses . + public override IReadOnlyList ContactLines(Box region, int quadrant) + { + ArgumentNullException.ThrowIfNull(region); + var lines = new List(); + var baseX = SignX(quadrant) * OffsetX; + var baseY = SignY(quadrant) * OffsetY; + for (var row = System.Math.Ceiling((region.Bottom - baseY) / PitchY); + row <= System.Math.Floor((region.Top - baseY) / PitchY); row++) + lines.Add(new ContactLine(true, baseY + row * PitchY)); + for (var column = System.Math.Ceiling((region.Left - baseX) / PitchX); + column <= System.Math.Floor((region.Right - baseX) / PitchX); column++) + lines.Add(new ContactLine(false, baseX + column * PitchX)); + return lines; + } + public override SupportBed Scaled(double factor) => new SupportGrid(PitchX * factor, PitchY * factor, OffsetX * factor, OffsetY * factor, RegistrationTolerance * factor, StabilityMargin * factor); diff --git a/OpenNest.Core/Support/SupportSlats.cs b/OpenNest.Core/Support/SupportSlats.cs index 65eb4fc5..0f12aff5 100644 --- a/OpenNest.Core/Support/SupportSlats.cs +++ b/OpenNest.Core/Support/SupportSlats.cs @@ -112,6 +112,24 @@ namespace OpenNest.Support return slats; } + /// + /// The center line of every slat that crosses . Material between + /// two neighboring center lines touches at most those slats' edges. + /// + public override IReadOnlyList ContactLines(Box region, int quadrant) + { + ArgumentNullException.ThrowIfNull(region); + var lines = new List(); + var alongX = Axis == SlatAxis.X; + var baseline = alongX ? SignY(quadrant) * Offset : SignX(quadrant) * Offset; + var low = alongX ? region.Bottom : region.Left; + var high = alongX ? region.Top : region.Right; + for (var slat = System.Math.Ceiling((low - baseline) / Pitch); + slat <= System.Math.Floor((high - baseline) / Pitch); slat++) + lines.Add(new ContactLine(alongX, baseline + slat * Pitch)); + return lines; + } + public override SupportBed Scaled(double factor) => new SupportSlats(Axis, Pitch * factor, Thickness * factor, Offset * factor, RegistrationTolerance * factor, StabilityMargin * factor); diff --git a/OpenNest.Data/MachineCuttingDefaults.cs b/OpenNest.Data/MachineCuttingDefaults.cs index 58e611e6..23ce12ba 100644 --- a/OpenNest.Data/MachineCuttingDefaults.cs +++ b/OpenNest.Data/MachineCuttingDefaults.cs @@ -130,6 +130,7 @@ public static class MachineCuttingDefaults TabHeldMaxLength = parameters.TabHeldMaxLength * scale, TabSizeMinWidth = parameters.TabSizeMinWidth * scale, TabSizeMaxWidth = parameters.TabSizeMaxWidth * scale, + SliceTippingSlugs = parameters.SliceTippingSlugs, PierceClearance = parameters.PierceClearance * scale, PierceDiameter = parameters.PierceDiameter * scale, Unknown = previous?.Unknown, @@ -147,6 +148,7 @@ public static class MachineCuttingDefaults parameters.TabHeldMaxLength = cutting.TabHeldMaxLength * scale; parameters.TabSizeMinWidth = cutting.TabSizeMinWidth * scale; parameters.TabSizeMaxWidth = cutting.TabSizeMaxWidth * scale; + parameters.SliceTippingSlugs = cutting.SliceTippingSlugs; parameters.PierceClearance = cutting.PierceClearance * scale; parameters.PierceDiameter = cutting.PierceDiameter * scale; } diff --git a/OpenNest.Data/ThicknessCuttingConfig.cs b/OpenNest.Data/ThicknessCuttingConfig.cs index 3b2ac06f..714ff75a 100644 --- a/OpenNest.Data/ThicknessCuttingConfig.cs +++ b/OpenNest.Data/ThicknessCuttingConfig.cs @@ -29,6 +29,10 @@ public class ThicknessCuttingConfig /// The widest part tabbed by size; 0 tabs nothing by size. public double TabSizeMaxWidth { get; set; } + /// Whether cutouts the support grid can't hold level have their slugs sliced; written only when on. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool SliceTippingSlugs { get; set; } + public double PierceClearance { get; set; } = 0.0625; /// The pierce zone's diameter; 0 turns it off. diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 859acf2a..28db32d0 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -65,6 +65,8 @@ public static class CuttingPlanService var nodes = new List(); // Reuse the reader's coordinate validation without publishing its native kernel. var identities = new HashSet(ReferenceEqualityComparer.Instance); + // Every part's box, read once, for slug slices to keep clear of parts nested in a cutout. + Box[] partBoxes = null; for (var index = 0; index < request.Parts.Count; index++) { token.ThrowIfCancellationRequested(); @@ -170,6 +172,11 @@ public static class CuttingPlanService prepared = DrawingPinCapture.Prepare(source, index, ownedClean, request.ConfirmedParameters, warnings, token); prepared = SupportTabCapture.Prepare(prepared, ownedClean, location, request, token); + if (request.ConfirmedParameters.SliceTippingSlugs) + { + partBoxes ??= request.Parts.Select(part => part?.BoundingBox).ToArray(); + prepared = SlugSliceCapture.Prepare(prepared, location, request, partBoxes, token); + } } else if (source.HasActiveDrawingPins) warnings.Add(DrawingPinCapture.NotApplied(source, index)); diff --git a/OpenNest.Engine/CuttingPlanning/SlugSliceCapture.cs b/OpenNest.Engine/CuttingPlanning/SlugSliceCapture.cs new file mode 100644 index 00000000..43b0f726 --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/SlugSliceCapture.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// +/// Capture-time slug slices for one regenerated part (docs/support-tabs.md#slug-slices): with +/// on and a support bed, +/// the slug of each cutout the bed cannot hold level is sliced along the bed's contact lines +/// before the cutout is cut (). +/// +internal static class SlugSliceCapture +{ + internal static PreparedContours Prepare(PreparedContours prepared, Vector location, CuttingPlanRequest request, + IReadOnlyList partBoxes, CancellationToken token) + { + if (prepared == null || request.ConfirmedParameters?.SliceTippingSlugs != true || request.SupportBed == null) + return prepared; + token.ThrowIfCancellationRequested(); + // A cutout's box can never hold its own part's box, so every part's box may be passed. + return prepared.WithSlugSlices(request.SupportBed, request.SupportQuadrant, location, partBoxes, token); + } +} diff --git a/OpenNest.IO/CuttingParametersDto.cs b/OpenNest.IO/CuttingParametersDto.cs index c1dd6ef7..4b6c9502 100644 --- a/OpenNest.IO/CuttingParametersDto.cs +++ b/OpenNest.IO/CuttingParametersDto.cs @@ -38,6 +38,9 @@ public class CuttingParametersDto public double NoBedTabMinWidth { get; set; } public double NoBedTabMaxWidth { get; set; } public double TabHeldMaxLength { get; set; } + // Written only when on, so files and settings saved without it are unchanged. + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault)] + public bool SliceTippingSlugs { get; set; } public SequenceDto Sequencing { get; set; } = new(); public AssignmentDto Assignment { get; set; } = new(); diff --git a/OpenNest.IO/CuttingParametersSerializer.cs b/OpenNest.IO/CuttingParametersSerializer.cs index c6787e4f..c55cd6cd 100644 --- a/OpenNest.IO/CuttingParametersSerializer.cs +++ b/OpenNest.IO/CuttingParametersSerializer.cs @@ -75,6 +75,7 @@ public static class CuttingParametersSerializer NoBedTabMinWidth = parameters.TabSizeMinWidth, NoBedTabMaxWidth = parameters.TabSizeMaxWidth, TabHeldMaxLength = parameters.TabHeldMaxLength, + SliceTippingSlugs = parameters.SliceTippingSlugs, Sequencing = ToSequenceDto(parameters.Sequencing), Assignment = ToAssignmentDto(parameters.Assignment), }; @@ -112,6 +113,7 @@ public static class CuttingParametersSerializer TabSizeMinWidth = dto.NoBedTabMinWidth, TabSizeMaxWidth = dto.NoBedTabMaxWidth, TabHeldMaxLength = dto.TabHeldMaxLength, + SliceTippingSlugs = dto.SliceTippingSlugs, Sequencing = FromSequenceDto(dto.Sequencing), Assignment = FromAssignmentDto(dto.Assignment), }; diff --git a/OpenNest.Tests/Support/SlugSliceTests.cs b/OpenNest.Tests/Support/SlugSliceTests.cs new file mode 100644 index 00000000..2f65a1f7 --- /dev/null +++ b/OpenNest.Tests/Support/SlugSliceTests.cs @@ -0,0 +1,285 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; +using OpenNest.Support; +using OpenNest.Tests.CuttingPlanning; + +namespace OpenNest.Tests.Support; + +/// +/// Slug slices (docs/support-tabs.md#slug-slices): the slug of a cutout its support bed cannot +/// hold level is cut along the bed's contact lines, a kerf short of the cutout, before the cutout. +/// +public class SlugSliceTests +{ + private const double Kerf = 0.06; + + // A 2 inch laser grid with no registration slack, so contacts are exactly on its lines. + private static readonly SupportGrid Bed = new(2, 2); + + // An 8 x 1 slot at world (3, 3.5)-(11, 4.5): its slug rests on the grid's row at Y = 4 alone. + private static readonly Vector[] Slot = + [new(2, 2.5), new(10, 2.5), new(10, 3.5), new(2, 3.5)]; + + private static readonly Vector Location = new(1, 1); + + private static CuttingParameters Parameters(bool slice = true, double kerf = Kerf) + { + var parameters = ExplicitContourTests.Parameters(); + parameters.Kerf = kerf; + parameters.SliceTippingSlugs = slice; + return parameters; + } + + // A 12 inch square plate part with one rectangular hole, both local. + private static Drawing Frame(double x1 = 2, double y1 = 2.5, double x2 = 10, double y2 = 3.5) + { + var program = LeadPathValidationTests.Rectangle(0, 0, 12, 12); + var hole = LeadPathValidationTests.Rectangle(x1, y1, x2, y2); + program.Codes.AddRange(hole.Codes); + return new Drawing("frame", program); + } + + private static (Nest Nest, Plate Plate, Part Part) Plate(CuttingParameters parameters, Drawing drawing = null, + SupportBed bed = null) + { + var nest = new Nest("slug slices"); + var plate = new Plate(30, 30) { CuttingParameters = parameters }; + nest.Plates.Add(plate); + nest.SetSupportBed(bed ?? Bed); + var part = new Part(drawing ?? Frame(), Location); + plate.Parts.Add(part); + return (nest, plate, part); + } + + private static IReadOnlyList Motions(FixedProgramPlacement proposal) => + ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, null, default).Motions; + + private static double DistanceToSlot(Vector world) + { + // The slot in world coordinates: (3, 3.5)-(11, 4.5); inside it, the nearest side. + return System.Math.Min(System.Math.Min(world.X - 3, 11 - world.X), System.Math.Min(world.Y - 3.5, 4.5 - world.Y)); + } + + [Fact] + public void ContactLines_AreTheGridsRowsAndColumnsAndTheSlatsCenters() + { + var region = new Box(3, 3.5, 8, 1); + + var grid = Bed.ContactLines(region, 1); + var slats = new SupportSlats(SlatAxis.Y, 3, 0.25, offset: 1.5).ContactLines(region, 1); + var mirrored = new SupportGrid(2, 2, 1, 0).ContactLines(new Box(-5, 0, 2, 1), 2); + + Assert.Equal([new(true, 4), new(false, 4), new(false, 6), new(false, 8), new(false, 10)], grid); + Assert.Equal([new(false, 4.5), new(false, 7.5), new(false, 10.5)], slats); + // A sheet in quadrant 2 sees the grid's X offset mirrored: columns at -1, -3, -5, ... + Assert.Equal([new(true, 0), new(false, -5), new(false, -3)], mirrored); + } + + [Fact] + public void Tips_OnlyForASlugOnContactsTheBedCannotHoldLevel() + { + SupportMaterial World(double x1, double y1, double x2, double y2) => + SupportMaterial.FromPolygons([new(x1, y1), new(x2, y1), new(x2, y2), new(x1, y2)]); + + // On one row of contacts: it balances on a line and tips. + Assert.True(SlugSlicer.Tips(Bed, 1, World(3, 3.5, 11, 4.5))); + // On a block of contacts around its center: it lies level. + Assert.False(SlugSlicer.Tips(Bed, 1, World(3, 3, 11, 9))); + // Between contacts: it falls through whole. + Assert.False(SlugSlicer.Tips(Bed, 1, World(2.5, 2.5, 3.5, 3.5))); + } + + [Fact] + public void Slices_RunAlongTheContactLinesAKerfShortOfTheCutout() + { + var slices = SlugSlicer.Slices(Bed, 1, Slot, Location, Kerf, reach: 0.3, pierceClearance: 0.05); + + // The row at world Y = 4 left to right, then the columns from X = 10 back to 4, back and forth. + Assert.Equal(5, slices.Count); + var row = slices[0]; + Assert.Equal(3, row.Start.Y, 9); + Assert.Equal(3, row.End.Y, 9); + Assert.True(row.End.X > row.Start.X); + Assert.Equal([9, 7, 5, 3], slices.Skip(1).Select(s => System.Math.Round(s.Start.X, 9))); + for (var i = 2; i < slices.Count; i++) + Assert.NotEqual(slices[i - 1].End.Y > slices[i - 1].Start.Y, slices[i].End.Y > slices[i].Start.Y); + foreach (var slice in slices) + { + // Within the Clipper rounding allowance of the kerf, never closer. + foreach (var end in new[] { slice.Start, slice.End }) + { + var distance = DistanceToSlot(end + Location); + Assert.InRange(distance, Kerf, Kerf + 1e-3); + } + // Pierced on the slice, its lead-in reach (at most halfway) from the start. + var length = slice.Start.DistanceTo(slice.End); + Assert.Equal(System.Math.Min(0.3, length / 2), slice.Pierce.DistanceTo(slice.Start), 9); + Assert.Equal(length, slice.Pierce.DistanceTo(slice.Start) + slice.Pierce.DistanceTo(slice.End), 9); + } + } + + [Fact] + public void Slices_LeaveOutSlicesWhosePierceFitsNeitherEnd() + { + // Pierced 0.1 along a slice from an end 0.06 from the slot, no pierce keeps 0.2 from it. + Assert.Empty(SlugSlicer.Slices(Bed, 1, Slot, Location, Kerf, reach: 0.1, pierceClearance: 0.2)); + } + + [Fact] + public void PlanCutting_SlicesATippingSlugBeforeItsCutout() + { + var (nest, plate, _) = Plate(Parameters()); + + var proposal = CuttingPlanBatch.Capture([plate], Parameters(), preservePartOrder: false).Plan(); + + var result = proposal.Plates[0].Result; + Assert.True(result.Status == CuttingPlanStatus.Ready, + string.Join("\n", proposal.Describe("in").Concat(result.Findings.Select(f => f.Message)))); + Assert.True(proposal.CanApply); + Assert.Empty(result.Findings); + var motions = Motions(Assert.Single(result.ProposedOrder)); + var slices = motions.Where(m => !m.Rapid && m.Layer == LayerType.Break).ToList(); + Assert.Equal(5, slices.Count); + foreach (var slice in slices) + { + Assert.InRange(DistanceToSlot(slice.Curve.Start), Kerf - 1e-9, 1); + Assert.InRange(DistanceToSlot(slice.Curve.End), Kerf - 1e-9, 1); + } + // Every slice is cut before any contour, so before the slot. + var lastSlice = motions.ToList().FindLastIndex(m => !m.Rapid && m.Layer == LayerType.Break); + var firstCut = motions.ToList().FindIndex(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display); + Assert.InRange(lastSlice, 0, firstCut - 1); + + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status); + var review = PostVerificationAnalyzer.Analyze(nest).Findings; + Assert.DoesNotContain(review, f => f.Kind is PostVerificationKind.Incomplete or PostVerificationKind.RapidCrossing + or PostVerificationKind.MissingLeadIn or PostVerificationKind.FixedLead or PostVerificationKind.PierceZone); + } + + [Theory] + [InlineData(false, Kerf)] // off + [InlineData(true, 0)] // no kerf to stop short by + public void PlanCutting_SlicesNothing_WhenOffOrWithoutAKerf(bool slice, double kerf) + { + var (_, plate, _) = Plate(Parameters(slice, kerf)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters(slice, kerf))); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.DoesNotContain(Motions(Assert.Single(result.ProposedOrder)), m => m.Layer == LayerType.Break); + } + + [Fact] + public void PlanCutting_DoesNotSliceASlugTheBedHoldsOrThatFallsThrough() + { + // A 6 x 6 hole rests level on a block of contacts; a 1 x 1 hole between contacts falls through. + var program = LeadPathValidationTests.Rectangle(0, 0, 12, 12); + program.Codes.AddRange(LeadPathValidationTests.Rectangle(2, 2, 8, 8).Codes); + program.Codes.AddRange(LeadPathValidationTests.Rectangle(9.5, 9.5, 10.5, 10.5).Codes); + var (_, plate, _) = Plate(Parameters(), new Drawing("two holes", program)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters())); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.DoesNotContain(Motions(Assert.Single(result.ProposedOrder)), m => m.Layer == LayerType.Break); + } + + [Fact] + public void PlanCutting_DoesNotSliceACutoutAnotherPartIsNestedIn() + { + // A 7.5 x 0.6 strip nested in the slot would be cut through by the slices. + var (_, plate, _) = Plate(Parameters()); + plate.Parts.Add(new Part(new Drawing("strip", LeadPathValidationTests.Rectangle(0, 0, 7.5, 0.6)), + new Vector(3.25, 3.7))); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters())); + + Assert.True(result.Status == CuttingPlanStatus.Ready, string.Join("\n", result.Findings.Select(f => f.Message))); + Assert.All(result.ProposedOrder, proposal => + Assert.DoesNotContain(Motions(proposal), m => m.Layer == LayerType.Break)); + } + + [Fact] + public void PlanCutting_SlicesAlongSlats() + { + // Slats along X every 2 inches: the slot's slug rests on the one at Y = 4 alone, too + // narrow under it for the stability margin. + var (_, plate, _) = Plate(Parameters(), bed: new SupportSlats(SlatAxis.X, 2, 0.125, stabilityMargin: 0.25)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters())); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + var slice = Assert.Single(Motions(Assert.Single(result.ProposedOrder)), m => !m.Rapid && m.Layer == LayerType.Break); + Assert.Equal(4, slice.Curve.Start.Y, 9); + Assert.Equal(4, slice.Curve.End.Y, 9); + } + + [Fact] + public void Settings_RoundTripAndAreLeftOutWhenOff() + { + var on = OpenNest.IO.CuttingParametersSerializer.Serialize(Parameters()); + var off = OpenNest.IO.CuttingParametersSerializer.Serialize(Parameters(slice: false)); + + Assert.True(OpenNest.IO.CuttingParametersSerializer.Deserialize(on).SliceTippingSlugs); + Assert.False(OpenNest.IO.CuttingParametersSerializer.Deserialize(off).SliceTippingSlugs); + Assert.DoesNotContain("sliceTippingSlugs", off); + Assert.True(OwnedCuttingParameters.Copy(Parameters()).SliceTippingSlugs); + } + + [Fact] + public void ReleasedContours_CrossingCutsInsideAHoleCutOnlyItsSlug() + { + // Crossing break cuts in the slot, then the slot, then the outside cut in two open pieces, + // so the open-cut proof runs. + var findings = new ReleasedContourState().Check( + ExecutionMotionReader.Read(TwoPieceFrameWithBreaks(inHole: true), Vector.Zero, null, default), Vector.Zero, 1); + + Assert.DoesNotContain(findings, f => f.Kind == PostVerificationKind.Incomplete); + } + + [Fact] + public void ReleasedContours_CrossingBreakCutsInMaterialStillNeedReview() + { + var findings = new ReleasedContourState().Check( + ExecutionMotionReader.Read(TwoPieceFrameWithBreaks(inHole: false), Vector.Zero, null, default), Vector.Zero, 1); + + Assert.Contains(findings, f => f.Kind == PostVerificationKind.Incomplete + && f.Message.Contains("Multiple interrupted/open")); + } + + // A 14 inch square cut in two open pieces with a slot at (3, 3.5)-(11, 4.5), and a row and two + // columns of break cuts crossing either inside the slot or in the material above it. + private static Program TwoPieceFrameWithBreaks(bool inHole) + { + var y = inHole ? 4 : 8; + var program = new Program(); + program.MoveTo(3.1, y); + program.Codes.Add(new LinearMove(10.9, y) { Layer = LayerType.Break }); + foreach (var x in new[] { 4.0, 6.0 }) + { + program.MoveTo(x, y - 0.4); + program.Codes.Add(new LinearMove(x, y + 0.4) { Layer = LayerType.Break }); + } + program.MoveTo(3, 3.5); + program.LineTo(11, 3.5); + program.LineTo(11, 4.5); + program.LineTo(3, 4.5); + program.LineTo(3, 3.5); + program.MoveTo(0.2, 0); + program.LineTo(14, 0); + program.LineTo(14, 14); + program.LineTo(7.2, 14); + program.MoveTo(6.8, 14); + program.LineTo(0, 14); + program.LineTo(0, 0.2); + return program; + } +} diff --git a/OpenNest/Controls/CuttingPanel.cs b/OpenNest/Controls/CuttingPanel.cs index f823a784..12898003 100644 --- a/OpenNest/Controls/CuttingPanel.cs +++ b/OpenNest/Controls/CuttingPanel.cs @@ -31,6 +31,7 @@ namespace OpenNest.Controls private readonly NumericUpDown nudTabSizeMin; private readonly NumericUpDown nudTabSizeMax; private readonly NumericUpDown nudTabHeldMax; + private readonly CheckBox chkSliceSlugs; private readonly NumericUpDown nudPierceClearance; private readonly NumericUpDown nudPierceDiameter; @@ -267,6 +268,21 @@ namespace OpenNest.Controls nudTabSizeMax.ValueChanged += (s, e) => OnParametersChanged(); nudTabHeldMax.ValueChanged += (s, e) => OnParametersChanged(); + // Cutouts: slugs that would tip are cut in pieces that drop between the supports. + AddSection("Cutouts"); + + chkSliceSlugs = new CheckBox + { + Text = "Slice slugs the support grid can't hold level", + AutoSize = true, + Margin = new Padding(0, 3, 0, 3), + }; + toolTip.SetToolTip(chkSliceSlugs, + "Plan Cutting first cuts each cutout slug that would tip along the support grid's lines, " + + "stopping a kerf short of the cutout, so its pieces drop between the supports."); + layout.Controls.Add(chkSliceSlugs); + chkSliceSlugs.CheckedChanged += (s, e) => OnParametersChanged(); + // Pierce AddSection("Pierce"); @@ -356,6 +372,7 @@ namespace OpenNest.Controls parameters.TabSizeMinWidth = (double)nudTabSizeMin.Value; parameters.TabSizeMaxWidth = (double)nudTabSizeMax.Value; parameters.TabHeldMaxLength = (double)nudTabHeldMax.Value; + parameters.SliceTippingSlugs = chkSliceSlugs.Checked; return parameters; } @@ -382,6 +399,7 @@ namespace OpenNest.Controls nudTabHeldMax.Value = Clamp(nudTabHeldMax, p.TabHeldMaxLength); tabOptions.Enabled = chkTabsEnabled.Checked; UpdateSupportTabOptions(); + chkSliceSlugs.Checked = p.SliceTippingSlugs; nudPierceClearance.Value = Clamp(nudPierceClearance, p.PierceClearance); nudPierceDiameter.Value = Clamp(nudPierceDiameter, p.PierceDiameter); chkRoundLeadInAngles.Checked = p.RoundLeadInAngles; diff --git a/docs/breakoffs.md b/docs/breakoffs.md index 050fb4c4..9ce497de 100644 --- a/docs/breakoffs.md +++ b/docs/breakoffs.md @@ -50,6 +50,9 @@ every full or prefix emission, never in single-contour probes) and the manual le scrap direction (`BreakCutEntry`), because the scrap narrows to a wedge near the outline. Entering at the far end cuts it in reverse. +Plan Cutting also cuts [slug slices](support-tabs.md#slug-slices) on the `Break` layer: open +cuts inside a cutout's slug, pierced on themselves with a straight lead-in along the cut. + Checks treat them as intentional open cuts retained by tabs: `ReleasedContourState` never makes them rapid obstacles or "open fragment" warnings, but reports a missing lead-in like any contour. `LeadPathValidator` lets a lead into a break cut run diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 57bd000e..fef42d5e 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -153,6 +153,11 @@ the final replay enforce them: contour, entered from the scrap with the internal lead-in. Their leads may run through the part's own material but not touch its boundaries; they are never rapid obstacles or unexplained open fragments. +- [Slug slices](support-tabs.md#slug-slices) (`CuttingParameters.SliceTippingSlugs`) are break + cuts capture adds inside each cutout whose slug the plate's support bed cannot hold level, + along the bed's contact lines and a kerf short of the cutout. Every full and prefix emission + cuts them right after the drawing's break cuts, pierced on themselves; the open-cut proof leaves + out those wholly inside one of the part's closed holes. - A drawing's tabbed cutouts (closed holes with `LayerType.Tab` spans, see [tabbed cutouts](tabbed-cutouts.md)) are material but not contour choices. Every full and prefix emission cuts their pieces after the break cuts and before any contour, diff --git a/docs/machine-cutting-defaults.md b/docs/machine-cutting-defaults.md index f187954b..fdf5cd7c 100644 --- a/docs/machine-cutting-defaults.md +++ b/docs/machine-cutting-defaults.md @@ -27,7 +27,8 @@ save them on close; thicknesses matching none stay blank. Every setting in Plan Cutting's Cutting Parameters dialog and the Place Lead-in panel is kept on the nest's machine thickness: leads on the Outside Cuts and Inside Cuts tabs, lead-in angle -rounding, tabs and pierce on the Tabs & Pierce tab (`ThicknessConfig.Cutting`). So 0.25 in can +rounding, tabs, [slug slicing](support-tabs.md#slug-slices) and pierce on the Tabs & Pierce tab +(`ThicknessConfig.Cutting`). So 0.25 in can tab parts while 0.5 in tabs none, and in Database mode every workstation gets the same settings. The dialog names what it edits above its fields ("Saved for Fiber 1 ยท Mild Steel A36 0.25 in") or why it edits this PC's settings instead (no machine, no material or thickness, or diff --git a/docs/support-tabs.md b/docs/support-tabs.md index 97d4cee7..dc4e38b9 100644 --- a/docs/support-tabs.md +++ b/docs/support-tabs.md @@ -190,6 +190,48 @@ advisory note instead (`SupportTabCapture.Notes`; never for a pin): a Ready plan status and lists it in Plan Cutting's warnings, and the console and MCP summaries print it under the plate. +## Slug slices + +A cutout's slug drops onto the bed too, and one the bed cannot hold level can tip up into the +head's path. Cutting Settings > Cutouts > "Slice slugs the support grid can't hold level" sets +`CuttingParameters.SliceTippingSlugs` (off by default, kept per machine thickness like every +cutting setting, and written to settings and nest files only when on). With it on and a support +bed, Plan Cutting cuts each such slug into pieces that hold no contact before it cuts the cutout, +so the pieces drop between the supports (or lie flat across them) instead of tipping. + +- Which slugs: `SlugSlicer.Tips` analyzes the cutout's inside as material on the bed (as above, + at the part's placed position). A slug the bed holds level is not sliced, and nor is one that + rests on no contact, which falls through whole. +- Where: along every line the bed's contacts lie on (`SupportBed.ContactLines`): the grid's rows + and columns through its points, a slat bed's slat center lines. Every contact under the slug is + then under a slice's kerf, so no piece rests on one: a grid's pieces are its cells, a slat bed's + are strips between slats. +- How far: each slice stops `CuttingParameters.Kerf` short of the cutout (measured from the + cutout chorded at 0.001, eroded with round corners), so the pieces stay attached to the part + until the cutout itself is cut, its kerf meets the slices' ends and they all drop together. No + kerf, no slices. A slice no longer than the kerf is left out. +- Which cutouts: one that holds another of the part's cuts (a contour, break cut or tabbed + cutout), or whose box holds another part's box (a part nested in it), is never sliced, since a + slice would cut what is inside. Tabbed cutouts keep their slugs, so they are never sliced. +- Cutting: the slices are break cuts (`LayerType.Break`) inside the slug: never material, never + rapid obstacles, cut by every post that cuts break cuts. `PreparedContours.WithSlugSlices` keeps + them in cutting order, and every full or prefix emission cuts them right after the drawing's + own break cuts and before any contour, so every slug is sliced before its cutout. The grid's + rows come first, then its columns, each set from its end nearer the last slice, every slice + started at its end nearer where the one before ended. Each is pierced on itself, the cutouts' + lead-in reach (but at least the pierce clearance, and at most halfway) along it, and led + straight back to its start; it has no lead-out. That pierce must keep the larger of the + effective pierce clearance and the kerf from the cutout, from one end or the other; a slice + whose pierce fits at neither is left out. +- Checks: inner grid cells are cut free as soon as their four slices are cut. The released-contour + check's open-cut proof therefore leaves out a straight break cut, leads included, that lies + wholly inside one of the part's closed holes (any closed cut but its largest) and touches none + of it: what it encloses is that hole's slug. Break cuts in material still take part in the proof. + +The hole's own lead-in is chosen as usual and may pierce in a slice's kerf or a piece already +dropped. Slices are planned for the nominal bed: a sheet registered off by up to its tolerance can +leave a contact just inside a piece's edge. + ## Lead-ins placed by hand Plate > Place Lead-in cuts where it is clicked. With tabs on, `SupportTabSettings.ForManualLeadIn`