refactor(ui): route every Auto Nest engine through the validated pipeline

This commit is contained in:
aj
2026-09-29 10:56:38 -04:00
parent b8bded1b09
commit b3a4390169
10 changed files with 443 additions and 496 deletions
@@ -0,0 +1,90 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class NestPipelineCommitTests
{
private sealed class StubEngine(bool overlap) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => new(
NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, job.Plates[0], new[]
{
new NestJobPlacement(job.Parts[0].Id, 0, 2, 2, 0),
new NestJobPlacement(job.Parts[0].Id, 1, overlap ? 2 : 20, 2, 0),
}) },
new[] { new PartFulfillment(job.Parts[0].Id, 2, 2, 0) },
new[] { new StockUsage(job.Plates[0].Id, 1, null) });
}
private static NestPipelineResult Result(Drawing drawing, bool overlap = false) =>
NestPipeline.Run(new StubEngine(overlap), "stub", new NestPipelineRequest("stub",
new[] { new NestItem { Drawing = drawing, Quantity = 2 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1), 1) }));
[Fact]
public void AppliesValidatedSettingsToEmptyPlateAndNeverFillsOccupiedPlate()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var occupied = nest.CreatePlate();
occupied.Parts.Add(new Part(drawing));
var empty = nest.CreatePlate();
empty.Quantity = 7;
empty.Quadrant = 3;
empty.Size = new Size(4, 8);
empty.EdgeSpacing = new Spacing(0, 0);
using var manager = new PlateManager(nest);
var result = Result(drawing);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
Assert.Same(empty, Assert.Single(applied));
Assert.Single(occupied.Parts);
Assert.Equal(2, empty.Parts.Count);
Assert.Equal(1, empty.Quantity);
Assert.Equal(1, empty.Quadrant);
Assert.Equal(result.Job.Plates[0].Size, empty.Size);
Assert.Equal(result.Job.Plates[0].EdgeSpacing, empty.EdgeSpacing);
Assert.Equal(0.25, empty.PartSpacing);
Assert.All(empty.Parts, p => Assert.Same(drawing, p.BaseDrawing));
Assert.Equal(3, drawing.Quantity.Nested);
}
[Fact]
public void InvalidResultRequiresExplicitConsentAndDiscardDoesNotMutateNest()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = Result(drawing, overlap: true);
Assert.False(result.IsValid);
Assert.Throws<InvalidOperationException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true);
Assert.Equal(2, Assert.Single(applied).Parts.Count);
}
[Fact]
public void CancelledCommitDoesNotCreateAnyPlate()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
using var manager = new PlateManager(nest);
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.ThrowsAny<OperationCanceledException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(Result(drawing), manager, token: cts.Token));
Assert.Empty(nest.Plates);
}
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace OpenNest.Engine.Jobs;
/// <summary>Applies an explicitly accepted whole-job proposal to empty physical sheets only.
/// Caller must keep the nest and its drawings stable from request construction through commit.</summary>
public static class NestPipelineCommit
{
public static IReadOnlyList<Plate> ApplyToEmptyPlates(
NestPipelineResult result,
PlateManager manager,
bool allowInvalid = false,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(manager);
token.ThrowIfCancellationRequested();
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
var applied = new List<Plate>();
// Commit is synchronous on the caller's owning thread. Cancellation is checked
// before mutation, not partway through attachment (which would leave half a job).
manager.BeginBatch();
try
{
foreach (var proposed in result.Plates)
{
if (proposed.Parts.Count == 0)
continue;
var plate = manager.GetOrCreateEmpty();
plate.Size = proposed.Stock.Size;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = 1;
plate.Parts.AddRange(proposed.Parts);
applied.Add(plate);
}
}
finally
{
manager.EndBatch();
}
return applied.AsReadOnly();
}
}
@@ -0,0 +1,75 @@
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
public class NestValidationFormTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public void DiscardIsDefaultAndMalformedOutputCannotBeKept(bool canKeep)
{
RunSta(() =>
{
using var form = new NestValidationForm(new[] { "part A and part B violate spacing" }, canKeep);
var buttons = form.Controls.OfType<FlowLayoutPanel>().Single().Controls.OfType<Button>().ToArray();
var discard = Assert.Single(buttons, b => b.Text == "Discard");
var keep = Assert.Single(buttons, b => b.Text == "Keep anyway");
Assert.Same(discard, form.AcceptButton);
Assert.Same(discard, form.CancelButton);
Assert.Equal(canKeep, keep.Enabled);
Assert.Contains("violate spacing", form.Controls.OfType<TextBox>().Single().Text);
});
}
private sealed class TestProgress(CancellationTokenSource cts) : NestProgressForm(cts)
{
public FormClosingEventArgs RequestClose()
{
var args = new FormClosingEventArgs(CloseReason.UserClosing, false);
OnFormClosing(args);
return args;
}
}
[Fact]
public void ClosingWholeJobProgressWaitsForWorkerAndRequestsCancellation()
{
RunSta(() =>
{
using var cts = new CancellationTokenSource();
using var form = new TestProgress(cts) { HoldOpenUntilCompleted = true };
Assert.True(form.Controls.Find("stopButton", true).Single().Enabled);
Assert.True(form.RequestClose().Cancel);
Assert.True(cts.IsCancellationRequested);
form.ShowCompleted();
Assert.False(form.RequestClose().Cancel);
});
}
[Fact]
public void CompletionClosesWithoutCancellingSuccessfulJob()
{
RunSta(() =>
{
using var cts = new CancellationTokenSource();
using var form = new TestProgress(cts) { HoldOpenUntilCompleted = true };
form.ShowCompleted();
Assert.False(form.RequestClose().Cancel);
Assert.False(cts.IsCancellationRequested);
});
}
private static void RunSta(Action action)
{
Exception? error = null;
var thread = new Thread(() => { try { action(); } catch (Exception ex) { error = ex; } });
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(15)));
if (error != null)
ExceptionDispatchInfo.Capture(error).Throw();
}
}
+8 -73
View File
@@ -24,11 +24,6 @@ namespace OpenNest.Forms
this.summaryLabel = new System.Windows.Forms.Label(); this.summaryLabel = new System.Windows.Forms.Label();
this.engineLabel = new System.Windows.Forms.Label(); this.engineLabel = new System.Windows.Forms.Label();
this.engineComboBox = new System.Windows.Forms.ComboBox(); this.engineComboBox = new System.Windows.Forms.ComboBox();
this.createNewPlatesAsNeededBox = new System.Windows.Forms.CheckBox();
this.partFirstGroup = new System.Windows.Forms.GroupBox();
this.partFirstCheckBox = new System.Windows.Forms.CheckBox();
this.sortOrderLabel = new System.Windows.Forms.Label();
this.sortOrderComboBox = new System.Windows.Forms.ComboBox();
this.minRemnantLabel = new System.Windows.Forms.Label(); this.minRemnantLabel = new System.Windows.Forms.Label();
this.minRemnantBox = new System.Windows.Forms.TextBox(); this.minRemnantBox = new System.Windows.Forms.TextBox();
this.plateOptimizerGroup = new System.Windows.Forms.GroupBox(); this.plateOptimizerGroup = new System.Windows.Forms.GroupBox();
@@ -45,7 +40,6 @@ namespace OpenNest.Forms
this.platesTab.SuspendLayout(); this.platesTab.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.partsGrid)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.partsGrid)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.plateGrid)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.plateGrid)).BeginInit();
this.partFirstGroup.SuspendLayout();
this.plateOptimizerGroup.SuspendLayout(); this.plateOptimizerGroup.SuspendLayout();
this.buttonPanel.SuspendLayout(); this.buttonPanel.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
@@ -77,8 +71,6 @@ namespace OpenNest.Forms
// //
this.platesTab.Controls.Add(this.engineLabel); this.platesTab.Controls.Add(this.engineLabel);
this.platesTab.Controls.Add(this.engineComboBox); this.platesTab.Controls.Add(this.engineComboBox);
this.platesTab.Controls.Add(this.createNewPlatesAsNeededBox);
this.platesTab.Controls.Add(this.partFirstGroup);
this.platesTab.Controls.Add(this.plateOptimizerGroup); this.platesTab.Controls.Add(this.plateOptimizerGroup);
this.platesTab.Location = new System.Drawing.Point(4, 25); this.platesTab.Location = new System.Drawing.Point(4, 25);
this.platesTab.Name = "platesTab"; this.platesTab.Name = "platesTab";
@@ -129,70 +121,18 @@ namespace OpenNest.Forms
this.engineComboBox.Size = new System.Drawing.Size(200, 24); this.engineComboBox.Size = new System.Drawing.Size(200, 24);
this.engineComboBox.TabIndex = 1; this.engineComboBox.TabIndex = 1;
// //
// createNewPlatesAsNeededBox
//
this.createNewPlatesAsNeededBox.AutoSize = true;
this.createNewPlatesAsNeededBox.Location = new System.Drawing.Point(10, 44);
this.createNewPlatesAsNeededBox.Name = "createNewPlatesAsNeededBox";
this.createNewPlatesAsNeededBox.Size = new System.Drawing.Size(202, 20);
this.createNewPlatesAsNeededBox.TabIndex = 2;
this.createNewPlatesAsNeededBox.Text = "Create new plates as needed";
this.createNewPlatesAsNeededBox.UseVisualStyleBackColor = true;
//
// partFirstGroup
//
this.partFirstGroup.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
this.partFirstGroup.Controls.Add(this.partFirstCheckBox);
this.partFirstGroup.Controls.Add(this.sortOrderLabel);
this.partFirstGroup.Controls.Add(this.sortOrderComboBox);
this.partFirstGroup.Controls.Add(this.minRemnantLabel);
this.partFirstGroup.Controls.Add(this.minRemnantBox);
this.partFirstGroup.Location = new System.Drawing.Point(10, 72);
this.partFirstGroup.Name = "partFirstGroup";
this.partFirstGroup.Size = new System.Drawing.Size(528, 80);
this.partFirstGroup.TabIndex = 3;
this.partFirstGroup.TabStop = false;
this.partFirstGroup.Text = " Part-First Mode";
//
// partFirstCheckBox
//
this.partFirstCheckBox.AutoSize = true;
this.partFirstCheckBox.Location = new System.Drawing.Point(10, 0);
this.partFirstCheckBox.Name = "partFirstCheckBox";
this.partFirstCheckBox.Size = new System.Drawing.Size(15, 14);
this.partFirstCheckBox.TabIndex = 0;
this.partFirstCheckBox.UseVisualStyleBackColor = true;
this.partFirstCheckBox.CheckedChanged += new System.EventHandler(this.partFirstCheckBox_CheckedChanged);
//
// sortOrderLabel
//
this.sortOrderLabel.AutoSize = true;
this.sortOrderLabel.Location = new System.Drawing.Point(10, 26);
this.sortOrderLabel.Name = "sortOrderLabel";
this.sortOrderLabel.Size = new System.Drawing.Size(75, 16);
this.sortOrderLabel.TabIndex = 1;
this.sortOrderLabel.Text = "Sort Order:";
//
// sortOrderComboBox
//
this.sortOrderComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.sortOrderComboBox.Location = new System.Drawing.Point(100, 23);
this.sortOrderComboBox.Name = "sortOrderComboBox";
this.sortOrderComboBox.Size = new System.Drawing.Size(180, 24);
this.sortOrderComboBox.TabIndex = 2;
//
// minRemnantLabel // minRemnantLabel
// //
this.minRemnantLabel.AutoSize = true; this.minRemnantLabel.AutoSize = true;
this.minRemnantLabel.Location = new System.Drawing.Point(10, 54); this.minRemnantLabel.Location = new System.Drawing.Point(10, 193);
this.minRemnantLabel.Name = "minRemnantLabel"; this.minRemnantLabel.Name = "minRemnantLabel";
this.minRemnantLabel.Size = new System.Drawing.Size(117, 16); this.minRemnantLabel.Size = new System.Drawing.Size(117, 16);
this.minRemnantLabel.TabIndex = 3; this.minRemnantLabel.TabIndex = 3;
this.minRemnantLabel.Text = "Min Remnant Size:"; this.minRemnantLabel.Text = "Min Salvage Size:";
// //
// minRemnantBox // minRemnantBox
// //
this.minRemnantBox.Location = new System.Drawing.Point(133, 51); this.minRemnantBox.Location = new System.Drawing.Point(155, 190);
this.minRemnantBox.Name = "minRemnantBox"; this.minRemnantBox.Name = "minRemnantBox";
this.minRemnantBox.Size = new System.Drawing.Size(60, 22); this.minRemnantBox.Size = new System.Drawing.Size(60, 22);
this.minRemnantBox.TabIndex = 4; this.minRemnantBox.TabIndex = 4;
@@ -201,17 +141,19 @@ namespace OpenNest.Forms
// plateOptimizerGroup // plateOptimizerGroup
// //
this.plateOptimizerGroup.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); this.plateOptimizerGroup.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
this.plateOptimizerGroup.Controls.Add(this.minRemnantLabel);
this.plateOptimizerGroup.Controls.Add(this.minRemnantBox);
this.plateOptimizerGroup.Controls.Add(this.optimizePlateSizeBox); this.plateOptimizerGroup.Controls.Add(this.optimizePlateSizeBox);
this.plateOptimizerGroup.Controls.Add(this.plateGrid); this.plateOptimizerGroup.Controls.Add(this.plateGrid);
this.plateOptimizerGroup.Controls.Add(this.salvageRateLabel); this.plateOptimizerGroup.Controls.Add(this.salvageRateLabel);
this.plateOptimizerGroup.Controls.Add(this.salvageRateBox); this.plateOptimizerGroup.Controls.Add(this.salvageRateBox);
this.plateOptimizerGroup.Controls.Add(this.salvageRatePercentLabel); this.plateOptimizerGroup.Controls.Add(this.salvageRatePercentLabel);
this.plateOptimizerGroup.Location = new System.Drawing.Point(10, 158); this.plateOptimizerGroup.Location = new System.Drawing.Point(10, 52);
this.plateOptimizerGroup.Name = "plateOptimizerGroup"; this.plateOptimizerGroup.Name = "plateOptimizerGroup";
this.plateOptimizerGroup.Size = new System.Drawing.Size(528, 188); this.plateOptimizerGroup.Size = new System.Drawing.Size(528, 224);
this.plateOptimizerGroup.TabIndex = 4; this.plateOptimizerGroup.TabIndex = 4;
this.plateOptimizerGroup.TabStop = false; this.plateOptimizerGroup.TabStop = false;
this.plateOptimizerGroup.Text = " Plate Optimizer"; this.plateOptimizerGroup.Text = " Stock Options";
// //
// optimizePlateSizeBox // optimizePlateSizeBox
// //
@@ -318,8 +260,6 @@ namespace OpenNest.Forms
this.platesTab.PerformLayout(); this.platesTab.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.partsGrid)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.partsGrid)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.plateGrid)).EndInit(); ((System.ComponentModel.ISupportInitialize)(this.plateGrid)).EndInit();
this.partFirstGroup.ResumeLayout(false);
this.partFirstGroup.PerformLayout();
this.plateOptimizerGroup.ResumeLayout(false); this.plateOptimizerGroup.ResumeLayout(false);
this.plateOptimizerGroup.PerformLayout(); this.plateOptimizerGroup.PerformLayout();
this.buttonPanel.ResumeLayout(false); this.buttonPanel.ResumeLayout(false);
@@ -335,11 +275,6 @@ namespace OpenNest.Forms
private System.Windows.Forms.Label summaryLabel; private System.Windows.Forms.Label summaryLabel;
private System.Windows.Forms.Label engineLabel; private System.Windows.Forms.Label engineLabel;
private System.Windows.Forms.ComboBox engineComboBox; private System.Windows.Forms.ComboBox engineComboBox;
private System.Windows.Forms.CheckBox createNewPlatesAsNeededBox;
private System.Windows.Forms.GroupBox partFirstGroup;
private System.Windows.Forms.CheckBox partFirstCheckBox;
private System.Windows.Forms.Label sortOrderLabel;
private System.Windows.Forms.ComboBox sortOrderComboBox;
private System.Windows.Forms.Label minRemnantLabel; private System.Windows.Forms.Label minRemnantLabel;
private System.Windows.Forms.TextBox minRemnantBox; private System.Windows.Forms.TextBox minRemnantBox;
private System.Windows.Forms.GroupBox plateOptimizerGroup; private System.Windows.Forms.GroupBox plateOptimizerGroup;
-38
View File
@@ -22,11 +22,6 @@ namespace OpenNest.Forms
LoadDefaultPlateOptions(); LoadDefaultPlateOptions();
SetPlateOptimizerVisible(false); SetPlateOptimizerVisible(false);
sortOrderComboBox.Items.Add("Bounding Box Area");
sortOrderComboBox.Items.Add("Size");
sortOrderComboBox.SelectedIndex = 0;
SetPartFirstVisible(false);
partsGrid.DataError += PartsGrid_DataError; partsGrid.DataError += PartsGrid_DataError;
} }
@@ -36,12 +31,6 @@ namespace OpenNest.Forms
set { engineComboBox.SelectedItem = value; } set { engineComboBox.SelectedItem = value; }
} }
public bool AllowPlateCreation
{
get { return createNewPlatesAsNeededBox.Checked; }
set { createNewPlatesAsNeededBox.Checked = value; }
}
public bool OptimizePlateSize public bool OptimizePlateSize
{ {
get { return optimizePlateSizeBox.Checked; } get { return optimizePlateSizeBox.Checked; }
@@ -59,22 +48,6 @@ namespace OpenNest.Forms
set { salvageRateBox.Text = (value * 100).ToString("F0"); } set { salvageRateBox.Text = (value * 100).ToString("F0"); }
} }
public bool PartFirstMode
{
get { return partFirstCheckBox.Checked; }
set { partFirstCheckBox.Checked = value; }
}
public PartSortOrder SortOrder
{
get
{
if (sortOrderComboBox.SelectedItem is string s && s == "Size")
return PartSortOrder.Size;
return PartSortOrder.BoundingBoxArea;
}
}
public double MinRemnantSize public double MinRemnantSize
{ {
get get
@@ -292,17 +265,6 @@ namespace OpenNest.Forms
salvageRateLabel.Visible = visible; salvageRateLabel.Visible = visible;
salvageRateBox.Visible = visible; salvageRateBox.Visible = visible;
salvageRatePercentLabel.Visible = visible; salvageRatePercentLabel.Visible = visible;
}
private void partFirstCheckBox_CheckedChanged(object sender, EventArgs e)
{
SetPartFirstVisible(partFirstCheckBox.Checked);
}
private void SetPartFirstVisible(bool visible)
{
sortOrderLabel.Visible = visible;
sortOrderComboBox.Visible = visible;
minRemnantLabel.Visible = visible; minRemnantLabel.Visible = visible;
minRemnantBox.Visible = visible; minRemnantBox.Visible = visible;
} }
+2 -82
View File
@@ -9,91 +9,11 @@ using OpenNest.Geometry;
namespace OpenNest.Forms namespace OpenNest.Forms
{ {
/// <summary> /// <summary>
/// Desktop adapter for whole-job engines (StockLadder and Engines/ plug-ins): builds the /// Desktop progress adapter for every whole-job engine. Building, validation and binding
/// NestJob from auto-nest items, translates NestJobProgress for NestProgressForm, and maps /// belong to the shared NestPipeline.
/// result poses back onto the nest's own drawings.
/// </summary> /// </summary>
internal static class JobEngineNest internal static class JobEngineNest
{ {
public static NestJob BuildJob(
IReadOnlyList<NestItem> items,
Plate template,
List<PlateOption> plateOptions,
double salvageRate,
double minRemnantSize,
int maxPlates,
out Dictionary<string, Drawing> drawingsByPartId
)
{
var parts = new List<NestJobPart>();
drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
for (var i = 0; i < items.Count; i++)
{
if (items[i].Quantity <= 0)
continue;
var partId = $"part-{i}";
parts.Add(DrawingJobMapper.FromItem(partId, items[i]));
drawingsByPartId[partId] = items[i].Drawing;
}
var stock = new List<NestPlateStock>();
if (plateOptions != null && plateOptions.Count > 0)
{
for (var i = 0; i < plateOptions.Count; i++)
{
var option = plateOptions[i];
stock.Add(
new NestPlateStock(
$"option-{i}",
new Size(option.Width, option.Length),
quantity: null,
template.PartSpacing,
template.EdgeSpacing,
template.Quadrant
)
);
}
}
else
{
stock.Add(DrawingJobMapper.FromPlate("plate", template, quantity: null));
}
var options = new NestJobOptions(
maxPlates: maxPlates,
salvageRate: plateOptions != null && plateOptions.Count > 0 ? salvageRate : 0,
minimumSalvageDimension: minRemnantSize
);
return new NestJob(parts, stock, options);
}
/// <summary>Parts for one result sheet, bound to the caller's drawings.</summary>
public static List<Part> CreateParts(
NestJobPlateResult sheet,
IReadOnlyDictionary<string, Drawing> drawingsByPartId
)
{
var parts = new List<Part>(sheet.Placements.Count);
foreach (var pose in sheet.Placements)
{
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
continue;
var part = new Part(drawing);
part.Rotate(pose.Rotation);
part.Location = new Vector(pose.X, pose.Y);
part.UpdateBounds();
parts.Add(part);
}
return parts;
}
/// <summary> /// <summary>
/// Forwards job progress to a NestProgress sink. An engine's optional LegacyProgress detail /// Forwards job progress to a NestProgress sink. An engine's optional LegacyProgress detail
/// (live preview parts) passes through; otherwise the stage and committed counts become a /// (live preview parts) passes through; otherwise the stage and committed counts become a
+98 -302
View File
@@ -1146,336 +1146,132 @@ namespace OpenNest.Forms
private async void RunAutoNest_Click(object sender, EventArgs e) private async void RunAutoNest_Click(object sender, EventArgs e)
{ {
var form = new AutoNestForm(activeForm.Nest); var target = activeForm;
form.AllowPlateCreation = true; if (target == null || target.IsDisposed || target.PlateView.Plate == null)
if (activeForm.Nest.PlateOptions.Count > 0)
form.LoadPlateOptions(activeForm.Nest.PlateOptions, activeForm.Nest.SalvageRate);
if (form.ShowDialog() != System.Windows.Forms.DialogResult.OK)
return; return;
var views = MdiChildren.OfType<EditNestForm>()
if (form.EngineName != null) .Where(f => ReferenceEquals(f.Nest, target.Nest)).Select(f => f.PlateView).ToArray();
if (nestingInProgress || Application.OpenForms.OfType<NestProgressForm>().Any()
|| views.Any(v => v.IsFillInProgress || v.Actions.CurrentAction?.IsBusy() == true))
{ {
EngineSelection.EngineName = form.EngineName; MessageBox.Show(this, "Finish or cancel the current plate operation before Auto Nest.",
engineComboBox.SelectedItem = form.EngineName; "Auto Nest", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
} }
foreach (var view in views)
view.SetAction(typeof(ActionSelect));
using var form = new AutoNestForm(target.Nest);
if (target.Nest.PlateOptions.Count > 0)
form.LoadPlateOptions(target.Nest.PlateOptions, target.Nest.SalvageRate);
if (form.ShowDialog(this) != DialogResult.OK)
return;
var items = form.GetNestItems(); var items = form.GetNestItems();
if (!items.Any(it => it.Quantity > 0)) if (!items.Any(it => it.Quantity > 0))
return; return;
var engineName = form.EngineName ?? EngineSelection.EngineName;
var optimizePlateSize = form.OptimizePlateSize; EngineSelection.EngineName = engineName;
var plateOptions = optimizePlateSize ? form.GetPlateOptions() : null; engineComboBox.SelectedItem = engineName;
var salvageRate = form.SalvageRate; var plateOptions = form.OptimizePlateSize ? form.GetPlateOptions() : null;
var partFirstMode = form.PartFirstMode; using var cts = new CancellationTokenSource();
var sortOrder = form.SortOrder; nestingCts = cts;
var minRemnantSize = form.MinRemnantSize; using var progressForm = new NestProgressForm(cts, showPlateRow: true)
var allowPlateCreation = form.AllowPlateCreation;
if (optimizePlateSize)
{ {
activeForm.Nest.PlateOptions = plateOptions; AllowAccept = false,
activeForm.Nest.SalvageRate = salvageRate; HoldOpenUntilCompleted = true,
} };
var receivingProgress = true;
nestingCts = new CancellationTokenSource();
var progressForm = new NestProgressForm(nestingCts, showPlateRow: true);
var jobEngineName = EngineSelection.IsFillStrategy(EngineSelection.EngineName)
? null
: EngineSelection.EngineName;
progressForm.AllowAccept = jobEngineName == null;
var progress = new Progress<NestProgress>(p => var progress = new Progress<NestProgress>(p =>
{ {
if (!receivingProgress || target.IsDisposed || cts.IsCancellationRequested)
return;
progressForm.UpdateProgress(p); progressForm.UpdateProgress(p);
if (p.IsOverallBest) if (p.IsOverallBest)
activeForm.PlateView.SetActiveParts(p.BestParts); target.PlateView.SetActiveParts(p.BestParts);
activeForm.PlateView.ActiveWorkArea = p.ActiveWorkArea; target.PlateView.ActiveWorkArea = p.ActiveWorkArea;
}); });
var jobProgress = JobEngineNest.CreateProgress(engineName, progress);
progressForm.Show(this);
SetNestingLockout(true); SetNestingLockout(true);
try try
{ {
if (jobEngineName != null) var request = new NestPipelineRequest(engineName, items,
await RunJobEngineAsync( NestStockBuilder.FromTemplate(target.PlateView.Plate, plateOptions),
jobEngineName, new NestJobOptions(maxPlates: 100,
items, salvageRate: plateOptions?.Count > 0 ? form.SalvageRate : 0,
progressForm, minimumSalvageDimension: form.MinRemnantSize));
progress,
nestingCts.Token,
plateOptions,
salvageRate,
minRemnantSize
);
else
await RunAutoNestAsync(
items,
progressForm,
progress,
nestingCts.Token,
plateOptions,
salvageRate,
partFirstMode,
sortOrder,
minRemnantSize,
allowPlateCreation
);
}
catch (Exception ex)
{
activeForm.PlateView.ClearPreviewParts();
MessageBox.Show(
$"Nesting error: {ex.Message}",
"Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error
);
}
finally
{
activeForm.PlateView.ActiveWorkArea = null;
progressForm.Close();
SetNestingLockout(false);
nestingCts.Dispose();
nestingCts = null;
}
}
private async Task RunAutoNestAsync( async Task<NestPipelineResult> SolveAsync()
List<NestItem> items,
NestProgressForm progressForm,
IProgress<NestProgress> progress,
CancellationToken token,
List<PlateOption> plateOptions = null,
double salvageRate = 0.5,
bool partFirstMode = false,
PartSortOrder sortOrder = PartSortOrder.BoundingBoxArea,
double minRemnantSize = 12.0,
bool allowPlateCreation = true
)
{
if (partFirstMode)
{
var existingPlates = new List<Plate>();
for (var i = 0; i < activeForm.Nest.Plates.Count; i++)
{ {
var p = activeForm.Nest.Plates[i]; try
if (p.Parts.Count > 0)
existingPlates.Add(p);
}
var template = activeForm.PlateView.Plate;
var nestOptions = new MultiPlateNestOptions
{
Template = template,
PlateOptions = plateOptions,
SalvageRate = salvageRate,
SortOrder = sortOrder,
MinRemnantSize = minRemnantSize,
AllowPlateCreation = allowPlateCreation,
Strategy = EngineSelection.FillStrategy,
};
var result = await Task.Run(() =>
MultiPlateNester.Nest(items, nestOptions, existingPlates, progress, token)
);
foreach (var pr in result.Plates)
{
if (pr.IsNew)
{ {
var plate = GetOrCreatePlate(); return await Task.Run(() => NestPipeline.Run(request, jobProgress, cts.Token));
plate.Size = pr.Plate.Size; }
plate.Parts.AddRange(pr.Parts); finally
{
receivingProgress = false;
progressForm.ShowCompleted();
progressForm.Close();
} }
} }
activeForm.Nest.UpdateDrawingQuantities(); var solve = SolveAsync();
progressForm.ShowCompleted(); // Owned modal progress holds drawings/plates stable through solve and cancellation.
return; if (!solve.IsCompleted)
} progressForm.ShowDialog(this);
var result = await solve;
cts.Token.ThrowIfCancellationRequested();
target.PlateView.ClearPreviewParts();
target.PlateView.ActiveWorkArea = null;
const int maxPlates = 100; var allowInvalid = false;
if (!result.IsValid)
for (var plateIndex = 0; plateIndex < maxPlates; plateIndex++)
{
var remaining = items.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0 || token.IsCancellationRequested)
break;
var plate = GetOrCreatePlate();
var placed = await NestSinglePlateAsync(
plate,
plateIndex,
remaining,
progressForm,
progress,
token,
plateOptions,
salvageRate
);
if (!placed)
break;
}
activeForm.Nest.UpdateDrawingQuantities();
progressForm.ShowCompleted();
}
/// <summary>
/// Whole-job path for StockLadder and Engines/ plug-ins: the engine owns plate count and
/// size selection, reports NestJobProgress into the progress form, and its result is
/// committed onto empty or new plates. Cancellation discards the run (engines throw).
/// </summary>
private async Task RunJobEngineAsync(
string engineName,
List<NestItem> items,
NestProgressForm progressForm,
IProgress<NestProgress> progress,
CancellationToken token,
List<PlateOption> plateOptions,
double salvageRate,
double minRemnantSize
)
{
const int maxPlates = 100;
var engine = NestingEngineRegistry.Create(engineName);
var job = JobEngineNest.BuildJob(
items,
activeForm.PlateView.Plate,
plateOptions,
salvageRate,
minRemnantSize,
maxPlates,
out var drawingsByPartId
);
var jobProgress = JobEngineNest.CreateProgress(engineName, progress);
NestJobResult result;
try
{
result = await Task.Run(() => engine.Solve(job, jobProgress, token));
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
activeForm.PlateView.ClearPreviewParts();
return;
}
activeForm.PlateView.ClearPreviewParts();
foreach (var sheet in result.Plates)
{
var parts = JobEngineNest.CreateParts(sheet, drawingsByPartId);
if (parts.Count == 0)
continue;
var plate = GetOrCreatePlate();
plate.Size = sheet.Stock.Size;
plate.Parts.AddRange(parts);
}
activeForm.PlateView.Invalidate();
activeForm.Nest.UpdateDrawingQuantities();
progressForm.ShowCompleted();
if (result.Status != NestJobStatus.Complete)
MessageBox.Show(
$"{engineName} could not place every part ({result.StopReason}).",
"Auto Nest",
MessageBoxButtons.OK,
MessageBoxIcon.Information
);
}
private Plate GetOrCreatePlate()
{
var plate = activeForm.PlateManager.GetOrCreateEmpty();
activeForm.PlateManager.LoadLast();
return plate;
}
private async Task<bool> NestSinglePlateAsync(
Plate plate,
int plateIndex,
List<NestItem> items,
NestProgressForm progressForm,
IProgress<NestProgress> progress,
CancellationToken token,
List<PlateOption> plateOptions = null,
double salvageRate = 0.5
)
{
List<Part> nestParts;
if (plateOptions != null && plateOptions.Count > 0)
{
var result = await Task.Run(() =>
PlateOptimizer.Optimize(
items,
plateOptions,
salvageRate,
plate,
progress,
token,
EngineSelection.FillStrategy
)
);
if (
result == null
|| result.Parts.Count == 0
|| (token.IsCancellationRequested && !progressForm.Accepted)
)
return false;
plate.Size = new Geometry.Size(result.ChosenSize.Width, result.ChosenSize.Length);
nestParts = result.Parts;
// Deduct placed quantities — the optimizer clones items internally
// so the originals are untouched after dry runs.
foreach (var item in items)
{ {
var placed = nestParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name); using var review = new NestValidationForm(result.Violations, result.CanKeep);
item.Quantity = System.Math.Max(0, item.Quantity - placed); if (review.ShowDialog(this) != DialogResult.OK)
return;
allowInvalid = true;
} }
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, target.PlateManager,
allowInvalid, cts.Token);
if (applied.Count > 0)
target.PlateManager.LoadAt(target.Nest.Plates.IndexOf(applied[0]));
if (plateOptions?.Count > 0)
{
target.Nest.PlateOptions = plateOptions;
target.Nest.SalvageRate = form.SalvageRate;
}
target.PlateView.Invalidate();
if (allowInvalid)
{
target.OverlapDisplay = OverlapDisplayMode.Both;
await target.CheckOverlapsAsync();
}
if (result.Status != NestJobStatus.Complete)
MessageBox.Show(this, $"{engineName} could not place every part ({result.StopReason}).",
"Auto Nest", MessageBoxButtons.OK, MessageBoxIcon.Information);
} }
else catch (OperationCanceledException) when (cts.IsCancellationRequested)
{ {
// Same preview flow as before: fill the current plate's remaining demand with the // Stop discards the entire proposal, including engines that ignore cancellation.
// selected strategy, then commit the returned parts. App-scoped selection — the }
// process-global engine registry is never consulted. catch (Exception ex)
nestParts = await Task.Run(() => {
PlateFillService.Nest( MessageBox.Show(this, $"Nesting error: {ex.Message}", "Auto Nest",
EngineSelection.FillStrategy, MessageBoxButtons.OK, MessageBoxIcon.Error);
plate, }
items, finally
plateIndex, {
progress, receivingProgress = false;
token if (!target.IsDisposed)
) {
); target.PlateView.ClearPreviewParts();
target.PlateView.ActiveWorkArea = null;
}
SetNestingLockout(false);
nestingCts = null;
} }
activeForm.PlateView.ClearPreviewParts();
if (nestParts.Count == 0 || (token.IsCancellationRequested && !progressForm.Accepted))
return false;
plate.Parts.AddRange(nestParts);
activeForm.PlateView.Invalidate();
return true;
} }
private void SequenceAllPlates_Click(object sender, EventArgs e) private void SequenceAllPlates_Click(object sender, EventArgs e)
+22 -1
View File
@@ -25,6 +25,20 @@ namespace OpenNest.Forms
private readonly System.Windows.Forms.Timer fadeTimer; private readonly System.Windows.Forms.Timer fadeTimer;
private readonly Dictionary<Label, (int remaining, Color flashColor)> fadeCounters = new(); private readonly Dictionary<Label, (int remaining, Color flashColor)> fadeCounters = new();
private bool hasReceivedProgress; private bool hasReceivedProgress;
private bool completed;
/// <summary>Keep the owner disabled until a whole-job worker finishes after Stop/close.</summary>
private bool holdOpenUntilCompleted;
public bool HoldOpenUntilCompleted
{
get => holdOpenUntilCompleted;
set
{
holdOpenUntilCompleted = value;
if (value)
stopButton.Enabled = true; // Plug-ins need not report progress before cancellation.
}
}
public bool Accepted { get; private set; } public bool Accepted { get; private set; }
@@ -94,6 +108,7 @@ namespace OpenNest.Forms
public void ShowCompleted() public void ShowCompleted()
{ {
completed = true;
if (IsDisposed || !IsHandleCreated) if (IsDisposed || !IsHandleCreated)
return; return;
@@ -143,13 +158,19 @@ namespace OpenNest.Forms
protected override void OnFormClosing(FormClosingEventArgs e) protected override void OnFormClosing(FormClosingEventArgs e)
{ {
if (HoldOpenUntilCompleted && !completed)
{
cts.Cancel();
e.Cancel = true;
return;
}
fadeTimer.Stop(); fadeTimer.Stop();
fadeTimer.Dispose(); fadeTimer.Dispose();
elapsedTimer.Stop(); elapsedTimer.Stop();
elapsedTimer.Dispose(); elapsedTimer.Dispose();
stopwatch.Stop(); stopwatch.Stop();
if (!cts.IsCancellationRequested) if (!completed && !cts.IsCancellationRequested)
cts.Cancel(); cts.Cancel();
base.OnFormClosing(e); base.OnFormClosing(e);
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms;
/// <summary>A fresh, explicit decision for each invalid automatic nesting proposal.</summary>
public sealed class NestValidationForm : Form
{
public NestValidationForm(IReadOnlyList<string> violations, bool canKeep)
{
Text = "Auto Nest — validation problems";
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(740, 440);
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
var summary = new Label
{
Dock = DockStyle.Top,
Height = 70,
Padding = new Padding(10),
Text = canKeep
? "This layout failed validation. Discard leaves your plates unchanged. Keep anyway applies the entire layout and enables Overlap Check.\nOverlap Check highlights material overlaps; spacing, stock and rotation warnings remain listed here."
: "The engine returned malformed placements. No layout can be kept. Discard leaves your plates unchanged.",
};
var report = new TextBox
{
Dock = DockStyle.Fill,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both,
WordWrap = false,
Text = string.Join("\r\n\r\n", violations),
};
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 48,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(6),
};
var discard = new Button { Text = "Discard", AutoSize = true, DialogResult = DialogResult.Cancel };
var keep = new Button
{
Text = "Keep anyway",
AutoSize = true,
Enabled = canKeep,
DialogResult = DialogResult.OK,
};
buttons.Controls.Add(discard);
buttons.Controls.Add(keep);
Controls.Add(report);
Controls.Add(summary);
Controls.Add(buttons);
AcceptButton = discard;
CancelButton = discard;
ActiveControl = discard;
}
}
+38
View File
@@ -0,0 +1,38 @@
# Automatic nesting pipeline
`NestPipeline.Run` is the shared whole-job boundary in `OpenNest.Engine`:
1. Snapshot caller requirements and stock into a `NestJob`.
2. Resolve the selected name through `NestingEngineRegistry` (built-ins and plug-ins use the same path).
3. Run the engine without mutating caller drawings or plates.
4. Check returned placements with the independent `NestLayoutCheck` used by the benchmark.
5. Bind a representable result back to the caller's drawing references; the caller decides whether to commit it.
`NestStockBuilder.FromTemplate` snapshots sheet options and spacing settings for multiple-sheet jobs. `SinglePlate` offers exactly one physical sheet, regardless of the legacy plate repeat quantity. `NestPipelineCommit.ApplyToEmptyPlates` applies accepted desktop proposals to empty/new plates with the checked size, quadrant, spacing and quantity-one semantics. It never fills occupied plates.
## Desktop Auto Nest
Every engine selected in **Nest > Auto Nest** uses the pipeline. Part-First and its sorting controls are removed. Use interactive Fill Area/remnant tools for leftover space on occupied sheets; these are not whole-job nesting.
Auto Nest starts on empty/new sheets and does not change existing populated plates. Stock Options offers sheet sizes and salvage settings; minimum salvage size is part of that section, not a Part-First option. The maximum remains 100 physical sheets per run.
Progress is owned and modal so the input drawings cannot be edited while a worker uses them. Stop or closing progress cancels and discards the whole proposal. The dialog waits for the worker to finish; no engine has an Accept-early button on this path. A plug-in that ignores the cancellation token cannot commit its late result.
Every invalid result shows the validation report before any plates are changed:
- **Discard** is the default button and the Escape action. No proposed parts or sheets are applied.
- **Keep anyway** requires an explicit click and keeps the entire representable proposal, then enables Overlap Check.
- Malformed output (unknown requirements or nonfinite poses) is not keepable. It cannot be faithfully represented as the proposed layout; no partial/truncated proposal is offered.
Overlap Check displays material overlaps, not every spacing/stock/rotation failure in the report. A layout passing validation may still be incomplete; completeness and stop reason are separate from geometric validity. This check does not replace pre-post CNC verification.
The selected engine is saved in `%APPDATA%\OpenNest\engine-selection.json`.
Selection is restored after plug-ins load. If the saved engine is unavailable,
Default is selected and the status bar reports the fallback; startup does not
replace the saved missing-engine preference.
## Integration constraints
Callers must hold drawings and target state stable from snapshot through attachment. Do not append whole-job placements to an occupied target or flatten multiple returned sheets onto one plate: that would commit a layout different from the one checked. Do not trust arbitrary engine fulfillment metadata as a substitute for counting returned placements.
Interactive fills are outside this contract and still use `PlateFillService`. The benchmark invokes the same independent validator. Console/MCP/API migration is a separate adoption step; the existence of the pipeline does not imply every front end already calls it.