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