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

@@ -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.");
}
}