mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 12:01:05 -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) { }
|
||||
|
||||
|
||||
Reference in new issue
Block a user