diff --git a/OpenNest.Data/DrawingLibraryEditSession.cs b/OpenNest.Data/DrawingLibraryEditSession.cs
new file mode 100644
index 00000000..07b66ba8
--- /dev/null
+++ b/OpenNest.Data/DrawingLibraryEditSession.cs
@@ -0,0 +1,67 @@
+namespace OpenNest.Data;
+
+///
+/// Edits one listed library drawing's notes and nesting settings in place: the save is a
+/// metadata-only revision guarded by the revision it was listed with, so the package (and
+/// its hash) stays the same. A stale revision raises
+/// and changes nothing.
+///
+public sealed class DrawingLibraryEditSession
+{
+ private readonly IDrawingLibraryStore _library;
+
+ public DrawingLibraryEditSession(IDrawingLibraryStore library, DrawingLibraryRecord listed, string savedBy)
+ {
+ _library = library ?? throw new ArgumentNullException(nameof(library));
+ Listed = listed ?? throw new ArgumentNullException(nameof(listed));
+ if (listed.Id == Guid.Empty || listed.Revision < 1)
+ throw new ArgumentException("Choose a listed library drawing to edit.", nameof(listed));
+ if (listed.Archived)
+ throw new ArgumentException("An archived library drawing cannot be edited. Restore it first.", nameof(listed));
+ SavedBy = savedBy ?? "";
+ }
+
+ /// The record as it was listed; its revision guards the save.
+ public DrawingLibraryRecord Listed { get; }
+
+ public string SavedBy { get; }
+
+ /// The record to save: the listed details with new notes and settings.
+ public DrawingLibraryRecord Build(string notes, DrawingNestingSettings settings) => new()
+ {
+ Id = Listed.Id,
+ Name = Listed.Name,
+ Customer = Listed.Customer,
+ MaterialName = Listed.MaterialName,
+ MaterialGrade = Listed.MaterialGrade,
+ Thickness = Listed.Thickness,
+ Units = Listed.Units,
+ CutType = Listed.CutType,
+ Notes = notes ?? "",
+ Settings = settings ?? throw new ArgumentNullException(nameof(settings)),
+ SavedBy = SavedBy,
+ };
+
+ /// The first rule the edit breaks, or null when it may be saved.
+ public string? GetError(string notes, DrawingNestingSettings settings) =>
+ DrawingLibraryRules.GetMetadataError(Build(notes, settings));
+
+ /// True when saving would change nothing.
+ public bool IsUnchanged(string notes, DrawingNestingSettings settings) =>
+ (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
+
+ /// Saves the edit as the next revision; returns the stored record.
+ public async Task SaveAsync(string notes, DrawingNestingSettings settings,
+ CancellationToken cancellationToken = default)
+ {
+ var record = Build(notes, settings);
+ var error = DrawingLibraryRules.GetMetadataError(record);
+ if (error != null)
+ throw new ArgumentException(error, nameof(settings));
+ var saved = await _library.UpdateMetadataAsync(Listed.Id, record, Listed.Revision, cancellationToken);
+ if (!string.Equals(saved.PackageSha256, Listed.PackageSha256, StringComparison.OrdinalIgnoreCase)
+ || !Equals(saved.Settings, record.Settings) || saved.Notes != record.Notes)
+ throw new IOException("The nest server stored a different library drawing than was edited. Reload the library.");
+ return saved;
+ }
+}
diff --git a/OpenNest.Data/DrawingLibraryQuery.cs b/OpenNest.Data/DrawingLibraryQuery.cs
index 65503c5d..6006fc29 100644
--- a/OpenNest.Data/DrawingLibraryQuery.cs
+++ b/OpenNest.Data/DrawingLibraryQuery.cs
@@ -32,6 +32,9 @@ public sealed class DrawingLibraryQuery
public CutType? CutType { get; init; }
+ /// Only drawings whose Cut first setting equals this; null means any.
+ public bool? CutFirst { get; init; }
+
public bool IncludeArchived { get; init; }
/// Column to order by; ties are broken by id in the same direction.
diff --git a/OpenNest.Data/DrawingLibraryRecord.cs b/OpenNest.Data/DrawingLibraryRecord.cs
index ecc88a86..bd83bc5e 100644
--- a/OpenNest.Data/DrawingLibraryRecord.cs
+++ b/OpenNest.Data/DrawingLibraryRecord.cs
@@ -33,6 +33,12 @@ public sealed class DrawingLibraryRecord
public string Notes { get; set; } = "";
+ ///
+ /// Nesting settings applied when the drawing is added to a nest. Not part of the
+ /// package, so changing them adds a revision that reuses the same package.
+ ///
+ public DrawingNestingSettings Settings { get; set; } = new();
+
/// Hidden from browsing by default; still readable by id.
public bool Archived { get; set; }
diff --git a/OpenNest.Data/DrawingLibraryRules.cs b/OpenNest.Data/DrawingLibraryRules.cs
index c23a42d4..5aebd728 100644
--- a/OpenNest.Data/DrawingLibraryRules.cs
+++ b/OpenNest.Data/DrawingLibraryRules.cs
@@ -43,7 +43,8 @@ public static class DrawingLibraryRules
? "thickness must be a finite number, zero or greater."
: null)
?? (!Enum.IsDefined(record.Units) ? "units must be a supported unit name." : null)
- ?? (!Enum.IsDefined(record.CutType) ? "cutType must be Laser, Waterjet or Plasma." : null);
+ ?? (!Enum.IsDefined(record.CutType) ? "cutType must be Laser, Waterjet or Plasma." : null)
+ ?? (record.Settings is null ? "settings is required." : record.Settings.GetValidationError());
}
/// The first package rule breaks, or null when it may be stored.
diff --git a/OpenNest.Data/DrawingLibrarySaveSession.cs b/OpenNest.Data/DrawingLibrarySaveSession.cs
index 1ac6f6fb..a2789a32 100644
--- a/OpenNest.Data/DrawingLibrarySaveSession.cs
+++ b/OpenNest.Data/DrawingLibrarySaveSession.cs
@@ -137,8 +137,9 @@ public sealed class DrawingLibrarySaveSession
///
/// Library details to start from: the drawing's name, its customer (or the nest's), the
/// nest's material and grade (or the drawing's when the nest has none), the nest's
- /// thickness and units, and the linked revision's cut type and notes when the drawing
- /// came from this server's library, otherwise .
+ /// thickness and units, the linked revision's cut type and notes when the drawing
+ /// came from this server's library, otherwise , and the
+ /// drawing's own nesting settings.
///
public DrawingLibraryRecord Prefill(CutType lastCutType, string savedBy)
{
@@ -154,6 +155,7 @@ public sealed class DrawingLibrarySaveSession
Units = Nest.Units,
CutType = link?.CutType ?? (Enum.IsDefined(lastCutType) ? lastCutType : CutType.Laser),
Notes = link?.Notes ?? "",
+ Settings = DrawingNestingSettings.FromDrawing(Drawing),
SavedBy = savedBy ?? "",
};
}
@@ -351,6 +353,7 @@ public sealed class DrawingLibrarySaveSession
Units = record.Units,
CutType = record.CutType,
Notes = record.Notes,
+ Settings = record.Settings,
Archived = record.Archived,
PackageSha256 = record.PackageSha256,
PackageSize = record.PackageSize,
@@ -391,7 +394,8 @@ public sealed class DrawingLibrarySaveSession
&& record.Name == Metadata.Name && record.Customer == Metadata.Customer
&& record.MaterialName == Metadata.MaterialName && record.MaterialGrade == Metadata.MaterialGrade
&& record.Thickness == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness)
- && record.Units == Metadata.Units && record.CutType == Metadata.CutType && record.Notes == Metadata.Notes;
+ && record.Units == Metadata.Units && record.CutType == Metadata.CutType && record.Notes == Metadata.Notes
+ && Equals(record.Settings, Metadata.Settings);
/// True when a new request asks for exactly this save again.
public bool Matches(DrawingLibraryRecord metadata, byte[] package, DrawingLibraryRecord? revise) =>
@@ -400,6 +404,7 @@ public sealed class DrawingLibrarySaveSession
&& metadata.Name == Metadata.Name && metadata.Customer == Metadata.Customer
&& metadata.MaterialName == Metadata.MaterialName && metadata.MaterialGrade == Metadata.MaterialGrade
&& DrawingLibraryRules.NormalizeThickness(metadata.Thickness) == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness)
- && metadata.Units == Metadata.Units && metadata.CutType == Metadata.CutType && metadata.Notes == Metadata.Notes;
+ && metadata.Units == Metadata.Units && metadata.CutType == Metadata.CutType && metadata.Notes == Metadata.Notes
+ && Equals(metadata.Settings, Metadata.Settings);
}
}
diff --git a/OpenNest.Data/DrawingNestingSettings.cs b/OpenNest.Data/DrawingNestingSettings.cs
new file mode 100644
index 00000000..f1dcf7cc
--- /dev/null
+++ b/OpenNest.Data/DrawingNestingSettings.cs
@@ -0,0 +1,79 @@
+using System.Drawing;
+using OpenNest;
+
+namespace OpenNest.Data;
+
+///
+/// Per-drawing nesting settings a library record owns. They are structured server fields,
+/// not package content: canonical packages leave them at their defaults, and adding a library
+/// drawing to a nest applies these values. Editing them adds a revision without new geometry.
+/// A record, so two settings are equal when every value is.
+///
+public sealed record DrawingNestingSettings
+{
+ /// Automatic sequencing cuts this drawing's parts first (for example test squares).
+ public bool CutFirst { get; init; }
+
+ public int Priority { get; init; }
+
+ /// Rotation step in radians; zero means the engine's default.
+ public double StepAngle { get; init; }
+
+ /// Rotation start angle in radians.
+ public double StartAngle { get; init; }
+
+ /// Rotation end angle in radians.
+ public double EndAngle { get; init; }
+
+ public bool Allow180Equivalent { get; init; }
+
+ ///
+ /// Display color as a 32-bit ARGB value. A fully transparent value (alpha 0, the default)
+ /// means no stored color: the drawing gets the next part color when it is added.
+ ///
+ public int ColorArgb { get; init; }
+
+ /// The settings currently has.
+ public static DrawingNestingSettings FromDrawing(Drawing drawing)
+ {
+ ArgumentNullException.ThrowIfNull(drawing);
+ var constraints = drawing.Constraints ?? new NestConstraints();
+ return new DrawingNestingSettings
+ {
+ CutFirst = drawing.CutFirst,
+ Priority = drawing.Priority,
+ StepAngle = constraints.StepAngle,
+ StartAngle = constraints.StartAngle,
+ EndAngle = constraints.EndAngle,
+ Allow180Equivalent = constraints.Allow180Equivalent,
+ ColorArgb = drawing.Color.ToArgb(),
+ };
+ }
+
+ /// Writes these settings onto .
+ public void ApplyTo(Drawing drawing)
+ {
+ ArgumentNullException.ThrowIfNull(drawing);
+ drawing.CutFirst = CutFirst;
+ drawing.Priority = Priority;
+ drawing.Constraints = new NestConstraints
+ {
+ StepAngle = StepAngle,
+ StartAngle = StartAngle,
+ EndAngle = EndAngle,
+ Allow180Equivalent = Allow180Equivalent,
+ };
+ var color = Color.FromArgb(ColorArgb);
+ drawing.Color = color.A == 0 ? Drawing.GetNextColor() : color;
+ }
+
+ /// The first rule these settings break, or null when they may be saved.
+ public string? GetValidationError()
+ {
+ if (!double.IsFinite(StepAngle) || !double.IsFinite(StartAngle) || !double.IsFinite(EndAngle))
+ return "settings angles must be finite numbers.";
+ // Ranges are not checked: nest drawings carry whatever their files hold, and
+ // saving one to the library must not be refused for it.
+ return StepAngle < 0 ? "settings.stepAngle must be zero or greater." : null;
+ }
+}
diff --git a/OpenNest.Data/IDrawingLibraryStore.cs b/OpenNest.Data/IDrawingLibraryStore.cs
index 0814fac0..f300b299 100644
--- a/OpenNest.Data/IDrawingLibraryStore.cs
+++ b/OpenNest.Data/IDrawingLibraryStore.cs
@@ -20,4 +20,11 @@ public interface IDrawingLibraryStore : IDrawingLibrary
/// Saves the next revision when is current.
Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
int expectedRevision, CancellationToken cancellationToken = default);
+
+ ///
+ /// Saves new details and nesting settings, keeping the package, as the next revision
+ /// when is current.
+ ///
+ Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
+ CancellationToken cancellationToken = default);
}
diff --git a/OpenNest.Data/LibraryDrawingInsert.cs b/OpenNest.Data/LibraryDrawingInsert.cs
index 808c56ef..c307299b 100644
--- a/OpenNest.Data/LibraryDrawingInsert.cs
+++ b/OpenNest.Data/LibraryDrawingInsert.cs
@@ -136,10 +136,12 @@ public sealed class LibraryDrawingInsert
warnings.Add($"{label} differs from this nest: {string.Join("; ", differences)}. The drawing is "
+ "added, but its drawing cut pins are dropped and the nest's cutting settings apply.");
+ // The record owns these fields; whatever the package holds for them is ignored.
var drawing = package.Drawing;
drawing.Name = record.Name;
drawing.Customer = record.Customer;
drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
+ (record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing);
drawing.Quantity.Required = quantity;
drawing.Library = link;
// Pins were authored for the library material and thickness; elsewhere the nest's
diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs
index 7e764de8..213d02fd 100644
--- a/OpenNest.Data/RemoteDrawingLibrary.cs
+++ b/OpenNest.Data/RemoteDrawingLibrary.cs
@@ -272,6 +272,8 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
text.Append("&thickness=").Append(DrawingLibraryRules.NormalizeThickness(thickness).ToString("0.####", CultureInfo.InvariantCulture));
if (query.CutType is { } cutType)
text.Append("&cutType=").Append(cutType.ToString());
+ if (query.CutFirst is { } cutFirst)
+ text.Append("&cutFirst=").Append(cutFirst ? "true" : "false");
if (query.IncludeArchived)
text.Append("&includeArchived=true");
return text.ToString();
diff --git a/OpenNest.IO.Tests/DrawingPackageTests.cs b/OpenNest.IO.Tests/DrawingPackageTests.cs
index 28430835..a46e158d 100644
--- a/OpenNest.IO.Tests/DrawingPackageTests.cs
+++ b/OpenNest.IO.Tests/DrawingPackageTests.cs
@@ -21,10 +21,10 @@ public class DrawingPackageTests
Assert.NotSame(drawing, copy);
Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(copy.Program));
Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(copy.Program));
- Assert.Equal(drawing.Name, copy.Name);
- Assert.Equal(drawing.Customer, copy.Customer);
- Assert.Equal((drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density),
- (copy.Material.Name, copy.Material.Grade, copy.Material.Density));
+ // Record-owned fields are left at their defaults; the library record supplies them.
+ Assert.Equal(("", ""), (copy.Name, copy.Customer));
+ Assert.Equal(("", "", drawing.Material.Density), (copy.Material.Name, copy.Material.Grade, copy.Material.Density));
+ Assert.Equal((0, false), (copy.Priority, copy.CutFirst));
Assert.Equal(drawing.Source.Offset, copy.Source.Offset);
Assert.Equal(drawing.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)),
copy.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)));
@@ -73,11 +73,25 @@ public class DrawingPackageTests
var baseline = Sha(DrawingPackage.Write(drawing, Units.Inches));
Assert.NotEqual(baseline, Sha(DrawingPackage.Write(drawing, Units.Millimeters)));
- drawing.Customer = "Other customer";
- var renamed = Sha(DrawingPackage.Write(drawing, Units.Inches));
- Assert.NotEqual(baseline, renamed);
drawing.Bends[0].EndPoint = new Vector(10, 3.5);
- Assert.NotEqual(renamed, Sha(DrawingPackage.Write(drawing, Units.Inches)));
+ Assert.NotEqual(baseline, Sha(DrawingPackage.Write(drawing, Units.Inches)));
+ }
+
+ [Fact]
+ public void Package_RecordOwnedFields_DoNotChangeTheHash()
+ {
+ var drawing = DrawingPackageFixture.Build();
+ var baseline = Sha(DrawingPackage.Write(drawing, Units.Inches));
+
+ drawing.Name = "Renamed bracket";
+ drawing.Customer = "Other customer";
+ drawing.Material = new Material("Other Steel") { Grade = "Grade B", Density = drawing.Material.Density };
+ drawing.Color = System.Drawing.Color.FromArgb(255, 1, 2, 3);
+ drawing.Priority = 9;
+ drawing.CutFirst = true;
+ drawing.Constraints = new NestConstraints { StepAngle = 0.5, StartAngle = 0.25, EndAngle = 3, Allow180Equivalent = true };
+
+ Assert.Equal(baseline, Sha(DrawingPackage.Write(drawing, Units.Inches)));
}
[Fact]
diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs
index 81c0e189..dd92a7e5 100644
--- a/OpenNest.IO/DrawingPackage.cs
+++ b/OpenNest.IO/DrawingPackage.cs
@@ -15,6 +15,14 @@ namespace OpenNest.IO
/// Packages are canonical, so the same drawing content always has the same bytes and
/// SHA-256: archive timestamps and nest dates are fixed, the nest-specific required
/// quantity is zero, and derived best-fit caches and library links are left out.
+ ///
+ /// A package holds geometry and geometry-bound data only (program, source entities,
+ /// suppressed IDs, bends, cut pins, breakoffs, material density). Fields the library
+ /// record owns are written as their defaults: the nest and drawing names, customer,
+ /// material name and grade, color, priority, cut first and rotation constraints.
+ /// Renaming a library drawing or changing its nesting settings therefore never changes
+ /// its package hash; adding it to a nest applies the record's values instead.
+ ///
///
public static class DrawingPackage
{
@@ -22,6 +30,9 @@ namespace OpenNest.IO
private static readonly DateTimeOffset EntryTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);
private const string FixedDate = "2000-01-01T00:00:00.0000000";
+ // Removed so readers fall back to their defaults; the library record owns these values.
+ private static readonly string[] RecordOwnedDrawingFields = { "customer", "color", "priority", "cutFirst", "constraints" };
+
/// Packages without changing it.
public static byte[] Write(Drawing drawing, Units units)
{
@@ -120,6 +131,7 @@ namespace OpenNest.IO
var root = JsonNode.Parse(json)!.AsObject();
root["dateCreated"] = FixedDate;
root["dateLastModified"] = FixedDate;
+ root["name"] = "";
if (root["drawings"] is JsonArray drawings)
{
foreach (var drawing in drawings.OfType())
@@ -128,6 +140,15 @@ namespace OpenNest.IO
quantity["required"] = 0;
// A library link is nest-side provenance; the server's record describes the package.
drawing.Remove("library");
+ // Record-owned fields: the library record supplies them on insert.
+ drawing["name"] = "";
+ foreach (var field in RecordOwnedDrawingFields)
+ drawing.Remove(field);
+ if (drawing["material"] is JsonObject material)
+ {
+ material["name"] = "";
+ material["grade"] = "";
+ }
}
}
diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
index e03075bd..87ea1aae 100644
--- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
@@ -21,7 +21,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
Assert.Equal(before, NestDigest(factory.DatabasePath));
- Assert.Equal(4L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -31,30 +31,56 @@ public sealed class DrawingLibraryDatabaseTests
Assert.Equal(2, upgraded.List().Count);
}
+ [Fact]
+ public void V4Upgrade_ReplacesTheDrawingTablesAndKeepsNests()
+ {
+ using var factory = new ServerFactory();
+ CreateVersion3Database(factory.DatabasePath);
+ // The v4 layout: one column set with the package blob copied into every revision.
+ Execute(factory.DatabasePath, """
+ CREATE TABLE drawings (id TEXT NOT NULL, revision INTEGER NOT NULL, name TEXT NOT NULL, package BLOB NOT NULL, PRIMARY KEY (id));
+ CREATE TABLE drawing_revisions (id TEXT NOT NULL, revision INTEGER NOT NULL, name TEXT NOT NULL, package BLOB NOT NULL);
+ INSERT INTO drawings VALUES ('00000000-0000-0000-0000-000000000001', 1, 'Old', x'504B');
+ INSERT INTO drawing_revisions SELECT * FROM drawings;
+ PRAGMA user_version=4;
+ """);
+ var before = NestDigest(factory.DatabasePath);
+
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+
+ Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(before, NestDigest(factory.DatabasePath));
+ Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items);
+ var record = Record();
+ database.InsertDrawing(record.Id, record, Package());
+ Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_packages"));
+ }
+
[Fact]
public void NewerSchema_IsRefused()
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
- Execute(factory.DatabasePath, "PRAGMA user_version=5");
+ Execute(factory.DatabasePath, "PRAGMA user_version=6");
Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
[Fact]
- public void FailedV4Migration_RollsBackAndCanRestart()
+ public void FailedDrawingMigration_RollsBackAndCanRestart()
{
using var factory = new ServerFactory();
CreateVersion3Database(factory.DatabasePath);
var before = NestDigest(factory.DatabasePath);
- Execute(factory.DatabasePath, "CREATE TABLE drawing_revisions (fixture TEXT)");
+ // A table holding an index's name makes the drawing schema fail part-way through.
+ Execute(factory.DatabasePath, "CREATE TABLE ix_drawings_savedAt (fixture TEXT)");
Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
Assert.Equal(3L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name='drawings'"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
- Execute(factory.DatabasePath, "DROP TABLE drawing_revisions");
+ Execute(factory.DatabasePath, "DROP TABLE ix_drawings_savedAt");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
Assert.Equal(2, database.List().Count);
}
@@ -176,7 +202,7 @@ public sealed class DrawingLibraryDatabaseTests
}
}
- /// A real schema-v3 file: v4 is additive, so dropping its tables restores v3 exactly.
+ /// A real schema-v3 file: the drawing tables are additive, so dropping them restores v3 exactly.
private static void CreateVersion3Database(string path)
{
using (var database = new NestDatabase(path, pooling: false))
@@ -191,7 +217,7 @@ public sealed class DrawingLibraryDatabaseTests
database.SetDeleted(a.Id, false, 3);
}
- Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; PRAGMA user_version=3;");
+ Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3;");
}
private static string NestDigest(string path)
diff --git a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs
index 21c4ff2d..66e8de16 100644
--- a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs
+++ b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs
@@ -79,7 +79,9 @@ public sealed class DrawingLibraryEndToEndTests
Assert.Empty(contents.Warnings);
Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(contents.Drawing.Program));
Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(contents.Drawing.Program));
- Assert.Equal("Synthetic bracket", contents.Drawing.Name);
+ // The record owns the name; the canonical package leaves it blank.
+ Assert.Equal("Synthetic bracket", found.Name);
+ Assert.Equal("", contents.Drawing.Name);
}
[Fact]
diff --git a/OpenNest.Server.Tests/DrawingLibraryStorageTests.cs b/OpenNest.Server.Tests/DrawingLibraryStorageTests.cs
new file mode 100644
index 00000000..01eb5433
--- /dev/null
+++ b/OpenNest.Server.Tests/DrawingLibraryStorageTests.cs
@@ -0,0 +1,171 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+using static OpenNest.Server.Tests.DrawingLibraryFixtures;
+
+namespace OpenNest.Server.Tests;
+
+/// Content-addressed packages and structured nesting settings (schema v5).
+public sealed class DrawingLibraryStorageTests
+{
+ private static readonly DrawingNestingSettings TestSquare = new()
+ {
+ CutFirst = true,
+ Priority = 4,
+ StepAngle = System.Math.PI / 2,
+ StartAngle = 0,
+ EndAngle = System.Math.PI,
+ Allow180Equivalent = true,
+ ColorArgb = unchecked((int)0xFF336699),
+ };
+
+ [Fact]
+ public async Task IdenticalPackages_AreStoredOnce()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var package = Package("shared");
+
+ var first = await Upload(client, Record("First"), package);
+ var second = await Upload(client, Record("Second"), package);
+
+ Assert.Equal(first.PackageSha256, second.PackageSha256);
+ Assert.Equal(1L, Count(factory, "drawing_packages"));
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{second.Id}/file"));
+ }
+
+ [Fact]
+ public async Task MetadataSettingsAndArchiveRevisions_AddNoPackage()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var package = Package();
+ await Upload(client, record, package);
+
+ record.Notes = "Edited notes";
+ var notes = await PutMetadata(client, record, 1);
+ record.Settings = TestSquare;
+ var settings = await PutMetadata(client, record, 2);
+ using (var archive = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"3\"")))
+ Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
+
+ Assert.Equal((2, 3), (notes.Revision, settings.Revision));
+ Assert.Equal(1L, Count(factory, "drawing_packages"));
+ Assert.Equal(4L, Count(factory, "drawing_revisions"));
+ for (var revision = 1; revision <= 4; revision++)
+ Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/{revision}/file"));
+ }
+
+ [Fact]
+ public async Task NewGeometry_AddsAPackage_AndEarlierRevisionsKeepTheirs()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var original = Package("original");
+ var changed = Package("changed");
+ await Upload(client, record, original);
+
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, changed)));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(2L, Count(factory, "drawing_packages"));
+ Assert.Equal(original, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/1/file"));
+ Assert.Equal(changed, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file"));
+ Assert.Equal(changed, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file"));
+ }
+
+ [Fact]
+ public async Task Settings_RoundTripThroughEveryRead()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ record.Settings = TestSquare;
+
+ var saved = await Upload(client, record, Package());
+ var current = await client.GetFromJsonAsync($"/api/drawings/{record.Id}", Options);
+ var listed = Assert.Single((await client.GetFromJsonAsync("/api/drawings/query", Options))!.Items);
+ var history = Assert.Single((await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options))!.Items);
+
+ Assert.Equal(TestSquare, saved.Settings);
+ Assert.Equal(TestSquare, current!.Settings);
+ Assert.Equal(TestSquare, listed.Settings);
+ Assert.Equal(TestSquare, history.Settings);
+ }
+
+ [Fact]
+ public async Task SettingsOnlyEdit_IsARevision_AndRepeatingItIsANoOp()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var record = Record();
+ var saved = await Upload(client, record, Package());
+
+ record.Settings = TestSquare;
+ var edited = await PutMetadata(client, record, 1);
+ var repeated = await PutMetadata(client, record, 2);
+
+ Assert.Equal(2, edited.Revision);
+ Assert.Equal(TestSquare, edited.Settings);
+ Assert.Equal(saved.PackageSha256, edited.PackageSha256);
+ Assert.Equal(2, repeated.Revision);
+ var history = (await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options))!;
+ Assert.Equal(new[] { true, false }, history.Items.Select(r => r.Settings.CutFirst));
+ }
+
+ [Fact]
+ public async Task Query_FiltersByCutFirst()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var square = Record("Test square");
+ square.Settings = new DrawingNestingSettings { CutFirst = true };
+ await Upload(client, square, Package("square"));
+ await Upload(client, Record("Bracket"), Package("bracket"));
+
+ var cutFirst = (await client.GetFromJsonAsync("/api/drawings/query?cutFirst=true", Options))!;
+ var others = (await client.GetFromJsonAsync("/api/drawings/query?cutFirst=false", Options))!;
+ using var invalid = await client.GetAsync("/api/drawings/query?cutFirst=yes");
+
+ Assert.Equal("Test square", Assert.Single(cutFirst.Items).Name);
+ Assert.Equal("Bracket", Assert.Single(others.Items).Name);
+ Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("""{"name":"x","thickness":0,"units":"Inches","cutType":"Laser","settings":{"stepAngle":-1}}""")]
+ [InlineData("""{"name":"x","thickness":0,"units":"Inches","cutType":"Laser","settings":null}""")]
+ [InlineData("""{"name":"x","thickness":0,"units":"Inches","cutType":"Laser","settings":{"priority":"high"}}""")]
+ public async Task InvalidSettings_AreRejectedWithNoState(string metadata)
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var id = Guid.NewGuid();
+
+ using var response = await client.PostAsync("/api/drawings", Body(metadata.Replace("{\"name\"", $"{{\"id\":\"{id}\",\"name\""), Package()));
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.Equal(0L, Count(factory, "drawing_packages"));
+ Assert.Equal(0L, Count(factory, "drawings"));
+ }
+
+ private static async Task PutMetadata(HttpClient client, DrawingLibraryRecord record, int expected)
+ {
+ using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", $"\"{expected}\"",
+ JsonContent.Create(record, options: Options)));
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ return (await response.Content.ReadFromJsonAsync(Options))!;
+ }
+
+ private static long Count(ServerFactory factory, string table)
+ {
+ using var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False");
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = $"SELECT COUNT(*) FROM {table}";
+ return (long)command.ExecuteScalar()!;
+ }
+}
diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs
index b5c634ad..9c1c476c 100644
--- a/OpenNest.Server/DrawingLibraryRequest.cs
+++ b/OpenNest.Server/DrawingLibraryRequest.cs
@@ -14,7 +14,7 @@ internal static class DrawingLibraryRequest
{
private static readonly string[] QueryKeys =
{
- "search", "customer", "materialName", "materialGrade", "thickness", "cutType",
+ "search", "customer", "materialName", "materialGrade", "thickness", "cutType", "cutFirst",
"includeArchived", "sort", "order", "offset", "limit",
};
@@ -96,6 +96,18 @@ internal static class DrawingLibraryRequest
return false;
}
+ bool? cutFirst = null;
+ if (values.TryGetValue("cutFirst", out var cutFirstText))
+ {
+ if (!bool.TryParse(cutFirstText.ToString(), out var parsed))
+ {
+ error = "cutFirst must be true or false.";
+ return false;
+ }
+
+ cutFirst = parsed;
+ }
+
var offset = 0;
var limit = DrawingLibraryQuery.DefaultLimit;
if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error))
@@ -109,6 +121,7 @@ internal static class DrawingLibraryRequest
MaterialGrade = values["materialGrade"].ToString(),
Thickness = thickness,
CutType = cutType,
+ CutFirst = cutFirst,
IncludeArchived = includeArchived,
Sort = sort,
Descending = descending,
diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs
index 36a20d9b..15057981 100644
--- a/OpenNest.Server/NestDatabase.Drawings.cs
+++ b/OpenNest.Server/NestDatabase.Drawings.cs
@@ -6,53 +6,69 @@ using OpenNest.Data;
namespace OpenNest.Server;
///
-/// Drawing library storage: drawings holds each drawing's current revision and
-/// drawing_revisions every immutable revision, both in the nest database file
-/// and under the same monitor. Every change appends a revision in one transaction.
+/// Drawing library storage. drawing_packages stores each distinct package once, keyed
+/// by its SHA-256; drawing_revisions holds every immutable revision (details, nesting
+/// settings, archived flag and the package hash it points at); drawings holds each
+/// drawing's current revision for browsing. All share the nest database file and monitor.
+/// Every change appends a revision in one transaction, and only a new package adds a blob.
///
public sealed partial class NestDatabase
{
- // Schema v4 is additive: the nest tables are untouched.
private const string DrawingColumnDefinitions = """
- id TEXT NOT NULL,
- revision INTEGER NOT NULL,
- name TEXT NOT NULL,
- customer TEXT NOT NULL DEFAULT '',
- nameKey TEXT NOT NULL,
- customerKey TEXT NOT NULL DEFAULT '',
- materialName TEXT NOT NULL DEFAULT '',
- materialKey TEXT NOT NULL DEFAULT '',
- materialGrade TEXT NOT NULL DEFAULT '',
- gradeKey TEXT NOT NULL DEFAULT '',
- thickness REAL NOT NULL,
- units TEXT NOT NULL,
- cutType TEXT NOT NULL,
- notes TEXT NOT NULL DEFAULT '',
- archived INTEGER NOT NULL DEFAULT 0,
- packageSha256 TEXT NOT NULL,
- packageSize INTEGER NOT NULL,
- savedAt TEXT NOT NULL,
- savedBy TEXT NOT NULL DEFAULT '',
- package BLOB NOT NULL
+ id TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ customer TEXT NOT NULL DEFAULT '',
+ nameKey TEXT NOT NULL,
+ customerKey TEXT NOT NULL DEFAULT '',
+ materialName TEXT NOT NULL DEFAULT '',
+ materialKey TEXT NOT NULL DEFAULT '',
+ materialGrade TEXT NOT NULL DEFAULT '',
+ gradeKey TEXT NOT NULL DEFAULT '',
+ thickness REAL NOT NULL,
+ units TEXT NOT NULL,
+ cutType TEXT NOT NULL,
+ notes TEXT NOT NULL DEFAULT '',
+ cutFirst INTEGER NOT NULL DEFAULT 0,
+ priority INTEGER NOT NULL DEFAULT 0,
+ stepAngle REAL NOT NULL DEFAULT 0,
+ startAngle REAL NOT NULL DEFAULT 0,
+ endAngle REAL NOT NULL DEFAULT 0,
+ allow180Equivalent INTEGER NOT NULL DEFAULT 0,
+ colorArgb INTEGER NOT NULL DEFAULT 0,
+ archived INTEGER NOT NULL DEFAULT 0,
+ packageSha256 TEXT NOT NULL REFERENCES drawing_packages(sha256),
+ packageSize INTEGER NOT NULL,
+ savedAt TEXT NOT NULL,
+ savedBy TEXT NOT NULL DEFAULT ''
""";
+ // Schema v5 replaces the v4 drawing tables (which copied the blob into every revision).
private const string DrawingLibrarySchema = $"""
+ DROP TABLE IF EXISTS drawings;
+ DROP TABLE IF EXISTS drawing_revisions;
+ DROP TABLE IF EXISTS drawing_packages;
+ CREATE TABLE drawing_packages (
+ sha256 TEXT NOT NULL PRIMARY KEY,
+ size INTEGER NOT NULL,
+ package BLOB NOT NULL);
+ CREATE TABLE drawing_revisions ({DrawingColumnDefinitions}, PRIMARY KEY (id, revision));
CREATE TABLE drawings ({DrawingColumnDefinitions}, PRIMARY KEY (id));
- CREATE TABLE drawing_revisions ({DrawingColumnDefinitions});
- CREATE UNIQUE INDEX ix_drawing_revisions_key ON drawing_revisions(id, revision);
CREATE INDEX ix_drawings_customer_name ON drawings(customerKey, nameKey);
CREATE INDEX ix_drawings_material ON drawings(materialKey);
CREATE INDEX ix_drawings_thickness ON drawings(thickness);
+ CREATE INDEX ix_drawings_cutFirst ON drawings(cutFirst);
CREATE INDEX ix_drawings_savedAt ON drawings(savedAt DESC);
- PRAGMA user_version=4;
+ PRAGMA user_version=5;
""";
private const string DrawingColumns = """
id, revision, name, customer, materialName, materialGrade, thickness, units,
- cutType, notes, archived, packageSha256, packageSize, savedAt, savedBy
+ cutType, notes, archived, packageSha256, packageSize, savedAt, savedBy,
+ cutFirst, priority, stepAngle, startAngle, endAngle, allow180Equivalent, colorArgb
""";
- private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey, package";
+ private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey";
// Case-insensitive (ASCII) substring; the text is always a bound, escaped LIKE parameter.
private const string DrawingSearchPredicate = """
@@ -76,7 +92,10 @@ public sealed partial class NestDatabase
lock (_sync)
{
using var command = _connection.CreateCommand();
- command.CommandText = "SELECT package FROM drawings WHERE id=$id";
+ command.CommandText = """
+ SELECT p.package FROM drawings d JOIN drawing_packages p ON p.sha256 = d.packageSha256
+ WHERE d.id=$id
+ """;
command.Parameters.AddWithValue("$id", id.ToString());
return command.ExecuteScalar() as byte[];
}
@@ -98,6 +117,7 @@ public sealed partial class NestDatabase
if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade");
if (query.Thickness is not null) conditions.Add("thickness=$thickness");
if (query.CutType is not null) conditions.Add("cutType=$cutType");
+ if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst");
var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions);
lock (_sync)
@@ -135,15 +155,17 @@ public sealed partial class NestDatabase
using var transaction = _connection.BeginTransaction();
if (GetDrawing(id) is { } existing)
throw new DrawingConflictException(existing, "A library drawing with this id already exists.");
- WriteDrawingRevision(id, record, package, 1, archived: false, insert: true, transaction);
+ var sha = StorePackage(package, transaction);
+ WriteDrawingRevision(id, record, sha, package.LongLength, 1, archived: false, transaction);
transaction.Commit();
return GetDrawing(id) ?? throw new InvalidOperationException("Insert did not persist.");
}
}
///
- /// Appends a revision with new metadata and, when is not null,
- /// new geometry. Returns null for an unknown id; an identical save is a no-op.
+ /// Appends a revision with new details and settings and, when
+ /// is not null, new geometry; otherwise the revision points at the current package and
+ /// stores no blob. Returns null for an unknown id; an identical save is a no-op.
///
public DrawingLibraryRecord? UpdateDrawing(Guid id, DrawingLibraryRecord record, byte[]? package, int expectedRevision)
{
@@ -156,12 +178,19 @@ public sealed partial class NestDatabase
throw new DrawingConflictException(current, "The library drawing has changed. Reload before saving.");
if (current.Archived)
throw new DrawingConflictException(current, "The library drawing is archived. Restore it before saving.");
- package ??= GetDrawingPackage(id)!;
- ValidateDrawing(record, package);
- if (SameDrawingMetadata(current, record) && GetDrawingPackage(id)!.AsSpan().SequenceEqual(package))
+ var error = DrawingLibraryRules.GetMetadataError(record)
+ ?? (package is null ? null : DrawingLibraryRules.GetPackageError(package));
+ if (error is not null)
+ throw new ArgumentException(error, nameof(record));
+
+ var sha = package is null ? current.PackageSha256 : PackageSha256(package);
+ var size = package?.LongLength ?? current.PackageSize;
+ if (SameDrawingMetadata(current, record) && sha == current.PackageSha256)
return current;
- WriteDrawingRevision(id, record, package, checked(current.Revision + 1), archived: false, insert: false, transaction);
+ if (package is not null)
+ StorePackage(package, transaction);
+ WriteDrawingRevision(id, record, sha, size, checked(current.Revision + 1), archived: false, transaction);
transaction.Commit();
return GetDrawing(id);
}
@@ -181,7 +210,8 @@ public sealed partial class NestDatabase
throw new DrawingConflictException(current,
archived ? "The library drawing is already archived." : "The library drawing is not archived.");
- WriteDrawingRevision(id, current, GetDrawingPackage(id)!, checked(current.Revision + 1), archived, insert: false, transaction);
+ WriteDrawingRevision(id, current, current.PackageSha256, current.PackageSize,
+ checked(current.Revision + 1), archived, transaction);
transaction.Commit();
return GetDrawing(id);
}
@@ -215,7 +245,10 @@ public sealed partial class NestDatabase
lock (_sync)
{
using var command = _connection.CreateCommand();
- command.CommandText = "SELECT package FROM drawing_revisions WHERE id=$id AND revision=$revision";
+ command.CommandText = """
+ SELECT p.package FROM drawing_revisions r JOIN drawing_packages p ON p.sha256 = r.packageSha256
+ WHERE r.id=$id AND r.revision=$revision
+ """;
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$revision", revision);
return command.ExecuteScalar() as byte[];
@@ -229,32 +262,43 @@ public sealed partial class NestDatabase
throw new ArgumentException(error, nameof(record));
}
+ private static string PackageSha256(byte[] package) =>
+ Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant();
+
private static bool SameDrawingMetadata(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName &&
a.MaterialGrade == b.MaterialGrade && a.Thickness == DrawingLibraryRules.NormalizeThickness(b.Thickness) &&
- a.Units == b.Units && a.CutType == b.CutType && a.Notes == b.Notes;
+ a.Units == b.Units && a.CutType == b.CutType && a.Notes == b.Notes && Equals(a.Settings, b.Settings);
- // Called only under _sync with the caller's transaction; promotion and snapshot are one write.
- private void WriteDrawingRevision(Guid id, DrawingLibraryRecord record, byte[] package, int revision,
- bool archived, bool insert, SqliteTransaction transaction)
+ // Content-addressed: an identical package already stored is reused, never rewritten.
+ private string StorePackage(byte[] package, SqliteTransaction transaction)
{
+ var sha = PackageSha256(package);
using var command = _connection.CreateCommand();
command.Transaction = transaction;
- command.CommandText = insert
- ? $"""
- INSERT INTO drawings ({DrawingStoredColumns})
- VALUES ($id, $revision, $name, $customer, $materialName, $materialGrade, $thickness, $units,
- $cutType, $notes, $archived, $sha, $size, $savedAt, $savedBy,
- $nameKey, $customerKey, $materialKey, $gradeKey, $package)
- """
- : """
- UPDATE drawings SET revision=$revision, name=$name, customer=$customer,
- materialName=$materialName, materialGrade=$materialGrade, thickness=$thickness,
- units=$units, cutType=$cutType, notes=$notes, archived=$archived, packageSha256=$sha,
- packageSize=$size, savedAt=$savedAt, savedBy=$savedBy, nameKey=$nameKey,
- customerKey=$customerKey, materialKey=$materialKey, gradeKey=$gradeKey, package=$package
- WHERE id=$id
- """;
+ command.CommandText = "INSERT OR IGNORE INTO drawing_packages (sha256, size, package) VALUES ($sha, $size, $package)";
+ command.Parameters.AddWithValue("$sha", sha);
+ command.Parameters.AddWithValue("$size", package.LongLength);
+ command.Parameters.AddWithValue("$package", package);
+ command.ExecuteNonQuery();
+ return sha;
+ }
+
+ // Called only under _sync with the caller's transaction; promotion and snapshot are one
+ // write. The package must already be stored under packageSha256.
+ private void WriteDrawingRevision(Guid id, DrawingLibraryRecord record, string packageSha256, long packageSize,
+ int revision, bool archived, SqliteTransaction transaction)
+ {
+ var settings = record.Settings ?? new DrawingNestingSettings();
+ using var command = _connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = $"""
+ INSERT OR REPLACE INTO drawings ({DrawingStoredColumns})
+ VALUES ($id, $revision, $name, $customer, $materialName, $materialGrade, $thickness, $units,
+ $cutType, $notes, $archived, $sha, $size, $savedAt, $savedBy,
+ $cutFirst, $priority, $stepAngle, $startAngle, $endAngle, $allow180, $color,
+ $nameKey, $customerKey, $materialKey, $gradeKey)
+ """;
command.Parameters.AddWithValue("$id", id.ToString());
command.Parameters.AddWithValue("$revision", revision);
command.Parameters.AddWithValue("$name", record.Name);
@@ -266,15 +310,21 @@ public sealed partial class NestDatabase
command.Parameters.AddWithValue("$cutType", record.CutType.ToString());
command.Parameters.AddWithValue("$notes", record.Notes);
command.Parameters.AddWithValue("$archived", archived ? 1 : 0);
- command.Parameters.AddWithValue("$sha", Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant());
- command.Parameters.AddWithValue("$size", package.LongLength);
+ command.Parameters.AddWithValue("$sha", packageSha256);
+ command.Parameters.AddWithValue("$size", packageSize);
command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o"));
command.Parameters.AddWithValue("$savedBy", record.SavedBy);
+ command.Parameters.AddWithValue("$cutFirst", settings.CutFirst ? 1 : 0);
+ command.Parameters.AddWithValue("$priority", settings.Priority);
+ command.Parameters.AddWithValue("$stepAngle", settings.StepAngle);
+ command.Parameters.AddWithValue("$startAngle", settings.StartAngle);
+ command.Parameters.AddWithValue("$endAngle", settings.EndAngle);
+ command.Parameters.AddWithValue("$allow180", settings.Allow180Equivalent ? 1 : 0);
+ command.Parameters.AddWithValue("$color", settings.ColorArgb);
command.Parameters.AddWithValue("$nameKey", SharedListNames.Key(record.Name));
command.Parameters.AddWithValue("$customerKey", SharedListNames.Key(record.Customer));
command.Parameters.AddWithValue("$materialKey", SharedListNames.Key(record.MaterialName));
command.Parameters.AddWithValue("$gradeKey", SharedListNames.Key(record.MaterialGrade));
- command.Parameters.AddWithValue("$package", package);
command.ExecuteNonQuery();
command.Parameters.Clear();
@@ -296,6 +346,8 @@ public sealed partial class NestDatabase
command.Parameters.AddWithValue("$thickness", DrawingLibraryRules.NormalizeThickness(thickness));
if (query.CutType is { } cutType)
command.Parameters.AddWithValue("$cutType", cutType.ToString());
+ if (query.CutFirst is { } cutFirst)
+ command.Parameters.AddWithValue("$cutFirst", cutFirst ? 1 : 0);
}
// Fixed allowlist: request text never reaches the ORDER BY clause.
@@ -329,6 +381,16 @@ public sealed partial class NestDatabase
PackageSize = reader.GetInt64(12),
SavedAt = DateTime.Parse(reader.GetString(13)),
SavedBy = reader.GetString(14),
+ Settings = new DrawingNestingSettings
+ {
+ CutFirst = reader.GetInt32(15) != 0,
+ Priority = reader.GetInt32(16),
+ StepAngle = reader.GetDouble(17),
+ StartAngle = reader.GetDouble(18),
+ EndAngle = reader.GetDouble(19),
+ Allow180Equivalent = reader.GetInt32(20) != 0,
+ ColorArgb = reader.GetInt32(21),
+ },
};
}
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index 25534646..d585beeb 100644
--- a/OpenNest.Server/NestDatabase.cs
+++ b/OpenNest.Server/NestDatabase.cs
@@ -316,7 +316,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
- if (version > 4) throw new InvalidOperationException("Database schema is newer than this server.");
+ if (version > 5) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -346,8 +346,10 @@ public sealed partial class NestDatabase : IDisposable
""";
command.ExecuteNonQuery();
}
- if (version < 4)
+ if (version < 5)
{
+ // v5 replaces the v4 drawing tables outright (no production library data
+ // existed); the nest tables are untouched.
command.CommandText = DrawingLibrarySchema;
command.ExecuteNonQuery();
}
diff --git a/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs
new file mode 100644
index 00000000..33ab7c92
--- /dev/null
+++ b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs
@@ -0,0 +1,103 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class DrawingLibraryEditSessionTests
+{
+ private static DrawingLibraryRecord Listed(bool archived = false) => new()
+ {
+ Id = Guid.NewGuid(),
+ Revision = 4,
+ Name = "Test square",
+ Customer = "Synthetic customer",
+ MaterialName = "Synthetic Steel",
+ MaterialGrade = "Grade A",
+ Thickness = 0.25,
+ Units = Units.Inches,
+ CutType = CutType.Laser,
+ Notes = "Calibration",
+ Archived = archived,
+ PackageSha256 = "abc123",
+ SavedBy = "Someone else",
+ };
+
+ [Fact]
+ public async Task SaveAsync_KeepsTheDetailsAndSendsNewSettingsAtTheListedRevision()
+ {
+ var listed = Listed();
+ var store = new RecordingStore();
+ var session = new DrawingLibraryEditSession(store, listed, "Synthetic operator");
+ var settings = new DrawingNestingSettings { CutFirst = true };
+
+ Assert.True(session.IsUnchanged("Calibration", new DrawingNestingSettings()));
+ Assert.False(session.IsUnchanged("Calibration", settings));
+ var saved = await session.SaveAsync("Calibration", settings);
+
+ var (id, expected, record) = Assert.Single(store.Updates);
+ Assert.Equal((listed.Id, 4), (id, expected));
+ Assert.Equal((listed.Name, listed.Customer, listed.MaterialName, listed.MaterialGrade, listed.Thickness, listed.CutType),
+ (record.Name, record.Customer, record.MaterialName, record.MaterialGrade, record.Thickness, record.CutType));
+ Assert.Equal(("Calibration", "Synthetic operator"), (record.Notes, record.SavedBy));
+ Assert.Equal(settings, record.Settings);
+ Assert.Equal(5, saved.Revision);
+ }
+
+ [Fact]
+ public async Task SaveAsync_RefusesInvalidSettingsWithoutSending()
+ {
+ var store = new RecordingStore();
+ var session = new DrawingLibraryEditSession(store, Listed(), "");
+ var invalid = new DrawingNestingSettings { StepAngle = -1 };
+
+ Assert.NotNull(session.GetError("", invalid));
+ await Assert.ThrowsAsync(() => session.SaveAsync("", invalid));
+ Assert.Empty(store.Updates);
+ }
+
+ [Fact]
+ public void ArchivedDrawings_CannotBeEdited()
+ {
+ Assert.Throws(() => new DrawingLibraryEditSession(new RecordingStore(), Listed(archived: true), ""));
+ }
+
+ private sealed class RecordingStore : IDrawingLibraryStore
+ {
+ public List<(Guid Id, int Expected, DrawingLibraryRecord Record)> Updates { get; } = new();
+
+ public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
+ CancellationToken cancellationToken = default)
+ {
+ Updates.Add((id, expectedRevision, record));
+ return Task.FromResult(new DrawingLibraryRecord
+ {
+ Id = id,
+ Revision = expectedRevision + 1,
+ Name = record.Name,
+ Notes = record.Notes,
+ Settings = record.Settings,
+ PackageSha256 = "ABC123",
+ });
+ }
+
+ public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetAsync(Guid id, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetRevisionsAsync(Guid id, int offset = 0,
+ int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UploadAsync(DrawingLibraryRecord record, byte[] package,
+ CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
+ int expectedRevision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs
index 974dc48a..c3b7ef76 100644
--- a/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs
+++ b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs
@@ -58,6 +58,44 @@ public class DrawingLibrarySaveSessionTests
Assert.Null(DrawingLibraryRules.GetMetadataError(metadata));
}
+ [Fact]
+ public void Prefill_TakesTheDrawingsNestingSettings()
+ {
+ var drawing = SyntheticDrawing();
+ drawing.CutFirst = true;
+ drawing.Priority = 3;
+ drawing.Constraints = new NestConstraints { StepAngle = 0.5, StartAngle = 0, EndAngle = System.Math.PI, Allow180Equivalent = true };
+ drawing.Color = System.Drawing.Color.FromArgb(255, 10, 20, 30);
+
+ var metadata = Session(new FakeStore(), drawing).Prefill(CutType.Laser, "");
+
+ Assert.Equal(new DrawingNestingSettings
+ {
+ CutFirst = true,
+ Priority = 3,
+ StepAngle = 0.5,
+ EndAngle = System.Math.PI,
+ Allow180Equivalent = true,
+ ColorArgb = System.Drawing.Color.FromArgb(255, 10, 20, 30).ToArgb(),
+ }, metadata.Settings);
+ }
+
+ [Fact]
+ public async Task SaveAsync_SettingsOnlyChange_RevisesWithTheSamePackage()
+ {
+ var store = new FakeStore();
+ var existing = store.Seed(Metadata(), PackageA);
+ var session = Session(store);
+
+ var result = await session.SaveAsync(Edit(Metadata(), r => { r.Settings = new DrawingNestingSettings { CutFirst = true }; }),
+ PackageA, existing);
+
+ Assert.Equal(DrawingLibrarySaveOutcome.Revised, result.Outcome);
+ Assert.Equal(2, result.Saved.Revision);
+ Assert.True(result.Saved.Settings.CutFirst);
+ Assert.Equal(existing.PackageSha256, result.Saved.PackageSha256);
+ }
+
[Fact]
public void Prefill_FallsBackToTheNestCustomerAndDrawingMaterial_AndUsesALinkOnThisServer()
{
@@ -363,6 +401,7 @@ public class DrawingLibrarySaveSessionTests
Units = source.Units,
CutType = source.CutType,
Notes = source.Notes,
+ Settings = source.Settings,
Archived = source.Archived,
PackageSha256 = source.PackageSha256,
PackageSize = source.PackageSize,
@@ -484,6 +523,10 @@ public class DrawingLibrarySaveSessionTests
return Reply(next);
}
+ public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
+ CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
private void FailBeforeStoring()
{
if (!FailNextRequest)
@@ -519,7 +562,7 @@ public class DrawingLibrarySaveSessionTests
private static bool SameDetails(DrawingLibraryRecord a, DrawingLibraryRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName
&& a.MaterialGrade == b.MaterialGrade && a.Thickness == b.Thickness && a.Units == b.Units
- && a.CutType == b.CutType && a.Notes == b.Notes;
+ && a.CutType == b.CutType && a.Notes == b.Notes && Equals(a.Settings, b.Settings);
private static bool Filter(string filter, string value) =>
string.IsNullOrWhiteSpace(filter) || SharedListNames.Key(filter) == SharedListNames.Key(value);
diff --git a/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs
index d3dac08c..edc2f362 100644
--- a/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs
+++ b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs
@@ -71,6 +71,41 @@ public class LibraryDrawingInsertTests
link.Units, link.Notes, link.PackageSha256));
}
+ [Fact]
+ public void RecordSettings_ApplyOverWhateverThePackageHolds()
+ {
+ var nest = MatchingNest();
+ var package = Package();
+ package.Drawing.CutFirst = false;
+ package.Drawing.Priority = 7;
+ package.Drawing.Constraints = new NestConstraints { StepAngle = 1, StartAngle = 1, EndAngle = 2 };
+ var record = Record();
+ record.Settings = new DrawingNestingSettings
+ {
+ CutFirst = true,
+ Priority = 1,
+ StepAngle = System.Math.PI / 2,
+ EndAngle = System.Math.PI,
+ Allow180Equivalent = true,
+ ColorArgb = System.Drawing.Color.FromArgb(255, 40, 50, 60).ToArgb(),
+ };
+
+ LibraryDrawingInsert.Plan(nest, Server, record, package, 1).Apply();
+
+ var drawing = Assert.Single(nest.Drawings);
+ Assert.Equal(record.Settings, DrawingNestingSettings.FromDrawing(drawing));
+ }
+
+ [Fact]
+ public void RecordWithoutAColor_GetsAVisiblePartColor()
+ {
+ var nest = MatchingNest();
+
+ LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1).Apply();
+
+ Assert.Equal(255, Assert.Single(nest.Drawings).Color.A);
+ }
+
[Fact]
public void SpacingCaseAndThicknessRounding_StillMatch()
{
diff --git a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs
index 9d92eab6..150930c7 100644
--- a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs
+++ b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs
@@ -165,6 +165,33 @@ public class RemoteDrawingLibraryTests
Assert.Equal(record.Id, saved.Id);
}
+ [Fact]
+ public async Task UpdateMetadataAsync_SendsAndReadsNestingSettings()
+ {
+ var record = SampleRecord(revision: 2);
+ record.Settings = new DrawingNestingSettings { CutFirst = true, Priority = 2, EndAngle = 1.5, ColorArgb = -16776961 };
+ var handler = new StubHandler(_ => StubHandler.Json(record));
+ using var library = Library(handler);
+
+ var saved = await library.UpdateMetadataAsync(record.Id, record, 1);
+
+ Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
+ Assert.EndsWith($"/api/drawings/{record.Id}/metadata", handler.Requests[0].RequestUri!.AbsoluteUri);
+ Assert.Contains("\"settings\":{\"cutFirst\":true,\"priority\":2", handler.RequestBodies[0]);
+ Assert.Equal(record.Settings, saved.Settings);
+ }
+
+ [Fact]
+ public async Task QueryAsync_SendsTheCutFirstFilter()
+ {
+ var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty(), total = 0 }));
+ using var library = Library(handler);
+
+ await library.QueryAsync(new DrawingLibraryQuery { CutFirst = true });
+
+ Assert.Contains("&cutFirst=true", handler.Requests[0].RequestUri!.Query);
+ }
+
[Fact]
public async Task UploadAsync_DifferentStoredPackageOrId_IsRefused()
{
diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs
index cdd03574..5e48086d 100644
--- a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs
@@ -121,7 +121,50 @@ public class DrawingLibraryFormTests
"The drawing library refusal test did not complete.");
}
+ [Fact]
+ public void EditSettings_SavesCutFirstAsANewRevision_AndTheAddAppliesIt()
+ {
+ StaTestThread.Run(
+ () =>
+ {
+ var library = new FakeLibrary();
+ var record = library.Add("SYN-404", "Synthetic Steel", "Grade A", 0.25, Units.Inches, "Test square");
+ var nest = MatchingNest();
+ using var form = new DrawingLibraryForm(library, Server, nest);
+ form.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
+ form.EditDrawing = edit =>
+ {
+ using var editor = new EditLibraryDrawingForm(edit);
+ editor.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
+ editor.Show();
+ var save = Field