mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 09:15:06 -05:00
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:
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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 > 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;
|
||||
}
|
||||
@@ -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 > 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, "<nest name>.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;
|
||||
}
|
||||
}
|
||||
@@ -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 > 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 > 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.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user