diff --git a/OpenNest.Core/CNC/CuttingPlanning/CuttingPlanCommit.cs b/OpenNest.Core/CNC/CuttingPlanning/CuttingPlanCommit.cs
new file mode 100644
index 0000000..3a99505
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/CuttingPlanCommit.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// An owned planned program for one unlocked part; ownership passes to the part on Apply.
+public sealed class PlannedPartProgram
+{
+ public PlannedPartProgram(Part part, Program program, CuttingParameters parameters)
+ {
+ Part = part;
+ Program = program;
+ Parameters = parameters;
+ }
+
+ public Part Part { get; }
+ public Program Program { get; }
+ public CuttingParameters Parameters { get; }
+}
+
+/// The verified order and planned programs for one plate, bound to its captured state.
+public sealed class PlateCuttingPlan
+{
+ public PlateCuttingPlan(PlateCuttingState expected, IEnumerable order,
+ IEnumerable programs = null)
+ {
+ Expected = expected;
+ Order = order == null ? null : Array.AsReadOnly(order.ToArray());
+ Programs = Array.AsReadOnly((programs ?? []).ToArray());
+ }
+
+ public PlateCuttingState Expected { get; }
+ public IReadOnlyList Order { get; }
+ public IReadOnlyList Programs { get; }
+}
+
+public enum CuttingCommitStatus
+{
+ Applied,
+ Stale,
+ InvalidInput,
+ Cancelled,
+ Failed
+}
+
+///
+/// Applied means every plate in scope holds its new state. RefreshErrors are observer failures
+/// raised after that consistent state was published; they are not a rollback. Every other
+/// status leaves every plate exactly as it was.
+///
+public sealed class CuttingCommitResult
+{
+ internal CuttingCommitResult(CuttingCommitStatus status, string message = null, Plate plate = null,
+ Exception error = null, IEnumerable refreshErrors = null)
+ {
+ Status = status;
+ Message = message;
+ Plate = plate;
+ Error = error;
+ RefreshErrors = Array.AsReadOnly((refreshErrors ?? []).ToArray());
+ }
+
+ public CuttingCommitStatus Status { get; }
+ public string Message { get; }
+ public Plate Plate { get; }
+ public Exception Error { get; }
+ public IReadOnlyList RefreshErrors { get; }
+}
+
+///
+/// Installs verified cutting plans for a whole scope at once. Nothing is searched, emitted,
+/// rotated or regenerated here: inputs are validated and checked for freshness, bounds are
+/// staged, then order and programs are installed synchronously and published once per plate.
+///
+public static class CuttingPlanCommit
+{
+ public static CuttingCommitResult Apply(IEnumerable plans, CancellationToken token = default) =>
+ Apply(plans, token, null);
+
+ // beforeInstall is a test seam that runs inside the install boundary, before each program.
+ internal static CuttingCommitResult Apply(IEnumerable plans, CancellationToken token,
+ Action beforeInstall)
+ {
+ if (token.IsCancellationRequested)
+ return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
+ var scope = plans?.ToArray();
+ if (scope == null || scope.Length == 0 || scope.Any(p => p?.Expected == null || p.Order == null))
+ return Invalid(null, "A nonempty set of captured plate plans is required.");
+ var plates = new HashSet(ReferenceEqualityComparer.Instance);
+ foreach (var plan in scope)
+ if (!plates.Add(plan.Expected.Plate))
+ return Invalid(plan.Expected.Plate, "A plate appears more than once in the commit scope.");
+
+ // Freshness for the whole scope before any validation that reads live geometry.
+ foreach (var plan in scope)
+ {
+ string difference;
+ try
+ {
+ difference = plan.Expected.Difference(token);
+ }
+ catch (OperationCanceledException)
+ {
+ return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
+ }
+ if (difference != null)
+ return new(CuttingCommitStatus.Stale, difference, plan.Expected.Plate);
+ }
+
+ var staged = new List(scope.Length);
+ var targets = new HashSet(ReferenceEqualityComparer.Instance);
+ var installed = new HashSet(ReferenceEqualityComparer.Instance);
+ var live = new HashSet(scope.SelectMany(p => p.Expected.Order).Select(p => p.Program),
+ ReferenceEqualityComparer.Instance);
+ foreach (var plan in scope)
+ {
+ var plate = plan.Expected.Plate;
+ Part[] order;
+ try
+ {
+ order = plate.Parts.ValidateReorder(plan.Order);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ return Invalid(plate, "The planned order is not exactly the plate's current parts.");
+ }
+ var members = new HashSet(order, ReferenceEqualityComparer.Instance);
+ var programs = new List<(Part, Program, Box, CuttingParameters)>();
+ foreach (var planned in plan.Programs)
+ {
+ if (planned?.Part == null || !members.Contains(planned.Part) || !targets.Add(planned.Part))
+ return Invalid(plate, "Planned programs must target distinct parts of their own plate.");
+ if (planned.Part.LeadInsLocked)
+ return Invalid(plate, "A locked part's program is retained exactly and cannot be replaced.");
+ if (planned.Program == null || planned.Parameters == null || live.Contains(planned.Program)
+ || !installed.Add(planned.Program))
+ return Invalid(plate, "Planned programs and settings must be present, owned and unshared.");
+ if (!planned.Program.Codes.Any(code => code is Motion
+ || code is SubProgramCall call && call.Program?.Codes.Any(sub => sub is Motion) == true))
+ return Invalid(plate, "A planned program has no motion.");
+ Box bounds;
+ try
+ {
+ bounds = planned.Program.BoundingBox();
+ bounds.Offset(planned.Part.Location);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
+ or NotSupportedException or ArithmeticException)
+ {
+ return Invalid(plate, "A planned program's bounds cannot be computed.");
+ }
+ programs.Add((planned.Part, planned.Program, bounds, planned.Parameters));
+ }
+ var reordered = !order.SequenceEqual(plate.Parts, ReferenceEqualityComparer.Instance);
+ staged.Add(new(plate, order, programs, reordered || programs.Count != 0));
+ }
+
+ // Last cancellation point. The boundary below has no await, search or geometry work.
+ if (token.IsCancellationRequested)
+ return new(CuttingCommitStatus.Cancelled, "Cancelled before commit.");
+
+ var undo = new List<(Plate Plate, Part[] Order, (Part Part, PartCuttingState State)[] States)>();
+ try
+ {
+ foreach (var item in staged)
+ {
+ undo.Add((item.Plate, item.Plate.Parts.ToArray(),
+ item.Programs.Select(p => (p.Part, p.Part.CaptureCuttingState())).ToArray()));
+ item.Plate.Parts.SetOrder(item.Order);
+ foreach (var (part, program, bounds, parameters) in item.Programs)
+ {
+ beforeInstall?.Invoke(item.Plate, part);
+ part.InstallPlannedProgram(program, bounds, parameters);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ for (var i = undo.Count - 1; i >= 0; i--)
+ {
+ foreach (var (part, state) in undo[i].States)
+ part.RestoreCuttingState(state);
+ undo[i].Plate.Parts.SetOrder(undo[i].Order);
+ }
+ return new(CuttingCommitStatus.Failed, "Install failed; every plate was restored.",
+ undo.Count == 0 ? null : undo[^1].Plate, ex);
+ }
+
+ // Publish only after the whole scope is consistent.
+ var errors = new List();
+ foreach (var item in staged.Where(s => s.Changed))
+ item.Plate.Parts.RaiseItemsReordered(errors);
+ return new(CuttingCommitStatus.Applied, errors.Count == 0 ? null
+ : "Applied; one or more views failed to refresh.", refreshErrors: errors);
+ }
+
+ private static CuttingCommitResult Invalid(Plate plate, string message) =>
+ new(CuttingCommitStatus.InvalidInput, message, plate);
+
+ private sealed record Staged(Plate Plate, Part[] Order,
+ List<(Part Part, Program Program, Box Bounds, CuttingParameters Parameters)> Programs, bool Changed);
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PartCuttingState.cs b/OpenNest.Core/CNC/CuttingPlanning/PartCuttingState.cs
new file mode 100644
index 0000000..0a054ec
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/PartCuttingState.cs
@@ -0,0 +1,9 @@
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+/// Every Part field a cutting commit can change, held by reference for exact rollback.
+internal sealed record PartCuttingState(Program Program, bool OwnsProgram, double PreLeadInRotation,
+ bool HasManualLeadIns, bool LeadInsLocked, CuttingParameters CuttingParameters, Vector Location,
+ Box BoundingBox);
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
new file mode 100644
index 0000000..2cae3a8
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/PlateCuttingState.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Collections;
+using OpenNest.Geometry;
+
+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, cutoff definitions, and each
+/// part's complete cutting state with owned copies of its placed and drawing programs.
+/// A commit compares it with the live plate and refuses when anything differs.
+///
+public sealed class PlateCuttingState
+{
+ private readonly ObservableList partList;
+ private readonly PartRecord[] parts;
+ private readonly int quantity;
+ private readonly Size size;
+ private readonly int quadrant;
+ private readonly ObservableList cutOffList;
+ private readonly CutOffRecord[] cutOffs;
+ private readonly Dictionary drawings;
+
+ private PlateCuttingState(Plate plate, PartRecord[] parts,
+ CutOffRecord[] cutOffs, Dictionary drawings)
+ {
+ Plate = plate;
+ partList = plate.Parts;
+ this.parts = parts;
+ quantity = plate.Quantity;
+ size = plate.Size;
+ quadrant = plate.Quadrant;
+ cutOffList = plate.CutOffs;
+ this.cutOffs = cutOffs;
+ this.drawings = drawings;
+ Order = Array.AsReadOnly(parts.Select(p => p.Part).ToArray());
+ }
+
+ public Plate Plate { get; }
+
+ /// Captured part order (reference identity).
+ public IReadOnlyList Order { get; }
+
+ ///
+ /// Captures on the caller thread. Unsupported or malformed programs throw
+ /// or .
+ ///
+ public static PlateCuttingState Capture(Plate plate, CancellationToken token = default)
+ {
+ 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 records = new List(plate.Parts.Count);
+ foreach (var part in plate.Parts)
+ {
+ token.ThrowIfCancellationRequested();
+ if (part?.BaseDrawing == null || part.Program == null)
+ 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)));
+ var state = part.CaptureCuttingState();
+ 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);
+ }
+
+ /// True when the live plate still has exactly the captured state.
+ public bool IsCurrent(CancellationToken token = default) => Difference(token) == null;
+
+ /// Null when current; otherwise the first observed difference.
+ public string Difference(CancellationToken token = default)
+ {
+ var plate = Plate;
+ if (!ReferenceEquals(plate.Parts, partList) || !ReferenceEquals(plate.CutOffs, cutOffList))
+ return "The plate's part or cutoff list was replaced.";
+ 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 (plate.Parts.Count != parts.Length)
+ return "Parts were added or removed.";
+ for (var i = 0; i < parts.Length; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ var record = parts[i];
+ var part = plate.Parts[i];
+ if (!ReferenceEquals(part, record.Part))
+ return $"Part order changed at position {i + 1}.";
+ var live = part.CaptureCuttingState();
+ var was = record.State;
+ if (!ReferenceEquals(live.Program, was.Program) || live.OwnsProgram != was.OwnsProgram
+ || !ReferenceEquals(live.CuttingParameters, was.CuttingParameters)
+ || !ReferenceEquals(live.BoundingBox, was.BoundingBox))
+ return $"Part {i + 1} program, settings or bounds were replaced.";
+ if (!Bits(live.Location.X, was.Location.X) || !Bits(live.Location.Y, was.Location.Y)
+ || !Bits(live.PreLeadInRotation, was.PreLeadInRotation)
+ || live.HasManualLeadIns != was.HasManualLeadIns || live.LeadInsLocked != was.LeadInsLocked)
+ return $"Part {i + 1} pose, lead-in or lock state changed.";
+ if (!BoxValues(live.BoundingBox).SequenceEqual(record.Bounds))
+ 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 (!ReferenceEquals(part.BaseDrawing.Program, drawingProgram)
+ || !ProgramContent.Equal(drawingProgram, drawingCopy, token))
+ return $"Part {i + 1} drawing program changed.";
+ }
+ if (plate.CutOffs.Count != cutOffs.Length)
+ return "Cutoffs were added or removed.";
+ for (var i = 0; i < cutOffs.Length; i++)
+ {
+ var record = cutOffs[i];
+ var cutOff = plate.CutOffs[i];
+ if (!ReferenceEquals(cutOff, record.CutOff) || !ReferenceEquals(cutOff.Drawing, record.Drawing)
+ || cutOff.Axis != record.Axis || !Bits(cutOff.Position.X, record.Position.X)
+ || !Bits(cutOff.Position.Y, record.Position.Y)
+ || !Bits(cutOff.StartLimit, record.StartLimit) || !Bits(cutOff.EndLimit, record.EndLimit))
+ return $"Cutoff {i + 1} definition changed.";
+ }
+ // Part.Rotation derives from the manual flag, PreLeadInRotation and Program.Rotation,
+ // all compared exactly above.
+ return null;
+ }
+
+ private static long[] BoxValues(Box box) => box == null ? [] :
+ [BitConverter.DoubleToInt64Bits(box.X), BitConverter.DoubleToInt64Bits(box.Y),
+ BitConverter.DoubleToInt64Bits(box.Width), BitConverter.DoubleToInt64Bits(box.Length)];
+
+ private static bool Bits(double a, double b) =>
+ BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
+
+ 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 CutOffRecord(CutOff CutOff, Drawing Drawing, CutOffAxis Axis, Vector Position,
+ double? StartLimit, double? EndLimit);
+}
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ProgramContent.cs b/OpenNest.Core/CNC/CuttingPlanning/ProgramContent.cs
new file mode 100644
index 0000000..72c6d2d
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingPlanning/ProgramContent.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingPlanning;
+
+///
+/// Exact structural equality of two program graphs: instruction runtime types, scalar bits,
+/// motion metadata, variables and the same instruction/sub-program sharing shape. This is
+/// a freshness check, not geometric equivalence; unsupported instruction types never match.
+///
+internal static class ProgramContent
+{
+ internal static bool Equal(Program left, Program right, CancellationToken token = default)
+ {
+ var programs = new Dictionary(ReferenceEqualityComparer.Instance);
+ var programOwners = new HashSet(ReferenceEqualityComparer.Instance);
+ var codes = new Dictionary(ReferenceEqualityComparer.Instance);
+ var codeOwners = new HashSet(ReferenceEqualityComparer.Instance);
+ return SameProgram(left, right);
+
+ bool SameProgram(Program a, Program b)
+ {
+ token.ThrowIfCancellationRequested();
+ if (a == null || b == null)
+ return a == null && b == null;
+ // Pair graphs one-to-one so shared and distinct sub-programs cannot be confused.
+ if (programs.TryGetValue(a, out var paired))
+ return ReferenceEquals(paired, b);
+ if (!programOwners.Add(b))
+ return false;
+ programs.Add(a, b);
+ 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))
+ return false;
+ for (var i = 0; i < a.Codes.Count; i++)
+ if (!SameCode(a.Codes[i], b.Codes[i]))
+ return false;
+ foreach (var (id, child) in a.SubPrograms)
+ if (!b.SubPrograms.TryGetValue(id, out var other) || !SameProgram(child, other))
+ return false;
+ return true;
+ }
+
+ bool SameCode(ICode a, ICode b)
+ {
+ token.ThrowIfCancellationRequested();
+ if (a == null || b == null)
+ return a == null && b == null;
+ if (codes.TryGetValue(a, out var paired))
+ return ReferenceEquals(paired, b);
+ if (!codeOwners.Add(b))
+ return false;
+ codes.Add(a, b);
+ if (a.GetType() != b.GetType())
+ return false;
+ return (a, b) switch
+ {
+ (RapidMove x, RapidMove y) => SameMotion(x, y),
+ (LinearMove x, LinearMove y) => SameMotion(x, y) && x.Layer == y.Layer,
+ (ArcMove x, ArcMove y) => SameMotion(x, y) && x.Layer == y.Layer
+ && x.Rotation == y.Rotation && Bits(x.CenterPoint, y.CenterPoint),
+ (SubProgramCall x, SubProgramCall y) => x.Id == y.Id && Bits(x.Offset, y.Offset)
+ && Bits(x.Rotation, y.Rotation) && SameProgram(x.Program, y.Program),
+ (Comment x, Comment y) => string.Equals(x.Value, y.Value, StringComparison.Ordinal),
+ (Feedrate x, Feedrate y) => Bits(x.Value, y.Value)
+ && string.Equals(x.VariableRef, y.VariableRef, StringComparison.Ordinal),
+ (Kerf x, Kerf y) => x.Value == y.Value,
+ _ => false
+ };
+ }
+ }
+
+ private static bool SameMotion(Motion a, Motion b) => Bits(a.EndPoint, b.EndPoint)
+ && a.UseExactStop == b.UseExactStop && a.Feedrate == b.Feedrate && a.Suppressed == b.Suppressed
+ && SameRefs(a.VariableRefs, b.VariableRefs);
+
+ private static bool SameRefs(Dictionary a, Dictionary b)
+ {
+ if (a == null || b == null)
+ return a == null && b == null;
+ if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer))
+ return false;
+ foreach (var (key, value) in a)
+ if (!b.TryGetValue(key, out var other) || !string.Equals(value, other, StringComparison.Ordinal))
+ return false;
+ return true;
+ }
+
+ private static bool SameVariables(Dictionary a, Dictionary b)
+ {
+ if (a.Count != b.Count || !a.Comparer.Equals(b.Comparer))
+ return false;
+ foreach (var (key, value) in a)
+ {
+ if (!b.TryGetValue(key, out var other))
+ return false;
+ if (ReferenceEquals(value, other))
+ continue;
+ if (value == null || other == null
+ || !string.Equals(value.Name, other.Name, StringComparison.Ordinal)
+ || !string.Equals(value.Expression, other.Expression, StringComparison.Ordinal)
+ || !Bits(value.Value, other.Value) || value.Inline != other.Inline || value.Global != other.Global)
+ return false;
+ }
+ return true;
+ }
+
+ private static bool Bits(Vector a, Vector b) => Bits(a.X, b.X) && Bits(a.Y, b.Y);
+
+ private static bool Bits(double a, double b) =>
+ BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
+}
diff --git a/OpenNest.Core/Collections/ObservableList.cs b/OpenNest.Core/Collections/ObservableList.cs
index cd79bb2..f2ae45e 100644
--- a/OpenNest.Core/Collections/ObservableList.cs
+++ b/OpenNest.Core/Collections/ObservableList.cs
@@ -13,6 +13,9 @@ namespace OpenNest.Collections
public event EventHandler> ItemRemoved;
public event EventHandler> ItemChanged;
+ /// Raised once after (or a cutting commit) installs a new order.
+ public event EventHandler ItemsReordered;
+
public ObservableList()
{
items = new List();
@@ -64,6 +67,68 @@ namespace OpenNest.Collections
RemoveAt(i);
}
+ ///
+ /// Changes only the order of the current items. must hold exactly
+ /// the current non-null items: the same references with the same multiplicity. No
+ /// ItemAdded/ItemRemoved is raised, so quantity accounting is untouched; ItemsReordered is
+ /// raised once after the new order is installed. Invalid input throws before any change.
+ ///
+ public void Reorder(IEnumerable order)
+ {
+ SetOrder(ValidateReorder(order));
+ ItemsReordered?.Invoke(this, EventArgs.Empty);
+ }
+
+ internal T[] ValidateReorder(IEnumerable order)
+ {
+ ArgumentNullException.ThrowIfNull(order);
+ var proposed = order.ToArray();
+ if (proposed.Length != items.Count)
+ throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
+ var comparer = typeof(T).IsValueType
+ ? EqualityComparer.Default
+ : (IEqualityComparer)(object)ReferenceEqualityComparer.Instance;
+ var counts = new Dictionary(comparer);
+ foreach (var item in items)
+ {
+ if (item == null)
+ throw new InvalidOperationException("A list holding null items cannot be reordered.");
+ counts[item] = counts.TryGetValue(item, out var count) ? count + 1 : 1;
+ }
+ foreach (var item in proposed)
+ {
+ if (item == null || !counts.TryGetValue(item, out var count) || count == 0)
+ throw new ArgumentException("A reorder must contain exactly the current items.", nameof(order));
+ counts[item] = count - 1;
+ }
+ return proposed;
+ }
+
+ // Validated order only; raises nothing so a commit can publish after a whole scope installs.
+ internal void SetOrder(T[] order)
+ {
+ items.Clear();
+ items.AddRange(order);
+ }
+
+ // Invokes every observer even if one throws, so a refresh failure cannot starve the rest.
+ internal void RaiseItemsReordered(ICollection errors)
+ {
+ if (ItemsReordered == null)
+ return;
+ foreach (var handler in ItemsReordered.GetInvocationList())
+ {
+ try
+ {
+ ((EventHandler)handler)(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ errors.Add(ex);
+ }
+ }
+ }
+
public int IndexOf(T item)
{
return items.IndexOf(item);
diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs
index 9991ad7..4d12c80 100644
--- a/OpenNest.Core/Part.cs
+++ b/OpenNest.Core/Part.cs
@@ -135,6 +135,39 @@ namespace OpenNest
return true;
}
+ /// Exact cutting-state record for freshness and rollback; references are not copied.
+ internal CNC.CuttingPlanning.PartCuttingState CaptureCuttingState() =>
+ new(Program, ownsProgram, preLeadInRotation, HasManualLeadIns, LeadInsLocked,
+ CuttingParameters, location, BoundingBox);
+
+ /// Reinstates a state captured from this part, field for field.
+ internal void RestoreCuttingState(CNC.CuttingPlanning.PartCuttingState state)
+ {
+ Program = state.Program;
+ ownsProgram = state.OwnsProgram;
+ preLeadInRotation = state.PreLeadInRotation;
+ HasManualLeadIns = state.HasManualLeadIns;
+ LeadInsLocked = state.LeadInsLocked;
+ CuttingParameters = state.CuttingParameters;
+ location = state.Location;
+ BoundingBox = state.BoundingBox;
+ }
+
+ ///
+ /// Installs an owned, prevalidated planned program and its precomputed placed bounds.
+ /// Pose and lock are unchanged; nothing is regenerated or rotated here.
+ ///
+ internal void InstallPlannedProgram(Program program, Box bounds,
+ CNC.CuttingStrategy.CuttingParameters parameters)
+ {
+ preLeadInRotation = Rotation;
+ Program = program;
+ ownsProgram = true;
+ CuttingParameters = parameters;
+ HasManualLeadIns = true;
+ BoundingBox = bounds;
+ }
+
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs
index 16eb22c..0f3cf96 100644
--- a/OpenNest.Core/Plate.cs
+++ b/OpenNest.Core/Plate.cs
@@ -30,6 +30,17 @@ namespace OpenNest
remove { Parts.ItemChanged -= value; }
}
+ ///
+ /// Raised once after the part order changes without adding or removing parts, including
+ /// a committed cutting plan that also installed planned programs. Refresh order- and
+ /// program-derived views; quantity accounting is unchanged.
+ ///
+ public event EventHandler PartsReordered
+ {
+ add { Parts.ItemsReordered += value; }
+ remove { Parts.ItemsReordered -= value; }
+ }
+
public Plate()
: this(60, 120) { }
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
index e272b29..58128b2 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
@@ -28,6 +28,21 @@ public sealed class CuttingPlanRequest
MaxEntries = maxEntries;
}
+ ///
+ /// Plate scope: plans the plate's current parts and captures its exact state, so a Ready
+ /// result can later be applied through .
+ ///
+ public CuttingPlanRequest(Plate plate, Vector startPoint = default, int expansionBudget = 20000,
+ CuttingParameters confirmedParameters = null, IEnumerable eligibleParts = null,
+ bool preservePartOrder = false, int maxEntries = 16)
+ : this(plate?.Parts, startPoint, expansionBudget, confirmedParameters, eligibleParts,
+ preservePartOrder, maxEntries)
+ {
+ Plate = plate;
+ }
+
+ /// The plate scope, or null for a detached part list that cannot be applied.
+ public Plate Plate { get; }
public CuttingParameters ConfirmedParameters { get; }
public IReadOnlyList EligibleParts { get; }
public bool PreservePartOrder { get; }
@@ -44,8 +59,11 @@ public sealed class CuttingPlanSnapshot
internal CuttingPlanSnapshot(IEnumerable placements, Vector startPoint,
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable findings = null,
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
- Action expansionObserver = null)
+ Action expansionObserver = null, PlateCuttingState plateState = null,
+ CuttingParameters ownedParameters = null)
{
+ PlateState = plateState;
+ OwnedParameters = ownedParameters;
Placements = Array.AsReadOnly(placements.ToArray());
StartPoint = startPoint;
ExpansionBudget = expansionBudget;
@@ -57,6 +75,10 @@ public sealed class CuttingPlanSnapshot
ExpansionObserver = expansionObserver;
}
+ /// Exact captured plate state for plate-scoped requests; null for detached part lists.
+ internal PlateCuttingState PlateState { get; }
+ /// Owned copy of the confirmed parameters taken at capture; never the caller's object.
+ internal CuttingParameters OwnedParameters { get; }
internal bool Regeneration { get; }
internal bool PreservePartOrder { get; }
internal int MaxEntries { get; }
@@ -162,4 +184,6 @@ public sealed class CuttingPlanResult
public int Expansions { get; }
public double RapidDistance { get; }
public bool IndependentlyReplayed { get; }
+ /// The captured input this result was planned from; binds Apply to its freshness record.
+ internal CuttingPlanSnapshot Snapshot { get; set; }
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index 38072fe..684a707 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -23,6 +23,16 @@ public static class CuttingPlanService
try
{
token.ThrowIfCancellationRequested();
+ PlateCuttingState plateState = null;
+ if (request?.Plate != null)
+ {
+ // Exact freshness record first, on the caller thread; the planned list must be it.
+ plateState = PlateCuttingState.Capture(request.Plate, token);
+ if (request.Parts == null || !request.Parts.SequenceEqual(plateState.Order, ReferenceEqualityComparer.Instance))
+ throw new ArgumentException("The plate's parts changed after the request was created.");
+ if (request.Parts.Count == 0 && request.ExpansionBudget > 0 && request.MaxEntries > 0)
+ return new([], request.StartPoint, request.ExpansionBudget, plateState: plateState);
+ }
if (request?.Parts == null || request.Parts.Count == 0 || request.ExpansionBudget <= 0 || request.MaxEntries <= 0)
throw new ArgumentException("A nonempty source list and positive expansion budget are required.");
var eligible = new HashSet(ReferenceEqualityComparer.Instance);
@@ -80,7 +90,9 @@ public static class CuttingPlanService
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
- expansionObserver: request.ExpansionObserver);
+ expansionObserver: request.ExpansionObserver, plateState: plateState,
+ ownedParameters: request.ConfirmedParameters == null ? null
+ : OwnedCuttingParameters.Copy(request.ConfirmedParameters));
}
catch (OperationCanceledException)
{
@@ -105,6 +117,51 @@ public static class CuttingPlanService
/// Worker-only planning uses owned values; live identities are never dereferenced.
public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default)
+ {
+ var result = PlanCaptured(snapshot, token);
+ result.Snapshot = snapshot;
+ return result;
+ }
+
+ ///
+ /// Installs exactly the replayed proposals of Ready plate-scoped results, all or nothing.
+ /// Each plate must still match the state captured with its request; otherwise Stale and
+ /// nothing changes. Run on the thread that owns the plates. Never replans.
+ ///
+ public static CuttingCommitResult Apply(IEnumerable results, CancellationToken token = default) =>
+ Apply(results, token, null);
+
+ // beforeInstall is the commit's install-boundary test seam.
+ internal static CuttingCommitResult Apply(IEnumerable results, CancellationToken token,
+ Action beforeInstall)
+ {
+ var plans = new List();
+ foreach (var result in results ?? [])
+ {
+ var snapshot = result?.Snapshot;
+ if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed
+ || snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
+ return new(CuttingCommitStatus.InvalidInput,
+ "Only Ready, independently replayed plate-scoped proposals can be applied.");
+ var programs = new List();
+ foreach (var proposal in result.ProposedOrder)
+ {
+ if (!proposal.IsRegenerated)
+ continue;
+ if (snapshot.OwnedParameters == null)
+ return new(CuttingCommitStatus.InvalidInput, "A regenerated proposal has no captured settings.",
+ snapshot.PlateState.Plate);
+ // Fresh owned copies: a result can be applied at most once per captured state,
+ // and nothing installed aliases the proposal or another part's settings.
+ programs.Add(new(proposal.SourcePart, proposal.CopyProgram(),
+ OwnedCuttingParameters.Copy(snapshot.OwnedParameters)));
+ }
+ plans.Add(new(snapshot.PlateState, result.ProposedOrder.Select(p => p.SourcePart), programs));
+ }
+ return CuttingPlanCommit.Apply(plans, token, beforeInstall);
+ }
+
+ private static CuttingPlanResult PlanCaptured(CuttingPlanSnapshot snapshot, CancellationToken token)
{
if (token.IsCancellationRequested)
return new(CuttingPlanStatus.Cancelled);
@@ -112,6 +169,9 @@ public static class CuttingPlanService
return new(CuttingPlanStatus.InvalidInput);
if (snapshot.Failure is { } failure)
return new(failure, findings: snapshot.Findings);
+ if (snapshot.Placements.Count == 0)
+ return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
+ : new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
try
{
var fixedFindings = new List();
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanCommitTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanCommitTests.cs
new file mode 100644
index 0000000..4abc7e7
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanCommitTests.cs
@@ -0,0 +1,475 @@
+using System.Reflection;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Collections;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class CuttingPlanCommitTests
+{
+ [Fact]
+ public void Apply_FixedProgramPlan_ReordersWithoutAccountingEventsAndPublishesOnce()
+ {
+ var (nest, plate, parts) = FixedPlate();
+ var programs = parts.Select(p => p.Program).ToArray();
+ var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
+ var events = Watch(plate);
+ Assert.Contains(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
+ f => f.Kind == PostVerificationKind.RapidCrossing);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate));
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.Equal(parts, plate.Parts); // Planning never mutates.
+ var commit = CuttingPlanService.Apply([result]);
+
+ Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
+ Assert.Empty(commit.RefreshErrors);
+ Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
+ Assert.Equal((0, 0, 1), events());
+ Assert.Equal(quantities, parts.Select(p => p.BaseDrawing.Quantity.Nested));
+ Assert.Equal(programs, parts.Select(p => p.Program)); // Fixed programs are not replaced.
+ Assert.DoesNotContain(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings,
+ f => f.Kind == PostVerificationKind.RapidCrossing);
+
+ // The same proposal is bound to the captured state, which no longer exists.
+ var again = CuttingPlanService.Apply([result]);
+ Assert.Equal(CuttingCommitStatus.Stale, again.Status);
+ Assert.Equal(new[] { parts[1], parts[0], parts[2] }, plate.Parts);
+ Assert.Equal((0, 0, 1), events());
+ }
+
+ [Fact]
+ public void Apply_RegeneratedPlan_InstallsTheExactReplayedProgramWithOwnedSettings()
+ {
+ var (nest, plate, part, parameters) = RegeneratedPlate(Vector.Zero);
+ var location = part.Location;
+ var rotation = part.Rotation;
+ var quantity = part.BaseDrawing.Quantity.Nested;
+ 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]);
+
+ Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
+ Assert.True(ProgramContent.Equal(proposal.CopyProgram(), part.Program));
+ Assert.NotSame(parameters, part.CuttingParameters);
+ Assert.Equal(length, ((LineLeadIn)part.CuttingParameters.ExternalLeadIn).Length);
+ Assert.True(part.HasManualLeadIns);
+ Assert.False(part.LeadInsLocked);
+ Assert.Equal(BitConverter.DoubleToInt64Bits(location.X), BitConverter.DoubleToInt64Bits(part.Location.X));
+ Assert.Equal(BitConverter.DoubleToInt64Bits(location.Y), BitConverter.DoubleToInt64Bits(part.Location.Y));
+ Assert.Equal(BitConverter.DoubleToInt64Bits(rotation), BitConverter.DoubleToInt64Bits(part.Rotation));
+ Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
+ var bounds = part.Program.BoundingBox();
+ bounds.Offset(part.Location);
+ Assert.Equal((bounds.X, bounds.Y, bounds.Width, bounds.Length),
+ (part.BoundingBox.X, part.BoundingBox.Y, part.BoundingBox.Width, part.BoundingBox.Length));
+ // The installed program is owned: later proposal copies cannot alias it.
+ var detached = proposal.CopyProgram();
+ detached.Codes.Clear();
+ Assert.NotEmpty(part.Program.Codes);
+ Assert.Empty(PostVerificationAnalyzer.Analyze(nest, Vector.Zero).Findings);
+ }
+
+ [Theory]
+ [InlineData("order")]
+ [InlineData("pose")]
+ [InlineData("program-in-place")]
+ [InlineData("program-replaced")]
+ [InlineData("lock")]
+ [InlineData("quantity")]
+ [InlineData("cutoff")]
+ [InlineData("same-name-drawing")]
+ [InlineData("list-replaced")]
+ [InlineData("added")]
+ 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);
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate));
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ switch (change)
+ {
+ case "order": plate.Parts.Reorder([parts[2], parts[1], parts[0]]); break;
+ case "pose": parts[0].Offset(1e-12, 0); break;
+ case "program-in-place": ((Motion)parts[1].Program.Codes[1]).Feedrate = 7; break;
+ case "program-replaced":
+ Assert.True(parts[1].RestoreLeadInProgram((Program)parts[1].Program.Clone(), false)); break;
+ case "lock": parts[2].LeadInsLocked = true; break;
+ case "quantity": plate.Quantity = 2; break;
+ case "cutoff":
+ cutOff.Position = new Vector(BitConverter.Int64BitsToDouble(
+ BitConverter.DoubleToInt64Bits(30.0) + 1), 0); break;
+ // Every fixture drawing has the same name: identity must be by reference.
+ case "same-name-drawing": parts[2].BaseDrawing.Program.Codes.Add(new Comment("edited")); break;
+ case "list-replaced":
+ var list = new ObservableList();
+ foreach (var part in parts) list.Add(part);
+ plate.Parts = list; break;
+ case "added": plate.Parts.Add(Rectangle(20, 0, 2, 2)); break;
+ }
+ var after = PlateCuttingState.Capture(plate);
+ var events = Watch(plate);
+
+ var commit = CuttingPlanService.Apply([result]);
+
+ Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
+ Assert.Same(plate, commit.Plate);
+ Assert.False(string.IsNullOrEmpty(commit.Message));
+ Assert.True(after.IsCurrent(), after.Difference());
+ Assert.Equal((0, 0, 0), events());
+ }
+
+ [Fact]
+ public void Apply_LaterPlateStale_AppliesNothingAnywhere()
+ {
+ var (_, first, firstParts) = FixedPlate();
+ var (_, second, secondParts) = FixedPlate();
+ var results = new[]
+ {
+ CuttingPlanService.Plan(new CuttingPlanRequest(first)),
+ CuttingPlanService.Plan(new CuttingPlanRequest(second))
+ };
+ secondParts[0].LeadInsLocked = true;
+ var firstState = PlateCuttingState.Capture(first);
+ var events = Watch(first);
+
+ var commit = CuttingPlanService.Apply(results);
+
+ Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
+ Assert.Same(second, commit.Plate);
+ Assert.Equal(firstParts, first.Parts);
+ Assert.True(firstState.IsCurrent(), firstState.Difference());
+ Assert.Equal((0, 0, 0), events());
+ }
+
+ [Fact]
+ public void Apply_InstallFailureOnLaterPlate_RestoresEveryPlateExactlyWithoutPublishing()
+ {
+ var (_, first, firstParts) = FixedPlate();
+ var (_, second, part, parameters) = RegeneratedPlate(Vector.Zero);
+ var (_, third, thirdPart, thirdParameters) = RegeneratedPlate(Vector.Zero);
+ var results = new[]
+ {
+ CuttingPlanService.Plan(new CuttingPlanRequest(first)),
+ CuttingPlanService.Plan(new CuttingPlanRequest(second, confirmedParameters: parameters)),
+ CuttingPlanService.Plan(new CuttingPlanRequest(third, confirmedParameters: thirdParameters))
+ };
+ Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
+ var states = new[] { first, second, third }.Select(p => PlateCuttingState.Capture(p)).ToArray();
+ var program = part.Program;
+ var bounds = part.BoundingBox;
+ var events = new[] { Watch(first), Watch(second), Watch(third) };
+ var failure = new InvalidOperationException("injected");
+ var installs = 0;
+
+ var commit = CuttingPlanService.Apply(results, default, (plate, _) =>
+ {
+ installs++;
+ if (ReferenceEquals(plate, third)) throw failure;
+ });
+
+ Assert.Equal(CuttingCommitStatus.Failed, commit.Status);
+ Assert.Same(failure, commit.Error);
+ Assert.Equal(2, installs); // The second plate really was installed before the failure.
+ Assert.Equal(firstParts, first.Parts);
+ Assert.Same(program, part.Program);
+ Assert.Same(bounds, part.BoundingBox);
+ Assert.Same(parameters, part.CuttingParameters);
+ Assert.All(states, s => Assert.True(s.IsCurrent(), s.Difference()));
+ Assert.All(events, e => Assert.Equal((0, 0, 0), e()));
+ Assert.Same(thirdParameters, thirdPart.CuttingParameters);
+ }
+
+ [Fact]
+ public void Apply_CancelledBeforeCommit_ChangesNothing()
+ {
+ var (_, plate, parts) = FixedPlate();
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest(plate));
+ var state = PlateCuttingState.Capture(plate);
+ var events = Watch(plate);
+ using var cancel = new CancellationTokenSource();
+ cancel.Cancel();
+
+ var commit = CuttingPlanService.Apply([result], cancel.Token);
+
+ Assert.Equal(CuttingCommitStatus.Cancelled, commit.Status);
+ Assert.Equal(parts, plate.Parts);
+ Assert.True(state.IsCurrent(), state.Difference());
+ Assert.Equal((0, 0, 0), events());
+ }
+
+ [Fact]
+ public void Apply_ObserverFailureAfterPublication_IsAReportedRefreshFailureNotARollback()
+ {
+ var (_, first, firstParts) = FixedPlate();
+ var (_, second, secondParts) = FixedPlate();
+ var results = new[]
+ {
+ CuttingPlanService.Plan(new CuttingPlanRequest(first)),
+ CuttingPlanService.Plan(new CuttingPlanRequest(second))
+ };
+ var failure = new InvalidOperationException("view refresh");
+ var seen = new List();
+ first.PartsReordered += (_, _) => throw failure;
+ first.PartsReordered += (_, _) => seen.Add("first");
+ second.PartsReordered += (_, _) => seen.Add("second");
+
+ var commit = CuttingPlanService.Apply(results);
+
+ Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
+ Assert.Same(failure, Assert.Single(commit.RefreshErrors));
+ Assert.Equal(new[] { "first", "second" }, seen);
+ Assert.Equal(new[] { firstParts[1], firstParts[0], firstParts[2] }, first.Parts);
+ Assert.Equal(new[] { secondParts[1], secondParts[0], secondParts[2] }, second.Parts);
+ }
+
+ [Theory]
+ [InlineData("detached")]
+ [InlineData("not-ready")]
+ [InlineData("duplicate")]
+ [InlineData("empty")]
+ public void Apply_RefusesResultsThatAreNotApplicablePlatePlans(string fault)
+ {
+ var (_, plate, parts) = FixedPlate();
+ var state = PlateCuttingState.Capture(plate);
+ var events = Watch(plate);
+ CuttingPlanResult[] results = fault switch
+ {
+ "detached" => [CuttingPlanService.Plan(new CuttingPlanRequest(parts))],
+ "not-ready" => [CuttingPlanService.Plan(new CuttingPlanRequest(plate, preservePartOrder: true))],
+ "duplicate" => Enumerable.Repeat(CuttingPlanService.Plan(new CuttingPlanRequest(plate)), 2).ToArray(),
+ _ => []
+ };
+ if (fault == "detached")
+ Assert.Equal(CuttingPlanStatus.Ready, results[0].Status);
+ if (fault == "not-ready")
+ Assert.Equal(CuttingPlanStatus.ConstraintConflict, results[0].Status);
+
+ var commit = CuttingPlanService.Apply(results);
+
+ Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
+ Assert.Equal(parts, plate.Parts);
+ Assert.True(state.IsCurrent(), state.Difference());
+ Assert.Equal((0, 0, 0), events());
+ }
+
+ [Fact]
+ public void Apply_AllPlatesWithEmptySentinel_KeepsSentinelAndPlateList()
+ {
+ var nest = new Nest();
+ using var manager = new PlateManager(nest);
+ var plate = nest.CreatePlate();
+ foreach (var part in Fixture())
+ plate.Parts.Add(part);
+ Assert.Equal(2, nest.Plates.Count);
+ var sentinel = nest.Plates[1];
+ Assert.Empty(sentinel.Parts);
+ var listChanges = 0;
+ manager.PlateListChanged += (_, _) => listChanges++;
+ var sentinelEvents = Watch(sentinel);
+ var results = nest.Plates.ToArray().Select(p => CuttingPlanService.Plan(new CuttingPlanRequest(p))).ToArray();
+ Assert.All(results, r => Assert.Equal(CuttingPlanStatus.Ready, r.Status));
+ Assert.Empty(results[1].ProposedOrder);
+
+ var commit = CuttingPlanService.Apply(results);
+
+ Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
+ Assert.Equal(new[] { plate, sentinel }, nest.Plates);
+ Assert.Equal(0, listChanges);
+ Assert.Equal((0, 0, 0), sentinelEvents());
+ Assert.Equal(3, plate.Parts.Count);
+ }
+
+ [Theory]
+ [InlineData("missing")]
+ [InlineData("duplicate")]
+ [InlineData("foreign")]
+ [InlineData("count")]
+ [InlineData("null")]
+ public void Reorder_RejectsAnythingButTheSameReferencesWithoutChangeOrEvents(string fault)
+ {
+ var (_, plate, parts) = FixedPlate();
+ var events = Watch(plate);
+ Part[] order = fault switch
+ {
+ "missing" => [parts[0], parts[1], parts[1]],
+ "duplicate" => [parts[0], parts[1], parts[2], parts[2]],
+ "foreign" => [parts[0], parts[1], Rectangle(0, 0, 1, 1)],
+ "count" => [parts[0], parts[1]],
+ _ => [parts[0], parts[1], null!]
+ };
+
+ Assert.Throws(() => plate.Parts.Reorder(order));
+
+ Assert.Equal(parts, plate.Parts);
+ Assert.Equal((0, 0, 0), events());
+ }
+
+ [Fact]
+ public void ProgramContent_DetectsEveryAuthoredFieldAndSharingShape()
+ {
+ // Guard: a new settable instruction field must be added to ProgramContent and here.
+ var expected = new Dictionary
+ {
+ [typeof(RapidMove)] = ["EndPoint", "Feedrate", "Suppressed", "UseExactStop", "VariableRefs"],
+ [typeof(LinearMove)] = ["EndPoint", "Feedrate", "Layer", "Suppressed", "UseExactStop", "VariableRefs"],
+ [typeof(ArcMove)] = ["CenterPoint", "EndPoint", "Feedrate", "Layer", "Rotation", "Suppressed",
+ "UseExactStop", "VariableRefs"],
+ [typeof(SubProgramCall)] = ["Id", "Offset", "Program", "Rotation"],
+ [typeof(Comment)] = ["Value"],
+ [typeof(Feedrate)] = ["Value", "VariableRef"],
+ [typeof(Kerf)] = ["Value"],
+ [typeof(Program)] = ["Mode"]
+ };
+ foreach (var (type, names) in expected)
+ Assert.Equal(names, type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(p => p.SetMethod?.IsPublic == true && p.GetIndexParameters().Length == 0)
+ .Select(p => p.Name).Order());
+
+ var mutations = new Action[]
+ {
+ p => ((Motion)p.Codes[0]).EndPoint = Nudge(((Motion)p.Codes[0]).EndPoint),
+ p => ((Motion)p.Codes[1]).UseExactStop = true,
+ p => ((Motion)p.Codes[1]).Feedrate = 3,
+ p => ((Motion)p.Codes[1]).Suppressed = true,
+ p => ((Motion)p.Codes[1]).VariableRefs!["x"] = "other",
+ p => ((Motion)p.Codes[1]).VariableRefs = null,
+ p => ((LinearMove)p.Codes[1]).Layer = LayerType.Display,
+ p => ((ArcMove)p.Codes[2]).Layer = LayerType.Display,
+ p => ((ArcMove)p.Codes[2]).Rotation = RotationType.CW,
+ p => ((ArcMove)p.Codes[2]).CenterPoint = Nudge(((ArcMove)p.Codes[2]).CenterPoint),
+ p => ((SubProgramCall)p.Codes[3]).Id = 9,
+ p => ((SubProgramCall)p.Codes[3]).Offset = Nudge(((SubProgramCall)p.Codes[3]).Offset),
+ p => ((SubProgramCall)p.Codes[3]).Rotation = 1,
+ p => ((SubProgramCall)p.Codes[3]).Program.Codes.Add(new Comment("hole")),
+ p => ((Comment)p.Codes[5]).Value = "other",
+ p => ((Feedrate)p.Codes[6]).Value = 2,
+ p => ((Feedrate)p.Codes[6]).VariableRef = "f",
+ p => ((Kerf)p.Codes[7]).Value = KerfType.Right,
+ p => p.Mode = Mode.Incremental,
+ p => p.Rotate(1e-9),
+ p => p.Variables["v"] = new VariableDefinition("v", "2", 2),
+ p => p.SubPrograms[5] = new Program(),
+ // Two calls sharing one hole versus two equal but distinct holes.
+ p => ((SubProgramCall)p.Codes[4]).BindProgram(OwnedProgramCopy.Copy(((SubProgramCall)p.Codes[4]).Program))
+ };
+ var original = Rich();
+ Assert.True(ProgramContent.Equal(original, OwnedProgramCopy.Copy(original)));
+ for (var i = 0; i < mutations.Length; i++)
+ {
+ var changed = OwnedProgramCopy.Copy(original);
+ mutations[i](changed);
+ Assert.False(ProgramContent.Equal(original, changed), $"Mutation {i} was not detected.");
+ }
+ }
+
+ private static Vector Nudge(Vector v) =>
+ new(BitConverter.Int64BitsToDouble(BitConverter.DoubleToInt64Bits(v.X) + 1), v.Y);
+
+ private static Program Rich()
+ {
+ var hole = new Program();
+ hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Cut });
+ var program = new Program();
+ program.Variables["v"] = new VariableDefinition("v", "1", 1);
+ program.Codes.Add(new RapidMove(1, 1));
+ program.Codes.Add(new LinearMove(2, 1)
+ {
+ Layer = LayerType.Cut,
+ VariableRefs = new Dictionary { ["x"] = "v" }
+ });
+ program.Codes.Add(new ArcMove(new Vector(3, 2), new Vector(2, 2), RotationType.CCW) { Layer = LayerType.Cut });
+ program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(5, 5) });
+ program.Codes.Add(new SubProgramCall(hole, 0) { Id = 1, Offset = new Vector(7, 5) });
+ program.Codes.Add(new Comment("note"));
+ program.Codes.Add(new Feedrate(1));
+ program.Codes.Add(new Kerf(KerfType.Left));
+ program.SubPrograms[1] = hole;
+ return program;
+ }
+
+ private static Func<(int Added, int Removed, int Reordered)> Watch(Plate plate)
+ {
+ var added = 0;
+ var removed = 0;
+ var reordered = 0;
+ plate.PartAdded += (_, _) => added++;
+ plate.PartRemoved += (_, _) => removed++;
+ plate.PartsReordered += (_, _) => reordered++;
+ return () => (added, removed, reordered);
+ }
+
+ private static (Nest, Plate, Part[]) FixedPlate()
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ var parts = Fixture();
+ foreach (var part in parts)
+ plate.Parts.Add(part);
+ return (nest, plate, parts);
+ }
+
+ private static (Nest, Plate, Part, CuttingParameters) RegeneratedPlate(Vector location)
+ {
+ var clean = PreparedContourTests.Holes();
+ var parameters = ExplicitContourTests.Parameters();
+ var prepared = PreparedContours.Capture(clean, parameters);
+ var original = prepared.Emit(
+ [
+ prepared.Entry(0, 0, new Vector(2, 3)),
+ prepared.Entry(1, 0, new Vector(8, 3)),
+ prepared.ClosestEntry(2, new Vector(-2, 3))
+ ]);
+ var part = new Part(new Drawing("same", clean), location);
+ Assert.True(part.RestoreLeadInProgram(original, false));
+ part.CuttingParameters = parameters;
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ plate.Parts.Add(part);
+ return (nest, plate, part, parameters);
+ }
+
+ // Slice 1 fixture: A,B,C in this order crosses completed A; B,A,C does not.
+ private static Part[] Fixture() =>
+ [
+ Rectangle(4, 0, 4, 4),
+ Rectangle(0, 1, 2, 2),
+ Rectangle(10, 1, 2, 2)
+ ];
+
+ private static Part Rectangle(double x, double y, double width, double height)
+ {
+ var clean = new Program();
+ clean.MoveTo(width, height / 2);
+ Contour(clean, width, height);
+ var part = new Part(new Drawing("same", clean), new Vector(x, y));
+ var placed = new Program();
+ placed.MoveTo(width + 0.25, height / 2);
+ placed.Codes.Add(new LinearMove(width, height / 2) { Layer = LayerType.Leadin });
+ Contour(placed, width, height);
+ placed.Codes.Add(new LinearMove(width + 0.25, height / 2) { Layer = LayerType.Leadout });
+ Assert.True(part.RestoreLeadInProgram(placed, false));
+ part.CuttingParameters = new CuttingParameters();
+ return part;
+ }
+
+ private static void Contour(Program program, double width, double height)
+ {
+ program.LineTo(width, 0);
+ program.LineTo(0, 0);
+ program.LineTo(0, height);
+ program.LineTo(width, height);
+ program.LineTo(width, height / 2);
+ }
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 7c1165c..4187723 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -3,8 +3,9 @@
`OpenNest.Engine.CuttingPlanning.CuttingPlanService` plans contiguous whole-part
programs. It can retain fixed programs or jointly choose internal contour order,
entries and whole-part order using explicitly confirmed cutting parameters. It
-returns an owned proposal, not an Apply transaction or a replacement desktop
-command. Existing desktop sequencing, assignment and posting review are unchanged.
+returns an owned proposal; `Apply` installs Ready plate-scoped proposals atomically
+after an exact freshness check. There is no desktop command yet: existing desktop
+sequencing, assignment and posting review are unchanged.
## Capture before worker planning
@@ -14,13 +15,16 @@ programs and settings are stable. Pass that snapshot to `Plan` on a worker;
defaults to `Vector.Zero`, not a discovered controller position.
```csharp
-var request = new CuttingPlanRequest(parts, startPoint: start,
+var request = new CuttingPlanRequest(plate, startPoint: start,
confirmedParameters: parameters, expansionBudget: 20000,
maxEntries: 16, preservePartOrder: false);
var snapshot = CuttingPlanService.Capture(request, cancellationToken);
var result = CuttingPlanService.Plan(snapshot, cancellationToken);
```
+- A plate-scoped request plans the plate's current parts and records its exact
+ state for `Apply`. A detached part list (`new CuttingPlanRequest(parts, ...)`)
+ plans the same way but can never be applied. An empty plate is a Ready no-op.
- Omitting `confirmedParameters` preserves the original fixed-program contract:
locked and unlocked programs stay fixed; only whole-part order may change.
- Supplying confirmed parameters enables regeneration for unlocked placements.
@@ -38,7 +42,8 @@ Clean geometry accounts for the base program's existing rotation before applying
placement rotation; placement translation is applied once. Subprogram copying
must not rotate shared programs through their property setters. No live drawings
are attached to preview plates, so capture/search do not change quantity accounting.
-The snapshot is historical, not a freshness check against later edits. Original
+Planning works from this historical snapshot; Apply compares it with live state.
+Original
clean and executable graphs are type/mode-checked before cloning can erase unknown
semantics. Exact placed/proposed copies preserve authored motion feed/exact-stop
flags, symbolic bindings and shared subprogram identity; unsupported graphs are
@@ -121,15 +126,41 @@ a malformed emitted arc, is refused, not automatically repaired. Tabbed lead-out
leave from the trimmed cut end, but a lead-out after an open contour still needs
manual review of its retention gap, so confirmed-parameters planning refuses it.
+## Apply
+
+```csharp
+var commit = CuttingPlanService.Apply(results, cancellationToken); // one result per plate
+```
+
+Call it on the thread that owns the plates, with Ready, independently replayed
+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, cutoff definitions,
+and for every part its program reference and exact content (an in-place edit
+counts), drawing program, pose bits, lead-in/lock flags, settings reference and
+bounds. Any difference on any plate returns `Stale` and changes nothing; a result
+can therefore be applied at most once.
+
+The whole scope is validated and its bounds staged first; cancellation is checked
+immediately before the install. Order changes through `ObservableList.Reorder`
+semantics: same references, no `PartAdded`/`PartRemoved`, so drawing quantities,
+sentinel plates and plate lists are untouched. Regenerated parts receive a fresh
+owned copy of the replayed program and of the settings captured with the request,
+keep their pose and lock, and are marked as having lead-ins. Fixed programs are
+not replaced. An exception during install restores every plate exactly and returns
+`Failed`. After the whole scope is installed, each changed plate raises
+`Plate.PartsReordered` once; an observer exception is reported in `RefreshErrors`
+on an `Applied` result, not as a rollback.
+
## Remaining integration boundaries
The service does not establish clean-material non-overlap, inner-part-before-host
release dependencies, cutoff order or physical retention strength. It does not
-install programs atomically, check freshness at Apply, write CNC, or set posting
-consent. A `Ready` proposal can still be unsuitable for cutting.
+write CNC or set posting consent. A `Ready` proposal can still be unsuitable for
+cutting.
-Later slices add containment/cutoff dependencies and exact freshness/atomic Apply,
-then desktop integration and legacy automatic-path retirement. Windows interaction,
+Later slices add containment/cutoff dependencies, then desktop integration
+(including `PartsReordered` refresh hooks) and legacy automatic-path retirement. Windows interaction,
supplied-job coverage and actual posted order remain separate acceptance gates.
Fresh [pre-post verification](post-verification.md) is still required; it is not a
physical safety qualification.