From 098e1d92a736a25bc5de4e84cbafd00590488ba4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 11 Nov 2026 23:08:05 -0500 Subject: [PATCH 1/3] refactor(desktop): share plate export, file launching and nest report writing Move the toolkit-neutral parts of File > Export/Export All, Plate > View in CAD and the nest report commands into OpenNest.Desktop: export formats and suggested per-plate file names (now free of characters any platform refuses), the placement list, DXF/SVG writing with the view image left to the front end, opening a file in its associated program on Windows, macOS and Linux, the report file name check, capture with catalog density, the machine's thickness name and saved cut-time settings, and viewed-report temp folders. WinForms calls the shared code; its behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../NestReportExportTests.cs | 188 ++++++++++++ OpenNest.Desktop.Tests/PlateExportTests.cs | 286 ++++++++++++++++++ OpenNest.Desktop/ExternalFileLauncher.cs | 45 +++ OpenNest.Desktop/NestReportExport.cs | 157 ++++++++++ OpenNest.Desktop/PlateExport.cs | 169 +++++++++++ OpenNest/Forms/EditNestForm.cs | 112 ++----- OpenNest/Forms/MainForm.File.cs | 6 +- OpenNest/Forms/NestReportViewerForm.cs | 14 +- 8 files changed, 875 insertions(+), 102 deletions(-) create mode 100644 OpenNest.Desktop.Tests/NestReportExportTests.cs create mode 100644 OpenNest.Desktop.Tests/PlateExportTests.cs create mode 100644 OpenNest.Desktop/ExternalFileLauncher.cs create mode 100644 OpenNest.Desktop/NestReportExport.cs create mode 100644 OpenNest.Desktop/PlateExport.cs 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..3be4513b --- /dev/null +++ b/OpenNest.Desktop.Tests/PlateExportTests.cs @@ -0,0 +1,286 @@ +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("a.bmp", 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.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)); + + 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..dbf88a6a --- /dev/null +++ b/OpenNest.Desktop/ExternalFileLauncher.cs @@ -0,0 +1,45 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +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)); + } + + 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..c611d878 --- /dev/null +++ b/OpenNest.Desktop/PlateExport.cs @@ -0,0 +1,169 @@ +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/.bmp, .txt, .svg, + /// any case), or null for any other extension or none. + /// + public static PlateExportFormat? FromPath(string path) => + Path.GetExtension(path ?? "").ToLowerInvariant() switch + { + ".dxf" => PlateExportFormat.Dxf, + ".png" or ".jpg" or ".jpeg" or ".bmp" => 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); } } From 7792badac3f6b229b0450f465856fde306a3b811 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Nov 2026 19:28:39 -0500 Subject: [PATCH 2/3] feat(desktop): report opener failures and drop bitmap plate exports ExternalFileLauncher.OpenAsync waits for xdg-open or open without blocking and explains a failed exit code (no program set up for the file type, file not found), which the shell-less platforms otherwise swallow. File > Export no longer takes .bmp as an image: no cross-platform encoder writes it, so the image formats are .png, .jpg and .jpeg. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Desktop.Tests/PlateExportTests.cs | 10 +++++++- OpenNest.Desktop/ExternalFileLauncher.cs | 29 ++++++++++++++++++++++ OpenNest.Desktop/PlateExport.cs | 7 +++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/OpenNest.Desktop.Tests/PlateExportTests.cs b/OpenNest.Desktop.Tests/PlateExportTests.cs index 3be4513b..585b3a45 100644 --- a/OpenNest.Desktop.Tests/PlateExportTests.cs +++ b/OpenNest.Desktop.Tests/PlateExportTests.cs @@ -53,7 +53,6 @@ public sealed class PlateExportTests : IDisposable [InlineData("a.png", PlateExportFormat.Image)] [InlineData("a.JPG", PlateExportFormat.Image)] [InlineData("a.jpeg", PlateExportFormat.Image)] - [InlineData("a.bmp", PlateExportFormat.Image)] [InlineData("dir.v2/a.txt", PlateExportFormat.PlacementText)] [InlineData("a.svg", PlateExportFormat.Svg)] public void FromPath_ReadsTheExtension(string path, PlateExportFormat expected) => @@ -62,6 +61,7 @@ public sealed class PlateExportTests : IDisposable [Theory] [InlineData("a")] [InlineData("a.pdf")] + [InlineData("a.bmp")] [InlineData("a.dxf.bak")] [InlineData("")] [InlineData(null)] @@ -256,6 +256,14 @@ public sealed class PlateExportTests : IDisposable 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(); diff --git a/OpenNest.Desktop/ExternalFileLauncher.cs b/OpenNest.Desktop/ExternalFileLauncher.cs index dbf88a6a..7366dd72 100644 --- a/OpenNest.Desktop/ExternalFileLauncher.cs +++ b/OpenNest.Desktop/ExternalFileLauncher.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading.Tasks; namespace OpenNest.Desktop; @@ -38,6 +39,34 @@ public static class ExternalFileLauncher 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 diff --git a/OpenNest.Desktop/PlateExport.cs b/OpenNest.Desktop/PlateExport.cs index c611d878..8bd1f089 100644 --- a/OpenNest.Desktop/PlateExport.cs +++ b/OpenNest.Desktop/PlateExport.cs @@ -57,14 +57,15 @@ public static class PlateExport : throw new ArgumentException("Unsupported plate export format.", nameof(filterIndex)); /// - /// The format a chosen file's extension asks for (.dxf, .png/.jpg/.jpeg/.bmp, .txt, .svg, - /// any case), or null for any other extension or none. + /// 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" or ".bmp" => PlateExportFormat.Image, + ".png" or ".jpg" or ".jpeg" => PlateExportFormat.Image, ".txt" => PlateExportFormat.PlacementText, ".svg" => PlateExportFormat.Svg, _ => null, From d0654d267c385b0c164823a9660d1cf86c6e1284 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Nov 2026 19:28:39 -0500 Subject: [PATCH 3/3] feat(avalonia): export plates, view in CAD and nest reports File > Export Plate... and Export All Plates... write the shown plate (each plate in turn, shown while its file is chosen; cancel or a failure stops the rest) as DXF, the view's image, the placement list or SVG, chosen by the picked file's extension, under the WinForms suggested names. The image is the plate canvas drawn by its Skia painter at the screen's density, PNG or JPEG. Plate > View in CAD writes -P.dxf to the temp folder and opens it in the system's CAD program. File > Nest Report... writes the PDF to a private temp folder and opens it in the system's PDF viewer, which saves and prints it (older viewed reports are removed); File > Export Nest Report... saves it to a chosen file. Reports refuse while Auto Nest runs on the nest and are written off the UI thread from a detached snapshot. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Avalonia/MainWindow.Export.cs | 356 +++++++++++++++++++++++ OpenNest.Avalonia/MainWindow.axaml | 9 + OpenNest.Avalonia/MainWindow.axaml.cs | 1 + OpenNest.Avalonia/Rendering/ViewImage.cs | 59 ++++ docs/nest-reports.md | 10 + 5 files changed, 435 insertions(+) create mode 100644 OpenNest.Avalonia/MainWindow.Export.cs create mode 100644 OpenNest.Avalonia/Rendering/ViewImage.cs 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 ff16e5db..666cb6d6 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 b72fea1b..48695328 100644 --- a/OpenNest.Avalonia/MainWindow.axaml.cs +++ b/OpenNest.Avalonia/MainWindow.axaml.cs @@ -223,6 +223,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/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