feat(desktop): show Auto Nest as a sheet strip with a Keep/Discard review

Auto Nest no longer draws parts on the plate view before they are kept:
its result is a set of sheets, possibly of several sizes, not parts on the
current plate. Ghost parts stay with interactive Fill and remnant tools.

- NestJobStage.PlanImproved carries an engine's best whole-job plan so far;
  Irregular reports one whenever a variant or the tail re-plan beats it.
- NestPipeline adds PlanSheets: the plan on detached drawings built from
  the job geometry (with the caller's names and colors), so previews never
  touch caller drawings or quantities.
- SheetStripControl draws every sheet side by side at one scale, repeats
  once with their count, captioned with size, parts and utilization.
- The progress window shows "Best plan so far"; a review window shows the
  final sheets, totals, cost summary and unplaced parts before commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 23:56:38 -04:00
1 parent a2fb945669
commit cd2bea054b
13 files changed
+692 -47

No files matched your search

@@ -57,6 +57,48 @@ public class NestPipelineTests
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
[Fact]
public void PlanReportsBecomeDetachedSheetsThatLeaveCallerDrawingsAlone()
{
var item = Item("bracket", 2);
var engine = new ReportingEngine(job =>
OnePlate(
job,
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
new NestJobPlacement(job.Parts[0].Id, 1, 20, 1, 0)
));
var reports = new List<NestJobProgress>();
var result = NestPipeline.Run(engine, "Stub", Request("Stub", item), new InlineProgress(reports.Add));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var plan = Assert.Single(reports, r => r.Stage == NestJobStage.PlanImproved);
var sheet = Assert.Single(plan.PlanSheets);
Assert.Same(result.Job.Plates[0], sheet.Stock);
Assert.Equal(new[] { 1.0, 20.0 }, sheet.Parts.Select(p => p.Location.X));
Assert.All(sheet.Parts, part => Assert.NotSame(item.Drawing, part.BaseDrawing));
Assert.Equal(0, item.Drawing.Quantity.Nested);
}
private sealed class ReportingEngine(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
)
{
var result = solve(job);
progress?.Report(new NestJobProgress(NestJobStage.PlanImproved, string.Empty, -1, 0, 0, Plan: result));
return result;
}
}
private sealed class InlineProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value) => report(value);
}
[Fact]
public void OverlappingEngineOutputIsReportedByDrawingNameWithoutThrowing()
{
@@ -1,13 +1,13 @@
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
@@ -211,6 +211,32 @@ public class IrregularNestingEngineTests
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Fact]
public void ReportsEachBetterPlanEndingWithTheReturnedLayout()
{
var job = Job(new[] { Part("disc", Disc(3), 10), Part("ell", LShape(12, 8, 4), 10) },
new[] { Stock("sheet", 40, 60, spacing: 0.5) });
var reports = new List<NestJobProgress>();
var result = new IrregularNestingEngine().Solve(job, new SyncProgress(reports.Add));
var without = new IrregularNestingEngine().Solve(job);
var plans = reports.Where(r => r.Stage == NestJobStage.PlanImproved).ToList();
Assert.NotEmpty(plans);
// Plans are proposals, never commits: they carry no committed counts.
Assert.All(plans, p => Assert.Equal((0, 0), (p.CommittedPlates, p.CommittedParts)));
Assert.Equal(result.Plates.SelectMany(p => p.Placements), plans[^1].Plan.Plates.SelectMany(p => p.Placements));
Assert.True(reports.FindLastIndex(r => r.Stage == NestJobStage.PlanImproved)
< reports.FindIndex(r => r.Stage == NestJobStage.PlateCommitted));
// Reporting never steers the search.
Assert.Equal(without.Plates.SelectMany(p => p.Placements), result.Plates.SelectMany(p => p.Placements));
}
private sealed class SyncProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value) => report(value);
}
[Fact]
public void HasPublicParameterlessConstructorForPluginDiscovery()
{
+19 -2
View File
@@ -1,9 +1,17 @@
using System.Collections.Generic;
namespace OpenNest.Engine.Jobs;
public enum NestJobStage
{
EvaluatingCandidate,
PlateCommitted,
/// <summary>
/// The engine's best whole-job plan so far, in <see cref="NestJobProgress.Plan"/>. Not
/// validated and possibly replaced by a later plan; never counts as committed.
/// </summary>
PlanImproved,
}
/// <summary>
@@ -16,5 +24,14 @@ public sealed record NestJobProgress(
int PlateIndex,
int CommittedPlates,
int CommittedParts,
NestProgress LegacyProgress = null
);
NestProgress LegacyProgress = null,
NestJobResult Plan = null
)
{
/// <summary>
/// <see cref="Plan"/> as displayable sheets, filled in by <see cref="NestPipeline"/> for
/// <see cref="NestJobStage.PlanImproved"/> reports. Parts use detached drawings built from
/// the job geometry, so they never touch the caller's drawings or quantities.
/// </summary>
public IReadOnlyList<ProposedPlate> PlanSheets { get; init; }
}
+64 -3
View File
@@ -106,11 +106,71 @@ public static class NestPipeline
{
public void Report(NestJobProgress value)
{
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
if (value != null && value.Stage is NestJobStage.EvaluatingCandidate or NestJobStage.PlanImproved)
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
}
}
/// <summary>
/// Fills <see cref="NestJobProgress.PlanSheets"/> on an engine's plan reports. Parts use
/// detached drawings built from the solved job's geometry (bundled cutout frames included),
/// one per part, so a preview never touches the caller's drawings or their quantities.
/// </summary>
private sealed class PlanProgress(IProgress<NestJobProgress> inner, NestJob engineJob,
IReadOnlyDictionary<string, Drawing> callerDrawings, CancellationToken token)
: IProgress<NestJobProgress>
{
private readonly Dictionary<string, NestJobPart> parts =
engineJob.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
public void Report(NestJobProgress value)
{
if (value?.Stage == NestJobStage.PlanImproved && value.Plan != null && value.PlanSheets == null)
{
lock (drawings)
{
// A plan naming unknown parts has nothing to draw; validation reports it later.
if (value.Plan.Plates.Any(s => s.Placements.Any(p => p.PartId == null || !parts.ContainsKey(p.PartId))))
return;
value = value with { PlanSheets = Sheets(value.Plan) };
}
}
inner.Report(value);
}
private IReadOnlyList<ProposedPlate> Sheets(NestJobResult plan)
{
var sheets = new List<ProposedPlate>();
foreach (var group in RepeatedPlateGrouping.Create(plan.Plates, true, token))
{
var placed = new List<Part>(group.Sheet.Placements.Count);
foreach (var pose in group.Sheet.Placements)
{
if (!drawings.TryGetValue(pose.PartId, out var drawing))
{
drawing = DrawingJobMapper.CreateDrawing(parts[pose.PartId]);
// Borrow the caller's name and color for display; bundled frames keep their own.
if (callerDrawings.TryGetValue(pose.PartId, out var caller))
{
drawing.Name = caller.Name;
drawing.Color = caller.Color;
}
drawings[pose.PartId] = drawing;
}
// Same pose mapping as NestResultMaterializer: rotate in the snapshot frame, then move.
var part = new Part(drawing);
part.Rotate(pose.Rotation);
part.Location = new Geometry.Vector(pose.X, pose.Y);
part.UpdateBounds();
placed.Add(part);
}
sheets.Add(new ProposedPlate(group.Sheet.PlateIndex, group.Sheet.Stock, placed) { Quantity = group.Quantity });
}
return sheets;
}
}
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
/// Engine cancellation propagates unchanged.</summary>
@@ -263,9 +323,10 @@ public static class NestPipeline
NestJobValidator.Validate(engineJob);
var clock = Stopwatch.StartNew();
var engineProgress = progress == null ? null
: new PlanProgress(prepass == null ? progress : new PreviewProgress(progress), engineJob, drawingsByPartId, token);
var raw =
engine.Solve(engineJob, prepass == null || progress == null
? progress : new PreviewProgress(progress), token)
engine.Solve(engineJob, engineProgress, token)
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
var solveTime = clock.Elapsed;
token.ThrowIfCancellationRequested();
@@ -89,14 +89,29 @@ public sealed class IrregularNestingEngine : INestingEngine
break;
var plan = solver.Plan(demand, axis, beta);
if (best == null || plan.IsBetterThan(best))
{
best = plan;
ReportPlan(job, types, best, progress, token);
}
if (best.Unplaced == 0 && best.Sheets.Count == 0)
break;
}
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
best = solver.ImproveTail(best!, WorkBudget * 2);
return BuildResult(job, types, best, progress, token);
var improved = solver.ImproveTail(best!, WorkBudget * 2);
if (!ReferenceEquals(improved, best))
ReportPlan(job, types, improved, progress, token);
return BuildResult(job, types, improved, progress, token);
}
/// <summary>Reports a new best whole-job plan; reporting never changes the search.</summary>
private static void ReportPlan(NestJob job, IReadOnlyList<PartType> types, Plan plan,
IProgress<NestJobProgress>? progress, CancellationToken token)
{
if (progress == null)
return;
var result = BuildResult(job, types, plan, null, token);
progress.Report(new NestJobProgress(NestJobStage.PlanImproved, string.Empty, -1, 0, 0, Plan: result));
}
/// <summary>Exercises the production physical-tail lifecycle from a test-owned incumbent.
@@ -0,0 +1,70 @@
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
public class NestReviewFormTests
{
[Fact]
public void ShowsTheSheetsAndNamesUnplacedPartsWithoutTouchingQuantities() => StaTestThread.Run(() =>
{
var item = new NestItem { Drawing = new Drawing("bracket", Rectangle()), Quantity = 3 };
var stock = new NestPlateStock("sheet", new Size(48, 96), quantity: null, partSpacing: 0.25);
var engine = new StubEngine(job => new NestJobResult(
NestJobStatus.Incomplete,
NestJobStopReason.NoPlacementFound,
new[]
{
new NestJobPlateResult(0, job.Plates[0], new[]
{
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
new NestJobPlacement(job.Parts[0].Id, 1, 20, 1, 0),
}),
},
new[] { new PartFulfillment(job.Parts[0].Id, 3, 2, 1) },
new[] { new StockUsage(job.Plates[0].Id, 1, null) }));
var result = NestPipeline.Run(engine, "Stub", new NestPipelineRequest("Stub", new[] { item }, new[] { stock }));
using var form = new NestReviewForm(result, ColorScheme.Default);
Assert.Equal(1, form.Strip.SheetCount);
Assert.Equal(2, form.Strip.PartCount);
// Two 10 x 20 parts on a 48 x 96 sheet.
Assert.Equal(400.0 / (48 * 96), form.Strip.Utilization, 6);
Assert.Contains("bracket: 1 not placed", form.Summary);
Assert.Equal(0, item.Drawing.Quantity.Nested);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
[Fact]
public void RepeatedSheetsCountEveryPhysicalCopy() => StaTestThread.Run(() =>
{
var drawing = new Drawing("bracket", Rectangle());
var stock = new NestPlateStock("sheet", new Size(48, 96), quantity: null, partSpacing: 0.25);
var part = new Part(drawing, new Vector(1, 1));
using var strip = new OpenNest.Controls.SheetStripControl();
strip.SetSheets(new[] { new ProposedPlate(0, stock, new[] { part }) { Quantity = 3 } });
Assert.Equal(3, strip.SheetCount);
Assert.Equal(3, strip.PartCount);
}, TimeSpan.FromMinutes(1), "The STA test did not complete.");
private sealed class StubEngine(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default) => solve(job);
}
private static CNC.Program Rectangle()
{
var program = new CNC.Program();
program.MoveTo(0, 0);
program.LineTo(10, 0);
program.LineTo(10, 20);
program.LineTo(0, 20);
program.LineTo(0, 0);
return program;
}
}
+239
View File
@@ -0,0 +1,239 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Engine.Jobs;
namespace OpenNest.Controls
{
/// <summary>
/// A whole-job layout as a row of sheets, all drawn at one scale so sheet sizes compare
/// truly. Read-only: it never attaches parts to a plate or changes drawing quantities.
/// Repeated identical sheets show once with their count.
/// </summary>
internal sealed class SheetStripControl : ScrollableControl
{
private const int Gap = 16;
private const int Edge = 8;
private readonly List<Tile> tiles = new();
private ColorScheme colorScheme = ColorScheme.Default;
public SheetStripControl()
{
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer
| ControlStyles.ResizeRedraw | ControlStyles.UserPaint, true);
AutoScroll = true;
BackColor = ColorScheme.Default.BackgroundColor;
}
public ColorScheme ColorScheme
{
get => colorScheme;
set
{
colorScheme = value ?? ColorScheme.Default;
BackColor = colorScheme.BackgroundColor;
Invalidate();
}
}
/// <summary>The text shown when there are no sheets.</summary>
public string EmptyText { get; set; } = "No sheets yet";
public int SheetCount => tiles.Sum(t => t.Quantity);
/// <summary>Material area over sheet area for every physical sheet; 0 with no sheets.</summary>
public double Utilization
{
get
{
var sheetArea = tiles.Sum(t => t.Quantity * t.Sheet.Width * t.Sheet.Length);
return sheetArea > 0 ? tiles.Sum(t => t.Quantity * t.PartArea) / sheetArea : 0;
}
}
public int PartCount => tiles.Sum(t => t.Quantity * t.PartCount);
public void SetSheets(IReadOnlyList<ProposedPlate> sheets)
{
ClearTiles();
var first = 1;
foreach (var sheet in sheets ?? Array.Empty<ProposedPlate>())
{
tiles.Add(new Tile(sheet, first));
first += sheet.Quantity;
}
UpdateScrollSize();
Invalidate();
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
UpdateScrollSize();
}
protected override void Dispose(bool disposing)
{
if (disposing)
ClearTiles();
base.Dispose(disposing);
}
private void ClearTiles()
{
foreach (var tile in tiles)
tile.Dispose();
tiles.Clear();
}
private int CaptionHeight => Font.Height * 2 + 4;
/// <summary>
/// Pixels per world unit, shared by every sheet. All sheets fit the width when they can;
/// otherwise they shrink to a third of full height and the strip scrolls.
/// </summary>
private float PixelsPerUnit
{
get
{
if (tiles.Count == 0)
return 0;
var height = ClientSize.Height - CaptionHeight - Edge * 2 - SystemInformation.HorizontalScrollBarHeight;
var tallest = tiles.Max(t => t.Sheet.Width);
if (tallest <= 0 || height <= 0)
return 0;
var full = (float)(height / tallest);
// Largest scale whose tiles, captions included, fit the width (tile widths grow with scale).
var width = ClientSize.Width - Edge * 2 - Gap * (tiles.Count - 1);
float low = 0, high = full;
for (var i = 0; i < 24; i++)
{
var mid = (low + high) / 2;
if (tiles.Sum(t => TileWidth(t, mid)) <= width)
low = mid;
else
high = mid;
}
return System.Math.Max(low, full / 3);
}
}
private void UpdateScrollSize()
{
var scale = PixelsPerUnit;
var width = Edge * 2 + tiles.Sum(t => TileWidth(t, scale)) + Gap * System.Math.Max(0, tiles.Count - 1);
AutoScrollMinSize = new System.Drawing.Size(width, 0);
}
private int TileWidth(Tile tile, float scale) =>
System.Math.Max((int)System.Math.Ceiling(tile.Sheet.Length * scale), TextRenderer.MeasureText(tile.Detail, Font).Width);
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
var g = e.Graphics;
if (tiles.Count == 0)
{
TextRenderer.DrawText(g, EmptyText, Font, ClientRectangle, ForeColor,
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
return;
}
var scale = PixelsPerUnit;
if (scale <= 0)
return;
g.SmoothingMode = SmoothingMode.AntiAlias;
var x = Edge + AutoScrollPosition.X;
// Center the row vertically in the room above the scroll bar.
var room = ClientSize.Height - SystemInformation.HorizontalScrollBarHeight;
var content = (float)(tiles.Max(t => t.Sheet.Width) * scale) + CaptionHeight;
var baseline = (room - content) / 2 + (content - CaptionHeight);
using var partPen = new Pen(Color.FromArgb(60, 60, 60), 0);
using var clampBrush = new SolidBrush(Color.FromArgb(90, 230, 170, 40));
foreach (var tile in tiles)
{
var width = TileWidth(tile, scale);
var sheetWidth = (float)(tile.Sheet.Length * scale);
var sheetHeight = (float)(tile.Sheet.Width * scale);
var left = x + (width - sheetWidth) / 2;
var sheetRect = new RectangleF(left, baseline - sheetHeight, sheetWidth, sheetHeight);
g.FillRectangle(colorScheme.LayoutFillBrush, sheetRect);
var state = g.Save();
// World Y grows up; the sheet's lower-left corner sits at the rectangle's bottom-left.
g.TranslateTransform(left, baseline);
g.ScaleTransform(scale, -scale);
g.TranslateTransform((float)-tile.Sheet.X, (float)-tile.Sheet.Y);
foreach (var zone in tile.Clamps)
g.FillRectangle(clampBrush, zone);
foreach (var (path, brush) in tile.Parts)
{
g.FillPath(brush, path);
g.DrawPath(partPen, path);
}
g.Restore(state);
g.DrawRectangle(colorScheme.LayoutOutlinePen, sheetRect.X, sheetRect.Y, sheetRect.Width, sheetRect.Height);
var caption = new Rectangle(x, (int)baseline + 4, width, CaptionHeight);
TextRenderer.DrawText(g, tile.Title + "\n" + tile.Detail, Font, caption, ForeColor,
TextFormatFlags.HorizontalCenter | TextFormatFlags.Top);
x += width + Gap;
}
}
/// <summary>One distinct sheet layout with its world-space paths, built once per update.</summary>
private sealed class Tile : IDisposable
{
public Tile(ProposedPlate plate, int firstNumber)
{
var stock = plate.Stock;
Quantity = plate.Quantity;
PartCount = plate.Parts.Count;
// Sheet extents around the origin as the plate view places them for this quadrant.
var length = stock.Size.Length;
var width = stock.Size.Width;
var x = stock.Quadrant is 2 or 3 ? -length : 0;
var y = stock.Quadrant is 3 or 4 ? -width : 0;
Sheet = new Geometry.Box(x, y, length, width);
// Clamp zones are already in the placement frame, like the parts.
foreach (var zone in stock.ClampZones)
Clamps.Add(new RectangleF((float)zone.Bounds.X, (float)zone.Bounds.Y,
(float)zone.Bounds.Length, (float)zone.Bounds.Width));
foreach (var part in plate.Parts)
{
PartArea += part.BaseDrawing.Area;
var color = part.BaseDrawing.Color.IsEmpty || part.BaseDrawing.Color.A == 0
? Color.SteelBlue : part.BaseDrawing.Color;
Parts.Add((part.Program.GetFillPath(part.Location), new SolidBrush(Color.FromArgb(200, color))));
}
Title = Quantity == 1 ? $"Sheet {firstNumber}" : $"Sheets {firstNumber}–{firstNumber + Quantity - 1} (×{Quantity})";
var utilization = length * width > 0 ? PartArea / (length * width) : 0;
Detail = $"{stock.Size} · {PartCount} parts · {utilization:P0}";
}
public Geometry.Box Sheet { get; }
public int Quantity { get; }
public int PartCount { get; }
public double PartArea { get; }
public string Title { get; }
public string Detail { get; }
public List<RectangleF> Clamps { get; } = new();
public List<(GraphicsPath Path, Brush Brush)> Parts { get; } = new();
public void Dispose()
{
foreach (var (path, brush) in Parts)
{
path.Dispose();
brush.Dispose();
}
Parts.Clear();
}
}
}
}
+27 -28
View File
@@ -11,8 +11,11 @@ using OpenNest.Engine.Jobs;
namespace OpenNest.Forms.Commands
{
/// <summary>
/// Auto Nest from the confirmed dialog to the committed plates: owned modal progress,
/// whole-job solve, validation review, commit to empty plates and the result notices.
/// Auto Nest from the confirmed dialog to the committed plates: owned modal progress with
/// the best plan so far as a sheet strip, whole-job solve, validation review, a Keep/Discard
/// review of the final sheets, commit to empty plates and the result notices. The plate view
/// never shows Auto Nest parts before they are kept: the result is a set of sheets, not
/// parts on the current plate.
/// The caller owns the busy guard, the nesting lockout and the cancellation source.
/// </summary>
internal sealed class AutoNestCommand
@@ -78,17 +81,20 @@ namespace OpenNest.Forms.Commands
AllowAccept = false,
HoldOpenUntilCompleted = true,
};
progressForm.ShowPlanStrip(target.PlateView.ColorScheme);
var receivingProgress = true;
bool Receiving() => receivingProgress && !target.IsDisposed && !cts.IsCancellationRequested;
var progress = new Progress<NestProgress>(p =>
{
if (!receivingProgress || target.IsDisposed || cts.IsCancellationRequested)
return;
progressForm.UpdateProgress(p);
if (p.IsOverallBest)
target.PlateView.SetActiveParts(p.BestParts);
target.PlateView.ActiveWorkArea = p.ActiveWorkArea;
if (Receiving())
progressForm.UpdateProgress(p);
});
var jobProgress = JobEngineNest.CreateProgress(EngineName, progress);
var plans = new Progress<IReadOnlyList<ProposedPlate>>(sheets =>
{
if (Receiving())
progressForm.UpdatePlan(sheets);
});
var jobProgress = JobEngineNest.CreateProgress(EngineName, progress, plans);
try
{
var request = new NestPipelineRequest(EngineName, items,
@@ -117,8 +123,6 @@ namespace OpenNest.Forms.Commands
progressForm.ShowDialog(owner);
var result = await solve;
cts.Token.ThrowIfCancellationRequested();
target.PlateView.ClearPreviewParts();
target.PlateView.ActiveWorkArea = null;
var allowInvalid = false;
if (!result.IsValid)
@@ -130,6 +134,15 @@ namespace OpenNest.Forms.Commands
}
var costs = result.IsValid && result.CanKeep ? NestCostSummary.FromAccepted(result) : null;
var costText = costs?.Basis == "supplied-cost"
? $"Gross sheet cost: {costs.GrossTotal:G}; estimated salvage credit: {costs.SalvageCredit:G}; net score: {costs.NetScore:G}"
: null;
using (var review = new NestReviewForm(result, target.PlateView.ColorScheme, costText))
{
if (review.ShowDialog(owner) != DialogResult.OK)
return;
}
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, target.PlateManager,
allowInvalid, cts.Token);
if (applied.Count > 0)
@@ -145,7 +158,7 @@ namespace OpenNest.Forms.Commands
target.OverlapDisplay = OverlapDisplayMode.Both;
await target.CheckOverlapsAsync();
}
ShowResultNotices(result, applied, costs);
ShowResultNotices(applied);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
@@ -159,16 +172,11 @@ namespace OpenNest.Forms.Commands
finally
{
receivingProgress = false;
if (!target.IsDisposed)
{
target.PlateView.ClearPreviewParts();
target.PlateView.ActiveWorkArea = null;
}
}
}
private void ShowResultNotices(NestPipelineResult result, IReadOnlyList<Plate> applied,
NestCostSummary costs)
// Costs and unplaced parts were shown in the review; clamp removals exist only once kept.
private void ShowResultNotices(IReadOnlyList<Plate> applied)
{
// D8/D9: name every clamp Auto Nest pulled as a last resort and its added time.
var removalNotices = applied
@@ -182,15 +190,6 @@ namespace OpenNest.Forms.Commands
"Auto Nest removed clamps as a last resort so a part that would not "
+ "otherwise fit could be nested:\n\n" + string.Join("\n", removalNotices),
Caption, MessageBoxButtons.OK, MessageBoxIcon.Information);
if (costs?.Basis == "supplied-cost")
MessageBox.Show(owner, $"Gross sheet cost: {costs.GrossTotal:G}\nEstimated salvage credit: {costs.SalvageCredit:G}\nNet score: {costs.NetScore:G}",
"Auto Nest Cost", MessageBoxButtons.OK, MessageBoxIcon.Information);
// Never drop demand silently: name every unplaced part and why (the drawing
// list keeps showing the remaining quantities after this closes).
if (result.Unplaced.Count > 0)
MessageBox.Show(owner, $"{EngineName} could not place every part:\n\n"
+ string.Join("\n", result.Unplaced.Select(u => u.Describe())),
Caption, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
}
+32 -7
View File
@@ -22,25 +22,50 @@ namespace OpenNest.Forms
public static IProgress<NestJobProgress> CreateProgress(
string engineName,
IProgress<NestProgress> progress
) => new ProgressAdapter(engineName, progress);
) => new ProgressAdapter(engineName, progress, null);
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>
/// As <see cref="CreateProgress(string, IProgress{NestProgress})"/>, and also sends each
/// best-plan-so-far report's sheets to <paramref name="plans"/>.
/// </summary>
public static IProgress<NestJobProgress> CreateProgress(
string engineName,
IProgress<NestProgress> progress,
IProgress<IReadOnlyList<ProposedPlate>> plans
) => new ProgressAdapter(engineName, progress, plans);
public static string Describe(string engineName, NestJobProgress value) => value.Stage switch
{
NestJobStage.PlateCommitted =>
$"{engineName}: committed plate {value.CommittedPlates} ({value.CommittedParts} parts placed)",
NestJobStage.PlanImproved =>
$"{engineName}: found a better plan ({value.Plan?.Plates.Count ?? 0} sheets)",
_ => $"{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>
private sealed class ProgressAdapter(
string engineName,
IProgress<NestProgress> progress,
IProgress<IReadOnlyList<ProposedPlate>> plans
) : IProgress<NestJobProgress>
{
public void Report(NestJobProgress value)
{
if (value == null)
return;
// A plan sink shows plans as sheets; the plate row keeps tracking the search.
if (value.Stage == NestJobStage.PlanImproved && plans != null)
{
if (value.PlanSheets != null)
plans.Report(value.PlanSheets);
return;
}
if (value.LegacyProgress != null)
{
progress.Report(value.LegacyProgress);
+52 -1
View File
@@ -86,7 +86,8 @@ namespace OpenNest.Forms
phaseStepper.ActivePhase = progress.Phase;
SetValueWithFlash(plateValue, progress.PlateNumber.ToString());
if (progress.IsOverallBest)
// In plan mode the results rows describe the whole-job plan, not a part preview.
if (progress.IsOverallBest && planStrip == null)
{
SetValueWithFlash(partsValue, progress.BestPartCount.ToString());
@@ -106,6 +107,56 @@ namespace OpenNest.Forms
: progress.Phase.DisplayName();
}
private Controls.SheetStripControl planStrip;
/// <summary>
/// Adds the "best plan so far" sheet strip for whole-job runs, whose result is a set of
/// sheets rather than parts on the current plate. The results rows then describe that plan.
/// </summary>
public void ShowPlanStrip(ColorScheme colorScheme)
{
if (planStrip != null)
return;
var header = new Label
{
Dock = DockStyle.Top,
AutoSize = true,
Font = resultsHeader.Font,
ForeColor = resultsHeader.ForeColor,
Padding = new Padding(14, 8, 0, 4),
Text = "BEST PLAN SO FAR",
};
planStrip = new Controls.SheetStripControl
{
Dock = DockStyle.Fill,
ColorScheme = colorScheme,
EmptyText = "Waiting for the first complete plan…",
};
var panel = new Panel { Dock = DockStyle.Right, Width = 520 };
panel.Controls.Add(planStrip);
panel.Controls.Add(header);
Controls.Add(panel);
nestedAreaLabel.Text = "Sheets:";
densityLabel.Text = "Utilization:";
// Read the size first: raising the minimum would otherwise widen the form twice.
var client = ClientSize;
MinimumSize = new System.Drawing.Size(MinimumSize.Width + panel.Width, MinimumSize.Height);
ClientSize = new System.Drawing.Size(client.Width + panel.Width, client.Height);
}
/// <summary>Shows a whole-job plan in the strip added by <see cref="ShowPlanStrip"/>.</summary>
public void UpdatePlan(IReadOnlyList<Engine.Jobs.ProposedPlate> sheets)
{
if (planStrip == null || IsDisposed || !IsHandleCreated)
return;
planStrip.SetSheets(sheets);
SetValueWithFlash(partsValue, planStrip.PartCount.ToString());
var utilization = planStrip.Utilization;
SetValueWithFlash(densityValue, utilization.ToString("P1"), GetDensityColor(utilization));
densityBar.Value = utilization;
SetValueWithFlash(nestedAreaValue, planStrip.SheetCount.ToString());
}
public void ShowCompleted()
{
completed = true;
+91
View File
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Engine.Jobs;
namespace OpenNest.Forms
{
/// <summary>
/// The final Auto Nest result as a sheet strip, with what it places and leaves unplaced.
/// Keep commits it to the nest; Discard drops the whole proposal. Nothing is committed while
/// this is open, and it never attaches parts to a plate.
/// </summary>
internal sealed class NestReviewForm : Form
{
public NestReviewForm(NestPipelineResult result, ColorScheme colorScheme, string costSummary = null)
{
Text = $"Review Auto Nest — {result.EngineName}";
StartPosition = FormStartPosition.CenterParent;
ShowInTaskbar = false;
MinimizeBox = false;
Size = new Size(960, 520);
MinimumSize = new Size(560, 360);
Font = new Font("Segoe UI", 9.75F);
var strip = new SheetStripControl { Dock = DockStyle.Fill, ColorScheme = colorScheme };
strip.SetSheets(result.Plates);
Strip = strip;
var lines = new List<string>
{
$"{strip.SheetCount} sheet(s), {strip.PartCount} part(s) placed, {strip.Utilization:P1} utilization",
};
if (!result.IsValid)
lines.Add($"{result.Violations.Count} layout violation(s) accepted in the validation review; overlaps are marked after Keep.");
if (!string.IsNullOrEmpty(costSummary))
lines.Add(costSummary);
// Never drop demand silently: name every unplaced part and why.
if (result.Unplaced.Count > 0)
{
lines.Add($"Not placed ({result.Unplaced.Sum(u => u.Count)}):");
lines.AddRange(result.Unplaced.Select(u => " " + u.Describe()));
}
var summary = new TextBox
{
Dock = DockStyle.Bottom,
Multiline = true,
ReadOnly = true,
BorderStyle = BorderStyle.None,
BackColor = SystemColors.Control,
ScrollBars = lines.Count > 6 ? ScrollBars.Vertical : ScrollBars.None,
Height = System.Math.Min(lines.Count, 6) * (Font.Height + 2) + 12,
Text = string.Join("\r\n", lines),
TabStop = false,
};
Summary = summary.Text;
var keep = new Button { Text = "Keep", DialogResult = DialogResult.OK, AutoSize = true, MinimumSize = new Size(93, 29) };
var discard = new Button { Text = "Discard", DialogResult = DialogResult.Cancel, AutoSize = true, MinimumSize = new Size(93, 29) };
AcceptButton = keep;
CancelButton = discard;
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(9, 6, 9, 6),
};
buttons.Controls.Add(discard);
buttons.Controls.Add(keep);
var summaryPanel = new Panel
{
Dock = DockStyle.Bottom,
Height = summary.Height + 16,
Padding = new Padding(12, 10, 12, 6),
};
summary.Dock = DockStyle.Fill;
summaryPanel.Controls.Add(summary);
Controls.Add(strip);
Controls.Add(summaryPanel);
Controls.Add(buttons);
}
internal SheetStripControl Strip { get; }
internal string Summary { get; }
}
}
+5 -1
View File
@@ -27,6 +27,10 @@ Auto Nest starts on empty/new sheets and does not change existing populated plat
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.
Auto Nest never draws its parts on the plate view before they are kept: its result is a set of sheets, possibly of several sizes, not parts on the current plate. Ghost (preview) parts on the plate view mean only "what this plate gets if you accept now", and stay with interactive Fill and the remnant tools. Instead the progress window shows **Best plan so far**: every sheet of the engine's best whole-job plan drawn side by side at one scale, identical sheets once with their count, each captioned with size, part count and utilization. An engine reports a plan with `NestJobStage.PlanImproved` and `NestJobProgress.Plan`; `NestPipeline` adds `PlanSheets`, parts on detached drawings built from the job geometry, so nothing touches the caller's drawings or quantities. The plan is unvalidated and may be replaced; only Irregular reports plans today, so other engines show the strip empty until the review.
After a valid result, or an invalid one kept in the validation report, a review window shows the final sheets in the same strip with totals, the supplied-cost summary and every unplaced drawing, count and reason. **Keep** (Enter) commits the whole proposal; **Discard** (Escape) applies nothing.
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.
@@ -45,7 +49,7 @@ Unplaced demand is never dropped silently. `NestPipelineResult.Unplaced` lists e
- `NoPlacementFound`: the engine found no position; this does not prove none exists.
- `ProposalNotKeepable`: the proposal cannot be kept, so all demand is listed.
An incomplete but valid proposal is keepable like any other; keeping it is still an explicit action. Desktop Auto Nest lists each unplaced drawing, count and reason after committing (the drawing list keeps showing remaining quantities); Console `--autonest` writes `Unplaced:` lines to standard error; MCP `autonest_plate` adds `Unplaced:` lines and `autonest_job` names the reasons when it refuses an incomplete proposal; the API returns `NestResponse.Unplaced` by request part ID. This check does not replace pre-post CNC verification.
An incomplete but valid proposal is keepable like any other; keeping it is still an explicit action. Desktop Auto Nest lists each unplaced drawing, count and reason in its review before committing (the drawing list keeps showing remaining quantities); Console `--autonest` writes `Unplaced:` lines to standard error; MCP `autonest_plate` adds `Unplaced:` lines and `autonest_job` names the reasons when it refuses an incomplete proposal; the API returns `NestResponse.Unplaced` by request part ID. 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. A saved name from an earlier release
+5
View File
@@ -63,6 +63,11 @@ Curved outlines retain conservative flattening-error padding. This does not rela
footprints, no-fit polygons or layout validation tolerances; a part extending beyond the accepted
work-area bounds remains invalid.
Each time a strategy variant or the tail re-plan beats the best whole-job plan so far,
Irregular reports it as `NestJobStage.PlanImproved`, which desktop Auto Nest shows as its
"Best plan so far" sheet strip (see [automatic nesting](automatic-nesting.md)). The last
report is the returned layout; reporting never changes the search.
Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies
interlocked, as the Best Fit viewer shows them) alongside the single copies, and places a pair