Merge branch 'worktree-agent-aec6db3a2c226fa2a' into claude/avalonia-port-next-tasks-f1f6b6

This commit is contained in:
aj committed 2026-11-13 20:22:37 -05:00
commit e975244263
13 files changed
+1348 -102

No files matched your search

+356
View File
@@ -0,0 +1,356 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using OpenNest.Avalonia.Rendering;
using OpenNest.Data;
using OpenNest.Desktop;
namespace OpenNest.Avalonia
{
/// <summary>
/// File &gt; Export Plate, Export All Plates, Nest Report and Export Nest Report, and Plate &gt;
/// View in CAD, as the WinForms app has them: the same suggested file names and writers
/// (<see cref="PlateExport"/>, <see cref="NestReportExport"/>). A viewed report opens in the
/// system's PDF viewer, which saves and prints it; View in CAD opens the plate's DXF in the
/// program the system associates with .dxf.
/// </summary>
public partial class MainWindow
{
private static readonly FilePickerFileType DxfFiles =
new("DXF file") { Patterns = new[] { "*.dxf" } };
private static readonly FilePickerFileType ImageFiles =
new("Image as displayed") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg" } };
private static readonly FilePickerFileType PlacementFiles =
new("Locations and rotations") { Patterns = new[] { "*.txt" } };
private static readonly FilePickerFileType SvgFiles =
new("SVG file") { Patterns = new[] { "*.svg" } };
private static readonly FilePickerFileType PdfFiles =
new("PDF file") { Patterns = new[] { "*.pdf" } };
private void UpdateExportCommands(bool hasNest)
{
ExportPlateMenuItem.IsEnabled = hasNest;
ExportAllPlatesMenuItem.IsEnabled = hasNest;
ViewNestReportMenuItem.IsEnabled = hasNest;
ExportNestReportMenuItem.IsEnabled = hasNest;
ViewInCadMenuItem.IsEnabled = hasNest;
}
private async void OnExportPlate(object sender, RoutedEventArgs e)
{
var view = ActiveView;
if (view == null || !CanExport())
return;
writingFile = true;
try
{
await ExportPlateAsync(view, view.PlateIndex);
}
finally
{
writingFile = false;
}
}
/// <summary>
/// As WinForms: Export Plate once per plate, first to last, each plate shown while its
/// file is chosen; cancelling or a failure stops the rest.
/// </summary>
private async void OnExportAllPlates(object sender, RoutedEventArgs e)
{
var view = ActiveView;
if (view == null || !CanExport())
return;
writingFile = true;
try
{
var count = view.PlateCount;
var exported = await PlateExport.ExportAllAsync(count, i => ExportPlateAsync(view, i));
if (exported < count)
ShowNotice($"Exported {exported} of {count} plates of {view.Tab.Name}");
else
ShowNotice($"Exported all {count} plates of {view.Tab.Name}");
}
finally
{
writingFile = false;
}
}
// One export at a time; nesting does not block it, as its result is kept on this thread.
private bool CanExport()
{
if (!writingFile)
return true;
ShowNotice("A nest is already being saved or exported. Finish that before exporting.");
return false;
}
/// <summary>
/// Shows plate <paramref name="index"/>, asks for its file and writes it in the format the
/// file's extension names. True when it was written.
/// </summary>
private async Task<bool> ExportPlateAsync(NestView view, int index)
{
if (index < 0 || index >= view.PlateCount)
return false;
view.ShowPlate(index);
var plate = view.PlateView.Plate;
if (plate == null)
return false;
var nest = view.Tab.Document.Nest;
var number = index + 1;
var target = await PickPlateExportFileAsync(view.Tab, PlateExport.SuggestedFileName(nest.Name, number));
if (target == null)
return false;
var (path, format) = target.Value;
try
{
// The image is of the plate as shown, so show it again if another was picked meanwhile.
if (format == PlateExportFormat.Image && !ReferenceEquals(view.PlateView.Plate, plate))
view.ShowPlate(nest.Plates.IndexOf(plate));
PlateExport.Write(plate, nest.Units, path, format, file => ViewImage.Save(view.PlateView, file));
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "Export Plate",
$"Could not export plate to '{path}': {ex.Message}");
return false;
}
ShowNotice($"Exported plate {number} of {view.Tab.Name} to {path}");
return true;
}
// The file to export a plate to and its format, or null when cancelled or refused.
private async Task<(string Path, PlateExportFormat Format)?> PickPlateExportFileAsync(
NestTab tab, string suggestedFileName)
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Plate",
SuggestedFileName = suggestedFileName,
DefaultExtension = "dxf",
ShowOverwritePrompt = true,
FileTypeChoices = new[] { DxfFiles, ImageFiles, PlacementFiles, SvgFiles },
SuggestedStartLocation = await NestFolderAsync(tab),
});
if (file == null)
return null;
var path = file.TryGetLocalPath();
if (path == null)
{
ShowNotice("Plates can only be exported to a folder on this computer.");
return null;
}
// A name typed without an extension is the dialog's default type, DXF.
if (Path.GetExtension(path).Length == 0)
{
path += ".dxf";
if (File.Exists(path) && !await ConfirmDialog.AskAsync(this, "Export Plate",
$"{Path.GetFileName(path)} already exists. Replace it?", "Replace"))
return null;
}
if (PlateExport.FromPath(path) is not { } format)
{
await ConfirmDialog.ShowAsync(this, "Export Plate",
$"A plate cannot be exported as '{Path.GetFileName(path)}'. Choose a file name "
+ "ending in .dxf, .png, .jpg, .txt or .svg.");
return null;
}
return (path, format);
}
/// <summary>Writes the shown plate as a DXF in the temp folder and opens it in the system's CAD program.</summary>
private async void OnViewInCad(object sender, RoutedEventArgs e)
{
var view = ActiveView;
var plate = view?.PlateView.Plate;
if (plate == null)
return;
var nest = view.Tab.Document.Nest;
var path = PlateExport.ExternalCadPath(nest.Name, view.PlateIndex + 1);
Task<string> opened;
try
{
PlateExport.Write(plate, nest.Units, path, PlateExportFormat.Dxf, null);
opened = ExternalFileLauncher.OpenAsync(path);
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "View in CAD", $"Could not open '{path}' in CAD: {ex.Message}");
return;
}
ShowNotice($"Opening {path}");
await ReportOpenFailureAsync(opened, "View in CAD", $"Could not open '{path}' in CAD");
}
/// <summary>
/// Writes the nest report to a private temporary folder and opens it in the system's PDF
/// viewer, which saves and prints it. Viewed reports older than a day are removed.
/// </summary>
private async void OnViewNestReport(object sender, RoutedEventArgs e)
{
var tab = tabs.Active;
if (tab == null || !TryReportFileName(tab, out var fileName))
return;
if (!CanWrite(tab, "creating the report"))
return;
writingFile = true;
string path = null;
try
{
await MachinesLoaded;
// Auto Nest may have started on it while the machines were read.
if (IsNesting(tab, "creating the report"))
return;
path = NestReportExport.CreateViewPath(fileName);
await WriteReportAsync(tab, path);
await Task.Run(() => NestReportExport.DeleteViewedReportsBefore(
DateTime.UtcNow - NestReportExport.ViewedReportLifetime));
}
catch (Exception ex) when (IsReportFailure(ex))
{
if (path != null)
NestReportExport.DeleteReportFolder(path);
await ReportFailureAsync(tab, ex);
return;
}
finally
{
writingFile = false;
}
Task<string> opened;
try
{
opened = ExternalFileLauncher.OpenAsync(path);
}
catch (Exception ex)
{
await ConfirmDialog.ShowAsync(this, "Nest Report",
$"The nest report was written to '{path}', but it could not be opened: {ex.Message}");
return;
}
ShowNotice($"Opening the nest report for {tab.Name}");
await ReportOpenFailureAsync(opened, "Nest Report",
$"The nest report was written to '{path}', but it could not be opened");
}
/// <summary>Saves the nest report as a PDF file the user picks.</summary>
private async void OnExportNestReport(object sender, RoutedEventArgs e)
{
var tab = tabs.Active;
if (tab == null || !TryReportFileName(tab, out var fileName))
return;
if (!CanWrite(tab, "exporting the report"))
return;
writingFile = true;
try
{
var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Export Nest Report",
SuggestedFileName = fileName,
DefaultExtension = "pdf",
ShowOverwritePrompt = true,
FileTypeChoices = new[] { PdfFiles },
SuggestedStartLocation = await NestFolderAsync(tab),
});
if (file == null)
return;
var path = file.TryGetLocalPath();
if (path == null)
{
ShowNotice("The nest report can only be saved to a folder on this computer.");
return;
}
await MachinesLoaded;
// Auto Nest may have started on it while the file picker was open.
if (IsNesting(tab, "exporting the report"))
return;
await WriteReportAsync(tab, path);
ShowNotice($"Saved nest report to {path}");
}
catch (Exception ex) when (IsReportFailure(ex))
{
await ReportFailureAsync(tab, ex);
}
finally
{
writingFile = false;
}
}
// The report's file name, or false (after saying why) when the nest's name cannot be one.
private bool TryReportFileName(NestTab tab, out string fileName)
{
try
{
fileName = NestReportExport.ReportFileName(tab.Document.Nest);
return true;
}
catch (InvalidOperationException ex)
{
fileName = null;
_ = ConfirmDialog.ShowAsync(this, "Nest Report", $"Could not create the nest report: {ex.Message}");
return false;
}
}
/// <summary>
/// Captures the report on this thread, so the nest is read as it stands and is left
/// unchanged, then writes the detached snapshot off it.
/// </summary>
private static Task WriteReportAsync(NestTab tab, string path)
{
var snapshot = NestReportExport.Capture(
tab.Document.Nest,
DateTimeOffset.Now,
DesktopSettings.MachineConfigs,
UserSettingsFile.Read(UserSettingsFile.DefaultPath));
return Task.Run(() => NestReportExport.Write(snapshot, path));
}
private static bool IsReportFailure(Exception ex) =>
ex is InvalidOperationException
or NotSupportedException
or InvalidDataException
or IOException
or UnauthorizedAccessException
or ArgumentException;
private Task ReportFailureAsync(NestTab tab, Exception error) =>
ConfirmDialog.ShowAsync(this, "Nest Report",
$"Could not create the nest report for '{tab.Name}': {error.Message}");
// Says why the system could not open a file, once its opener has reported back.
private async Task ReportOpenFailureAsync(Task<string> opened, string title, string what)
{
string failure;
try
{
failure = await opened;
}
catch (Exception ex)
{
failure = ex.Message;
}
if (failure != null)
await ConfirmDialog.ShowAsync(this, title, $"{what}: {failure}");
}
private async Task<IStorageFolder> NestFolderAsync(NestTab tab)
{
var folder = tab.Path != null ? Path.GetDirectoryName(tab.Path) : null;
return folder != null ? await StorageProvider.TryGetFolderFromPathAsync(folder) : null;
}
}
}
+9
View File
@@ -13,6 +13,12 @@
<MenuItem x:Name="SaveAsMenuItem" Header="Save _As..." Click="OnSaveAs" />
<MenuItem x:Name="ExportNestMenuItem" Header="Save _Copy as .nest File..." Click="OnExportNest" />
<Separator />
<MenuItem x:Name="ExportPlateMenuItem" Header="_Export Plate..." Click="OnExportPlate" />
<MenuItem x:Name="ExportAllPlatesMenuItem" Header="Export All _Plates..." Click="OnExportAllPlates" />
<Separator />
<MenuItem x:Name="ViewNestReportMenuItem" Header="Nest _Report..." Click="OnViewNestReport" />
<MenuItem x:Name="ExportNestReportMenuItem" Header="Expor_t Nest Report..." Click="OnExportNestReport" />
<Separator />
<MenuItem x:Name="CloseMenuItem" Header="_Close" Click="OnCloseNest" />
<MenuItem x:Name="CloseAllMenuItem" Header="Close A_ll" Click="OnCloseAll" />
<Separator />
@@ -21,6 +27,9 @@
<MenuItem Header="_Nest">
<MenuItem x:Name="AutoNestMenuItem" Header="_Auto Nest" Click="OnAutoNest" />
</MenuItem>
<MenuItem Header="_Plate">
<MenuItem x:Name="ViewInCadMenuItem" Header="View in _CAD" Click="OnViewInCad" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem x:Name="DrawRapidsMenuItem" Header="Draw _Rapids" ToggleType="CheckBox"
Click="OnToggleDrawRapids" />
+1
View File
@@ -228,6 +228,7 @@ namespace OpenNest.Avalonia
SaveButton.IsEnabled = view != null;
SaveAsButton.IsEnabled = view != null;
CloseAllMenuItem.IsEnabled = view != null;
UpdateExportCommands(view != null);
Title = view == null
? "OpenNest (Avalonia prototype)"
: $"{view.Tab.Name} - OpenNest (Avalonia prototype)";
+59
View File
@@ -0,0 +1,59 @@
using System;
using System.IO;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using OpenNest.Avalonia.Controls;
using SkiaSharp;
namespace OpenNest.Avalonia.Rendering
{
/// <summary>
/// File &gt; Export's "image as displayed": the plate view drawn as it is shown, at the
/// screen's pixel density, by the same Skia drawing that paints it on screen. A .jpg or .jpeg
/// file is written as JPEG (as the WinForms export writes), any other as PNG.
/// </summary>
internal static class ViewImage
{
private const int JpegQuality = 90;
public static void Save(PlateCanvas canvas, string path)
{
ArgumentNullException.ThrowIfNull(canvas);
ArgumentException.ThrowIfNullOrWhiteSpace(path);
if (canvas.Bounds.Width < 1 || canvas.Bounds.Height < 1)
throw new InvalidOperationException("The plate view is not shown, so there is no image to save.");
var scaling = TopLevel.GetTopLevel(canvas)?.RenderScaling ?? 1;
var size = new PixelSize(
System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Width * scaling)),
System.Math.Max(1, (int)System.Math.Ceiling(canvas.Bounds.Height * scaling)));
using var bitmap = new RenderTargetBitmap(size, new Vector(96 * scaling, 96 * scaling));
// The canvas draws itself (it has no child controls) at the origin: RenderTargetBitmap
// .Render would place it where it sits in its parent, shifting it out of the image.
using (var context = bitmap.CreateDrawingContext())
canvas.Render(context);
if (!IsJpeg(path))
{
bitmap.Save(path);
return;
}
// Avalonia saves PNG only; Skia re-encodes it as JPEG.
using var png = new MemoryStream();
bitmap.Save(png);
png.Position = 0;
using var decoded = SKBitmap.Decode(png)
?? throw new InvalidOperationException("The plate view image could not be encoded.");
using var image = SKImage.FromBitmap(decoded);
using var jpeg = image.Encode(SKEncodedImageFormat.Jpeg, JpegQuality)
?? throw new InvalidOperationException("The plate view image could not be encoded as JPEG.");
using var file = File.Create(path);
jpeg.SaveTo(file);
}
private static bool IsJpeg(string path) =>
Path.GetExtension(path).ToLowerInvariant() is ".jpg" or ".jpeg";
}
}
@@ -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(),
};
}
+294
View File
@@ -0,0 +1,294 @@
using System.Globalization;
using System.Runtime.InteropServices;
using System.Xml.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
namespace OpenNest.Desktop.Tests;
public sealed class PlateExportTests : IDisposable
{
private readonly string folder = Path.Combine(Path.GetTempPath(), $"opennest-desktop-export-{Guid.NewGuid():N}");
public PlateExportTests() => Directory.CreateDirectory(folder);
public void Dispose() => Directory.Delete(folder, true);
[Theory]
[InlineData("job 1", 3, "job 1-P3")]
[InlineData("A/B:C*?", 1, "A_B_C__-P1")]
[InlineData("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("dir.v2/a.txt", PlateExportFormat.PlacementText)]
[InlineData("a.svg", PlateExportFormat.Svg)]
public void FromPath_ReadsTheExtension(string path, PlateExportFormat expected) =>
Assert.Equal(expected, PlateExport.FromPath(path));
[Theory]
[InlineData("a")]
[InlineData("a.pdf")]
[InlineData("a.bmp")]
[InlineData("a.dxf.bak")]
[InlineData("")]
[InlineData(null)]
public void FromPath_IsNullForOtherFiles(string path) => Assert.Null(PlateExport.FromPath(path));
[Theory]
[InlineData("en-US")]
[InlineData("fr-FR")]
public void PlacementText_GivesEachPartsSourceOriginAndDegrees(string culture)
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
var plate = CreateJob().Plates[0];
var path = Path.Combine(folder, "plate.txt");
PlateExport.Write(plate, Units.Inches, path, PlateExportFormat.PlacementText, null);
var expected = plate.Parts.Select(part =>
{
var pt = part.BaseDrawing.Source.Offset.Rotate(part.Rotation);
return string.Format("{0}|{1},{2}|{3}", part.BaseDrawing.Source.Path,
System.Math.Round(part.Location.X - pt.X, 8),
System.Math.Round(part.Location.Y - pt.Y, 8),
Angle.ToDegrees(part.Rotation));
});
Assert.Equal(expected, File.ReadAllLines(path));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
[Fact]
public void PlacementText_LessTheRotatedSourceOffset()
{
var drawing = new Drawing("d", Square(2)) { Source = { Path = "d.dxf", Offset = new Vector(1, 0) } };
var part = new Part(drawing, new Vector(10, 20));
part.Rotate(Angle.ToRadians(90));
var plate = new Plate(48, 96);
plate.Parts.Add(part);
var writer = new StringWriter { NewLine = "\n" };
PlateExport.WritePlacements(plate, writer);
// The source origin is the location less (1, 0) turned 90 degrees, (0, 1).
var fields = writer.ToString().TrimEnd('\n').Split('|');
Assert.Equal("d.dxf", fields[0]);
var xy = fields[1].Split(',').Select(double.Parse).ToArray();
Assert.Equal(part.Location.X, xy[0], 6);
Assert.Equal(part.Location.Y - 1, xy[1], 6);
Assert.Equal(90, double.Parse(fields[2]), 6);
}
[Fact]
public void Dxf_ReadsBackThroughTheCadImporter()
{
var plate = CreateJob().Plates[0];
var path = Path.Combine(folder, "plate.dxf");
PlateExport.Write(plate, Units.Inches, path, PlateExportFormat.Dxf, null);
var imported = CadImporter.Import(path);
var box = imported.Entities.Cast<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));
[Theory]
[InlineData(0, null)]
[InlineData(2, "'/tmp/job-P1.dxf' was not found.")]
[InlineData(3, "No program is set up to open 'job-P1.dxf'.")]
[InlineData(4, "The system could not open '/tmp/job-P1.dxf' (exit code 4).")]
public void FailureMessage_ExplainsTheOpenersExitCode(int exitCode, string expected) =>
Assert.Equal(expected, ExternalFileLauncher.FailureMessage(exitCode, "/tmp/job-P1.dxf"));
internal static Program Square(double size)
{
var program = new Program();
program.Codes.Add(new RapidMove(new Vector(0, 0)));
program.Codes.Add(new LinearMove(new Vector(size, 0)));
program.Codes.Add(new LinearMove(new Vector(size, size)));
program.Codes.Add(new LinearMove(new Vector(0, size)));
program.Codes.Add(new LinearMove(new Vector(0, 0)));
return program;
}
internal static Nest CreateJob()
{
var nest = new Nest("export job") { Units = Units.Inches };
var drawing = new Drawing("rect", Square(4));
drawing.Source.Path = "/drawings/rect.dxf";
drawing.Source.Offset = new Vector(1.234567891, -0.987654321);
drawing.Quantity.Required = 4;
nest.Drawings.Add(drawing);
var plate = new Plate(24, 48) { Quantity = 1 };
var part = new Part(drawing, new Vector(6.123456789, 8.987654321));
part.Rotate(Angle.ToRadians(15.25));
plate.Parts.Add(part);
plate.Parts.Add(new Part(drawing, new Vector(14.5, 16.25)));
nest.Plates.Add(plate);
return nest;
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
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));
}
/// <summary>
/// Opens <paramref name="path"/> and, where an opener program reports the result
/// (<c>xdg-open</c>, <c>open</c>), waits for it without blocking and returns why it failed,
/// or null once it opened. The Windows shell reports a failure by throwing instead. Throws
/// when the opener cannot start.
/// </summary>
public static async Task<string> OpenAsync(string path)
{
var info = StartInfo(path);
using var process = Process.Start(info);
if (info.UseShellExecute || process == null)
return null;
await process.WaitForExitAsync().ConfigureAwait(false);
return FailureMessage(process.ExitCode, path);
}
/// <summary>
/// What an <c>xdg-open</c> or <c>open</c> exit code means for <paramref name="path"/>, or
/// null for success (0).
/// </summary>
public static string FailureMessage(int exitCode, string path) => exitCode switch
{
0 => null,
2 => $"'{path}' was not found.",
3 => $"No program is set up to open '{System.IO.Path.GetFileName(path)}'.",
_ => $"The system could not open '{path}' (exit code {exitCode}).",
};
private static OSPlatform CurrentPlatform() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? OSPlatform.Windows
: RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? OSPlatform.OSX
: OSPlatform.Linux;
}
+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;
}
}
+170
View File
@@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using OpenNest.IO;
using OpenNest.Math;
namespace OpenNest.Desktop;
/// <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, .txt, .svg, any
/// case), or null for any other extension or none. Bitmap (.bmp) is not offered: no
/// cross-platform encoder writes it.
/// </summary>
public static PlateExportFormat? FromPath(string path) =>
Path.GetExtension(path ?? "").ToLowerInvariant() switch
{
".dxf" => PlateExportFormat.Dxf,
".png" or ".jpg" or ".jpeg" => PlateExportFormat.Image,
".txt" => PlateExportFormat.PlacementText,
".svg" => PlateExportFormat.Svg,
_ => null,
};
/// <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);
}
}
+10
View File
@@ -19,6 +19,16 @@ there is no separate direct-save command. Closing the window deletes its tempora
folder; a render failure opens no viewer and leaves no folder. Without the WebView2
Runtime the window still offers Save As.
The cross-platform desktop (`OpenNest.Avalonia`) shares the file name check,
capture and writer (`OpenNest.Desktop.NestReportExport`) but has no viewer of its
own: **File -> Nest Report...** writes the PDF to a private folder under the
system temp folder's `OpenNest/Reports/` and opens it in the system's PDF viewer
(`ExternalFileLauncher`: the Windows shell, `open` on macOS, `xdg-open`
elsewhere), which saves and prints it. Because that viewer's closing cannot be
seen, each view removes viewed-report folders older than a day instead. **File ->
Export Nest Report...** saves the PDF straight to a chosen file. Either refuses
while Auto Nest runs on the nest, checked before and after the file picker.
The same two calls back the command and any future integration:
```csharp