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