diff --git a/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs index 676dbef5..e7753935 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs @@ -12,7 +12,7 @@ namespace OpenNest.CNC.CuttingPlanning; /// Exact caller-thread record of everything a cutting proposal for one plate depends on: /// part list instance and order, plate quantity/size/quadrant/settings, cutoff definitions, and /// each part's complete cutting state with owned copies of its placed and drawing programs, -/// drawing cutoff classification and exact settings content. +/// drawing cutoff classification, drawing pins and pins-off, and exact settings content. /// A commit compares it with the live plate and refuses when anything differs. /// public sealed class PlateCuttingState @@ -24,12 +24,12 @@ public sealed class PlateCuttingState private readonly int quadrant; private readonly ObservableList cutOffList; private readonly CutOffRecord[] cutOffs; - private readonly Dictionary drawings; + private readonly Dictionary drawings; private readonly Dictionary settings; private readonly CuttingParameters plateSettings; private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs, - Dictionary drawings, Dictionary settings) + Dictionary drawings, Dictionary settings) { this.settings = settings; plateSettings = plate.CuttingParameters; @@ -61,7 +61,7 @@ public sealed class PlateCuttingState ArgumentNullException.ThrowIfNull(plate); if (plate.Parts == null || plate.CutOffs == null) throw new ArgumentException("Plate part and cutoff lists are required."); - var drawings = new Dictionary(ReferenceEqualityComparer.Instance); + var drawings = new Dictionary(ReferenceEqualityComparer.Instance); var settings = new Dictionary(ReferenceEqualityComparer.Instance); Fingerprint(plate.CuttingParameters); var records = new List(plate.Parts.Count); @@ -72,11 +72,13 @@ public sealed class PlateCuttingState throw new ArgumentException("Plate contains a missing part, drawing or program."); var drawing = part.BaseDrawing; if (!drawings.ContainsKey(drawing)) - drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null - : OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff)); + drawings.Add(drawing, new(drawing.Program, drawing.Program == null ? null + : OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff, + drawing.CutPins, StateFingerprint.Of(drawing.CutPins))); var state = part.CaptureCuttingState(); Fingerprint(state.CuttingParameters); - records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox))); + records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox), + part.DrawingPinsOff)); } var cutOffs = plate.CutOffs.Select(c => c == null ? throw new ArgumentException("Plate contains a missing cutoff definition.") @@ -130,12 +132,19 @@ public sealed class PlateCuttingState return $"Part {i + 1} program was edited in place."; if (!SameSettings(live.CuttingParameters)) return $"Part {i + 1} cutting settings were edited in place."; - var (drawingProgram, drawingCopy, isCutOff) = drawings[part.BaseDrawing]; - if (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram) - || !ProgramContent.Equal(drawingProgram, drawingCopy, token)) + var drawing = drawings[part.BaseDrawing]; + if (!ReferenceEquals(part.BaseDrawing.Program, drawing.Program) + || !ProgramContent.Equal(drawing.Program, drawing.Copy, token)) return $"Part {i + 1} drawing program changed."; - if (part.BaseDrawing.IsCutOff != isCutOff) + if (part.BaseDrawing.IsCutOff != drawing.IsCutOff) return $"Part {i + 1} cutoff classification changed."; + // The drawing's pins decide how its pinned contours are cut, and pins-off whether + // they apply to this placement: either change makes the proposal stale. + if (part.DrawingPinsOff != record.DrawingPinsOff) + return $"Part {i + 1} drawing pins were switched on or off."; + if (!ReferenceEquals(part.BaseDrawing.CutPins, drawing.Pins) + || StateFingerprint.Of(drawing.Pins) != drawing.PinsFingerprint) + return $"Part {i + 1} drawing pins changed."; } if (plate.CutOffs.Count != cutOffs.Length) return "Cutoffs were added or removed."; @@ -188,7 +197,11 @@ public sealed class PlateCuttingState private static bool Bits(double? a, double? b) => a.HasValue == b.HasValue && (!a.HasValue || Bits(a.Value, b.Value)); - private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds); + private sealed record PartRecord(Part Part, PartCuttingState State, Program Program, long[] Bounds, + bool DrawingPinsOff); + + private sealed record DrawingRecord(Program Program, Program Copy, bool IsCutOff, + List Pins, string PinsFingerprint); private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position, double? StartLimit, double? EndLimit); diff --git a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs index 92bf9c97..d3d75de6 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Text; using OpenNest.CNC.CuttingStrategy; @@ -23,11 +24,51 @@ internal static class StateFingerprint /// A settings object is not an exact built-in type. internal static string Of(CuttingParameters settings) { - var text = new StringBuilder(); - Settings(settings); - return text.ToString(); + var writer = new Writer(); + writer.Settings(settings); + return writer.ToString(); + } - void Settings(CuttingParameters p) + /// + /// Fingerprint of a drawing's cut pins in order: each pin's point, lead-in, lead-out and + /// tab. A null list and an empty list differ. + /// + internal static string Of(IReadOnlyList pins) + { + var writer = new Writer(); + writer.Pins(pins); + return writer.ToString(); + } + + private sealed class Writer + { + private readonly StringBuilder text = new(); + + public override string ToString() => text.ToString(); + + internal void Pins(IReadOnlyList pins) + { + if (pins == null) + { + text.Append("null;"); + return; + } + text.Append("Pins{"); + foreach (var pin in pins) + { + if (!Begin(pin, typeof(ContourCutPin))) + continue; + Number(pin.Point.X); + Number(pin.Point.Y); + Lead(pin.LeadIn); + Out(pin.LeadOut); + TabSettings(pin.Tab); + text.Append('}'); + } + text.Append('}'); + } + + internal void Settings(CuttingParameters p) { if (!Begin(p, typeof(CuttingParameters))) return; @@ -56,7 +97,7 @@ internal static class StateFingerprint text.Append('}'); } - void Sequence(SequenceParameters s) + private void Sequence(SequenceParameters s) { if (!Begin(s, typeof(SequenceParameters))) return; @@ -72,7 +113,7 @@ internal static class StateFingerprint text.Append('}'); } - void Assignment(AssignmentParameters a) + private void Assignment(AssignmentParameters a) { if (!Begin(a, typeof(AssignmentParameters))) return; @@ -82,7 +123,7 @@ internal static class StateFingerprint text.Append('}'); } - void Lead(LeadIn lead) + private void Lead(LeadIn lead) { if (lead == null) { @@ -122,7 +163,7 @@ internal static class StateFingerprint text.Append('}'); } - void Out(LeadOut lead) + private void Out(LeadOut lead) { if (lead == null) { @@ -146,7 +187,7 @@ internal static class StateFingerprint text.Append('}'); } - void TabSettings(Tab tab) + private void TabSettings(Tab tab) { if (tab == null) { @@ -180,7 +221,7 @@ internal static class StateFingerprint // Writes the exact type tag and returns true; null writes a marker and returns false. // A runtime type other than the expected exact type throws before any member is read. - bool Begin(object value, Type exact) + private bool Begin(object value, Type exact) { if (value == null) { @@ -193,15 +234,15 @@ internal static class StateFingerprint return true; } - void Number(double value) => + private void Number(double value) => text.Append(BitConverter.DoubleToInt64Bits(value).ToString(CultureInfo.InvariantCulture)).Append(';'); - void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';'); + private void Int(int value) => text.Append(value.ToString(CultureInfo.InvariantCulture)).Append(';'); - void Flag(bool value) => text.Append(value ? "1;" : "0;"); + private void Flag(bool value) => text.Append(value ? "1;" : "0;"); // Length-prefixed so a delimiter inside the text cannot shift fields; null differs from "". - void Text(string value) + private void Text(string value) { if (value == null) text.Append("~;"); diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index adf76d3a..0688e194 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -70,6 +70,9 @@ namespace OpenNest /// public bool DrawingPinsOff { get; set; } + /// True when the drawing has cut pins and this placement has not switched them off. + public bool HasActiveDrawingPins => !DrawingPinsOff && BaseDrawing?.CutPins is { Count: > 0 }; + public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; } public void ApplyLeadIns( diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs index 5f5e0a53..81996c3f 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs @@ -124,6 +124,8 @@ public static class CuttingPlanService if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source))) prepared = DrawingPinCapture.Prepare(source, index, ownedClean, request.ConfirmedParameters, warnings, token); + else if (source.HasActiveDrawingPins) + warnings.Add(DrawingPinCapture.NotApplied(source, index)); } var captured = material; // Fixed-route material is captured only for a pair whose bounds admit containment. diff --git a/OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs b/OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs index 876ed7a5..c9888b1e 100644 --- a/OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs +++ b/OpenNest.Engine/CuttingPlanning/DrawingPinCapture.cs @@ -19,7 +19,8 @@ internal static class DrawingPinCapture CuttingParameters parameters, List warnings, CancellationToken token) { var prepared = PreparedContours.Capture(ownedClean, parameters, token); - if (source.BaseDrawing.CutPins is not { Count: > 0 }) + // Pins switched off for this placement (D1's escape hatch) leave it to the plate settings. + if (!source.HasActiveDrawingPins) 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. @@ -33,6 +34,17 @@ internal static class DrawingPinCapture return pinned; } + /// + /// A part with active pins whose program is kept: locked lead-ins override the pins (they + /// are kept exactly, never refused), and an unlocked part a caller left ineligible is not + /// regenerated at all. + /// + internal static CuttingPlanFinding NotApplied(Part source, int ordinal) => + new(ordinal, source, null, null, PostVerificationKind.FixedLead, source.LeadInsLocked + ? $"Locked lead-ins override drawing pins on part {ordinal + 1}; Clear Lead-ins to apply the pins." + : $"Drawing pins on part {ordinal + 1} are not applied: the part is not regenerated.") + { FixedLead = new(null, null, null) }; + private static string Unresolved(CutPinResolution resolution) { var pin = resolution.PinIndex + 1; diff --git a/OpenNest.Tests/CuttingPlanning/DrawingPinCaptureTests.cs b/OpenNest.Tests/CuttingPlanning/DrawingPinCaptureTests.cs new file mode 100644 index 00000000..ab2131ae --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/DrawingPinCaptureTests.cs @@ -0,0 +1,163 @@ +using System.Reflection; +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; + +/// +/// Capture reads a placement's active drawing pins (not when switched off), a locked or +/// unregenerated part keeps its program with a warning, and a proposal goes stale when the +/// drawing's pins or the part's pins-off flag change after capture. +/// +public class DrawingPinCaptureTests +{ + [Fact] + public void PinsOff_PlansThePartWithTheNestSettingsAndNoWarning() + { + var (plate, part) = PinnedPlate(); + part.DrawingPinsOff = true; + var plain = new Part(new Drawing("plain", ExplicitContourTests.Square(false)), part.Location); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: PinnedContourTests.NestSettings())); + var automatic = CuttingPlanService.Plan(new CuttingPlanRequest([plain], + confirmedParameters: PinnedContourTests.NestSettings())); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.Empty(result.Findings); + Assert.Equal(ExplicitContourTests.Fingerprint(automatic.ProposedOrder[0].CopyProgram()), + ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram())); + } + + [Fact] + public void LockedPartWithActivePins_KeepsItsProgramWithAWarningNotARefusal() + { + var (plate, part) = PinnedPlate(); + var prepared = PreparedContours.Capture(part.BaseDrawing.Program, ExplicitContourTests.Parameters()); + var locked = prepared.Emit([prepared.ClosestEntry(0, new Vector(-1, 5))]); + Assert.True(part.RestoreLeadInProgram(locked, true)); + var program = part.Program; + + var proposal = CuttingPlanBatch.Capture([plate], PinnedContourTests.NestSettings(), false).Plan(); + + var planned = Assert.Single(proposal.Plates); + Assert.True(planned.HasOnlyFixedLeadWarnings); + var warning = Assert.Single(planned.Result.Findings); + Assert.Equal(PostVerificationKind.FixedLead, warning.Kind); + Assert.Equal("Locked lead-ins override drawing pins on part 1; Clear Lead-ins to apply the pins.", warning.Message); + Assert.False(Assert.Single(planned.Result.ProposedOrder).IsRegenerated); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status); + Assert.Same(program, part.Program); + + // Switching the pins off for this part leaves nothing to warn about. + part.DrawingPinsOff = true; + Assert.True(CuttingPlanBatch.Capture([plate], PinnedContourTests.NestSettings(), false).Plan().CanApply); + } + + [Fact] + public void IneligiblePartWithActivePins_IsNotRegeneratedAndWarns() + { + var (plate, part) = PinnedPlate(); + var other = new Part(new Drawing("other", ExplicitContourTests.Square(false)), new Vector(30, 0)); + plate.Parts.Add(other); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: PinnedContourTests.NestSettings(), eligibleParts: [other])); + + Assert.Equal(CuttingPlanStatus.BestEffort, result.Status); + Assert.Equal("Drawing pins on part 1 are not applied: the part is not regenerated.", + Assert.Single(result.Findings).Message); + } + + [Fact] + public void PinnedPartWithEarlierLeadInsFromOtherSettings_IsRegeneratedWithItsPins() + { + var (plate, part) = PinnedPlate(); + var earlier = ExplicitContourTests.Parameters(); + var prepared = PreparedContours.Capture(part.BaseDrawing.Program, earlier); + Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, new Vector(11, 5))]), false)); + part.CuttingParameters = earlier; + + var proposal = CuttingPlanBatch.Capture([plate], PinnedContourTests.NestSettings(), false).Plan(); + + Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in"))); + var placement = Assert.Single(proposal.Plates[0].Result.ProposedOrder); + Assert.True(placement.IsRegenerated); + Assert.Equal(new Vector(0, 0), Assert.Single(placement.ContourChoices).Point); + } + + [Theory] + [InlineData("unchanged")] + [InlineData("pins-off")] + [InlineData("pin-added")] + [InlineData("pin-replaced")] + [InlineData("pin-removed")] + [InlineData("list-replaced")] + public void Apply_IsStaleWhenPinsOrPinsOffChangeAfterCapture(string change) + { + var (plate, part) = PinnedPlate(); + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: PinnedContourTests.NestSettings())); + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + var pins = part.BaseDrawing.CutPins; + switch (change) + { + case "pins-off": part.DrawingPinsOff = true; break; + case "pin-added": pins.Add(PinnedContourTests.Corner(new Vector(10, 10))); break; + case "pin-replaced": pins[0] = PinnedContourTests.Corner(new Vector(10, 0)); break; + case "pin-removed": pins.Clear(); break; + case "list-replaced": part.BaseDrawing.CutPins = [.. pins]; break; + } + + var commit = CuttingPlanService.Apply([result]); + + Assert.Equal(change == "unchanged" ? CuttingCommitStatus.Applied : CuttingCommitStatus.Stale, commit.Status); + if (change != "unchanged") + Assert.Contains("drawing pins", result.Snapshot!.PlateState!.Difference(), StringComparison.Ordinal); + } + + [Fact] + public void PinFingerprint_SeesEveryPublicPropertyOfAPin() + { + // ContourCutPin is immutable: every public property must come from a constructor + // argument the fingerprint writes. A new property means extending StateFingerprint. + var properties = typeof(ContourCutPin).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray(); + Assert.Equal(["HasTab", "LeadIn", "LeadOut", "Point", "Tab"], properties); + Assert.Empty(typeof(ContourCutPin).GetFields(BindingFlags.Public | BindingFlags.Instance)); + + var baseline = new ContourCutPin(new Vector(1, 2), new LineLeadIn { Length = 0.25 }, new NoLeadOut(), + new NormalTab { Size = 0.03 }); + ContourCutPin[] variants = + [ + new(new Vector(1.5, 2), baseline.LeadIn, baseline.LeadOut, baseline.Tab), + new(new Vector(1, 2.5), baseline.LeadIn, baseline.LeadOut, baseline.Tab), + new(baseline.Point, new LineLeadIn { Length = 0.3 }, baseline.LeadOut, baseline.Tab), + new(baseline.Point, new ArcLeadIn { Radius = 0.25 }, baseline.LeadOut, baseline.Tab), + new(baseline.Point, baseline.LeadIn, baseline.LeadOut, new NormalTab { Size = 0.04 }), + new(baseline.Point, baseline.LeadIn, baseline.LeadOut, new BreakerTab { Size = 0.03 }), + new(baseline.Point, baseline.LeadIn, baseline.LeadOut), // No tab. + new(baseline.Point, baseline.LeadIn, new LineLeadOut { Length = 0.1 }), + ]; + var fingerprints = variants.Select(v => StateFingerprint.Of([v])) + .Append(StateFingerprint.Of([baseline])).ToArray(); + Assert.Equal(fingerprints.Length, fingerprints.Distinct().Count()); + Assert.Equal(StateFingerprint.Of([baseline]), StateFingerprint.Of([new ContourCutPin( + new Vector(1, 2), new LineLeadIn { Length = 0.25 }, new NoLeadOut(), new NormalTab { Size = 0.03 })])); + Assert.NotEqual(StateFingerprint.Of((IReadOnlyList)null!), StateFingerprint.Of([])); + Assert.NotEqual(StateFingerprint.Of([baseline, variants[0]]), StateFingerprint.Of([variants[0], baseline])); + } + + private static (Plate Plate, Part Part) PinnedPlate() + { + var part = new Part(PinnedContourTests.SquareWithPin(new Vector(0, 0), PinnedContourTests.Corner()), + new Vector(5, 5)); + var plate = new Nest().CreatePlate(); + plate.Parts.Add(part); + return (plate, part); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 187ef134..26d75c50 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -400,6 +400,16 @@ clean contours with `CutPinResolver` and prepares them with "Drawing pin 2 no longer matches the geometry; planned with nest settings.", which makes the plate UNVERIFIED. A tab pinned on an internal contour is not honoured either: only the outside is tabbed. +- A placement with `Part.DrawingPinsOff` ignores its drawing's pins and plans entirely from + the plate settings, with no warning (D1's per-part escape hatch). `Part.HasActiveDrawingPins` + is the shared test for "pins apply here". +- A pinned part is regenerated like any unlocked part, whatever lead-ins or settings it + carried before. A locked part keeps its exact locked program and warns "Locked lead-ins + override drawing pins on part N; Clear Lead-ins to apply the pins." It is not refused. An + unlocked part a caller left ineligible warns that its pins are not applied. +- The plate state compared by `Apply` includes each drawing's pin list (reference and exact + content, `StateFingerprint.Of(pins)`) and each part's pins-off flag, so changing either + after capture makes the proposal `Stale`. ## Apply @@ -412,8 +422,8 @@ results from plate-scoped requests; anything else is `InvalidInput`. Apply never replans. Each plate is compared exactly with the state captured with its request: part list instance and order, plate quantity/size/quadrant and settings, cutoff definitions, and for every part its program reference and exact content (an -in-place edit counts), drawing program and cutoff classification, pose bits, -lead-in/lock flags, settings (reference and exact content) and bounds. Any +in-place edit counts), drawing program, cutoff classification and cut pins, pose bits, +lead-in/lock/pins-off flags, settings (reference and exact content) and bounds. Any difference on any plate returns `Stale` and changes nothing, so a proposal that changes a plate can be applied once; an unchanged (no-op) proposal stays current. A malformed live program is also `Stale`, not an exception. A part repeated on