feat(cutoffs): apply nest-wide with minimum retained tail
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Plans and applies the same automatic-cutoff settings to a stable, ordered set of plates.
|
||||
/// Callers must prevent concurrent edits for the duration of either operation.
|
||||
/// </summary>
|
||||
public static class AutomaticCutOffBatch
|
||||
{
|
||||
/// <summary>Detached plans in plate order. Invalid input identifies the one-based plate number.</summary>
|
||||
public static IReadOnlyList<AutomaticCutOffPlan> Create(IReadOnlyList<Plate> plates,
|
||||
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plates);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
if (plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
|
||||
throw new ArgumentException("Each plate must occur only once.", nameof(plates));
|
||||
|
||||
var plans = new List<AutomaticCutOffPlan>(plates.Count);
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
try
|
||||
{
|
||||
plans.Add(AutomaticCutOffPlanner.Create(plates[index], options, settings));
|
||||
}
|
||||
catch (ArgumentException error)
|
||||
{
|
||||
throw new ArgumentException($"Plate {index + 1}: {error.Message}", nameof(plates), error);
|
||||
}
|
||||
}
|
||||
return plans.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replans all plates before changing any. A blocking plan returns without applying any
|
||||
/// definitions; empty/unchanged plates are untouched. On failure, restores cutoff state
|
||||
/// on every touched plate, reporting explicitly if any restoration also fails.
|
||||
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
|
||||
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||
{
|
||||
var plans = Create(plates, options, settings);
|
||||
if (plans.Any(plan => plan.HasBlockingDiagnostics))
|
||||
return plans;
|
||||
|
||||
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
||||
try
|
||||
{
|
||||
for (var index = 0; index < plates.Count; index++)
|
||||
{
|
||||
var plan = plans[index];
|
||||
if (plan.Definitions.Count == 0)
|
||||
continue;
|
||||
|
||||
var plate = plates[index];
|
||||
// Register recovery before the first observable mutation, including AddRange.
|
||||
restoreActions.Add((index + 1, CaptureRestore(plate, plan)));
|
||||
plate.CutOffs.AddRange(plan.Definitions);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
}
|
||||
}
|
||||
catch (Exception applyError)
|
||||
{
|
||||
var rollbackErrors = new List<Exception>();
|
||||
for (var index = restoreActions.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var saved = restoreActions[index];
|
||||
try
|
||||
{
|
||||
saved.Restore();
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
// A broken observer on one plate must not prevent recovery of the others.
|
||||
rollbackErrors.Add(new InvalidOperationException(
|
||||
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
||||
}
|
||||
}
|
||||
if (rollbackErrors.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
||||
+ string.Join("; ", rollbackErrors.Select(e => e.Message))
|
||||
+ "\nThe nest may be incomplete; review it before saving or cutting.",
|
||||
new AggregateException(new[] { applyError }.Concat(rollbackErrors)));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
|
||||
{
|
||||
// Regeneration replaces drawing programs and removes/reinserts cutoff parts.
|
||||
// Save only that state; real parts, poses, programs and quantities stay untouched.
|
||||
var programs = plate.CutOffs.Select(c => (CutOff: c, Program: c.Drawing.Program)).ToArray();
|
||||
var parts = plate.Parts.Select((part, index) => (Part: part, Index: index))
|
||||
.Where(p => p.Part.BaseDrawing.IsCutOff).ToArray();
|
||||
return () =>
|
||||
{
|
||||
foreach (var definition in plan.Definitions)
|
||||
plate.CutOffs.Remove(definition);
|
||||
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||
plate.Parts.RemoveAt(index);
|
||||
}
|
||||
foreach (var saved in programs)
|
||||
saved.CutOff.Drawing.Program = saved.Program;
|
||||
foreach (var saved in parts)
|
||||
plate.Parts.Insert(saved.Index, saved.Part);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public sealed class AutomaticCutOffOptions
|
||||
/// greater than AutomaticCutOffPlanner.MinimumSpacing; Create also bounds candidate count.
|
||||
/// </summary>
|
||||
public double Spacing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum retained-tail length along X in model units. Zero permits any positive tail.
|
||||
/// The desktop default is 12 inches (304.8 mm).
|
||||
/// </summary>
|
||||
public double MinimumTailLength { get; set; }
|
||||
}
|
||||
|
||||
public enum AutomaticCutOffDiagnosticCode
|
||||
@@ -24,6 +30,7 @@ public enum AutomaticCutOffDiagnosticCode
|
||||
EmptyCut,
|
||||
SegmentedCut,
|
||||
NoSafeTailSeparator,
|
||||
TailBelowMinimum,
|
||||
}
|
||||
|
||||
public sealed record AutomaticCutOffDiagnostic(
|
||||
@@ -133,7 +140,16 @@ public static class AutomaticCutOffPlanner
|
||||
AddCandidate(sign * distance, false);
|
||||
}
|
||||
if (hasTailCandidate)
|
||||
AddCandidate(sign * separator, true);
|
||||
{
|
||||
if (length - separator >= options.MinimumTailLength)
|
||||
AddCandidate(sign * separator, true);
|
||||
else
|
||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||
AutomaticCutOffDiagnosticCode.TailBelowMinimum,
|
||||
"The proposed tail is shorter than the minimum tail length; the final separator was skipped. "
|
||||
+ "Other automatic lines and existing cut-offs are unchanged. No retained tail is claimed.",
|
||||
X: sign * separator));
|
||||
}
|
||||
|
||||
if (diagnostics.Any(d => d.IsBlocking))
|
||||
{
|
||||
@@ -219,6 +235,8 @@ public static class AutomaticCutOffPlanner
|
||||
{
|
||||
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
||||
Require(Nonnegative(options.MinimumTailLength),
|
||||
"Minimum tail length must be finite and nonnegative.", nameof(options));
|
||||
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
|
||||
double.IsFinite(plate.Size.Width) && plate.Size.Width > 0,
|
||||
"Sheet length and width must be positive and finite.", nameof(plate));
|
||||
|
||||
Reference in New Issue
Block a user