From 7949d41b6d0de48fa48e0e20907624cb6d6f40c8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 16 Oct 2026 14:57:34 -0400 Subject: [PATCH] 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 --- .../CuttingPlanning/ContourEntryCandidate.cs | 3 + .../CNC/CuttingPlanning/PreparedContours.cs | 115 ++++++++++- .../CuttingStrategy/ContourCuttingStrategy.cs | 34 +++- .../CuttingPlanning/CuttingPlanService.cs | 9 +- .../CuttingPlanning/DrawingPinCapture.cs | 54 ++++++ .../CuttingPlanning/PinnedContourTests.cs | 182 ++++++++++++++++++ docs/cutting-planner.md | 23 +++ 7 files changed, 403 insertions(+), 17 deletions(-) create mode 100644 OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs create mode 100644 OpenNest.Tests/CuttingPlanning/PinnedContourTests.cs 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