From a18f8c9b56eec6acb3faa45071eead20e0874d4c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 16 Oct 2026 15:09:54 -0400 Subject: [PATCH] feat(engine): pinned contours use the fixed-lead warning path A drawing-pinned contour is now a fixed emission like a locked program. A regenerated placement maps its execution motions onto the emitted contour choices (one group per choice after the program's scribes, opened by its rapids) and owns as fixed the lead-in, cut, lead-out and tab of every pinned contour; the rapids between contours, including the one into a pinned contour, stay the planner's and strictly checked. If the motions do not split cleanly into one group per choice, nothing is treated as fixed. So a pinned lead that meets another part's material or its own part's no longer rejects the candidate: the feasibility precheck accepts a pinned entry as given, the search keeps it in place, and replay reports a FixedLead warning naming the part, cutting contour, pin and the part hit, with the advice to increase spacing or switch off drawing pins for the part. The plate is UNVERIFIED and needs acceptance; the lead is never shortened and the entry never moved. The unpinned control and the same plate with pins off are Ready. Co-Authored-By: Claude Opus 5.5 --- .../ContourEntryFeasibility.cs | 4 + .../CuttingPlanning/CuttingPlanModels.cs | 62 ++++++- .../CuttingPlanning/CuttingPlanService.cs | 6 +- .../CuttingPlanning/FixedLeadWarnings.cs | 20 ++- .../CuttingPlanning/JointCuttingPlanSearch.cs | 9 +- .../CuttingPlanning/PinnedLeadWarningTests.cs | 152 ++++++++++++++++++ docs/cutting-planner.md | 11 +- 7 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/PinnedLeadWarningTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs index 95c9df39..ee88fd48 100644 --- a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs +++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs @@ -88,6 +88,10 @@ public sealed class ContourEntryFeasibility // substitute for the isolated emitter's owned-choice check. if (!ReferenceEquals(choice.Owner, prepared)) return new(ContourFeasibilityStatus.Incomplete, "Foreign contour choice."); + // A drawing pin's entry is a fixed emission, accepted as given: it is never filtered + // out here. The emitted program's checks report its violations as warnings. + if (prepared.PinnedEntry(choice.ContourOrdinal) == choice) + return new(ContourFeasibilityStatus.Clear, null); var key = new Key(choice.ContourOrdinal, choice.EntityOrdinal, choice.Point.X, choice.Point.Y, nodeContext ?? string.Empty); if (cache.TryGetValue(key, out var known)) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 02cb44fc..16051954 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -133,17 +133,71 @@ public sealed class FixedProgramPlacement /// /// The motions of (this placement's own program read with its /// incoming rapid first) that are a fixed emission, accepted as given in regeneration mode: - /// everything a locked program does after the planner's rapid into it. Null when nothing is - /// fixed. Cutoffs and unlocked programs a caller left ineligible keep their strict checks. + /// everything a locked program does after the planner's rapid into it, and the lead-in, cut, + /// lead-out and tab of every drawing-pinned contour in a regenerated program, whose + /// (default: this proposal's) were emitted. The rapids between + /// contours stay the planner's. Null when nothing is fixed. Cutoffs and unlocked programs a + /// caller left ineligible keep their strict checks. /// - internal Func FixedMotions(OwnedExecution execution) + internal Func FixedMotions(OwnedExecution execution, IReadOnlyList choices = null) { - if (IsCutOff || Prepared != null || !LeadInsLocked) + if (IsCutOff) + return null; + if (Prepared != null) + { + var pins = PinsByMotion(execution, choices); + return pins == null ? null : motion => pins[motion] != null; + } + if (!LeadInsLocked) return null; var opensWithRapid = execution.Motions.Count != 0 && execution.Motions[0].Rapid; return motion => motion > 0 || !opensWithRapid; } + /// + /// For each motion of a regenerated program emitted from , the + /// one-based drawing pin whose contour it cuts (lead-ins, cuts, lead-outs; never the rapid + /// into the contour), or null. Null overall when no choice is pinned, or when the motions do + /// not split into one group per choice after the scribes, so nothing is treated as fixed. + /// + internal int?[] PinsByMotion(OwnedExecution execution, IReadOnlyList choices = null) + { + choices ??= ContourChoices; + if (Prepared == null || !choices.Any(c => Prepared.IsPinned(c.ContourOrdinal))) + return null; + var pins = new int?[execution.Motions.Count]; + var group = -1; + var opening = true; // Inside the rapids that open a group. + int? pin = null; + for (var index = 0; index < execution.Motions.Count; index++) + { + var motion = execution.Motions[index]; + if (motion.Rapid) + { + if (!opening) + opening = true; + continue; + } + if (opening) + { + opening = false; + // Scribes are emitted first, before any contour. + if (motion.Layer == LayerType.Scribe) + { + pin = null; + if (group >= 0) + return null; + continue; + } + if (++group >= choices.Count) + return null; + pin = Prepared.PinNumberOf(choices[group].ContourOrdinal); + } + pins[index] = pin; + } + return group == choices.Count - 1 ? pins : null; + } + internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList choices, System.Threading.CancellationToken token = default) { diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 81996c3f..52be6235 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -369,7 +369,7 @@ public static class CuttingPlanService return new(CuttingPlanStatus.UnsupportedGeometry, findings: [JointCuttingPlanSearch.Finding(source, PostVerificationKind.Incomplete, ex.Message)], expansions: expansions); } - var isFixed = source.FixedMotions(execution); + var isFixed = proposal.FixedMotions(execution); var (strict, fixedFindings) = checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, isFixed, token); findings.AddRange(JointCuttingPlanSearch.Map(snapshot, strict)); @@ -380,7 +380,9 @@ public static class CuttingPlanService if (!lead.IsComplete || !lead.IsClear) findings.Add(JointCuttingPlanSearch.Finding(source, lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason)); - warnings.AddRange(fixedLeads.Select(f => FixedLeadWarnings.FromLead(source, execution, f, materialOwners))); + var pins = proposal.PinsByMotion(execution); + warnings.AddRange(fixedLeads.Select(f => FixedLeadWarnings.FromLead(source, execution, f, materialOwners, + pins?[f.Motion]))); // A locked program's coverage (a tab gap, a partial or reversed cut) has no certified // selection to compare with; a regenerated program's never escapes the strict rule. if (!complete && source.Prepared == null && isFixed != null) diff --git a/OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs b/OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs index f6157b78..c0c7cdf2 100644 --- a/OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs +++ b/OpenNest.Engine/CuttingPlanning/FixedLeadWarnings.cs @@ -15,22 +15,30 @@ internal static class FixedLeadWarnings private const string LockedAdvice = "The locked program is kept as given. Increase spacing, or edit or " + "unlock this part's lead-ins and replan."; + private const string PinnedAdvice = "The pinned contour is cut exactly as its drawing says. Increase spacing " + + "or switch off drawing pins for this part (right-click > Drawing Pins Off)."; + /// A replay-checker finding (rapid crossing or retention) inside the fixed emission. internal static CuttingPlanFinding FromChecker(CuttingPlanSnapshot snapshot, FixedProgramPlacement source, PostVerificationFinding finding) { var other = finding.OtherPartNumber is int part ? snapshot.Placements[part - 1] : null; + var owner = source.Prepared == null ? "Locked lead-ins" : "Drawing pins"; + var advice = source.Prepared == null ? LockedAdvice : PinnedAdvice; return new(source.SourceOrdinal, source.SourcePart, other?.SourceOrdinal, other?.SourcePart, - PostVerificationKind.FixedLead, $"Locked lead-ins: {finding.Message} {LockedAdvice}") + PostVerificationKind.FixedLead, $"{owner}: {finding.Message} {advice}") { Witness = finding, FixedLead = new(null, null, other?.SourceOrdinal) }; } - /// A lead motion of the fixed emission that meets material or could not be certified. + /// + /// A lead motion of the fixed emission that meets material or could not be certified; + /// is the drawing pin that fixed it, or null for a locked program. + /// internal static CuttingPlanFinding FromLead(FixedProgramPlacement source, OwnedExecution execution, - FixedLeadFailure failure, IReadOnlyList materialOwners) + FixedLeadFailure failure, IReadOnlyList materialOwners, int? pin) { var contour = FixedLeadReview.ContourAt(execution, failure.Motion); var obstacle = failure.Obstacle is int index ? materialOwners[index] : null; @@ -39,13 +47,15 @@ internal static class FixedLeadWarnings : obstacle != null ? "contacts or enters another part's material" : $"contacts or enters its own part's material ({failure.Result.Reason})"; var motion = execution.Motions[failure.Motion]; + var lead = pin is int number ? $"Drawing pin {number} lead" : "Locked lead"; return new(source.SourceOrdinal, source.SourcePart, obstacle?.SourceOrdinal, obstacle?.SourcePart, - PostVerificationKind.FixedLead, $"Locked lead on cutting contour {contour} {what}. {LockedAdvice}") + PostVerificationKind.FixedLead, $"{lead} on cutting contour {contour} {what}. " + + (pin == null ? LockedAdvice : PinnedAdvice)) { Witness = new(PostVerificationKind.FixedLead, 1, source.SourceOrdinal + 1, obstacle == null ? null : obstacle.SourceOrdinal + 1, failure.Result.Reason ?? what) { Location = motion.Curve?.Midpoint ?? motion.End }, - FixedLead = new(contour, null, obstacle?.SourceOrdinal) + FixedLead = new(contour, pin, obstacle?.SourceOrdinal) }; } diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 5e40aafd..8a7ec245 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -502,7 +502,7 @@ internal static class JointCuttingPlanSearch } // Prefix includes all earlier cuts and scribes. Replay from BEFORE the whole part. var checker = before.Copy(); - if (!Check(source, execution, arrival, checker, attempt)) continue; + if (!Check(source, execution, arrival, checker, attempt, prefix)) continue; var distance = execution.RapidDistanceFrom(arrival); var next = prefix.Length == prepared.Count ? new Node([.. node.Order, source.Propose(program, execution, prefix, token)], @@ -556,7 +556,7 @@ internal static class JointCuttingPlanSearch var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token); var checker = node.Checker.Copy(); CountExpansion(source); - if (!Check(source, execution, node.Position, checker, attempt)) continue; + if (!Check(source, execution, node.Position, checker, attempt, chain)) continue; var proposal = source.Propose(program, execution, chain, token); yield return new(new([.. node.Order, proposal], execution.DeparturePoint, checker, null, node), execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, endpoint, endpoint); @@ -728,13 +728,14 @@ internal static class JointCuttingPlanSearch token.ThrowIfCancellationRequested(); } + // choices: the contours a regenerated execution emits, so its pinned ones are known. private bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival, - ReleasedContourState checker, Attempt attempt) + ReleasedContourState checker, Attempt attempt, IReadOnlyList choices = null) { // A fixed emission is accepted as given and never constrains the search: its own // violations neither reject this candidate nor teach an order rule. Final replay // reports them as warnings. Every planner-chosen motion stays strict. - var isFixed = source.FixedMotions(execution); + var isFixed = source.FixedMotions(execution, choices); var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, isFixed, token).Strict; rejected.AddRange(Map(snapshot, findings)); attempt.NoteCrossings(source.SourceOrdinal, findings); diff --git a/OpenNest.Tests/CuttingPlanning/PinnedLeadWarningTests.cs b/OpenNest.Tests/CuttingPlanning/PinnedLeadWarningTests.cs new file mode 100644 index 00000000..6d50a2b6 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/PinnedLeadWarningTests.cs @@ -0,0 +1,152 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// D1a for drawing pins: a pinned lead that meets material is a named warning on an UNVERIFIED +/// proposal, never relocated or shortened. The rapids between contours stay strict. +/// +public class PinnedLeadWarningTests +{ + [Fact] + public void PinnedLeadIntoNeighbour_IsUnverifiedWithTheRestPlannedAndTheEntryKept() + { + var (plate, pinned, neighbour) = NeighbourFixture(pins: true); + + var proposal = CuttingPlanBatch.Capture([plate], PinnedContourTests.NestSettings(), false).Plan(); + + var planned = Assert.Single(proposal.Plates); + Assert.True(planned.HasOnlyFixedLeadWarnings, string.Join("\n", proposal.Describe("in"))); + var warning = Assert.Single(planned.Result.Findings); + Assert.Equal(PostVerificationKind.FixedLead, warning.Kind); + Assert.Same(pinned, warning.SourcePart); + Assert.Same(neighbour, warning.OtherSourcePart); + Assert.Equal(new FixedLeadWarning(1, 1, 1), warning.FixedLead); + Assert.Equal("Drawing pin 1 lead on cutting contour 1 contacts or enters another part's material. " + + "The pinned contour is cut exactly as its drawing says. Increase spacing or switch off drawing pins " + + "for this part (right-click > Drawing Pins Off).", warning.Message); + // The search never relocates the pinned entry, and the neighbour is planned normally. + var proposed = planned.Result.ProposedOrder; + var placement = Assert.Single(proposed, p => ReferenceEquals(p.SourcePart, pinned)); + Assert.Equal(new Vector(10, 5), Assert.Single(placement.ContourChoices).Point); + var lead = ExecutionMotionReader.Read(placement.CopyProgram(), pinned.Location, null, default).Motions + .Single(m => m.Layer == LayerType.Leadin); + Assert.Equal(1.0, lead.Length, 9); // Not shortened to fit. + Assert.True(Assert.Single(proposed, p => ReferenceEquals(p.SourcePart, neighbour)).IsRegenerated); + Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status); + } + + [Fact] + public void UnpinnedControl_IsReady() + { + var (plate, _, _) = NeighbourFixture(pins: false); + + var proposal = CuttingPlanBatch.Capture([plate], PinnedContourTests.NestSettings(), false).Plan(); + + Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in"))); + } + + [Fact] + public void PinsOffOnThePinnedPart_IsReady() + { + var (plate, pinned, _) = NeighbourFixture(pins: true); + pinned.DrawingPinsOff = true; + + var proposal = CuttingPlanBatch.Capture([plate], PinnedContourTests.NestSettings(), false).Plan(); + + Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in"))); + } + + [Fact] + public void PinnedHoleLeadIntoItsOwnPartsMaterial_Warns() + { + // Two square holes; hole 2's pinned 2.5 lead runs from beyond its right edge. + var program = new Program(); + program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4); program.LineTo(2, 4); program.LineTo(2, 2); + program.MoveTo(4.5, 2); program.LineTo(6.5, 2); program.LineTo(6.5, 4); program.LineTo(4.5, 4); program.LineTo(4.5, 2); + program.MoveTo(0, 0); program.LineTo(0, 10); program.LineTo(10, 10); program.LineTo(10, 0); program.LineTo(0, 0); + var drawing = new Drawing("holes", program); + drawing.CutPins.Add(new ContourCutPin(new Vector(4.5, 3), new LineLeadIn { Length = 2.5, ApproachAngle = 90 }, + new NoLeadOut())); + var part = new Part(drawing, new Vector(1, 1)); + + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(-2, -2), + confirmedParameters: PinnedContourTests.NestSettings())); + + Assert.Equal(CuttingPlanStatus.BestEffort, result.Status); + Assert.True(result.IndependentlyReplayed); + var warning = Assert.Single(result.Findings); + Assert.StartsWith("Drawing pin 1 lead on cutting contour", warning.Message); + Assert.Contains("contacts or enters its own part's material", warning.Message); + Assert.Null(warning.OtherSourcePart); + Assert.Equal(1, warning.FixedLead!.PinNumber); + } + + [Fact] + public void RapidIntoAPinnedContour_StaysStrict() + { + // Holes(): circles at (3, 3) and (7, 3) in a 10 x 10 square; the outside is pinned at + // (10, 3). Cutting the left hole last and leaving from its far side makes the planner's + // rapid to the pinned pierce cross both finished holes: a refusal, never a warning. + var drawing = new Drawing("holes", PreparedContourTests.Holes()); + drawing.CutPins.Add(new ContourCutPin(new Vector(10, 3), new LineLeadIn { Length = 0.25, ApproachAngle = 90 }, + new NoLeadOut())); + var part = new Part(drawing, Vector.Zero); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], new Vector(-2, -2), + confirmedParameters: PinnedContourTests.NestSettings())); + var source = snapshot.Placements[0]; + var prepared = source.Prepared!; + var choices = new[] { prepared.Entry(1, 0, new Vector(6, 3)), prepared.Entry(0, 0, new Vector(2, 3)), + prepared.PinnedEntry(2)! }; + var program = prepared.Emit(choices); + var proposal = source.Propose(program, ExecutionMotionReader.Read(program, Vector.Zero, new Vector(-2, -2), default), + choices); + + var replay = CuttingPlanService.ReplayPrograms(snapshot, [proposal], 0, default); + + Assert.Equal(CuttingPlanStatus.ConstraintConflict, replay.Status); + Assert.Contains(replay.Findings, f => f.Kind == PostVerificationKind.RapidCrossing); + Assert.DoesNotContain(replay.Findings, f => f.Kind == PostVerificationKind.FixedLead); + } + + [Fact] + public void FeasibilityProbe_AcceptsAPinnedEntryAsGiven() + { + var (plate, pinned, _) = NeighbourFixture(pins: true); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: PinnedContourTests.NestSettings())); + var source = snapshot.Placements.Single(p => ReferenceEquals(p.SourcePart, pinned)); + var adapter = new ContourEntryFeasibility(source.Prepared!, source.Location, source.Material!, + snapshot.Placements.Select(p => p.Material!).ToArray()); + + var verdict = adapter.Check(source.Prepared!.PinnedEntry(0)!); + + Assert.True(verdict.IsClear); + Assert.Equal(0, adapter.EvaluationCount); + } + + /// + /// A 10 x 10 square pinned at its right edge midpoint with a 1.0 straight lead, and a + /// 2 x 2 neighbour 0.3 to its right that the lead runs through. + /// + private static (Plate Plate, Part Pinned, Part Neighbour) NeighbourFixture(bool pins) + { + var drawing = new Drawing("pinned", ExplicitContourTests.Square(false)); + if (pins) + drawing.CutPins.Add(new ContourCutPin(new Vector(10, 5), new LineLeadIn { Length = 1.0, ApproachAngle = 90 }, + new NoLeadOut())); + var pinned = new Part(drawing, Vector.Zero); + var neighbour = new Part(new Drawing("neighbour", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), + new Vector(10.3, 4)); + var plate = new Nest().CreatePlate(); + plate.Parts.Add(pinned); + plate.Parts.Add(neighbour); + return (plate, pinned, neighbour); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 26d75c50..3300cd4e 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -356,7 +356,8 @@ its retention gap, so confirmed-parameters planning refuses it in a planned cont With confirmed parameters, a locked program (Place Lead-in locks, PEP-imported locked parts) is a fixed emission: everything it does after the planner's rapid into it is -accepted as given (user decision D1a, 2026-10-14). Its violations are not refusals: +accepted as given (user decision D1a, 2026-10-14). So is each drawing-pinned contour of a +regenerated part (see [drawing pins](#drawing-pins)). Their violations are not refusals: - Its lead-ins and lead-outs meeting another part's material or its own, a rapid inside it crossing a completed contour, a lead-out after its own open contour, and coverage @@ -400,6 +401,14 @@ clean contours with `CutPinResolver` and prepares them with "Drawing pin 2 no longer matches the geometry; planned with nest settings.", which makes the plate UNVERIFIED. A tab pinned on an internal contour is not honoured either: only the outside is tabbed. +- A pinned contour's lead-in, cut, lead-out and tab are a fixed emission like a locked program + (see [fixed emissions](#fixed-emissions-warn)): a pinned lead that meets another part's + material or its own part's (an already-cut hole included) is a `FixedLead` warning naming + the part, cutting contour, pin and the part hit, with the advice "Increase spacing or switch + off drawing pins for this part (right-click > Drawing Pins Off)". The entry is never moved + and the lead never shortened to fit; the feasibility precheck accepts it as given. The + rapids between contours, including the one into a pinned contour, stay strict. Plan + Cutting does not model clamps, for pinned leads or any other. - A placement with `Part.DrawingPinsOff` ignores its drawing's pins and plans entirely from the plate settings, with no warning (D1's per-part escape hatch). `Part.HasActiveDrawingPins` is the shared test for "pins apply here".