diff --git a/OpenNest.Data/LibraryDrawingInsert.cs b/OpenNest.Data/LibraryDrawingInsert.cs
new file mode 100644
index 00000000..46a97ec5
--- /dev/null
+++ b/OpenNest.Data/LibraryDrawingInsert.cs
@@ -0,0 +1,204 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using OpenNest;
+
+namespace OpenNest.Data;
+
+/// A downloaded library package as the caller read it (with DrawingPackage.Read).
+public sealed class LibraryPackage
+{
+ public LibraryPackage(byte[] bytes, Drawing drawing, Units units, IReadOnlyList? warnings = null)
+ {
+ ArgumentNullException.ThrowIfNull(bytes);
+ ArgumentNullException.ThrowIfNull(drawing);
+ Sha256 = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
+ Drawing = drawing;
+ Units = units;
+ Warnings = warnings ?? Array.Empty();
+ }
+
+ /// Lower-case hex SHA-256 of the downloaded bytes.
+ public string Sha256 { get; }
+
+ /// The package's drawing; the insert adopts this instance as the nest's copy.
+ public Drawing Drawing { get; }
+
+ public Units Units { get; }
+
+ public IReadOnlyList Warnings { get; }
+}
+
+public enum LibraryInsertKind
+{
+ /// The library drawing becomes a new nest drawing.
+ AddDrawing,
+
+ /// The nest already holds this exact revision; only its quantity grows.
+ AddQuantity,
+
+ /// Nothing may change; says why.
+ Refused,
+}
+
+///
+/// Decides how one library drawing revision enters a nest, before anything changes:
+/// a new exact copy carrying its library link, more quantity on the copy already there,
+/// or a refusal. Material, grade or thickness that differ from the nest still allow the
+/// add, but the drawing's cut pins are dropped (D3). Different units, a different revision
+/// of the same drawing, or another drawing with the same name refuse it.
+///
+public sealed class LibraryDrawingInsert
+{
+ private bool _applied;
+
+ private LibraryDrawingInsert(Nest nest, LibraryInsertKind kind, Drawing? drawing, int quantity,
+ bool keepPins, IReadOnlyList warnings, string refusal)
+ {
+ Nest = nest;
+ Kind = kind;
+ Drawing = drawing;
+ Quantity = quantity;
+ KeepPins = keepPins;
+ Warnings = warnings;
+ Refusal = refusal;
+ }
+
+ public Nest Nest { get; }
+
+ public LibraryInsertKind Kind { get; }
+
+ /// The new copy () or the existing one; null when refused.
+ public Drawing? Drawing { get; }
+
+ /// The quantity added to the nest's required count.
+ public int Quantity { get; }
+
+ ///
+ /// For a new copy, true when the library drawing's cut pins may be used in this nest:
+ /// the library material, grade and thickness match the nest's. An added quantity
+ /// leaves the existing copy as it is.
+ ///
+ public bool KeepPins { get; }
+
+ /// Operator-facing notes to show before the add completes.
+ public IReadOnlyList Warnings { get; }
+
+ /// Why nothing may change; empty unless is .
+ public string Refusal { get; }
+
+ public bool IsRefused => Kind == LibraryInsertKind.Refused;
+
+ public static LibraryDrawingInsert Plan(Nest nest, string serverUrl, DrawingLibraryRecord record,
+ LibraryPackage package, int quantity)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentNullException.ThrowIfNull(package);
+ ArgumentOutOfRangeException.ThrowIfLessThan(quantity, 1);
+ var serverKey = ServerUrl.Key(serverUrl);
+ if (serverKey.Length == 0)
+ throw new ArgumentException("A library insert needs the server the drawing came from.", nameof(serverUrl));
+
+ var label = Describe(record);
+ if (!string.Equals(package.Sha256, record.PackageSha256, StringComparison.OrdinalIgnoreCase))
+ return Refuse(nest, $"The downloaded package for {label} does not match the library record. Try again.");
+ if (package.Units != record.Units)
+ return Refuse(nest, $"The package for {label} is drawn in {UnitName(package.Units)}, "
+ + $"but its library record says {UnitName(record.Units)}.");
+ if (package.Units != nest.Units)
+ return Refuse(nest, $"{label} is drawn in {UnitName(package.Units)} and this nest is in "
+ + $"{UnitName(nest.Units)}. Converting units is not supported.");
+
+ var link = new DrawingLibraryLink(serverKey, record.Id, record.Revision, record.CutType,
+ record.Thickness, record.MaterialName, record.MaterialGrade, record.Units, record.Notes,
+ record.PackageSha256.ToLowerInvariant());
+
+ var sameRevision = nest.Drawings.FirstOrDefault(d => d.Library?.IsSameRevision(link) == true);
+ if (sameRevision != null)
+ return new LibraryDrawingInsert(nest, LibraryInsertKind.AddQuantity, sameRevision, quantity,
+ keepPins: false, Array.Empty(), "");
+
+ var otherRevision = nest.Drawings.FirstOrDefault(d => d.Library is { } l
+ && l.Id == link.Id && string.Equals(l.ServerKey, link.ServerKey, StringComparison.Ordinal));
+ if (otherRevision != null)
+ return Refuse(nest, $"This nest already uses revision {otherRevision.Library!.Revision} of this "
+ + $"library drawing as '{otherRevision.Name}'. Use Update Drawings to change its revision.");
+
+ // DrawingCollection is keyed by name, so a second drawing with this name would be lost.
+ var sameName = nest.Drawings.FirstOrDefault(d => string.Equals(d.Name, record.Name, StringComparison.Ordinal));
+ if (sameName != null)
+ return Refuse(nest, $"This nest already has a drawing named '{sameName.Name}' "
+ + $"({DescribeExisting(sameName)}), so {label} was not added. Rename one of them first.");
+
+ var differences = Differences(nest, record);
+ var warnings = package.Warnings.ToList();
+ if (differences.Count > 0)
+ 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.");
+
+ var drawing = package.Drawing;
+ drawing.Name = record.Name;
+ drawing.Customer = record.Customer;
+ drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0);
+ drawing.Quantity.Required = quantity;
+ drawing.Library = link;
+ return new LibraryDrawingInsert(nest, LibraryInsertKind.AddDrawing, drawing, quantity,
+ keepPins: differences.Count == 0, warnings, "");
+ }
+
+ /// Changes the planned nest; call at most once, and only for an insert that is not refused.
+ public void Apply()
+ {
+ if (IsRefused)
+ throw new InvalidOperationException(Refusal);
+ if (_applied)
+ throw new InvalidOperationException("This library insert was already applied.");
+
+ if (Kind == LibraryInsertKind.AddQuantity)
+ {
+ if (!Nest.Drawings.Any(d => ReferenceEquals(d, Drawing)))
+ throw new InvalidOperationException($"'{Drawing!.Name}' is no longer in this nest.");
+ Drawing!.Quantity.Required += Quantity;
+ }
+ else if (!Nest.Drawings.Add(Drawing!))
+ {
+ throw new InvalidOperationException($"This nest already has a drawing named '{Drawing!.Name}'.");
+ }
+
+ _applied = true;
+ }
+
+ private static LibraryDrawingInsert Refuse(Nest nest, string reason) =>
+ new(nest, LibraryInsertKind.Refused, null, 0, keepPins: false, Array.Empty(), reason);
+
+ private static List Differences(Nest nest, DrawingLibraryRecord record)
+ {
+ var differences = new List();
+ var material = nest.Material ?? new Material();
+ if (SharedListNames.Key(record.MaterialName) != SharedListNames.Key(material.Name))
+ differences.Add($"material {Text(record.MaterialName)} (nest {Text(material.Name)})");
+ if (SharedListNames.Key(record.MaterialGrade) != SharedListNames.Key(material.Grade))
+ differences.Add($"grade {Text(record.MaterialGrade)} (nest {Text(material.Grade)})");
+ var thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
+ var nestThickness = DrawingLibraryRules.NormalizeThickness(nest.Thickness);
+ if (thickness != nestThickness)
+ differences.Add($"thickness {Number(thickness)} (nest {Number(nestThickness)})");
+ return differences;
+ }
+
+ private static string Describe(DrawingLibraryRecord record) =>
+ string.IsNullOrWhiteSpace(record.Customer)
+ ? $"library drawing '{record.Name}' revision {record.Revision}"
+ : $"library drawing '{record.Name}' ({record.Customer}) revision {record.Revision}";
+
+ private static string DescribeExisting(Drawing drawing) =>
+ drawing.Library is { } link
+ ? $"library revision {link.Revision} from {link.ServerKey}"
+ : "not from the drawing library";
+
+ private static string Text(string? value) => string.IsNullOrWhiteSpace(value) ? "not set" : value.Trim();
+
+ private static string Number(double value) => value.ToString("0.####", CultureInfo.CurrentCulture);
+
+ private static string UnitName(Units units) => units.ToString().ToLowerInvariant();
+}
diff --git a/OpenNest.Data/ServerUrl.cs b/OpenNest.Data/ServerUrl.cs
index 0b4d25a1..06f3de01 100644
--- a/OpenNest.Data/ServerUrl.cs
+++ b/OpenNest.Data/ServerUrl.cs
@@ -3,6 +3,12 @@ namespace OpenNest.Data;
/// Base-address validation shared by the nest server's HTTP clients.
internal static class ServerUrl
{
+ ///
+ /// The key that binds saved nests and library links to a server: the address trimmed,
+ /// without trailing slashes. Two settings that differ only that way name the same server.
+ ///
+ public static string Key(string? url) => (url ?? "").Trim().TrimEnd('/');
+
///
/// Returns the absolute http(s) base address with exactly one trailing slash, so
/// relative combines replace no path segment. Throws for any other address.
diff --git a/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs
new file mode 100644
index 00000000..ec14d9ca
--- /dev/null
+++ b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs
@@ -0,0 +1,235 @@
+using System.Security.Cryptography;
+using OpenNest.Data;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests.Data;
+
+public class LibraryDrawingInsertTests
+{
+ private const string Server = "http://library.test:8090";
+ private static readonly Guid LibraryId = Guid.Parse("3f1e2d4c-5b6a-4987-8a1b-2c3d4e5f6a7b");
+ private static readonly byte[] PackageBytes = { (byte)'P', (byte)'K', 3, 4, 1, 2 };
+
+ private static Nest MatchingNest() => new("Synthetic job")
+ {
+ Units = Units.Inches,
+ Thickness = 0.25,
+ Material = new Material("Synthetic Steel", "Grade A"),
+ };
+
+ private static DrawingLibraryRecord Record(int revision = 3, string name = "SYN-101", byte[]? package = null) => new()
+ {
+ Id = LibraryId,
+ Revision = revision,
+ Name = name,
+ Customer = "Synthetic customer",
+ MaterialName = "Synthetic Steel",
+ MaterialGrade = "Grade A",
+ Thickness = 0.25,
+ Units = Units.Inches,
+ CutType = CutType.Plasma,
+ Notes = "Deburr",
+ PackageSha256 = Convert.ToHexString(SHA256.HashData(package ?? PackageBytes)).ToLowerInvariant(),
+ };
+
+ private static LibraryPackage Package(Units units = Units.Inches, IReadOnlyList? warnings = null)
+ {
+ var drawing = new RectangleShape { Name = "old package name", Length = 4, Width = 2 }.GetDrawing();
+ drawing.Material = new Material("Package material", "Package grade", 0.284);
+ drawing.Customer = "Package customer";
+ return new LibraryPackage(PackageBytes, drawing, units, warnings);
+ }
+
+ [Fact]
+ public void Matching_AddsAnExactCopyCarryingTheLinkAndKeepsPins()
+ {
+ var nest = MatchingNest();
+ var package = Package();
+
+ var insert = LibraryDrawingInsert.Plan(nest, Server + "/", Record(), package, 5);
+
+ Assert.Equal(LibraryInsertKind.AddDrawing, insert.Kind);
+ Assert.True(insert.KeepPins);
+ Assert.Empty(insert.Warnings);
+ Assert.Empty(nest.Drawings);
+
+ insert.Apply();
+
+ var drawing = Assert.Single(nest.Drawings);
+ Assert.Same(package.Drawing, drawing);
+ Assert.Equal(("SYN-101", "Synthetic customer", 5), (drawing.Name, drawing.Customer, drawing.Quantity.Required));
+ Assert.Equal(("Synthetic Steel", "Grade A", 0.284), (drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density));
+ var link = drawing.Library!;
+ Assert.Equal(
+ (Server, LibraryId, 3, CutType.Plasma, 0.25, "Synthetic Steel", "Grade A", Units.Inches, "Deburr", Record().PackageSha256),
+ (link.ServerKey, link.Id, link.Revision, link.CutType, link.Thickness, link.MaterialName, link.MaterialGrade,
+ link.Units, link.Notes, link.PackageSha256));
+ }
+
+ [Fact]
+ public void SpacingCaseAndThicknessRounding_StillMatch()
+ {
+ var nest = MatchingNest();
+ nest.Material = new Material(" synthetic STEEL ", "grade a");
+ nest.Thickness = 0.250004;
+
+ var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1);
+
+ Assert.True(insert.KeepPins);
+ Assert.Empty(insert.Warnings);
+ }
+
+ [Theory]
+ [InlineData("Other Steel", "Grade A", 0.25, "material Synthetic Steel (nest Other Steel)")]
+ [InlineData("Synthetic Steel", "Grade B", 0.25, "grade Grade A (nest Grade B)")]
+ [InlineData("Synthetic Steel", "Grade A", 0.1875, "thickness 0.25 (nest 0.1875)")]
+ [InlineData("", "", 0.0, "material Synthetic Steel (nest not set)")]
+ public void Mismatch_AddsButDropsPinsAndNamesTheDifference(string material, string grade, double thickness, string expected)
+ {
+ var nest = MatchingNest();
+ nest.Material = new Material(material, grade);
+ nest.Thickness = thickness;
+
+ var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 2);
+
+ Assert.Equal(LibraryInsertKind.AddDrawing, insert.Kind);
+ Assert.False(insert.KeepPins);
+ var warning = Assert.Single(insert.Warnings);
+ Assert.Contains(expected, warning);
+ Assert.Contains("'SYN-101'", warning);
+ Assert.Contains("cut pins are dropped", warning);
+ insert.Apply();
+ Assert.Single(nest.Drawings);
+ }
+
+ [Fact]
+ public void EveryDifference_IsNamed()
+ {
+ var nest = new Nest("Other job") { Units = Units.Inches, Thickness = 0.1875, Material = new Material("Other Steel", "Grade B") };
+
+ var warning = Assert.Single(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1).Warnings);
+
+ Assert.Contains("material Synthetic Steel (nest Other Steel)", warning);
+ Assert.Contains("grade Grade A (nest Grade B)", warning);
+ Assert.Contains("thickness 0.25 (nest 0.1875)", warning);
+ }
+
+ [Fact]
+ public void PackageWarnings_AreShownBeforeTheAdd()
+ {
+ var insert = LibraryDrawingInsert.Plan(MatchingNest(), Server, Record(),
+ Package(warnings: new[] { "reader note" }), 1);
+
+ Assert.Equal(new[] { "reader note" }, insert.Warnings);
+ Assert.True(insert.KeepPins);
+ }
+
+ [Fact]
+ public void DifferentUnits_AreRefused()
+ {
+ var nest = MatchingNest();
+ nest.Units = Units.Millimeters;
+
+ var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1);
+
+ AssertRefused(insert, nest, "inches", "millimeters");
+ }
+
+ [Fact]
+ public void PackageDisagreeingWithItsRecord_IsRefused()
+ {
+ var nest = MatchingNest();
+
+ AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(Units.Millimeters), 1), nest, "library record says inches");
+ AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(package: new byte[] { 9 }), Package(), 1), nest, "does not match");
+ }
+
+ [Fact]
+ public void SameRevisionAlreadyInTheNest_AddsQuantityOnly()
+ {
+ var nest = MatchingNest();
+ LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 4).Apply();
+ var existing = Assert.Single(nest.Drawings);
+
+ var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 3);
+
+ Assert.Equal(LibraryInsertKind.AddQuantity, insert.Kind);
+ Assert.Same(existing, insert.Drawing);
+ insert.Apply();
+ Assert.Same(existing, Assert.Single(nest.Drawings));
+ Assert.Equal(7, existing.Quantity.Required);
+ }
+
+ [Fact]
+ public void SameRevisionRenamedInTheNest_StillAddsQuantity()
+ {
+ var nest = MatchingNest();
+ var first = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1);
+ first.Drawing!.Name = "renamed";
+ first.Apply();
+
+ var insert = LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1);
+
+ Assert.Equal(LibraryInsertKind.AddQuantity, insert.Kind);
+ }
+
+ [Fact]
+ public void OtherRevisionOfTheSameDrawing_IsRefused()
+ {
+ var nest = MatchingNest();
+ var first = LibraryDrawingInsert.Plan(nest, Server, Record(revision: 2), Package(), 1);
+ first.Drawing!.Name = "renamed";
+ first.Apply();
+
+ AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(revision: 3), Package(), 1), nest,
+ "revision 2", "'renamed'", "Update Drawings");
+ }
+
+ [Fact]
+ public void DifferentDrawingWithTheSameName_IsRefusedNamingBoth()
+ {
+ var nest = MatchingNest();
+ nest.Drawings.Add(new RectangleShape { Name = "SYN-101", Length = 1, Width = 1 }.GetDrawing());
+
+ AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1), nest,
+ "'SYN-101' (not from the drawing library)", "library drawing 'SYN-101' (Synthetic customer) revision 3");
+ }
+
+ [Fact]
+ public void SameRevisionFromAnotherServer_IsADifferentDrawing()
+ {
+ var nest = MatchingNest();
+ LibraryDrawingInsert.Plan(nest, "http://other.test:8090", Record(), Package(), 1).Apply();
+
+ AssertRefused(LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1), nest,
+ "library revision 3 from http://other.test:8090");
+ }
+
+ [Fact]
+ public void Apply_RunsOnce()
+ {
+ var insert = LibraryDrawingInsert.Plan(MatchingNest(), Server, Record(), Package(), 1);
+ insert.Apply();
+
+ Assert.Throws(insert.Apply);
+ }
+
+ [Fact]
+ public void Plan_RefusesMissingInputs()
+ {
+ Assert.Throws(() => LibraryDrawingInsert.Plan(MatchingNest(), Server, Record(), Package(), 0));
+ Assert.Throws(() => LibraryDrawingInsert.Plan(MatchingNest(), " / ", Record(), Package(), 1));
+ }
+
+ private static void AssertRefused(LibraryDrawingInsert insert, Nest nest, params string[] fragments)
+ {
+ var before = nest.Drawings.Select(d => (d, d.Quantity.Required)).ToList();
+
+ Assert.True(insert.IsRefused);
+ Assert.Null(insert.Drawing);
+ foreach (var fragment in fragments)
+ Assert.Contains(fragment, insert.Refusal);
+ Assert.Throws(insert.Apply);
+ Assert.Equal(before, nest.Drawings.Select(d => (d, d.Quantity.Required)).ToList());
+ }
+}