diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index 25c56587..22038dbd 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -491,7 +491,7 @@ public sealed class CuttingPlanProposal + $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated."); else if (RequiresWarningAcceptance) lines.Add((Plates.All(p => p.IsReady || p.HasOnlyFixedLeadWarnings) - ? "Unverified plan available: fixed lead-ins could not be certified. " + ? "Unverified plan available: " + Unverified(Plates.Where(p => !p.IsReady)).Headline + " " : Plates.Any(p => p.Result.IsSafestRouteFallback) ? "Safest-route plan available: no route passed every check within the search limit. " : "Best-effort plan available. ") @@ -509,6 +509,19 @@ public sealed class CuttingPlanProposal return lines; } + // What an unverified plan whose motions all passed asks the operator to review: its fixed + // lead-ins, parts its support bed cannot hold level, or both. + private static (string Headline, string Plate) Unverified(IEnumerable plates) + { + var kinds = plates.SelectMany(p => p.Result.Findings).Select(f => f.Kind).Where(k => k != null).ToHashSet(); + return kinds.Count == 1 && kinds.Contains(PostVerificationKind.PartSupport) + ? ("a part may tip once cut free.", "a part may tip once cut free.") + : kinds.Contains(PostVerificationKind.PartSupport) + ? ("fixed lead-ins could not be certified, and a part may tip once cut free.", + "fixed lead-ins need review, and a part may tip once cut free.") + : ("fixed lead-ins could not be certified.", "fixed lead-ins need review."); + } + private static IEnumerable DescribePlate(CuttingPlanPlateResult plate, string unit, string changeSettings) { var heading = $"Plate {plate.PlateNumber}: "; @@ -541,7 +554,7 @@ public sealed class CuttingPlanProposal if (plate.CanApplyWithWarnings) { yield return heading + (plate.HasOnlyFixedLeadWarnings - ? $"unverified: fixed lead-ins need review. {Count(plate.PartCount, "part")}, " + ? $"unverified: {Unverified([plate]).Plate} {Count(plate.PartCount, "part")}, " + $"{plate.RegeneratedCount} regenerated; every planned motion passed its checks." : result.SafestRoute is { } severity ? $"safest route found, unverified. No route passed every check within the search limit; " diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 3103bfab..bf23e914 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -739,8 +739,16 @@ internal static class JointCuttingPlanSearch { // Placement origins are authored program coordinates, not a downstream cut. // Use the same material-center look-ahead as the retained entry search. + var toward = target ?? node.RoutePosition - source.Location; var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token) - .OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.RoutePosition - source.Location)).ToArray(); + .OrderBy(c => c.Choice.Point.DistanceTo(toward)).ToArray(); + // As in SelectEntries: when no single tab can steady the part, its other entries + // clear of inside corners follow the steadiest. + if (prepared.HasSupportTab && prepared.Support is { TabCanSupport: false }) + ranked = [.. ranked, .. prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, + token: token, supportEntries: SupportEntries.ClearOfInsideCorners) + .Where(c => !ranked.Any(r => r.Choice.Point.DistanceTo(c.Choice.Point) <= OpenNest.Math.Tolerance.Epsilon)) + .OrderBy(c => c.Choice.Point.DistanceTo(toward))]; perimeters = ranked.Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear) .Select(c => c.Choice).ToArray(); if (perimeters.Length == 0) @@ -1260,6 +1268,25 @@ internal static class JointCuttingPlanSearch selection = Steadiest(source.Prepared, ContourEntrySelection.Select(ordered, candidate => Evaluate(source, candidate), snapshot.MaxEntries, token)); } + // When no single tab can steady the part, its steadiest entry is only a preference: + // the other entries clear of inside corners follow it, steadiest first, so a part + // boxed in by its neighbors can still be left toward the next cut. + if (contour == source.Prepared.PerimeterOrdinal && source.Prepared.HasSupportTab + && source.Prepared.Support is { TabCanSupport: false } + && (selection.Choices.Count != 0 || selection.UncertainChoices.Count != 0)) + { + var others = ContourEntrySelection.Select( + RankedEntries(source, contour, target, arrival, sightTargets, SupportEntries.ClearOfInsideCorners), + candidate => Evaluate(source, candidate), snapshot.MaxEntries, token); + var kept = selection.Choices.Concat(selection.UncertainChoices).ToArray(); + bool IsNew(ContourChoice choice) => + !kept.Any(k => k.Point.DistanceTo(choice.Point) <= OpenNest.Math.Tolerance.Epsilon); + selection = selection with + { + Choices = selection.Choices.Concat(others.Choices.Where(IsNew)).ToArray(), + UncertainChoices = selection.UncertainChoices.Concat(others.UncertainChoices.Where(IsNew)).ToArray(), + }; + } // No configured lead-in fits a cutout: pierce where eroding it leaves room. string erodedReason = null; // Entries the check could not settle are no fit either: a clear pierce outranks them. diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs index 62dad1fd..22e42864 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanMessageTests.cs @@ -93,6 +93,25 @@ public class CuttingPlanMessageTests Assert.True(part.LeadInsLocked); Assert.Same(program, part.Program); } + [Fact] + public void Describe_OnlyAnUnsteadyPart_SaysItMayTipNotThatLeadsNeedReview() + { + // Nothing of the grid runs under the strip, so no single tab can hold it level. + var plate = new Plate(20, 20) { SupportBed = new OpenNest.Support.SupportGrid(1, 1, 0, 0.9, stabilityMargin: 0.1) }; + plate.Parts.Add(new Part(new Drawing("strip", LeadPathValidationTests.Rectangle(0, 0, 10, 0.8)), new Vector(2, 2))); + var settings = ExplicitContourTests.Parameters(); + settings.SupportTabs = true; + settings.TabConfig = new NormalTab { Size = 0.2 }; + + var proposal = CuttingPlanBatch.Capture([plate], settings, false).Plan(); + var text = string.Join("\n", proposal.Describe("in")); + + Assert.True(proposal.RequiresWarningAcceptance, text); + Assert.StartsWith("Unverified plan available: a part may tip once cut free.", text); + Assert.Contains("Plate 1: unverified: a part may tip once cut free.", text); + Assert.DoesNotContain("fixed lead-ins", text); + } + [Fact] public void Describe_LockedLeadHitsNeighbor_WarnsWithAdviceAndNeedsAcceptance() { diff --git a/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs b/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs index e7e2a9f0..c2175ae3 100644 --- a/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs +++ b/OpenNest.Tests/CuttingPlanning/SupportTabPlanningTests.cs @@ -141,6 +141,62 @@ public class SupportTabPlanningTests Assert.Contains("no single tab can steady it", warning.Message); } + [Fact] + public void StripWithALeadOut_IsTabbedAndPlanned() + { + // The lead-out leaves the tabbed perimeter from its trimmed end, away from the tab: the + // open-cut proof shows it cuts no gap, as pre-post review does. + var bed = new SupportGrid(2, 4, 0, 2.25, stabilityMargin: 0.1); + var (plate, _) = Strip(bed); + var parameters = Parameters(); + parameters.ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 60 }; + + 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 proposal = Assert.Single(result.ProposedOrder); + var (entry, cutEnd) = EntryAndCutEnd(proposal); + Assert.Equal(TabSize, entry.DistanceTo(cutEnd), 6); + var motions = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, null, default).Motions; + Assert.Equal(LayerType.Leadout, motions[^1].Layer); + } + + // A 10 x 0.8 strip with a 9 x 0.4 slot near its bottom edge: no rapid from its top edge + // reaches the plate below it without crossing the slot once that is cut. + private static Program SlottedStrip() + { + var program = new Program(); + program.Merge(LeadPathValidationTests.Rectangle(0.5, 0.1, 9.5, 0.5)); + program.Merge(LeadPathValidationTests.Rectangle(0, 0, 10, 0.8)); + return program; + } + + [Fact] + public void PartNoSingleTabCanHold_LeavesFromAnotherEntryWhenItsSteadiestIsBoxedIn() + { + // No grid row runs under the slotted strip, so no tab can hold it; its steadiest tab + // point is on its top edge, across the slot from the part cut after it. + var bed = new SupportGrid(1, 1, 0, 0.9, stabilityMargin: 0.1); + var plate = new Plate(20, 20) { SupportBed = bed }; + var slotted = new Part(new Drawing("slotted", SlottedStrip()), new Vector(2, 2)); + plate.Parts.Add(slotted); + plate.Parts.Add(new Part(new Drawing("below", LeadPathValidationTests.Rectangle(0, 0, 10, 0.8)), new Vector(2, 0.6))); + var support = SupportAnalyzer.Analyze(bed, plate.Quadrant, slotted); + Assert.False(support.TabCanSupport); + Assert.True(support.BestTabPoint!.Value.Y > 2.4, $"best tab {support.BestTabPoint}"); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(0, 0), + confirmedParameters: Parameters(), preservePartOrder: true)); + + Assert.True(result.Status == CuttingPlanStatus.BestEffort, string.Join("\n", result.Findings.Select(f => f.Message))); + var (entry, cutEnd) = EntryAndCutEnd(result.ProposedOrder.Single(p => ReferenceEquals(p.SourcePart, slotted))); + Assert.Equal(TabSize, entry.DistanceTo(cutEnd), 6); + Assert.True(entry.Y < 2.4, $"entry {entry}"); + Assert.Contains(result.Findings, f => ReferenceEquals(f.SourcePart, slotted) + && f.Kind == PostVerificationKind.PartSupport && f.Message.Contains("no single tab can steady it")); + } + [Fact] public void FallbackCatalog_KeepsClearOfInsideCornersBeforeOfferingEveryEntry() { diff --git a/docs/support-tabs.md b/docs/support-tabs.md index a6b007d3..422e02ec 100644 --- a/docs/support-tabs.md +++ b/docs/support-tabs.md @@ -131,6 +131,13 @@ back first to every catalog entry clear of inside corners, then to every entry 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 catalogs. +When no single tab can steady the part (it rests on no contacts, or too few), the steadiest +entry is only a preference: its other fitting entries clear of inside corners follow it, +steadiest first, in the search and in contour recovery. A part boxed in by its neighbors, such as +the upper of two strips nested in a cutout, can then be left toward the next cut instead of +blocking the plan. It is still tabbed and still warned about as one that may tip. A part a tab +can hold level keeps to its steadying entries. + A drawing pin on the perimeter wins: its entry, leads and tab are kept as pinned, and the analysis is only carried for review. Replay and best-effort plans (`SupportTabCapture.Review`) warn with `PostVerificationKind.PartSupport` whenever an unsupported part's emitted perimeter is