diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
index fa4073a3..110d5b7f 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
@@ -33,6 +33,9 @@ internal enum AutomaticEntryKind
/// Tier 3 (fallback): one of the eight compass points of a whole circle.
CircleCompass = 6,
+
+ /// A drawing pin's own entry: the one and only candidate of a pinned contour.
+ Pinned = 7,
}
///
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 83a83819..90f8e589 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -23,17 +23,106 @@ public sealed class PreparedContours
private readonly Shape[] shapes;
private readonly CuttingParameters parameters;
private readonly List scribes;
+ // By contour ordinal; all null without pins. A pinned contour has exactly one entry and
+ // its own owned settings (the pin's lead-in, lead-out and tab).
+ private readonly ContourChoice[] pinned;
+ private readonly CuttingParameters[] contourParameters;
+ private readonly int[] pinNumbers;
private PreparedContours(Shape[] shapes, List scribes, CuttingParameters parameters)
{
this.shapes = shapes;
this.scribes = scribes;
this.parameters = parameters;
+ pinned = new ContourChoice[shapes.Length];
+ pinNumbers = new int[shapes.Length];
+ }
+
+ private PreparedContours(PreparedContours unpinned, IReadOnlyList pins, CancellationToken token)
+ : this(unpinned.shapes, unpinned.scribes, unpinned.parameters)
+ {
+ contourParameters = new CuttingParameters[shapes.Length];
+ var rejected = new List();
+ foreach (var pin in pins)
+ {
+ token.ThrowIfCancellationRequested();
+ if (pin == null)
+ throw new ArgumentException("A pin resolution is missing.", nameof(pins));
+ if (!pin.IsResolved)
+ continue; // The caller reports why; the contour stays automatic.
+ var contour = pin.ContourOrdinal;
+ if (contour < 0 || contour >= Count || pin.EntityOrdinal < 0
+ || pin.EntityOrdinal >= shapes[contour].Entities.Count)
+ throw new ArgumentException("A pin resolution does not belong to these contours.", nameof(pins));
+ if (pinned[contour] != null)
+ throw new ArgumentException("Two pins resolve to one contour.", nameof(pins));
+ // The emitter tabs only the outside, so a tab elsewhere cannot be cut as pinned.
+ if (pin.Pin.HasTab && contour != PerimeterOrdinal)
+ {
+ rejected.Add(pin);
+ continue;
+ }
+ var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
+ ValidateChoice(choice);
+ pinned[contour] = choice;
+ pinNumbers[contour] = pin.PinIndex + 1;
+ contourParameters[contour] = PinParameters(parameters, pin.Pin);
+ }
+ RejectedPins = rejected.AsReadOnly();
}
public int Count => shapes.Length;
public int PerimeterOrdinal => Count - 1;
+ /// Resolved pins this preparation could not honour: a tab on an internal contour.
+ public IReadOnlyList RejectedPins { get; } = Array.Empty();
+
+ /// True when a drawing pin fixes this contour's entry, leads and tab.
+ public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
+
+ /// The one-based drawing pin that fixes this contour, or null.
+ public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
+
+ /// The pinned contour's only entry, or null for an automatic contour.
+ public ContourChoice PinnedEntry(int contourOrdinal) => IsPinned(contourOrdinal) ? pinned[contourOrdinal] : null;
+
+ ///
+ /// As , then fixes every
+ /// resolved pin's contour: that contour's only entry is the pin's point and it is emitted with
+ /// the pin's own lead-in, lead-out and tab through the same emitter, so its tab gap is genuine
+ /// and certifiable. must come from against
+ /// contours of this same program; unresolved pins are skipped (the caller reports them), a pin
+ /// with a tab on an internal contour is listed in , and every other
+ /// contour keeps the automatic selection with .
+ ///
+ public static PreparedContours Capture(Program program, CuttingParameters parameters,
+ IReadOnlyList pins, CancellationToken token = default)
+ {
+ ArgumentNullException.ThrowIfNull(pins);
+ var unpinned = Capture(program, parameters, token);
+ return pins.Any(p => p?.IsResolved != false) ? new PreparedContours(unpinned, pins, token) : unpinned;
+ }
+
+ // The pin's leads replace every contour type's leads, since the type the emitter detects is
+ // not the caller's concern; angle rounding is off because a pinned entry never moves.
+ private static CuttingParameters PinParameters(CuttingParameters shared, ContourCutPin pin)
+ {
+ var owned = OwnedCuttingParameters.Copy(shared);
+ owned.ExternalLeadIn = pin.LeadIn;
+ owned.InternalLeadIn = pin.LeadIn;
+ owned.ArcCircleLeadIn = pin.LeadIn;
+ owned.ExternalLeadOut = pin.LeadOut;
+ owned.InternalLeadOut = pin.LeadOut;
+ owned.ArcCircleLeadOut = pin.LeadOut;
+ owned.RoundLeadInAngles = false;
+ owned.TabsEnabled = pin.HasTab;
+ if (pin.HasTab)
+ owned.TabConfig = pin.Tab;
+ return owned;
+ }
+
+ private CuttingParameters ParametersFor(int contourOrdinal) => contourParameters?[contourOrdinal] ?? parameters;
+
public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
@@ -100,10 +189,13 @@ public sealed class PreparedContours
}
}
+ /// The closest native entry to ; a pinned contour's pinned entry.
public ContourChoice ClosestEntry(int contourOrdinal, Vector approach)
{
PostVerificationGeometry.Validate(approach);
var shape = GetShape(contourOrdinal);
+ if (pinned[contourOrdinal] is { } pin)
+ return pin;
var point = shape.ClosestPointTo(approach, out var entity);
return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point);
}
@@ -112,6 +204,7 @@ public sealed class PreparedContours
/// Closest native point first, then source-order vertices/midpoints or eight circle
/// angles (0 through 315 degrees). Geometrical duplicates keep their first entity.
/// Emit before validating actual paths: circle rounding/clamping may move this point.
+ /// A pinned contour has its pinned entry only.
///
public IReadOnlyList Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default)
{
@@ -119,6 +212,8 @@ public sealed class PreparedContours
if (maxEntries <= 0)
throw new ArgumentOutOfRangeException(nameof(maxEntries));
var shape = GetShape(contourOrdinal);
+ if (pinned[contourOrdinal] is { } pin)
+ return new[] { pin };
var entries = new List();
entries.Add(ClosestEntry(contourOrdinal, approach));
for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++)
@@ -189,6 +284,8 @@ public sealed class PreparedContours
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
+ if (pinned[contourOrdinal] is { } pin)
+ 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 MergeByGeometry(PreferredCandidates(shape, contourOrdinal, token));
@@ -202,7 +299,8 @@ public sealed class PreparedContours
/// (pass the arrival point there when there is no next cut).
/// A pure-circle contour therefore yields compass points instead of refusing. Every
/// fallback passes the same reflex/cusp exclusion and geometric duplicate merge as the
- /// preferred tier; fallbacks never replace a preferred point at the same geometry.
+ /// preferred tier; fallbacks never replace a preferred point at the same geometry. A
+ /// pinned contour's catalogue is its pinned entry alone: nothing may relocate it.
///
internal IReadOnlyList AutomaticEntryCandidatesWithFallbacks(
int contourOrdinal,
@@ -211,6 +309,8 @@ public sealed class PreparedContours
{
token.ThrowIfCancellationRequested();
var shape = GetShape(contourOrdinal);
+ if (pinned[contourOrdinal] is { } pin)
+ return new[] { new ContourEntryCandidate(pin, AutomaticEntryKind.Pinned) };
var all = IsSingleCircle(shape)
? new List()
: PreferredCandidates(shape, contourOrdinal, token);
@@ -461,7 +561,8 @@ public sealed class PreparedContours
// Same owned clones as a real emission; the source shapes/settings are never used
// directly, so the probe cannot drift the preparation or mutate it.
return new ContourCuttingStrategy { Parameters = parameters }.EmitCandidateIsolated(
- shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice);
+ shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choice,
+ contourParameters);
}
// Each prefix is a standalone owned program, including the same scribes once.
@@ -469,7 +570,8 @@ public sealed class PreparedContours
{
Validate(choices, false);
return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared(
- shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
+ shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices,
+ contourParameters);
}
internal void ValidateCompleteChoices(IReadOnlyList choices) => Validate(choices, true);
@@ -481,7 +583,9 @@ public sealed class PreparedContours
{
token.ThrowIfCancellationRequested();
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
- return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
+ // Only the outside is tabbed, with its own pin's tab when it is pinned.
+ var perimeter = ParametersFor(PerimeterOrdinal);
+ return new(this, choices, expected, perimeter.TabsEnabled ? perimeter.TabConfig.Size : 0);
}
private void Validate(IReadOnlyList choices, bool complete)
@@ -508,6 +612,9 @@ public sealed class PreparedContours
var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point);
if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon)
throw new ArgumentException("Entry is not on the selected native entity.");
+ // A pinned contour is cut exactly as pinned: no other entry may be emitted for it.
+ if (pinned[choice.ContourOrdinal] is { } pin && pin != choice)
+ throw new ArgumentException("A pinned contour keeps its drawing pin's entry.");
}
private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count
diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
index 450906ba..c6dec526 100644
--- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
+++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
@@ -234,12 +234,15 @@ namespace OpenNest.CNC.CuttingStrategy
return null;
}
+ /// Per-contour settings by contour ordinal (a drawing pin's
+ /// own lead-in, lead-out and tab); a null list or entry uses .
internal Program EmitPrepared(Shape[] shapes, List scribes,
- IReadOnlyList choices)
+ IReadOnlyList choices,
+ IReadOnlyList contourParameters = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
- EmitChosenContours(result, shapes, choices);
+ EmitChosenContours(result, shapes, choices, contourParameters);
result.Mode = Mode.Incremental;
return result;
}
@@ -250,11 +253,11 @@ namespace OpenNest.CNC.CuttingStrategy
// perimeter never degrades to a hole because it was isolated. Output must not be
// installed on a Part or accepted as a complete plan.
internal Program EmitCandidateIsolated(Shape[] shapes, List scribes,
- CuttingPlanning.ContourChoice choice)
+ CuttingPlanning.ContourChoice choice, IReadOnlyList contourParameters = null)
{
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribes);
- EmitChosenContours(result, shapes, new[] { choice });
+ EmitChosenContours(result, shapes, new[] { choice }, contourParameters);
result.Mode = Mode.Incremental;
return result;
}
@@ -263,14 +266,25 @@ namespace OpenNest.CNC.CuttingStrategy
// Contour emission itself reads only the contour's own geometry and settings, never
// prior choices — the differential tests in ContourCandidateEmissionTests pin this.
private void EmitChosenContours(Program result, Shape[] shapes,
- IReadOnlyList choices)
+ IReadOnlyList choices, IReadOnlyList contourParameters)
{
- foreach (var choice in choices)
+ var shared = Parameters;
+ try
{
- var shape = shapes[choice.ContourOrdinal];
- EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
- choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
- exactCirclePrograms: true);
+ foreach (var choice in choices)
+ {
+ // A contour's own settings apply to its emission alone; scribes and every
+ // other contour keep the shared settings.
+ Parameters = contourParameters?[choice.ContourOrdinal] ?? shared;
+ var shape = shapes[choice.ContourOrdinal];
+ EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal],
+ choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null,
+ exactCirclePrograms: true);
+ }
+ }
+ finally
+ {
+ Parameters = shared;
}
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index 3a86e20b..5f5e0a53 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -122,7 +122,8 @@ public static class CuttingPlanService
warnings.Add(new(index, source, null, null, PostVerificationKind.Incomplete, material.Reason));
}
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
- prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
+ prepared = DrawingPinCapture.Prepare(source, index, ownedClean, request.ConfirmedParameters,
+ warnings, token);
}
var captured = material;
// Fixed-route material is captured only for a pair whose bounds admit containment.
@@ -263,8 +264,9 @@ public static class CuttingPlanService
if (snapshot.Regeneration)
{
var joint = JointCuttingPlanSearch.Run(snapshot, token);
+ // Capture warnings (drawing pins that could not be honoured) stay visible on a refusal.
if (joint.Status != CuttingPlanStatus.Ready)
- return new(joint.Status, findings: joint.Findings, expansions: joint.Expansions);
+ return new(joint.Status, findings: snapshot.Findings.Concat(joint.Findings), expansions: joint.Expansions);
return ReplayPrograms(snapshot, joint.Order, joint.Expansions, token);
}
var search = FixedProgramSearch.Run(snapshot, token);
@@ -328,7 +330,8 @@ public static class CuttingPlanService
var distance = 0.0;
var findings = new List();
// Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
- var warnings = new List();
+ // Capture adds the drawing pins it could not honour; their contours were planned normally.
+ var warnings = new List(snapshot.Findings);
var materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
var materials = materialOwners.Select(p => p.Material).ToArray();
foreach (var proposal in order)
diff --git a/OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs b/OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs
new file mode 100644
index 00000000..876ed7a5
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs
@@ -0,0 +1,54 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Diagnostics;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// Capture-time preparation of a regenerated part's contours with its drawing's pins (D1: the
+/// drawing wins). A pin that does not resolve exactly is never guessed: its contour is planned
+/// from the plate settings and the plate gets a warning, so its proposal is UNVERIFIED.
+///
+internal static class DrawingPinCapture
+{
+ internal static PreparedContours Prepare(Part source, int ordinal, Program ownedClean,
+ CuttingParameters parameters, List warnings, CancellationToken token)
+ {
+ var prepared = PreparedContours.Capture(ownedClean, parameters, token);
+ if (source.BaseDrawing.CutPins is not { Count: > 0 })
+ return prepared;
+ // The clean program is the drawing's own program rotated by the part's rotation, so the
+ // resolver maps the source-frame pins onto exactly these contours.
+ var resolutions = CutPinResolver.Resolve(source.BaseDrawing, source.Rotation, prepared, token);
+ var pinned = PreparedContours.Capture(ownedClean, parameters, resolutions, token);
+ foreach (var resolution in resolutions.Where(r => !r.IsResolved))
+ warnings.Add(Warning(source, ordinal, resolution, Unresolved(resolution)));
+ foreach (var rejected in pinned.RejectedPins)
+ warnings.Add(Warning(source, ordinal, rejected, $"Drawing pin {rejected.PinIndex + 1} puts a tab on an "
+ + "internal contour, but only the outside can be tabbed; planned with nest settings."));
+ return pinned;
+ }
+
+ private static string Unresolved(CutPinResolution resolution)
+ {
+ var pin = resolution.PinIndex + 1;
+ return resolution.Status switch
+ {
+ CutPinStatus.NotOnContour => $"Drawing pin {pin} no longer matches the geometry; planned with nest settings.",
+ CutPinStatus.OnSeveralContours => $"Drawing pin {pin} touches more than one contour; planned with nest settings.",
+ CutPinStatus.ContourPinnedTwice => $"Drawing pin {pin} shares its contour with another pin, so neither is used; "
+ + "planned with nest settings.",
+ _ => $"Drawing pin {pin} cannot be placed on this drawing's program; planned with nest settings.",
+ };
+ }
+
+ private static CuttingPlanFinding Warning(Part source, int ordinal, CutPinResolution resolution, string message) =>
+ new(ordinal, source, null, null, PostVerificationKind.FixedLead, message)
+ {
+ FixedLead = new(null, resolution.PinIndex + 1, null)
+ };
+}
diff --git a/OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs b/OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs
new file mode 100644
index 00000000..ce4cafd8
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs
@@ -0,0 +1,182 @@
+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;
+
+///
+/// D1 / invariant 3: a pinned contour has exactly one entry, its pin's, and is emitted with the
+/// pin's own lead-in, lead-out and tab through the existing emitter. Unpinned contours keep the
+/// plate's automatic selection; a pin that does not resolve is never guessed (invariant 5).
+///
+public class PinnedContourTests
+{
+ private static readonly Vector Start = new(-2, -2);
+
+ [Fact]
+ public void PinnedOutsideCorner_HasOneEntryEmittedWithThePinsLeadAndTab()
+ {
+ var drawing = SquareWithPin(new Vector(0, 0), Corner());
+ var prepared = Prepare(drawing, NestSettings());
+ var perimeter = prepared.PerimeterOrdinal;
+
+ var pinned = Assert.IsType(prepared.PinnedEntry(perimeter));
+ Assert.Equal(new Vector(0, 0), pinned.Point);
+ Assert.Equal(1, prepared.PinNumberOf(perimeter));
+ Assert.Same(pinned, prepared.ClosestEntry(perimeter, new Vector(10, 10)));
+ Assert.Equal([pinned], prepared.Entries(perimeter, new Vector(10, 10)));
+ Assert.Equal([pinned], prepared.AutomaticEntryCandidatesWithFallbacks(perimeter, new Vector(10, 10)).Select(c => c.Choice));
+ // Nothing may relocate a pinned entry, not even an exact native point elsewhere on it.
+ Assert.Throws(() => prepared.Entry(perimeter, 1, new Vector(5, 10)));
+
+ var motions = ExecutionMotionReader.Read(prepared.Emit([pinned]), Vector.Zero, null, default).Motions;
+ var lead = Assert.Single(motions, m => m.Layer == LayerType.Leadin);
+ Assert.Equal(0.25, lead.Length, 9);
+ Assert.Equal(new Vector(0, 0), lead.End);
+ Assert.DoesNotContain(motions, m => m.Layer == LayerType.Leadout);
+ Assert.Equal(0.03, motions.Last(m => !m.Rapid).End.DistanceTo(new Vector(0, 0)), 6);
+ }
+
+ [Fact]
+ public void PinnedOutsideCorner_PlansReadyAndIsIndependentlyReplayed()
+ {
+ var drawing = SquareWithPin(new Vector(0, 0), Corner());
+ var part = new Part(drawing, new Vector(5, 5));
+ var plate = new Nest().CreatePlate();
+ plate.Parts.Add(part);
+
+ var proposal = CuttingPlanBatch.Capture([plate], NestSettings(), false).Plan();
+
+ var planned = Assert.Single(proposal.Plates);
+ Assert.True(planned.IsReady, string.Join("\n", proposal.Describe("in")));
+ Assert.True(planned.Result.IndependentlyReplayed);
+ var placement = Assert.Single(planned.Result.ProposedOrder);
+ Assert.Equal(new Vector(0, 0), Assert.Single(placement.ContourChoices).Point);
+ var motions = ExecutionMotionReader.Read(placement.CopyProgram(), part.Location, null, default).Motions;
+ Assert.Equal(0.25, Assert.Single(motions, m => m.Layer == LayerType.Leadin).Length, 9);
+ Assert.Equal(new Vector(5, 5), motions.First(m => m.Layer == LayerType.Leadin).End);
+ }
+
+ [Fact]
+ public void PinnedHole_BesideAnUnpinnedHole_ThatStaysAutomatic()
+ {
+ // Holes(): circles at (3, 3) and (7, 3), radius 1, inside a 10 x 10 square.
+ var drawing = new Drawing("holes", PreparedContourTests.Holes());
+ var pinLead = new LineLeadIn { Length = 0.4, ApproachAngle = 90 };
+ drawing.CutPins.Add(new ContourCutPin(new Vector(4, 3), pinLead, new NoLeadOut()));
+ var unpinned = new Part(new Drawing("plain", PreparedContourTests.Holes()), new Vector(30, 0));
+ var part = new Part(drawing, new Vector(0, 0));
+ var settings = NestSettings();
+
+ var pinnedResult = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
+ var plainResult = CuttingPlanService.Plan(new CuttingPlanRequest([unpinned], Start + new Vector(30, 0),
+ confirmedParameters: settings));
+
+ Assert.Equal(CuttingPlanStatus.Ready, pinnedResult.Status);
+ Assert.True(pinnedResult.IndependentlyReplayed);
+ var choices = Assert.Single(pinnedResult.ProposedOrder).ContourChoices;
+ Assert.Equal(new Vector(4, 3), Assert.Single(choices, c => c.ContourOrdinal == 0).Point);
+ // The other hole and the outside are chosen exactly as for the same drawing without pins.
+ var plain = Assert.Single(plainResult.ProposedOrder).ContourChoices;
+ Assert.Equal(plain.Where(c => c.ContourOrdinal != 0).Select(c => (c.ContourOrdinal, c.EntityOrdinal, c.Point)),
+ choices.Where(c => c.ContourOrdinal != 0).Select(c => (c.ContourOrdinal, c.EntityOrdinal, c.Point)));
+ // The pinned hole uses the pin's straight lead; the others use the nest's arc lead-ins.
+ var leadIns = ExecutionMotionReader.Read(pinnedResult.ProposedOrder[0].CopyProgram(), Vector.Zero, null, default)
+ .Motions.Where(m => m.Layer == LayerType.Leadin).ToArray();
+ Assert.Single(leadIns, m => m.Curve.IsLine && System.Math.Abs(m.Length - 0.4) < 1e-9);
+ Assert.Equal(2, leadIns.Count(m => !m.Curve.IsLine));
+ }
+
+ [Fact]
+ public void PinWithTabAndLeadOut_IsRefusedWhenTheDrawingIsAuthored()
+ {
+ Assert.Throws(() => new ContourCutPin(new Vector(0, 0),
+ new LineLeadIn { Length = 0.25 }, new LineLeadOut { Length = 0.1 }, new NormalTab { Size = 0.03 }));
+ }
+
+ [Fact]
+ public void UnresolvedPin_PlansThatContourAutomaticallyWithAnUnverifiedWarning()
+ {
+ var drawing = SquareWithPin(new Vector(5, 5), Corner()); // Inside the square: on no contour.
+ var plain = new Part(new Drawing("plain", ExplicitContourTests.Square(false)), new Vector(5, 5));
+ var part = new Part(drawing, new Vector(5, 5));
+ var settings = NestSettings();
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
+ var automatic = CuttingPlanService.Plan(new CuttingPlanRequest([plain], Start, confirmedParameters: settings));
+
+ Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ var warning = Assert.Single(result.Findings);
+ Assert.Equal(PostVerificationKind.FixedLead, warning.Kind);
+ Assert.Equal("Drawing pin 1 no longer matches the geometry; planned with nest settings.", warning.Message);
+ Assert.Equal(new FixedLeadWarning(null, 1, null), warning.FixedLead);
+ Assert.Equal(ExplicitContourTests.Fingerprint(automatic.ProposedOrder[0].CopyProgram()),
+ ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram()));
+ }
+
+ [Fact]
+ public void PinnedTabOnAHole_IsNotHonouredAndWarns()
+ {
+ var drawing = new Drawing("holes", PreparedContourTests.Holes());
+ drawing.CutPins.Add(new ContourCutPin(new Vector(4, 3), new LineLeadIn { Length = 0.2, ApproachAngle = 90 },
+ new NoLeadOut(), new NormalTab { Size = 0.03 }));
+ var prepared = Prepare(drawing, NestSettings());
+
+ Assert.False(prepared.IsPinned(0));
+ Assert.Equal(0, Assert.Single(prepared.RejectedPins).PinIndex);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([new Part(drawing)], Start,
+ confirmedParameters: NestSettings()));
+ Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
+ Assert.Contains("only the outside can be tabbed", Assert.Single(result.Findings).Message);
+ }
+
+ [Fact]
+ public void PinsCanOnlyComeFromResolutionsOfTheSameContours()
+ {
+ var drawing = SquareWithPin(new Vector(0, 0), Corner());
+ var prepared = PreparedContours.Capture(drawing.Program, NestSettings());
+ var resolution = Assert.Single(CutPinResolver.Resolve(drawing, 0, prepared));
+
+ Assert.Throws(() => PreparedContours.Capture(drawing.Program, NestSettings(),
+ [resolution, resolution]));
+ Assert.Throws(() => PreparedContours.Capture(drawing.Program, NestSettings(),
+ [resolution with { ContourOrdinal = 5 }]));
+ Assert.Throws(() => PreparedContours.Capture(drawing.Program, NestSettings(),
+ [resolution with { Point = new Vector(3, 3) }]));
+ // No resolved pin: the plain preparation, unchanged.
+ Assert.False(PreparedContours.Capture(drawing.Program, NestSettings(),
+ [resolution with { Status = CutPinStatus.NotOnContour }]).IsPinned(0));
+ }
+
+ internal static ContourCutPin Corner(Vector? point = null) => new(point ?? Vector.Zero,
+ new LineLeadIn { Length = 0.25, ApproachAngle = 90 }, new NoLeadOut(), new NormalTab { Size = 0.03 });
+
+ internal static Drawing SquareWithPin(Vector point, ContourCutPin template)
+ {
+ var drawing = new Drawing("pinned", ExplicitContourTests.Square(false));
+ drawing.CutPins.Add(new ContourCutPin(point, template.LeadIn, template.LeadOut, template.Tab));
+ return drawing;
+ }
+
+ // The nest's own settings: 0.15 arc lead-ins, tabs off.
+ internal static CuttingParameters NestSettings() => new()
+ {
+ ExternalLeadIn = new ArcLeadIn { Radius = 0.15 },
+ InternalLeadIn = new ArcLeadIn { Radius = 0.15 },
+ ArcCircleLeadIn = new ArcLeadIn { Radius = 0.15 },
+ ExternalLeadOut = new NoLeadOut(),
+ InternalLeadOut = new NoLeadOut(),
+ PierceClearance = 0.05
+ };
+
+ private static PreparedContours Prepare(Drawing drawing, CuttingParameters settings)
+ {
+ var plain = PreparedContours.Capture(drawing.Program, settings);
+ return PreparedContours.Capture(drawing.Program, settings, CutPinResolver.Resolve(drawing, 0, plain));
+ }
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index c9e29585..187ef134 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -378,6 +378,29 @@ accepted as given (user decision D1a, 2026-10-14). Its violations are not refusa
- The [pre-post review](post-verification.md) checks locked leads again before posting;
accepting the plan is not posting consent.
+### Drawing pins
+
+A drawing's cut pins (`Drawing.CutPins`, from the drawing library) fix how named contours are
+cut: the entry point, lead-in, lead-out and tab (user decision D1: the drawing wins over the
+plate's settings). Capture resolves each regenerated part's pins against its own rotated
+clean contours with `CutPinResolver` and prepares them with
+`PreparedContours.Capture(program, parameters, pins)`:
+
+- A pinned contour has exactly one entry, the pin's; every candidate catalogue returns it
+ alone and emission refuses any other entry for that contour, so the search can never move
+ it. It is emitted through the same emitter with the pin's own lead-in and lead-out for
+ every contour type, the pin's tab (or none) and angle rounding off; the emitter's own
+ geometry rules (pierce clearance, small-circle clamping) still apply as in any emission.
+ The certified tab gap is the pinned outside's own.
+- Unpinned contours on the same part, and every other part, keep the plate's automatic
+ selection; a plate without pins plans exactly as before.
+- A pin is never guessed (invariant 5). One that lies on no contour, on several, shares a
+ contour with another pin, or cannot be placed on the program leaves its contour automatic
+ and adds a `FixedLead` warning naming the pin (`FixedLeadWarning.PinNumber`), for example
+ "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.
+
## Apply
```csharp