feat(engine): capture and freshness for pins

Capture now honours a placement's pins only while they are active: a part
with DrawingPinsOff plans from the plate settings with no warning (D1's
per-part escape hatch), and Part.HasActiveDrawingPins is the shared test.
A pinned part is regenerated like any unlocked part whatever lead-ins or
settings it carried. A locked part with active pins keeps its exact program
and warns "Locked lead-ins override drawing pins on part N; Clear Lead-ins
to apply the pins." instead of being refused; an unlocked part a caller left
ineligible warns that its pins are not applied.

Apply's freshness record now holds each drawing's pin list (reference and an
explicit StateFingerprint of every pin's point, leads and tab) and each
part's pins-off flag, so editing either after capture returns Stale. The
fingerprint writer is shared between settings and pins; a reflection test
pins ContourCutPin's public surface to what the fingerprint covers.

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:25 -04:00
1 parent 7949d41b6d
commit 8d17cf8c59
7 files changed
+273 -29

No files matched your search

@@ -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.
/// </summary>
public sealed class PlateCuttingState
@@ -24,12 +24,12 @@ public sealed class PlateCuttingState
private readonly int quadrant;
private readonly ObservableList<CutOff> cutOffList;
private readonly CutOffRecord[] cutOffs;
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
private readonly Dictionary<Drawing, DrawingRecord> drawings;
private readonly Dictionary<CuttingParameters, string> settings;
private readonly CuttingParameters plateSettings;
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
Dictionary<Drawing, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
Dictionary<Drawing, DrawingRecord> drawings, Dictionary<CuttingParameters, string> 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<Drawing, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
var drawings = new Dictionary<Drawing, DrawingRecord>(ReferenceEqualityComparer.Instance);
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
Fingerprint(plate.CuttingParameters);
var records = new List<PartRecord>(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<ContourCutPin> Pins, string PinsFingerprint);
private sealed record CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
double? StartLimit, double? EndLimit);
@@ -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
/// <exception cref="NotSupportedException">A settings object is not an exact built-in type.</exception>
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)
/// <summary>
/// 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.
/// </summary>
internal static string Of(IReadOnlyList<ContourCutPin> 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<ContourCutPin> 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("~;");
+3
View File
@@ -70,6 +70,9 @@ namespace OpenNest
/// </summary>
public bool DrawingPinsOff { get; set; }
/// <summary>True when the drawing has cut pins and this placement has not switched them off.</summary>
public bool HasActiveDrawingPins => !DrawingPinsOff && BaseDrawing?.CutPins is { Count: > 0 };
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
public void ApplyLeadIns(
@@ -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.
@@ -19,7 +19,8 @@ internal static class DrawingPinCapture
CuttingParameters parameters, List<CuttingPlanFinding> 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;
}
/// <summary>
/// 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.
/// </summary>
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;
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ContourCutPin>)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);
}
}
+12 -2
View File
@@ -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