Files
OpenNest/OpenNest.Core/PlateManager.cs
T
ajandClaude Opus 5.5 15750949dd feat(nest): plates name the ledger item each sheet is cut from
Auto Nest on MaterialLedger stock now records which inventory item each
physical sheet uses, so ML can hold exactly those items instead of matching
by size.

- Core: Plate.StockItemCodes (entry i = sheet i of Quantity; empty = uncoded).
  Core invalidates it: a resize clears it (turning the sheet keeps it), a lower
  Quantity truncates it, a nest material/grade/thickness change clears every
  plate's, ClearAll clears it and new plates never copy it.
- Desktop Auto Nest: after a kept proposal is committed, LedgerStockAssignment
  deals each ledger row's codes to its plates in Nest.Plates order (bundles
  repeat their code; clamp-removal variants use their base row). Rejected,
  discarded or cancelled proposals never reach it. NestPipelineCommit.PairWithStock
  pairs committed plates with their solved stock; the engine stays ledger-free.
- Plate list tooltip: "Stock: CODE (location)"; locations are display-only.
- nest.json: optional plate stockItemCodes, omitted when empty, read leniently.
- /api/nests/{id}/sheets: new plates[] { index, width, length, quantity,
  stockItemCodes }; sheets[] unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:40:44 -04:00

343 lines
10 KiB
C#

using System;
using OpenNest.Collections;
namespace OpenNest
{
public class PlateChangedEventArgs : EventArgs
{
public Plate Plate { get; }
public int Index { get; }
public PlateChangedEventArgs(Plate plate, int index)
{
Plate = plate;
Index = index;
}
}
public class PlateManager : IDisposable
{
private readonly Nest nest;
private bool disposed;
private bool suppressNavigation;
private bool batching;
private Plate subscribedLast;
private Plate subscribedSecondToLast;
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
public event EventHandler PlateListChanged;
public PlateManager(Nest nest)
{
this.nest = nest;
nest.Plates.ItemAdded += OnPlateAdded;
nest.Plates.ItemRemoved += OnPlateRemoved;
}
public int CurrentIndex { get; private set; }
public Plate CurrentPlate => nest.Plates.Count > 0 ? nest.Plates[CurrentIndex] : null;
public int Count => nest.Plates.Count;
public bool IsFirst => Count == 0 || CurrentIndex <= 0;
public bool IsLast => CurrentIndex + 1 >= Count;
public bool CanRemoveCurrent =>
Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0;
public void LoadFirst()
{
if (Count == 0)
return;
CurrentIndex = 0;
FireCurrentPlateChanged();
}
public void LoadLast()
{
if (Count == 0)
return;
CurrentIndex = Count - 1;
FireCurrentPlateChanged();
}
public bool LoadNext()
{
if (CurrentIndex + 1 >= Count)
return false;
CurrentIndex++;
FireCurrentPlateChanged();
return true;
}
public bool LoadPrevious()
{
if (Count == 0 || CurrentIndex - 1 < 0)
return false;
CurrentIndex--;
FireCurrentPlateChanged();
return true;
}
public void LoadAt(int index)
{
if (index < 0 || index >= Count)
return;
CurrentIndex = index;
FireCurrentPlateChanged();
}
public void EnsureSentinel()
{
suppressNavigation = true;
try
{
if (Count == 0 || nest.Plates[^1].Parts.Count > 0)
nest.CreatePlate();
while (
Count > 1
&& nest.Plates[^1].Parts.Count == 0
&& nest.Plates[^2].Parts.Count == 0
)
{
nest.Plates.RemoveAt(Count - 1);
}
}
finally
{
suppressNavigation = false;
}
SubscribeToTailPlates();
}
/// <summary>
/// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
/// that matched the old defaults to the new defaults.
/// </summary>
public void ChangePlateDefaults(Action changeDefaults)
{
ArgumentNullException.ThrowIfNull(changeDefaults);
var defaults = nest.PlateDefaults;
var trailing = Count > 0 ? nest.Plates[^1] : null;
// Exact configuration equality, not geometric tolerance: even a small edit
// makes this configured stock rather than the untouched workspace sentinel.
var followsDefaults = trailing != null
&& trailing.Parts.Count == 0
&& trailing.CutOffs.Count == 0
&& trailing.Size.Width == defaults.Size.Width
&& trailing.Size.Length == defaults.Size.Length
&& trailing.Quadrant == defaults.Quadrant
&& trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left
&& trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom
&& trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right
&& trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top
&& trailing.PartSpacing == defaults.PartSpacing
&& trailing.RemovedClamps.Count == 0
&& Equals(trailing.ClampRail, defaults.ClampRail)
&& (defaults.ClampRail == null
? trailing.ClampZones.Count == 0
: defaults.ClampRail.Matches(trailing));
changeDefaults();
if (!followsDefaults)
return;
defaults = nest.PlateDefaults;
trailing.Size = defaults.Size;
trailing.Quadrant = defaults.Quadrant;
trailing.EdgeSpacing = defaults.EdgeSpacing;
trailing.PartSpacing = defaults.PartSpacing;
trailing.SetClampRail(defaults.ClampRail);
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (ReferenceEquals(CurrentPlate, trailing))
FireCurrentPlateChanged();
}
public void BeginBatch()
{
batching = true;
}
public void EndBatch()
{
batching = false;
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
FireCurrentPlateChanged();
}
public Plate GetOrCreateEmpty()
{
for (var i = Count - 1; i >= 0; i--)
{
if (nest.Plates[i].Parts.Count == 0)
return nest.Plates[i];
}
return nest.CreatePlate();
}
public void RemoveCurrent()
{
if (Count < 2)
return;
nest.Plates.RemoveAt(CurrentIndex);
}
/// <summary>
/// Whether <see cref="ClearAll"/> would change anything: more than one plate, or the
/// remaining plate still holds parts or cut-off definitions.
/// </summary>
public bool CanClearAll
{
get
{
if (Count == 0)
return false;
var remaining = nest.Plates[0];
return Count > 1
|| remaining.Parts.Count > 0
|| remaining.CutOffs.Count > 0
|| remaining.Quantity > 1;
}
}
/// <summary>
/// Clears the nest: removes every plate after the first and empties the remaining
/// plate (parts and cut-off definitions), resetting its repeat quantity. Nest metadata
/// and plate defaults are preserved; the first plate's configuration stays as the
/// workspace sentinel — the same result as removing every other plate one at a time.
/// The plate count never reaches zero, so observers never see a null current plate.
/// </summary>
public void ClearAll()
{
if (Count == 0)
return;
BeginBatch();
try
{
while (nest.Plates.Count > 1)
nest.Plates.RemoveAt(nest.Plates.Count - 1);
var remaining = nest.Plates[0];
remaining.CutOffs.Clear();
remaining.Parts.Clear();
remaining.Quantity = 1;
remaining.StockItemCodes = Array.Empty<string>();
}
finally
{
EndBatch();
}
}
private void SubscribeToTailPlates()
{
UnsubscribeFromTailPlates();
if (Count > 0)
{
subscribedLast = nest.Plates[^1];
subscribedLast.PartAdded += OnTailPartAdded;
subscribedLast.PartRemoved += OnTailPartRemoved;
}
if (Count > 1)
{
subscribedSecondToLast = nest.Plates[^2];
subscribedSecondToLast.PartAdded += OnTailPartAdded;
subscribedSecondToLast.PartRemoved += OnTailPartRemoved;
}
}
private void UnsubscribeFromTailPlates()
{
if (subscribedLast != null)
{
subscribedLast.PartAdded -= OnTailPartAdded;
subscribedLast.PartRemoved -= OnTailPartRemoved;
subscribedLast = null;
}
if (subscribedSecondToLast != null)
{
subscribedSecondToLast.PartAdded -= OnTailPartAdded;
subscribedSecondToLast.PartRemoved -= OnTailPartRemoved;
subscribedSecondToLast = null;
}
}
private void OnTailPartAdded(object sender, ItemAddedEventArgs<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnTailPartRemoved(object sender, ItemRemovedEventArgs<Part> e)
{
if (!batching)
EnsureSentinel();
}
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> e)
{
if (!suppressNavigation && !batching)
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (!suppressNavigation)
{
CurrentIndex = Count - 1;
FireCurrentPlateChanged();
}
}
private void OnPlateRemoved(object sender, ItemRemovedEventArgs<Plate> e)
{
if (CurrentIndex >= Count && Count > 0)
CurrentIndex = Count - 1;
if (!suppressNavigation && !batching)
EnsureSentinel();
PlateListChanged?.Invoke(this, EventArgs.Empty);
if (!suppressNavigation)
FireCurrentPlateChanged();
}
private void FireCurrentPlateChanged()
{
CurrentPlateChanged?.Invoke(
this,
new PlateChangedEventArgs(CurrentPlate, CurrentIndex)
);
}
public void Dispose()
{
if (disposed)
return;
disposed = true;
UnsubscribeFromTailPlates();
nest.Plates.ItemAdded -= OnPlateAdded;
nest.Plates.ItemRemoved -= OnPlateRemoved;
}
}
}