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: