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