feat(ui): route Auto Nest through whole-job engines

StockLadder and Engines/ plug-ins only implement INestingEngine.Solve, so
selecting them in Auto Nest had no path to run. MainForm now solves the
whole job through JobEngineNest when the selected engine is not a
built-in fill strategy, feeding NestJobProgress into NestProgressForm and
binding the result poses back onto the nest's own drawings. Whole-job
engines throw on cancel rather than returning a partial layout, so the
progress form hides Accept for these runs. Built-in strategies keep the
existing per-plate fill path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 14:03:10 -04:00
co-authored by Claude Opus 5.5
parent 23dd99fa2f
commit 39db4dc10f
4 changed files with 396 additions and 157 deletions
+1
View File
@@ -102,6 +102,7 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
The UI application with MDI interface.
- **Auto Nest engine routing**: when the selected engine is not a built-in fill strategy (`EngineSelection.IsFillStrategy` is false, i.e. StockLadder or an `Engines/` plug-in), `MainForm.RunJobEngineAsync` solves the whole job through `INestingEngine.Solve`. `JobEngineNest` builds the `NestJob` from the auto-nest items and either the plate options or the current plate, and it converts `NestJobProgress` for `NestProgressForm`: an engine's `LegacyProgress` passes through, and otherwise the stage and committed counts become the description. It then binds the result poses back onto the nest's own drawings. Whole-job engines throw on cancel, so the progress form hides Accept (`AllowAccept = false`) and Stop discards the run. Built-in strategies keep the existing per-plate fill path.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
+144
View File
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Forms
{
/// <summary>
/// Desktop adapter for whole-job engines (StockLadder and Engines/ plug-ins): builds the
/// NestJob from auto-nest items, translates NestJobProgress for NestProgressForm, and maps
/// result poses back onto the nest's own drawings.
/// </summary>
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>
/// 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
/// description. Committed counts are authoritative only after PlateCommitted.
/// </summary>
public static IProgress<NestJobProgress> CreateProgress(
string engineName,
IProgress<NestProgress> progress
) => new ProgressAdapter(engineName, progress);
public static string Describe(string engineName, NestJobProgress value) =>
value.Stage == NestJobStage.PlateCommitted
? $"{engineName}: committed plate {value.CommittedPlates} ({value.CommittedParts} parts placed)"
: $"{engineName}: evaluating plate {WorkingPlate(value)} on stock {value.StockId}";
/// <summary>One-based plate under evaluation; nesters that do not know it report -1.</summary>
private static int WorkingPlate(NestJobProgress value) =>
value.PlateIndex >= 0 ? value.PlateIndex + 1 : value.CommittedPlates + 1;
private sealed class ProgressAdapter(string engineName, IProgress<NestProgress> progress)
: IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
if (value == null)
return;
if (value.LegacyProgress != null)
{
progress.Report(value.LegacyProgress);
return;
}
progress.Report(
new NestProgress
{
Phase = NestPhase.Custom,
PlateNumber =
value.Stage == NestJobStage.PlateCommitted
? value.CommittedPlates
: WorkingPlate(value),
Description = Describe(engineName, value),
}
);
}
}
}
}
+84
View File
@@ -1042,6 +1042,11 @@ namespace OpenNest.Forms
var progressForm = new NestProgressForm(nestingCts, showPlateRow: true);
progressForm.PreviewPlate = CreatePreviewPlate(activeForm.PlateView.Plate);
var jobEngineName = EngineSelection.IsFillStrategy(EngineSelection.EngineName)
? null
: EngineSelection.EngineName;
progressForm.AllowAccept = jobEngineName == null;
var progress = new Progress<NestProgress>(p =>
{
progressForm.UpdateProgress(p);
@@ -1058,6 +1063,18 @@ namespace OpenNest.Forms
try
{
if (jobEngineName != null)
await RunJobEngineAsync(
jobEngineName,
items,
progressForm,
progress,
nestingCts.Token,
plateOptions,
salvageRate,
minRemnantSize
);
else
await RunAutoNestAsync(
items,
progressForm,
@@ -1176,6 +1193,73 @@ namespace OpenNest.Forms
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(progressForm);
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(NestProgressForm progressForm)
{
var plate = activeForm.PlateManager.GetOrCreateEmpty();
+10
View File
@@ -28,6 +28,16 @@ namespace OpenNest.Forms
public bool Accepted { get; private set; }
/// <summary>
/// False hides "Accept" for solvers that cannot return a partial result on cancellation
/// (whole-job engines throw instead).
/// </summary>
public bool AllowAccept
{
get => acceptButton.Visible;
set => acceptButton.Visible = value;
}
public Plate PreviewPlate
{
get => previewPlateView.Plate;