feat(planning): keep support tabs out of inside corners and report weak tabs

The best tab point used to land in inside corners: equally good points
run along a straight edge, vertices were tried first and the first tie
won. Operators cannot grind a nub flush there.

- SupportAnalyzer finds inside corners (inward-turning vertex runs of at
  least 30 degrees, so chorded fillets count) and keeps tab points a
  clearance from them; of equally good points it picks the one farthest
  from an inside corner. Plan Cutting and the overlay use twice the tab
  size; AcceptsTabAt rejects closer points
- When no steadying entry has a lead-in that fits, the search falls back
  to entries clear of inside corners, then to every entry, each ordered
  by how well the tab steadies the part, keeping only the steadiest that
  fit
- Replay notes parts whose planned tab cannot hold them level or sits in
  an inside corner; the notes are advisory and leave a Ready plan Ready
- Console/MCP plate summaries print a ready plan's notes
- docs/support-tabs.md and cutting-planner notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 18:18:16 -04:00
1 parent a7c0747e11
commit f51e5aefdd
13 files changed
+435 -52

No files matched your search

@@ -44,6 +44,22 @@ internal enum AutomaticEntryKind
SupportTab = 8,
}
/// <summary>
/// Which automatic entries a perimeter tabbed for support offers (see
/// <see cref="PreparedContours.WithSupportTab"/>); every other contour offers its whole catalogue.
/// </summary>
internal enum SupportEntries
{
/// <summary>Entries whose tab steadies the part, clear of inside corners, plus its best tab point.</summary>
Steadying,
/// <summary>Every entry clear of inside corners: the fallback when no steadying entry fits.</summary>
ClearOfInsideCorners,
/// <summary>Every entry: the last fallback, when nothing clear of inside corners fits.</summary>
Any,
}
/// <summary>
/// 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
@@ -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);
}
/// <summary>
@@ -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<ContourEntryCandidate>()
: PreferredCandidates(shape, contourOrdinal, token);
all.AddRange(FallbackCandidates(shape, contourOrdinal, lookAhead, token));
return SupportFiltered(contourOrdinal, all, ignoreSupport);
return SupportFiltered(contourOrdinal, all, supportEntries);
}
/// <summary>
/// Whether this preparation tabs its perimeter for support (see <see cref="WithSupportTab"/>):
/// 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.
/// </summary>
internal bool HasSupportTab => support != null;
/// <summary>
/// The support margin with this perimeter's tab at <paramref name="choice"/> (see
/// <see cref="PartSupport.MarginWith"/>); negative infinity when it is not tabbed for support.
/// </summary>
internal double SupportMarginAt(ContourChoice choice) =>
support?.MarginWith(choice.Point + supportOrigin) ?? double.NegativeInfinity;
/// <summary>Whether a support tab at <paramref name="choice"/> holds the part level (see <see cref="PartSupport.HoldsLevelWithTabAt"/>).</summary>
internal bool HoldsLevelAt(ContourChoice choice) =>
support != null && support.HoldsLevelWithTabAt(choice.Point + supportOrigin);
/// <summary>Whether a support tab at <paramref name="choice"/> keeps the inside-corner clearance.</summary>
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<ContourEntryCandidate> SupportFiltered(int contourOrdinal,
List<ContourEntryCandidate> candidates, bool ignoreSupport)
List<ContourEntryCandidate> 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))
+134 -15
View File
@@ -14,19 +14,38 @@ namespace OpenNest.Support
/// most zero. With a <see cref="SupportBed.RegistrationTolerance"/>, 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.
/// </summary>
public static class SupportAnalyzer
{
/// <summary>Points sampled along the outline, besides its vertices, to find the best tab point.</summary>
public const int TabSamples = 256;
public static PartSupport Analyze(SupportBed bed, int quadrant, Part part) =>
Analyze(bed, quadrant, SupportMaterial.FromPart(part));
/// <summary>
/// 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.
/// </summary>
public const double InsideCornerAngle = System.Math.PI / 6;
public static PartSupport Analyze(SupportBed bed, int quadrant, SupportMaterial material)
/// <summary>How far a tab of size <paramref name="tabSize"/> keeps from an inside corner: its uncut span and as much again.</summary>
public static double InsideCornerClearance(double tabSize) => tabSize > 0 ? 2 * tabSize : 0;
/// <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(SupportBed bed, int quadrant, Part part, double insideCornerClearance = 0) =>
Analyze(bed, quadrant, SupportMaterial.FromPart(part), insideCornerClearance);
/// <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(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<Vector> corners, Vector point)
{
var clear = double.PositiveInfinity;
foreach (var corner in corners)
clear = System.Math.Min(clear, corner.DistanceTo(point));
return clear;
}
/// <summary>
/// The outline vertices that lie in an inside corner: each run of consecutive vertices
/// turning inward whose turns add up to at least <see cref="InsideCornerAngle"/>.
/// </summary>
internal static Vector[] InsideCorners(IReadOnlyList<Vector> outline)
{
var count = outline.Count;
if (count < 3)
return Array.Empty<Vector>();
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<Vector>();
// 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<Vector>();
var corners = new List<Vector>();
var run = new List<Vector>();
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();
}
/// <summary>The worst margin of <paramref name="center"/> over every shifted hull, each with <paramref name="tab"/> added.</summary>
@@ -194,7 +293,8 @@ namespace OpenNest.Support
}
internal PartSupport(Vector centerOfGravity, IReadOnlyList<Vector> contacts, double margin,
double requiredMargin, IReadOnlyList<Vector[]> hulls, Vector? bestTabPoint, double bestTabMargin)
double requiredMargin, IReadOnlyList<Vector[]> hulls, Vector? bestTabPoint, double bestTabMargin,
IReadOnlyList<Vector> 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;
}
/// <summary>True when the part was analyzed; otherwise <see cref="Reason"/> says why not.</summary>
@@ -215,6 +317,12 @@ namespace OpenNest.Support
/// <summary>Every bed contact under the part at the bed's nominal position.</summary>
public IReadOnlyList<Vector> Contacts { get; }
/// <summary>The outline vertices in an inside corner (see <see cref="SupportAnalyzer.InsideCorners"/>).</summary>
public IReadOnlyList<Vector> InsideCorners { get; } = Array.Empty<Vector>();
/// <summary>How far an accepted tab point keeps from an inside corner.</summary>
public double InsideCornerClearance { get; }
/// <summary>The worst signed distance of the center of gravity inside the contacts' hull.</summary>
public double Margin { get; } = double.NegativeInfinity;
@@ -224,7 +332,10 @@ namespace OpenNest.Support
/// <summary>True when the bed alone holds the part level by at least the required margin.</summary>
public bool IsSupported => IsComplete && Meets(Margin);
/// <summary>The outline point where one tab steadies the part most, or null when not analyzed.</summary>
/// <summary>
/// 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.
/// </summary>
public Vector? BestTabPoint { get; }
/// <summary>The margin with a tab at <see cref="BestTabPoint"/>.</summary>
@@ -238,17 +349,25 @@ namespace OpenNest.Support
IsComplete ? SupportAnalyzer.MarginWith(CenterOfGravity, hulls, tab) : double.NegativeInfinity;
/// <summary>
/// Whether a tab at <paramref name="tab"/> 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 <paramref name="tab"/> 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.
/// </summary>
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;
}
/// <summary>Whether a tab at <paramref name="tab"/> holds the part level by the required margin.</summary>
public bool HoldsLevelWithTabAt(Vector tab) => IsComplete && Meets(MarginWith(tab));
/// <summary>Whether <paramref name="point"/> keeps <see cref="InsideCornerClearance"/> from every inside corner.</summary>
public bool IsClearOfInsideCorners(Vector point) =>
SupportAnalyzer.IsClear(SupportAnalyzer.Clearance(InsideCorners, point), InsideCornerClearance);
private bool Meets(double margin) => margin > 0 && margin >= RequiredMargin;
}
}
@@ -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.");
@@ -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;
}
@@ -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);
}
@@ -803,18 +803,21 @@ internal static class JointCuttingPlanSearch
private IReadOnlyList<ContourChoice> 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<ContourEntryCandidate> 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;
}
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
internal static IEnumerable<CuttingPlanFinding> 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.");
}
}
@@ -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()
{
@@ -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<Vector> { 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)]
+9 -6
View File
@@ -27,7 +27,7 @@ namespace OpenNest.Controls
private readonly ConditionalWeakTable<Part, Entry> 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;
}
+3 -2
View File
@@ -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.
+22 -5
View File
@@ -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.