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.