diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs index 52727d9f..43f5c4cc 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs @@ -42,6 +42,8 @@ internal static class OwnedCuttingParameters : CopyTab(source.TabConfig), TabsEnabled = source.TabsEnabled, SupportTabs = source.SupportTabs, + NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth), + NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth), Sequencing = new SequenceParameters { Method = sequence.Method, diff --git a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs index 989b64af..a9c478f2 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs @@ -90,6 +90,8 @@ internal static class StateFingerprint TabSettings(p.TabConfig); Flag(p.TabsEnabled); Flag(p.SupportTabs); + Number(p.NoBedTabMinWidth); + Number(p.NoBedTabMaxWidth); Sequence(p.Sequencing); Assignment(p.Assignment); text.Append('}'); diff --git a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs index 089dbb16..2519b787 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs @@ -51,6 +51,32 @@ namespace OpenNest.CNC.CuttingStrategy /// public bool SupportTabs { get; set; } + /// + /// With on a plate that has no support bed, the narrowest part + /// width that is tabbed instead (see ). + /// + public double NoBedTabMinWidth { get; set; } + + /// + /// With on a plate that has no support bed, the widest part + /// width that is tabbed instead; 0 turns the rule off, so nothing is tabbed without a bed. + /// + public double NoBedTabMaxWidth { get; set; } + + /// Whether to is a usable range. + public bool HasNoBedTabRange() => + NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth) + && NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth; + + /// + /// Whether a part wide (the short side of its minimum-area + /// bounding rectangle) is tabbed when its plate has no support bed. + /// + public bool TabsWithoutBedAt(double width) => + HasNoBedTabRange() + && width >= NoBedTabMinWidth - Math.Tolerance.Epsilon + && width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon; + public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); } diff --git a/OpenNest.Core/Diagnostics/PartSupportReview.cs b/OpenNest.Core/Diagnostics/PartSupportReview.cs index 9eb53d7e..7fbce4a2 100644 --- a/OpenNest.Core/Diagnostics/PartSupportReview.cs +++ b/OpenNest.Core/Diagnostics/PartSupportReview.cs @@ -10,10 +10,11 @@ using OpenNest.Support; namespace OpenNest.Diagnostics; /// -/// Pre-post review of part support (docs/support-tabs.md). On a plate with a support bed, every -/// part whose cutting parameters ask for support tabs and that the bed cannot hold level must be +/// Pre-post review of part support (docs/support-tabs.md). Every part whose cutting parameters +/// ask for support tabs and that needs one (: its plate's +/// support bed cannot hold it level or, without a bed, its width is in the no-bed range) must be /// cut with a tab that keeps it level, however its program was made (planned, placed by hand or -/// locked). The tab is the gap the outside cut leaves between its end and its start. Reports +/// locked); without a bed any tab will do. The tab is the gap the outside cut leaves between its end and its start. Reports /// only; a part whose material or program cannot be read is reported as not checked. /// internal static class PartSupportReview @@ -21,19 +22,21 @@ internal static class PartSupportReview internal static void Add(Plate plate, int plateNumber, List findings, CancellationToken token) { - if (plate.SupportBed is not { } bed) - return; + var bed = plate.SupportBed; for (var index = 0; index < plate.Parts.Count; index++) { token.ThrowIfCancellationRequested(); var part = plate.Parts[index]; - if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff - || (part.CuttingParameters ?? plate.CuttingParameters)?.SupportTabs != true) + var parameters = part?.CuttingParameters ?? plate.CuttingParameters; + if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff || parameters?.SupportTabs != true + || (bed == null && !parameters.HasNoBedTabRange())) continue; var partNumber = index + 1; try { - var support = SupportAnalyzer.Analyze(bed, plate.Quadrant, part); + var support = SupportTabSettings.Analyze(parameters, bed, plate.Quadrant, SupportMaterial.FromPart(part)); + if (support == null) + continue; if (!support.IsComplete) { findings.Add(new(PostVerificationKind.Incomplete, plateNumber, partNumber, null, @@ -100,6 +103,14 @@ internal static class PartSupportReview private static string Message(PartSupport support, Vector? tab) { + // Without a bed any tab steadies the part, so the only failure is no tab at all. + if (!support.HasBed) + return FormattableString.Invariant( + $"The part is {support.Width:0.####} wide, in the width range tabbed without a support bed, ") + + "and it has no tab; it may tip once cut free." + + (support.BestTabPoint is { } suggested + ? FormattableString.Invariant($" Tab it near ({suggested.X:0.####}, {suggested.Y:0.####}).") + : ""); var held = support.Margin == double.NegativeInfinity ? "rests on no support contact" : support.Margin < 0 diff --git a/OpenNest.Core/Support/SupportAnalyzer.cs b/OpenNest.Core/Support/SupportAnalyzer.cs index 5670116d..358bf2bc 100644 --- a/OpenNest.Core/Support/SupportAnalyzer.cs +++ b/OpenNest.Core/Support/SupportAnalyzer.cs @@ -58,20 +58,70 @@ namespace OpenNest.Support var contacts = bed.Contacts(material, quadrant, Vector.Zero); var corners = InsideCorners(material.Outline); - var samples = OutlineSamples(material.Outline) + var best = BestTabPoint(material.Outline, center, corners, insideCornerClearance, + point => MarginWith(center, hulls, point), out var bestMargin); + + return new PartSupport(center, contacts, margin, bed.StabilityMargin, hulls, best, bestMargin, + corners, insideCornerClearance); + } + + /// + /// A part on a plate with no support bed, tabbed for its instead + /// (). With no contacts + /// to judge by, a tab anywhere clear of inside corners is accepted; the best tab point is + /// the clear outline point nearest the line through the center of gravity across the + /// outline's long side, then the one farthest from an inside corner. + /// + /// + /// How far a tab point keeps from an inside corner; 0 keeps it off the corner only. + /// + public static PartSupport AnalyzeWithoutBed(SupportMaterial material, double insideCornerClearance = 0) + { + ArgumentNullException.ThrowIfNull(material); + if (!(insideCornerClearance >= 0) || double.IsInfinity(insideCornerClearance)) + throw new ArgumentOutOfRangeException(nameof(insideCornerClearance)); + if (!material.IsComplete) + return new PartSupport(material.Reason); + if (!material.TryGetCenterOfGravity(out var center, out _)) + return new PartSupport("The part encloses no area."); + + var corners = InsideCorners(material.Outline); + var best = BestTabPoint(material.Outline, center, corners, insideCornerClearance, + _ => double.PositiveInfinity, out _); + return new PartSupport(center, best, corners, insideCornerClearance, Width(material)); + } + + /// + /// The part's width: the short side of its outline's minimum-area bounding rectangle, or + /// NaN when its material could not be read. + /// + public static double Width(SupportMaterial material) + { + ArgumentNullException.ThrowIfNull(material); + if (!material.IsComplete || material.Outline.Count < 3) + return double.NaN; + var box = BoundingRectangle(material.Outline); + return System.Math.Min(box.Width, box.Height); + } + + // The outline point a tab steadies the part most from, by `score`, clear of every inside + // corner where the outline allows it. A tab cannot lift the margin past the contacts' own + // hull edges, so a whole stretch of outline often ties; of those, the one nearest the line + // through the center across the outline's long side, then the one farthest from an inside + // corner, or the first. + private static Vector? BestTabPoint(IReadOnlyList outline, Vector center, IReadOnlyList corners, + double insideCornerClearance, Func score, out double bestMargin) + { + var samples = OutlineSamples(outline) .Select(point => (Point: point, Clear: Clearance(corners, point))) .ToList(); - // Clear of every inside corner where the outline allows it. var allowed = samples.Where(s => IsClear(s.Clear, insideCornerClearance)).ToList(); if (allowed.Count > 0) samples = allowed; - var scored = samples.Select(s => (s.Point, s.Clear, Margin: MarginWith(center, hulls, s.Point))).ToList(); + var scored = samples.Select(s => (s.Point, s.Clear, Margin: score(s.Point))).ToList(); - // A tab cannot lift the margin past the contacts' own hull edges, so a whole stretch - // of outline often ties; of those, the one nearest the line through the center across - // the outline's long side, then the one farthest from an inside corner, or the first. - var bestMargin = scored.Count == 0 ? double.NegativeInfinity : scored.Max(s => s.Margin); - var along = scored.Count == 0 ? Vector.Zero : LongSide(material.Outline); + bestMargin = scored.Count == 0 ? double.NegativeInfinity : scored.Max(s => s.Margin); + var along = scored.Count == 0 ? Vector.Zero : LongSide(outline); Vector? best = null; var bestOffset = double.PositiveInfinity; var bestClear = double.NegativeInfinity; @@ -88,20 +138,21 @@ namespace OpenNest.Support bestClear = sample.Clear; } } - - return new PartSupport(center, contacts, margin, bed.StabilityMargin, hulls, best, bestMargin, - corners, insideCornerClearance); + return best; } /// The unit direction of the longer side of the outline's minimum-area bounding rectangle. private static Vector LongSide(IReadOnlyList outline) { - // The full angle range checks every hull edge. - var box = RotatingCalipers.MinimumBoundingRectangle(ConvexHull.Compute(outline.ToList()), 0, Math.Angle.TwoPI); + var box = BoundingRectangle(outline); var angle = box.Width >= box.Height ? box.Angle : box.Angle + Math.Angle.HalfPI; return new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); } + // The full angle range checks every hull edge. + private static BoundingRectangleResult BoundingRectangle(IReadOnlyList outline) => + RotatingCalipers.MinimumBoundingRectangle(ConvexHull.Compute(outline.ToList()), 0, Math.Angle.TwoPI); + // A point `clear` from the nearest inside corner keeps `clearance` from it; with no // clearance it must still not be the corner. internal static bool IsClear(double clear, double clearance) => @@ -324,6 +375,31 @@ namespace OpenNest.Support InsideCornerClearance = insideCornerClearance; } + // A part on a plate with no bed, tabbed for its width: it rests on nothing modeled, and a + // tab anywhere clear of inside corners is as good as any other. + internal PartSupport(Vector centerOfGravity, Vector? bestTabPoint, IReadOnlyList insideCorners, + double insideCornerClearance, double width) + { + HasBed = false; + CenterOfGravity = centerOfGravity; + Contacts = Array.Empty(); + BestTabPoint = bestTabPoint; + BestTabMargin = double.PositiveInfinity; + InsideCorners = insideCorners; + InsideCornerClearance = insideCornerClearance; + Width = width; + } + + /// + /// False for a part on a plate with no support bed, tabbed for its + /// (): it has no contacts, never counts as + /// supported, and any tab clear of inside corners steadies it. + /// + public bool HasBed { get; } = true; + + /// The part's width when analyzed without a bed (see ); NaN otherwise. + public double Width { get; } = double.NaN; + /// True when the part was analyzed; otherwise says why not. public bool IsComplete => Reason == null; @@ -363,7 +439,9 @@ namespace OpenNest.Support /// The worst margin with a tab holding the part at . public double MarginWith(Vector tab) => - IsComplete ? SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab) : double.NegativeInfinity; + !IsComplete ? double.NegativeInfinity + : !HasBed ? double.PositiveInfinity + : SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab); /// True when a tab at holds the part level by the required margin. public bool IsSteadiedByTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab)); diff --git a/OpenNest.Core/Support/SupportTabSettings.cs b/OpenNest.Core/Support/SupportTabSettings.cs index 2de4ff57..d3e57aca 100644 --- a/OpenNest.Core/Support/SupportTabSettings.cs +++ b/OpenNest.Core/Support/SupportTabSettings.cs @@ -5,26 +5,62 @@ using OpenNest.CNC.CuttingStrategy; namespace OpenNest.Support { /// - /// Cutting settings for a lead-in placed by hand under - /// : the tool cuts where it is clicked, so the - /// part is tabbed there when its plate's support bed cannot hold it level. Plan Cutting - /// chooses the tab point itself (docs/support-tabs.md). + /// Which parts tabs (docs/support-tabs.md): the + /// parts the plate's support bed cannot hold level or, on a plate with no bed, the parts + /// whose width is in the settings' no-bed range. Also the cutting settings for a lead-in + /// placed by hand: the tool cuts where it is clicked, so the part is tabbed there. Plan + /// Cutting chooses the tab point itself. /// public static class SupportTabSettings { + /// + /// The analysis that tabs a part, or null when support tabs are off or the part is + /// outside the rule: with a bed, the bed's analysis; without one, a no-bed analysis + /// () of a part whose width is in + /// 's range. The part needs a tab when + /// the result is complete and not supported; an incomplete result could not be read. + /// + /// + /// How far a tab point keeps from an inside corner; 0 keeps it off the corner only. + /// + public static PartSupport Analyze(CuttingParameters parameters, SupportBed bed, int quadrant, + SupportMaterial material, double insideCornerClearance = 0) + { + ArgumentNullException.ThrowIfNull(material); + if (parameters?.SupportTabs != true) + return null; + if (bed != null) + return SupportAnalyzer.Analyze(bed, quadrant, material, insideCornerClearance); + if (!parameters.HasNoBedTabRange()) + return null; + if (!material.IsComplete) + return SupportAnalyzer.AnalyzeWithoutBed(material, insideCornerClearance); + return parameters.TabsWithoutBedAt(SupportAnalyzer.Width(material)) + ? SupportAnalyzer.AnalyzeWithoutBed(material, insideCornerClearance) + : null; + } + + /// + /// Whether support tabs are on but can tab nothing: the plate has no support bed and the + /// settings set no no-bed width range. + /// + public static bool SkipsWithoutBed(CuttingParameters parameters, SupportBed bed) => + parameters?.SupportTabs == true && bed == null && !parameters.HasNoBedTabRange(); + /// /// itself, or a copy with tabs enabled when support tabs are - /// on, the plate has a support bed, a tab size is set and the bed cannot hold - /// level. The caller's settings are never changed. + /// on, a tab size is set and needs a tab (see ). + /// The caller's settings are never changed. /// public static CuttingParameters ForManualLeadIn(CuttingParameters parameters, Plate plate, Part part) { ArgumentNullException.ThrowIfNull(parameters); if (!parameters.SupportTabs || parameters.TabsEnabled || parameters.TabConfig == null - || plate?.SupportBed == null || part == null) + || plate == null || part == null + || (plate.SupportBed == null && !parameters.HasNoBedTabRange())) return parameters; - var support = SupportAnalyzer.Analyze(plate.SupportBed, plate.Quadrant, part); - if (!support.IsComplete || support.IsSupported) + var support = Analyze(parameters, plate.SupportBed, plate.Quadrant, SupportMaterial.FromPart(part)); + if (support is not { IsComplete: true, IsSupported: false }) return parameters; var tabbed = OwnedCuttingParameters.Copy(parameters); tabbed.TabsEnabled = true; diff --git a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs index 31639bf2..d289891c 100644 --- a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs +++ b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs @@ -86,6 +86,7 @@ internal static class BestEffortCuttingPlan order.Add(proposal); } token.ThrowIfCancellationRequested(); + findings.AddRange(SupportTabCapture.Skipped(snapshot)); return new(CuttingPlanStatus.BestEffort, order, findings, rapidDistance: distance) { Snapshot = snapshot }; } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index aeba94b2..5f405974 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -107,12 +107,19 @@ public sealed class CuttingPlanSnapshot internal CuttingPlanStatus? Failure { get; } internal IReadOnlyList Findings { get; } + /// + /// Support tabs are on, but the request had no support bed and its settings set no no-bed + /// width range, so capture tabbed no part; the plan carries a note saying so. + /// + internal bool SupportTabsSkipped { get; init; } + // A worker-only search variant over the same owned capture. Never recapture the live plate: // both proposals retain the identical freshness record and confirmed settings. internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) => new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration, PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters, - Dependencies, BestEffort); + Dependencies, BestEffort) + { SupportTabsSkipped = SupportTabsSkipped }; } public sealed class FixedProgramPlacement diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 1ba5dc3e..95267e1a 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -6,6 +6,7 @@ using OpenNest.CNC; using OpenNest.CNC.CuttingPlanning; using OpenNest.Diagnostics; using OpenNest.Geometry; +using OpenNest.Support; namespace OpenNest.Engine.CuttingPlanning; @@ -173,7 +174,10 @@ public static class CuttingPlanService expansionObserver: request.ExpansionObserver, plateState: plateState, ownedParameters: request.ConfirmedParameters == null ? null : OwnedCuttingParameters.Copy(request.ConfirmedParameters), - dependencies: dependencies, findings: warnings, bestEffort: bestEffort); + dependencies: dependencies, findings: warnings, bestEffort: bestEffort) + { + SupportTabsSkipped = SupportTabSettings.SkipsWithoutBed(request.ConfirmedParameters, request.SupportBed), + }; } catch (OperationCanceledException) { @@ -460,7 +464,7 @@ public static class CuttingPlanService } token.ThrowIfCancellationRequested(); // Support tabs that cannot hold their part level are noted, never refused. - var notes = order.SelectMany(SupportTabCapture.Notes).ToList(); + var notes = order.SelectMany(SupportTabCapture.Notes).Concat(SupportTabCapture.Skipped(snapshot)).ToList(); if (safestRoute && findings.Count != 0 && findings.All(f => f.Kind != PostVerificationKind.Incomplete)) return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(), expansions, distance) diff --git a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs index 1ab1a952..73e0908e 100644 --- a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs +++ b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs @@ -11,9 +11,11 @@ using OpenNest.Support; namespace OpenNest.Engine.CuttingPlanning; /// -/// Capture-time support tabs for one regenerated part (docs/support-tabs.md): with a support -/// bed and on, a part the bed -/// cannot hold level has its perimeter tabbed where the tab steadies it; a pinned perimeter +/// Capture-time support tabs for one regenerated part (docs/support-tabs.md): with +/// on, a part the support bed +/// cannot hold level, or on a plate with no bed a part in the no-bed width range +/// (), has its perimeter tabbed where the tab steadies +/// it; a pinned perimeter /// keeps its pin (the drawing wins) and only carries the analysis for replay to review. A /// supported part, a missing tab configuration or material the analyzer cannot read leave the /// preparation as it was. @@ -25,17 +27,17 @@ internal static class SupportTabCapture { var parameters = request.ConfirmedParameters; // A display perimeter is a supplied blank: it is never cut free, so it needs no tab. - if (prepared == null || !prepared.HasCutPerimeter || request.SupportBed == null || parameters?.SupportTabs != true - || parameters.TabConfig == null) + if (prepared == null || !prepared.HasCutPerimeter || parameters?.SupportTabs != true + || parameters.TabConfig == null || (request.SupportBed == null && !parameters.HasNoBedTabRange())) return prepared; token.ThrowIfCancellationRequested(); // The clean program is the drawing's own, rotated as placed: its material at the // part's location is the material that drops onto the bed. The tab keeps clear of // inside corners by twice its size. - var support = SupportAnalyzer.Analyze(request.SupportBed, request.SupportQuadrant, + var support = SupportTabSettings.Analyze(parameters, request.SupportBed, request.SupportQuadrant, SupportMaterial.FromProgram(ownedClean, location), SupportAnalyzer.InsideCornerClearance(parameters.TabConfig.Size)); - if (!support.IsComplete || support.IsSupported || support.BestTabPoint == null) + if (support is not { IsComplete: true, IsSupported: false } || support.BestTabPoint == null) return prepared; return prepared.WithSupportTab(support, location, parameters.TabConfig); } @@ -77,7 +79,9 @@ internal static class SupportTabCapture if (prepared.PerimeterTabbed && support.IsSteadiedByTabAt(tab)) return null; var part = source.SourceOrdinal + 1; - var held = support.Margin == double.NegativeInfinity + var held = !support.HasBed + ? FormattableString.Invariant($"is {support.Width:0.####} wide, in the width range tabbed without a support bed,") + : support.Margin == double.NegativeInfinity ? "rests on no support contact" : support.Margin < 0 ? FormattableString.Invariant($"has its center of gravity {-support.Margin:0.####} outside its support contacts") @@ -89,8 +93,20 @@ internal static class SupportTabCapture : support.TabCanSupport ? "no lead-in fits at a point where its tab would steady the part" : "no single tab can steady it"; + var needs = support.HasBed ? FormattableString.Invariant($" (needs {support.RequiredMargin:0.####})") : ""; return new CuttingPlanFinding(source.SourceOrdinal, source.SourcePart, null, null, - PostVerificationKind.PartSupport, FormattableString.Invariant( - $"Part {part} {held} (needs {support.RequiredMargin:0.####}) and {why}; it may tip once cut free.")); + PostVerificationKind.PartSupport, $"Part {part} {held}{needs} and {why}; it may tip once cut free."); + } + + /// + /// One plan note when support tabs are on but the plate has no support bed and the settings + /// set no no-bed width range, so no part was tabbed. Advisory, like . + /// + internal static IEnumerable Skipped(CuttingPlanSnapshot snapshot) + { + if (snapshot.SupportTabsSkipped) + yield return new CuttingPlanFinding(null, null, null, null, null, + "Support tabs are on, but the plate has no support bed and no width range is set for " + + "plates without one, so no part was tabbed."); } } diff --git a/OpenNest.IO.Tests/SupportGridPersistenceTests.cs b/OpenNest.IO.Tests/SupportGridPersistenceTests.cs index cc02cf73..45932f76 100644 --- a/OpenNest.IO.Tests/SupportGridPersistenceTests.cs +++ b/OpenNest.IO.Tests/SupportGridPersistenceTests.cs @@ -141,4 +141,18 @@ public class SupportGridPersistenceTests Assert.True(restored.SupportTabs); } + + [Fact] + public void NoBedTabRangeRoundTripsInCuttingParameters() + { + var parameters = new CuttingParameters { SupportTabs = true, NoBedTabMinWidth = 0.5, NoBedTabMaxWidth = 4 }; + + var restored = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters)); + var snapshot = CuttingParametersSerializer.FromDto(CuttingParametersSerializer.ToDto(parameters)); + + Assert.Equal(0.5, restored.NoBedTabMinWidth); + Assert.Equal(4, restored.NoBedTabMaxWidth); + Assert.Equal(4, snapshot.NoBedTabMaxWidth); + Assert.Equal(0, CuttingParametersSerializer.Deserialize("{}").NoBedTabMaxWidth); + } } diff --git a/OpenNest.IO/CuttingParametersDto.cs b/OpenNest.IO/CuttingParametersDto.cs index ff7d5704..bca1a671 100644 --- a/OpenNest.IO/CuttingParametersDto.cs +++ b/OpenNest.IO/CuttingParametersDto.cs @@ -33,6 +33,8 @@ public class CuttingParametersDto public bool RoundLeadInAngles { get; set; } public double LeadInAngleIncrement { get; set; } public bool SupportTabs { get; set; } + public double NoBedTabMinWidth { get; set; } + public double NoBedTabMaxWidth { 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 36f6e1ac..200a7981 100644 --- a/OpenNest.IO/CuttingParametersSerializer.cs +++ b/OpenNest.IO/CuttingParametersSerializer.cs @@ -71,6 +71,8 @@ public static class CuttingParametersSerializer RoundLeadInAngles = parameters.RoundLeadInAngles, LeadInAngleIncrement = parameters.LeadInAngleIncrement, SupportTabs = parameters.SupportTabs, + NoBedTabMinWidth = parameters.NoBedTabMinWidth, + NoBedTabMaxWidth = parameters.NoBedTabMaxWidth, Sequencing = ToSequenceDto(parameters.Sequencing), Assignment = ToAssignmentDto(parameters.Assignment), }; @@ -104,6 +106,8 @@ public static class CuttingParametersSerializer RoundLeadInAngles = dto.RoundLeadInAngles, LeadInAngleIncrement = dto.LeadInAngleIncrement, SupportTabs = dto.SupportTabs, + NoBedTabMinWidth = dto.NoBedTabMinWidth, + NoBedTabMaxWidth = dto.NoBedTabMaxWidth, Sequencing = FromSequenceDto(dto.Sequencing), Assignment = FromAssignmentDto(dto.Assignment), }; diff --git a/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs index 3f4d6e7e..dcb48fca 100644 --- a/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs +++ b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs @@ -41,6 +41,8 @@ public class OwnedCuttingParameterTests TabConfig = tab, TabsEnabled = true, SupportTabs = true, + NoBedTabMinWidth = 0.5, + NoBedTabMaxWidth = 4, RoundLeadInAngles = true, LeadInAngleIncrement = 30, Sequencing = new SequenceParameters diff --git a/OpenNest.Tests/Support/NoBedSupportTabTests.cs b/OpenNest.Tests/Support/NoBedSupportTabTests.cs new file mode 100644 index 00000000..bcb9c7b6 --- /dev/null +++ b/OpenNest.Tests/Support/NoBedSupportTabTests.cs @@ -0,0 +1,169 @@ +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; + +/// +/// Support tabs on a plate with no support bed: the parts whose width is in the settings' no-bed +/// range are tabbed instead, any tab clear of inside corners counts, and with no range Plan +/// Cutting says that nothing was tabbed. +/// +public class NoBedSupportTabTests +{ + private const double TabSize = 0.2; + + private static CuttingParameters Parameters(double min = 0.5, double max = 2) + { + var parameters = ExplicitContourTests.Parameters(); + parameters.SupportTabs = true; + parameters.TabConfig = new NormalTab { Size = TabSize }; + parameters.NoBedTabMinWidth = min; + parameters.NoBedTabMaxWidth = max; + return parameters; + } + + // A 10 x 1 strip at (2, 2) on a plate with no support bed. + private static (Nest Nest, Plate Plate, Part Part) Strip(CuttingParameters parameters) + { + var nest = new Nest("no bed"); + var plate = new Plate(20, 20) { CuttingParameters = parameters }; + nest.Plates.Add(plate); + var part = new Part(new Drawing("strip", LeadPathValidationTests.Rectangle(0, 0, 10, 1)), new Vector(2, 2)); + plate.Parts.Add(part); + return (nest, plate, part); + } + + private static double TabGap(FixedProgramPlacement proposal) + { + var motions = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, null, default).Motions; + var entry = motions.Last(m => m.Layer == LayerType.Leadin).End; + var cutEnd = motions.Last(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).End; + return entry.DistanceTo(cutEnd); + } + + [Theory] + [InlineData(0, 0, false)] // no range + [InlineData(2, 1, false)] // minimum above maximum + [InlineData(0.5, 2, true)] + [InlineData(1, 1, true)] // the bounds are inclusive + public void TheRangeTabsWidthsInsideIt(double min, double max, bool tabsAWidthOfOne) + { + var parameters = new CuttingParameters { NoBedTabMinWidth = min, NoBedTabMaxWidth = max }; + + Assert.Equal(tabsAWidthOfOne, parameters.TabsWithoutBedAt(1)); + Assert.False(parameters.TabsWithoutBedAt(3)); + } + + [Fact] + public void WithoutABed_TheBestTabCrossesTheCenterAndAnyClearTabIsAccepted() + { + var (_, _, part) = Strip(Parameters()); + var material = SupportMaterial.FromPart(part); + + var support = SupportAnalyzer.AnalyzeWithoutBed(material); + + Assert.True(support.IsComplete); + Assert.False(support.HasBed); + Assert.False(support.IsSupported); + Assert.Equal(1, support.Width, 6); + Assert.Equal(1, SupportAnalyzer.Width(material), 6); + // The nearest of the outline samples to the center line at x = 7. + Assert.InRange(support.BestTabPoint!.Value.X, 6.9, 7.1); + Assert.True(support.AcceptsTabAt(new Vector(2, 2.5))); + Assert.True(support.IsSteadiedByTabAt(new Vector(12, 2.5))); + } + + [Fact] + public void Analyze_UsesTheBedWhenThereIsOneAndTheRangeOnlyWithout() + { + var (_, _, part) = Strip(Parameters()); + var material = SupportMaterial.FromPart(part); + var bed = new SupportGrid(2, 4, 0, 2.25, stabilityMargin: 0.1); + + Assert.True(SupportTabSettings.Analyze(Parameters(), bed, 1, material).HasBed); + Assert.False(SupportTabSettings.Analyze(Parameters(), null, 1, material).HasBed); + Assert.Null(SupportTabSettings.Analyze(Parameters(2, 5), null, 1, material)); + Assert.Null(SupportTabSettings.Analyze(Parameters(0, 0), null, 1, material)); + var off = Parameters(); + off.SupportTabs = false; + Assert.Null(SupportTabSettings.Analyze(off, null, 1, material)); + } + + [Fact] + public void PlanCutting_TabsAPartInTheRange() + { + var (_, plate, _) = Strip(Parameters()); + + 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.Equal(TabSize, TabGap(Assert.Single(result.ProposedOrder)), 6); + Assert.Empty(result.Findings); + } + + [Fact] + public void PlanCutting_LeavesAPartOutsideTheRangeUntabbed() + { + var (_, plate, _) = Strip(Parameters(2, 5)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters(2, 5))); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Equal(0, TabGap(Assert.Single(result.ProposedOrder)), 6); + Assert.Empty(result.Findings); + } + + [Fact] + public void PlanCutting_NotesThatNothingWasTabbedWithoutABedOrARange() + { + var (_, plate, _) = Strip(Parameters(0, 0)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters(0, 0))); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Equal(0, TabGap(Assert.Single(result.ProposedOrder)), 6); + var note = Assert.Single(result.Findings); + Assert.Null(note.Kind); + Assert.Contains("no support bed", note.Message); + } + + [Fact] + public void ManualLeadIn_TabsAPartInTheRange() + { + var parameters = Parameters(); + var (_, plate, part) = Strip(parameters); + + Assert.True(SupportTabSettings.ForManualLeadIn(parameters, plate, part).TabsEnabled); + Assert.Same(parameters, SupportTabSettings.ForManualLeadIn(parameters, plate, new Part( + new Drawing("wide", LeadPathValidationTests.Rectangle(0, 0, 10, 5)), new Vector(2, 5)))); + } + + [Fact] + public void PrePost_WarnsAboutAnUntabbedPartInTheRange_AndAcceptsAnyTab() + { + var (untabbed, _, bare) = Strip(Parameters()); + bare.ApplySingleLeadIn(Parameters(), new Vector(0, 0.5), new Line(new Vector(0, 0), new Vector(0, 1)), + ContourType.External); + var (tabbed, _, held) = Strip(Parameters()); + var withTabs = Parameters(); + withTabs.TabsEnabled = true; + held.ApplySingleLeadIn(withTabs, new Vector(0, 0.5), new Line(new Vector(0, 0), new Vector(0, 1)), + ContourType.External); + + var finding = Assert.Single(PostVerificationAnalyzer.Analyze(untabbed).Findings, + f => f.Kind == PostVerificationKind.PartSupport); + Assert.Contains("without a support bed", finding.Message); + Assert.Contains("no tab", finding.Message); + Assert.DoesNotContain(PostVerificationAnalyzer.Analyze(tabbed).Findings, + f => f.Kind == PostVerificationKind.PartSupport); + } +} diff --git a/OpenNest/Controls/CuttingPanel.cs b/OpenNest/Controls/CuttingPanel.cs index 440edb0b..5fc31e37 100644 --- a/OpenNest/Controls/CuttingPanel.cs +++ b/OpenNest/Controls/CuttingPanel.cs @@ -34,6 +34,8 @@ namespace OpenNest.Controls private readonly NumericUpDown nudTabWidth; private readonly RadioButton rbTabAll; private readonly RadioButton rbSupportTab; + private readonly NumericUpDown nudNoBedTabMin; + private readonly NumericUpDown nudNoBedTabMax; private readonly NumericUpDown nudPierceClearance; private readonly NumericUpDown nudPierceDiameter; @@ -110,7 +112,7 @@ namespace OpenNest.Controls { HeaderText = "Tabs", Dock = DockStyle.Top, - ExpandedHeight = 106, + ExpandedHeight = 132, IsExpanded = false, }; @@ -155,17 +157,51 @@ namespace OpenNest.Controls }; tabsPanel.ContentPanel.Controls.Add(rbSupportTab); + // A plate with no support bed tabs the parts whose narrow side is in this range; + // a maximum of 0 tabs none. + tabsPanel.ContentPanel.Controls.Add( + new Label + { + Text = "No bed: tab widths", + Location = new Point(44, 76), + AutoSize = true, + } + ); + nudNoBedTabMin = CreateNumeric(160, 74, 0, 0.25); + nudNoBedTabMin.Size = new Size(70, 22); + nudNoBedTabMin.Enabled = false; + tabsPanel.ContentPanel.Controls.Add(nudNoBedTabMin); + tabsPanel.ContentPanel.Controls.Add( + new Label + { + Text = "to", + Location = new Point(234, 76), + AutoSize = true, + } + ); + nudNoBedTabMax = CreateNumeric(254, 74, 0, 0.25); + nudNoBedTabMax.Size = new Size(70, 22); + nudNoBedTabMax.Enabled = false; + tabsPanel.ContentPanel.Controls.Add(nudNoBedTabMax); + chkTabsEnabled.CheckedChanged += (s, e) => { var enabled = chkTabsEnabled.Checked; nudTabWidth.Enabled = enabled; rbTabAll.Enabled = enabled; rbSupportTab.Enabled = enabled; + UpdateNoBedRange(); OnParametersChanged(); }; // The two choices toggle together, so one handler sees every change. - rbTabAll.CheckedChanged += (s, e) => OnParametersChanged(); + rbTabAll.CheckedChanged += (s, e) => + { + UpdateNoBedRange(); + OnParametersChanged(); + }; + nudNoBedTabMin.ValueChanged += (s, e) => OnParametersChanged(); + nudNoBedTabMax.ValueChanged += (s, e) => OnParametersChanged(); // Pierce section var piercePanel = new CollapsiblePanel @@ -257,6 +293,8 @@ namespace OpenNest.Controls RoundLeadInAngles = chkRoundLeadInAngles.Checked, LeadInAngleIncrement = (double)nudLeadInAngleIncrement.Value, SupportTabs = chkTabsEnabled.Checked && rbSupportTab.Checked, + NoBedTabMinWidth = (double)nudNoBedTabMin.Value, + NoBedTabMaxWidth = (double)nudNoBedTabMax.Value, }; } @@ -274,6 +312,9 @@ namespace OpenNest.Controls rbTabAll.Checked = !p.SupportTabs; if (p.TabConfig != null) nudTabWidth.Value = (decimal)p.TabConfig.Size; + nudNoBedTabMin.Value = Clamp(nudNoBedTabMin, p.NoBedTabMinWidth); + nudNoBedTabMax.Value = Clamp(nudNoBedTabMax, p.NoBedTabMaxWidth); + UpdateNoBedRange(); nudPierceClearance.Value = (decimal)p.PierceClearance; nudPierceDiameter.Value = (decimal)p.PierceDiameter; chkRoundLeadInAngles.Checked = p.RoundLeadInAngles; @@ -283,6 +324,18 @@ namespace OpenNest.Controls suppressEvents = false; } + private void UpdateNoBedRange() + { + var enabled = chkTabsEnabled.Checked && rbSupportTab.Checked; + nudNoBedTabMin.Enabled = enabled; + nudNoBedTabMax.Enabled = enabled; + } + + private static decimal Clamp(NumericUpDown control, double value) => + double.IsFinite(value) + ? System.Math.Clamp((decimal)value, control.Minimum, control.Maximum) + : control.Minimum; + private void OnParametersChanged() { if (!suppressEvents) diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index c74630e1..e00c6a4c 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -39,7 +39,9 @@ var result = CuttingPlanService.Plan(snapshot, cancellationToken); the plate's own under `ForPlate`), each regenerated part the bed cannot hold level gets a tabbed perimeter whose automatic entries keep to points where the tab steadies it. A tab that still cannot hold its part level, or sits in an inside corner, is noted on the plan - without changing its status; see [support tabs](support-tabs.md). + without changing its status. Without a bed, the parts whose width is in + `NoBedTabMinWidth`..`NoBedTabMaxWidth` are tabbed instead, and with no range set the plan notes + that nothing was tabbed; see [support tabs](support-tabs.md). - Parameters are caller-confirmed inputs. The service does not recover missing operator settings or silently change lead styles to find a solution. diff --git a/docs/support-tabs.md b/docs/support-tabs.md index 50e21c30..5c75268b 100644 --- a/docs/support-tabs.md +++ b/docs/support-tabs.md @@ -90,6 +90,24 @@ A support-tabbed perimeter's automatic entries are therefore limited to catalogu analysis accepts, plus the closest native point to its best tab point (`AutomaticEntryKind.SupportTab`, never on a reflex or cusp vertex). Ranking and lead checks are unchanged. +## Plates without a support bed + +With no bed there are no contacts to judge a part by. Cutting Settings > Tabs > "No bed: tab +widths" sets `CuttingParameters.NoBedTabMinWidth` to `NoBedTabMaxWidth`: on a plate with no +support bed, support tabs then tab every part whose width (the short side of its outline's +minimum-area bounding rectangle, `SupportAnalyzer.Width`) is in that range, bounds included. A +maximum of 0 turns the rule off. `SupportTabSettings.Analyze` makes this choice for Plan Cutting, +hand-placed lead-ins and the pre-post review alike; a plate with a bed never uses the range. + +`SupportAnalyzer.AnalyzeWithoutBed` gives such a part a `PartSupport` with `HasBed` false: no +contacts, never supported, and every tab clear of inside corners steadies it, so the perimeter's +automatic entries are every catalogue point clear of inside corners plus the best tab point, the +clear outline point nearest the line through the center of gravity across the long side. Which +of those wins is left to the usual ranking toward the next cut. + +With support tabs on, no bed and no range, nothing is tabbed and the plan carries one advisory +note saying so; its status is unchanged. + If none of those entries has a lead-in that fits, the part is still tabbed. The search falls back first to every catalogue entry clear of inside corners, then to every entry (`SupportEntries`), each ordered by how well the tab steadies the part; of the entries whose @@ -118,12 +136,13 @@ result. Plan Cutting is the only automatic lead-in assignment. ## Pre-post review -`PartSupportReview` runs in every pre-post verification and in Validate Nest. On a plate with a -support bed, each part whose settings (its own, else the plate's) ask for support tabs and that -the bed cannot hold level is checked, however its program was made. The tab is the gap between +`PartSupportReview` runs in every pre-post verification and in Validate Nest. Each part whose +settings (its own, else the plate's) ask for support tabs and that needs one (the plate's bed +cannot hold it level or, with no bed, its width is in the no-bed range) is checked, however its +program was made. The tab is the gap between the end and the start of the program's outside cut (the chain of cutting motions with the -largest extent). A part with no such gap, or whose gap does not steady it, gets a -`PartSupport` warning at its center of gravity naming the best tab point; posting needs +largest extent). A part with no such gap, or whose gap does not steady it (without +a bed any gap does), gets a `PartSupport` warning at its center of gravity naming the best tab point; posting needs consent as for every warning. A part whose material cannot be read is reported as not checked. Not covered yet: