feat(core): pinned contour preparation

PreparedContours.Capture(program, parameters, pins) takes CutPinResolver
results for the same contours and fixes every resolved pin's contour: its
one and only entry is the pin's point, and it is emitted through the
existing emitter with the pin's own lead-in, lead-out and tab (angle
rounding off, so the entry never moves). Every catalogue returns the pinned
entry alone and emission refuses any other entry for that contour, so no
search path can relocate it. The certified tab size is the pinned outside's
own, so a pinned tab gap is genuine and replay certifies it. Unpinned
contours keep the shared settings and automatic selection; without pins the
existing preparation is untouched (5.1 digests unchanged).

Capture now resolves a regenerated part's drawing pins. A pin that does not
resolve (not on a contour, on several, sharing one, or unplaceable) is never
guessed: its contour stays automatic and the plate gets a FixedLead warning
naming the pin, making the proposal UNVERIFIED. A tab pinned on an internal
contour cannot be cut as pinned (only the outside is tabbed) and warns the
same way. Pins-off, locked parts with pins and freshness follow in 5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-16 15:33:24 -04:00
1 parent d56f67a99a
commit 7949d41b6d
7 files changed
+403 -17

No files matched your search

@@ -33,6 +33,9 @@ internal enum AutomaticEntryKind
/// <summary>Tier 3 (fallback): one of the eight compass points of a whole circle.</summary>
CircleCompass = 6,
/// <summary>A drawing pin's own entry: the one and only candidate of a pinned contour.</summary>
Pinned = 7,
}
/// <summary>
@@ -23,17 +23,106 @@ public sealed class PreparedContours
private readonly Shape[] shapes;
private readonly CuttingParameters parameters;
private readonly List<Entity> 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<Entity> 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<CutPinResolution> pins, CancellationToken token)
: this(unpinned.shapes, unpinned.scribes, unpinned.parameters)
{
contourParameters = new CuttingParameters[shapes.Length];
var rejected = new List<CutPinResolution>();
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;
/// <summary>Resolved pins this preparation could not honour: a tab on an internal contour.</summary>
public IReadOnlyList<CutPinResolution> RejectedPins { get; } = Array.Empty<CutPinResolution>();
/// <summary>True when a drawing pin fixes this contour's entry, leads and tab.</summary>
public bool IsPinned(int contourOrdinal) => GetShape(contourOrdinal) != null && pinned[contourOrdinal] != null;
/// <summary>The one-based drawing pin that fixes this contour, or null.</summary>
public int? PinNumberOf(int contourOrdinal) => IsPinned(contourOrdinal) ? pinNumbers[contourOrdinal] : null;
/// <summary>The pinned contour's only entry, or null for an automatic contour.</summary>
public ContourChoice PinnedEntry(int contourOrdinal) => IsPinned(contourOrdinal) ? pinned[contourOrdinal] : null;
/// <summary>
/// As <see cref="Capture(Program, CuttingParameters, CancellationToken)"/>, 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. <paramref name="pins"/> must come from <see cref="CutPinResolver"/> 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 <see cref="RejectedPins"/>, and every other
/// contour keeps the automatic selection with <paramref name="parameters"/>.
/// </summary>
public static PreparedContours Capture(Program program, CuttingParameters parameters,
IReadOnlyList<CutPinResolution> 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
}
}
/// <summary>The closest native entry to <paramref name="approach"/>; a pinned contour's pinned entry.</summary>
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.
/// </summary>
public IReadOnlyList<ContourChoice> 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<ContourChoice>();
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
/// <paramref name="lookAhead"/> (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.
/// </summary>
internal IReadOnlyList<ContourEntryCandidate> 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<ContourEntryCandidate>()
: 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<ContourChoice> 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<ContourChoice> 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
@@ -234,12 +234,15 @@ namespace OpenNest.CNC.CuttingStrategy
return null;
}
/// <param name="contourParameters">Per-contour settings by contour ordinal (a drawing pin's
/// own lead-in, lead-out and tab); a null list or entry uses <see cref="Parameters"/>.</param>
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> choices)
IReadOnlyList<CuttingPlanning.ContourChoice> choices,
IReadOnlyList<CuttingParameters> 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<Entity> scribes,
CuttingPlanning.ContourChoice choice)
CuttingPlanning.ContourChoice choice, IReadOnlyList<CuttingParameters> 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<CuttingPlanning.ContourChoice> choices)
IReadOnlyList<CuttingPlanning.ContourChoice> choices, IReadOnlyList<CuttingParameters> 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;
}
}
@@ -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<CuttingPlanFinding>();
// Violations inside fixed emissions: accepted as given, reported, never refused (D1a).
var warnings = new List<CuttingPlanFinding>();
// Capture adds the drawing pins it could not honour; their contours were planned normally.
var warnings = new List<CuttingPlanFinding>(snapshot.Findings);
var materialOwners = snapshot.Placements.Where(p => !p.IsCutOff).ToArray();
var materials = materialOwners.Select(p => p.Material).ToArray();
foreach (var proposal in order)
@@ -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;
/// <summary>
/// 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.
/// </summary>
internal static class DrawingPinCapture
{
internal static PreparedContours Prepare(Part source, int ordinal, Program ownedClean,
CuttingParameters parameters, List<CuttingPlanFinding> 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)
};
}
@@ -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;
/// <summary>
/// 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).
/// </summary>
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<ContourChoice>(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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => PreparedContours.Capture(drawing.Program, NestSettings(),
[resolution, resolution]));
Assert.Throws<ArgumentException>(() => PreparedContours.Capture(drawing.Program, NestSettings(),
[resolution with { ContourOrdinal = 5 }]));
Assert.Throws<ArgumentException>(() => 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));
}
}
+23
View File
@@ -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