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); } }