feat(cutting): apply verified plans atomically

Plate-scoped cutting plan requests now record the plate's exact state at
capture, and CuttingPlanService.Apply installs Ready, replayed proposals
for a whole scope at once:

- Any change after capture (order, pose bits, program reference or
  in-place content, drawing program, lock/lead-in flags, settings,
  quantity, size, quadrant or cutoff definitions) returns Stale with
  nothing changed.
- Order changes without PartAdded/PartRemoved, so drawing quantities and
  sentinel plates are untouched; ObservableList.Reorder exposes the same
  operation and Plate.PartsReordered is raised once per changed plate.
- Regenerated parts receive owned copies of the replayed program and of
  the settings captured with the request; fixed programs stay in place.
- An install failure restores every plate exactly; an observer failure
  after publication is reported as a refresh error, not a rollback.
This commit is contained in:
aj committed 2026-10-04 23:48:28 -04:00
1 parent ebc54f0653
commit 9c530ca7d2
11 files changed
+1186 -10

No files matched your search

@@ -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;
/// <summary>An owned planned program for one unlocked part; ownership passes to the part on Apply.</summary>
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; }
}
/// <summary>The verified order and planned programs for one plate, bound to its captured state.</summary>
public sealed class PlateCuttingPlan
{
public PlateCuttingPlan(PlateCuttingState expected, IEnumerable<Part> order,
IEnumerable<PlannedPartProgram> programs = null)
{
Expected = expected;
Order = order == null ? null : Array.AsReadOnly(order.ToArray());
Programs = Array.AsReadOnly((programs ?? []).ToArray());
}
public PlateCuttingState Expected { get; }
public IReadOnlyList<Part> Order { get; }
public IReadOnlyList<PlannedPartProgram> Programs { get; }
}
public enum CuttingCommitStatus
{
Applied,
Stale,
InvalidInput,
Cancelled,
Failed
}
/// <summary>
/// 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.
/// </summary>
public sealed class CuttingCommitResult
{
internal CuttingCommitResult(CuttingCommitStatus status, string message = null, Plate plate = null,
Exception error = null, IEnumerable<Exception> 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<Exception> RefreshErrors { get; }
}
/// <summary>
/// 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.
/// </summary>
public static class CuttingPlanCommit
{
public static CuttingCommitResult Apply(IEnumerable<PlateCuttingPlan> 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<PlateCuttingPlan> plans, CancellationToken token,
Action<Plate, Part> 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<Plate>(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<Staged>(scope.Length);
var targets = new HashSet<Part>(ReferenceEqualityComparer.Instance);
var installed = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var live = new HashSet<Program>(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<Part>(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<Exception>();
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);
}
@@ -0,0 +1,9 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Every Part field a cutting commit can change, held by reference for exact rollback.</summary>
internal sealed record PartCuttingState(Program Program, bool OwnsProgram, double PreLeadInRotation,
bool HasManualLeadIns, bool LeadInsLocked, CuttingParameters CuttingParameters, Vector Location,
Box BoundingBox);
@@ -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;
/// <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.
/// A commit compares it with the live plate and refuses when anything differs.
/// </summary>
public sealed class PlateCuttingState
{
private readonly ObservableList<Part> partList;
private readonly PartRecord[] parts;
private readonly int quantity;
private readonly Size size;
private readonly int quadrant;
private readonly ObservableList<CutOff> cutOffList;
private readonly CutOffRecord[] cutOffs;
private readonly Dictionary<Drawing, (Program Program, Program Copy)> drawings;
private PlateCuttingState(Plate plate, PartRecord[] parts,
CutOffRecord[] cutOffs, Dictionary<Drawing, (Program, Program)> 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; }
/// <summary>Captured part order (reference identity).</summary>
public IReadOnlyList<Part> Order { get; }
/// <summary>
/// Captures on the caller thread. Unsupported or malformed programs throw
/// <see cref="ArgumentException"/> or <see cref="NotSupportedException"/>.
/// </summary>
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<Drawing, (Program, Program)>(ReferenceEqualityComparer.Instance);
var records = new List<PartRecord>(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);
}
/// <summary>True when the live plate still has exactly the captured state.</summary>
public bool IsCurrent(CancellationToken token = default) => Difference(token) == null;
/// <summary>Null when current; otherwise the first observed difference.</summary>
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);
}
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
internal static class ProgramContent
{
internal static bool Equal(Program left, Program right, CancellationToken token = default)
{
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
var programOwners = new HashSet<Program>(ReferenceEqualityComparer.Instance);
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
var codeOwners = new HashSet<ICode>(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<string, string> a, Dictionary<string, string> 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<string, VariableDefinition> a, Dictionary<string, VariableDefinition> 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);
}
@@ -13,6 +13,9 @@ namespace OpenNest.Collections
public event EventHandler<ItemRemovedEventArgs<T>> ItemRemoved;
public event EventHandler<ItemChangedEventArgs<T>> ItemChanged;
/// <summary>Raised once after <see cref="Reorder"/> (or a cutting commit) installs a new order.</summary>
public event EventHandler ItemsReordered;
public ObservableList()
{
items = new List<T>();
@@ -64,6 +67,68 @@ namespace OpenNest.Collections
RemoveAt(i);
}
/// <summary>
/// Changes only the order of the current items. <paramref name="order"/> 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.
/// </summary>
public void Reorder(IEnumerable<T> order)
{
SetOrder(ValidateReorder(order));
ItemsReordered?.Invoke(this, EventArgs.Empty);
}
internal T[] ValidateReorder(IEnumerable<T> 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<T>.Default
: (IEqualityComparer<T>)(object)ReferenceEqualityComparer.Instance;
var counts = new Dictionary<T, int>(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<Exception> 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);
+33
View File
@@ -135,6 +135,39 @@ namespace OpenNest
return true;
}
/// <summary>Exact cutting-state record for freshness and rollback; references are not copied.</summary>
internal CNC.CuttingPlanning.PartCuttingState CaptureCuttingState() =>
new(Program, ownsProgram, preLeadInRotation, HasManualLeadIns, LeadInsLocked,
CuttingParameters, location, BoundingBox);
/// <summary>Reinstates a state captured from this part, field for field.</summary>
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;
}
/// <summary>
/// Installs an owned, prevalidated planned program and its precomputed placed bounds.
/// Pose and lock are unchanged; nothing is regenerated or rotated here.
/// </summary>
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;
+11
View File
@@ -30,6 +30,17 @@ namespace OpenNest
remove { Parts.ItemChanged -= value; }
}
/// <summary>
/// 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.
/// </summary>
public event EventHandler PartsReordered
{
add { Parts.ItemsReordered += value; }
remove { Parts.ItemsReordered -= value; }
}
public Plate()
: this(60, 120) { }
@@ -28,6 +28,21 @@ public sealed class CuttingPlanRequest
MaxEntries = maxEntries;
}
/// <summary>
/// Plate scope: plans the plate's current parts and captures its exact state, so a Ready
/// result can later be applied through <see cref="CuttingPlanService.Apply"/>.
/// </summary>
public CuttingPlanRequest(Plate plate, Vector startPoint = default, int expansionBudget = 20000,
CuttingParameters confirmedParameters = null, IEnumerable<Part> eligibleParts = null,
bool preservePartOrder = false, int maxEntries = 16)
: this(plate?.Parts, startPoint, expansionBudget, confirmedParameters, eligibleParts,
preservePartOrder, maxEntries)
{
Plate = plate;
}
/// <summary>The plate scope, or null for a detached part list that cannot be applied.</summary>
public Plate Plate { get; }
public CuttingParameters ConfirmedParameters { get; }
public IReadOnlyList<Part> EligibleParts { get; }
public bool PreservePartOrder { get; }
@@ -44,8 +59,11 @@ public sealed class CuttingPlanSnapshot
internal CuttingPlanSnapshot(IEnumerable<FixedProgramPlacement> placements, Vector startPoint,
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
Action<int> expansionObserver = null)
Action<int> 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;
}
/// <summary>Exact captured plate state for plate-scoped requests; null for detached part lists.</summary>
internal PlateCuttingState PlateState { get; }
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
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; }
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
internal CuttingPlanSnapshot Snapshot { get; set; }
}
@@ -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<Part>(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
/// <summary>Worker-only planning uses owned values; live identities are never dereferenced.</summary>
public static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token = default)
{
var result = PlanCaptured(snapshot, token);
result.Snapshot = snapshot;
return result;
}
/// <summary>
/// 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.
/// </summary>
public static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token = default) =>
Apply(results, token, null);
// beforeInstall is the commit's install-boundary test seam.
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
Action<Plate, Part> beforeInstall)
{
var plans = new List<PlateCuttingPlan>();
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<PlannedPartProgram>();
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<CuttingPlanFinding>();
@@ -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<Part>();
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<string>();
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<ArgumentException>(() => 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<Type, string[]>
{
[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<Program>[]
{
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<string, string> { ["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);
}
}
+39 -8
View File
@@ -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.