From 69a83b3ad9996b760f4036384da52dfda8e8df39 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 26 Oct 2026 13:20:03 -0400 Subject: [PATCH] feat(support): let a tab alone hold short parts the bed can't Small parts such as 1.5" test squares on a 2" laser grid rest on at most one grid point, so no single support tab can bring their center of gravity inside the contacts and Plan Cutting warned "no single tab can steady it" on every one. In practice a part that short stays in the sheet on its tab, so the bed's contacts don't matter. Cutting Settings > Tabs gets "Tab alone holds parts up to N long" (CuttingParameters.TabHeldMaxLength, 0 = off, the default). On a plate with a bed, a part the bed can't hold level whose length (long side of its minimum-area bounding rectangle) is within the limit is still tabbed, but any tab clear of inside corners steadies it, so Plan Cutting, hand-placed lead-ins and the pre-post review warn only when it has no tab at all. The setting persists with the cutting parameters and is part of the plan state fingerprint. Co-Authored-By: Claude Opus 5.5 --- .../CuttingPlanning/OwnedCuttingParameters.cs | 1 + .../CNC/CuttingPlanning/StateFingerprint.cs | 1 + .../CNC/CuttingStrategy/CuttingParameters.cs | 13 ++ .../Diagnostics/PartSupportReview.cs | 12 +- OpenNest.Core/Support/SupportAnalyzer.cs | 62 +++++++- OpenNest.Core/Support/SupportTabSettings.cs | 12 +- .../CuttingPlanning/SupportTabCapture.cs | 4 +- .../SupportGridPersistenceTests.cs | 13 ++ OpenNest.IO/CuttingParametersDto.cs | 1 + OpenNest.IO/CuttingParametersSerializer.cs | 2 + .../OwnedCuttingParameterTests.cs | 1 + .../Support/TabHeldSupportTabTests.cs | 140 ++++++++++++++++++ OpenNest/Controls/CuttingPanel.cs | 40 +++-- docs/cutting-planner.md | 3 +- docs/support-tabs.md | 16 +- 15 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 OpenNest.Tests/Support/TabHeldSupportTabTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs index 830eaba0..32b83ff0 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs @@ -44,6 +44,7 @@ internal static class OwnedCuttingParameters SupportTabs = source.SupportTabs, NoBedTabMinWidth = Dimension(source.NoBedTabMinWidth), NoBedTabMaxWidth = Dimension(source.NoBedTabMaxWidth), + TabHeldMaxLength = Dimension(source.TabHeldMaxLength), Sequencing = new SequenceParameters { Method = sequence.Method, diff --git a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs index b8bd6328..6a4e0fd0 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs @@ -92,6 +92,7 @@ internal static class StateFingerprint Flag(p.SupportTabs); Number(p.NoBedTabMinWidth); Number(p.NoBedTabMaxWidth); + Number(p.TabHeldMaxLength); 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 2519b787..ddd33099 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs @@ -77,6 +77,19 @@ namespace OpenNest.CNC.CuttingStrategy && width >= NoBedTabMinWidth - Math.Tolerance.Epsilon && width <= NoBedTabMaxWidth + Math.Tolerance.Epsilon; + /// + /// With on a plate with a support bed, the longest part (the + /// long side of its outline's minimum-area bounding rectangle) its tab alone holds: such a + /// part the bed cannot hold level is tabbed, and any tab clear of inside corners steadies + /// it. 0 turns the rule off. + /// + public double TabHeldMaxLength { get; set; } + + /// Whether a part long is held by its tab alone (see ). + public bool TabHoldsAt(double length) => + TabHeldMaxLength > 0 && double.IsFinite(TabHeldMaxLength) + && length <= TabHeldMaxLength + 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 7fbce4a2..db44ab60 100644 --- a/OpenNest.Core/Diagnostics/PartSupportReview.cs +++ b/OpenNest.Core/Diagnostics/PartSupportReview.cs @@ -103,10 +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, ") + // Without a bed, or for a part its tab alone holds, any tab steadies the part, so the only + // failure is no tab at all. + if (!support.HasBed || support.IsHeldByTab) + return (support.HasBed + ? FormattableString.Invariant( + $"The part is {support.Length:0.####} long, short enough for its tab alone to hold, ") + : 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.####}).") diff --git a/OpenNest.Core/Support/SupportAnalyzer.cs b/OpenNest.Core/Support/SupportAnalyzer.cs index 358bf2bc..0fc9c6d9 100644 --- a/OpenNest.Core/Support/SupportAnalyzer.cs +++ b/OpenNest.Core/Support/SupportAnalyzer.cs @@ -104,6 +104,37 @@ namespace OpenNest.Support return System.Math.Min(box.Width, box.Height); } + /// + /// The part's length: the long side of its outline's minimum-area bounding rectangle, or + /// NaN when its material could not be read. + /// + public static double Length(SupportMaterial material) + { + ArgumentNullException.ThrowIfNull(material); + if (!material.IsComplete || material.Outline.Count < 3) + return double.NaN; + var box = BoundingRectangle(material.Outline); + return System.Math.Max(box.Width, box.Height); + } + + /// + /// , a bed analysis of a part the bed cannot hold level, for a + /// part short enough for its tab alone to hold + /// (): its contacts and + /// margin are kept, any tab clear of inside corners steadies it, and the best tab point is + /// chosen as without a bed (). + /// + public static PartSupport HeldByTab(PartSupport support, SupportMaterial material) + { + ArgumentNullException.ThrowIfNull(support); + ArgumentNullException.ThrowIfNull(material); + if (!support.IsComplete || support.IsSupported || !support.HasBed) + return support; + var best = BestTabPoint(material.Outline, support.CenterOfGravity, support.InsideCorners, + support.InsideCornerClearance, _ => double.PositiveInfinity, out _); + return support.WithTabHold(best, Length(material)); + } + // 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 @@ -377,6 +408,25 @@ namespace OpenNest.Support // 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. + // A copy of `source` held by its tab alone: it keeps its contacts and margin, and any tab + // clear of inside corners steadies it. + private PartSupport(PartSupport source, Vector? bestTabPoint, double length) + { + CenterOfGravity = source.CenterOfGravity; + Contacts = source.Contacts; + Margin = source.Margin; + RequiredMargin = source.RequiredMargin; + hulls = source.hulls; + InsideCorners = source.InsideCorners; + InsideCornerClearance = source.InsideCornerClearance; + BestTabPoint = bestTabPoint; + BestTabMargin = double.PositiveInfinity; + IsHeldByTab = true; + Length = length; + } + + internal PartSupport WithTabHold(Vector? bestTabPoint, double length) => new(this, bestTabPoint, length); + internal PartSupport(Vector centerOfGravity, Vector? bestTabPoint, IReadOnlyList insideCorners, double insideCornerClearance, double width) { @@ -397,6 +447,16 @@ namespace OpenNest.Support /// public bool HasBed { get; } = true; + /// + /// True for a part on a bed that cannot hold it level but short enough for its tab alone + /// to hold (): any tab clear of inside corners + /// steadies it. + /// + public bool IsHeldByTab { get; } + + /// The part's length when (see ); NaN otherwise. + public double Length { get; } = double.NaN; + /// The part's width when analyzed without a bed (see ); NaN otherwise. public double Width { get; } = double.NaN; @@ -440,7 +500,7 @@ namespace OpenNest.Support /// The worst margin with a tab holding the part at . public double MarginWith(Vector tab) => !IsComplete ? double.NegativeInfinity - : !HasBed ? double.PositiveInfinity + : !HasBed || IsHeldByTab ? double.PositiveInfinity : SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab); /// True when a tab at holds the part level by the required margin. diff --git a/OpenNest.Core/Support/SupportTabSettings.cs b/OpenNest.Core/Support/SupportTabSettings.cs index d3e57aca..2c1589e2 100644 --- a/OpenNest.Core/Support/SupportTabSettings.cs +++ b/OpenNest.Core/Support/SupportTabSettings.cs @@ -15,7 +15,9 @@ namespace OpenNest.Support { /// /// 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 + /// outside the rule: with a bed, the bed's analysis, held by its tab alone + /// () when the bed cannot hold it level and its + /// length is within ; 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. @@ -30,7 +32,13 @@ namespace OpenNest.Support if (parameters?.SupportTabs != true) return null; if (bed != null) - return SupportAnalyzer.Analyze(bed, quadrant, material, insideCornerClearance); + { + var support = SupportAnalyzer.Analyze(bed, quadrant, material, insideCornerClearance); + return support is { IsComplete: true, IsSupported: false } + && parameters.TabHoldsAt(SupportAnalyzer.Length(material)) + ? SupportAnalyzer.HeldByTab(support, material) + : support; + } if (!parameters.HasNoBedTabRange()) return null; if (!material.IsComplete) diff --git a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs index 73e0908e..e040fc9d 100644 --- a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs +++ b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs @@ -81,6 +81,8 @@ internal static class SupportTabCapture var part = source.SourceOrdinal + 1; var held = !support.HasBed ? FormattableString.Invariant($"is {support.Width:0.####} wide, in the width range tabbed without a support bed,") + : support.IsHeldByTab + ? FormattableString.Invariant($"is {support.Length:0.####} long, short enough for its tab alone to hold,") : support.Margin == double.NegativeInfinity ? "rests on no support contact" : support.Margin < 0 @@ -93,7 +95,7 @@ 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.####})") : ""; + var needs = support.HasBed && !support.IsHeldByTab ? FormattableString.Invariant($" (needs {support.RequiredMargin:0.####})") : ""; return new CuttingPlanFinding(source.SourceOrdinal, source.SourcePart, null, null, PostVerificationKind.PartSupport, $"Part {part} {held}{needs} and {why}; it may tip once cut free."); } diff --git a/OpenNest.IO.Tests/SupportGridPersistenceTests.cs b/OpenNest.IO.Tests/SupportGridPersistenceTests.cs index 45932f76..1be81a6c 100644 --- a/OpenNest.IO.Tests/SupportGridPersistenceTests.cs +++ b/OpenNest.IO.Tests/SupportGridPersistenceTests.cs @@ -155,4 +155,17 @@ public class SupportGridPersistenceTests Assert.Equal(4, snapshot.NoBedTabMaxWidth); Assert.Equal(0, CuttingParametersSerializer.Deserialize("{}").NoBedTabMaxWidth); } + + [Fact] + public void TabHeldLengthRoundTripsInCuttingParameters() + { + var parameters = new CuttingParameters { SupportTabs = true, TabHeldMaxLength = 2 }; + + var restored = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters)); + var snapshot = CuttingParametersSerializer.FromDto(CuttingParametersSerializer.ToDto(parameters)); + + Assert.Equal(2, restored.TabHeldMaxLength); + Assert.Equal(2, snapshot.TabHeldMaxLength); + Assert.Equal(0, CuttingParametersSerializer.Deserialize("{}").TabHeldMaxLength); + } } diff --git a/OpenNest.IO/CuttingParametersDto.cs b/OpenNest.IO/CuttingParametersDto.cs index 25178edf..49584225 100644 --- a/OpenNest.IO/CuttingParametersDto.cs +++ b/OpenNest.IO/CuttingParametersDto.cs @@ -35,6 +35,7 @@ public class CuttingParametersDto public bool SupportTabs { get; set; } public double NoBedTabMinWidth { get; set; } public double NoBedTabMaxWidth { get; set; } + public double TabHeldMaxLength { 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 c9de9fea..be956805 100644 --- a/OpenNest.IO/CuttingParametersSerializer.cs +++ b/OpenNest.IO/CuttingParametersSerializer.cs @@ -73,6 +73,7 @@ public static class CuttingParametersSerializer SupportTabs = parameters.SupportTabs, NoBedTabMinWidth = parameters.NoBedTabMinWidth, NoBedTabMaxWidth = parameters.NoBedTabMaxWidth, + TabHeldMaxLength = parameters.TabHeldMaxLength, Sequencing = ToSequenceDto(parameters.Sequencing), Assignment = ToAssignmentDto(parameters.Assignment), }; @@ -108,6 +109,7 @@ public static class CuttingParametersSerializer SupportTabs = dto.SupportTabs, NoBedTabMinWidth = dto.NoBedTabMinWidth, NoBedTabMaxWidth = dto.NoBedTabMaxWidth, + TabHeldMaxLength = dto.TabHeldMaxLength, Sequencing = FromSequenceDto(dto.Sequencing), Assignment = FromAssignmentDto(dto.Assignment), }; diff --git a/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs index 1841cb23..07650bbc 100644 --- a/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs +++ b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs @@ -44,6 +44,7 @@ public class OwnedCuttingParameterTests SupportTabs = true, NoBedTabMinWidth = 0.5, NoBedTabMaxWidth = 4, + TabHeldMaxLength = 2, RoundLeadInAngles = true, LeadInAngleIncrement = 30, Sequencing = new SequenceParameters diff --git a/OpenNest.Tests/Support/TabHeldSupportTabTests.cs b/OpenNest.Tests/Support/TabHeldSupportTabTests.cs new file mode 100644 index 00000000..cb89e874 --- /dev/null +++ b/OpenNest.Tests/Support/TabHeldSupportTabTests.cs @@ -0,0 +1,140 @@ +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 bed for parts short enough for their tab alone to hold +/// (): a part the bed cannot hold level is still +/// tabbed, but any tab clear of inside corners steadies it, so it is not reported. +/// +public class TabHeldSupportTabTests +{ + private const double TabSize = 0.2; + + // A 2 inch laser grid; a 1.5 inch square rests on one of its points at most. + private static readonly SupportGrid Bed = new(2, 2, 0, 0, registrationTolerance: 0.125, stabilityMargin: 0.25); + + private static CuttingParameters Parameters(double tabHeldMaxLength) + { + var parameters = ExplicitContourTests.Parameters(); + parameters.SupportTabs = true; + parameters.TabConfig = new NormalTab { Size = TabSize }; + parameters.TabHeldMaxLength = tabHeldMaxLength; + return parameters; + } + + // A square `size` on a side at (3, 3) on the 2 inch grid. + private static (Nest Nest, Plate Plate, Part Part) Square(CuttingParameters parameters, double size = 1.5) + { + var nest = new Nest("tab held"); + var plate = new Plate(20, 20) { CuttingParameters = parameters }; + nest.Plates.Add(plate); + nest.SetSupportBed(Bed); + var part = new Part(new Drawing("square", LeadPathValidationTests.Rectangle(0, 0, size, size)), new Vector(3, 3)); + 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, false)] // off + [InlineData(1, false)] + [InlineData(1.5, true)] // the bound is inclusive + [InlineData(2, true)] + public void TheLimitHoldsPartsUpToIt(double max, bool holdsALengthOfOneAndAHalf) + { + Assert.Equal(holdsALengthOfOneAndAHalf, new CuttingParameters { TabHeldMaxLength = max }.TabHoldsAt(1.5)); + } + + [Fact] + public void Analyze_HoldsAShortPartTheBedCannotByAnyClearTab() + { + var (_, _, part) = Square(Parameters(2)); + var material = SupportMaterial.FromPart(part); + + var held = SupportTabSettings.Analyze(Parameters(2), Bed, 1, material); + var bedOnly = SupportTabSettings.Analyze(Parameters(0), Bed, 1, material); + + Assert.Equal(1.5, SupportAnalyzer.Length(material), 6); + Assert.False(bedOnly.IsHeldByTab); + Assert.False(bedOnly.TabCanSupport); + Assert.True(held.IsHeldByTab); + Assert.True(held.HasBed); + Assert.False(held.IsSupported); + Assert.Equal(bedOnly.Margin, held.Margin); + Assert.True(held.TabCanSupport); + Assert.True(held.AcceptsTabAt(new Vector(3, 3.75))); + Assert.True(held.IsSteadiedByTabAt(new Vector(4.5, 3.75))); + Assert.False(SupportTabSettings.Analyze(Parameters(1), Bed, 1, material).IsHeldByTab); + } + + [Fact] + public void Analyze_LeavesAPartTheBedHoldsAlone() + { + // A 5 inch square rests on a 2 x 2 block of grid points around its center. + var (_, _, part) = Square(Parameters(6), 5); + + var support = SupportTabSettings.Analyze(Parameters(6), Bed, 1, SupportMaterial.FromPart(part)); + + Assert.True(support.IsSupported); + Assert.False(support.IsHeldByTab); + } + + [Fact] + public void PlanCutting_WarnsAboutAShortPartNoTabCanSteady_WithoutTheLimit() + { + var (_, plate, _) = Square(Parameters(0)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters(0))); + + Assert.Equal(CuttingPlanStatus.BestEffort, result.Status); + Assert.Contains("no single tab can steady it", Assert.Single(result.Findings).Message); + } + + [Fact] + public void PlanCutting_TabsAShortPartWithoutAWarning() + { + var (_, plate, _) = Square(Parameters(2)); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, Vector.Zero, + confirmedParameters: Parameters(2))); + + 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 PrePost_WarnsOnlyAboutAnUntabbedShortPart() + { + var lead = new Line(new Vector(0, 0), new Vector(0, 1.5)); + var (untabbed, _, bare) = Square(Parameters(2)); + bare.ApplySingleLeadIn(Parameters(2), new Vector(0, 0.75), lead, ContourType.External); + var (tabbed, _, held) = Square(Parameters(2)); + var withTabs = Parameters(2); + withTabs.TabsEnabled = true; + held.ApplySingleLeadIn(withTabs, new Vector(0, 0.75), lead, ContourType.External); + + var finding = Assert.Single(PostVerificationAnalyzer.Analyze(untabbed).Findings, + f => f.Kind == PostVerificationKind.PartSupport); + Assert.Contains("short enough for its tab alone to hold", 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 ad0c3717..687cd8d7 100644 --- a/OpenNest/Controls/CuttingPanel.cs +++ b/OpenNest/Controls/CuttingPanel.cs @@ -45,9 +45,10 @@ namespace OpenNest.Controls private readonly NumericUpDown nudTabWidth; private readonly RadioButton rbTabAll; private readonly RadioButton rbSupportTab; - private readonly TableLayoutPanel noBedOptions; + private readonly TableLayoutPanel supportTabOptions; private readonly NumericUpDown nudNoBedTabMin; private readonly NumericUpDown nudNoBedTabMax; + private readonly NumericUpDown nudTabHeldMax; private readonly NumericUpDown nudPierceClearance; private readonly NumericUpDown nudPierceDiameter; @@ -194,23 +195,35 @@ namespace OpenNest.Controls "Plan Cutting tabs each part that would tip once cut free, where it best keeps the part level."); AddSpanningRow(tabOptions, rbSupportTab); - // A plate with no support bed tabs the parts whose narrow side is in this range; - // a maximum of 0 tabs none. - noBedOptions = new TableLayoutPanel + // A part up to this long is held by its tab alone, wherever the tab goes; a plate with + // no support bed tabs the parts whose narrow side is in the range below. A maximum of + // 0 turns either rule off. + supportTabOptions = new TableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, ColumnCount = 1, Margin = new Padding(FieldIndent + 2, 0, 0, 0), }; - noBedOptions.Controls.Add( + nudTabHeldMax = CreateNumeric(0, 0.25); + nudTabHeldMax.Width = 80; + toolTip.SetToolTip(nudTabHeldMax, + "Parts this short stay in the sheet on their tab, so a tab anywhere holds them without the grid."); + supportTabOptions.Controls.Add( + CreateInline( + CreateHint("Tab alone holds parts up to", new Padding(0, 0, 4, 0)), + nudTabHeldMax, + CreateHint("long (0 = off)", new Padding(6, 0, 0, 0)) + ) + ); + supportTabOptions.Controls.Add( CreateHint("On plates with no support bed, tab parts whose narrow side is:", new Padding(0, 4, 0, 2)) ); nudNoBedTabMin = CreateNumeric(0, 0.25); nudNoBedTabMin.Width = 80; nudNoBedTabMax = CreateNumeric(0, 0.25); nudNoBedTabMax.Width = 80; - noBedOptions.Controls.Add( + supportTabOptions.Controls.Add( CreateInline( nudNoBedTabMin, CreateHint("to", new Padding(4, 0, 4, 0)), @@ -218,25 +231,26 @@ namespace OpenNest.Controls CreateHint("(0 = none)", new Padding(6, 0, 0, 0)) ) ); - AddSpanningRow(tabOptions, noBedOptions); + AddSpanningRow(tabOptions, supportTabOptions); layout.Controls.Add(tabOptions); chkTabsEnabled.CheckedChanged += (s, e) => { tabOptions.Enabled = chkTabsEnabled.Checked; - UpdateNoBedRange(); + UpdateSupportTabOptions(); OnParametersChanged(); }; // The two choices toggle together, so one handler sees every change. rbTabAll.CheckedChanged += (s, e) => { - UpdateNoBedRange(); + UpdateSupportTabOptions(); OnParametersChanged(); }; nudTabWidth.ValueChanged += (s, e) => OnParametersChanged(); nudNoBedTabMin.ValueChanged += (s, e) => OnParametersChanged(); nudNoBedTabMax.ValueChanged += (s, e) => OnParametersChanged(); + nudTabHeldMax.ValueChanged += (s, e) => OnParametersChanged(); // Pierce AddSection("Pierce"); @@ -289,6 +303,7 @@ namespace OpenNest.Controls SupportTabs = chkTabsEnabled.Checked && rbSupportTab.Checked, NoBedTabMinWidth = (double)nudNoBedTabMin.Value, NoBedTabMaxWidth = (double)nudNoBedTabMax.Value, + TabHeldMaxLength = (double)nudTabHeldMax.Value, }; } @@ -308,8 +323,9 @@ namespace OpenNest.Controls nudTabWidth.Value = Clamp(nudTabWidth, p.TabConfig.Size); nudNoBedTabMin.Value = Clamp(nudNoBedTabMin, p.NoBedTabMinWidth); nudNoBedTabMax.Value = Clamp(nudNoBedTabMax, p.NoBedTabMaxWidth); + nudTabHeldMax.Value = Clamp(nudTabHeldMax, p.TabHeldMaxLength); tabOptions.Enabled = chkTabsEnabled.Checked; - UpdateNoBedRange(); + UpdateSupportTabOptions(); nudPierceClearance.Value = Clamp(nudPierceClearance, p.PierceClearance); nudPierceDiameter.Value = Clamp(nudPierceDiameter, p.PierceDiameter); chkRoundLeadInAngles.Checked = p.RoundLeadInAngles; @@ -319,9 +335,9 @@ namespace OpenNest.Controls suppressEvents = false; } - private void UpdateNoBedRange() + private void UpdateSupportTabOptions() { - noBedOptions.Enabled = chkTabsEnabled.Checked && rbSupportTab.Checked; + supportTabOptions.Enabled = chkTabsEnabled.Checked && rbSupportTab.Checked; } private static decimal Clamp(NumericUpDown control, double value) => diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 8ef4ff86..803c355f 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -39,7 +39,8 @@ 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. Without a bed, the parts whose width is in + without changing its status. A part no longer than `TabHeldMaxLength` is held by its tab + alone, so any tab clear of inside corners steadies it. 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 diff --git a/docs/support-tabs.md b/docs/support-tabs.md index 5c75268b..dfcee7b8 100644 --- a/docs/support-tabs.md +++ b/docs/support-tabs.md @@ -90,6 +90,20 @@ 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. +## Parts the tab alone holds + +A tabbed part stays in the sheet on its tab, and a part short enough does not need the bed at +all: the tab holds it wherever it goes. Cutting Settings > Tabs > "Tab alone holds parts up to" +sets `CuttingParameters.TabHeldMaxLength`: on a plate with a support bed, a part the bed cannot +hold level whose length (the long side of its outline's minimum-area bounding rectangle, +`SupportAnalyzer.Length`) is at most that, bound included, is still tabbed, but +`SupportAnalyzer.HeldByTab` marks its analysis `IsHeldByTab`: it keeps its contacts and margin, +every tab clear of inside corners steadies it, and its best tab point is chosen as on a plate +with no bed (below). A part the bed holds is not tabbed. 0 turns the rule off. +`SupportTabSettings.Analyze` applies it for Plan Cutting, hand-placed lead-ins and the pre-post +review alike, so such a part warns only when it is cut with no tab at all. View > Draw Support +does not read cutting settings and still shows it red, since it would tip without its tab. + ## Plates without a support bed With no bed there are no contacts to judge a part by. Cutting Settings > Tabs > "No bed: tab @@ -142,7 +156,7 @@ cannot hold it level or, with no bed, its width is in the no-bed range) is check 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 (without -a bed any gap does), gets a `PartSupport` warning at its center of gravity naming the best tab point; posting needs +a bed, or for a part its tab alone holds, 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: