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".