diff --git a/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
index a9694af..1902fa6 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
@@ -52,7 +52,9 @@ public sealed class PlateCuttingState
///
/// Captures on the caller thread. Unsupported or malformed programs throw
- /// or .
+ /// or . Settings whose
+ /// authored state cannot be captured exactly are recorded as refused: the plate stays
+ /// plannable but every later commit against it reports Stale.
///
public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
{
@@ -83,6 +85,8 @@ public sealed class PlateCuttingState
void Fingerprint(CuttingParameters parameters)
{
+ // A refused capture is stored as-is; Difference treats it as never current, so
+ // every Apply for such settings is Stale and no foreign code ever runs at Apply.
if (parameters != null && !settings.ContainsKey(parameters))
settings.Add(parameters, StateFingerprint.Of(parameters));
}
@@ -94,6 +98,9 @@ public sealed class PlateCuttingState
/// Null when current; otherwise the first observed difference.
public string Difference(CancellationToken token = default)
{
+ const string Current = "current";
+ const string Stale = "stale";
+ var checkedSettings = new Dictionary(ReferenceEqualityComparer.Instance);
var plate = Plate;
if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
return "The plate's part or cutoff list was replaced.";
@@ -151,18 +158,31 @@ public sealed class PlateCuttingState
return null;
// References were compared already; this catches in-place edits of the same object.
+ // Each distinct settings object is fingerprinted at most once per check. A refused
+ // capture (Invalid) is never current: the state cannot be proven unchanged, and the
+ // live object is deliberately not re-read. A fingerprint that fails on re-read is
+ // likewise stale, never an escaping exception (cancellation stays distinct).
bool SameSettings(CuttingParameters parameters)
{
if (parameters == null)
return true;
- try
+ if (checkedSettings.TryGetValue(parameters, out var decision))
+ return decision == Current;
+ var current = false;
+ if (settings.TryGetValue(parameters, out var captured)
+ && captured != StateFingerprint.Invalid)
{
- return settings.TryGetValue(parameters, out var captured) && captured == StateFingerprint.Of(parameters);
- }
- catch (NotSupportedException)
- {
- return false; // Grown past the exact fingerprint budget since capture.
+ try
+ {
+ current = captured == StateFingerprint.Of(parameters);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ current = false;
+ }
}
+ checkedSettings[parameters] = current ? Current : Stale;
+ return current;
}
}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs
index 7d0c8cd..71dcc01 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/StateFingerprint.cs
@@ -4,104 +4,171 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
-using System.Text;
+using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
///
-/// Exact text record of an object's public state (properties and fields, recursively; scalars by
-/// bits, runtime types included) for detecting in-place edits of settings after capture. It is a
-/// freshness fingerprint, not a serializer. Value types contribute fields only, so computed struct
-/// properties cannot recurse without bound.
+/// Exact text record of a settings object's authored state for detecting in-place edits of
+/// settings after capture. It is a freshness fingerprint, not a serializer.
+/// No code of a foreign (non-OpenNest) type is ever executed: capture reads no foreign
+/// property getter (it could mutate live state or throw) and enumerates no foreign collection
+/// (its enumerator could throw after capture). Concretely it renders invariant scalars
+/// (doubles by bit pattern, so results are culture-free); arrays and the exact BCL containers
+/// List/Dictionary/HashSet/KeyValuePair, whose contents are rendered recursively and, where
+/// order is insertion order rather than authored semantics, sorted; concrete public types of
+/// an OpenNest assembly through their public readable properties and fields; and any other
+/// concrete type through its declared instance fields only, which include auto-property
+/// backing fields, so edits to them are seen. Behavioral enumerables and delegates are
+/// refused, as are depth and node-budget overflow and member-read failures; a reference
+/// already on the current path renders as a stable cycle marker, which loses nothing because
+/// the first visit rendered everything reachable from it. A refusal yields
+/// , which never compares equal, so refused state is unequal (Stale)
+/// instead of silently equal.
///
internal static class StateFingerprint
{
- private const int MaxDepth = 16;
+ internal const string Invalid = "";
+ private const int MaxDepth = 24;
private const int NodeBudget = 100000;
+ private static readonly Assembly Bcl = typeof(object).Assembly;
+ private static readonly Assembly[] OpenNestAssemblies =
+ [typeof(CuttingParameters).Assembly, typeof(OpenNest.Geometry.Vector).Assembly];
+ ///
+ /// Fingerprint of , or when the exact
+ /// authored state cannot be captured without executing foreign code or guessing.
+ ///
internal static string Of(object value)
{
- var text = new StringBuilder();
- var path = new HashSet