diff --git a/OpenNest.Avalonia/MainWindow.Export.cs b/OpenNest.Avalonia/MainWindow.Export.cs
new file mode 100644
index 00000000..5e4c41c6
--- /dev/null
+++ b/OpenNest.Avalonia/MainWindow.Export.cs
@@ -0,0 +1,356 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using OpenNest.Avalonia.Rendering;
+using OpenNest.Data;
+using OpenNest.Desktop;
+
+namespace OpenNest.Avalonia
+{
+ ///
+ /// File > Export Plate, Export All Plates, Nest Report and Export Nest Report, and Plate >
+ /// View in CAD, as the WinForms app has them: the same suggested file names and writers
+ /// (, ). A viewed report opens in the
+ /// system's PDF viewer, which saves and prints it; View in CAD opens the plate's DXF in the
+ /// program the system associates with .dxf.
+ ///
+ public partial class MainWindow
+ {
+ private static readonly FilePickerFileType DxfFiles =
+ new("DXF file") { Patterns = new[] { "*.dxf" } };
+
+ private static readonly FilePickerFileType ImageFiles =
+ new("Image as displayed") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg" } };
+
+ private static readonly FilePickerFileType PlacementFiles =
+ new("Locations and rotations") { Patterns = new[] { "*.txt" } };
+
+ private static readonly FilePickerFileType SvgFiles =
+ new("SVG file") { Patterns = new[] { "*.svg" } };
+
+ private static readonly FilePickerFileType PdfFiles =
+ new("PDF file") { Patterns = new[] { "*.pdf" } };
+
+ private void UpdateExportCommands(bool hasNest)
+ {
+ ExportPlateMenuItem.IsEnabled = hasNest;
+ ExportAllPlatesMenuItem.IsEnabled = hasNest;
+ ViewNestReportMenuItem.IsEnabled = hasNest;
+ ExportNestReportMenuItem.IsEnabled = hasNest;
+ ViewInCadMenuItem.IsEnabled = hasNest;
+ }
+
+ private async void OnExportPlate(object sender, RoutedEventArgs e)
+ {
+ var view = ActiveView;
+ if (view == null || !CanExport())
+ return;
+ writingFile = true;
+ try
+ {
+ await ExportPlateAsync(view, view.PlateIndex);
+ }
+ finally
+ {
+ writingFile = false;
+ }
+ }
+
+ ///
+ /// As WinForms: Export Plate once per plate, first to last, each plate shown while its
+ /// file is chosen; cancelling or a failure stops the rest.
+ ///
+ private async void OnExportAllPlates(object sender, RoutedEventArgs e)
+ {
+ var view = ActiveView;
+ if (view == null || !CanExport())
+ return;
+ writingFile = true;
+ try
+ {
+ var count = view.PlateCount;
+ var exported = await PlateExport.ExportAllAsync(count, i => ExportPlateAsync(view, i));
+ if (exported < count)
+ ShowNotice($"Exported {exported} of {count} plates of {view.Tab.Name}");
+ else
+ ShowNotice($"Exported all {count} plates of {view.Tab.Name}");
+ }
+ finally
+ {
+ writingFile = false;
+ }
+ }
+
+ // One export at a time; nesting does not block it, as its result is kept on this thread.
+ private bool CanExport()
+ {
+ if (!writingFile)
+ return true;
+ ShowNotice("A nest is already being saved or exported. Finish that before exporting.");
+ return false;
+ }
+
+ ///
+ /// Shows plate , asks for its file and writes it in the format the
+ /// file's extension names. True when it was written.
+ ///
+ private async Task ExportPlateAsync(NestView view, int index)
+ {
+ if (index < 0 || index >= view.PlateCount)
+ return false;
+ view.ShowPlate(index);
+ var plate = view.PlateView.Plate;
+ if (plate == null)
+ return false;
+ var nest = view.Tab.Document.Nest;
+ var number = index + 1;
+ var target = await PickPlateExportFileAsync(view.Tab, PlateExport.SuggestedFileName(nest.Name, number));
+ if (target == null)
+ return false;
+
+ var (path, format) = target.Value;
+ try
+ {
+ // The image is of the plate as shown, so show it again if another was picked meanwhile.
+ if (format == PlateExportFormat.Image && !ReferenceEquals(view.PlateView.Plate, plate))
+ view.ShowPlate(nest.Plates.IndexOf(plate));
+ PlateExport.Write(plate, nest.Units, path, format, file => ViewImage.Save(view.PlateView, file));
+ }
+ catch (Exception ex)
+ {
+ await ConfirmDialog.ShowAsync(this, "Export Plate",
+ $"Could not export plate to '{path}': {ex.Message}");
+ return false;
+ }
+ ShowNotice($"Exported plate {number} of {view.Tab.Name} to {path}");
+ return true;
+ }
+
+ // The file to export a plate to and its format, or null when cancelled or refused.
+ private async Task<(string Path, PlateExportFormat Format)?> PickPlateExportFileAsync(
+ NestTab tab, string suggestedFileName)
+ {
+ var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export Plate",
+ SuggestedFileName = suggestedFileName,
+ DefaultExtension = "dxf",
+ ShowOverwritePrompt = true,
+ FileTypeChoices = new[] { DxfFiles, ImageFiles, PlacementFiles, SvgFiles },
+ SuggestedStartLocation = await NestFolderAsync(tab),
+ });
+ if (file == null)
+ return null;
+ var path = file.TryGetLocalPath();
+ if (path == null)
+ {
+ ShowNotice("Plates can only be exported to a folder on this computer.");
+ return null;
+ }
+
+ // A name typed without an extension is the dialog's default type, DXF.
+ if (Path.GetExtension(path).Length == 0)
+ {
+ path += ".dxf";
+ if (File.Exists(path) && !await ConfirmDialog.AskAsync(this, "Export Plate",
+ $"{Path.GetFileName(path)} already exists. Replace it?", "Replace"))
+ return null;
+ }
+ if (PlateExport.FromPath(path) is not { } format)
+ {
+ await ConfirmDialog.ShowAsync(this, "Export Plate",
+ $"A plate cannot be exported as '{Path.GetFileName(path)}'. Choose a file name "
+ + "ending in .dxf, .png, .jpg, .txt or .svg.");
+ return null;
+ }
+ return (path, format);
+ }
+
+ /// Writes the shown plate as a DXF in the temp folder and opens it in the system's CAD program.
+ private async void OnViewInCad(object sender, RoutedEventArgs e)
+ {
+ var view = ActiveView;
+ var plate = view?.PlateView.Plate;
+ if (plate == null)
+ return;
+ var nest = view.Tab.Document.Nest;
+ var path = PlateExport.ExternalCadPath(nest.Name, view.PlateIndex + 1);
+ Task opened;
+ try
+ {
+ PlateExport.Write(plate, nest.Units, path, PlateExportFormat.Dxf, null);
+ opened = ExternalFileLauncher.OpenAsync(path);
+ }
+ catch (Exception ex)
+ {
+ await ConfirmDialog.ShowAsync(this, "View in CAD", $"Could not open '{path}' in CAD: {ex.Message}");
+ return;
+ }
+ ShowNotice($"Opening {path}");
+ await ReportOpenFailureAsync(opened, "View in CAD", $"Could not open '{path}' in CAD");
+ }
+
+ ///
+ /// Writes the nest report to a private temporary folder and opens it in the system's PDF
+ /// viewer, which saves and prints it. Viewed reports older than a day are removed.
+ ///
+ private async void OnViewNestReport(object sender, RoutedEventArgs e)
+ {
+ var tab = tabs.Active;
+ if (tab == null || !TryReportFileName(tab, out var fileName))
+ return;
+ if (!CanWrite(tab, "creating the report"))
+ return;
+ writingFile = true;
+ string path = null;
+ try
+ {
+ await MachinesLoaded;
+ // Auto Nest may have started on it while the machines were read.
+ if (IsNesting(tab, "creating the report"))
+ return;
+ path = NestReportExport.CreateViewPath(fileName);
+ await WriteReportAsync(tab, path);
+ await Task.Run(() => NestReportExport.DeleteViewedReportsBefore(
+ DateTime.UtcNow - NestReportExport.ViewedReportLifetime));
+ }
+ catch (Exception ex) when (IsReportFailure(ex))
+ {
+ if (path != null)
+ NestReportExport.DeleteReportFolder(path);
+ await ReportFailureAsync(tab, ex);
+ return;
+ }
+ finally
+ {
+ writingFile = false;
+ }
+
+ Task opened;
+ try
+ {
+ opened = ExternalFileLauncher.OpenAsync(path);
+ }
+ catch (Exception ex)
+ {
+ await ConfirmDialog.ShowAsync(this, "Nest Report",
+ $"The nest report was written to '{path}', but it could not be opened: {ex.Message}");
+ return;
+ }
+ ShowNotice($"Opening the nest report for {tab.Name}");
+ await ReportOpenFailureAsync(opened, "Nest Report",
+ $"The nest report was written to '{path}', but it could not be opened");
+ }
+
+ /// Saves the nest report as a PDF file the user picks.
+ private async void OnExportNestReport(object sender, RoutedEventArgs e)
+ {
+ var tab = tabs.Active;
+ if (tab == null || !TryReportFileName(tab, out var fileName))
+ return;
+ if (!CanWrite(tab, "exporting the report"))
+ return;
+ writingFile = true;
+ try
+ {
+ var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export Nest Report",
+ SuggestedFileName = fileName,
+ DefaultExtension = "pdf",
+ ShowOverwritePrompt = true,
+ FileTypeChoices = new[] { PdfFiles },
+ SuggestedStartLocation = await NestFolderAsync(tab),
+ });
+ if (file == null)
+ return;
+ var path = file.TryGetLocalPath();
+ if (path == null)
+ {
+ ShowNotice("The nest report can only be saved to a folder on this computer.");
+ return;
+ }
+ await MachinesLoaded;
+ // Auto Nest may have started on it while the file picker was open.
+ if (IsNesting(tab, "exporting the report"))
+ return;
+ await WriteReportAsync(tab, path);
+ ShowNotice($"Saved nest report to {path}");
+ }
+ catch (Exception ex) when (IsReportFailure(ex))
+ {
+ await ReportFailureAsync(tab, ex);
+ }
+ finally
+ {
+ writingFile = false;
+ }
+ }
+
+ // The report's file name, or false (after saying why) when the nest's name cannot be one.
+ private bool TryReportFileName(NestTab tab, out string fileName)
+ {
+ try
+ {
+ fileName = NestReportExport.ReportFileName(tab.Document.Nest);
+ return true;
+ }
+ catch (InvalidOperationException ex)
+ {
+ fileName = null;
+ _ = ConfirmDialog.ShowAsync(this, "Nest Report", $"Could not create the nest report: {ex.Message}");
+ return false;
+ }
+ }
+
+ ///
+ /// Captures the report on this thread, so the nest is read as it stands and is left
+ /// unchanged, then writes the detached snapshot off it.
+ ///
+ private static Task WriteReportAsync(NestTab tab, string path)
+ {
+ var snapshot = NestReportExport.Capture(
+ tab.Document.Nest,
+ DateTimeOffset.Now,
+ DesktopSettings.MachineConfigs,
+ UserSettingsFile.Read(UserSettingsFile.DefaultPath));
+ return Task.Run(() => NestReportExport.Write(snapshot, path));
+ }
+
+ private static bool IsReportFailure(Exception ex) =>
+ ex is InvalidOperationException
+ or NotSupportedException
+ or InvalidDataException
+ or IOException
+ or UnauthorizedAccessException
+ or ArgumentException;
+
+ private Task ReportFailureAsync(NestTab tab, Exception error) =>
+ ConfirmDialog.ShowAsync(this, "Nest Report",
+ $"Could not create the nest report for '{tab.Name}': {error.Message}");
+
+ // Says why the system could not open a file, once its opener has reported back.
+ private async Task ReportOpenFailureAsync(Task opened, string title, string what)
+ {
+ string failure;
+ try
+ {
+ failure = await opened;
+ }
+ catch (Exception ex)
+ {
+ failure = ex.Message;
+ }
+ if (failure != null)
+ await ConfirmDialog.ShowAsync(this, title, $"{what}: {failure}");
+ }
+
+ private async Task NestFolderAsync(NestTab tab)
+ {
+ var folder = tab.Path != null ? Path.GetDirectoryName(tab.Path) : null;
+ return folder != null ? await StorageProvider.TryGetFolderFromPathAsync(folder) : null;
+ }
+ }
+}
diff --git a/OpenNest.Avalonia/MainWindow.axaml b/OpenNest.Avalonia/MainWindow.axaml
index a681ae0a..7ee0fa11 100644
--- a/OpenNest.Avalonia/MainWindow.axaml
+++ b/OpenNest.Avalonia/MainWindow.axaml
@@ -13,6 +13,12 @@
+
+
+
+
+
+
@@ -21,6 +27,9 @@
+
+
+
diff --git a/OpenNest.Avalonia/MainWindow.axaml.cs b/OpenNest.Avalonia/MainWindow.axaml.cs
index 41a95408..a1ea386b 100644
--- a/OpenNest.Avalonia/MainWindow.axaml.cs
+++ b/OpenNest.Avalonia/MainWindow.axaml.cs
@@ -228,6 +228,7 @@ namespace OpenNest.Avalonia
SaveButton.IsEnabled = view != null;
SaveAsButton.IsEnabled = view != null;
CloseAllMenuItem.IsEnabled = view != null;
+ UpdateExportCommands(view != null);
Title = view == null
? "OpenNest (Avalonia prototype)"
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
diff --git a/OpenNest.Avalonia/Rendering/ViewImage.cs b/OpenNest.Avalonia/Rendering/ViewImage.cs
new file mode 100644
index 00000000..bfae175b
--- /dev/null
+++ b/OpenNest.Avalonia/Rendering/ViewImage.cs
@@ -0,0 +1,59 @@
+using System;
+using System.IO;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using OpenNest.Avalonia.Controls;
+using SkiaSharp;
+
+namespace OpenNest.Avalonia.Rendering
+{
+ ///
+ /// File > Export's "image as displayed": the plate view drawn as it is shown, at the
+ /// screen's pixel density, by the same Skia drawing that paints it on screen. A .jpg or .jpeg
+ /// file is written as JPEG (as the WinForms export writes), any other as PNG.
+ ///
+ internal static class ViewImage
+ {
+ private const int JpegQuality = 90;
+
+ public static void Save(PlateCanvas canvas, string path)
+ {
+ ArgumentNullException.ThrowIfNull(canvas);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ if (canvas.Bounds.Width < 1 || canvas.Bounds.Height < 1)
+ throw new InvalidOperationException("The plate view is not shown, so there is no image to save.");
+
+ var scaling = TopLevel.GetTopLevel(canvas)?.RenderScaling ?? 1;
+ var size = new PixelSize(
+ System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Width * scaling)),
+ System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Height * scaling)));
+ using var bitmap = new RenderTargetBitmap(size, new Vector(96 * scaling, 96 * scaling));
+ // The canvas draws itself (it has no child controls) at the origin: RenderTargetBitmap
+ // .Render would place it where it sits in its parent, shifting it out of the image.
+ using (var context = bitmap.CreateDrawingContext())
+ canvas.Render(context);
+
+ if (!IsJpeg(path))
+ {
+ bitmap.Save(path);
+ return;
+ }
+
+ // Avalonia saves PNG only; Skia re-encodes it as JPEG.
+ using var png = new MemoryStream();
+ bitmap.Save(png);
+ png.Position = 0;
+ using var decoded = SKBitmap.Decode(png)
+ ?? throw new InvalidOperationException("The plate view image could not be encoded.");
+ using var image = SKImage.FromBitmap(decoded);
+ using var jpeg = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality)
+ ?? throw new InvalidOperationException("The plate view image could not be encoded as JPEG.");
+ using var file = File.Create(path);
+ jpeg.SaveTo(file);
+ }
+
+ private static bool IsJpeg(string path) =>
+ Path.GetExtension(path).ToLowerInvariant() is ".jpg" or ".jpeg";
+ }
+}
diff --git a/OpenNest.Desktop.Tests/NestReportExportTests.cs b/OpenNest.Desktop.Tests/NestReportExportTests.cs
new file mode 100644
index 00000000..d51fb21a
--- /dev/null
+++ b/OpenNest.Desktop.Tests/NestReportExportTests.cs
@@ -0,0 +1,188 @@
+using OpenNest.Data;
+
+namespace OpenNest.Desktop.Tests;
+
+public sealed class NestReportExportTests : IDisposable
+{
+ private static readonly DateTimeOffset At = new(2026, 11, 11, 9, 30, 0, TimeSpan.Zero);
+ private readonly string folder = Path.Combine(Path.GetTempPath(), $"opennest-desktop-report-{Guid.NewGuid():N}");
+
+ public NestReportExportTests() => Directory.CreateDirectory(folder);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(folder))
+ Directory.Delete(folder, true);
+ }
+
+ [Fact]
+ public void ReportFileName_IsTheNestNameDotReportPdf() =>
+ Assert.Equal("export job.report.pdf", NestReportExport.ReportFileName(PlateExportTests.CreateJob()));
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("a/b")]
+ [InlineData("a\0b")]
+ public void ReportFileName_RefusesUnusableNames(string name)
+ {
+ var nest = PlateExportTests.CreateJob();
+ nest.Name = name;
+
+ var error = Assert.Throws(() => NestReportExport.ReportFileName(nest));
+ Assert.Contains("Rename the nest", error.Message);
+ }
+
+ [Fact]
+ public void ReportFileName_RefusesNoNest() =>
+ Assert.Throws(() => NestReportExport.ReportFileName(null));
+
+ [Fact]
+ public void Write_WritesAPdf_AndLeavesTheNestUntouched()
+ {
+ var nest = PlateExportTests.CreateJob();
+ var nested = nest.Drawings.Select(d => d.Quantity.Nested).ToList();
+ var modified = nest.DateLastModified;
+ var path = Path.Combine(folder, NestReportExport.ReportFileName(nest));
+
+ var snapshot = NestReportExport.Capture(nest, At, Array.Empty(), new Dictionary());
+ NestReportExport.Write(snapshot, path);
+
+ var bytes = File.ReadAllBytes(path);
+ Assert.True(bytes.Length > 1000);
+ Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
+ Assert.Equal(nested, nest.Drawings.Select(d => d.Quantity.Nested).ToList());
+ Assert.Equal(modified, nest.DateLastModified);
+ // Only the report's own file is left.
+ Assert.Equal(new[] { path }, Directory.GetFiles(folder));
+ }
+
+ [Fact]
+ public void Capture_TakesTheDensityFromTheNestsMachine()
+ {
+ var nest = PlateExportTests.CreateJob();
+ nest.Thickness = 0.25;
+ nest.Material = new Material("Steel", "A36");
+ var other = Machine("Other", ("Steel", "A36", 0.5));
+ var own = Machine("Own", ("Steel", "A36", 0.2836));
+ nest.Machine = new NestMachine(own.Id, own.Name);
+
+ var snapshot = NestReportExport.Capture(nest, At, new[] { other, own }, new Dictionary());
+
+ // A 4 x 4 square, 0.25 thick: 4 cubic inches at the nest machine's density.
+ Assert.Equal(4 * 0.2836, snapshot.Drawings.Single().Weight!.Value, 6);
+ }
+
+ [Fact]
+ public void Capture_FallsBackToAnyMachinesMaterial()
+ {
+ var nest = PlateExportTests.CreateJob();
+ nest.Thickness = 0.25;
+ nest.Material = new Material("Steel", "A36");
+
+ var snapshot = NestReportExport.Capture(
+ nest, At, new[] { Machine("Only", ("Steel", "A36", 0.3)) }, new Dictionary());
+
+ Assert.Equal(4 * 0.3, snapshot.Drawings.Single().Weight!.Value, 6);
+ }
+
+ [Fact]
+ public void Capture_NamesTheThicknessFromTheNestsMachine()
+ {
+ var nest = PlateExportTests.CreateJob();
+ nest.Thickness = 0.0598;
+ nest.Material = new Material("Steel", "A36");
+ var own = Machine("Own", ("Steel", "A36", 0.2836));
+ own.Materials[0].Thicknesses.Add(new ThicknessConfig { Value = 0.0598, Name = " 16 ga " });
+ nest.Machine = new NestMachine(own.Id, own.Name);
+
+ var named = NestReportExport.Capture(nest, At, new[] { own }, new Dictionary());
+ nest.Thickness = 0.25;
+ var unnamed = NestReportExport.Capture(nest, At, new[] { own }, new Dictionary());
+
+ Assert.Equal("16 ga", named.ThicknessName);
+ Assert.Equal("", unnamed.ThicknessName);
+ }
+
+ [Fact]
+ public void Capture_WithoutADensity_HasNoWeight()
+ {
+ var nest = PlateExportTests.CreateJob();
+ nest.Thickness = 0.25;
+ nest.Material = new Material("Unobtainium", "X");
+
+ var snapshot = NestReportExport.Capture(
+ nest, At, new[] { Machine("Only", ("Steel", "A36", 0.3)) }, new Dictionary());
+
+ Assert.Null(snapshot.Drawings.Single().Weight);
+ }
+
+ [Fact]
+ public void Capture_EstimatesCutTimes_FromTheSavedCutTimeSettings()
+ {
+ var nest = PlateExportTests.CreateJob();
+ var slow = new Dictionary
+ {
+ ["LastFeedrate"] = "10",
+ ["LastRapidFeedrate"] = "100",
+ ["LastPierceTime"] = "1",
+ ["CutTimeMachine"] = SavedCutTimeSettings.FullSpeed,
+ };
+ var fast = new Dictionary(slow) { ["LastFeedrate"] = "1000" };
+
+ var slowReport = NestReportExport.Capture(nest, At, Array.Empty(), slow);
+ var fastReport = NestReportExport.Capture(nest, At, Array.Empty(), fast);
+
+ Assert.True(slowReport.HasCutTimes);
+ Assert.True(slowReport.Plates.Single().CutTime > fastReport.Plates.Single().CutTime);
+ }
+
+ [Fact]
+ public void ViewPath_IsANewPrivateFolderPerReport()
+ {
+ var first = NestReportExport.CreateViewPath("job.report.pdf", folder);
+ var second = NestReportExport.CreateViewPath("job.report.pdf", folder);
+
+ Assert.NotEqual(first, second);
+ Assert.Equal("job.report.pdf", Path.GetFileName(first));
+ Assert.True(Directory.Exists(Path.GetDirectoryName(first)));
+ Assert.Equal(folder, Path.GetDirectoryName(Path.GetDirectoryName(first)));
+
+ NestReportExport.DeleteReportFolder(first);
+
+ Assert.False(Directory.Exists(Path.GetDirectoryName(first)));
+ Assert.True(Directory.Exists(Path.GetDirectoryName(second)));
+ }
+
+ [Fact]
+ public void DeleteReportFolder_IgnoresAMissingFolder() =>
+ NestReportExport.DeleteReportFolder(Path.Combine(folder, "gone", "job.report.pdf"));
+
+ [Fact]
+ public void DeleteViewedReportsBefore_RemovesOnlyOlderReports()
+ {
+ var old = NestReportExport.CreateViewPath("old.report.pdf", folder);
+ File.WriteAllText(old, "%PDF");
+ Directory.SetLastWriteTimeUtc(Path.GetDirectoryName(old)!, DateTime.UtcNow.AddDays(-2));
+ var recent = NestReportExport.CreateViewPath("new.report.pdf", folder);
+ File.WriteAllText(recent, "%PDF");
+
+ var removed = NestReportExport.DeleteViewedReportsBefore(
+ DateTime.UtcNow - NestReportExport.ViewedReportLifetime, folder);
+
+ Assert.Equal(1, removed);
+ Assert.False(File.Exists(old));
+ Assert.True(File.Exists(recent));
+ }
+
+ [Fact]
+ public void DeleteViewedReportsBefore_WithoutTheFolder_RemovesNothing() =>
+ Assert.Equal(0, NestReportExport.DeleteViewedReportsBefore(DateTime.UtcNow, Path.Combine(folder, "none")));
+
+ private static MachineConfig Machine(string name, params (string Name, string Grade, double Density)[] materials) =>
+ new()
+ {
+ Name = name,
+ Materials = materials.Select(m => new MaterialConfig { Name = m.Name, Grade = m.Grade, Density = m.Density }).ToList(),
+ };
+}
diff --git a/OpenNest.Desktop.Tests/PlateExportTests.cs b/OpenNest.Desktop.Tests/PlateExportTests.cs
new file mode 100644
index 00000000..585b3a45
--- /dev/null
+++ b/OpenNest.Desktop.Tests/PlateExportTests.cs
@@ -0,0 +1,294 @@
+using System.Globalization;
+using System.Runtime.InteropServices;
+using System.Xml.Linq;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.IO;
+using OpenNest.Math;
+
+namespace OpenNest.Desktop.Tests;
+
+public sealed class PlateExportTests : IDisposable
+{
+ private readonly string folder = Path.Combine(Path.GetTempPath(), $"opennest-desktop-export-{Guid.NewGuid():N}");
+
+ public PlateExportTests() => Directory.CreateDirectory(folder);
+
+ public void Dispose() => Directory.Delete(folder, true);
+
+ [Theory]
+ [InlineData("job 1", 3, "job 1-P3")]
+ [InlineData("A/B:C*?", 1, "A_B_C__-P1")]
+ [InlineData("ac\"d|e\\f", 2, "a_b_c_d_e_f-P2")]
+ [InlineData(" ", 2, "Nest-P2")]
+ [InlineData(null, 4, "Nest-P4")]
+ public void SuggestedFileName_IsUsableOnEveryPlatform(string nestName, int plate, string expected)
+ {
+ Assert.Equal(expected, PlateExport.SuggestedFileName(nestName, plate));
+ Assert.Equal(expected + ".dxf", PlateExport.ExternalCadFileName(nestName, plate));
+ }
+
+ [Fact]
+ public void ExternalCadPath_IsTheDxfInTheTempFolder() =>
+ Assert.Equal(Path.Combine(Path.GetTempPath(), "job-P2.dxf"), PlateExport.ExternalCadPath("job", 2));
+
+ [Theory]
+ [InlineData(1, PlateExportFormat.Dxf)]
+ [InlineData(2, PlateExportFormat.Image)]
+ [InlineData(3, PlateExportFormat.PlacementText)]
+ [InlineData(4, PlateExportFormat.Svg)]
+ public void FromFilterIndex_FollowsTheSaveDialogOrder(int index, PlateExportFormat expected) =>
+ Assert.Equal(expected, PlateExport.FromFilterIndex(index));
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(5)]
+ [InlineData(99)]
+ public void FromFilterIndex_RefusesOtherIndexes(int index) =>
+ Assert.Equal("filterIndex", Assert.Throws(() => PlateExport.FromFilterIndex(index)).ParamName);
+
+ [Theory]
+ [InlineData("a.dxf", PlateExportFormat.Dxf)]
+ [InlineData("a.DXF", PlateExportFormat.Dxf)]
+ [InlineData("a.png", PlateExportFormat.Image)]
+ [InlineData("a.JPG", PlateExportFormat.Image)]
+ [InlineData("a.jpeg", PlateExportFormat.Image)]
+ [InlineData("dir.v2/a.txt", PlateExportFormat.PlacementText)]
+ [InlineData("a.svg", PlateExportFormat.Svg)]
+ public void FromPath_ReadsTheExtension(string path, PlateExportFormat expected) =>
+ Assert.Equal(expected, PlateExport.FromPath(path));
+
+ [Theory]
+ [InlineData("a")]
+ [InlineData("a.pdf")]
+ [InlineData("a.bmp")]
+ [InlineData("a.dxf.bak")]
+ [InlineData("")]
+ [InlineData(null)]
+ public void FromPath_IsNullForOtherFiles(string path) => Assert.Null(PlateExport.FromPath(path));
+
+ [Theory]
+ [InlineData("en-US")]
+ [InlineData("fr-FR")]
+ public void PlacementText_GivesEachPartsSourceOriginAndDegrees(string culture)
+ {
+ var original = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
+ var plate = CreateJob().Plates[0];
+ var path = Path.Combine(folder, "plate.txt");
+
+ PlateExport.Write(plate, Units.Inches, path, PlateExportFormat.PlacementText, null);
+
+ var expected = plate.Parts.Select(part =>
+ {
+ var pt = part.BaseDrawing.Source.Offset.Rotate(part.Rotation);
+ return string.Format("{0}|{1},{2}|{3}", part.BaseDrawing.Source.Path,
+ System.Math.Round(part.Location.X - pt.X, 8),
+ System.Math.Round(part.Location.Y - pt.Y, 8),
+ Angle.ToDegrees(part.Rotation));
+ });
+ Assert.Equal(expected, File.ReadAllLines(path));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = original;
+ }
+ }
+
+ [Fact]
+ public void PlacementText_LessTheRotatedSourceOffset()
+ {
+ var drawing = new Drawing("d", Square(2)) { Source = { Path = "d.dxf", Offset = new Vector(1, 0) } };
+ var part = new Part(drawing, new Vector(10, 20));
+ part.Rotate(Angle.ToRadians(90));
+ var plate = new Plate(48, 96);
+ plate.Parts.Add(part);
+ var writer = new StringWriter { NewLine = "\n" };
+
+ PlateExport.WritePlacements(plate, writer);
+
+ // The source origin is the location less (1, 0) turned 90 degrees, (0, 1).
+ var fields = writer.ToString().TrimEnd('\n').Split('|');
+ Assert.Equal("d.dxf", fields[0]);
+ var xy = fields[1].Split(',').Select(double.Parse).ToArray();
+ Assert.Equal(part.Location.X, xy[0], 6);
+ Assert.Equal(part.Location.Y - 1, xy[1], 6);
+ Assert.Equal(90, double.Parse(fields[2]), 6);
+ }
+
+ [Fact]
+ public void Dxf_ReadsBackThroughTheCadImporter()
+ {
+ var plate = CreateJob().Plates[0];
+ var path = Path.Combine(folder, "plate.dxf");
+
+ PlateExport.Write(plate, Units.Inches, path, PlateExportFormat.Dxf, null);
+
+ var imported = CadImporter.Import(path);
+ var box = imported.Entities.Cast().GetBoundingBox();
+ // The sheet outline and both parts come back.
+ Assert.True(box.Width >= plate.Size.Width - 1e-6 && box.Length >= plate.Size.Length - 1e-6, $"{box}");
+ Assert.True(imported.Entities.Count >= 4 + 2 * 4, $"only {imported.Entities.Count} entities");
+ }
+
+ [Fact]
+ public void Svg_IsWellFormedSvgOfThePlate()
+ {
+ var plate = CreateJob().Plates[0];
+ var path = Path.Combine(folder, "plate.svg");
+
+ PlateExport.Write(plate, Units.Millimeters, path, PlateExportFormat.Svg, null);
+
+ var root = XDocument.Load(path).Root!;
+ Assert.Equal("svg", root.Name.LocalName);
+ Assert.EndsWith("mm", root.Attribute("width")!.Value);
+ Assert.NotEmpty(root.Descendants().Where(e => e.Name.LocalName == "path"));
+ }
+
+ [Fact]
+ public void Image_IsDrawnByTheFrontEnd_AtTheChosenPath()
+ {
+ var path = Path.Combine(folder, "view.png");
+ var drawn = new List();
+
+ PlateExport.Write(CreateJob().Plates[0], Units.Inches, path, PlateExportFormat.Image, drawn.Add);
+
+ Assert.Equal(new[] { path }, drawn);
+ }
+
+ [Fact]
+ public void Image_WithoutARenderer_IsRefusedAndWritesNothing()
+ {
+ var path = Path.Combine(folder, "view.png");
+
+ Assert.Throws(() =>
+ PlateExport.Write(CreateJob().Plates[0], Units.Inches, path, PlateExportFormat.Image, null));
+ Assert.False(File.Exists(path));
+ }
+
+ [Fact]
+ public void Write_RefusesAnUnknownFormat_WithoutCreatingAFile()
+ {
+ var path = Path.Combine(folder, "plate");
+
+ Assert.Throws(() =>
+ PlateExport.Write(CreateJob().Plates[0], Units.Inches, path, (PlateExportFormat)42, null));
+ Assert.False(File.Exists(path));
+ }
+
+ [Theory]
+ [InlineData(PlateExportFormat.Dxf)]
+ [InlineData(PlateExportFormat.PlacementText)]
+ [InlineData(PlateExportFormat.Svg)]
+ public void Write_LeavesTheNestUntouched(PlateExportFormat format)
+ {
+ var nest = CreateJob();
+ var plate = nest.Plates[0];
+ var poses = plate.Parts.Select(p => (p.Location, p.Rotation)).ToList();
+ var nested = nest.Drawings.Select(d => d.Quantity.Nested).ToList();
+
+ PlateExport.Write(plate, nest.Units, Path.Combine(folder, "plate.out"), format, null);
+
+ Assert.Equal(poses, plate.Parts.Select(p => (p.Location, p.Rotation)).ToList());
+ Assert.Equal(nested, nest.Drawings.Select(d => d.Quantity.Nested).ToList());
+ }
+
+ [Fact]
+ public void Write_ToAMissingFolder_Throws()
+ {
+ var path = Path.Combine(folder, "missing", "plate.txt");
+
+ Assert.ThrowsAny(() =>
+ PlateExport.Write(CreateJob().Plates[0], Units.Inches, path, PlateExportFormat.PlacementText, null));
+ }
+
+ [Fact]
+ public async Task ExportAll_ExportsEveryPlateInOrder()
+ {
+ var asked = new List();
+
+ var count = await PlateExport.ExportAllAsync(3, i => { asked.Add(i); return Task.FromResult(true); });
+
+ Assert.Equal(3, count);
+ Assert.Equal(new[] { 0, 1, 2 }, asked);
+ }
+
+ [Fact]
+ public async Task ExportAll_StopsAtTheFirstCancelOrFailure()
+ {
+ var asked = new List();
+
+ var count = await PlateExport.ExportAllAsync(4, i => { asked.Add(i); return Task.FromResult(i != 1); });
+
+ Assert.Equal(1, count);
+ Assert.Equal(new[] { 0, 1 }, asked);
+ }
+
+ [Fact]
+ public void Launcher_OpensThroughTheShellOnWindows()
+ {
+ var info = ExternalFileLauncher.StartInfo(@"C:\temp\job-P1.dxf", OSPlatform.Windows);
+
+ Assert.True(info.UseShellExecute);
+ Assert.Equal(@"C:\temp\job-P1.dxf", info.FileName);
+ }
+
+ [Theory]
+ [InlineData("LINUX", "xdg-open")]
+ [InlineData("FREEBSD", "xdg-open")]
+ [InlineData("OSX", "open")]
+ public void Launcher_OpensWithTheDesktopOpenerElsewhere(string platform, string opener)
+ {
+ const string path = "/tmp/my job's \"plate\"-P1.dxf";
+
+ var info = ExternalFileLauncher.StartInfo(path, OSPlatform.Create(platform));
+
+ Assert.False(info.UseShellExecute);
+ Assert.Equal(opener, info.FileName);
+ // The path is one argument, never parsed by a shell.
+ Assert.Equal(new[] { path }, info.ArgumentList);
+ Assert.Equal("", info.Arguments);
+ }
+
+ [Fact]
+ public void Launcher_RefusesABlankPath() =>
+ Assert.ThrowsAny(() => ExternalFileLauncher.StartInfo(" ", OSPlatform.Linux));
+
+ [Theory]
+ [InlineData(0, null)]
+ [InlineData(2, "'/tmp/job-P1.dxf' was not found.")]
+ [InlineData(3, "No program is set up to open 'job-P1.dxf'.")]
+ [InlineData(4, "The system could not open '/tmp/job-P1.dxf' (exit code 4).")]
+ public void FailureMessage_ExplainsTheOpenersExitCode(int exitCode, string expected) =>
+ Assert.Equal(expected, ExternalFileLauncher.FailureMessage(exitCode, "/tmp/job-P1.dxf"));
+
+ internal static Program Square(double size)
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(new Vector(0, 0)));
+ program.Codes.Add(new LinearMove(new Vector(size, 0)));
+ program.Codes.Add(new LinearMove(new Vector(size, size)));
+ program.Codes.Add(new LinearMove(new Vector(0, size)));
+ program.Codes.Add(new LinearMove(new Vector(0, 0)));
+ return program;
+ }
+
+ internal static Nest CreateJob()
+ {
+ var nest = new Nest("export job") { Units = Units.Inches };
+ var drawing = new Drawing("rect", Square(4));
+ drawing.Source.Path = "/drawings/rect.dxf";
+ drawing.Source.Offset = new Vector(1.234567891, -0.987654321);
+ drawing.Quantity.Required = 4;
+ nest.Drawings.Add(drawing);
+ var plate = new Plate(24, 48) { Quantity = 1 };
+ var part = new Part(drawing, new Vector(6.123456789, 8.987654321));
+ part.Rotate(Angle.ToRadians(15.25));
+ plate.Parts.Add(part);
+ plate.Parts.Add(new Part(drawing, new Vector(14.5, 16.25)));
+ nest.Plates.Add(plate);
+ return nest;
+ }
+}
diff --git a/OpenNest.Desktop/ExternalFileLauncher.cs b/OpenNest.Desktop/ExternalFileLauncher.cs
new file mode 100644
index 00000000..7366dd72
--- /dev/null
+++ b/OpenNest.Desktop/ExternalFileLauncher.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+
+namespace OpenNest.Desktop;
+
+///
+/// Opens a file in the program the system associates with its type: Plate > View in CAD's
+/// DXF, View Nest Report's PDF. Windows opens it through the shell, macOS through
+/// open and Linux and other Unix desktops through xdg-open.
+///
+public static class ExternalFileLauncher
+{
+ /// The start info that opens on the running platform.
+ public static ProcessStartInfo StartInfo(string path) => StartInfo(path, CurrentPlatform());
+
+ /// The start info that opens on .
+ public static ProcessStartInfo StartInfo(string path, OSPlatform platform)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
+ // .NET defaults UseShellExecute to false, which would try to run the file itself as a
+ // program; the shell opens it in the associated program instead.
+ if (platform == OSPlatform.Windows)
+ return new ProcessStartInfo(path) { UseShellExecute = true };
+
+ var info = new ProcessStartInfo(platform == OSPlatform.OSX ? "open" : "xdg-open")
+ {
+ UseShellExecute = false,
+ };
+ // An argument, never a shell command line: spaces and quotes in the path stay literal.
+ info.ArgumentList.Add(path);
+ return info;
+ }
+
+ /// Opens in its associated program; throws when it cannot start.
+ public static void Open(string path)
+ {
+ using var process = Process.Start(StartInfo(path));
+ }
+
+ ///
+ /// Opens and, where an opener program reports the result
+ /// (xdg-open, open), waits for it without blocking and returns why it failed,
+ /// or null once it opened. The Windows shell reports a failure by throwing instead. Throws
+ /// when the opener cannot start.
+ ///
+ public static async Task OpenAsync(string path)
+ {
+ var info = StartInfo(path);
+ using var process = Process.Start(info);
+ if (info.UseShellExecute || process == null)
+ return null;
+ await process.WaitForExitAsync().ConfigureAwait(false);
+ return FailureMessage(process.ExitCode, path);
+ }
+
+ ///
+ /// What an xdg-open or open exit code means for , or
+ /// null for success (0).
+ ///
+ public static string FailureMessage(int exitCode, string path) => exitCode switch
+ {
+ 0 => null,
+ 2 => $"'{path}' was not found.",
+ 3 => $"No program is set up to open '{System.IO.Path.GetFileName(path)}'.",
+ _ => $"The system could not open '{path}' (exit code {exitCode}).",
+ };
+
+ private static OSPlatform CurrentPlatform() =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OSPlatform.Windows
+ : RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? OSPlatform.OSX
+ : OSPlatform.Linux;
+}
diff --git a/OpenNest.Desktop/NestReportExport.cs b/OpenNest.Desktop/NestReportExport.cs
new file mode 100644
index 00000000..cd4a173f
--- /dev/null
+++ b/OpenNest.Desktop/NestReportExport.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using OpenNest.Data;
+using OpenNest.Reporting;
+
+namespace OpenNest.Desktop;
+
+///
+/// File > Export Nest Report and View Nest Report for both desktop apps: the checks before a
+/// report is written, the report file name, capturing and writing the PDF, and the private
+/// temporary folders a viewed report is rendered into. Whether the nest is busy (nesting, a fill
+/// or plate action) is the front end's to say; see docs/nest-reports.md.
+///
+public static class NestReportExport
+{
+ /// How long a viewed report's temporary folder is kept before a later view removes it.
+ public static readonly TimeSpan ViewedReportLifetime = TimeSpan.FromDays(1);
+
+ ///
+ /// The report's file name, "<nest name>.report.pdf". Throws
+ /// when there is no nest, or its name is blank or
+ /// holds a character a file name cannot, so the user renames it before any dialog opens.
+ ///
+ public static string ReportFileName(Nest nest)
+ {
+ if (nest == null)
+ throw new InvalidOperationException("No nest is available to report.");
+ 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.");
+ return $"{nest.Name}.report.pdf";
+ }
+
+ ///
+ /// The report of , captured synchronously while the caller keeps the
+ /// nest stable. The nest material's density comes from (the nest's
+ /// machine, may be null) and the catalog when the material has
+ /// none, and the thickness's name ("16 ga") from the machine's cutting defaults; with
+ /// each plate gets an estimated cut time.
+ /// Capturing reads the nest only: no quantity, selection or dirty-state change.
+ ///
+ public static NestReportSnapshot Capture(
+ Nest nest,
+ DateTimeOffset generatedAt,
+ MachineConfig machine,
+ IEnumerable materials,
+ CutParameters cutParameters)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ var material = nest.Material;
+ var catalogDensity = MaterialDensityLookup.Resolve(material?.Name, material?.Grade, machine, materials);
+ return NestReportBuilder.Capture(nest, generatedAt, cutParameters, catalogDensity) with
+ {
+ ThicknessName = MachineThicknessName(machine, nest),
+ };
+ }
+
+ /// The name the machine config gives the nest's thickness ("16 ga"), or empty.
+ public static string MachineThicknessName(MachineConfig machine, Nest nest) =>
+ MachineCuttingDefaults.Find(machine, nest)?.Name?.Trim() ?? "";
+
+ ///
+ /// The report captured as the desktop apps do, from machines already read: the nest's
+ /// machine (by id) and every machine's materials from , and the
+ /// cut-time settings last used in Calculate Cut Time or the simulator
+ /// (, the shared user settings) for the estimated cut times.
+ ///
+ public static NestReportSnapshot Capture(
+ Nest nest,
+ DateTimeOffset generatedAt,
+ IEnumerable machines,
+ IReadOnlyDictionary userSettings)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ ArgumentNullException.ThrowIfNull(machines);
+ ArgumentNullException.ThrowIfNull(userSettings);
+ var known = machines.Where(m => m != null).ToList();
+ var saved = SavedCutTimeSettings.Read(userSettings);
+ var cutParameters = saved.ToCutParameters(nest.Units, SavedCutTimeSettings.ChooseMachine(known, saved.Machine));
+ var machine = nest.Machine == null ? null : known.FirstOrDefault(m => m.Id == nest.Machine.Id);
+ var materials = known.SelectMany(m => m.Materials ?? new List());
+ return Capture(nest, generatedAt, machine, materials, cutParameters);
+ }
+
+ ///
+ /// Writes a captured report to . The writer replaces an
+ /// existing destination only once the new report is complete.
+ ///
+ public static void Write(NestReportSnapshot snapshot, string destination) =>
+ NestPdfWriter.Write(snapshot, destination);
+
+ /// The folder viewed reports are rendered under: OpenNest/Reports in the temp folder.
+ public static string ViewedReportsRoot => Path.Combine(Path.GetTempPath(), "OpenNest", "Reports");
+
+ ///
+ /// A new private folder under (default )
+ /// for one viewed report, created, and the report's path in it.
+ ///
+ public static string CreateViewPath(string reportFileName, string root = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(reportFileName);
+ var folder = Path.Combine(root ?? ViewedReportsRoot, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(folder);
+ return Path.Combine(folder, reportFileName);
+ }
+
+ /// Deletes a viewed report's folder. Best effort: a leftover temp report is harmless.
+ public static void DeleteReportFolder(string pdfPath)
+ {
+ try
+ {
+ var folder = Path.GetDirectoryName(pdfPath);
+ if (folder != null && Directory.Exists(folder))
+ Directory.Delete(folder, true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+
+ ///
+ /// Deletes the viewed-report folders under (default
+ /// ) last written before . A report
+ /// opened in the system's PDF viewer cannot be deleted when its viewer closes, so each view
+ /// clears those older than . Best effort; returns how many
+ /// folders were removed.
+ ///
+ public static int DeleteViewedReportsBefore(DateTime before, string root = null)
+ {
+ root ??= ViewedReportsRoot;
+ var removed = 0;
+ try
+ {
+ if (!Directory.Exists(root))
+ return 0;
+ foreach (var folder in Directory.EnumerateDirectories(root))
+ {
+ try
+ {
+ if (Directory.GetLastWriteTimeUtc(folder) >= before.ToUniversalTime())
+ continue;
+ Directory.Delete(folder, true);
+ removed++;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ return removed;
+ }
+}
diff --git a/OpenNest.Desktop/PlateExport.cs b/OpenNest.Desktop/PlateExport.cs
new file mode 100644
index 00000000..8bd1f089
--- /dev/null
+++ b/OpenNest.Desktop/PlateExport.cs
@@ -0,0 +1,170 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using OpenNest.IO;
+using OpenNest.Math;
+
+namespace OpenNest.Desktop;
+
+/// What File > Export writes for one plate.
+public enum PlateExportFormat
+{
+ /// The plate's parts as DXF geometry.
+ Dxf,
+
+ /// A picture of the plate view as shown; the front end renders it.
+ Image,
+
+ /// One line per part: source drawing path | X,Y | rotation in degrees.
+ PlacementText,
+
+ /// The plate's parts as SVG in the nest's units.
+ Svg,
+}
+
+///
+/// File > Export, Export All and Plate > View in CAD for both desktop apps: the file each
+/// plate is suggested to be saved as, the format a chosen file asks for, and the writers. The
+/// image of the view is the one format the toolkit draws, so its writer is passed in.
+///
+public static class PlateExport
+{
+ // Characters Windows refuses in a file name, refused on every platform so a suggested name
+ // works wherever the file is later copied. The platform's own set is added to them.
+ private static readonly char[] InvalidFileNameChars = "<>:\"/\\|?*"
+ .Concat(Enumerable.Range(0, 32).Select(c => (char)c))
+ .Concat(Path.GetInvalidFileNameChars())
+ .Distinct()
+ .ToArray();
+
+ ///
+ /// The WinForms save dialog's file types in order: its 1-based filter index picks the format.
+ ///
+ public static IReadOnlyList DialogFormats { get; } = new[]
+ {
+ PlateExportFormat.Dxf,
+ PlateExportFormat.Image,
+ PlateExportFormat.PlacementText,
+ PlateExportFormat.Svg,
+ };
+
+ /// The format for the save dialog's 1-based .
+ public static PlateExportFormat FromFilterIndex(int filterIndex) =>
+ filterIndex >= 1 && filterIndex <= DialogFormats.Count
+ ? DialogFormats[filterIndex - 1]
+ : throw new ArgumentException("Unsupported plate export format.", nameof(filterIndex));
+
+ ///
+ /// The format a chosen file's extension asks for (.dxf, .png/.jpg/.jpeg, .txt, .svg, any
+ /// case), or null for any other extension or none. Bitmap (.bmp) is not offered: no
+ /// cross-platform encoder writes it.
+ ///
+ public static PlateExportFormat? FromPath(string path) =>
+ Path.GetExtension(path ?? "").ToLowerInvariant() switch
+ {
+ ".dxf" => PlateExportFormat.Dxf,
+ ".png" or ".jpg" or ".jpeg" => PlateExportFormat.Image,
+ ".txt" => PlateExportFormat.PlacementText,
+ ".svg" => PlateExportFormat.Svg,
+ _ => null,
+ };
+
+ ///
+ /// The name a plate's export is suggested under, without an extension: the nest name and
+ /// "-P" with the 1-based plate number ("job-P2"). Characters a file name cannot hold on any
+ /// platform become underscores; a blank nest name is "Nest".
+ ///
+ public static string SuggestedFileName(string nestName, int plateNumber)
+ {
+ var baseName = string.IsNullOrWhiteSpace(nestName) ? "Nest" : nestName.Trim();
+ var chars = baseName.Select(c => Array.IndexOf(InvalidFileNameChars, c) >= 0 ? '_' : c).ToArray();
+ return $"{new string(chars)}-P{plateNumber}";
+ }
+
+ /// The DXF that View in CAD writes for the plate and opens: .dxf.
+ public static string ExternalCadFileName(string nestName, int plateNumber) =>
+ SuggestedFileName(nestName, plateNumber) + ".dxf";
+
+ /// Where View in CAD writes the plate's DXF: the user's temp folder.
+ public static string ExternalCadPath(string nestName, int plateNumber) =>
+ Path.Combine(Path.GetTempPath(), ExternalCadFileName(nestName, plateNumber));
+
+ ///
+ /// Writes to as
+ /// . SVG is written in ; the image is drawn by
+ /// (the shown view, which only the toolkit can draw). The file
+ /// is written in place: a failure part-way can leave a partial file, as the WinForms export
+ /// always has. The nest, plate and parts are only read.
+ ///
+ public static void Write(
+ Plate plate,
+ Units units,
+ string destination,
+ PlateExportFormat format,
+ Action writeImage)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ ArgumentException.ThrowIfNullOrWhiteSpace(destination);
+ switch (format)
+ {
+ case PlateExportFormat.Dxf:
+ Dxf.ExportPlate(plate, destination);
+ break;
+ case PlateExportFormat.Image:
+ if (writeImage == null)
+ throw new NotSupportedException("This view cannot be saved as an image.");
+ writeImage(destination);
+ break;
+ case PlateExportFormat.PlacementText:
+ using (var writer = new StreamWriter(destination))
+ WritePlacements(plate, writer);
+ break;
+ case PlateExportFormat.Svg:
+ Svg.ExportPlate(plate, destination, units);
+ break;
+ default:
+ throw new ArgumentException("Unsupported plate export format.", nameof(format));
+ }
+ }
+
+ ///
+ /// The placement list: per part, in plate order, "source path|X,Y|degrees", where X,Y is the
+ /// drawing's source origin on the plate (the part location less its rotated source offset)
+ /// rounded to 8 places. Numbers use the current culture, as the WinForms export always has.
+ ///
+ public static void WritePlacements(Plate plate, TextWriter writer)
+ {
+ ArgumentNullException.ThrowIfNull(plate);
+ ArgumentNullException.ThrowIfNull(writer);
+ foreach (var part in plate.Parts)
+ {
+ var pt = part.BaseDrawing.Source.Offset.Rotate(part.Rotation);
+ writer.WriteLine(
+ "{0}|{1},{2}|{3}",
+ part.BaseDrawing.Source.Path,
+ System.Math.Round(part.Location.X - pt.X, 8),
+ System.Math.Round(part.Location.Y - pt.Y, 8),
+ Angle.ToDegrees(part.Rotation));
+ }
+ }
+
+ ///
+ /// Export All: exports plates 0..-1 in order through
+ /// (which shows the plate, asks for its file and writes it),
+ /// stopping at the first that is cancelled or fails. Returns how many were exported.
+ ///
+ public static async Task ExportAllAsync(int plateCount, Func> exportPlate)
+ {
+ ArgumentNullException.ThrowIfNull(exportPlate);
+ var exported = 0;
+ for (var i = 0; i < plateCount; i++)
+ {
+ if (!await exportPlate(i))
+ break;
+ exported++;
+ }
+ return exported;
+ }
+}
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index 853f1385..4d38bc72 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -14,6 +14,7 @@ using OpenNest.Collections;
using OpenNest.Controls;
using OpenNest.Data;
using OpenNest.Data.MaterialLedger;
+using OpenNest.Desktop;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Sequencing;
@@ -21,6 +22,7 @@ using OpenNest.IO;
using OpenNest.Math;
using OpenNest.Properties;
using OpenNest.Reporting;
+using OpenNest.Reporting;
using Timer = System.Timers.Timer;
namespace OpenNest.Forms
@@ -694,7 +696,7 @@ namespace OpenNest.Forms
public bool Export()
{
- var suggestedFileName = string.Format("{0}-P{1}", Nest.Name, PlateManager.CurrentIndex + 1);
+ var suggestedFileName = PlateExport.SuggestedFileName(Nest.Name, PlateManager.CurrentIndex + 1);
var target = ShowPlateExportDialog(suggestedFileName);
if (target == null)
return false;
@@ -731,46 +733,15 @@ namespace OpenNest.Forms
}
}
- internal virtual void WritePlateExport(Plate plate, string destination, int filterIndex)
- {
- switch (filterIndex)
- {
- case 1:
- Dxf.ExportPlate(plate, destination);
- break;
- case 2:
- using (var img = new Bitmap(PlateView.Width, PlateView.Height))
- {
- PlateView.DrawToBitmap(
- img,
- new Rectangle(0, 0, PlateView.Width, PlateView.Height)
- );
- img.Save(destination, System.Drawing.Imaging.ImageFormat.Jpeg);
- }
- break;
- case 3:
- using (var writer = new StreamWriter(destination))
- {
- foreach (var part in plate.Parts)
- {
- var pt = part.BaseDrawing.Source.Offset.Rotate(part.Rotation);
+ internal virtual void WritePlateExport(Plate plate, string destination, int filterIndex) =>
+ PlateExport.Write(plate, Nest.Units, destination, PlateExport.FromFilterIndex(filterIndex), WriteViewImage);
- writer.WriteLine(
- "{0}|{1},{2}|{3}",
- part.BaseDrawing.Source.Path,
- System.Math.Round(part.Location.X - pt.X, 8),
- System.Math.Round(part.Location.Y - pt.Y, 8),
- Angle.ToDegrees(part.Rotation)
- );
- }
- }
- break;
- case 4:
- Svg.ExportPlate(plate, destination, Nest.Units);
- break;
- default:
- throw new ArgumentException("Unsupported plate export format.", nameof(filterIndex));
- }
+ // The image of the plate view as shown, as a JPEG the size of the view.
+ private void WriteViewImage(string destination)
+ {
+ using var img = new Bitmap(PlateView.Width, PlateView.Height);
+ PlateView.DrawToBitmap(img, new Rectangle(0, 0, PlateView.Width, PlateView.Height));
+ img.Save(destination, System.Drawing.Imaging.ImageFormat.Jpeg);
}
internal virtual void ReportPlateExportFailure(string destination, Exception error) =>
@@ -801,24 +772,15 @@ namespace OpenNest.Forms
{
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."
- );
+ var fileName = NestReportExport.ReportFileName(nest);
if (isJobBusy() || IsReportTargetBusy(nest))
throw new InvalidOperationException(
$"Finish or cancel the current nesting or plate action before creating the report for '{nest.Name}'."
);
- return new NestReportTargets(nest, $"{nest.Name}.report.pdf");
+ return new NestReportTargets(nest, fileName);
}
///
@@ -847,30 +809,16 @@ namespace OpenNest.Forms
// Reporting must have no accounting side effects: no quantity refresh, no
// selection or dirty-state change. Only the detached snapshot reaches the writer.
- var material = targets.Nest.Material;
- var machine = MachineSettings.FindMachine(targets.Nest.Machine);
- var catalogDensity = Data.MaterialDensityLookup.Resolve(
- material?.Name,
- material?.Grade,
- machine,
- MachineSettings.LoadMaterials()
- );
- var snapshot = NestReportBuilder.Capture(
+ var snapshot = NestReportExport.Capture(
targets.Nest,
DateTimeOffset.Now,
- SavedCutParameters.Load(targets.Nest.Units),
- catalogDensity
- ) with
- {
- ThicknessName = MachineThicknessName(machine, targets.Nest),
- };
- NestPdfWriter.Write(snapshot, destination);
+ MachineSettings.FindMachine(targets.Nest.Machine),
+ MachineSettings.LoadMaterials(),
+ SavedCutParameters.Load(targets.Nest.Units)
+ );
+ NestReportExport.Write(snapshot, destination);
}
- /// The name the machine config gives the nest's thickness ("16 ga"), or empty.
- private static string MachineThicknessName(Data.MachineConfig machine, Nest nest) =>
- Data.MachineCuttingDefaults.Find(machine, nest)?.Name?.Trim() ?? "";
-
///
/// Writes one printout page per drawing, in order. Capture is synchronous on the UI thread
/// and reads only drawings, so it has no accounting or dirty-state side effects.
@@ -889,7 +837,7 @@ namespace OpenNest.Forms
);
var snapshot = PartPrintoutBuilder.Capture(Nest, drawings, DateTimeOffset.Now, catalogDensity) with
{
- ThicknessName = MachineThicknessName(machine, Nest),
+ ThicknessName = NestReportExport.MachineThicknessName(machine, Nest),
};
PartPrintoutWriter.Write(snapshot, destination);
}
@@ -1143,23 +1091,13 @@ namespace OpenNest.Forms
///
/// Temp DXF name for View in CAD; characters a file name cannot hold become underscores.
///
- internal static string ExternalCadFileName(string nestName, int plateNumber)
- {
- var baseName = string.IsNullOrWhiteSpace(nestName) ? "Nest" : nestName.Trim();
- var invalid = Path.GetInvalidFileNameChars();
- var chars = baseName.Select(c => Array.IndexOf(invalid, c) >= 0 ? '_' : c).ToArray();
- return $"{new string(chars)}-P{plateNumber}.dxf";
- }
+ internal static string ExternalCadFileName(string nestName, int plateNumber) =>
+ PlateExport.ExternalCadFileName(nestName, plateNumber);
- // .NET 8 defaults UseShellExecute to false, which tries to run the DXF as an executable;
- // the shell opens it in the program associated with .dxf instead.
- internal static ProcessStartInfo ExternalCadStartInfo(string path) =>
- new ProcessStartInfo(path) { UseShellExecute = true };
+ // The shell opens the DXF in the program associated with .dxf.
+ internal static ProcessStartInfo ExternalCadStartInfo(string path) => ExternalFileLauncher.StartInfo(path);
- internal virtual void LaunchExternalCad(string path)
- {
- using var process = Process.Start(ExternalCadStartInfo(path));
- }
+ internal virtual void LaunchExternalCad(string path) => ExternalFileLauncher.Open(path);
internal virtual void ReportExternalCadFailure(string path, Exception error) =>
MessageBox.Show(this, $"Could not open '{path}' in CAD: {error.Message}",
diff --git a/OpenNest/Forms/MainForm.File.cs b/OpenNest/Forms/MainForm.File.cs
index 297f58b0..5664aaf6 100644
--- a/OpenNest/Forms/MainForm.File.cs
+++ b/OpenNest/Forms/MainForm.File.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
+using OpenNest.Desktop;
using OpenNest.Engine.BestFit;
using OpenNest.Forms.Commands;
using OpenNest.IO;
@@ -326,11 +327,10 @@ namespace OpenNest.Forms
if (form == null || !TryCaptureReportTargets(form, out var targets))
return;
- var path = Path.Combine(Path.GetTempPath(), "OpenNest", "Reports",
- Guid.NewGuid().ToString("N"), targets.SuggestedFileName);
+ string path;
try
{
- Directory.CreateDirectory(Path.GetDirectoryName(path));
+ path = NestReportExport.CreateViewPath(targets.SuggestedFileName);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
diff --git a/OpenNest/Forms/NestReportViewerForm.cs b/OpenNest/Forms/NestReportViewerForm.cs
index 8af5659e..3a1f0b42 100644
--- a/OpenNest/Forms/NestReportViewerForm.cs
+++ b/OpenNest/Forms/NestReportViewerForm.cs
@@ -5,6 +5,7 @@ using System.Runtime.InteropServices;
using System.Windows.Forms;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
+using OpenNest.Desktop;
namespace OpenNest.Forms
{
@@ -142,17 +143,6 @@ namespace OpenNest.Forms
}
/// Best effort: a leftover temp report is harmless.
- internal static void DeleteReportFolder(string pdfPath)
- {
- try
- {
- var folder = Path.GetDirectoryName(pdfPath);
- if (folder != null && Directory.Exists(folder))
- Directory.Delete(folder, true);
- }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
- {
- }
- }
+ internal static void DeleteReportFolder(string pdfPath) => NestReportExport.DeleteReportFolder(pdfPath);
}
}
diff --git a/docs/nest-reports.md b/docs/nest-reports.md
index 20b83e55..e6331412 100644
--- a/docs/nest-reports.md
+++ b/docs/nest-reports.md
@@ -19,6 +19,16 @@ there is no separate direct-save command. Closing the window deletes its tempora
folder; a render failure opens no viewer and leaves no folder. Without the WebView2
Runtime the window still offers Save As.
+The cross-platform desktop (`OpenNest.Avalonia`) shares the file name check,
+capture and writer (`OpenNest.Desktop.NestReportExport`) but has no viewer of its
+own: **File -> Nest Report...** writes the PDF to a private folder under the
+system temp folder's `OpenNest/Reports/` and opens it in the system's PDF viewer
+(`ExternalFileLauncher`: the Windows shell, `open` on macOS, `xdg-open`
+elsewhere), which saves and prints it. Because that viewer's closing cannot be
+seen, each view removes viewed-report folders older than a day instead. **File ->
+Export Nest Report...** saves the PDF straight to a chosen file. Either refuses
+while Auto Nest runs on the nest, checked before and after the file picker.
+
The same two calls back the command and any future integration:
```csharp