diff --git a/OpenNest.Data/NestRecord.cs b/OpenNest.Data/NestRecord.cs
index e77d592f..a828b968 100644
--- a/OpenNest.Data/NestRecord.cs
+++ b/OpenNest.Data/NestRecord.cs
@@ -34,6 +34,12 @@ public sealed class NestRecord
public double Thickness { get; set; }
+ ///
+ /// The nest's units, which is in. The server reads them from the
+ /// archive like the counts; inches when the archive has none or the server predates them.
+ ///
+ public Units Units { get; set; }
+
/// quote | toBeCut | hasBeenCut (camelCase on the wire, case-insensitive readers).
public NestStatus Status { get; set; } = NestStatus.Quote;
diff --git a/OpenNest.Data/NestRecordFactory.cs b/OpenNest.Data/NestRecordFactory.cs
index fc13cb60..04923ca7 100644
--- a/OpenNest.Data/NestRecordFactory.cs
+++ b/OpenNest.Data/NestRecordFactory.cs
@@ -46,6 +46,7 @@ public static class NestRecordFactory
Material = nest.Material?.Name ?? "",
MaterialGrade = nest.Material?.Grade ?? "",
Thickness = nest.Thickness,
+ Units = nest.Units,
Status = nest.Status,
// The trailing empty plate is the editor's new-plate slot, not a plate of the nest.
PlateCount = PlateDisplayNumbering.DisplayedPlateCount(nest.Plates),
diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
index 372efb15..6585b4b7 100644
--- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs
@@ -22,7 +22,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(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -43,7 +43,7 @@ public sealed class DrawingLibraryDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
{
- Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(before, NestDigest(factory.DatabasePath));
// Two distinct blobs across four revisions: the bracket's notes edit and the
// plate both reuse the bracket's first package.
@@ -80,7 +80,7 @@ public sealed class DrawingLibraryDatabaseTests
{
using var factory = new ServerFactory();
using (new NestDatabase(factory.DatabasePath, pooling: false)) { }
- Execute(factory.DatabasePath, "PRAGMA user_version=12");
+ Execute(factory.DatabasePath, "PRAGMA user_version=13");
Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false));
}
diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
index 7265f605..86b0ffb7 100644
--- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
+++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs
@@ -22,7 +22,7 @@ public sealed class DrawingSetDatabaseTests
using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items);
Assert.Equal(before, Digest(factory.DatabasePath));
- Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
@@ -47,7 +47,7 @@ public sealed class DrawingSetDatabaseTests
Assert.Equal(before, Digest(factory.DatabasePath));
Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing");
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
- Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
[Fact]
diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs
index fe4fe93c..9aa24cbf 100644
--- a/OpenNest.Server.Tests/MaterialListTests.cs
+++ b/OpenNest.Server.Tests/MaterialListTests.cs
@@ -24,7 +24,7 @@ public sealed class MaterialListTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
- Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Empty(upgraded.ListMaterials());
Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade));
Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName);
diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs
index 0e04840b..f51502bb 100644
--- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs
+++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs
@@ -74,7 +74,7 @@ public sealed class NestMaterialGradeTests
Assert.Equal("Stainless", record.Material);
Assert.Equal("", record.MaterialGrade);
Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade);
- Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
}
private static NestRecord Record(string name, string material, string grade) => new()
diff --git a/OpenNest.Server.Tests/NestUnitsTests.cs b/OpenNest.Server.Tests/NestUnitsTests.cs
new file mode 100644
index 00000000..b2262637
--- /dev/null
+++ b/OpenNest.Server.Tests/NestUnitsTests.cs
@@ -0,0 +1,109 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Data.Sqlite;
+using OpenNest.Data;
+
+namespace OpenNest.Server.Tests;
+
+///
+/// Nest records carry the archive's units, which their thickness is in, so a list of nests can
+/// tell a millimeter nest from an inch one without reading each archive.
+///
+public sealed class NestUnitsTests
+{
+ [Fact]
+ public async Task Upload_TakesUnitsFromTheArchive_NotTheClientsClaim()
+ {
+ using var factory = new ServerFactory();
+ using var client = factory.CreateClient();
+ var nest = NestApiTests.SyntheticNest();
+ nest.Units = Units.Millimeters;
+ nest.Thickness = 6.35;
+
+ using var created = await client.PostAsync("/api/nests",
+ NestRevisionTests.Body(new NestRecord { Name = "Metric", Units = Units.Inches }, NestApiTests.Archive(nest)));
+ var json = await created.Content.ReadAsStringAsync();
+ var id = JsonDocument.Parse(json).RootElement.GetProperty("id").GetGuid();
+
+ Assert.Equal("millimeters", JsonDocument.Parse(json).RootElement.GetProperty("units").GetString());
+ using var listed = JsonDocument.Parse(await client.GetStringAsync("/api/nests/query"));
+ var item = Assert.Single(listed.RootElement.GetProperty("items").EnumerateArray());
+ Assert.Equal("millimeters", item.GetProperty("units").GetString());
+ Assert.Equal(6.35, item.GetProperty("thickness").GetDouble());
+ using var record = JsonDocument.Parse(await client.GetStringAsync($"/api/nests/{id}"));
+ Assert.Equal("millimeters", record.RootElement.GetProperty("units").GetString());
+ }
+
+ [Fact]
+ public void MetadataOnlySave_KeepsTheArchivesUnits()
+ {
+ using var factory = new ServerFactory();
+ using var database = new NestDatabase(factory.DatabasePath, pooling: false);
+ var id = Guid.NewGuid();
+ var created = database.Insert(id, Record("Metric", Units.Millimeters), [1]);
+
+ var updated = database.UpdateMetadata(id, Record("Metric renamed", Units.Inches), created.Revision)!;
+
+ Assert.Equal(Units.Millimeters, updated.Units);
+ Assert.Equal([Units.Millimeters, Units.Millimeters], database.History(id, 0, 10)!.Items.Select(r => r.Units));
+ }
+
+ [Fact]
+ public void Version11Database_ReadsEachStoredArchivesUnits()
+ {
+ using var factory = new ServerFactory();
+ var metric = Guid.NewGuid();
+ var inch = Guid.NewGuid();
+ var unreadable = Guid.NewGuid();
+ var metricNest = NestApiTests.SyntheticNest();
+ metricNest.Units = Units.Millimeters;
+ using (var database = new NestDatabase(factory.DatabasePath, pooling: false))
+ {
+ var created = database.Insert(metric, Record("Metric", Units.Inches), NestApiTests.Archive(metricNest));
+ database.UpdateMetadata(metric, Record("Metric renamed", Units.Inches), created.Revision);
+ database.Insert(inch, Record("Inch", Units.Inches), NestApiTests.Archive(NestApiTests.SyntheticNest()));
+ database.Insert(unreadable, Record("Broken", Units.Inches), [1]);
+ }
+ Execute(factory.DatabasePath, """
+ ALTER TABLE nests DROP COLUMN units;
+ ALTER TABLE nest_revisions DROP COLUMN units;
+ PRAGMA user_version=11;
+ """);
+
+ using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
+
+ Assert.Equal(Units.Millimeters, upgraded.Get(metric)!.Units);
+ Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units));
+ Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units);
+ Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units);
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ }
+
+ private static NestRecord Record(string name, Units units) => new()
+ {
+ Name = name,
+ Material = "Steel",
+ Thickness = 0.25,
+ Units = units,
+ DateCreated = new DateTime(2026, 1, 1),
+ DateModified = new DateTime(2026, 1, 2),
+ };
+
+ private static void Execute(string path, string sql)
+ {
+ using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ private static object? Scalar(string path, string sql)
+ {
+ using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ return command.ExecuteScalar();
+ }
+}
diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
index 3c09519e..26c25774 100644
--- a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
+++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs
@@ -151,7 +151,7 @@ public sealed class SetReleaseDrawingTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
- Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
var kept = upgraded.GetRelease(releaseId)!;
Assert.Empty(kept.Drawings);
Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity));
diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs
index db7afb90..fd8e4493 100644
--- a/OpenNest.Server.Tests/SetReleaseTests.cs
+++ b/OpenNest.Server.Tests/SetReleaseTests.cs
@@ -294,7 +294,7 @@ public sealed class SetReleaseTests
using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false);
- Assert.Equal(11L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
+ Assert.Equal(12L, Scalar(factory.DatabasePath, "PRAGMA user_version"));
Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'"));
var release = upgraded.GetRelease(releaseId)!;
Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone));
diff --git a/OpenNest.Server/NestArchive.cs b/OpenNest.Server/NestArchive.cs
index 27da30d8..14820162 100644
--- a/OpenNest.Server/NestArchive.cs
+++ b/OpenNest.Server/NestArchive.cs
@@ -20,7 +20,7 @@ internal static class NestArchive
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
///
- /// Overwrites the record's plate count, part count, material and thickness with the
+ /// Overwrites the record's plate count, part count, material, thickness and units with the
/// archive's own. Returns an error when the bytes are not a readable nest archive.
///
public static string? Apply(NestRecord record, byte[] file)
@@ -33,6 +33,7 @@ internal static class NestArchive
// falls back to the plate defaults. Thickness falls back when not positive.
record.Material = (nest.Material ?? defaults.Material)?.Name ?? "";
record.Thickness = nest.Thickness > 0 ? nest.Thickness : defaults.Thickness;
+ record.Units = UnitsOf(nest);
// NestReader drops parts whose drawing is missing. Cut-off parts are never written as
// parts, so every remaining part counts.
@@ -106,8 +107,7 @@ internal static class NestArchive
})
.ToList();
- // NestReader: absent or unknown units keep the nest default, inches.
- var units = Enum.TryParse(nest.Units, ignoreCase: true, out var parsed) ? parsed : Units.Inches;
+ var units = UnitsOf(nest);
sheets = new NestSheets
{
@@ -121,6 +121,13 @@ internal static class NestArchive
return null;
}
+ /// The archive's units, for backfilling records stored before they were kept; inches when unreadable.
+ public static Units ReadUnits(byte[] file) => Read(file, out var nest) is null ? UnitsOf(nest) : Units.Inches;
+
+ // NestReader: absent or unknown units keep the nest default, inches.
+ private static Units UnitsOf(NestJson nest) =>
+ Enum.TryParse(nest.Units, ignoreCase: true, out var parsed) ? parsed : Units.Inches;
+
private static string? Read(byte[] file, out NestJson nest)
{
nest = null!;
diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs
index d558db46..f1c1e242 100644
--- a/OpenNest.Server/NestDatabase.cs
+++ b/OpenNest.Server/NestDatabase.cs
@@ -154,10 +154,10 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = """
INSERT INTO nests (id, name, customer, dateCreated, dateModified, material,
- materialGrade, thickness, status, plateCount, partCount, comments, madeBy,
+ materialGrade, thickness, units, status, plateCount, partCount, comments, madeBy,
fileSize, savedAt, file)
VALUES ($id, $name, $customer, $dateCreated, $dateModified, $material,
- $materialGrade, $thickness, $status, $plateCount, $partCount, $comments,
+ $materialGrade, $thickness, $units, $status, $plateCount, $partCount, $comments,
$madeBy, $fileSize, $savedAt, $file)
""";
AddRecordParameters(command, id, record, file, updateFile: true);
@@ -186,9 +186,10 @@ public sealed partial class NestDatabase : IDisposable
if (current.Revision != expectedRevision) throw new RevisionConflictException(current);
if (file is null)
{
- // The counts describe the archive, which a metadata-only save leaves unchanged.
+ // The counts and units describe the archive, which a metadata-only save leaves unchanged.
record.PlateCount = current.PlateCount;
record.PartCount = current.PartCount;
+ record.Units = current.Units;
}
file ??= GetFile(id)!;
if (SameMetadata(current, record) && GetFile(id)!.AsSpan().SequenceEqual(file)) return current;
@@ -210,7 +211,7 @@ public sealed partial class NestDatabase : IDisposable
UPDATE nests SET
name = $name, customer = $customer, dateCreated = $dateCreated,
dateModified = $dateModified, material = $material,
- materialGrade = $materialGrade, thickness = $thickness, status = $status, plateCount = $plateCount,
+ materialGrade = $materialGrade, thickness = $thickness, units = $units, status = $status, plateCount = $plateCount,
partCount = $partCount, comments = $comments, madeBy = $madeBy,
savedAt = $savedAt, revision = $revision, restoredFromRevision=$source{fileClause}
WHERE id = $id
@@ -320,7 +321,7 @@ public sealed partial class NestDatabase : IDisposable
private static bool SameMetadata(NestRecord a, NestRecord b) =>
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated &&
a.DateModified == b.DateModified && a.Material == b.Material && a.MaterialGrade == b.MaterialGrade &&
- a.Thickness == b.Thickness && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount &&
+ a.Thickness == b.Thickness && a.Units == b.Units && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount &&
a.Comments == b.Comments && a.MadeBy == b.MadeBy;
private void Snapshot(Guid id, SqliteTransaction transaction)
@@ -341,7 +342,7 @@ public sealed partial class NestDatabase : IDisposable
command.Transaction = transaction;
command.CommandText = "PRAGMA user_version";
var version = (long)command.ExecuteScalar()!;
- if (version > 11) throw new InvalidOperationException("Database schema is newer than this server.");
+ if (version > 12) throw new InvalidOperationException("Database schema is newer than this server.");
if (version == 0)
{
command.CommandText = """
@@ -426,10 +427,47 @@ public sealed partial class NestDatabase : IDisposable
command.CommandText = ReleaseUpgradeToV11;
command.ExecuteNonQuery();
}
+ if (version < 12)
+ AddUnits(command);
transaction.Commit();
}
}
+ // v12: records keep the archive's units, which their thickness is in. Every stored archive,
+ // current or historical, is read once so millimeter nests are not reported as inches.
+ private static void AddUnits(SqliteCommand command)
+ {
+ foreach (var table in new[] { "nests", "nest_revisions" })
+ {
+ command.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name='units'";
+ if ((long)command.ExecuteScalar()! == 0)
+ {
+ command.CommandText = $"ALTER TABLE {table} ADD COLUMN units TEXT NOT NULL DEFAULT 'Inches'";
+ command.ExecuteNonQuery();
+ }
+ var millimeters = new List();
+ command.CommandText = $"SELECT rowid, file FROM {table}";
+ using (var reader = command.ExecuteReader())
+ {
+ while (reader.Read())
+ {
+ if (NestArchive.ReadUnits((byte[])reader.GetValue(1)) == Units.Millimeters)
+ millimeters.Add(reader.GetInt64(0));
+ }
+ }
+ command.CommandText = $"UPDATE {table} SET units='Millimeters' WHERE rowid=$rowid";
+ var rowid = command.Parameters.Add("$rowid", SqliteType.Integer);
+ foreach (var id in millimeters)
+ {
+ rowid.Value = id;
+ command.ExecuteNonQuery();
+ }
+ command.Parameters.Clear();
+ }
+ command.CommandText = "PRAGMA user_version=12";
+ command.ExecuteNonQuery();
+ }
+
/// Checks the live connection without exposing storage error details.
public bool IsHealthy()
{
@@ -464,7 +502,7 @@ public sealed partial class NestDatabase : IDisposable
private const string RecordColumns = """
id, name, customer, dateCreated, dateModified, material, thickness,
status, plateCount, partCount, comments, madeBy, fileSize, savedAt, revision, deleted, restoredFromRevision,
- materialGrade
+ materialGrade, units
""";
// Case-insensitive (ASCII) substring over the text columns and the status's stored
@@ -557,6 +595,7 @@ public sealed partial class NestDatabase : IDisposable
command.Parameters.AddWithValue("$material", record.Material);
command.Parameters.AddWithValue("$materialGrade", record.MaterialGrade);
command.Parameters.AddWithValue("$thickness", record.Thickness);
+ command.Parameters.AddWithValue("$units", record.Units.ToString());
command.Parameters.AddWithValue("$status", record.Status.ToString());
command.Parameters.AddWithValue("$plateCount", record.PlateCount);
command.Parameters.AddWithValue("$partCount", record.PartCount);
@@ -594,6 +633,7 @@ public sealed partial class NestDatabase : IDisposable
Deleted = reader.GetInt32(first + 15) != 0,
RestoredFromRevision = reader.IsDBNull(first + 16) ? null : reader.GetInt32(first + 16),
MaterialGrade = reader.GetString(first + 17),
+ Units = Enum.TryParse(reader.GetString(first + 18), true, out var units) ? units : Units.Inches,
};
}
}
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 6c50e274..4f4f90dc 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -83,6 +83,7 @@ entry, never programs or geometry, and takes the archive-derived fields below fr
| `material` | string | Material name (type), e.g. `Stainless`. Read from the uploaded archive (nest material, else plate defaults' on an explicit null); a metadata-only PUT may change it, and the desktop applies the record's value when opening the nest. |
| `materialGrade` | string | Optional; default blank. Older servers drop it. A blank grade on a record saved before schema v8 is treated as unrecorded: opening keeps the archive's grade while the material name matches. |
| `thickness` | number | Read from the uploaded archive (nest thickness, else plate defaults' when not positive); editable like `material`. |
+| `units` | `"inches"` \| `"millimeters"` | The nest's units, which `thickness` is in. Server-computed from the uploaded archive (absent or unknown values are inches, as `NestReader` reads them); ignored on upload and kept by a metadata-only PUT. Schema v12 read every stored archive, current and historical, to fill it. Older servers omit it, which readers take as inches. |
| `status` | `"draft"` \| `"quote"` \| `"toBeCut"` \| `"hasBeenCut"` | Default `quote`. |
| `plateCount` / `partCount` | integer | Server-computed from the uploaded archive (non-cutoff parts only; a trailing plate with no parts or cut-offs is not counted); ignored on upload and kept by a metadata-only PUT. |
| `comments` | string | |
@@ -247,7 +248,8 @@ only the requested page of metadata (never archive bytes):
can merge into): `status` (`draft`, `quote`, `toBeCut` or `hasBeenCut`),
`material` and `materialGrade` (compared by `SharedListNames.Key`, so case and
spacing are ignored; a present but blank `materialGrade` asks for nests with no
- grade) and `thickness` (a number, zero or greater, matched at four decimals).
+ grade) and `thickness` (a number, zero or greater, matched at four decimals in
+ each nest's own units).
The client sends them only when set, so plain browsing still works against
servers without them.
- `offset` (default 0, at least 0) and `limit` (default 100, 1 to 500).