diff --git a/OpenNest.Data/EngineSelectionSettings.cs b/OpenNest.Data/EngineSelectionSettings.cs
new file mode 100644
index 0000000..6d7bd42
--- /dev/null
+++ b/OpenNest.Data/EngineSelectionSettings.cs
@@ -0,0 +1,95 @@
+using System.Text.Json;
+
+namespace OpenNest.Data;
+
+///
+/// Last-used Auto Nest engine, stored separately from nest/plate defaults.
+/// Loading does not resolve the name: the host must finish plug-in discovery first.
+///
+public sealed class EngineSelectionSettings
+{
+ public const string DefaultEngineName = "Default";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public string EngineName { get; set; } = DefaultEngineName;
+
+ /// %APPDATA%\OpenNest\engine-selection.json.
+ public static string DefaultPath => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest", "engine-selection.json");
+
+ /// Missing, unreadable or corrupt settings safely use Default without writing.
+ public static EngineSelectionSettings Load(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ return new();
+
+ try
+ {
+ var settings = JsonSerializer.Deserialize(
+ File.ReadAllText(path), JsonOptions) ?? new();
+ settings.EngineName = NormalizeName(settings.EngineName);
+ return settings;
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
+ {
+ return new();
+ }
+ }
+
+ ///
+ /// Resolves against the host's selectable engines AFTER plug-in loading. A missing engine
+ /// returns Default plus a status-bar warning, without replacing the saved preference.
+ /// Names use registry casing so desktop combo-box selection remains exact.
+ ///
+ public string Resolve(IEnumerable availableEngineNames, out string? statusMessage)
+ {
+ ArgumentNullException.ThrowIfNull(availableEngineNames);
+ var requestedName = NormalizeName(EngineName);
+ var registeredName = availableEngineNames.FirstOrDefault(
+ name => string.Equals(name, requestedName, StringComparison.OrdinalIgnoreCase));
+ if (registeredName is not null)
+ {
+ statusMessage = null;
+ return registeredName;
+ }
+
+ statusMessage = $"Saved Auto Nest engine '{requestedName}' is unavailable. Using Default.";
+ return DefaultEngineName;
+ }
+
+ ///
+ /// Writes camelCase JSON, creating the parent directory and retrying IO collisions as
+ /// LocalJsonProvider does. The host handles a persistent write failure.
+ ///
+ public void Save(string path)
+ {
+ var json = JsonSerializer.Serialize(
+ new EngineSelectionSettings { EngineName = NormalizeName(EngineName) }, JsonOptions);
+ var directory = Path.GetDirectoryName(Path.GetFullPath(path));
+
+ for (var attempt = 0; attempt < 3; attempt++)
+ {
+ try
+ {
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(path, json);
+ return;
+ }
+ catch (IOException) when (attempt < 2)
+ {
+ Thread.Sleep(100);
+ }
+ }
+ }
+
+ private static string NormalizeName(string? name) =>
+ string.IsNullOrWhiteSpace(name) ? DefaultEngineName : name.Trim();
+}
diff --git a/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs b/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs
new file mode 100644
index 0000000..7457510
--- /dev/null
+++ b/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs
@@ -0,0 +1,178 @@
+using System.Text.Json;
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class EngineSelectionSettingsTests : IDisposable
+{
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
+ private readonly string _path;
+
+ public EngineSelectionSettingsTests()
+ {
+ Directory.CreateDirectory(_directory);
+ _path = Path.Combine(_directory, "engine-selection.json");
+ }
+
+ public void Dispose() => Directory.Delete(_directory, recursive: true);
+
+ [Fact]
+ public void DefaultPath_UsesApplicationDataOpenNestDirectory()
+ {
+ Assert.Equal(
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "OpenNest", "engine-selection.json"),
+ EngineSelectionSettings.DefaultPath);
+ }
+
+ [Fact]
+ public void SaveThenLoad_RoundTripsPluginNameAsCamelCaseJson()
+ {
+ new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
+
+ var loaded = EngineSelectionSettings.Load(_path);
+ using var json = JsonDocument.Parse(File.ReadAllText(_path));
+
+ Assert.Equal("Opus55NestingEngine", loaded.EngineName);
+ Assert.Equal("Opus55NestingEngine", json.RootElement.GetProperty("engineName").GetString());
+ }
+
+ [Fact]
+ public void Save_OverwritesPreviousSelection()
+ {
+ new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
+ new EngineSelectionSettings { EngineName = "Strip" }.Save(_path);
+
+ Assert.Equal("Strip", EngineSelectionSettings.Load(_path).EngineName);
+ }
+
+ [Fact]
+ public void Save_CreatesMissingParentDirectory()
+ {
+ var path = Path.Combine(_directory, "nested", "engine-selection.json");
+
+ new EngineSelectionSettings { EngineName = "Strip" }.Save(path);
+
+ Assert.Equal("Strip", EngineSelectionSettings.Load(path).EngineName);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Save_EmptySelectionWritesDefault(string? engineName)
+ {
+ new EngineSelectionSettings { EngineName = engineName! }.Save(_path);
+
+ using var json = JsonDocument.Parse(File.ReadAllText(_path));
+ Assert.Equal("Default", json.RootElement.GetProperty("engineName").GetString());
+ }
+
+ [Fact]
+ public void Load_MissingFileReturnsDefaultWithoutCreatingFile()
+ {
+ var loaded = EngineSelectionSettings.Load(_path);
+
+ Assert.Equal("Default", loaded.EngineName);
+ Assert.False(File.Exists(_path));
+ Assert.Equal("Default", loaded.Resolve(new[] { "Default" }, out var message));
+ Assert.Null(message);
+ }
+
+ [Theory]
+ [InlineData("{ broken json")]
+ [InlineData("null")]
+ [InlineData("[]")]
+ [InlineData("{}")]
+ [InlineData("{\"engineName\":null}")]
+ [InlineData("{\"engineName\":\" \"}")]
+ [InlineData("{\"engineName\":42}")]
+ public void Load_CorruptOrEmptySettingsReturnsDefault(string json)
+ {
+ File.WriteAllText(_path, json);
+
+ Assert.Equal("Default", EngineSelectionSettings.Load(_path).EngineName);
+ Assert.Equal(json, File.ReadAllText(_path));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("\0")]
+ public void Load_InvalidPathReturnsDefault(string? path)
+ {
+ Assert.Equal("Default", EngineSelectionSettings.Load(path!).EngineName);
+ }
+
+ [Fact]
+ public void Load_UnreadablePathReturnsDefault()
+ {
+ Directory.CreateDirectory(_path);
+
+ Assert.Equal("Default", EngineSelectionSettings.Load(_path).EngineName);
+ }
+
+ [Fact]
+ public void Load_IgnoresUnknownFieldsAndTrimsSelection()
+ {
+ File.WriteAllText(_path,
+ """{"EngineName":" Opus55NestingEngine ","futureSetting":true}""");
+
+ Assert.Equal("Opus55NestingEngine", EngineSelectionSettings.Load(_path).EngineName);
+ }
+
+ [Theory]
+ [InlineData("Default")]
+ [InlineData("Strip")]
+ [InlineData("Vertical Remnant")]
+ [InlineData("Horizontal Remnant")]
+ [InlineData("Opus55NestingEngine")]
+ public void Resolve_AvailableSelectionUsesRegisteredCasingWithoutWarning(string engineName)
+ {
+ var settings = new EngineSelectionSettings { EngineName = engineName.ToLowerInvariant() };
+
+ var resolved = settings.Resolve(new[] { "Default", engineName }, out var message);
+
+ Assert.Equal(engineName, resolved);
+ Assert.Null(message);
+ }
+
+ [Fact]
+ public void Resolve_MissingPluginFallsBackWithVisibleMessageWithoutOverwritingSavedChoice()
+ {
+ new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
+ var settings = EngineSelectionSettings.Load(_path);
+
+ var resolved = settings.Resolve(new[] { "Default", "Strip" }, out var message);
+
+ Assert.Equal("Default", resolved);
+ Assert.Equal("Saved Auto Nest engine 'Opus55NestingEngine' is unavailable. Using Default.", message);
+ Assert.Equal("Opus55NestingEngine", settings.EngineName);
+ Assert.Equal("Opus55NestingEngine", EngineSelectionSettings.Load(_path).EngineName);
+ }
+
+ [Fact]
+ public void Load_DoesNotResolveBeforePluginDiscovery()
+ {
+ new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path);
+ var settings = EngineSelectionSettings.Load(_path);
+ var availableEngines = new List { "Default" };
+
+ // The host loads settings independently, then supplies the completed registry.
+ availableEngines.Add("Opus55NestingEngine");
+ var resolved = settings.Resolve(availableEngines, out var message);
+
+ Assert.Equal("Opus55NestingEngine", resolved);
+ Assert.Null(message);
+ }
+
+ [Fact]
+ public void Save_UnwritablePathReportsFailureToCaller()
+ {
+ File.WriteAllText(_path, "not a directory");
+ var path = Path.Combine(_path, "engine-selection.json");
+
+ Assert.Throws(() => new EngineSelectionSettings().Save(path));
+ }
+}
diff --git a/OpenNest/Forms/EngineSelection.cs b/OpenNest/Forms/EngineSelection.cs
index 04a1c50..0062bcf 100644
--- a/OpenNest/Forms/EngineSelection.cs
+++ b/OpenNest/Forms/EngineSelection.cs
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
using System.Linq;
+using OpenNest.Data;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
@@ -14,13 +17,14 @@ namespace OpenNest.Forms
///
public static class EngineSelection
{
- public const string DefaultEngineName = "Default";
+ public const string DefaultEngineName = EngineSelectionSettings.DefaultEngineName;
/// Registered jobs engine deliberately kept out of the desktop combo.
public const string HiddenEngineName = "StockLadder";
private static string engineName = DefaultEngineName;
+ /// Current selection. User changes are persisted for the next launch.
public static string EngineName
{
get { return engineName; }
@@ -29,9 +33,32 @@ namespace OpenNest.Forms
engineName = string.IsNullOrWhiteSpace(value)
? DefaultEngineName
: value.Trim();
+
+ try
+ {
+ new EngineSelectionSettings { EngineName = engineName }
+ .Save(EngineSelectionSettings.DefaultPath);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // A read-only profile must not prevent selecting an engine for this session.
+ Debug.WriteLine($"[EngineSelection] Could not save engine selection: {ex.Message}");
+ }
}
}
+ ///
+ /// Call after NestingEngineRegistry.LoadPlugins and before populating the desktop combo.
+ /// Returns a status-bar warning when the saved engine is unavailable, otherwise empty.
+ /// Loading never saves the fallback over a temporarily missing plug-in preference.
+ ///
+ public static string LoadSavedSelection()
+ {
+ var settings = EngineSelectionSettings.Load(EngineSelectionSettings.DefaultPath);
+ engineName = settings.Resolve(UiEngineNames, out var statusMessage);
+ return statusMessage ?? string.Empty;
+ }
+
/// Desktop combo contents: registered jobs engines minus StockLadder.
public static IEnumerable UiEngineNames =>
NestingEngineRegistry.AvailableEngines
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index b34d66e..2a43ede 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -78,6 +78,9 @@ namespace OpenNest.Forms
// contract and expose a public parameterless constructor.
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
NestingEngineRegistry.LoadPlugins(enginesDir);
+ var engineWarning = EngineSelection.LoadSavedSelection();
+ if (!string.IsNullOrEmpty(engineWarning))
+ Shown += (_, _) => statusLabel1.Text = engineWarning;
OptionsForm.ApplyDisabledStrategies();
ColorSchemeRegistry.ApplyActiveFromSettings();