fix(cutting): treat classification, settings and malformed programs as stale

Review of the atomic Apply found freshness gaps:

- A drawing's cutoff classification decides lead, material, obstacle and
  dependency treatment but was not captured; changing it after planning
  still applied the old proposal.
- Part and plate cutting settings were compared by reference only, so an
  in-place edit after capture applied (and a regenerated part overwrote
  it); plate settings were not compared at all. Settings are now captured
  as an exact public-state fingerprint.
- A live program whose instruction list was set to null made Apply throw
  instead of returning Stale, and a respelled key in a case-insensitive
  binding dictionary compared equal.

Caller-confirmed planning settings remain planning input: editing them
after capture does not stale the plan and does not leak into the result.
This commit is contained in:
aj committed 2026-10-05 00:23:48 -04:00
1 parent 4bbd8454b5
commit d37b38622f
5 files changed
+226 -13

No files matched your search

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Geometry;
@@ -9,8 +10,9 @@ namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// Exact caller-thread record of everything a cutting proposal for one plate depends on:
/// part list instance and order, plate quantity/size/quadrant, cutoff definitions, and each
/// part's complete cutting state with owned copies of its placed and drawing programs.
/// 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.
/// A commit compares it with the live plate and refuses when anything differs.
/// </summary>
public sealed class PlateCuttingState
@@ -22,11 +24,15 @@ 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)> drawings;
private readonly Dictionary<Drawing, (Program Program, Program Copy, bool IsCutOff)> drawings;
private readonly Dictionary<CuttingParameters, string> settings;
private readonly CuttingParameters plateSettings;
private PlateCuttingState(Plate plate, PartRecord[] parts,
CutOffRecord[] cutOffs, Dictionary<Drawing, (Program, Program)> drawings)
private PlateCuttingState(Plate plate, PartRecord[] parts, CutOffRecord[] cutOffs,
Dictionary<Drawing, (Program, Program, bool)> drawings, Dictionary<CuttingParameters, string> settings)
{
this.settings = settings;
plateSettings = plate.CuttingParameters;
Plate = plate;
partList = plate.Parts;
this.parts = parts;
@@ -53,7 +59,9 @@ 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)>(ReferenceEqualityComparer.Instance);
var drawings = new Dictionary<Drawing, (Program, Program, bool)>(ReferenceEqualityComparer.Instance);
var settings = new Dictionary<CuttingParameters, string>(ReferenceEqualityComparer.Instance);
Fingerprint(plate.CuttingParameters);
var records = new List<PartRecord>(plate.Parts.Count);
foreach (var part in plate.Parts)
{
@@ -63,14 +71,21 @@ public sealed class PlateCuttingState
var drawing = part.BaseDrawing;
if (!drawings.ContainsKey(drawing))
drawings.Add(drawing, (drawing.Program, drawing.Program == null ? null
: OwnedProgramCopy.Copy(drawing.Program, token)));
: OwnedProgramCopy.Copy(drawing.Program, token), drawing.IsCutOff));
var state = part.CaptureCuttingState();
Fingerprint(state.CuttingParameters);
records.Add(new(part, state, OwnedProgramCopy.Copy(part.Program, token), BoxValues(state.BoundingBox)));
}
var cutOffs = plate.CutOffs.Select(c => c == null
? throw new ArgumentException("Plate contains a missing cutoff definition.")
: new CutOffRecord(c, c.Drawing, c.Axis, c.Position, c.StartLimit, c.EndLimit)).ToArray();
return new(plate, records.ToArray(), cutOffs, drawings);
return new(plate, records.ToArray(), cutOffs, drawings, settings);
void Fingerprint(CuttingParameters parameters)
{
if (parameters != null && !settings.ContainsKey(parameters))
settings.Add(parameters, StateFingerprint.Of(parameters));
}
}
/// <summary>True when the live plate still has exactly the captured state.</summary>
@@ -85,6 +100,8 @@ public sealed class PlateCuttingState
if (plate.Quantity != quantity || !Bits(plate.Size.Width, size.Width)
|| !Bits(plate.Size.Length, size.Length) || plate.Quadrant != quadrant)
return "Plate quantity, size or quadrant changed.";
if (!ReferenceEquals(plate.CuttingParameters, plateSettings) || !SameSettings(plateSettings))
return "Plate cutting settings changed.";
if (plate.Parts.Count != parts.Length)
return "Parts were added or removed.";
for (var i = 0; i < parts.Length; i++)
@@ -108,10 +125,14 @@ public sealed class PlateCuttingState
return $"Part {i + 1} bounds changed.";
if (!ProgramContent.Equal(part.Program, record.Program, token))
return $"Part {i + 1} program was edited in place.";
var (drawingProgram, drawingCopy) = drawings[part.BaseDrawing];
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))
return $"Part {i + 1} drawing program changed.";
if (part.BaseDrawing.IsCutOff != isCutOff)
return $"Part {i + 1} cutoff classification changed.";
}
if (plate.CutOffs.Count != cutOffs.Length)
return "Cutoffs were added or removed.";
@@ -128,6 +149,21 @@ public sealed class PlateCuttingState
// Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
// all compared exactly above.
return null;
// References were compared already; this catches in-place edits of the same object.
bool SameSettings(CuttingParameters parameters)
{
if (parameters == null)
return true;
try
{
return settings.TryGetValue(parameters, out var captured) && captured == StateFingerprint.Of(parameters);
}
catch (NotSupportedException)
{
return false; // Grown past the exact fingerprint budget since capture.
}
}
}
private static long[] BoxValues(Box box) => box == null ? [] :
@@ -31,6 +31,9 @@ internal static class ProgramContent
if (!programOwners.Add(b))
return false;
programs.Add(a, b);
// Codes is a writable field: a missing list is a difference, never an exception.
if (a.Codes == null || b.Codes == null)
return a.Codes == null && b.Codes == null && a.GetType() == b.GetType();
if (a.GetType() != b.GetType() || a.Mode != b.Mode || !Bits(a.Rotation, b.Rotation)
|| a.Codes.Count != b.Codes.Count || a.SubPrograms.Count != b.SubPrograms.Count
|| !SameVariables(a.Variables, b.Variables))
@@ -81,7 +84,7 @@ internal static class ProgramContent
{
if (a == null || b == null)
return a == null && b == null;
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer))
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
if (!b.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal))
@@ -89,9 +92,13 @@ internal static class ProgramContent
return true;
}
// A case-insensitive dictionary finds a respelled key; authored spelling must still match.
private static bool SameSpelling(IEnumerable<string> a, IEnumerable<string> b) =>
new HashSet<string>(a, StringComparer.Ordinal).SetEquals(b);
private static bool SameVariables(Dictionary<string, VariableDefinition> a, Dictionary<string, VariableDefinition> b)
{
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer))
if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer) || !SameSpelling(a.Keys, b.Keys))
return false;
foreach (var (key, value) in a)
{
@@ -0,0 +1,107 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
internal static class StateFingerprint
{
private const int MaxDepth = 16;
private const int NodeBudget = 100000;
internal static string Of(object value)
{
var text = new StringBuilder();
var path = new HashSet<object>(ReferenceEqualityComparer.Instance);
var nodes = 0;
Append(value, 0);
return text.ToString();
void Append(object item, int depth)
{
if (++nodes > NodeBudget)
throw new NotSupportedException("Settings are too large to fingerprint exactly.");
if (item == null)
{
text.Append("null;");
return;
}
var type = item.GetType();
text.Append(type.FullName).Append('=');
switch (item)
{
case double number:
text.Append(BitConverter.DoubleToInt64Bits(number)).Append(';');
return;
case float number:
text.Append(BitConverter.SingleToInt32Bits(number)).Append(';');
return;
case string characters:
text.Append(characters.Length).Append(':').Append(characters).Append(';');
return;
}
if (type.IsPrimitive || type.IsEnum || item is decimal)
{
text.Append(Convert.ToString(item, CultureInfo.InvariantCulture)).Append(';');
return;
}
if (depth >= MaxDepth || (!type.IsValueType && !path.Add(item)))
{
text.Append("<depth-or-cycle>;");
return;
}
try
{
text.Append('{');
if (item is IEnumerable sequence)
{
foreach (var element in sequence)
Append(element, depth + 1);
}
else
{
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance)
.OrderBy(f => f.Name, StringComparer.Ordinal))
{
text.Append(field.Name).Append(':');
Append(field.GetValue(item), depth + 1);
}
if (!type.IsValueType)
foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.OrderBy(p => p.Name, StringComparer.Ordinal))
{
text.Append(property.Name).Append(':');
object read;
try
{
read = property.GetValue(item);
}
catch (TargetInvocationException exception)
{
text.Append("throws ").Append(exception.InnerException?.GetType().FullName).Append(';');
continue;
}
Append(read, depth + 1);
}
}
text.Append('}');
}
finally
{
if (!type.IsValueType)
path.Remove(item);
}
}
}
}
@@ -39,6 +39,20 @@ public class CuttingDependencyTests
Assert.Contains(q, plate.Parts);
}
[Fact]
public void Apply_CutOffReclassifiedAfterPlanning_IsStale()
{
var (_, plate, p, q, cut) = CutOffPlate(ExplicitContourTests.Parameters(), orphan: false);
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate, new Vector(11, 12.5)));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
cut.BaseDrawing.IsCutOff = false;
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Equal(new[] { p, q, cut }, plate.Parts);
}
[Fact]
public void Plan_OrphanedCutOff_PrecedesEveryPart()
{
@@ -45,16 +45,18 @@ public class CuttingPlanCommitTests
[Fact]
public void Apply_RegeneratedPlan_InstallsTheExactReplayedProgramWithOwnedSettings()
{
var (nest, plate, part, parameters) = RegeneratedPlate(Vector.Zero);
var (nest, plate, part, _) = RegeneratedPlate(Vector.Zero);
var location = part.Location;
var rotation = part.Rotation;
var quantity = part.BaseDrawing.Quantity.Nested;
// Caller-confirmed settings are planning input, not plate state: editing them after
// capture neither stales the plan nor leaks into what is installed.
var parameters = ExplicitContourTests.Parameters();
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate, confirmedParameters: parameters));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
var proposal = Assert.Single(result.ProposedOrder);
Assert.True(proposal.IsRegenerated);
// Settings edited after capture must not leak into what is installed.
((LineLeadIn)parameters.ExternalLeadIn).Length = length * 3;
var commit = CuttingPlanService.Apply([result]);
@@ -91,11 +93,17 @@ public class CuttingPlanCommitTests
[InlineData("same-name-drawing")]
[InlineData("list-replaced")]
[InlineData("added")]
[InlineData("cutoff-classification")]
[InlineData("part-settings-in-place")]
[InlineData("part-settings-nested")]
[InlineData("plate-settings-in-place")]
[InlineData("plate-settings-replaced")]
public void Apply_AnyChangeAfterCapture_IsStaleAndChangesNothing(string change)
{
var (_, plate, parts) = FixedPlate();
var cutOff = new CutOff(new Vector(30, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutOff);
plate.CuttingParameters = new CuttingParameters();
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
switch (change)
@@ -117,6 +125,13 @@ public class CuttingPlanCommitTests
foreach (var part in parts) list.Add(part);
plate.Parts = list; break;
case "added": plate.Parts.Add(Rectangle(20, 0, 2, 2)); break;
// Classification decides lead, material, obstacle and dependency treatment.
case "cutoff-classification": parts[0].BaseDrawing.IsCutOff = true; break;
// A regenerated part would otherwise overwrite an edit made after capture.
case "part-settings-in-place": parts[0].CuttingParameters.Kerf = 0.125; break;
case "part-settings-nested": parts[0].CuttingParameters.Assignment.Preference = "LIAT"; break;
case "plate-settings-in-place": plate.CuttingParameters.PierceClearance = 0.25; break;
case "plate-settings-replaced": plate.CuttingParameters = new CuttingParameters(); break;
}
var after = PlateCuttingState.Capture(plate);
var events = Watch(plate);
@@ -130,6 +145,40 @@ public class CuttingPlanCommitTests
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void Apply_MalformedLiveProgram_IsStaleInsteadOfThrowing()
{
var (_, plate, parts) = FixedPlate();
var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate));
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
parts[1].Program.Codes = null!;
var events = Watch(plate);
var commit = CuttingPlanService.Apply([result]);
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
Assert.Equal(parts, plate.Parts);
Assert.Null(parts[1].Program.Codes);
Assert.Equal((0, 0, 0), events());
}
[Fact]
public void ProgramContent_ComparesAuthoredKeySpellingExactly()
{
Program With(string key)
{
var program = new Program();
program.Codes.Add(new LinearMove(1, 0)
{
VariableRefs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { [key] = "v" }
});
return program;
}
Assert.True(ProgramContent.Equal(With("X"), With("X")));
Assert.False(ProgramContent.Equal(With("X"), With("x")));
}
[Fact]
public void Apply_LaterPlateStale_AppliesNothingAnywhere()
{