diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
index 69687cc7..cb20589a 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
@@ -44,6 +44,22 @@ internal enum AutomaticEntryKind
SupportTab = 8,
}
+///
+/// Which automatic entries a perimeter tabbed for support offers (see
+/// ); every other contour offers its whole catalogue.
+///
+internal enum SupportEntries
+{
+ /// Entries whose tab steadies the part, clear of inside corners, plus its best tab point.
+ Steadying,
+
+ /// Every entry clear of inside corners: the fallback when no steadying entry fits.
+ ClearOfInsideCorners,
+
+ /// Every entry: the last fallback, when nothing clear of inside corners fits.
+ Any,
+}
+
///
/// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 1dcaf876..44b66868 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -401,7 +401,7 @@ public sealed class PreparedContours
return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
if (IsSingleCircle(shape))
throw new ArgumentException("Circles have no preferred corners or joints; use the fallback catalogue.");
- return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), false);
+ return SupportFiltered(contourOrdinal, PreferredCandidates(shape, contourOrdinal, token), SupportEntries.Steadying);
}
///
@@ -419,7 +419,7 @@ public sealed class PreparedContours
int contourOrdinal,
Vector? lookAhead = null,
CancellationToken token = default,
- bool ignoreSupport = false)
+ SupportEntries supportEntries = SupportEntries.Steadying)
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
@@ -429,23 +429,40 @@ public sealed class PreparedContours
? new List()
: PreferredCandidates(shape, contourOrdinal, token);
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
- return SupportFiltered(contourOrdinal, all, ignoreSupport);
+ return SupportFiltered(contourOrdinal, all, supportEntries);
}
///
/// Whether this preparation tabs its perimeter for support (see ):
- /// its automatic catalogue then keeps to steadying points unless asked to ignore support.
+ /// its automatic catalogue then keeps to steadying points unless asked for a fallback.
///
internal bool HasSupportTab => support != null;
+ ///
+ /// The support margin with this perimeter's tab at (see
+ /// ); negative infinity when it is not tabbed for support.
+ ///
+ internal double SupportMarginAt(ContourChoice choice) =>
+ support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
+
+ /// Whether a support tab at holds the part level (see ).
+ internal bool HoldsLevelAt(ContourChoice choice) =>
+ support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
+
+ /// Whether a support tab at keeps the inside-corner clearance.
+ internal bool IsClearOfInsideCorners(ContourChoice choice) =>
+ support == null || support.IsClearOfInsideCorners(choice.Point + supportOrigin);
+
// A perimeter tabbed for support keeps the candidates whose tab steadies the part, and gains
- // the outline's best tab point; with ignoreSupport (the search's fallback when none of them
- // has a lead-in that fits) every candidate stays, still tabbed.
+ // the outline's best tab point. The search's fallbacks, when none of them has a lead-in that
+ // fits, keep every candidate clear of inside corners and then every candidate, still tabbed.
private IReadOnlyList SupportFiltered(int contourOrdinal,
- List candidates, bool ignoreSupport)
+ List candidates, SupportEntries entries)
{
- if (support == null || contourOrdinal != PerimeterOrdinal || ignoreSupport)
+ if (support == null || contourOrdinal != PerimeterOrdinal || entries == SupportEntries.Any)
return MergeByGeometry(candidates);
+ if (entries == SupportEntries.ClearOfInsideCorners)
+ return MergeByGeometry(candidates.Where(c => IsClearOfInsideCorners(c.Choice)).ToList());
var kept = candidates.Where(c => support.AcceptsTabAt(c.Choice.Point + supportOrigin)).ToList();
var best = ClosestEntry(PerimeterOrdinal, support.BestTabPoint!.Value - supportOrigin);
if (!IsForbiddenVertex(GetShape(PerimeterOrdinal), best.Point))
diff --git a/OpenNest.Core/Support/SupportAnalyzer.cs b/OpenNest.Core/Support/SupportAnalyzer.cs
index 94248151..4bcca2f2 100644
--- a/OpenNest.Core/Support/SupportAnalyzer.cs
+++ b/OpenNest.Core/Support/SupportAnalyzer.cs
@@ -14,19 +14,38 @@ namespace OpenNest.Support
/// most zero. With a , the bed is also
/// checked shifted by that much along each axis and diagonally, keeping the worst margin.
/// A perimeter tab holds the part at the tab, so a tab point counts as one more contact.
+ /// A tab never sits in an inside corner, where the operator cannot grind its nub flush.
///
public static class SupportAnalyzer
{
/// Points sampled along the outline, besides its vertices, to find the best tab point.
public const int TabSamples = 256;
- public static PartSupport Analyze(SupportBed bed, int quadrant, Part part) =>
- Analyze(bed, quadrant, SupportMaterial.FromPart(part));
+ ///
+ /// The least the outline must turn inward, in radians, for an inside corner; a run of
+ /// inward-turning vertices (a chorded fillet) counts as one corner by its whole turn.
+ ///
+ public const double InsideCornerAngle = System.Math.PI / 6;
- public static PartSupport Analyze(SupportBed bed, int quadrant, SupportMaterial material)
+ /// How far a tab of size keeps from an inside corner: its uncut span and as much again.
+ public static double InsideCornerClearance(double tabSize) => tabSize > 0 ? 2 * tabSize : 0;
+
+ ///
+ /// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
+ ///
+ public static PartSupport Analyze(SupportBed bed, int quadrant, Part part, double insideCornerClearance = 0) =>
+ Analyze(bed, quadrant, SupportMaterial.FromPart(part), insideCornerClearance);
+
+ ///
+ /// How far a tab point keeps from an inside corner; 0 keeps it off the corner only.
+ ///
+ public static PartSupport Analyze(SupportBed bed, int quadrant, SupportMaterial material,
+ double insideCornerClearance = 0)
{
ArgumentNullException.ThrowIfNull(bed);
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 _))
@@ -38,19 +57,99 @@ namespace OpenNest.Support
var margin = hulls.Min(hull => Margin(center, hull));
var contacts = bed.Contacts(material, quadrant, Vector.Zero);
+ var corners = InsideCorners(material.Outline);
+ var samples = OutlineSamples(material.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();
+
+ // Equally good points run along a straight edge; of those, the one farthest from an
+ // inside corner, or the first.
+ var bestMargin = scored.Count == 0 ? double.NegativeInfinity : scored.Max(s => s.Margin);
Vector? best = null;
- var bestMargin = double.NegativeInfinity;
- foreach (var point in OutlineSamples(material.Outline))
+ var bestClear = double.NegativeInfinity;
+ foreach (var sample in scored)
{
- var withTab = MarginWith(center, hulls, point);
- if (withTab > bestMargin)
+ if (sample.Margin >= bestMargin - Math.Tolerance.Epsilon && sample.Clear > bestClear)
{
- best = point;
- bestMargin = withTab;
+ best = sample.Point;
+ bestClear = sample.Clear;
}
}
- return new PartSupport(center, contacts, margin, bed.StabilityMargin, hulls, best, bestMargin);
+ return new PartSupport(center, contacts, margin, bed.StabilityMargin, hulls, best, bestMargin,
+ corners, insideCornerClearance);
+ }
+
+ // 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) =>
+ clearance > 0 ? clear >= clearance : clear > Math.Tolerance.Epsilon;
+
+ internal static double Clearance(IReadOnlyList corners, Vector point)
+ {
+ var clear = double.PositiveInfinity;
+ foreach (var corner in corners)
+ clear = System.Math.Min(clear, corner.DistanceTo(point));
+ return clear;
+ }
+
+ ///
+ /// The outline vertices that lie in an inside corner: each run of consecutive vertices
+ /// turning inward whose turns add up to at least .
+ ///
+ internal static Vector[] InsideCorners(IReadOnlyList outline)
+ {
+ var count = outline.Count;
+ if (count < 3)
+ return Array.Empty();
+ var area = 0.0;
+ for (var i = 0; i < count; i++)
+ {
+ var a = outline[i];
+ var b = outline[(i + 1) % count];
+ area += a.X * b.Y - b.X * a.Y;
+ }
+ if (area == 0)
+ return Array.Empty();
+
+ // Signed turn at each vertex: negative turns inward, whatever the outline's winding.
+ var turns = new double[count];
+ for (var i = 0; i < count; i++)
+ {
+ var into = outline[i] - outline[(i + count - 1) % count];
+ var outOf = outline[(i + 1) % count] - outline[i];
+ var cross = into.X * outOf.Y - into.Y * outOf.X;
+ var dot = into.X * outOf.X + into.Y * outOf.Y;
+ turns[i] = System.Math.Atan2(cross, dot) * System.Math.Sign(area);
+ }
+
+ // Start after a vertex that does not turn inward so no run is split by the wrap.
+ var start = Array.FindIndex(turns, turn => turn >= 0);
+ if (start < 0)
+ return Array.Empty();
+ var corners = new List();
+ var run = new List();
+ var turned = 0.0;
+ for (var step = 1; step <= count; step++)
+ {
+ var i = (start + step) % count;
+ if (turns[i] < 0)
+ {
+ run.Add(outline[i]);
+ turned -= turns[i];
+ continue;
+ }
+ if (turned >= InsideCornerAngle - Math.Tolerance.Epsilon)
+ corners.AddRange(run);
+ run.Clear();
+ turned = 0;
+ }
+ return corners.ToArray();
}
/// The worst margin of over every shifted hull, each with added.
@@ -194,7 +293,8 @@ namespace OpenNest.Support
}
internal PartSupport(Vector centerOfGravity, IReadOnlyList contacts, double margin,
- double requiredMargin, IReadOnlyList hulls, Vector? bestTabPoint, double bestTabMargin)
+ double requiredMargin, IReadOnlyList hulls, Vector? bestTabPoint, double bestTabMargin,
+ IReadOnlyList insideCorners, double insideCornerClearance)
{
CenterOfGravity = centerOfGravity;
Contacts = contacts;
@@ -203,6 +303,8 @@ namespace OpenNest.Support
this.hulls = hulls;
BestTabPoint = bestTabPoint;
BestTabMargin = bestTabMargin;
+ InsideCorners = insideCorners;
+ InsideCornerClearance = insideCornerClearance;
}
/// True when the part was analyzed; otherwise says why not.
@@ -215,6 +317,12 @@ namespace OpenNest.Support
/// Every bed contact under the part at the bed's nominal position.
public IReadOnlyList Contacts { get; }
+ /// The outline vertices in an inside corner (see ).
+ public IReadOnlyList InsideCorners { get; } = Array.Empty();
+
+ /// How far an accepted tab point keeps from an inside corner.
+ public double InsideCornerClearance { get; }
+
/// The worst signed distance of the center of gravity inside the contacts' hull.
public double Margin { get; } = double.NegativeInfinity;
@@ -224,7 +332,10 @@ namespace OpenNest.Support
/// True when the bed alone holds the part level by at least the required margin.
public bool IsSupported => IsComplete && Meets(Margin);
- /// The outline point where one tab steadies the part most, or null when not analyzed.
+ ///
+ /// The outline point clear of inside corners where one tab steadies the part most, or
+ /// null when not analyzed. Of equally good points, the one farthest from an inside corner.
+ ///
public Vector? BestTabPoint { get; }
/// The margin with a tab at .
@@ -238,17 +349,25 @@ namespace OpenNest.Support
IsComplete ? SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab) : double.NegativeInfinity;
///
- /// Whether a tab at is good enough: it holds the part level by the
- /// required margin, or, where no single tab can, it is as good as the best one found.
+ /// Whether a tab at is good enough: it keeps clear of inside corners,
+ /// and it holds the part level by the required margin or, where no single tab can, it is
+ /// as good as the best one found.
///
public bool AcceptsTabAt(Vector tab)
{
- if (!IsComplete)
+ if (!IsComplete || !IsClearOfInsideCorners(tab))
return false;
var margin = MarginWith(tab);
return TabCanSupport ? Meets(margin) : margin >= BestTabMargin - Math.Tolerance.Epsilon;
}
+ /// Whether a tab at holds the part level by the required margin.
+ public bool HoldsLevelWithTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab));
+
+ /// Whether keeps from every inside corner.
+ public bool IsClearOfInsideCorners(Vector point) =>
+ SupportAnalyzer.IsClear(SupportAnalyzer.Clearance(InsideCorners, point), InsideCornerClearance);
+
private bool Meets(double margin) => margin > 0 && margin >= RequiredMargin;
}
}
diff --git a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs
index c01ff656..acdd6912 100644
--- a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs
+++ b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs
@@ -75,6 +75,7 @@ internal static class BestEffortCuttingPlan
f.OtherPartNumber is int otherOrdinal ? snapshot.Placements[otherOrdinal - 1].SourcePart : null,
f.Kind, f.Message)
{ Witness = f }));
+ findings.AddRange(SupportTabCapture.Notes(proposal));
distance += execution.RapidDistanceFrom(position);
if (!double.IsFinite(distance))
throw new ArgumentException("Best-effort travel is not finite.");
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
index 52848168..e3d36a83 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
@@ -460,6 +460,9 @@ public sealed class CuttingPlanProposal
yield return heading + $"ready. {Count(plate.PartCount, "part")}, {plate.RegeneratedCount} "
+ $"regenerated, {kept} kept as is; rapid travel "
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}";
+ // A ready plan's findings are advisory notes (support tabs that may not hold a part level).
+ foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
+ yield return line;
yield break;
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index 2aaa4986..0a50c5e2 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -405,12 +405,14 @@ public static class CuttingPlanService
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: findings, expansions: expansions);
+ // Support tabs that cannot hold their part level are noted, never refused.
+ var notes = order.SelectMany(SupportTabCapture.Notes).ToList();
// Every planner-chosen motion passed. Fixed-emission warnings make the proposal
// UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
if (warnings.Count != 0)
- return new(CuttingPlanStatus.BestEffort, order, warnings.Distinct(), expansions,
+ return new(CuttingPlanStatus.BestEffort, order, warnings.Concat(notes).Distinct(), expansions,
distance, independentlyReplayed: true);
- return new(CuttingPlanStatus.Ready, order, expansions: expansions,
+ return new(CuttingPlanStatus.Ready, order, notes, expansions: expansions,
rapidDistance: distance, independentlyReplayed: true);
}
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 456e2eb7..b0a439ee 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -803,18 +803,21 @@ internal static class JointCuttingPlanSearch
private IReadOnlyList SelectEntries(FixedProgramPlacement source, int contour,
Vector? target, Vector arrival, Vector[] sightTargets = null)
{
- var ordered = RankedEntries(source, contour, target, arrival, sightTargets, ignoreSupport: false);
+ var ordered = RankedEntries(source, contour, target, arrival, sightTargets, SupportEntries.Steadying);
var selection = ContourEntrySelection.Select(ordered,
candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
// No steadying point of a support-tabbed perimeter has a lead-in that fits: it is
- // still tabbed, wherever its configured lead-ins fit.
- if (contour == source.Prepared.PerimeterOrdinal && source.Prepared.HasSupportTab
- && selection.Choices.Count == 0 && selection.UncertainChoices.Count == 0)
- {
- ordered = RankedEntries(source, contour, target, arrival, sightTargets, ignoreSupport: true);
- selection = ContourEntrySelection.Select(ordered,
- candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
- }
+ // still tabbed, where a lead-in fits and the tab steadies it most, clear of inside
+ // corners unless no entry clear of them fits.
+ if (contour == source.Prepared.PerimeterOrdinal && source.Prepared.HasSupportTab)
+ foreach (var fallback in new[] { SupportEntries.ClearOfInsideCorners, SupportEntries.Any })
+ {
+ if (selection.Choices.Count != 0 || selection.UncertainChoices.Count != 0)
+ break;
+ ordered = RankedEntries(source, contour, target, arrival, sightTargets, fallback);
+ selection = Steadiest(source.Prepared, ContourEntrySelection.Select(ordered,
+ candidate => Evaluate(source, candidate), snapshot.MaxEntries, token));
+ }
// No configured lead-in fits the perimeter: with a pierce diameter, try open scrap.
string scrapReason = null;
if (contour == source.Prepared.PerimeterOrdinal && selection.Choices.Count == 0
@@ -840,15 +843,33 @@ internal static class JointCuttingPlanSearch
return selection.Choices.Concat(selection.UncertainChoices).Take(snapshot.MaxEntries).ToArray();
}
- // One contour's automatic catalogue ranked toward the next cut, before lead checks.
+ // A support fallback's selection keeps only the fitting entries whose tab steadies the
+ // part most: every one that holds it level, or else those as good as the best.
+ private static ContourSelectionResult Steadiest(PreparedContours prepared, ContourSelectionResult selection)
+ {
+ var fitting = selection.Choices.Concat(selection.UncertainChoices).ToArray();
+ if (fitting.Length == 0)
+ return selection;
+ var best = fitting.Max(prepared.SupportMarginAt);
+ bool Keep(ContourChoice choice) => prepared.HoldsLevelAt(choice)
+ || prepared.SupportMarginAt(choice) >= best - OpenNest.Math.Tolerance.Epsilon && !fitting.Any(prepared.HoldsLevelAt);
+ return selection with
+ {
+ Choices = selection.Choices.Where(Keep).ToArray(),
+ UncertainChoices = selection.UncertainChoices.Where(Keep).ToArray(),
+ };
+ }
+
+ // One contour's automatic catalogue ranked toward the next cut, before lead checks. A
+ // support fallback leads with the entries whose tab steadies the part most.
private IReadOnlyList RankedEntries(FixedProgramPlacement source, int contour,
- Vector? target, Vector arrival, Vector[] sightTargets, bool ignoreSupport)
+ Vector? target, Vector arrival, Vector[] sightTargets, SupportEntries supportEntries)
{
// Last contour has no downstream target, but still needs its exact native
// closest-arrival fallback. Keep target null in the ranker: arrival is not
// a next cut and must not acquire facing-side priority.
var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token,
- ignoreSupport);
+ supportEntries);
var ordered = catalogue.RankTowardNextCut(target, arrival);
// With no lead-out the tool leaves the part from its outside entry. From inside a
// slot or pocket the straight way to the next cut runs back over the part just
@@ -864,6 +885,8 @@ internal static class JointCuttingPlanSearch
// pierce clears the hole itself lead.
if (target is { } next && contour != source.Prepared.PerimeterOrdinal)
ordered = ordered.DepartingClear(source.Prepared, next, token);
+ if (supportEntries != SupportEntries.Steadying && contour == source.Prepared.PerimeterOrdinal)
+ ordered = ordered.OrderByDescending(c => source.Prepared.SupportMarginAt(c.Choice)).ToList();
return ordered;
}
diff --git a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs
index 76bbc9af..8b525980 100644
--- a/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs
+++ b/OpenNest.Engine/CuttingPlanning/SupportTabCapture.cs
@@ -1,3 +1,6 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
@@ -24,11 +27,40 @@ internal static class SupportTabCapture
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.
+ // 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,
- SupportMaterial.FromProgram(ownedClean, location));
+ SupportMaterial.FromProgram(ownedClean, location),
+ SupportAnalyzer.InsideCornerClearance(parameters.TabConfig.Size));
if (!support.IsComplete || support.IsSupported || support.BestTabPoint == null)
return prepared;
return prepared.WithSupportTab(support, location, parameters.TabConfig);
}
+
+ ///
+ /// Notes for a planned support-tabbed part whose tab, at its perimeter entry, cannot hold it
+ /// level or sits in an inside corner. Advisory: they never refuse or downgrade a plan.
+ ///
+ internal static IEnumerable Notes(FixedProgramPlacement planned)
+ {
+ var prepared = planned.Prepared;
+ if (prepared?.Support is not { } support
+ || planned.ContourChoices.FirstOrDefault(c => c.ContourOrdinal == prepared.PerimeterOrdinal) is not { } entry)
+ yield break;
+ var tab = entry.Point + planned.Location;
+ var at = FormattableString.Invariant($"({tab.X:0.####}, {tab.Y:0.####})");
+ if (!support.TabCanSupport)
+ yield return JointCuttingPlanSearch.Finding(planned, null,
+ $"The support grid cannot hold it level, and no single tab can: its tab at {at} steadies it "
+ + "as far as one tab can, but it may still tip. Add tabs by hand or move the part, then replan.");
+ else if (!prepared.HoldsLevelAt(entry))
+ yield return JointCuttingPlanSearch.Finding(planned, null,
+ $"The support grid cannot hold it level, and no lead-in fits where a tab would: its tab at {at} "
+ + "is the steadiest that fits, but it may still tip. Shorten the lead-in in Cutting Settings or "
+ + "space the parts farther apart, then replan.");
+ if (!prepared.IsClearOfInsideCorners(entry))
+ yield return JointCuttingPlanSearch.Finding(planned, null,
+ $"Its support tab at {at} is in an inside corner, because no lead-in fits clear of one; "
+ + "its nub will be hard to grind flush.");
+ }
}
diff --git a/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs b/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs
index 3b3cc9d5..b9d44d3f 100644
--- a/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs
@@ -59,6 +59,107 @@ public class SupportTabPlanningTests
Assert.True(support.MarginWith(entry) >= bed.StabilityMargin);
}
+ // A 10 x 1 strip with a 2 x 0.2 bump on its top edge: inside corners at (4, 1) and (6, 1).
+ private static Program BumpedStrip()
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(0, 1); program.LineTo(4, 1); program.LineTo(4, 1.2); program.LineTo(6, 1.2);
+ program.LineTo(6, 1); program.LineTo(10, 1); program.LineTo(10, 0); program.LineTo(0, 0);
+ return program;
+ }
+
+ [Fact]
+ public void BumpedStrip_IsNotTabbedInAnInsideCorner()
+ {
+ // The bump's base corners tie with the top edge as tab points; the tab keeps clear.
+ var bed = new SupportGrid(2, 4, 0, 2.25, stabilityMargin: 0.1);
+ var plate = new Plate(20, 20) { SupportBed = bed };
+ plate.Parts.Add(new Part(new Drawing("bumped", BumpedStrip()), new Vector(2, 2)));
+
+ var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(0, 0),
+ confirmedParameters: Parameters()));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, string.Join("\n", result.Findings.Select(f => f.Message)));
+ var (entry, cutEnd) = EntryAndCutEnd(Assert.Single(result.ProposedOrder));
+ Assert.Equal(TabSize, entry.DistanceTo(cutEnd), 6);
+ foreach (var corner in new[] { new Vector(6, 3), new Vector(8, 3) })
+ {
+ Assert.True(entry.DistanceTo(corner) >= 2 * TabSize, $"entry {entry}");
+ Assert.True(cutEnd.DistanceTo(corner) >= TabSize, $"cut end {cutEnd}");
+ }
+ }
+
+ [Fact]
+ public void BlockedSteadyingSide_TabsTheSteadiestEntryThatFitsAndReportsIt()
+ {
+ // Only the middle of the top edge steadies the strip by 0.2. Parts 0.1 above it and beside
+ // its upper half leave no room for a lead-in there or at its top corners.
+ var bed = new SupportGrid(2, 4, 0, 2.25, stabilityMargin: 0.2);
+ var (plate, strip) = Strip(bed);
+ plate.Parts.Add(new Part(new Drawing("above", LeadPathValidationTests.Rectangle(0, 0, 14, 2)), new Vector(0, 3.1)));
+ plate.Parts.Add(new Part(new Drawing("left", LeadPathValidationTests.Rectangle(0, 0, 1.9, 0.5)), new Vector(0, 2.5)));
+ plate.Parts.Add(new Part(new Drawing("right", LeadPathValidationTests.Rectangle(0, 0, 1.9, 0.5)), new Vector(12.1, 2.5)));
+
+ var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(7, 0),
+ confirmedParameters: Parameters()));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, string.Join("\n", result.Findings.Select(f => f.Message)));
+ var proposal = result.ProposedOrder.Single(p => ReferenceEquals(p.SourcePart, strip));
+ var (entry, cutEnd) = EntryAndCutEnd(proposal);
+ Assert.Equal(TabSize, entry.DistanceTo(cutEnd), 6);
+ // The steadiest entry that fits: halfway up an end, not the bottom corner nearer the start.
+ var support = SupportAnalyzer.Analyze(bed, plate.Quadrant, strip);
+ Assert.True(support.MarginWith(entry) > support.MarginWith(new Vector(7, 2)), $"entry {entry}");
+ Assert.True(entry.Y > 2.5 - 1e-9, $"entry {entry}");
+ Assert.True(support.MarginWith(entry) > support.MarginWith(new Vector(12, 2)), $"entry {entry}");
+ Assert.False(support.HoldsLevelWithTabAt(entry));
+ Assert.Contains(result.Findings, f => ReferenceEquals(f.SourcePart, strip)
+ && f.Message.Contains("no lead-in fits where a tab would"));
+ }
+
+ [Fact]
+ public void PartNoSingleTabCanHold_IsTabbedAndReported()
+ {
+ // The grid's rows run either side of the strip: nothing under it, so one tab can only balance it.
+ var bed = new SupportGrid(1, 1, 0, 0.9, stabilityMargin: 0.1);
+ var plate = new Plate(20, 20) { SupportBed = bed };
+ var strip = new Part(new Drawing("strip", LeadPathValidationTests.Rectangle(0, 0, 10, 0.8)), new Vector(2, 2));
+ plate.Parts.Add(strip);
+
+ var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(0, 0),
+ confirmedParameters: Parameters()));
+
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ var (entry, cutEnd) = EntryAndCutEnd(Assert.Single(result.ProposedOrder));
+ Assert.Equal(TabSize, entry.DistanceTo(cutEnd), 6);
+ var note = Assert.Single(result.Findings);
+ Assert.Same(strip, note.SourcePart);
+ Assert.Contains("no single tab can", note.Message);
+ }
+
+ [Fact]
+ public void FallbackCatalogue_KeepsClearOfInsideCornersBeforeOfferingEveryEntry()
+ {
+ var bed = new SupportGrid(2, 4, 0, 0.25, stabilityMargin: 0.1);
+ var program = BumpedStrip();
+ var clearance = SupportAnalyzer.InsideCornerClearance(TabSize);
+ var support = SupportAnalyzer.Analyze(bed, 1, SupportMaterial.FromProgram(program, Vector.Zero), clearance);
+ var prepared = PreparedContours.Capture(program, Parameters())
+ .WithSupportTab(support, Vector.Zero, new NormalTab { Size = TabSize });
+ var corners = new[] { new Vector(4, 1), new Vector(6, 1) };
+ bool NearACorner(ContourEntryCandidate c) => corners.Any(corner => c.Choice.Point.DistanceTo(corner) < clearance);
+
+ var clear = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal,
+ supportEntries: SupportEntries.ClearOfInsideCorners);
+ var any = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal,
+ supportEntries: SupportEntries.Any);
+
+ Assert.Contains(any, NearACorner);
+ Assert.DoesNotContain(clear, NearACorner);
+ Assert.Equal(any.Count(c => !NearACorner(c)), clear.Count);
+ }
+
[Fact]
public void SupportedPart_IsNotTabbed()
{
diff --git a/OpenNest.Tests/Support/SupportAnalyzerTests.cs b/OpenNest.Tests/Support/SupportAnalyzerTests.cs
index 77c8d92e..f876e099 100644
--- a/OpenNest.Tests/Support/SupportAnalyzerTests.cs
+++ b/OpenNest.Tests/Support/SupportAnalyzerTests.cs
@@ -89,6 +89,54 @@ public class SupportAnalyzerTests
Assert.False(support.AcceptsTabAt(new Vector(0, 0)));
}
+ // The 10 x 1 strip with a 2 x 0.2 bump on its top edge: inside corners at (4, 1) and (6, 1).
+ private static SupportMaterial BumpedStrip() =>
+ SupportMaterial.FromPolygons(new[]
+ {
+ new Vector(0, 0), new Vector(0, 1), new Vector(4, 1), new Vector(4, 1.2),
+ new Vector(6, 1.2), new Vector(6, 1), new Vector(10, 1), new Vector(10, 0),
+ });
+
+ [Fact]
+ public void BumpedStrip_EquallyGoodTabPoints_KeepTheTabOutOfTheInsideCorners()
+ {
+ // Along the top edge the margin is held by the contact row, so the inside corners tie
+ // with the edge around them and, as vertices, used to be found first.
+ var grid = new SupportGrid(2, 4, 0, 0.25, stabilityMargin: 0.1);
+
+ var support = SupportAnalyzer.Analyze(grid, 1, BumpedStrip(), insideCornerClearance: 0.25);
+
+ Assert.Equal(new[] { new Vector(4, 1), new Vector(6, 1) }, support.InsideCorners.OrderBy(c => c.X));
+ Assert.True(support.TabCanSupport);
+ Assert.Equal(support.MarginWith(new Vector(4, 1)), support.BestTabMargin, 9);
+ var best = support.BestTabPoint!.Value;
+ Assert.True(best.DistanceTo(new Vector(4, 1)) > 0.5 && best.DistanceTo(new Vector(6, 1)) > 0.5, best.ToString());
+ Assert.False(support.AcceptsTabAt(new Vector(4, 1)));
+ Assert.False(support.AcceptsTabAt(new Vector(6.2, 1)));
+ Assert.True(support.AcceptsTabAt(new Vector(6.3, 1)));
+ }
+
+ [Fact]
+ public void InsideCorners_CountAChordedFilletByItsWholeTurnAndIgnoreShallowBends()
+ {
+ // An L whose inside corner is a quarter-round fillet chorded into 6 small turns, and a
+ // 10 degree inward bend along its bottom edge.
+ var outline = new List { new(0, 0), new(5, 0.4), new(10, 0), new(10, 2) };
+ for (var i = 0; i <= 6; i++)
+ {
+ var angle = -System.Math.PI / 2 - i * System.Math.PI / 12;
+ outline.Add(new Vector(3 + System.Math.Cos(angle), 3 + System.Math.Sin(angle)));
+ }
+ outline.Add(new Vector(2, 10));
+ outline.Add(new Vector(0, 10));
+
+ var corners = SupportAnalyzer.InsideCorners(outline);
+
+ Assert.Equal(7, corners.Length);
+ Assert.All(corners, c => Assert.Equal(1, c.DistanceTo(new Vector(3, 3)), 9));
+ Assert.Equal(corners, SupportAnalyzer.InsideCorners(Enumerable.Reverse(outline).ToList()).OrderBy(outline.IndexOf));
+ }
+
[Theory]
[InlineData(1, 1, 1)]
[InlineData(2, -1, 1)]
diff --git a/OpenNest/Controls/SupportOverlay.cs b/OpenNest/Controls/SupportOverlay.cs
index 79a3dab8..2cb673b1 100644
--- a/OpenNest/Controls/SupportOverlay.cs
+++ b/OpenNest/Controls/SupportOverlay.cs
@@ -27,7 +27,7 @@ namespace OpenNest.Controls
private readonly ConditionalWeakTable cache = new();
private sealed record Entry(CNC.Program Program, Vector Location, double Rotation, SupportBed Bed, int Quadrant,
- PartSupport Support);
+ double Clearance, PartSupport Support);
public void Draw(Graphics g, PlateView view)
{
@@ -42,11 +42,13 @@ namespace OpenNest.Controls
using var supportedPen = new Pen(SupportedColor, 2f);
using var tippingPen = new Pen(TippingColor, 2f);
using var tabBrush = new SolidBrush(TabColor);
+ // Tab points keep clear of inside corners as Plan Cutting's do.
+ var clearance = SupportAnalyzer.InsideCornerClearance(plate.CuttingParameters?.TabConfig?.Size ?? 0);
foreach (var part in plate.Parts)
{
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff)
continue;
- var support = Analyze(part, bed, plate.Quadrant);
+ var support = Analyze(part, bed, plate.Quadrant, clearance);
if (!support.IsComplete)
continue;
@@ -58,14 +60,15 @@ namespace OpenNest.Controls
}
}
- private PartSupport Analyze(Part part, SupportBed bed, int quadrant)
+ private PartSupport Analyze(Part part, SupportBed bed, int quadrant, double clearance)
{
if (cache.TryGetValue(part, out var entry) && ReferenceEquals(entry.Program, part.Program)
&& entry.Location == part.Location && entry.Rotation == part.Rotation && Equals(entry.Bed, bed)
- && entry.Quadrant == quadrant)
+ && entry.Quadrant == quadrant && entry.Clearance == clearance)
return entry.Support;
- var support = SupportAnalyzer.Analyze(bed, quadrant, part);
- cache.AddOrUpdate(part, new Entry(part.Program, part.Location, part.Rotation, bed, quadrant, support));
+ var support = SupportAnalyzer.Analyze(bed, quadrant, part, clearance);
+ cache.AddOrUpdate(part, new Entry(part.Program, part.Location, part.Rotation, bed, quadrant, clearance,
+ support));
return support;
}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 1498aa97..dd68cb61 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -37,8 +37,9 @@ var result = CuttingPlanService.Plan(snapshot, cancellationToken);
- `preservePartOrder` fixes whole-part order, not eligible internal contour choices.
- With `CuttingParameters.SupportTabs` and a support bed (`CuttingPlanRequest.SupportBed`,
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; see
- [support tabs](support-tabs.md).
+ 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).
- 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 926105af..7834cc90 100644
--- a/docs/support-tabs.md
+++ b/docs/support-tabs.md
@@ -44,9 +44,17 @@ outline point (vertices plus 256 even samples) that maximizes it. `AcceptsTabAt(
any point that reaches the stability margin, or, when no single tab can, only points as good as
the best one found. A tab's bending strength is ignored: it is a support point, not a cantilever.
+A tab never goes in an inside corner, where its nub cannot be ground flush. An inside corner is
+a run of consecutive inward-turning outline vertices that turn at least 30 degrees together, so a
+chorded fillet counts while a shallow bend does not. Tab points keep the analysis's inside-corner
+clearance from those vertices (Plan Cutting and the overlay use twice the tab size; 0 keeps a tab
+off the corner only), and `AcceptsTabAt` rejects closer points. Equally good points usually run
+along a straight edge; of those, `BestTabPoint` is the one farthest from an inside corner. Only
+when every outline point is too close does the clearance give way.
+
View > Draw Support shows the grid (while its points are at least 6 pixels apart), each part's
contacts, and its center of gravity: green when supported, red when it can tip, with an orange
-diamond on a red part's best tab point.
+diamond on a red part's best tab point (keeping the plate's tab-size clearance).
## Support tabs in Plan Cutting
@@ -59,9 +67,18 @@ other parts keep the plate settings, so supported parts are not tabbed.
The emitter tabs a perimeter at its entry: the cut stops short of the entry by the tab size.
A support-tabbed perimeter's automatic entries are therefore limited to catalogue points the
analysis accepts, plus the closest native point to its best tab point (`AutomaticEntryKind.SupportTab`,
-never on a reflex or cusp vertex). Ranking, lead checks and replay are unchanged. If none of
-those entries has a lead-in that fits, the search falls back to the full catalogue and the part
-is still tabbed, wherever a lead-in fits; this fallback is not reported yet.
+never on a reflex or cusp vertex). Ranking, lead checks and replay are 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
+lead-in fits, it keeps every one that holds the part level, or else only the steadiest. The
+open-scrap pierce fallback then tries the last of those catalogues.
+
+Replay notes each planned part whose tab, at its perimeter entry, cannot hold it level (no single
+tab can, or no lead-in fits where one would) or sits in an inside corner
+(`SupportTabCapture.Notes`). The notes are advisory: a Ready plan keeps its status and lists them
+in Plan Cutting's warnings, and the console and MCP summaries print them under the plate.
Not covered yet:
@@ -69,5 +86,5 @@ Not covered yet:
- Locked or ineligible parts keep their programs; the older Assign Lead-ins command and
Auto Tab sizes ignore the bed.
- Only one perimeter tab per part. A part that needs more (one tab cannot reach the stability
- margin) gets the best single tab.
+ margin) gets the best single tab and a note.
- Pre-post verification does not yet warn about unsupported, untabbed parts.