diff --git a/OpenNest.Data/NestDefaults.cs b/OpenNest.Data/NestDefaults.cs
index 7fd24e07..c94c11df 100644
--- a/OpenNest.Data/NestDefaults.cs
+++ b/OpenNest.Data/NestDefaults.cs
@@ -49,7 +49,7 @@ public sealed class NestDefaults
///
/// The built-in defaults used when no file exists and for every field
/// that is missing or invalid. Matches the historical
- /// MainForm.CreateDefaultNest values (units default to Inches; callers
+ /// File > New values (units default to Inches; callers
/// may override from their own settings).
///
public static NestDefaults Fallback => new();
diff --git a/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs b/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs
index 58008339..7d345a1b 100644
--- a/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/MainFormCharacterizationTests.cs
@@ -18,15 +18,7 @@ public class MainFormCharacterizationTests
[InlineData(900, "N26-322")]
[InlineData(27000, "N26-3222")]
public void NestNamesUseTwoDigitYearAndPaddedBase30Sequence(int id, string expected) =>
- StaTestThread.Run(() =>
- {
- using var host = new Host();
- var name = (string)typeof(MainForm)
- .GetMethod("GetNestName", BindingFlags.Instance | BindingFlags.NonPublic)!
- .Invoke(host, [new DateTime(2026, 10, 17), id])!;
-
- Assert.Equal(expected, name);
- }, TimeSpan.FromMinutes(1), "The STA test did not complete.");
+ Assert.Equal(expected, NewNestFactory.GetNestName(new DateTime(2026, 10, 17), id));
[Fact]
public void BusyGuardsDifferInWhichOperationsTheyCount() => StaTestThread.Run(() =>
diff --git a/OpenNest/Forms/BomImportForm.cs b/OpenNest/Forms/BomImportForm.cs
index d10b9fd4..c6166378 100644
--- a/OpenNest/Forms/BomImportForm.cs
+++ b/OpenNest/Forms/BomImportForm.cs
@@ -32,7 +32,7 @@ namespace OpenNest.Forms
dgvParts.AutoGenerateColumns = false;
dgvParts.DataSource = _parts;
_groupSettings = new Dictionary();
- _defaults = MainForm.LoadSavedNestDefaults(out _);
+ _defaults = MachineSettings.LoadSavedNestDefaults(out _);
ApplyDefaults();
}
@@ -414,7 +414,7 @@ namespace OpenNest.Forms
var nestsCreated = 0;
var importErrors = new List();
// Read when the nests are created, warning (once per session) like New does.
- var clamps = MainForm.LoadClampSettings(this);
+ var clamps = MachineSettings.LoadClampSettings(this);
foreach (var group in groups)
{
diff --git a/OpenNest/Forms/MachineSettings.cs b/OpenNest/Forms/MachineSettings.cs
new file mode 100644
index 00000000..28a7e6f6
--- /dev/null
+++ b/OpenNest/Forms/MachineSettings.cs
@@ -0,0 +1,83 @@
+using System.Windows.Forms;
+using OpenNest.Data;
+
+namespace OpenNest.Forms
+{
+ ///
+ /// The desktop entry points for the persisted machine clamp settings and nest defaults.
+ /// The warning variants report an unusable file once per session.
+ ///
+ internal static class MachineSettings
+ {
+ private static bool clampSettingsWarned;
+ private static bool defaultsWarned;
+
+ ///
+ /// Loads the machine clamp settings for creating new nests (New, BOM import); an
+ /// unreadable or partly invalid file warns once per session, because the new nest's
+ /// clamps may not match the machine.
+ ///
+ public static ClampSettings LoadClampSettings(IWin32Window owner)
+ {
+ var settings = LoadSavedClampSettings(out var status);
+
+ if (
+ status is ClampSettingsStatus.Invalid or ClampSettingsStatus.Partial
+ && !clampSettingsWarned
+ )
+ {
+ clampSettingsWarned = true;
+ var problem =
+ status == ClampSettingsStatus.Invalid
+ ? "could not be read, so new nests have no clamps"
+ : "has invalid values that were ignored, so new nests may be missing clamps";
+ MessageBox.Show(
+ owner,
+ $"The clamp settings file {problem}:\n{ClampSettings.DefaultPath}\n\nCheck the clamps in Nest > Clamps and use Save as Machine Defaults to rewrite the file.",
+ "Clamps",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning
+ );
+ }
+ return settings;
+ }
+
+ /// The single desktop entry point for the persisted machine clamp settings.
+ public static ClampSettings LoadSavedClampSettings(out ClampSettingsStatus status) =>
+ ClampSettings.Load(ClampSettings.DefaultPath, out status);
+
+ ///
+ /// Loads the persisted nest defaults (see );
+ /// a corrupt or unreadable file warns once per session.
+ ///
+ public static NestDefaults LoadNestDefaults()
+ {
+ var defaults = LoadSavedNestDefaults(out var status);
+
+ if (status == NestDefaultsStatus.Invalid && !defaultsWarned)
+ {
+ defaultsWarned = true;
+ MessageBox.Show(
+ $"The nest defaults file could not be read:\n{NestDefaults.DefaultPath}\n\nBuilt-in defaults will be used. Re-save your defaults from Tools > Nest Defaults.",
+ "Nest Defaults",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning
+ );
+ }
+ return defaults;
+ }
+
+ ///
+ /// The single desktop entry point for persisted nest defaults. Units
+ /// fall back to the legacy DefaultUnit setting whenever the file is
+ /// missing or unusable or has no valid unit, so New, BOM import and
+ /// the defaults dialog always agree.
+ ///
+ public static NestDefaults LoadSavedNestDefaults(out NestDefaultsStatus status) =>
+ NestDefaults.Load(
+ NestDefaults.DefaultPath,
+ Properties.Settings.Default.DefaultUnit,
+ out status
+ );
+ }
+}
diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs
index 7155706f..c3aa3fe3 100644
--- a/OpenNest/Forms/MainForm.Designer.cs
+++ b/OpenNest/Forms/MainForm.Designer.cs
@@ -21,7 +21,7 @@ namespace OpenNest.Forms
components.Dispose();
}
if (disposing)
- updater?.Dispose();
+ updaterStatus?.Dispose();
base.Dispose(disposing);
}
diff --git a/OpenNest/Forms/MainForm.File.cs b/OpenNest/Forms/MainForm.File.cs
index 96a877da..3681c68d 100644
--- a/OpenNest/Forms/MainForm.File.cs
+++ b/OpenNest/Forms/MainForm.File.cs
@@ -6,116 +6,11 @@ using OpenNest.Data;
using OpenNest.Engine.BestFit;
using OpenNest.Forms.Commands;
using OpenNest.IO;
-using OpenNest.Properties;
namespace OpenNest.Forms
{
public partial class MainForm
{
- private Nest CreateDefaultNest()
- {
- var nest = new Nest();
- LoadNestDefaults().ApplyTo(nest);
- // After the defaults so the rail is converted to the new nest's units.
- LoadClampSettings(this).ApplyToNewNest(nest);
- return nest;
- }
-
- ///
- /// Loads the machine clamp settings for creating new nests (New, BOM import); an
- /// unreadable or partly invalid file warns once per session, because the new nest's
- /// clamps may not match the machine.
- ///
- internal static ClampSettings LoadClampSettings(IWin32Window owner)
- {
- var settings = LoadSavedClampSettings(out var status);
-
- if (
- status is ClampSettingsStatus.Invalid or ClampSettingsStatus.Partial
- && !clampSettingsWarned
- )
- {
- clampSettingsWarned = true;
- var problem =
- status == ClampSettingsStatus.Invalid
- ? "could not be read, so new nests have no clamps"
- : "has invalid values that were ignored, so new nests may be missing clamps";
- MessageBox.Show(
- owner,
- $"The clamp settings file {problem}:\n{ClampSettings.DefaultPath}\n\nCheck the clamps in Nest > Clamps and use Save as Machine Defaults to rewrite the file.",
- "Clamps",
- MessageBoxButtons.OK,
- MessageBoxIcon.Warning
- );
- }
- return settings;
- }
-
- private static bool clampSettingsWarned;
-
- /// The single desktop entry point for the persisted machine clamp settings.
- internal static ClampSettings LoadSavedClampSettings(out ClampSettingsStatus status) =>
- ClampSettings.Load(ClampSettings.DefaultPath, out status);
-
- ///
- /// Loads the persisted nest defaults (see );
- /// a corrupt or unreadable file warns once per session.
- ///
- private NestDefaults LoadNestDefaults()
- {
- var defaults = LoadSavedNestDefaults(out var status);
-
- if (status == NestDefaultsStatus.Invalid && !defaultsWarned)
- {
- defaultsWarned = true;
- MessageBox.Show(
- $"The nest defaults file could not be read:\n{NestDefaults.DefaultPath}\n\nBuilt-in defaults will be used. Re-save your defaults from Tools > Nest Defaults.",
- "Nest Defaults",
- MessageBoxButtons.OK,
- MessageBoxIcon.Warning
- );
- }
- return defaults;
- }
-
- private bool defaultsWarned;
-
- ///
- /// The single desktop entry point for persisted nest defaults. Units
- /// fall back to the legacy DefaultUnit setting whenever the file is
- /// missing or unusable or has no valid unit, so New, BOM import and
- /// the defaults dialog always agree.
- ///
- internal static NestDefaults LoadSavedNestDefaults(out NestDefaultsStatus status) =>
- NestDefaults.Load(
- NestDefaults.DefaultPath,
- Properties.Settings.Default.DefaultUnit,
- out status
- );
-
- private string GetNestName(DateTime date, int id)
- {
- var year = (date.Year % 100).ToString("D2");
- var seq = ToBase36(id).PadLeft(3, '2');
-
- return $"N{year}-{seq}";
- }
-
- private static string ToBase36(int value)
- {
- const string chars = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
- if (value == 0)
- return chars[0].ToString();
-
- var result = "";
- while (value > 0)
- {
- result = chars[value % chars.Length] + result;
- value /= chars.Length;
- }
- return result;
- }
-
private void LoadNest(Nest nest, FormWindowState windowState = FormWindowState.Maximized,
Guid remoteId = default, string serverUrl = null, int remoteRevision = 0)
{
@@ -142,19 +37,7 @@ namespace OpenNest.Forms
var windowState =
ActiveMdiChild != null ? ActiveMdiChild.WindowState : FormWindowState.Maximized;
- var nest = CreateDefaultNest();
-
- nest.DateCreated = DateTime.Now;
- nest.DateLastModified = DateTime.Now;
-
- if (DateTime.Now.Date != Settings.Default.LastNestCreatedDate.Date)
- {
- Settings.Default.NestNumber = 1;
- Settings.Default.LastNestCreatedDate = DateTime.Now.Date;
- }
-
- nest.Name = GetNestName(DateTime.Now, Settings.Default.NestNumber++);
- LoadNest(nest, windowState);
+ LoadNest(NewNestFactory.Create(this), windowState);
}
private async void Open_Click(object sender, EventArgs e)
diff --git a/OpenNest/Forms/MainForm.Nest.cs b/OpenNest/Forms/MainForm.Nest.cs
index f723d2a5..0af7145b 100644
--- a/OpenNest/Forms/MainForm.Nest.cs
+++ b/OpenNest/Forms/MainForm.Nest.cs
@@ -77,7 +77,7 @@ namespace OpenNest.Forms
return;
}
- var defaults = LoadSavedClampSettings(out _);
+ var defaults = MachineSettings.LoadSavedClampSettings(out _);
using var form = new ClampsForm(nest.PlateDefaults.ClampRail, nest.Units, defaults);
if (form.ShowDialog(this) != DialogResult.OK)
return;
diff --git a/OpenNest/Forms/MainForm.Tools.cs b/OpenNest/Forms/MainForm.Tools.cs
index 8972fc8d..7f09b75f 100644
--- a/OpenNest/Forms/MainForm.Tools.cs
+++ b/OpenNest/Forms/MainForm.Tools.cs
@@ -177,7 +177,7 @@ namespace OpenNest.Forms
private void NestDefaults_Click(object sender, EventArgs e)
{
- using (var form = new NestDefaultsForm(LoadSavedNestDefaults(out _)))
+ using (var form = new NestDefaultsForm(MachineSettings.LoadSavedNestDefaults(out _)))
{
if (form.ShowDialog(this) == DialogResult.OK)
form.GetDefaults().Save(NestDefaults.DefaultPath);
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index c56457f8..2cfefe12 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -3,10 +3,8 @@ using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
-using System.Reflection;
using System.Threading;
using System.Windows.Forms;
-using OpenNest.Data;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Forms.Commands;
@@ -110,7 +108,7 @@ namespace OpenNest.Forms
mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExport), exportNestMenu);
mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExit), storageModeMenu);
UpdateOverlapMenu();
- LoadSettings();
+ MainWindowSettings.Load(this);
var renderer = new ToolStripRenderer(ToolbarTheme.Toolbar);
menuStrip1.Renderer = renderer;
@@ -124,7 +122,7 @@ namespace OpenNest.Forms
this.SetBevel(false);
AllowDrop = true;
- LoadPosts();
+ LoadPosts(PostProcessorLoader.Load(PostProcessorLoader.DefaultDirectory));
EnableCheck();
UpdateStatus();
@@ -146,84 +144,11 @@ namespace OpenNest.Forms
engineComboBox.SelectedItem = EngineSelection.EngineName;
engineComboBox.SelectedIndexChanged += EngineComboBox_SelectedIndexChanged;
- InitializeUpdater();
+ updaterStatus = new UpdaterStatus(this, statusStrip1, statusLabel1.Font);
+ updaterStatus.Start();
}
- private global::OpenNest.UpdaterService updater;
- private ToolStripStatusLabel updateStatusLabel;
-
- ///
- /// Starts background update checks against the per-machine feed URL
- /// (Options dialog; blank disables checks). Never restarts on its
- /// own -- shows a clickable status item once an update is ready and
- /// restarts only when the user clicks it.
- ///
- private void InitializeUpdater()
- {
- updateStatusLabel = new ToolStripStatusLabel
- {
- Visible = false,
- ForeColor = Color.DarkGreen,
- Font = new Font(statusLabel1.Font, FontStyle.Bold),
- };
- updateStatusLabel.Click += (_, _) =>
- {
- if (!updater.HasPendingUpdate)
- return;
-
- var result = MessageBox.Show(
- this,
- "An OpenNest update has been downloaded. Restart now to apply it? Any unsaved nest will be lost.",
- "Update ready",
- MessageBoxButtons.YesNo,
- MessageBoxIcon.Question
- );
- if (result == DialogResult.Yes)
- updater.ApplyAndRestart();
- };
- statusStrip1.Items.Add(updateStatusLabel);
-
- updater = new global::OpenNest.UpdaterService();
- updater.UpdateReady += version =>
- {
- if (IsHandleCreated)
- BeginInvoke(new System.Action(() => ShowUpdateReady(version)));
- };
- updater.Start();
- }
-
- private void ShowUpdateReady(string version)
- {
- updateStatusLabel.Text = $"Update {version} ready - click to restart";
- updateStatusLabel.Visible = true;
- }
-
-
- private void LoadSettings()
- {
- var screen = Screen.PrimaryScreen.Bounds;
-
- if (screen.Contains(Settings.Default.MainWindowLocation))
- Location = Settings.Default.MainWindowLocation;
-
- if (
- Settings.Default.MainWindowSize.Width <= screen.Width
- && Settings.Default.MainWindowSize.Height <= screen.Height
- )
- {
- Size = Settings.Default.MainWindowSize;
- }
-
- WindowState = Settings.Default.MainWindowState;
- }
-
- private void SaveSettings()
- {
- Settings.Default.MainWindowLocation = Location;
- Settings.Default.MainWindowSize = Size;
- Settings.Default.MainWindowState = WindowState;
- Settings.Default.Save();
- }
+ private UpdaterStatus updaterStatus;
private static ToolStripButton CreateActionButton(string name, string text, Image image,
EventHandler click)
@@ -242,40 +167,27 @@ namespace OpenNest.Forms
return button;
}
- private void LoadPosts()
+ private void LoadPosts(PostProcessorLoader.Result posts)
{
- var exepath = Assembly.GetEntryAssembly().Location;
- var postprocessordir = Path.Combine(Path.GetDirectoryName(exepath), "Posts");
-
- if (Directory.Exists(postprocessordir))
+ foreach (var postProcessor in posts.PostProcessors)
{
- foreach (var file in Directory.GetFiles(postprocessordir, "*.dll"))
- {
- var types = Assembly.LoadFile(file).GetTypes();
+ var postProcessorMenuItem = new ToolStripMenuItem(postProcessor.Name);
+ postProcessorMenuItem.Tag = postProcessor;
+ postProcessorMenuItem.Click += PostProcessor_Click;
+ mnuNestPost.DropDownItems.Add(postProcessorMenuItem);
- foreach (var type in types)
- {
- if (type.GetInterfaces().Contains(typeof(IPostProcessor)) == false)
- continue;
-
- var postProcessor = Activator.CreateInstance(type) as IPostProcessor;
- var postProcessorMenuItem = new ToolStripMenuItem(postProcessor.Name);
- postProcessorMenuItem.Tag = postProcessor;
- postProcessorMenuItem.Click += PostProcessor_Click;
- mnuNestPost.DropDownItems.Add(postProcessorMenuItem);
-
- var toolbarPost = new ToolStripMenuItem(postProcessor.Name) { Tag = postProcessor };
- toolbarPost.Click += PostProcessor_Click;
- postButton.DropDownItems.Add(toolbarPost);
-
- if (postProcessor is IMaterialProvidingPostProcessor materialProvider)
- PostProcessorMaterials.AddFrom(materialProvider);
- }
- }
+ var toolbarPost = new ToolStripMenuItem(postProcessor.Name) { Tag = postProcessor };
+ toolbarPost.Click += PostProcessor_Click;
+ postButton.DropDownItems.Add(toolbarPost);
}
mnuNestPost.Visible = mnuNestPost.DropDownItems.Count > 0;
postButton.Visible = postButton.DropDownItems.Count > 0;
+
+ if (posts.Failures.Count > 0)
+ Shown += (_, _) => MessageBox.Show(this,
+ "These post processors could not be loaded:\n\n" + string.Join("\n", posts.Failures),
+ "Post Processors", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
#region Overrides
@@ -344,52 +256,12 @@ namespace OpenNest.Forms
{
base.OnLoad(e);
- MigrateNestTemplate();
+ MainWindowSettings.MigrateNestTemplate();
if (Settings.Default.CreateNewNestOnOpen)
New_Click(this, new EventArgs());
}
- ///
- /// One-time upgrade: converts a legacy .nstdot nest template
- /// (NestTemplatePath setting) into defaults.json, then clears the
- /// setting so the template mechanism is never consulted again.
- ///
- private void MigrateNestTemplate()
- {
- var templatePath = Settings.Default.NestTemplatePath;
- if (string.IsNullOrWhiteSpace(templatePath))
- return;
-
- // A failed conversion keeps the setting populated so the user
- // can still find their template file.
- var converted = true;
- if (File.Exists(templatePath) && !File.Exists(NestDefaults.DefaultPath))
- {
- try
- {
- var nest = new NestReader(templatePath) { BestFitStore = BestFitCacheStore.Shared }.Read();
- NestDefaults.FromNest(nest).Save(NestDefaults.DefaultPath);
- }
- catch (Exception ex)
- {
- converted = false;
- MessageBox.Show(
- $"The nest template could not be converted to the new defaults file:\n{templatePath}\n\n{ex.Message}\n\nIt will no longer be loaded automatically. Set defaults under Tools > Nest Defaults.",
- "Nest Template",
- MessageBoxButtons.OK,
- MessageBoxIcon.Warning
- );
- }
- }
-
- if (!converted)
- return;
-
- Settings.Default.NestTemplatePath = "";
- Settings.Default.Save();
- }
-
protected override void OnDragEnter(DragEventArgs e)
{
base.OnDragEnter(e);
@@ -430,7 +302,7 @@ namespace OpenNest.Forms
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
base.OnClosing(e);
- SaveSettings();
+ MainWindowSettings.Save(this);
}
#endregion
diff --git a/OpenNest/Forms/MainWindowSettings.cs b/OpenNest/Forms/MainWindowSettings.cs
new file mode 100644
index 00000000..10efcd1f
--- /dev/null
+++ b/OpenNest/Forms/MainWindowSettings.cs
@@ -0,0 +1,81 @@
+using System;
+using System.IO;
+using System.Windows.Forms;
+using OpenNest.Data;
+using OpenNest.Engine.BestFit;
+using OpenNest.IO;
+using OpenNest.Properties;
+
+namespace OpenNest.Forms
+{
+ /// The main window's persisted placement and its one-time settings upgrades.
+ internal static class MainWindowSettings
+ {
+ /// Restores the saved placement when it still fits the primary screen.
+ public static void Load(Form form)
+ {
+ var screen = Screen.PrimaryScreen.Bounds;
+
+ if (screen.Contains(Settings.Default.MainWindowLocation))
+ form.Location = Settings.Default.MainWindowLocation;
+
+ if (
+ Settings.Default.MainWindowSize.Width <= screen.Width
+ && Settings.Default.MainWindowSize.Height <= screen.Height
+ )
+ {
+ form.Size = Settings.Default.MainWindowSize;
+ }
+
+ form.WindowState = Settings.Default.MainWindowState;
+ }
+
+ public static void Save(Form form)
+ {
+ Settings.Default.MainWindowLocation = form.Location;
+ Settings.Default.MainWindowSize = form.Size;
+ Settings.Default.MainWindowState = form.WindowState;
+ Settings.Default.Save();
+ }
+
+ ///
+ /// One-time upgrade: converts a legacy .nstdot nest template
+ /// (NestTemplatePath setting) into defaults.json, then clears the
+ /// setting so the template mechanism is never consulted again.
+ ///
+ public static void MigrateNestTemplate()
+ {
+ var templatePath = Settings.Default.NestTemplatePath;
+ if (string.IsNullOrWhiteSpace(templatePath))
+ return;
+
+ // A failed conversion keeps the setting populated so the user
+ // can still find their template file.
+ var converted = true;
+ if (File.Exists(templatePath) && !File.Exists(NestDefaults.DefaultPath))
+ {
+ try
+ {
+ var nest = new NestReader(templatePath) { BestFitStore = BestFitCacheStore.Shared }.Read();
+ NestDefaults.FromNest(nest).Save(NestDefaults.DefaultPath);
+ }
+ catch (Exception ex)
+ {
+ converted = false;
+ MessageBox.Show(
+ $"The nest template could not be converted to the new defaults file:\n{templatePath}\n\n{ex.Message}\n\nIt will no longer be loaded automatically. Set defaults under Tools > Nest Defaults.",
+ "Nest Template",
+ MessageBoxButtons.OK,
+ MessageBoxIcon.Warning
+ );
+ }
+ }
+
+ if (!converted)
+ return;
+
+ Settings.Default.NestTemplatePath = "";
+ Settings.Default.Save();
+ }
+ }
+}
diff --git a/OpenNest/Forms/NewNestFactory.cs b/OpenNest/Forms/NewNestFactory.cs
new file mode 100644
index 00000000..e42e08f4
--- /dev/null
+++ b/OpenNest/Forms/NewNestFactory.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Windows.Forms;
+using OpenNest.Properties;
+
+namespace OpenNest.Forms
+{
+ ///
+ /// Creates File > New nests: machine defaults and clamps, then the next name in the
+ /// day's sequence ( restarts at 1 each day).
+ ///
+ internal static class NewNestFactory
+ {
+ private const string Base30Digits = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
+
+ public static Nest Create(IWin32Window owner)
+ {
+ var nest = CreateDefaultNest(owner);
+ var now = DateTime.Now;
+
+ nest.DateCreated = now;
+ nest.DateLastModified = now;
+
+ if (now.Date != Settings.Default.LastNestCreatedDate.Date)
+ {
+ Settings.Default.NestNumber = 1;
+ Settings.Default.LastNestCreatedDate = now.Date;
+ }
+
+ nest.Name = GetNestName(now, Settings.Default.NestNumber++);
+ return nest;
+ }
+
+ private static Nest CreateDefaultNest(IWin32Window owner)
+ {
+ var nest = new Nest();
+ MachineSettings.LoadNestDefaults().ApplyTo(nest);
+ // After the defaults so the rail is converted to the new nest's units.
+ MachineSettings.LoadClampSettings(owner).ApplyToNewNest(nest);
+ return nest;
+ }
+
+ ///
+ /// "N" + two-digit year + the sequence in an unambiguous base-30 alphabet, padded to
+ /// three digits with its zero digit ('2').
+ ///
+ public static string GetNestName(DateTime date, int id)
+ {
+ var year = (date.Year % 100).ToString("D2");
+ var seq = ToBase30(id).PadLeft(3, Base30Digits[0]);
+
+ return $"N{year}-{seq}";
+ }
+
+ private static string ToBase30(int value)
+ {
+ if (value == 0)
+ return Base30Digits[0].ToString();
+
+ var result = "";
+ while (value > 0)
+ {
+ result = Base30Digits[value % Base30Digits.Length] + result;
+ value /= Base30Digits.Length;
+ }
+ return result;
+ }
+ }
+}
diff --git a/OpenNest/Forms/UpdaterStatus.cs b/OpenNest/Forms/UpdaterStatus.cs
new file mode 100644
index 00000000..c639d82d
--- /dev/null
+++ b/OpenNest/Forms/UpdaterStatus.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+
+namespace OpenNest.Forms
+{
+ ///
+ /// Background update checks against the per-machine feed URL (Options dialog; blank
+ /// disables checks) and the status-bar item that announces a downloaded update. Never
+ /// restarts on its own: it restarts only when the user clicks the item and confirms.
+ ///
+ internal sealed class UpdaterStatus : IDisposable
+ {
+ private readonly Form owner;
+ private readonly UpdaterService updater = new();
+ private readonly ToolStripStatusLabel label;
+
+ public UpdaterStatus(Form owner, StatusStrip statusStrip, Font baseFont)
+ {
+ this.owner = owner;
+ label = new ToolStripStatusLabel
+ {
+ Visible = false,
+ ForeColor = Color.DarkGreen,
+ Font = new Font(baseFont, FontStyle.Bold),
+ };
+ label.Click += Label_Click;
+ statusStrip.Items.Add(label);
+
+ updater.UpdateReady += version =>
+ {
+ if (owner.IsHandleCreated)
+ owner.BeginInvoke(new Action(() => ShowUpdateReady(version)));
+ };
+ }
+
+ public void Start() => updater.Start();
+
+ public void Dispose() => updater.Dispose();
+
+ private void Label_Click(object sender, EventArgs e)
+ {
+ if (!updater.HasPendingUpdate)
+ return;
+
+ var result = MessageBox.Show(
+ owner,
+ "An OpenNest update has been downloaded. Restart now to apply it? Any unsaved nest will be lost.",
+ "Update ready",
+ MessageBoxButtons.YesNo,
+ MessageBoxIcon.Question
+ );
+ if (result == DialogResult.Yes)
+ updater.ApplyAndRestart();
+ }
+
+ private void ShowUpdateReady(string version)
+ {
+ label.Text = $"Update {version} ready - click to restart";
+ label.Visible = true;
+ }
+ }
+}
diff --git a/OpenNest/PostProcessorLoader.cs b/OpenNest/PostProcessorLoader.cs
new file mode 100644
index 00000000..dc9a0144
--- /dev/null
+++ b/OpenNest/PostProcessorLoader.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+
+namespace OpenNest
+{
+ ///
+ /// Discovers post processors in the desktop's Posts/ directory and registers the
+ /// materials they provide. Each DLL loads in isolation: one that fails is reported in
+ /// instead of stopping the others or startup.
+ ///
+ public static class PostProcessorLoader
+ {
+ public sealed record Result(IReadOnlyList PostProcessors, IReadOnlyList Failures);
+
+ public static string DefaultDirectory =>
+ Path.Combine(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), "Posts");
+
+ public static Result Load(string directory)
+ {
+ var postProcessors = new List();
+ var failures = new List();
+ if (!Directory.Exists(directory))
+ return new Result(postProcessors, failures);
+
+ foreach (var file in Directory.GetFiles(directory, "*.dll"))
+ {
+ try
+ {
+ // Instantiate the whole DLL before registering any of it.
+ var loaded = Assembly.LoadFile(file).GetTypes()
+ .Where(type => type.GetInterfaces().Contains(typeof(IPostProcessor)))
+ .Select(type => (IPostProcessor)Activator.CreateInstance(type))
+ .ToList();
+ foreach (var postProcessor in loaded)
+ {
+ postProcessors.Add(postProcessor);
+ if (postProcessor is IMaterialProvidingPostProcessor materialProvider)
+ PostProcessorMaterials.AddFrom(materialProvider);
+ }
+ }
+ catch (Exception ex)
+ {
+ var reason = ex is TargetInvocationException { InnerException: { } inner } ? inner : ex;
+ failures.Add($"{Path.GetFileName(file)}: {reason.Message}");
+ }
+ }
+ return new Result(postProcessors, failures);
+ }
+ }
+}