mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-05 11:12:11 -04:00
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:
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user