feat(cutting): tab parts by width when the plate has no support bed

With support tabs on and no support bed, nothing was tabbed and nothing
said so. A new no-bed width range (NoBedTabMinWidth..NoBedTabMaxWidth,
the short side of the part's minimum-area bounding rectangle) now picks
the parts to tab instead. SupportTabSettings.Analyze makes the choice
for Plan Cutting, hand-placed lead-ins and the pre-post review; without
a bed any tab clear of inside corners counts. With no bed and no range,
the plan notes that nothing was tabbed. Cutting Settings > Tabs gains
the range fields, and the values persist with the cutting parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 22:02:31 -04:00
1 parent 07499fe1cd
commit 479883e7ac
18 files changed
+500 -52

No files matched your search

@@ -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,
@@ -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('}');
@@ -51,6 +51,32 @@ namespace OpenNest.CNC.CuttingStrategy
/// </summary>
public bool SupportTabs { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> on a plate that has no support bed, the narrowest part
/// width that is tabbed instead (see <see cref="TabsWithoutBedAt"/>).
/// </summary>
public double NoBedTabMinWidth { get; set; }
/// <summary>
/// With <see cref="SupportTabs"/> 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.
/// </summary>
public double NoBedTabMaxWidth { get; set; }
/// <summary>Whether <see cref="NoBedTabMinWidth"/> to <see cref="NoBedTabMaxWidth"/> is a usable range.</summary>
public bool HasNoBedTabRange() =>
NoBedTabMaxWidth > 0 && double.IsFinite(NoBedTabMaxWidth)
&& NoBedTabMinWidth >= 0 && NoBedTabMinWidth <= NoBedTabMaxWidth;
/// <summary>
/// Whether a part <paramref name="width"/> wide (the short side of its minimum-area
/// bounding rectangle) is tabbed when its plate has no support bed.
/// </summary>
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();
}
+19 -8
View File
@@ -10,10 +10,11 @@ using OpenNest.Support;
namespace OpenNest.Diagnostics;
/// <summary>
/// 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 (<see cref="SupportTabSettings.Analyze"/>: 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.
/// </summary>
internal static class PartSupportReview
@@ -21,19 +22,21 @@ internal static class PartSupportReview
internal static void Add(Plate plate, int plateNumber, List<PostVerificationFinding> 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
+92 -14
View File
@@ -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);
}
/// <summary>
/// A part on a plate with no support bed, tabbed for its <see cref="Width"/> instead
/// (<see cref="CNC.CuttingStrategy.CuttingParameters.TabsWithoutBedAt"/>). 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.
/// </summary>
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
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));
}
/// <summary>
/// The part's width: the short side of its outline's minimum-area bounding rectangle, or
/// NaN when its material could not be read.
/// </summary>
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<Vector> outline, Vector center, IReadOnlyList<Vector> corners,
double insideCornerClearance, Func<Vector, double> 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;
}
/// <summary>The unit direction of the longer side of the outline's minimum-area bounding rectangle.</summary>
private static Vector LongSide(IReadOnlyList<Vector> 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<Vector> 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<Vector> insideCorners,
double insideCornerClearance, double width)
{
HasBed = false;
CenterOfGravity = centerOfGravity;
Contacts = Array.Empty<Vector>();
BestTabPoint = bestTabPoint;
BestTabMargin = double.PositiveInfinity;
InsideCorners = insideCorners;
InsideCornerClearance = insideCornerClearance;
Width = width;
}
/// <summary>
/// False for a part on a plate with no support bed, tabbed for its <see cref="Width"/>
/// (<see cref="SupportAnalyzer.AnalyzeWithoutBed"/>): it has no contacts, never counts as
/// supported, and any tab clear of inside corners steadies it.
/// </summary>
public bool HasBed { get; } = true;
/// <summary>The part's width when analyzed without a bed (see <see cref="SupportAnalyzer.Width"/>); NaN otherwise.</summary>
public double Width { get; } = double.NaN;
/// <summary>True when the part was analyzed; otherwise <see cref="Reason"/> says why not.</summary>
public bool IsComplete => Reason == null;
@@ -363,7 +439,9 @@ namespace OpenNest.Support
/// <summary>The worst margin with a tab holding the part at <paramref name="tab"/>.</summary>
public double MarginWith(Vector tab) =>
IsComplete ? SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab) : double.NegativeInfinity;
!IsComplete ? double.NegativeInfinity
: !HasBed ? double.PositiveInfinity
: SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab);
/// <summary>True when a tab at <paramref name="tab"/> holds the part level by the required margin.</summary>
public bool IsSteadiedByTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab));
+45 -9
View File
@@ -5,26 +5,62 @@ using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.Support
{
/// <summary>
/// Cutting settings for a lead-in placed by hand under
/// <see cref="CuttingParameters.SupportTabs"/>: 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 <see cref="CuttingParameters.SupportTabs"/> 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.
/// </summary>
public static class SupportTabSettings
{
/// <summary>
/// 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
/// (<see cref="SupportAnalyzer.AnalyzeWithoutBed"/>) of a part whose width is in
/// <see cref="CuttingParameters.TabsWithoutBedAt"/>'s range. The part needs a tab when
/// the result is complete and not supported; an incomplete result could not be read.
/// </summary>
/// <param name="insideCornerClearance">
/// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
/// </param>
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;
}
/// <summary>
/// Whether support tabs are on but can tab nothing: the plate has no support bed and the
/// settings set no no-bed width range.
/// </summary>
public static bool SkipsWithoutBed(CuttingParameters parameters, SupportBed bed) =>
parameters?.SupportTabs == true && bed == null && !parameters.HasNoBedTabRange();
/// <summary>
/// <paramref name="parameters"/> 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
/// <paramref name="part"/> level. The caller's settings are never changed.
/// on, a tab size is set and <paramref name="part"/> needs a tab (see <see cref="Analyze"/>).
/// The caller's settings are never changed.
/// </summary>
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;
@@ -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 };
}
@@ -107,12 +107,19 @@ public sealed class CuttingPlanSnapshot
internal CuttingPlanStatus? Failure { get; }
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
/// <summary>
/// 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.
/// </summary>
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
@@ -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)
@@ -11,9 +11,11 @@ using OpenNest.Support;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Capture-time support tabs for one regenerated part (docs/support-tabs.md): with a support
/// bed and <see cref="CNC.CuttingStrategy.CuttingParameters.SupportTabs"/> 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
/// <see cref="CNC.CuttingStrategy.CuttingParameters.SupportTabs"/> on, a part the support bed
/// cannot hold level, or on a plate with no bed a part in the no-bed width range
/// (<see cref="SupportTabSettings.Analyze"/>), 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.");
}
/// <summary>
/// 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 <see cref="Notes"/>.
/// </summary>
internal static IEnumerable<CuttingPlanFinding> 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.");
}
}
@@ -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);
}
}
+2
View File
@@ -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();
@@ -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),
};
@@ -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
@@ -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;
/// <summary>
/// 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.
/// </summary>
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);
}
}
+55 -2
View File
@@ -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)
+3 -1
View File
@@ -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.
+24 -5
View File
@@ -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: