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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-13 19:27:07 -05:00
1 parent 09357ca95d
commit 098e1d92a7
8 files changed
+875 -102

No files matched your search

@@ -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<InvalidOperationException>(() => NestReportExport.ReportFileName(nest));
Assert.Contains("Rename the nest", error.Message);
}
[Fact]
public void ReportFileName_RefusesNoNest() =>
Assert.Throws<InvalidOperationException>(() => 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<MachineConfig>(), new Dictionary<string, string>());
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<string, string>());
// 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<string, string>());
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<string, string>());
nest.Thickness = 0.25;
var unnamed = NestReportExport.Capture(nest, At, new[] { own }, new Dictionary<string, string>());
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<string, string>());
Assert.Null(snapshot.Drawings.Single().Weight);
}
[Fact]
public void Capture_EstimatesCutTimes_FromTheSavedCutTimeSettings()
{
var nest = PlateExportTests.CreateJob();
var slow = new Dictionary<string, string>
{
["LastFeedrate"] = "10",
["LastRapidFeedrate"] = "100",
["LastPierceTime"] = "1",
["CutTimeMachine"] = SavedCutTimeSettings.FullSpeed,
};
var fast = new Dictionary<string, string>(slow) { ["LastFeedrate"] = "1000" };
var slowReport = NestReportExport.Capture(nest, At, Array.Empty<MachineConfig>(), slow);
var fastReport = NestReportExport.Capture(nest, At, Array.Empty<MachineConfig>(), 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(),
};
}
+286
View File
@@ -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("a<b>c\"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<ArgumentException>(() => 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<IBoundable>().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<string>();
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<NotSupportedException>(() =>
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<ArgumentException>(() =>
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<IOException>(() =>
PlateExport.Write(CreateJob().Plates[0], Units.Inches, path, PlateExportFormat.PlacementText, null));
}
[Fact]
public async Task ExportAll_ExportsEveryPlateInOrder()
{
var asked = new List<int>();
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<int>();
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<ArgumentException>(() => 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;
}
}
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace OpenNest.Desktop;
/// <summary>
/// Opens a file in the program the system associates with its type: Plate &gt; View in CAD's
/// DXF, View Nest Report's PDF. Windows opens it through the shell, macOS through
/// <c>open</c> and Linux and other Unix desktops through <c>xdg-open</c>.
/// </summary>
public static class ExternalFileLauncher
{
/// <summary>The start info that opens <paramref name="path"/> on the running platform.</summary>
public static ProcessStartInfo StartInfo(string path) => StartInfo(path, CurrentPlatform());
/// <summary>The start info that opens <paramref name="path"/> on <paramref name="platform"/>.</summary>
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;
}
/// <summary>Opens <paramref name="path"/> in its associated program; throws when it cannot start.</summary>
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;
}
+157
View File
@@ -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;
/// <summary>
/// File &gt; 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.
/// </summary>
public static class NestReportExport
{
/// <summary>How long a viewed report's temporary folder is kept before a later view removes it.</summary>
public static readonly TimeSpan ViewedReportLifetime = TimeSpan.FromDays(1);
/// <summary>
/// The report's file name, "&lt;nest name&gt;.report.pdf". Throws
/// <see cref="InvalidOperationException"/> 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.
/// </summary>
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";
}
/// <summary>
/// The report of <paramref name="nest"/>, captured synchronously while the caller keeps the
/// nest stable. The nest material's density comes from <paramref name="machine"/> (the nest's
/// machine, may be null) and the <paramref name="materials"/> catalog when the material has
/// none, and the thickness's name ("16 ga") from the machine's cutting defaults; with
/// <paramref name="cutParameters"/> each plate gets an estimated cut time.
/// Capturing reads the nest only: no quantity, selection or dirty-state change.
/// </summary>
public static NestReportSnapshot Capture(
Nest nest,
DateTimeOffset generatedAt,
MachineConfig machine,
IEnumerable<MaterialConfig> 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),
};
}
/// <summary>The name the machine config gives the nest's thickness ("16 ga"), or empty.</summary>
public static string MachineThicknessName(MachineConfig machine, Nest nest) =>
MachineCuttingDefaults.Find(machine, nest)?.Name?.Trim() ?? "";
/// <summary>
/// The report captured as the desktop apps do, from machines already read: the nest's
/// machine (by id) and every machine's materials from <paramref name="machines"/>, and the
/// cut-time settings last used in Calculate Cut Time or the simulator
/// (<paramref name="userSettings"/>, the shared user settings) for the estimated cut times.
/// </summary>
public static NestReportSnapshot Capture(
Nest nest,
DateTimeOffset generatedAt,
IEnumerable<MachineConfig> machines,
IReadOnlyDictionary<string, string> 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<MaterialConfig>());
return Capture(nest, generatedAt, machine, materials, cutParameters);
}
/// <summary>
/// Writes a captured report to <paramref name="destination"/>. The writer replaces an
/// existing destination only once the new report is complete.
/// </summary>
public static void Write(NestReportSnapshot snapshot, string destination) =>
NestPdfWriter.Write(snapshot, destination);
/// <summary>The folder viewed reports are rendered under: OpenNest/Reports in the temp folder.</summary>
public static string ViewedReportsRoot => Path.Combine(Path.GetTempPath(), "OpenNest", "Reports");
/// <summary>
/// A new private folder under <paramref name="root"/> (default <see cref="ViewedReportsRoot"/>)
/// for one viewed report, created, and the report's path in it.
/// </summary>
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);
}
/// <summary>Deletes a viewed report's folder. Best effort: a leftover temp report is harmless.</summary>
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)
{
}
}
/// <summary>
/// Deletes the viewed-report folders under <paramref name="root"/> (default
/// <see cref="ViewedReportsRoot"/>) last written before <paramref name="before"/>. A report
/// opened in the system's PDF viewer cannot be deleted when its viewer closes, so each view
/// clears those older than <see cref="ViewedReportLifetime"/>. Best effort; returns how many
/// folders were removed.
/// </summary>
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;
}
}
+169
View File
@@ -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;
/// <summary>What File &gt; Export writes for one plate.</summary>
public enum PlateExportFormat
{
/// <summary>The plate's parts as DXF geometry.</summary>
Dxf,
/// <summary>A picture of the plate view as shown; the front end renders it.</summary>
Image,
/// <summary>One line per part: source drawing path | X,Y | rotation in degrees.</summary>
PlacementText,
/// <summary>The plate's parts as SVG in the nest's units.</summary>
Svg,
}
/// <summary>
/// File &gt; Export, Export All and Plate &gt; 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.
/// </summary>
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();
/// <summary>
/// The WinForms save dialog's file types in order: its 1-based filter index picks the format.
/// </summary>
public static IReadOnlyList<PlateExportFormat> DialogFormats { get; } = new[]
{
PlateExportFormat.Dxf,
PlateExportFormat.Image,
PlateExportFormat.PlacementText,
PlateExportFormat.Svg,
};
/// <summary>The format for the save dialog's 1-based <paramref name="filterIndex"/>.</summary>
public static PlateExportFormat FromFilterIndex(int filterIndex) =>
filterIndex >= 1 && filterIndex <= DialogFormats.Count
? DialogFormats[filterIndex - 1]
: throw new ArgumentException("Unsupported plate export format.", nameof(filterIndex));
/// <summary>
/// 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.
/// </summary>
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,
};
/// <summary>
/// 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".
/// </summary>
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}";
}
/// <summary>The DXF that View in CAD writes for the plate and opens: <see cref="SuggestedFileName"/>.dxf.</summary>
public static string ExternalCadFileName(string nestName, int plateNumber) =>
SuggestedFileName(nestName, plateNumber) + ".dxf";
/// <summary>Where View in CAD writes the plate's DXF: the user's temp folder.</summary>
public static string ExternalCadPath(string nestName, int plateNumber) =>
Path.Combine(Path.GetTempPath(), ExternalCadFileName(nestName, plateNumber));
/// <summary>
/// Writes <paramref name="plate"/> to <paramref name="destination"/> as
/// <paramref name="format"/>. SVG is written in <paramref name="units"/>; the image is drawn by
/// <paramref name="writeImage"/> (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.
/// </summary>
public static void Write(
Plate plate,
Units units,
string destination,
PlateExportFormat format,
Action<string> 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));
}
}
/// <summary>
/// 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.
/// </summary>
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));
}
}
/// <summary>
/// Export All: exports plates 0..<paramref name="plateCount"/>-1 in order through
/// <paramref name="exportPlate"/> (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.
/// </summary>
public static async Task<int> ExportAllAsync(int plateCount, Func<int, Task<bool>> exportPlate)
{
ArgumentNullException.ThrowIfNull(exportPlate);
var exported = 0;
for (var i = 0; i < plateCount; i++)
{
if (!await exportPlate(i))
break;
exported++;
}
return exported;
}
}
+25 -87
View File
@@ -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);
}
/// <summary>
@@ -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);
}
/// <summary>The name the machine config gives the nest's thickness ("16 ga"), or empty.</summary>
private static string MachineThicknessName(Data.MachineConfig machine, Nest nest) =>
Data.MachineCuttingDefaults.Find(machine, nest)?.Name?.Trim() ?? "";
/// <summary>
/// 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
/// <summary>
/// Temp DXF name for View in CAD; characters a file name cannot hold become underscores.
/// </summary>
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}",
+3 -3
View File
@@ -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)
{
+2 -12
View File
@@ -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
}
/// <summary>Best effort: a leftover temp report is harmless.</summary>
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);
}
}