feat(library): content-addressed packages and editable nesting settings

Schema v5 stores each distinct drawing package once in drawing_packages,
keyed by SHA-256; revisions point at it, so details, settings, archive and
restore revisions add no blob. The v4 drawing tables are replaced outright
(no production library existed); nest tables are untouched.

Nesting settings (cut first, priority, rotation constraints, color) become
structured record fields (DrawingLibraryRecord.Settings), returned on every
read, filterable by cutFirst, editable through the metadata endpoint and
applied by LibraryDrawingInsert. Canonical packages now leave every
record-owned field at its default, so renaming or changing settings never
changes the package hash. Saving a nest drawing to the library carries its
settings into the record.

The desktop library browser gains a Cut first column and an Edit Settings
dialog that saves notes and settings as a new revision with the same package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-17 22:47:18 -04:00
1 parent 42d99f296c
commit 5a374aa3f4
32 files changed
+1502 -118

No files matched your search

@@ -0,0 +1,67 @@
namespace OpenNest.Data;
/// <summary>
/// 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 <see cref="DrawingLibraryConflictException"/>
/// and changes nothing.
/// </summary>
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 ?? "";
}
/// <summary>The record as it was listed; its revision guards the save.</summary>
public DrawingLibraryRecord Listed { get; }
public string SavedBy { get; }
/// <summary>The record to save: the listed details with new notes and settings.</summary>
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,
};
/// <summary>The first rule the edit breaks, or null when it may be saved.</summary>
public string? GetError(string notes, DrawingNestingSettings settings) =>
DrawingLibraryRules.GetMetadataError(Build(notes, settings));
/// <summary>True when saving would change nothing.</summary>
public bool IsUnchanged(string notes, DrawingNestingSettings settings) =>
(notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings);
/// <summary>Saves the edit as the next revision; returns the stored record.</summary>
public async Task<DrawingLibraryRecord> 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;
}
}
+3
View File
@@ -32,6 +32,9 @@ public sealed class DrawingLibraryQuery
public CutType? CutType { get; init; }
/// <summary>Only drawings whose Cut first setting equals this; null means any.</summary>
public bool? CutFirst { get; init; }
public bool IncludeArchived { get; init; }
/// <summary>Column to order by; ties are broken by id in the same direction.</summary>
+6
View File
@@ -33,6 +33,12 @@ public sealed class DrawingLibraryRecord
public string Notes { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
public DrawingNestingSettings Settings { get; set; } = new();
/// <summary>Hidden from browsing by default; still readable by id.</summary>
public bool Archived { get; set; }
+2 -1
View File
@@ -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());
}
/// <summary>The first package rule <paramref name="package"/> breaks, or null when it may be stored.</summary>
+9 -4
View File
@@ -137,8 +137,9 @@ public sealed class DrawingLibrarySaveSession
/// <summary>
/// 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 <paramref name="lastCutType"/>.
/// thickness and units, the linked revision's cut type and notes when the drawing
/// came from this server's library, otherwise <paramref name="lastCutType"/>, and the
/// drawing's own nesting settings.
/// </summary>
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);
/// <summary>True when a new request asks for exactly this save again.</summary>
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);
}
}
+79
View File
@@ -0,0 +1,79 @@
using System.Drawing;
using OpenNest;
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
public sealed record DrawingNestingSettings
{
/// <summary>Automatic sequencing cuts this drawing's parts first (for example test squares).</summary>
public bool CutFirst { get; init; }
public int Priority { get; init; }
/// <summary>Rotation step in radians; zero means the engine's default.</summary>
public double StepAngle { get; init; }
/// <summary>Rotation start angle in radians.</summary>
public double StartAngle { get; init; }
/// <summary>Rotation end angle in radians.</summary>
public double EndAngle { get; init; }
public bool Allow180Equivalent { get; init; }
/// <summary>
/// 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.
/// </summary>
public int ColorArgb { get; init; }
/// <summary>The settings <paramref name="drawing"/> currently has.</summary>
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(),
};
}
/// <summary>Writes these settings onto <paramref name="drawing"/>.</summary>
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;
}
/// <summary>The first rule these settings break, or null when they may be saved.</summary>
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;
}
}
+7
View File
@@ -20,4 +20,11 @@ public interface IDrawingLibraryStore : IDrawingLibrary
/// <summary>Saves the next revision when <paramref name="expectedRevision"/> is current.</summary>
Task<DrawingLibraryRecord> UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
int expectedRevision, CancellationToken cancellationToken = default);
/// <summary>
/// Saves new details and nesting settings, keeping the package, as the next revision
/// when <paramref name="expectedRevision"/> is current.
/// </summary>
Task<DrawingLibraryRecord> UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
CancellationToken cancellationToken = default);
}
+2
View File
@@ -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
+2
View File
@@ -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();
+22 -8
View File
@@ -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]
+21
View File
@@ -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.
/// <para>
/// 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.
/// </para>
/// </summary>
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" };
/// <summary>Packages <paramref name="drawing"/> without changing it.</summary>
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<JsonObject>())
@@ -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"] = "";
}
}
}
@@ -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<InvalidOperationException>(() => 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<SqliteException>(() => 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
}
}
/// <summary>A real schema-v3 file: v4 is additive, so dropping its tables restores v3 exactly.</summary>
/// <summary>A real schema-v3 file: the drawing tables are additive, so dropping them restores v3 exactly.</summary>
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)
@@ -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]
@@ -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;
/// <summary>Content-addressed packages and structured nesting settings (schema v5).</summary>
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<DrawingLibraryRecord>($"/api/drawings/{record.Id}", Options);
var listed = Assert.Single((await client.GetFromJsonAsync<DrawingLibraryPage>("/api/drawings/query", Options))!.Items);
var history = Assert.Single((await client.GetFromJsonAsync<DrawingLibraryPage>($"/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<DrawingLibraryPage>($"/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<DrawingLibraryPage>("/api/drawings/query?cutFirst=true", Options))!;
var others = (await client.GetFromJsonAsync<DrawingLibraryPage>("/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<DrawingLibraryRecord> 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<DrawingLibraryRecord>(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()!;
}
}
+14 -1
View File
@@ -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,
+123 -61
View File
@@ -6,53 +6,69 @@ using OpenNest.Data;
namespace OpenNest.Server;
/// <summary>
/// Drawing library storage: <c>drawings</c> holds each drawing's current revision and
/// <c>drawing_revisions</c> 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. <c>drawing_packages</c> stores each distinct package once, keyed
/// by its SHA-256; <c>drawing_revisions</c> holds every immutable revision (details, nesting
/// settings, archived flag and the package hash it points at); <c>drawings</c> 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.
/// </summary>
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.");
}
}
/// <summary>
/// Appends a revision with new metadata and, when <paramref name="package"/> 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 <paramref name="package"/>
/// 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.
/// </summary>
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),
},
};
}
+4 -2
View File
@@ -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();
}
@@ -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<ArgumentException>(() => session.SaveAsync("", invalid));
Assert.Empty(store.Updates);
}
[Fact]
public void ArchivedDrawings_CannotBeEdited()
{
Assert.Throws<ArgumentException>(() => 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<DrawingLibraryRecord> 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<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryPage?> GetRevisionsAsync(Guid id, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryRecord> UploadAsync(DrawingLibraryRecord record, byte[] package,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryRecord> UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}
@@ -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<DrawingLibraryRecord> 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);
@@ -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()
{
@@ -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<DrawingLibraryRecord>(), 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()
{
@@ -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<Button>(editor, "saveButton");
Assert.False(save.Enabled);
Field<CheckBox>(editor, "cutFirstCheckBox").Checked = true;
Assert.True(save.Enabled);
Run(editor.SaveAsync());
return editor.Saved;
};
form.Show();
var grid = Field<DataGridView>(form, "drawingGrid");
PumpUntil(() => grid.Rows.Count == 1, "the library page");
Assert.Equal("", grid.Rows[0].Cells["cutFirstColumn"].Value);
Run(form.EditSelectedAsync());
PumpUntil(() => Equals(grid.Rows[0].Cells["revisionColumn"].Value, record.Revision + 1), "the reloaded page");
var update = Assert.Single(library.MetadataUpdates);
Assert.Equal((record.Id, record.Revision), (update.Id, update.ExpectedRevision));
Assert.True(update.Record.Settings.CutFirst);
Assert.Equal("Test square", update.Record.Notes);
Assert.Equal("Yes", grid.Rows[0].Cells["cutFirstColumn"].Value);
Run(form.AddSelectedAsync());
Assert.True(Assert.Single(nest.Drawings).CutFirst);
},
TestTimeout,
"The drawing library edit test did not complete.");
}
private static Nest MatchingNest() => new("Synthetic job")
{
@@ -148,7 +191,7 @@ public class DrawingLibraryFormTests
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
/// <summary>Serves every record (ignoring filters) and real packages by revision.</summary>
private sealed class FakeLibrary : IDrawingLibrary
private sealed class FakeLibrary : IDrawingLibraryStore
{
private readonly List<DrawingLibraryRecord> _records = new();
private readonly Dictionary<(Guid, int), byte[]> _packages = new();
@@ -198,5 +241,52 @@ public class DrawingLibraryFormTests
Downloads.Add((id, revision));
return Task.FromResult(_packages.TryGetValue((id, revision), out var package) ? package : null);
}
public List<(Guid Id, int ExpectedRevision, DrawingLibraryRecord Record)> MetadataUpdates { get; } = new();
/// <summary>Replaces the listed record with the next revision, keeping its package.</summary>
public Task<DrawingLibraryRecord> UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
CancellationToken cancellationToken = default)
{
MetadataUpdates.Add((id, expectedRevision, record));
var index = _records.FindIndex(r => r.Id == id);
var current = _records[index];
if (current.Revision != expectedRevision)
throw new DrawingLibraryConflictException(current);
var saved = new DrawingLibraryRecord
{
Id = id,
Revision = current.Revision + 1,
Name = current.Name,
Customer = current.Customer,
MaterialName = current.MaterialName,
MaterialGrade = current.MaterialGrade,
Thickness = current.Thickness,
Units = current.Units,
CutType = current.CutType,
Notes = record.Notes,
Settings = record.Settings,
PackageSha256 = current.PackageSha256,
SavedAt = current.SavedAt.AddMinutes(1),
};
_records[index] = saved;
_packages[(id, saved.Revision)] = _packages[(id, current.Revision)];
return Task.FromResult(saved);
}
public Task<DrawingLibraryRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryPage?> GetRevisionsAsync(Guid id, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryRecord> UploadAsync(DrawingLibraryRecord record, byte[] package,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<DrawingLibraryRecord> UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
int expectedRevision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}
@@ -250,6 +250,10 @@ public class SaveToDrawingLibraryFormTests
return Task.FromResult(_revisions[id][^1]);
}
public Task<DrawingLibraryRecord> UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
private DrawingLibraryRecord Stored(DrawingLibraryRecord metadata, Guid id, int revision, byte[] package)
{
_clock = _clock.AddMinutes(1);
@@ -265,6 +269,7 @@ public class SaveToDrawingLibraryFormTests
Units = metadata.Units,
CutType = metadata.CutType,
Notes = metadata.Notes,
Settings = metadata.Settings,
SavedBy = metadata.SavedBy,
PackageSha256 = Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant(),
SavedAt = _clock,
+30 -7
View File
@@ -34,6 +34,7 @@ namespace OpenNest.Forms
gradeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
thicknessColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
cutTypeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
cutFirstColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
revisionColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
savedColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
notesGroup = new System.Windows.Forms.GroupBox();
@@ -44,6 +45,7 @@ namespace OpenNest.Forms
summaryLabel = new System.Windows.Forms.Label();
quantityLabel = new System.Windows.Forms.Label();
quantityBox = new System.Windows.Forms.NumericUpDown();
editButton = new System.Windows.Forms.Button();
addButton = new System.Windows.Forms.Button();
closeButton = new System.Windows.Forms.Button();
statusLabel = new System.Windows.Forms.Label();
@@ -136,7 +138,7 @@ namespace OpenNest.Forms
//
drawingGrid.AllowUserToAddRows = false;
drawingGrid.AllowUserToDeleteRows = false;
drawingGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { nameColumn, customerColumn, materialColumn, gradeColumn, thicknessColumn, cutTypeColumn, revisionColumn, savedColumn });
drawingGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { nameColumn, customerColumn, materialColumn, gradeColumn, thicknessColumn, cutTypeColumn, cutFirstColumn, revisionColumn, savedColumn });
drawingGrid.Dock = System.Windows.Forms.DockStyle.Fill;
drawingGrid.MultiSelect = false;
drawingGrid.Name = "drawingGrid";
@@ -193,6 +195,13 @@ namespace OpenNest.Forms
cutTypeColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic;
cutTypeColumn.Width = 80;
//
// cutFirstColumn
//
cutFirstColumn.HeaderText = "Cut first";
cutFirstColumn.Name = "cutFirstColumn";
cutFirstColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
cutFirstColumn.Width = 70;
//
// revisionColumn
//
revisionColumn.DefaultCellStyle = rightAligned;
@@ -231,7 +240,7 @@ namespace OpenNest.Forms
// bottomTable
//
bottomTable.AutoSize = true;
bottomTable.ColumnCount = 7;
bottomTable.ColumnCount = 8;
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
@@ -239,13 +248,15 @@ namespace OpenNest.Forms
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
bottomTable.Controls.Add(previousButton, 0, 0);
bottomTable.Controls.Add(nextButton, 1, 0);
bottomTable.Controls.Add(summaryLabel, 2, 0);
bottomTable.Controls.Add(quantityLabel, 3, 0);
bottomTable.Controls.Add(quantityBox, 4, 0);
bottomTable.Controls.Add(addButton, 5, 0);
bottomTable.Controls.Add(closeButton, 6, 0);
bottomTable.Controls.Add(editButton, 5, 0);
bottomTable.Controls.Add(addButton, 6, 0);
bottomTable.Controls.Add(closeButton, 7, 0);
bottomTable.Dock = System.Windows.Forms.DockStyle.Fill;
bottomTable.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
bottomTable.Name = "bottomTable";
@@ -297,13 +308,23 @@ namespace OpenNest.Forms
quantityBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
quantityBox.Value = new decimal(new int[] { 1, 0, 0, 0 });
//
// editButton
//
editButton.AutoSize = true;
editButton.Enabled = false;
editButton.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3);
editButton.Name = "editButton";
editButton.TabIndex = 5;
editButton.Text = "Edit Settings...";
editButton.UseVisualStyleBackColor = true;
editButton.Click += EditButton_Click;
//
// addButton
//
addButton.AutoSize = true;
addButton.Enabled = false;
addButton.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3);
addButton.Name = "addButton";
addButton.TabIndex = 5;
addButton.TabIndex = 6;
addButton.Text = "Add to Nest";
addButton.UseVisualStyleBackColor = true;
addButton.Click += AddButton_Click;
@@ -313,7 +334,7 @@ namespace OpenNest.Forms
closeButton.AutoSize = true;
closeButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
closeButton.Name = "closeButton";
closeButton.TabIndex = 6;
closeButton.TabIndex = 7;
closeButton.Text = "Close";
closeButton.UseVisualStyleBackColor = true;
//
@@ -374,6 +395,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.DataGridViewTextBoxColumn gradeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn thicknessColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn cutTypeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn cutFirstColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn revisionColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn savedColumn;
private System.Windows.Forms.GroupBox notesGroup;
@@ -384,6 +406,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.Label summaryLabel;
private System.Windows.Forms.Label quantityLabel;
private System.Windows.Forms.NumericUpDown quantityBox;
private System.Windows.Forms.Button editButton;
private System.Windows.Forms.Button addButton;
private System.Windows.Forms.Button closeButton;
private System.Windows.Forms.Label statusLabel;
+38 -4
View File
@@ -17,13 +17,15 @@ namespace OpenNest.Forms
/// one bounded page at a time through <see cref="DrawingLibraryBrowseSession"/>, filtered
/// to the nest's material and thickness unless Show all is ticked. Add downloads the
/// listed revision's exact package, plans it with <see cref="LibraryDrawingInsert"/>,
/// shows any warning or refusal first and only then changes the nest.
/// shows any warning or refusal first and only then changes the nest. Edit Settings
/// changes the selected drawing's notes and nesting settings (Cut first, priority,
/// rotation, color) on the server as a new revision with the same package.
/// </summary>
public partial class DrawingLibraryForm : Form
{
private const string LoadingText = "Loading...";
private readonly IDrawingLibrary library;
private readonly IDrawingLibraryStore library;
private readonly string serverUrl;
private readonly Nest nest;
private readonly DrawingLibraryBrowseSession session;
@@ -32,13 +34,16 @@ namespace OpenNest.Forms
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <summary>Shows the settings editor and returns the saved record, or null; replaced by tests.</summary>
internal Func<DrawingLibraryEditSession, DrawingLibraryRecord> EditDrawing { get; set; }
/// <summary>
/// The library drawings this dialog added or raised the quantity of, in order. The
/// dialog stays open after each add, so callers check this rather than the dialog result.
/// </summary>
public List<Drawing> AddedDrawings { get; } = new();
public DrawingLibraryForm(IDrawingLibrary library, string serverUrl, Nest nest)
public DrawingLibraryForm(IDrawingLibraryStore library, string serverUrl, Nest nest)
{
InitializeComponent();
this.library = library ?? throw new ArgumentNullException(nameof(library));
@@ -46,6 +51,11 @@ namespace OpenNest.Forms
this.nest = nest ?? throw new ArgumentNullException(nameof(nest));
session = new DrawingLibraryBrowseSession(library, nest);
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
EditDrawing = edit =>
{
using var form = new EditLibraryDrawingForm(edit);
return form.ShowDialog(this) == DialogResult.OK ? form.Saved : null;
};
GridStyle.Apply(drawingGrid);
nameColumn.Tag = DrawingSortField.Name;
@@ -112,6 +122,26 @@ namespace OpenNest.Forms
private async void AddButton_Click(object sender, EventArgs e) => await AddSelectedAsync();
private async void EditButton_Click(object sender, EventArgs e) => await EditSelectedAsync();
/// <summary>
/// Edits the selected drawing's notes and nesting settings as a new library revision,
/// then reloads the page so the list shows the saved revision.
/// </summary>
internal async Task EditSelectedAsync()
{
var record = SelectedRecord();
if (record == null || adding || record.Archived)
return;
var saved = EditDrawing(new DrawingLibraryEditSession(library, record, Environment.UserName));
if (saved == null || IsDisposed)
return;
await RunAsync(session.RefreshAsync);
if (!IsDisposed)
statusLabel.Text = $"Saved '{saved.Name}' revision {saved.Revision}.";
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (keyData == Keys.Enter && drawingGrid.ContainsFocus && SelectedRecord() != null)
@@ -251,6 +281,7 @@ namespace OpenNest.Forms
record.MaterialGrade,
FormatThickness(record.Thickness),
record.CutType.ToString(),
record.Settings?.CutFirst == true ? "Yes" : "",
record.Revision,
record.SavedAt == default ? "" : record.SavedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture));
drawingGrid.Rows[index].Tag = record;
@@ -275,8 +306,11 @@ namespace OpenNest.Forms
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
}
private void UpdateCommands() =>
private void UpdateCommands()
{
addButton.Enabled = !adding && SelectedRecord() != null;
editButton.Enabled = !adding && SelectedRecord() is { Archived: false };
}
private void UpdateFilterLabel(bool showAll = false)
{
+299
View File
@@ -0,0 +1,299 @@
namespace OpenNest.Forms
{
partial class EditLibraryDrawingForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
layoutTable = new System.Windows.Forms.TableLayoutPanel();
cutFirstCheckBox = new System.Windows.Forms.CheckBox();
priorityLabel = new System.Windows.Forms.Label();
priorityBox = new System.Windows.Forms.NumericUpDown();
stepLabel = new System.Windows.Forms.Label();
stepBox = new System.Windows.Forms.NumericUpDown();
startLabel = new System.Windows.Forms.Label();
startBox = new System.Windows.Forms.NumericUpDown();
endLabel = new System.Windows.Forms.Label();
endBox = new System.Windows.Forms.NumericUpDown();
allow180CheckBox = new System.Windows.Forms.CheckBox();
colorLabel = new System.Windows.Forms.Label();
colorButton = new System.Windows.Forms.Button();
notesLabel = new System.Windows.Forms.Label();
notesBox = new System.Windows.Forms.TextBox();
statusLabel = new System.Windows.Forms.Label();
buttonPanel = new System.Windows.Forms.FlowLayoutPanel();
cancelButton = new System.Windows.Forms.Button();
saveButton = new System.Windows.Forms.Button();
layoutTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)priorityBox).BeginInit();
((System.ComponentModel.ISupportInitialize)stepBox).BeginInit();
((System.ComponentModel.ISupportInitialize)startBox).BeginInit();
((System.ComponentModel.ISupportInitialize)endBox).BeginInit();
buttonPanel.SuspendLayout();
SuspendLayout();
//
// layoutTable
//
layoutTable.ColumnCount = 2;
layoutTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
layoutTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
layoutTable.Controls.Add(cutFirstCheckBox, 1, 0);
layoutTable.Controls.Add(priorityLabel, 0, 1);
layoutTable.Controls.Add(priorityBox, 1, 1);
layoutTable.Controls.Add(stepLabel, 0, 2);
layoutTable.Controls.Add(stepBox, 1, 2);
layoutTable.Controls.Add(startLabel, 0, 3);
layoutTable.Controls.Add(startBox, 1, 3);
layoutTable.Controls.Add(endLabel, 0, 4);
layoutTable.Controls.Add(endBox, 1, 4);
layoutTable.Controls.Add(allow180CheckBox, 1, 5);
layoutTable.Controls.Add(colorLabel, 0, 6);
layoutTable.Controls.Add(colorButton, 1, 6);
layoutTable.Controls.Add(notesLabel, 0, 7);
layoutTable.Controls.Add(notesBox, 1, 7);
layoutTable.Controls.Add(statusLabel, 0, 8);
layoutTable.Controls.Add(buttonPanel, 0, 9);
layoutTable.SetColumnSpan(statusLabel, 2);
layoutTable.SetColumnSpan(buttonPanel, 2);
layoutTable.Dock = System.Windows.Forms.DockStyle.Fill;
layoutTable.Location = new System.Drawing.Point(0, 0);
layoutTable.Name = "layoutTable";
layoutTable.Padding = new System.Windows.Forms.Padding(8);
layoutTable.RowCount = 10;
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.Size = new System.Drawing.Size(464, 441);
layoutTable.TabIndex = 0;
//
// cutFirstCheckBox
//
cutFirstCheckBox.AutoSize = true;
cutFirstCheckBox.Name = "cutFirstCheckBox";
cutFirstCheckBox.TabIndex = 0;
cutFirstCheckBox.Text = "Cut first (sequence these parts before others)";
cutFirstCheckBox.UseVisualStyleBackColor = true;
cutFirstCheckBox.CheckedChanged += Value_Changed;
//
// priorityLabel
//
priorityLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
priorityLabel.AutoSize = true;
priorityLabel.Name = "priorityLabel";
priorityLabel.Text = "Priority:";
//
// priorityBox
//
priorityBox.Anchor = System.Windows.Forms.AnchorStyles.Left;
priorityBox.Maximum = int.MaxValue;
priorityBox.Minimum = int.MinValue;
priorityBox.Name = "priorityBox";
priorityBox.Size = new System.Drawing.Size(100, 25);
priorityBox.TabIndex = 1;
priorityBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
priorityBox.ValueChanged += Value_Changed;
//
// stepLabel
//
stepLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
stepLabel.AutoSize = true;
stepLabel.Name = "stepLabel";
stepLabel.Text = "Rotation step (°):";
//
// stepBox
//
stepBox.Anchor = System.Windows.Forms.AnchorStyles.Left;
stepBox.DecimalPlaces = 2;
stepBox.Maximum = new decimal(new int[] { 360, 0, 0, 0 });
stepBox.Name = "stepBox";
stepBox.Size = new System.Drawing.Size(100, 25);
stepBox.TabIndex = 2;
stepBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
stepBox.ValueChanged += Value_Changed;
//
// startLabel
//
startLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
startLabel.AutoSize = true;
startLabel.Name = "startLabel";
startLabel.Text = "Rotation start (°):";
//
// startBox
//
startBox.Anchor = System.Windows.Forms.AnchorStyles.Left;
startBox.DecimalPlaces = 2;
startBox.Maximum = new decimal(new int[] { 360, 0, 0, 0 });
startBox.Name = "startBox";
startBox.Size = new System.Drawing.Size(100, 25);
startBox.TabIndex = 3;
startBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
startBox.ValueChanged += Value_Changed;
//
// endLabel
//
endLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
endLabel.AutoSize = true;
endLabel.Name = "endLabel";
endLabel.Text = "Rotation end (°):";
//
// endBox
//
endBox.Anchor = System.Windows.Forms.AnchorStyles.Left;
endBox.DecimalPlaces = 2;
endBox.Maximum = new decimal(new int[] { 360, 0, 0, 0 });
endBox.Name = "endBox";
endBox.Size = new System.Drawing.Size(100, 25);
endBox.TabIndex = 4;
endBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
endBox.ValueChanged += Value_Changed;
//
// allow180CheckBox
//
allow180CheckBox.AutoSize = true;
allow180CheckBox.Name = "allow180CheckBox";
allow180CheckBox.TabIndex = 5;
allow180CheckBox.Text = "Allow 180° equivalent";
allow180CheckBox.UseVisualStyleBackColor = true;
allow180CheckBox.CheckedChanged += Value_Changed;
//
// colorLabel
//
colorLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
colorLabel.AutoSize = true;
colorLabel.Name = "colorLabel";
colorLabel.Text = "Color:";
//
// colorButton
//
colorButton.Anchor = System.Windows.Forms.AnchorStyles.Left;
colorButton.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
colorButton.Name = "colorButton";
colorButton.Size = new System.Drawing.Size(60, 25);
colorButton.TabIndex = 6;
colorButton.Click += ColorButton_Click;
//
// notesLabel
//
notesLabel.AutoSize = true;
notesLabel.Margin = new System.Windows.Forms.Padding(3, 6, 3, 0);
notesLabel.Name = "notesLabel";
notesLabel.Text = "Notes:";
//
// notesBox
//
notesBox.AcceptsReturn = true;
notesBox.Dock = System.Windows.Forms.DockStyle.Fill;
notesBox.MaxLength = 4000;
notesBox.Multiline = true;
notesBox.Name = "notesBox";
notesBox.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
notesBox.TabIndex = 7;
notesBox.TextChanged += Value_Changed;
//
// statusLabel
//
statusLabel.AutoEllipsis = true;
statusLabel.Dock = System.Windows.Forms.DockStyle.Fill;
statusLabel.ForeColor = System.Drawing.Color.Firebrick;
statusLabel.Name = "statusLabel";
statusLabel.Size = new System.Drawing.Size(100, 20);
//
// buttonPanel
//
buttonPanel.AutoSize = true;
buttonPanel.Controls.Add(cancelButton);
buttonPanel.Controls.Add(saveButton);
buttonPanel.Dock = System.Windows.Forms.DockStyle.Fill;
buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft;
buttonPanel.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
buttonPanel.Name = "buttonPanel";
buttonPanel.TabIndex = 8;
//
// cancelButton
//
cancelButton.AutoSize = true;
cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
cancelButton.Name = "cancelButton";
cancelButton.TabIndex = 1;
cancelButton.Text = "Cancel";
cancelButton.UseVisualStyleBackColor = true;
//
// saveButton
//
saveButton.AutoSize = true;
saveButton.Enabled = false;
saveButton.Name = "saveButton";
saveButton.TabIndex = 0;
saveButton.Text = "Save to Library";
saveButton.UseVisualStyleBackColor = true;
saveButton.Click += SaveButton_Click;
//
// EditLibraryDrawingForm
//
AcceptButton = saveButton;
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
CancelButton = cancelButton;
ClientSize = new System.Drawing.Size(464, 441);
Controls.Add(layoutTable);
Font = new System.Drawing.Font("Segoe UI", 9.75F);
MaximizeBox = false;
MinimizeBox = false;
MinimumSize = new System.Drawing.Size(420, 400);
Name = "EditLibraryDrawingForm";
ShowIcon = false;
ShowInTaskbar = false;
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Edit Library Drawing";
layoutTable.ResumeLayout(false);
layoutTable.PerformLayout();
((System.ComponentModel.ISupportInitialize)priorityBox).EndInit();
((System.ComponentModel.ISupportInitialize)stepBox).EndInit();
((System.ComponentModel.ISupportInitialize)startBox).EndInit();
((System.ComponentModel.ISupportInitialize)endBox).EndInit();
buttonPanel.ResumeLayout(false);
buttonPanel.PerformLayout();
ResumeLayout(false);
}
#endregion
private System.Windows.Forms.TableLayoutPanel layoutTable;
private System.Windows.Forms.CheckBox cutFirstCheckBox;
private System.Windows.Forms.Label priorityLabel;
private System.Windows.Forms.NumericUpDown priorityBox;
private System.Windows.Forms.Label stepLabel;
private System.Windows.Forms.NumericUpDown stepBox;
private System.Windows.Forms.Label startLabel;
private System.Windows.Forms.NumericUpDown startBox;
private System.Windows.Forms.Label endLabel;
private System.Windows.Forms.NumericUpDown endBox;
private System.Windows.Forms.CheckBox allow180CheckBox;
private System.Windows.Forms.Label colorLabel;
private System.Windows.Forms.Button colorButton;
private System.Windows.Forms.Label notesLabel;
private System.Windows.Forms.TextBox notesBox;
private System.Windows.Forms.Label statusLabel;
private System.Windows.Forms.FlowLayoutPanel buttonPanel;
private System.Windows.Forms.Button cancelButton;
private System.Windows.Forms.Button saveButton;
}
}
+147
View File
@@ -0,0 +1,147 @@
using System;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Math;
namespace OpenNest.Forms
{
/// <summary>
/// Drawing library &gt; Edit Settings. Edits one library drawing's notes and nesting
/// settings (Cut first, priority, rotation constraints, color) through
/// <see cref="DrawingLibraryEditSession"/>. Saving adds a revision on the server that
/// keeps the same package; nests that already hold the drawing are not changed.
/// </summary>
public partial class EditLibraryDrawingForm : Form
{
private readonly DrawingLibraryEditSession session;
private readonly decimal initialStep;
private readonly decimal initialStart;
private readonly decimal initialEnd;
private Color color;
private bool saving;
/// <summary>Shows a message; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <summary>The saved revision once the dialog closes with OK; otherwise null.</summary>
public DrawingLibraryRecord Saved { get; private set; }
public EditLibraryDrawingForm(DrawingLibraryEditSession session)
{
InitializeComponent();
this.session = session ?? throw new ArgumentNullException(nameof(session));
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
var listed = session.Listed;
var settings = listed.Settings ?? new DrawingNestingSettings();
cutFirstCheckBox.Checked = settings.CutFirst;
priorityBox.Value = settings.Priority;
initialStep = SetDegrees(stepBox, settings.StepAngle);
initialStart = SetDegrees(startBox, settings.StartAngle);
initialEnd = SetDegrees(endBox, settings.EndAngle);
allow180CheckBox.Checked = settings.Allow180Equivalent;
color = Color.FromArgb(settings.ColorArgb);
colorButton.BackColor = color.A == 0 ? SystemColors.Control : color;
notesBox.Text = listed.Notes;
Text = $"Edit '{listed.Name}' — library revision {listed.Revision}";
UpdateCommands();
}
/// <summary>The settings as currently entered; unchanged angles keep their exact stored radians.</summary>
internal DrawingNestingSettings ReadSettings()
{
var listed = session.Listed.Settings ?? new DrawingNestingSettings();
return new DrawingNestingSettings
{
CutFirst = cutFirstCheckBox.Checked,
Priority = (int)priorityBox.Value,
StepAngle = ReadDegrees(stepBox, initialStep, listed.StepAngle),
StartAngle = ReadDegrees(startBox, initialStart, listed.StartAngle),
EndAngle = ReadDegrees(endBox, initialEnd, listed.EndAngle),
Allow180Equivalent = allow180CheckBox.Checked,
ColorArgb = color.ToArgb(),
};
}
private void Value_Changed(object sender, EventArgs e) => UpdateCommands();
private void ColorButton_Click(object sender, EventArgs e)
{
using var dialog = new ColorDialog { Color = color, FullOpen = true };
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
// Opaque, as nest drawings are.
color = Color.FromArgb(255, dialog.Color);
colorButton.BackColor = color;
UpdateCommands();
}
private async void SaveButton_Click(object sender, EventArgs e) => await SaveAsync();
/// <summary>Saves the edit as a new library revision; closes with OK on success.</summary>
internal async Task SaveAsync()
{
if (saving || !CanSave())
return;
saving = true;
UpdateCommands();
statusLabel.Text = "Saving...";
try
{
Saved = await session.SaveAsync(notesBox.Text, ReadSettings());
if (IsDisposed)
return;
DialogResult = DialogResult.OK;
Close();
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException
|| ex is ArgumentException)
{
// A conflict (someone else saved first) is an IOException too; nothing was changed.
if (IsDisposed)
return;
statusLabel.Text = "";
ShowMessage(ex is DrawingLibraryConflictException
? ex.Message + Environment.NewLine + Environment.NewLine + "Close this dialog to reload the library, then edit again."
: $"Could not save the library drawing: {ex.Message}", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
finally
{
saving = false;
if (!IsDisposed)
UpdateCommands();
}
}
private bool CanSave()
{
var settings = ReadSettings();
return session.GetError(notesBox.Text, settings) == null && !session.IsUnchanged(notesBox.Text, settings);
}
private void UpdateCommands()
{
var error = session.GetError(notesBox.Text, ReadSettings());
saveButton.Enabled = !saving && CanSave();
if (!saving)
statusLabel.Text = error == null ? "" : char.ToUpper(error[0], CultureInfo.CurrentCulture) + error[1..];
}
private static decimal SetDegrees(NumericUpDown box, double radians)
{
var degrees = (decimal)System.Math.Round(Angle.ToDegrees(radians), box.DecimalPlaces);
box.Value = System.Math.Clamp(degrees, box.Minimum, box.Maximum);
return box.Value;
}
private static double ReadDegrees(NumericUpDown box, decimal initial, double original) =>
box.Value == initial ? original : Angle.ToRadians((double)box.Value);
}
}
+1 -1
View File
@@ -1227,7 +1227,7 @@ namespace OpenNest.Forms
if (!NestStorage.Settings.IsDatabaseMode)
return;
IDrawingLibrary library;
IDrawingLibraryStore library;
try
{
library = NestStorage.CreateDrawingLibrary();
+28 -6
View File
@@ -33,6 +33,8 @@ namespace OpenNest.Forms
cutTypeBox = new System.Windows.Forms.ComboBox();
notesLabel = new System.Windows.Forms.Label();
notesBox = new System.Windows.Forms.TextBox();
settingsTitleLabel = new System.Windows.Forms.Label();
settingsLabel = new System.Windows.Forms.Label();
saveAsLabel = new System.Windows.Forms.Label();
saveAsBox = new System.Windows.Forms.ComboBox();
duplicateLabel = new System.Windows.Forms.Label();
@@ -65,11 +67,13 @@ namespace OpenNest.Forms
layoutTable.Controls.Add(cutTypeBox, 1, 5);
layoutTable.Controls.Add(notesLabel, 0, 6);
layoutTable.Controls.Add(notesBox, 1, 6);
layoutTable.Controls.Add(saveAsLabel, 0, 7);
layoutTable.Controls.Add(saveAsBox, 1, 7);
layoutTable.Controls.Add(duplicateLabel, 0, 8);
layoutTable.Controls.Add(statusLabel, 0, 9);
layoutTable.Controls.Add(buttonPanel, 0, 10);
layoutTable.Controls.Add(settingsTitleLabel, 0, 7);
layoutTable.Controls.Add(settingsLabel, 1, 7);
layoutTable.Controls.Add(saveAsLabel, 0, 8);
layoutTable.Controls.Add(saveAsBox, 1, 8);
layoutTable.Controls.Add(duplicateLabel, 0, 9);
layoutTable.Controls.Add(statusLabel, 0, 10);
layoutTable.Controls.Add(buttonPanel, 0, 11);
layoutTable.SetColumnSpan(duplicateLabel, 2);
layoutTable.SetColumnSpan(statusLabel, 2);
layoutTable.SetColumnSpan(buttonPanel, 2);
@@ -77,7 +81,8 @@ namespace OpenNest.Forms
layoutTable.Location = new System.Drawing.Point(0, 0);
layoutTable.Name = "layoutTable";
layoutTable.Padding = new System.Windows.Forms.Padding(8);
layoutTable.RowCount = 11;
layoutTable.RowCount = 12;
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
@@ -212,6 +217,21 @@ namespace OpenNest.Forms
notesBox.TabIndex = 13;
notesBox.TextChanged += Notes_Changed;
//
// settingsTitleLabel
//
settingsTitleLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
settingsTitleLabel.AutoSize = true;
settingsTitleLabel.Name = "settingsTitleLabel";
settingsTitleLabel.Text = "Nesting:";
//
// settingsLabel
//
settingsLabel.AutoEllipsis = true;
settingsLabel.Dock = System.Windows.Forms.DockStyle.Fill;
settingsLabel.Name = "settingsLabel";
settingsLabel.Size = new System.Drawing.Size(100, 24);
settingsLabel.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// saveAsLabel
//
saveAsLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
@@ -324,6 +344,8 @@ namespace OpenNest.Forms
private System.Windows.Forms.ComboBox cutTypeBox;
private System.Windows.Forms.Label notesLabel;
private System.Windows.Forms.TextBox notesBox;
private System.Windows.Forms.Label settingsTitleLabel;
private System.Windows.Forms.Label settingsLabel;
private System.Windows.Forms.Label saveAsLabel;
private System.Windows.Forms.ComboBox saveAsBox;
private System.Windows.Forms.Label duplicateLabel;
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net.Http;
@@ -6,6 +7,7 @@ using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.IO;
using OpenNest.Math;
namespace OpenNest.Forms
{
@@ -20,6 +22,7 @@ namespace OpenNest.Forms
{
private readonly DrawingLibrarySaveSession session;
private readonly string savedBy;
private readonly DrawingNestingSettings settings;
private DrawingLibraryDuplicates duplicates;
private int lookupGeneration;
private bool loading;
@@ -54,6 +57,8 @@ namespace OpenNest.Forms
thicknessLabel.Text = $"Thickness ({UnitsHelper.GetShortString(metadata.Units)}):";
cutTypeBox.SelectedItem = metadata.CutType;
notesBox.Text = metadata.Notes;
settings = metadata.Settings;
settingsLabel.Text = DescribeSettings(settings);
loading = false;
Text = $"Save '{drawing.Name}' to Drawing Library — {serverUrl}";
@@ -240,9 +245,27 @@ namespace OpenNest.Forms
Units = session.Nest.Units,
CutType = cutTypeBox.SelectedItem is CutType cutType ? cutType : CutType.Laser,
Notes = notesBox.Text,
Settings = settings,
SavedBy = savedBy,
};
/// <summary>A one-line summary of the drawing's nesting settings, which are saved with it.</summary>
internal static string DescribeSettings(DrawingNestingSettings settings)
{
var parts = new List<string>();
if (settings.CutFirst)
parts.Add("Cut first");
parts.Add($"priority {settings.Priority.ToString(CultureInfo.CurrentCulture)}");
var start = Angle.ToDegrees(settings.StartAngle);
var end = Angle.ToDegrees(settings.EndAngle);
parts.Add($"rotation {start.ToString("0.##", CultureInfo.CurrentCulture)}°–{end.ToString("0.##", CultureInfo.CurrentCulture)}°");
if (settings.StepAngle > 0)
parts.Add($"step {Angle.ToDegrees(settings.StepAngle).ToString("0.##", CultureInfo.CurrentCulture)}°");
if (settings.Allow180Equivalent)
parts.Add("180° equivalent");
return string.Join(", ", parts) + " (from the drawing; edit later in the library)";
}
private bool CanSave()
{
var metadata = ReadMetadata();
+19
View File
@@ -157,6 +157,25 @@ thickness loads the drawing without a link and adds a reader warning. Converter
edits keep the link as provenance; split pieces get none. Drawing packages leave
the link out, so linking a drawing does not change its package bytes.
### Drawing packages
A library drawing's geometry travels as a canonical drawing package
(`OpenNest.IO.DrawingPackage`): a `.nest` archive with one drawing and no plates,
fixed archive timestamps and dates, and no best-fit entries, so equal content has
equal bytes and SHA-256. The server stores each distinct package once by that hash.
A package holds geometry and geometry-bound data only: program, source entities and
offset, suppressed entity IDs, bends, cut pins, breakoffs and material density. Fields
the server's library record owns are written as their defaults, so editing them never
changes the hash:
- nest and drawing `name` (blank), drawing `customer`, material `name` and `grade` (blank);
- `color`, `priority`, `cutFirst` and `constraints` (omitted);
- `quantity.required` (zero) and `library` (omitted).
Adding a library drawing to a nest ignores whatever a package holds for these fields
and applies the record's values.
## Drawing cut-first flag
A drawing may write `cutFirst: true` to have automatic part sequencing cut its parts
+44 -13
View File
@@ -143,7 +143,7 @@ only the requested page of metadata (never archive bytes):
## Drawing library endpoints
The server also keeps a shared library of drawings (schema v4). The desktop
The server also keeps a shared library of drawings (schema v5). The desktop
adds library drawings to a nest and saves nest drawings to the library (below).
A drawing's geometry travels as an opaque
**drawing package**: a `.nest` archive holding exactly one drawing and no plates,
@@ -153,18 +153,28 @@ parses it.
`OpenNest.Data.DrawingLibraryRecord` fields: `id` (client-allocated before the
first upload), `revision`, `name`, `customer`, `materialName`, `materialGrade`,
`thickness` (stored rounded to 4 decimals), `units` (`inches` | `millimeters`),
`cutType` (`laser` | `waterjet` | `plasma`), `notes`, `archived`, `savedBy`, and
the server-computed `packageSha256` (lower-case hex), `packageSize` and `savedAt`.
`cutType` (`laser` | `waterjet` | `plasma`), `notes`, `settings`, `archived`,
`savedBy`, and the server-computed `packageSha256` (lower-case hex), `packageSize`
and `savedAt`.
`settings` holds the drawing's nesting settings, applied when it is added to a
nest: `cutFirst` (sequence its parts first, for example test squares), `priority`,
`stepAngle`, `startAngle`, `endAngle` (radians), `allow180Equivalent` and
`colorArgb` (32-bit ARGB; alpha 0 means no stored color, so the nest assigns one).
They are record fields, not package content (see
[drawing packages](nest-file-format.md#drawing-packages)), so changing them adds a
revision without new geometry. Default quantity is not stored: quantity belongs to
each nest.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | `/api/drawings` | multipart: `metadata` (JSON record with a nonempty `id`) + `package` | Revision 1; 409 with the current record when the id exists |
| GET | `/api/drawings/query?search=&customer=&materialName=&materialGrade=&thickness=&cutType=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }` |
| GET | `/api/drawings/query?search=&customer=&materialName=&materialGrade=&thickness=&cutType=&cutFirst=&includeArchived=&sort=&order=&offset=&limit=` | — | `{ items, total, offset, limit }` |
| GET | `/api/drawings/{id}` / `/{id}/file` | — | Current record / package bytes (archived drawings included), or 404 |
| GET | `/api/drawings/{id}/revisions?offset=0&limit=100` | — | Revision page, newest first; limit 1–200 |
| GET | `/api/drawings/{id}/revisions/{revision}/file` | — | Exact immutable package bytes |
| PUT | `/api/drawings/{id}/file` | multipart: `metadata` + `package` | New revision |
| PUT | `/api/drawings/{id}/metadata` | JSON record | New revision with the same package |
| PUT | `/api/drawings/{id}/metadata` | JSON record (all details and `settings`; an omitted `settings` means defaults) | New revision with the same package |
| POST | `/api/drawings/{id}/archive` / `/restore` | — | New revision with `archived` changed |
- Existing-drawing writes require `If-Match` exactly as for nests: 428 missing,
@@ -174,11 +184,16 @@ the server-computed `packageSha256` (lower-case hex), `packageSize` and `savedAt
the query unless `includeArchived=true`.
- Every change appends an immutable revision in the same transaction that
updates the current row, including metadata-only edits and archive/restore.
An identical save at the current revision is a no-op.
An identical save (details, settings and package hash) at the current
revision is a no-op.
- Packages are content-addressed: each distinct package is stored once, keyed by
its SHA-256, and revisions point at it. Only a new package adds a blob;
details, settings, archive and restore revisions add none.
- Validation (400, no state change): `cutType` and `units` must be single enum
names (no numbers or combinations); `thickness` finite and at least 0; `name`
nonblank; name, customer, material, grade and savedBy at most 200 characters,
notes at most 4000; no NUL characters; package nonempty, at most 16 MiB and
notes at most 4000; no NUL characters; `settings` not null, angles finite
and `stepAngle` at least 0; package nonempty, at most 16 MiB and
starting with a ZIP signature. Metadata `id` on a PUT must be empty or match
the route.
- Query: `search` is a literal, case-insensitive (ASCII) substring of name,
@@ -187,14 +202,19 @@ the server-computed `packageSha256` (lower-case hex), `packageSize` and `savedAt
(trimmed, collapsed spaces, case-insensitive); blank means any. `thickness`
matches exactly after rounding to 4 decimals. `sort` is one of `savedAt`
(default), `name`, `customer`, `materialName`, `materialGrade`, `thickness`,
`cutType`, `revision`; ties break by `id`. Paging, `order` and the refusal of
`cutType`, `revision`; ties break by `id`. `cutFirst=true|false` keeps only
drawings with that setting. Paging, `order` and the refusal of
unknown, repeated or out-of-range parameters follow the nest query.
- An older server answers 404 on these routes; the client reports that the
server must be updated and never falls back to another store.
Schema v4 adds the `drawings` and `drawing_revisions` tables to the same file
and leaves the nest tables untouched. A server older than v4 refuses to open a
v4 database, so rolling back after the upgrade needs the pre-upgrade backup (see
Schema v5 keeps three drawing tables in the same file: `drawing_packages`
(`sha256`, `size`, `package`), `drawing_revisions` (every immutable revision's
details, settings, archived flag and package hash) and `drawings` (each
drawing's current revision, indexed for browsing). Upgrading replaces the v4
drawing tables outright, discarding any v4 library data (no production library
existed), and leaves the nest tables untouched. A server older than v5 refuses
to open a v5 database, so rolling back after the upgrade needs the pre-upgrade backup (see
[Backup, restore, and upgrade](#backup-restore-and-upgrade)). The stopped-volume
`tar` backup includes the drawing tables, but `nest_manifest.py` and
`restore_check` do not verify drawings yet.
@@ -221,7 +241,16 @@ plans the insert (`OpenNest.Data.LibraryDrawingInsert`) before changing anything
The added drawing is an independent copy carrying a library link (server key,
id, revision and a snapshot of the library details) that is saved with the nest
(see [the nest file format](nest-file-format.md#drawing-library-links)). Editing it
in the nest never changes the library.
in the nest never changes the library. The copy takes its name, customer,
material and nesting settings from the library record, not from the package.
The browser's **Cut first** column shows the setting. **Edit Settings...**
(`OpenNest.Data.DrawingLibraryEditSession`) edits the selected active drawing's
Cut first flag, priority, rotation step/start/end (degrees), 180° equivalent,
color and notes, and saves them through the metadata endpoint guarded by the
listed revision: a new revision with the same package. A 409 is shown and the
operator reloads. Nests that already hold the drawing keep their copy and
revision.
### Save to Drawing Library (desktop)
@@ -232,7 +261,9 @@ from the drawing (the nest's customer when the drawing has none), material,
grade, thickness and units from the nest (the drawing's material when the nest
has none), and the cut type and notes of the linked revision when the drawing
came from this server's library, otherwise the cut type last saved on this PC.
Units follow the nest and cannot be changed.
Units follow the nest and cannot be changed. The drawing's own nesting
settings (Cut first, priority, rotation, color) are saved in the record and
summarized read-only in the dialog; change them later with Edit Settings.
Whenever the details change, the dialog looks up active library drawings with
the same normalized customer, name, material and grade and the exact thickness,