mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 23:11:07 -04:00
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 > 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);
|
||||
}
|
||||
}
|
||||
@@ -1227,7 +1227,7 @@ namespace OpenNest.Forms
|
||||
if (!NestStorage.Settings.IsDatabaseMode)
|
||||
return;
|
||||
|
||||
IDrawingLibrary library;
|
||||
IDrawingLibraryStore library;
|
||||
try
|
||||
{
|
||||
library = NestStorage.CreateDrawingLibrary();
|
||||
|
||||
+28
-6
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user