feat(desktop): add File -> Export Nest Report command

Capture the report target and reject whole-job nesting, open progress
windows, interactive fill and busy plate actions across every view
sharing the nest; revalidate after the save dialog, capture the snapshot
synchronously on the UI thread and render it through OpenNest.Reporting.
Windows adapter tests cover enablement, guards, cancel, success and a
write failure against an existing destination (compile-only on Linux;
Windows runtime acceptance still owed).
This commit is contained in:
aj
2026-09-29 18:29:05 -04:00
parent 64045be968
commit 314ca2f2a6
6 changed files with 511 additions and 8 deletions
@@ -0,0 +1,321 @@
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
// Reads Application.OpenForms for the busy guards, so it must not overlap fill-lifetime tests
// that transiently open NestProgressForm.
[Collection("Fill operation lifetime")]
public class NestReportExportTests
{
[Fact]
public void MenuFollowsActiveDocument() => RunSta(() =>
{
using var host = new TestMainForm();
host.Show();
var item = Menu(host, "mnuFileExportNestReport");
Assert.False(item.Enabled);
using var form = new EditNestForm(new Nest("enabled job")) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
Assert.True(item.Enabled);
form.Close();
host.Activate();
Assert.False(item.Enabled);
});
[Fact]
public void JobBusyRejectsBeforeAnyDialog() => RunSta(() =>
{
using var host = new TestMainForm();
host.Show();
using var form = new EditNestForm(new Nest("busy job")) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
SetField(host, "nestingInProgress", true);
try
{
Menu(host, "mnuFileExportNestReport").PerformClick();
}
finally
{
SetField(host, "nestingInProgress", false);
}
Assert.Empty(host.DialogRequests);
var failure = Assert.Single(host.Failures);
Assert.Contains("busy job", failure);
Assert.Contains("Finish or cancel", failure);
});
[Fact]
public void InteractiveFillOnAnyViewOfTheNestRejects() => RunSta(() =>
{
using var host = new TestMainForm();
host.Show();
using var form = new EditNestForm(new Nest("filling job")) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
SetBackingField(form.PlateView, "IsFillInProgress", true);
try
{
Menu(host, "mnuFileExportNestReport").PerformClick();
Assert.Empty(host.DialogRequests);
Assert.Contains("Finish or cancel", Assert.Single(host.Failures));
}
finally
{
SetBackingField(form.PlateView, "IsFillInProgress", false);
}
// Cleared guard: the command now reaches the (test-intercepted) dialog.
Menu(host, "mnuFileExportNestReport").PerformClick();
Assert.Single(host.DialogRequests);
});
[Fact]
public void BusyPlateActionOnAnyViewOfTheNestRejects() => RunSta(() =>
{
using var host = new TestMainForm();
host.Show();
using var form = new EditNestForm(CreateJob("action job")) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
// The default ActionSelect reports busy while parts are selected.
form.PlateView.SelectAll();
Assert.True(form.PlateView.Actions.CurrentAction!.IsBusy());
try
{
Menu(host, "mnuFileExportNestReport").PerformClick();
Assert.Empty(host.DialogRequests);
Assert.Contains("Finish or cancel", Assert.Single(host.Failures));
}
finally
{
form.PlateView.DeselectAll();
}
Assert.False(form.PlateView.Actions.CurrentAction!.IsBusy());
});
[Fact]
public void InvalidNestNameRejectsBeforeAnyDialog() => RunSta(() =>
{
using var host = new TestMainForm();
host.Show();
using var form = new EditNestForm(new Nest("bad/name")) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
Menu(host, "mnuFileExportNestReport").PerformClick();
Assert.Empty(host.DialogRequests);
Assert.Contains("bad/name", Assert.Single(host.Failures));
});
[Fact]
public void CancelWritesNothingAndNotifiesNothing() => RunSta(() =>
{
using var host = new TestMainForm { NextDialogPath = null };
host.Show();
using var form = new EditNestForm(CreateJob("cancel job")) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
var path = Path.Combine(Path.GetTempPath(), $"opennest-report-cancel-{Guid.NewGuid():N}.pdf");
host.NextDialogPath = path;
host.NextDialogResult = DialogResult.Cancel;
Menu(host, "mnuFileExportNestReport").PerformClick();
Assert.False(File.Exists(path));
Assert.Empty(host.Failures);
Assert.Empty(host.Successes);
});
[Fact]
public void SuccessfulExportWritesPdfAndLeavesJobStateUntouched() => RunSta(() =>
{
using var host = new TestMainForm { NextDialogResult = DialogResult.OK };
host.Show();
var nest = CreateJob("export job");
using var form = new EditNestForm(nest) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
form.PlateManager.LoadAt(1);
var path = Path.Combine(Path.GetTempPath(), $"opennest-report-ok-{Guid.NewGuid():N}.pdf");
try
{
host.NextDialogPath = path;
Menu(host, "mnuFileExportNestReport").PerformClick();
Assert.Equal(path, Assert.Single(host.Successes));
var bytes = File.ReadAllBytes(path);
Assert.Equal("%PDF-", System.Text.Encoding.ASCII.GetString(bytes, 0, 5));
Assert.True(bytes.Length > 1000);
// Selected plate and job contents are untouched by reporting.
Assert.Equal(1, form.PlateManager.CurrentIndex);
Assert.Equal(2, nest.Plates.Count);
Assert.Single(nest.Plates[0].Parts);
Assert.Equal(2, nest.Drawings.Count);
}
finally
{
File.Delete(path);
}
});
[Fact]
public void RenderFailureLeavesExistingDestinationUnchanged() => RunSta(() =>
{
using var host = new TestMainForm { NextDialogResult = DialogResult.OK };
host.Show();
var nest = CreateJob("failure job");
// Outside the report's supported text contract: fails font validation before layout.
nest.Notes = "em\u2014dash";
using var form = new EditNestForm(nest) { MdiParent = host };
form.PlateView.SetOverlapAutoCheck(null); // Background overlap workers are irrelevant here.
form.Show();
var path = Path.Combine(Path.GetTempPath(), $"opennest-report-fail-{Guid.NewGuid():N}.pdf");
var original = new byte[] { 1, 2, 3, 4 };
File.WriteAllBytes(path, original);
try
{
host.NextDialogPath = path;
Menu(host, "mnuFileExportNestReport").PerformClick();
Assert.Empty(host.Successes);
var failure = Assert.Single(host.Failures);
Assert.Contains("failure job", failure);
Assert.Equal(original, File.ReadAllBytes(path));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, ".*report-fail*.tmp"));
}
finally
{
File.Delete(path);
}
});
[Fact]
public void BusyAfterDialogRejectsWriteWithoutTouchingDestination() => RunSta(() =>
{
using var form = new EditNestForm(CreateJob("late busy"));
form.PlateView.SetOverlapAutoCheck(null);
var targets = form.CaptureReportTargets(() => false);
var path = Path.Combine(Path.GetTempPath(), $"opennest-report-late-{Guid.NewGuid():N}.pdf");
try
{
var error = Assert.Throws<InvalidOperationException>(
() => form.WriteNestReport(targets, path, () => true));
Assert.Contains("started while saving", error.Message);
Assert.False(File.Exists(path));
}
finally
{
File.Delete(path);
}
});
[Fact]
public void WriteRejectsAStaleTargetFromAnotherDocument() => RunSta(() =>
{
using var form = new EditNestForm(CreateJob("current job"));
form.PlateView.SetOverlapAutoCheck(null);
var stale = new EditNestForm.NestReportTargets(new Nest("other job"), "other.report.pdf");
var error = Assert.Throws<InvalidOperationException>(
() => form.WriteNestReport(stale, "ignored.pdf", () => false));
Assert.Contains("active document changed", error.Message);
});
private static Nest CreateJob(string name)
{
var nest = new Nest(name) { Units = Units.Inches };
var drawing = new Drawing("rect", Rectangle());
drawing.Quantity.Required = 2;
nest.Drawings.Add(drawing);
nest.Drawings.Add(new Drawing("spare", Rectangle()));
var first = new Plate(24, 48) { Quantity = 1 };
first.Parts.Add(new Part(drawing, new Vector(2, 2)));
var second = new Plate(24, 48) { Quantity = 2 };
second.Parts.Add(new Part(drawing, new Vector(4, 4)));
nest.Plates.Add(first);
nest.Plates.Add(second);
return nest;
}
private static Program Rectangle()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(4, 0));
program.Codes.Add(new LinearMove(4, 2));
program.Codes.Add(new LinearMove(0, 2));
program.Codes.Add(new LinearMove(0, 0));
return program;
}
private static ToolStripMenuItem Menu(MainForm host, string name) =>
(ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single();
private static void SetField(object target, string name, object value) =>
typeof(MainForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(target, value);
// Same reflection precedent PlateViewFillLifetimeTests uses for fill-lifetime state.
private static void SetBackingField(object target, string property, object value) =>
target.GetType()
.GetField($"<{property}>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(target, value);
private sealed class TestMainForm : MainForm
{
public List<string> DialogRequests { get; } = [];
public string? NextDialogPath { get; set; }
public DialogResult NextDialogResult { get; set; } = DialogResult.OK;
public List<string> Failures { get; } = [];
public List<string> Successes { get; } = [];
// No startup migrations/automatic new document, and no settings save at close.
protected override void OnLoad(EventArgs e) { }
protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { }
internal override string? ShowReportSaveDialog(string suggestedFileName)
{
DialogRequests.Add(suggestedFileName);
return NextDialogResult == DialogResult.OK ? NextDialogPath : null;
}
internal override void ReportFailure(EditNestForm.NestReportTargets? targets, Exception error) =>
Failures.Add($"{targets?.Nest.Name}|{error.Message}");
internal override void ReportSuccess(string destination) => Successes.Add(destination);
}
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(90)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
}
+74
View File
@@ -16,6 +16,7 @@ using OpenNest.Engine.Sequencing;
using OpenNest.IO; using OpenNest.IO;
using OpenNest.Math; using OpenNest.Math;
using OpenNest.Properties; using OpenNest.Properties;
using OpenNest.Reporting;
using OpenNest.Shapes; using OpenNest.Shapes;
using Timer = System.Timers.Timer; using Timer = System.Timers.Timer;
@@ -478,6 +479,79 @@ namespace OpenNest.Forms
} while (PlateManager.LoadNext()); } while (PlateManager.LoadNext());
} }
/// <summary>The report target fixed before the save dialog opens.</summary>
internal sealed record NestReportTargets(Nest Nest, string SuggestedFileName);
/// <summary>
/// Capture the report target before the owned save dialog opens. The exact nest
/// reference is fixed here; later failures are reported against it, not a newer
/// active document. Whole-job nesting, open progress windows (via <paramref name="isJobBusy"/>),
/// interactive fill and busy plate actions on every view sharing this nest are rejected.
/// </summary>
internal NestReportTargets CaptureReportTargets(Func<bool> isJobBusy)
{
ArgumentNullException.ThrowIfNull(isJobBusy);
var nest = Nest;
if (nest == null)
throw new InvalidOperationException("No nest is available to report.");
// The nest name becomes the suggested file name; reject unusable names before the dialog.
if (
string.IsNullOrWhiteSpace(nest.Name)
|| nest.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
)
throw new InvalidOperationException(
$"The nest name '{nest.Name}' cannot be used as a report file name. Rename the nest first."
);
if (isJobBusy() || IsReportTargetBusy(nest))
throw new InvalidOperationException(
"Finish or cancel the current nesting or plate action before exporting the nest report."
);
return new NestReportTargets(nest, $"{nest.Name}.report.pdf");
}
/// <summary>
/// Revalidate the fixed target after the dialog closed, then synchronously capture the
/// snapshot on the UI thread and write it. The dialog pumps messages, so an async fill
/// could have committed or started meanwhile; once capture begins nothing yields.
/// </summary>
internal void WriteNestReport(
NestReportTargets targets,
string destination,
Func<bool> isJobBusy
)
{
ArgumentNullException.ThrowIfNull(targets);
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
ArgumentNullException.ThrowIfNull(isJobBusy);
if (!ReferenceEquals(targets.Nest, Nest))
throw new InvalidOperationException(
"The active document changed while saving. No report was written."
);
if (isJobBusy() || IsReportTargetBusy(targets.Nest))
throw new InvalidOperationException(
"A nesting or plate operation started while saving. No report was written."
);
// Reporting must have no accounting side effects: no quantity refresh, no
// selection or dirty-state change. Only the detached snapshot reaches the writer.
var snapshot = NestReportBuilder.Capture(targets.Nest, DateTimeOffset.Now);
NestPdfWriter.Write(snapshot, destination);
}
private bool IsReportTargetBusy(Nest nest) =>
Application
.OpenForms.OfType<EditNestForm>()
.Where(form => ReferenceEquals(form.Nest, nest))
.Select(form => form.PlateView)
.Any(view =>
!view.IsDisposed
&& (view.IsFillInProgress || view.Actions.CurrentAction?.IsBusy() == true)
);
public void RotateCw() public void RotateCw()
{ {
PlateView.Plate.Rotate90(RotationType.CW); PlateView.Plate.Rotate90(RotationType.CW);
+11 -1
View File
@@ -39,6 +39,7 @@
toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator(); toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator();
mnuFileExport = new System.Windows.Forms.ToolStripMenuItem(); mnuFileExport = new System.Windows.Forms.ToolStripMenuItem();
mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem(); mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem();
mnuFileExportNestReport = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator(); toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator();
mnuFileExit = new System.Windows.Forms.ToolStripMenuItem(); mnuFileExit = new System.Windows.Forms.ToolStripMenuItem();
mnuEdit = new System.Windows.Forms.ToolStripMenuItem(); mnuEdit = new System.Windows.Forms.ToolStripMenuItem();
@@ -193,7 +194,7 @@
// //
// mnuFile // mnuFile
// //
mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, toolStripMenuItem3, mnuFileExit }); mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, mnuFileExportNestReport, toolStripMenuItem3, mnuFileExit });
mnuFile.Name = "mnuFile"; mnuFile.Name = "mnuFile";
mnuFile.Size = new System.Drawing.Size(37, 20); mnuFile.Size = new System.Drawing.Size(37, 20);
mnuFile.Text = "&File"; mnuFile.Text = "&File";
@@ -266,6 +267,14 @@
mnuFileExportAll.Text = "Export All"; mnuFileExportAll.Text = "Export All";
mnuFileExportAll.Click += ExportAll_Click; mnuFileExportAll.Click += ExportAll_Click;
// //
// mnuFileExportNestReport
//
mnuFileExportNestReport.Enabled = false;
mnuFileExportNestReport.Name = "mnuFileExportNestReport";
mnuFileExportNestReport.Size = new System.Drawing.Size(146, 22);
mnuFileExportNestReport.Text = "Export Nest Report...";
mnuFileExportNestReport.Click += ExportNestReport_Click;
//
// toolStripMenuItem3 // toolStripMenuItem3
// //
toolStripMenuItem3.Name = "toolStripMenuItem3"; toolStripMenuItem3.Name = "toolStripMenuItem3";
@@ -1360,6 +1369,7 @@
private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel; private System.Windows.Forms.ToolStripStatusLabel plateQtyStatusLabel;
private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel; private System.Windows.Forms.ToolStripStatusLabel plateUtilStatusLabel;
private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll; private System.Windows.Forms.ToolStripMenuItem mnuFileExportAll;
private System.Windows.Forms.ToolStripMenuItem mnuFileExportNestReport;
private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem openNestToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem pEPToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem mnuViewZoomTo; private System.Windows.Forms.ToolStripMenuItem mnuViewZoomTo;
+82
View File
@@ -216,6 +216,7 @@ namespace OpenNest.Forms
exportNestMenu.Enabled = hasValue; exportNestMenu.Enabled = hasValue;
mnuFileExport.Enabled = hasValue; mnuFileExport.Enabled = hasValue;
mnuFileExportAll.Enabled = hasValue; mnuFileExportAll.Enabled = hasValue;
mnuFileExportNestReport.Enabled = hasValue && !databaseSaveInProgress;
btnZoomOut.Enabled = hasValue; btnZoomOut.Enabled = hasValue;
btnZoomIn.Enabled = hasValue; btnZoomIn.Enabled = hasValue;
mnuEdit.Visible = hasValue; mnuEdit.Visible = hasValue;
@@ -742,6 +743,87 @@ namespace OpenNest.Forms
activeForm.ExportAll(); activeForm.ExportAll();
} }
private void ExportNestReport_Click(object sender, EventArgs e)
{
var form = activeForm;
if (form == null)
return;
// The report never depends on a selected post or NC output; it only needs a
// quiescent nest so the snapshot is coherent.
Func<bool> isJobBusy = IsNestJobBusy;
EditNestForm.NestReportTargets targets;
try
{
targets = form.CaptureReportTargets(isJobBusy);
}
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
{
ReportFailure(null, ex);
return;
}
var destination = ShowReportSaveDialog(targets.SuggestedFileName);
if (destination == null)
return; // Cancel: nothing written, job untouched.
try
{
form.WriteNestReport(targets, destination, isJobBusy);
}
catch (Exception ex)
when (ex is InvalidOperationException
or NotSupportedException
or InvalidDataException
or IOException
or UnauthorizedAccessException
or ArgumentException)
{
// Failures describe the captured target; an active-document switch is irrelevant.
ReportFailure(targets, ex);
return;
}
statusLabel1.Text = $"Saved nest report to {destination}";
ReportSuccess(destination);
}
/// <summary>
/// True while any operation could mutate nests or hold their plate views: whole-job
/// nesting, an open progress window (a closed one can still await its commit), or a
/// background database save serializing the live nest.
/// </summary>
private bool IsNestJobBusy() =>
nestingInProgress
|| Application.OpenForms.OfType<NestProgressForm>().Any()
|| databaseSaveInProgress;
/// <summary>Returns the chosen path, or null on cancel. Overwrite consent is the dialog prompt.</summary>
internal virtual string ShowReportSaveDialog(string suggestedFileName)
{
using var dlg = new SaveFileDialog
{
Filter = "PDF report (*.pdf)|*.pdf",
FileName = suggestedFileName,
AddExtension = true,
DefaultExt = ".pdf",
OverwritePrompt = true,
};
return dlg.ShowDialog(this) == DialogResult.OK ? dlg.FileName : null;
}
/// <summary>Failure notice naming the fixed target; internal so tests observe it without dialogs.</summary>
internal virtual void ReportFailure(
EditNestForm.NestReportTargets targets,
Exception error) =>
MessageBox.Show(this,
$"Could not export the nest report{(targets == null ? "" : $" for '{targets.Nest.Name}'")}: {error.Message}",
"Export Nest Report", MessageBoxButtons.OK, MessageBoxIcon.Warning);
/// <summary>Success notice; internal so tests observe completion without dialogs.</summary>
internal virtual void ReportSuccess(string destination) =>
MessageBox.Show(this, $"Nest report saved to:\n\n{destination}", "Export Nest Report",
MessageBoxButtons.OK, MessageBoxIcon.Information);
private void Exit_Click(object sender, EventArgs e) private void Exit_Click(object sender, EventArgs e)
{ {
Close(); Close();
+1
View File
@@ -27,6 +27,7 @@
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" /> <ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" /> <ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" /> <ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" /> <ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" /> <ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" /> <PackageReference Include="System.Drawing.Common" Version="8.0.10" />
+22 -7
View File
@@ -1,8 +1,18 @@
# Nest report PDF library # Nest report PDF export
The first delivery is a bounded, cross-platform library slice, not yet a desktop Desktop: **File -> Export Nest Report...** writes `<nest-name>.report.pdf` for the
menu command or a general report layout engine. Capture a stable `Nest` on its active nest's whole job through an overwrite-confirming save dialog. The command
owning thread, then render only the detached snapshot: is disabled without an open document and during a background database save, and
it refuses to run while whole-job nesting, an open progress window, interactive
fill or a busy plate action holds the nest or any second window sharing it. After
the dialog closes, the target and those conditions are revalidated; the snapshot
is then captured synchronously on the UI thread and only that detached snapshot
reaches the renderer. Export never changes the selected plate, dirty state,
timestamps or quantities, and it never touches post selection, verification or
CNC output. Opening and printing use any normal PDF viewer; the report includes
no printer controls and there are no persisted report settings or templates.
The same two calls back the command and any future integration:
```csharp ```csharp
var snapshot = NestReportBuilder.Capture(nest, DateTimeOffset.Now); var snapshot = NestReportBuilder.Capture(nest, DateTimeOffset.Now);
@@ -129,6 +139,11 @@ vector curves, unfilled holes and visible tab gaps with no connecting stroke.
Pixel inspection supplements geometry assertions; it is not a CNC-validation Pixel inspection supplements geometry assertions; it is not a CNC-validation
result. result.
Windows compilation is not runtime acceptance. The later desktop adapter must be Windows compilation is not runtime acceptance. `OpenNest.WinForms.Tests/Forms/NestReportExportTests.cs`
tested on Windows for busy-operation guards, cancellation, overwrite handling, covers enablement, busy/cancel/failure/success adapter behavior, but it only
unchanged job state and PDF viewing/printing, including the packaged application. compiles on Linux. Windows runtime acceptance still owes: exporting a real nest
(and the packaged application, so bundled fonts/notices are verified), checking
page sizes/labels/copies and embedded fonts with `pdffonts`, a tabbed part with a
hole, opening and printing the PDF, and exercising fill/nesting rejection, cancel,
overwrite and invalid-path behavior with job state and any existing destination
unchanged.