From fbc23d6b6462a101a17f3540e67b0bf5aba6ec89 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 3 Nov 2026 11:02:07 -0500 Subject: [PATCH] feat(nesting): nest higher priority numbers first Priority worked like a rank (lower first) with a default of 0, and the Edit Drawing form stopped at 0, so moving one part ahead of the rest meant renumbering every other drawing. Higher-first lets a user raise a single part to 1 and leave everything else at the default. Every engine, filler and candidate comparer now serves the highest tier first. Stored values keep their order: - .nest files add `nestPriority` and keep writing the negated value under `priority`, so older readers nest in the same order; files without `nestPriority` are negated on read. Drawing packages strip both, so package hashes are unchanged. - Library settings send both keys the same way for older clients. - Server schema v18 negates stored drawing priorities (an older server refuses the upgraded database). - Job documents move to version 2; version 1 jobs are converted, and an older worker refuses version 2 instead of reversing the order. The Edit Drawing form now accepts negative priorities and says higher numbers nest first. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Api/NestRequestPart.cs | 1 + OpenNest.Core/Drawing.cs | 1 + OpenNest.Core/LegacyPriority.cs | 13 +++ OpenNest.Data/DrawingNestingSettings.cs | 22 +++++ .../Jobs/NestJobDocumentPriorityTests.cs | 45 +++++++++ .../Jobs/NestJobExampleTests.cs | 2 +- .../Jobs/NestJobStockSelectionTests.cs | 6 +- .../Jobs/RemoteNestProgressTests.cs | 2 +- .../Jobs/RemoteNestReplayTests.cs | 2 +- .../NestingEngines/EngineContractTests.cs | 4 +- .../NestingEngines/RepeatEngineSafetyTests.cs | 4 +- .../RectanglePacking/AreaPackerTests.cs | 4 +- .../Jobs/NestJobCandidateComparer.cs | 2 +- OpenNest.Engine/Jobs/NestJobPart.cs | 8 +- .../Fillers/PlateFillOrchestrator.cs | 2 +- .../Jobs/Placement/Fillers/PlateFiller.cs | 2 +- .../Jobs/Placement/PlateFillService.cs | 2 +- .../Jobs/Remote/NestJobDocument.cs | 13 ++- OpenNest.Engine/NestItem.cs | 4 +- .../NestingEngines/Circles/DiscPacker.cs | 6 +- .../NestingEngines/FlatBar/BarLayout.cs | 4 +- .../NestingEngines/FlatBar/BarPacker.cs | 14 +-- .../FlatBar/FlatBarNestingEngine.cs | 8 +- .../Irregular/FrontierPacker.cs | 6 +- .../NestingEngines/Rectangles/SheetPacker.cs | 4 +- .../NestingEngines/SheetByStock.cs | 4 +- .../RectanglePacking/AreaPacker.cs | 4 +- .../RectanglePacking/MaxRectsPacker.cs | 12 +-- OpenNest.IO.Tests/NestPriorityTests.cs | 92 +++++++++++++++++++ OpenNest.IO/DrawingPackage.cs | 2 +- OpenNest.IO/NestFormat.cs | 10 ++ OpenNest.IO/NestReader.cs | 2 +- OpenNest.IO/NestWriter.cs | 3 +- .../DrawingLibraryDatabaseTests.cs | 32 ++++++- .../DrawingSetDatabaseTests.cs | 4 +- OpenNest.Server.Tests/MaterialListTests.cs | 2 +- .../NestMaterialGradeTests.cs | 2 +- OpenNest.Server.Tests/NestStockCodesTests.cs | 2 +- OpenNest.Server.Tests/NestUnitsTests.cs | 2 +- .../SetReleaseDrawingTests.cs | 2 +- OpenNest.Server.Tests/SetReleaseTests.cs | 2 +- OpenNest.Server.Tests/SharedSettingsTests.cs | 2 +- OpenNest.Server/NestDatabase.Drawings.cs | 10 ++ OpenNest.Server/NestDatabase.cs | 8 +- .../DrawingNestingSettingsPriorityTests.cs | 39 ++++++++ .../Data/RemoteDrawingLibraryTests.cs | 2 +- OpenNest/Forms/EditDrawingForm.Designer.cs | 3 +- docs/nest-file-format.md | 10 +- docs/nest-storage.md | 4 +- docs/nesting-engines.md | 4 +- docs/server-nesting.md | 3 +- 51 files changed, 364 insertions(+), 79 deletions(-) create mode 100644 OpenNest.Core/LegacyPriority.cs create mode 100644 OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs create mode 100644 OpenNest.IO.Tests/NestPriorityTests.cs create mode 100644 OpenNest.Tests/Data/DrawingNestingSettingsPriorityTests.cs diff --git a/OpenNest.Api/NestRequestPart.cs b/OpenNest.Api/NestRequestPart.cs index 9b3775bf..f37fad8f 100644 --- a/OpenNest.Api/NestRequestPart.cs +++ b/OpenNest.Api/NestRequestPart.cs @@ -7,5 +7,6 @@ public class NestRequestPart public string DxfPath { get; init; } public int Quantity { get; init; } = 1; public bool AllowRotation { get; init; } = true; + /// Nesting order: higher numbers nest first. public int Priority { get; init; } = 0; } diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index e43ac92d..8e24dbab 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -66,6 +66,7 @@ namespace OpenNest public string Customer { get; set; } + /// Nesting order: higher numbers nest first. Zero is the default. public int Priority { get; set; } public DwgQty Quantity; diff --git a/OpenNest.Core/LegacyPriority.cs b/OpenNest.Core/LegacyPriority.cs new file mode 100644 index 00000000..4dce4438 --- /dev/null +++ b/OpenNest.Core/LegacyPriority.cs @@ -0,0 +1,13 @@ +namespace OpenNest; + +/// +/// Nesting priority places higher numbers first. Files, the drawing library and server job +/// documents written before that stored a lower-first value under priority; they keep +/// writing it beside the new value so older readers see the same order. Converting negates, +/// which keeps the order and is its own inverse. +/// +public static class LegacyPriority +{ + /// Converts between a higher-first priority and its lower-first legacy value, either way. + public static int Convert(int priority) => priority == int.MinValue ? int.MaxValue : -priority; +} diff --git a/OpenNest.Data/DrawingNestingSettings.cs b/OpenNest.Data/DrawingNestingSettings.cs index f96ca70c..c12bd772 100644 --- a/OpenNest.Data/DrawingNestingSettings.cs +++ b/OpenNest.Data/DrawingNestingSettings.cs @@ -1,4 +1,5 @@ using System.Drawing; +using System.Text.Json.Serialization; using OpenNest; namespace OpenNest.Data; @@ -14,8 +15,29 @@ public sealed record DrawingNestingSettings /// Automatic sequencing cuts this drawing's parts first (for example test squares). public bool CutFirst { get; init; } + /// Nesting order: higher numbers nest first. + [JsonIgnore] public int Priority { get; init; } + /// + /// The lower-first legacy value () older clients read + /// and send. Written before , so a reader that sees both ends on it. + /// + [JsonPropertyName("priority")] + public int LegacyPriorityValue + { + get => LegacyPriority.Convert(Priority); + init => Priority = LegacyPriority.Convert(value); + } + + /// on the wire; absent from older clients. + [JsonPropertyName("nestPriority")] + public int NestPriority + { + get => Priority; + init => Priority = value; + } + /// Rotation step in radians; zero means the engine's default. public double StepAngle { get; init; } diff --git a/OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs new file mode 100644 index 00000000..9a421607 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobDocumentPriorityTests.cs @@ -0,0 +1,45 @@ +using OpenNest.Engine.Jobs.Remote; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// Version 2 job documents nest higher priority numbers first. Version 1 documents came from +/// desktops that nested lower numbers first and are converted when read, so a job queued before +/// an upgrade keeps its order. +/// +public class NestJobDocumentPriorityTests +{ + [Fact] + public void CurrentVersionKeepsPriority() + { + var item = Assert.Single(Document(NestJobDocument.CurrentVersion, 3).ToRequest(Drawings()).Items); + + Assert.Equal(3, item.Priority); + } + + [Fact] + public void VersionOneConvertsLowerFirstPriority() + { + var json = Document(1, 3).ToJson(); + + var item = Assert.Single(NestJobDocument.FromJson(json).ToRequest(Drawings()).Items); + + Assert.Equal(-3, item.Priority); + } + + [Fact] + public void UnknownVersionIsRefused() + { + Assert.Throws(() => Document(NestJobDocument.CurrentVersion + 1, 0).ToRequest(Drawings())); + } + + private static Drawing[] Drawings() => [new Drawing("a", TestDrawingFactory.Rectangle(10, 20))]; + + private static NestJobDocument Document(int version, int priority) => new() + { + Version = version, + EngineName = "Rectangles", + DrawingNames = ["a"], + Items = [new NestJobDocument.ItemJson { Drawing = 0, Quantity = 1, Priority = priority }], + }; +} diff --git a/OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs index 2698b104..5203edf3 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs @@ -21,7 +21,7 @@ public class NestJobExampleTests new[] { Part("bracket", 100.0, 60.0, 5, priority: 0), - Part("plate-clip", 40.0, 40.0, 8, priority: 1), + Part("plate-clip", 40.0, 40.0, 8, priority: -1), }, // Mixed inventory: five large sheets and unlimited small sheets. new[] diff --git a/OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs index 4883f4d7..f17502c9 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs @@ -1,5 +1,5 @@ -using OpenNest.Geometry; using OpenNest.Engine.Jobs; +using OpenNest.Geometry; namespace OpenNest.Engine.Tests.Jobs; @@ -22,7 +22,7 @@ public class NestJobStockSelectionTests public void ExhaustedLargeStockIsNotRecreatedWhileSmallerStockServesSmallParts() { var result = Solve( - new[] { Part("large", 1, 0), Part("small", 2, 1) }, + new[] { Part("large", 1, 1), Part("small", 2, 0) }, new[] { Stock("large", 20, 20, 1), Stock("small", 10, 10, 2) }, request => request.Stock.Id switch @@ -98,7 +98,7 @@ public class NestJobStockSelectionTests public void CandidatePriorityAreaEnvelopeAndInputOrderAreComparedInDocumentedOrder() { var priority = Solve( - new[] { Part("high", 1, 0), Part("low", 1, 1) }, + new[] { Part("high", 1, 1), Part("low", 1, 0) }, new[] { Stock("a", 10, 10, 1), Stock("b", 10, 10, 1) }, request => request.Stock.Id == "a" ? Candidate(request, "low") : Candidate(request, "high") diff --git a/OpenNest.Engine.Tests/Jobs/RemoteNestProgressTests.cs b/OpenNest.Engine.Tests/Jobs/RemoteNestProgressTests.cs index 008f4167..7aa31d7c 100644 --- a/OpenNest.Engine.Tests/Jobs/RemoteNestProgressTests.cs +++ b/OpenNest.Engine.Tests/Jobs/RemoteNestProgressTests.cs @@ -28,7 +28,7 @@ public class RemoteNestProgressTests return new NestPipelineRequest(engine, [ new NestItem { Drawing = new Drawing("bracket", TestDrawingFactory.Rectangle(10, 20)), Quantity = 9 }, - new NestItem { Drawing = new Drawing("ell", TestDrawingFactory.LShape()), Quantity = 6, Priority = 1 }, + new NestItem { Drawing = new Drawing("ell", TestDrawingFactory.LShape()), Quantity = 6, Priority = -1 }, ], NestStockBuilder.FromTemplate(template, null), new NestJobOptions(maxPlates: 5)); diff --git a/OpenNest.Engine.Tests/Jobs/RemoteNestReplayTests.cs b/OpenNest.Engine.Tests/Jobs/RemoteNestReplayTests.cs index 5c1dae96..86803174 100644 --- a/OpenNest.Engine.Tests/Jobs/RemoteNestReplayTests.cs +++ b/OpenNest.Engine.Tests/Jobs/RemoteNestReplayTests.cs @@ -17,7 +17,7 @@ public class RemoteNestReplayTests var template = new Plate(48, 96) { EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), PartSpacing = 0.25 }; template.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 30.0 })); return new NestPipelineRequest(engine, - [new NestItem { Drawing = a, Quantity = 12 }, new NestItem { Drawing = b, Quantity = 7, Priority = 1 }], + [new NestItem { Drawing = a, Quantity = 12 }, new NestItem { Drawing = b, Quantity = 7, Priority = -1 }], NestStockBuilder.FromTemplate(template, [new PlateOption { Width = 48, Length = 96, Cost = 100 }, new PlateOption { Width = 60, Length = 120, Quantity = 2, Cost = 160 }]), new NestJobOptions(maxPlates: 10, salvageRate: 0.5, minimumSalvageDimension: 6)); diff --git a/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs b/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs index d08933fe..148ec260 100644 --- a/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs @@ -71,9 +71,9 @@ public abstract class EngineContractTests where TEngine : INestingEngin } [Fact] - public void ContractLowerNumberPriorityWins() + public void ContractHigherNumberPriorityWins() { - var job = Job([Part("low", Block(8), 1, priority: 9), Part("high", Block(8), 1, priority: 0)], + var job = Job([Part("low", Block(8), 1, priority: 0), Part("high", Block(8), 1, priority: 9)], [Stock("s", 10, 10, quantity: 1)]); var result = new TEngine().Solve(job); LayoutAssert.Valid(job, result); diff --git a/OpenNest.Engine.Tests/NestingEngines/RepeatEngineSafetyTests.cs b/OpenNest.Engine.Tests/NestingEngines/RepeatEngineSafetyTests.cs index fc7964c7..3c7ebead 100644 --- a/OpenNest.Engine.Tests/NestingEngines/RepeatEngineSafetyTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/RepeatEngineSafetyTests.cs @@ -79,7 +79,7 @@ public abstract class RepeatEngineSafetyTests public void SameNamedDrawingsRetainExactDistinctRequirementIds() { var a = new Drawing("same", Shapes.Rectangle(6, 9)); - var b = new Drawing("same", Shapes.Rectangle(3, 9)) { Priority = 1 }; + var b = new Drawing("same", Shapes.Rectangle(3, 9)) { Priority = -1 }; var job = Job([DrawingJobMapper.FromDrawing("a", a, 5), DrawingJobMapper.FromDrawing("A", b, 8)], [Stock("s", 10, 10, spacing: 0.2)]); var result = Engine().Solve(job); LayoutAssert.Valid(job, result); @@ -202,7 +202,7 @@ public abstract class RepeatEngineSafetyTests } private static NestJob Mixed(int a, int b) => Job( - [Part("a", Shapes.Rectangle(6, 9), a, RotationPolicy.Fixed(0)), Part("b", Shapes.Rectangle(3, 9), b, RotationPolicy.Fixed(0), priority: 1)], + [Part("a", Shapes.Rectangle(6, 9), a, RotationPolicy.Fixed(0)), Part("b", Shapes.Rectangle(3, 9), b, RotationPolicy.Fixed(0), priority: -1)], [Stock("s", 10, 10, spacing: 0.2)]); private sealed class InlineProgress(Action report) : IProgress diff --git a/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs b/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs index 9645cb54..68e44c0d 100644 --- a/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs +++ b/OpenNest.Engine.Tests/RectanglePacking/AreaPackerTests.cs @@ -216,10 +216,10 @@ public class AreaPackerTests } [Fact] - public void Pack_ServesLowerPriorityNumberFirst() + public void Pack_ServesHigherPriorityNumberFirst() { var area = new Box(0, 0, 10, 10); - var items = new List { Item("later", 10, 10, 1, priority: 1), Item("first", 6, 6, 1, priority: 0) }; + var items = new List { Item("later", 10, 10, 1, priority: 0), Item("first", 6, 6, 1, priority: 1) }; var parts = AreaPacker.Pack(area, items, 0, new DefaultFillComparer(), CancellationToken.None); diff --git a/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs b/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs index acd95b22..5df50475 100644 --- a/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs +++ b/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs @@ -30,7 +30,7 @@ public sealed class NestJobCandidateComparer var priorities = parts .Select(part => part.Priority) .Distinct() - .OrderBy(priority => priority); + .OrderByDescending(priority => priority); foreach (var priority in priorities) { var leftCount = Count(left, priority); diff --git a/OpenNest.Engine/Jobs/NestJobPart.cs b/OpenNest.Engine/Jobs/NestJobPart.cs index 7ec3d40f..f8c17801 100644 --- a/OpenNest.Engine/Jobs/NestJobPart.cs +++ b/OpenNest.Engine/Jobs/NestJobPart.cs @@ -9,8 +9,8 @@ public sealed class NestJobPart /// Unique requirement ID. /// Snapshot of the part geometry. /// Positive number requested. - /// Placement precedence: lower numbers are placed first and win scarce - /// stock. Zero is the default and highest ordinary priority; equal priorities are peers. + /// Placement precedence: higher numbers are placed first and win scarce + /// stock. Zero is the default; negative numbers place after it. Equal priorities are peers. /// Allowed rotations, or null for automatic rotation. public NestJobPart( string id, @@ -37,8 +37,8 @@ public sealed class NestJobPart /// Positive number requested; never decremented by placement code. public int Quantity { get; } /// - /// Placement precedence: a lower number is placed first and wins scarce stock. - /// Zero is the default and highest ordinary priority. Parts with equal priority are peers. + /// Placement precedence: a higher number is placed first and wins scarce stock. + /// Zero is the default; negative numbers place after it. Parts with equal priority are peers. /// public int Priority { get; } public RotationPolicy Rotation { get; } diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs index 2fdd4467..4123fdd1 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFillOrchestrator.cs @@ -36,7 +36,7 @@ internal static class PlateFillOrchestrator var fillItems = items .Where(item => ShouldFill(item, plate, plateArea)) - .OrderBy(item => item.Priority) + .OrderByDescending(item => item.Priority) .ThenByDescending(item => item.Drawing.Area) .ToList(); diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs index a5735f81..953c17d7 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs @@ -517,7 +517,7 @@ internal class PlateFiller var workArea = Plate.WorkArea(); var fillItems = items .Where(item => item.Quantity != 1) - .OrderBy(item => item.Priority) + .OrderByDescending(item => item.Priority) .ThenByDescending(item => item.Drawing.Area) .ToList(); var packItems = items.Where(item => item.Quantity == 1).ToList(); diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs index df1195ce..bb66e63d 100644 --- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -226,7 +226,7 @@ public static class PlateFillService // strategies, partition the sheet at static keep-outs before searching, then // certify each accepted material footprint against the full zone and occupants. var result = new List(); - foreach (var item in items.OrderBy(item => item.Priority)) + foreach (var item in items.OrderByDescending(item => item.Priority)) { token.ThrowIfCancellationRequested(); // Fill/Remnant whole-plate orchestration skips exhausted demand; the diff --git a/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs b/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs index d09c4823..49fe7fa0 100644 --- a/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs +++ b/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs @@ -15,7 +15,14 @@ namespace OpenNest.Engine.Jobs.Remote; /// public sealed class NestJobDocument { - public const int CurrentVersion = 1; + /// + /// Version 2 nests higher priority numbers first. Version 1 jobs, from desktops that nested + /// lower numbers first, still read, with their priorities converted + /// (); an older worker refuses version 2 instead of reversing it. + /// + public const int CurrentVersion = 2; + + private const int LowerFirstPriorityVersion = 1; private static readonly JsonSerializerOptions Options = new() { @@ -87,7 +94,7 @@ public sealed class NestJobDocument public NestPipelineRequest ToRequest(IReadOnlyList drawings) { ArgumentNullException.ThrowIfNull(drawings); - if (Version != CurrentVersion) + if (Version != CurrentVersion && Version != LowerFirstPriorityVersion) throw new FormatException($"Nest job version {Version} is not supported; this build reads {CurrentVersion}."); if (string.IsNullOrWhiteSpace(EngineName)) throw new FormatException("The nest job names no engine."); @@ -108,7 +115,7 @@ public sealed class NestJobDocument { Drawing = drawings[item.Drawing], Quantity = item.Quantity, - Priority = item.Priority, + Priority = Version == LowerFirstPriorityVersion ? LegacyPriority.Convert(item.Priority) : item.Priority, StepAngle = item.StepAngle, RotationStart = item.RotationStart, RotationEnd = item.RotationEnd, diff --git a/OpenNest.Engine/NestItem.cs b/OpenNest.Engine/NestItem.cs index 782807c7..ffba5241 100644 --- a/OpenNest.Engine/NestItem.cs +++ b/OpenNest.Engine/NestItem.cs @@ -1,4 +1,4 @@ -namespace OpenNest.Engine +namespace OpenNest.Engine { public class NestItem { @@ -8,7 +8,7 @@ public Drawing Drawing { get; set; } /// - /// Priority of the part determines nesting order. Highest priority will be nested first. + /// Priority of the part determines nesting order. Higher numbers are nested first. /// public int Priority { get; set; } diff --git a/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs b/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs index 40e33961..8f39f8ed 100644 --- a/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs +++ b/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs @@ -37,7 +37,7 @@ internal enum DiscSeed /// /// Packs discs onto one sheet. Each disc goes to the best feasible center tangent to two of /// {work-area walls, placed discs, clamp keep-outs}, the exact candidate set for circles, so no -/// no-fit polygons are needed. Discs go lower priority number first, then largest first, so small +/// no-fit polygons are needed. Discs go higher priority number first, then largest first, so small /// discs land in the gaps between large ones. An optional hexagonal or square lattice of one type /// seeds the sheet before the greedy pass fills the rest. /// @@ -72,7 +72,7 @@ internal static class DiscPacker } } // Stable: equal radii keep requirement order. - foreach (var type in fits.OrderBy(t => t.Priority).ThenByDescending(t => t.Radius)) + foreach (var type in fits.OrderByDescending(t => t.Priority).ThenByDescending(t => t.Radius)) { while (left[type.Index] > 0) { @@ -102,7 +102,7 @@ internal static class DiscPacker { if (fits.Count == 0) return null; - var top = fits.Min(t => t.Priority); + var top = fits.Max(t => t.Priority); var best = fits.Where(t => t.Priority == top) .OrderByDescending(t => left[t.Index] * t.Radius * t.Radius) .First(); diff --git a/OpenNest.Engine/NestingEngines/FlatBar/BarLayout.cs b/OpenNest.Engine/NestingEngines/FlatBar/BarLayout.cs index 0cc63a9a..c96dad27 100644 --- a/OpenNest.Engine/NestingEngines/FlatBar/BarLayout.cs +++ b/OpenNest.Engine/NestingEngines/FlatBar/BarLayout.cs @@ -35,7 +35,7 @@ internal sealed record BarSheet(BarStock Bar, IReadOnlyList Parts, Box? /// and backed off until it passes; the box-to-box start always passes, so the result is never /// worse than plain box packing. Parts whose outline fills their box gain nothing and skip the /// slide. The length a slide frees at the end of a run is refilled from the remaining demand, -/// lowest priority number first, then longest first. +/// highest priority number first, then longest first. /// internal sealed class BarLayout { @@ -156,7 +156,7 @@ internal sealed class BarLayout private IEnumerable RefillOrder(BarStock bar, int[] available) => types.Where(t => available[t.Index] > 0 && !double.IsNaN(bar.Lengths[t.Index])) - .OrderBy(t => t.Priority) + .OrderByDescending(t => t.Priority) .ThenByDescending(t => bar.Lengths[t.Index]) .ThenBy(t => t.Index); diff --git a/OpenNest.Engine/NestingEngines/FlatBar/BarPacker.cs b/OpenNest.Engine/NestingEngines/FlatBar/BarPacker.cs index 894809f6..ff435896 100644 --- a/OpenNest.Engine/NestingEngines/FlatBar/BarPacker.cs +++ b/OpenNest.Engine/NestingEngines/FlatBar/BarPacker.cs @@ -61,7 +61,7 @@ internal sealed record BarPlan(IReadOnlyList Bars, IReadOnlyList @@ -95,7 +95,7 @@ internal static class BarPacker { var remaining = items .Select((item, order) => (item, order)) - .OrderBy(x => x.item.Priority) + .OrderByDescending(x => x.item.Priority) .ThenByDescending(x => x.item.Length) .ThenBy(x => x.item.Type) .ThenBy(x => x.order) @@ -135,7 +135,7 @@ internal static class BarPacker /// /// Takes out one item of a length group (never the longest group) and refills the freed /// space greedily from the unplaced items, keeping the swap only when the run ends fuller. - /// Only items of the same or a lower-number priority may replace the removed one. + /// Only items of the same or a higher-number priority may replace the removed one. /// private static bool TryImprove(PackedSegment segment, List remaining) { @@ -161,7 +161,7 @@ internal static class BarPacker var first = remaining[i]; // Charging the trial its own gains is conservative for types already in the run. var trial = new PackedSegment(free); - if (!trial.Fits(first) || first.Priority > removed.Priority) + if (!trial.Fits(first) || first.Priority < removed.Priority) continue; trial.Items.Add(first); for (var j = i + 1; j < remaining.Count; j++) @@ -169,7 +169,7 @@ internal static class BarPacker if (trial.Remaining < shortest) break; var next = remaining[j]; - if (!trial.Fits(next) || next.Priority > removed.Priority) + if (!trial.Fits(next) || next.Priority < removed.Priority) continue; trial.Items.Add(next); } @@ -311,7 +311,7 @@ internal static class BarPacker var pool = items .GroupBy(i => i.Type) .Select(g => (Item: g.First(), Count: g.Count())) - .OrderBy(t => t.Item.Priority) + .OrderByDescending(t => t.Item.Priority) .ThenByDescending(t => t.Item.Length) .ThenBy(t => t.Item.Type) .ToList(); @@ -392,7 +392,7 @@ internal static class BarPacker { var ordered = segment.Items .OrderByDescending(i => i.Length) - .ThenBy(i => i.Priority) + .ThenByDescending(i => i.Priority) .ThenBy(i => i.Type) .ToList(); segment.Items.Clear(); diff --git a/OpenNest.Engine/NestingEngines/FlatBar/FlatBarNestingEngine.cs b/OpenNest.Engine/NestingEngines/FlatBar/FlatBarNestingEngine.cs index c1ebd333..7fe9cb0f 100644 --- a/OpenNest.Engine/NestingEngines/FlatBar/FlatBarNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/FlatBar/FlatBarNestingEngine.cs @@ -245,14 +245,14 @@ public sealed class FlatBarNestingEngine : INestingEngine private readonly record struct Estimate(int Tier, int UrgentUnplaced, int Unplaced, double Cost) : IComparable { - public static Estimate Zero => new(int.MaxValue, 0, 0, 0); + public static Estimate Zero => new(int.MinValue, 0, 0, 0); public static Estimate Leaving(IReadOnlyList types, int[] left, double penalty) { var open = types.Where(t => left[t.Index] > 0).ToList(); if (open.Count == 0) return Zero; - var tier = open.Min(t => t.Priority); + var tier = open.Max(t => t.Priority); var count = open.Sum(t => left[t.Index]); return new Estimate(tier, open.Where(t => t.Priority == tier).Sum(t => left[t.Index]), count, count * penalty); } @@ -261,9 +261,9 @@ public sealed class FlatBarNestingEngine : INestingEngine public int CompareTo(Estimate other) { - // A more urgent tier left unplaced is worse than any later tier left unplaced. + // A more urgent (higher) tier left unplaced is worse than any later tier left unplaced. if (Tier != other.Tier) - return other.Tier.CompareTo(Tier); + return Tier.CompareTo(other.Tier); var c = UrgentUnplaced.CompareTo(other.UrgentUnplaced); if (c != 0) return c; diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs index aecabab2..0cea0a12 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs @@ -294,20 +294,20 @@ internal sealed class FrontierPacker var bestValue = double.PositiveInfinity; var bestSide = double.PositiveInfinity; var bestLead = double.PositiveInfinity; - var bestPriority = int.MaxValue; + var bestPriority = int.MinValue; var bestRank = int.MaxValue; void Consider(Func> build, int typeIndex, double area, double advance, double side, double lead) { var priority = types[typeIndex].Part.Priority; var rank = groupRanks?[typeIndex] ?? 0; - if (priority > bestPriority || (priority == bestPriority && rank > bestRank)) + if (priority < bestPriority || (priority == bestPriority && rank > bestRank)) return; var fills = advance <= Tie; var value = PlacementValue(fills, advance, area, beta, typeWeights?[typeIndex] ?? 1.0); var better = bestParts == null - || priority < bestPriority + || priority > bestPriority || rank < bestRank || (fills && !bestFills) || ( diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs index 6bc4c0d6..2c746498 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs @@ -29,8 +29,8 @@ internal sealed record SheetPlan( /// /// Packs the remaining demand onto one sheet of the given stock with a maximal-rectangles free -/// list. Lower priority numbers are always served first: a higher-number type is only placed -/// when no lower-number type still fits anywhere. +/// list. Higher priority numbers are always served first: a lower-number type is only placed +/// when no higher-number type still fits anywhere. /// internal static class SheetPacker { diff --git a/OpenNest.Engine/NestingEngines/SheetByStock.cs b/OpenNest.Engine/NestingEngines/SheetByStock.cs index 5b60bcef..181f5597 100644 --- a/OpenNest.Engine/NestingEngines/SheetByStock.cs +++ b/OpenNest.Engine/NestingEngines/SheetByStock.cs @@ -161,7 +161,7 @@ internal static class SheetByStock } /// - /// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining + /// Priority guard: how many instances of the most urgent (highest-number) tier with remaining /// demand this layout leaves unplaced. Layouts are ranked on this before cost, so a cheaper sheet /// can never win by serving a later tier at the expense of an earlier one. /// @@ -171,7 +171,7 @@ internal static class SheetByStock var active = types.Where(t => remaining[t.Index] > 0).ToList(); if (active.Count == 0) return 0; - var top = active.Min(t => t.Priority); + var top = active.Max(t => t.Priority); var placed = layout.Poses.Count(p => priority[p.PartId] == top); return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed; } diff --git a/OpenNest.Engine/RectanglePacking/AreaPacker.cs b/OpenNest.Engine/RectanglePacking/AreaPacker.cs index cb95ba5b..b46b69d2 100644 --- a/OpenNest.Engine/RectanglePacking/AreaPacker.cs +++ b/OpenNest.Engine/RectanglePacking/AreaPacker.cs @@ -11,7 +11,7 @@ namespace OpenNest.Engine.RectanglePacking; /// /// Packs mixed parts into one rectangular area as their program bounding boxes, at 0 or 90 /// degrees, with the maximal-rectangles packer. Every fit rule and pick mode is tried. The layout -/// that places the most parts, then the most box area, wins, compared tier by tier from the lowest +/// that places the most parts, then the most box area, wins, compared tier by tier from the highest /// ; the caller's fill comparer breaks ties, so leftover packing /// keeps the same goal (density, or a clear right or top remnant) as the rest of the fill. /// @@ -66,7 +66,7 @@ internal static class AreaPacker demand[i] = System.Math.Clamp(items[i].Quantity, 0, capacity); } - var tiers = types.Select(t => t.Priority).Distinct().Order().ToArray(); + var tiers = types.Select(t => t.Priority).Distinct().OrderDescending().ToArray(); List? best = null; (int Count, double Area)[]? bestTiers = null; diff --git a/OpenNest.Engine/RectanglePacking/MaxRectsPacker.cs b/OpenNest.Engine/RectanglePacking/MaxRectsPacker.cs index d3964bef..11d85d17 100644 --- a/OpenNest.Engine/RectanglePacking/MaxRectsPacker.cs +++ b/OpenNest.Engine/RectanglePacking/MaxRectsPacker.cs @@ -16,7 +16,7 @@ internal enum PickMode } /// A box type to pack. -/// Demand tier; lower numbers are served first. +/// Demand tier; higher numbers are served first. /// Allowed packing sizes (one per orientation), already grown by any spacing. /// Ranking area: larger boxes win score ties and go first in ordered mode. internal sealed record PackType(int Priority, IReadOnlyList<(double Width, double Height)> Sizes, double Area); @@ -25,9 +25,9 @@ internal sealed record PackType(int Priority, IReadOnlyList<(double Width, doubl internal readonly record struct PackPlacement(int Type, int Size, double X, double Y); /// -/// Packs box types onto one under a fit rule and pick mode. Lower -/// priority numbers are always served first: a higher-number type is only placed when no -/// lower-number type still fits anywhere. Deterministic: ties resolve by input order. +/// Packs box types onto one under a fit rule and pick mode. Higher +/// priority numbers are always served first: a lower-number type is only placed when no +/// higher-number type still fits anywhere. Deterministic: ties resolve by input order. /// internal static class MaxRectsPacker { @@ -60,7 +60,7 @@ internal static class MaxRectsPacker { // Free space only shrinks, so a size that fails once never fits again on this sheet. var dead = types.Select(t => new bool[t.Sizes.Count]).ToArray(); - var tiers = types.Select(t => t.Priority).Distinct().Order().ToArray(); + var tiers = types.Select(t => t.Priority).Distinct().OrderDescending().ToArray(); while (true) { @@ -115,7 +115,7 @@ internal static class MaxRectsPacker var order = Enumerable .Range(0, types.Count) .Where(t => types[t].Sizes.Count > 0) - .OrderBy(t => types[t].Priority) + .OrderByDescending(t => types[t].Priority) .ThenByDescending(t => types[t].Area) .ThenBy(t => t); diff --git a/OpenNest.IO.Tests/NestPriorityTests.cs b/OpenNest.IO.Tests/NestPriorityTests.cs new file mode 100644 index 00000000..0225a1b5 --- /dev/null +++ b/OpenNest.IO.Tests/NestPriorityTests.cs @@ -0,0 +1,92 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json.Nodes; + +namespace OpenNest.IO.Tests; + +/// +/// Priority nests higher numbers first. Files keep writing the lower-first legacy value under +/// priority so older readers keep the same order, and files without nestPriority +/// are converted when read. +/// +public class NestPriorityTests +{ + [Fact] + public void Write_KeepsTheLegacyValueBesideTheNewOne() + { + var file = Write(5); + + var drawing = DrawingJson(file); + Assert.Equal(-5, (int)drawing["priority"]!); + Assert.Equal(5, (int)drawing["nestPriority"]!); + Assert.Equal(5, Read(file).Priority); + } + + [Fact] + public void Write_OmitsAZeroPriority() + { + var file = Write(0); + + Assert.False(DrawingJson(file).ContainsKey("nestPriority")); + Assert.Equal(0, Read(file).Priority); + } + + [Fact] + public void Read_ConvertsAFileWithOnlyTheLegacyValue() + { + var file = Rewrite(Write(5), drawing => + { + drawing.Remove("nestPriority"); + drawing["priority"] = 3; + }); + + Assert.Equal(-3, Read(file).Priority); + } + + [Fact] + public void Read_PrefersTheNewValue() + { + var file = Rewrite(Write(5), drawing => drawing["priority"] = 9); + + Assert.Equal(5, Read(file).Priority); + } + + private static byte[] Write(int priority) + { + var drawing = DrawingPackageFixture.Build(); + drawing.Priority = priority; + var nest = new Nest(drawing.Name) { Units = Units.Millimeters }; + nest.Drawings.Add(drawing); + using var stream = new MemoryStream(); + Assert.True(new NestWriter(nest).Write(stream)); + return stream.ToArray(); + } + + private static Drawing Read(byte[] file) => + Assert.Single(new NestReader(new MemoryStream(file)).Read().Drawings); + + private static JsonObject DrawingJson(byte[] file) + { + using var archive = new ZipArchive(new MemoryStream(file), ZipArchiveMode.Read); + using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open(), Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd())!["drawings"]![0]!.AsObject(); + } + + private static byte[] Rewrite(byte[] file, Action change) + { + using var stream = new MemoryStream(); + stream.Write(file); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var entry = archive.GetEntry("nest.json")!; + JsonNode root; + using (var reader = new StreamReader(entry.Open(), Encoding.UTF8)) + root = JsonNode.Parse(reader.ReadToEnd())!; + change(root["drawings"]![0]!.AsObject()); + entry.Delete(); + using var writer = new StreamWriter(archive.CreateEntry("nest.json").Open(), new UTF8Encoding(false)); + writer.Write(root.ToJsonString()); + } + return stream.ToArray(); + } +} diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs index 3255614f..54a3afb4 100644 --- a/OpenNest.IO/DrawingPackage.cs +++ b/OpenNest.IO/DrawingPackage.cs @@ -33,7 +33,7 @@ namespace OpenNest.IO 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", "customColor", "priority", "cutFirst", "constraints" }; + private static readonly string[] RecordOwnedDrawingFields = { "customer", "color", "customColor", "priority", "nestPriority", "cutFirst", "constraints" }; /// Packages without changing it. public static byte[] Write(Drawing drawing, Units units) diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 55bbb0e1..bff29f22 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -443,8 +443,18 @@ namespace OpenNest.IO public bool CustomColor { get; init; } public QuantityDto Quantity { get; init; } = new(); + + /// + /// The lower-first legacy priority (), still + /// written so older readers keep the same order. Read only when + /// is absent. + /// public int Priority { get; init; } + /// Nesting priority, higher numbers first. Omitted when zero. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? NestPriority { get; init; } + /// Sequence this drawing's parts first. Omitted when false. [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool CutFirst { get; init; } diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 2f950724..f6638b31 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -278,7 +278,7 @@ namespace OpenNest.IO drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B); drawing.HasCustomColor = d.CustomColor; drawing.Quantity.Required = d.Quantity.Required; - drawing.Priority = d.Priority; + drawing.Priority = d.NestPriority ?? LegacyPriority.Convert(d.Priority); drawing.CutFirst = d.CutFirst; drawing.Constraints.StepAngle = d.Constraints.StepAngle; drawing.Constraints.StartAngle = d.Constraints.StartAngle; diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 6b4f4a98..060f5173 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -236,7 +236,8 @@ namespace OpenNest.IO }, CustomColor = d.HasCustomColor, Quantity = new QuantityDto { Required = d.Quantity.Required }, - Priority = d.Priority, + Priority = LegacyPriority.Convert(d.Priority), + NestPriority = d.Priority == 0 ? null : d.Priority, CutFirst = d.CutFirst, Constraints = new ConstraintsDto { diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index fb5b01f7..01d1e96a 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -23,7 +23,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(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -44,7 +44,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, 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. @@ -81,11 +81,37 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=18"); + Execute(factory.DatabasePath, "PRAGMA user_version=19"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } + [Fact] + public void V18Upgrade_NegatesLowerFirstPrioritiesInEveryRevision() + { + using var factory = new ServerFactory(); + var record = Record(); + var other = Record("Synthetic plate"); + using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) + { + database.InsertDrawing(record.Id, record, Package()); + database.InsertDrawing(other.Id, other, Package("other")); + } + // A v17 library stored lower-first values: 3 nested before 0, and int.MinValue before both. + Execute(factory.DatabasePath, $""" + UPDATE drawings SET priority = 3 WHERE id = '{record.Id}'; + UPDATE drawing_revisions SET priority = 3 WHERE id = '{record.Id}'; + UPDATE drawings SET priority = -2147483648 WHERE id = '{other.Id}'; + PRAGMA user_version=17; + """); + + using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(-3, upgraded.GetDrawing(record.Id)!.Settings.Priority); + Assert.Equal(-3, Assert.Single(upgraded.DrawingHistory(record.Id, 0, 100)!.Items).Settings.Priority); + Assert.Equal(int.MaxValue, upgraded.GetDrawing(other.Id)!.Settings.Priority); + } + [Fact] public void FailedDrawingMigration_RollsBackAndCanRestart() { diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 9e29d7f7..19be8a90 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -23,7 +23,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(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -48,7 +48,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(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs index 98184c2a..0623e578 100644 --- a/OpenNest.Server.Tests/MaterialListTests.cs +++ b/OpenNest.Server.Tests/MaterialListTests.cs @@ -25,7 +25,7 @@ public sealed class MaterialListTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, 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 25ab993e..99fa4f6a 100644 --- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -75,7 +75,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(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/NestStockCodesTests.cs b/OpenNest.Server.Tests/NestStockCodesTests.cs index c4172028..2897da11 100644 --- a/OpenNest.Server.Tests/NestStockCodesTests.cs +++ b/OpenNest.Server.Tests/NestStockCodesTests.cs @@ -119,7 +119,7 @@ public sealed class NestStockCodesTests database.SetDeleted(deleted, false, record.Revision); Assert.Equal(["SHEET-1", "SHEET-2"], database.StockCodesInUse(null).Select(u => u.ItemCode)); } - Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static Nest Nest(string name, NestStatus status, params string[][] plateCodes) diff --git a/OpenNest.Server.Tests/NestUnitsTests.cs b/OpenNest.Server.Tests/NestUnitsTests.cs index 24589349..bddfc562 100644 --- a/OpenNest.Server.Tests/NestUnitsTests.cs +++ b/OpenNest.Server.Tests/NestUnitsTests.cs @@ -77,7 +77,7 @@ public sealed class NestUnitsTests 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(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, Units units) => new() diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs index b450a791..abc32ada 100644 --- a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs +++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs @@ -152,7 +152,7 @@ public sealed class SetReleaseDrawingTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, 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 bd7f615e..b2bdc7c9 100644 --- a/OpenNest.Server.Tests/SetReleaseTests.cs +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -295,7 +295,7 @@ public sealed class SetReleaseTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, 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.Tests/SharedSettingsTests.cs b/OpenNest.Server.Tests/SharedSettingsTests.cs index c9384b2d..8d910729 100644 --- a/OpenNest.Server.Tests/SharedSettingsTests.cs +++ b/OpenNest.Server.Tests/SharedSettingsTests.cs @@ -68,7 +68,7 @@ public sealed class SharedSettingsTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(17L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Empty(upgraded.ListSharedSettings()); } diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs index 2a412992..c2f4385f 100644 --- a/OpenNest.Server/NestDatabase.Drawings.cs +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -87,6 +87,16 @@ public sealed partial class NestDatabase PRAGMA user_version=5; """; + // v18: priority nests higher numbers first. Stored values were lower-first, so they are + // negated as LegacyPriority.Convert does (int.MinValue, which has no negation, becomes int.MaxValue). + private const string PriorityNegation = "CASE WHEN priority = -2147483648 THEN 2147483647 ELSE -priority END"; + + private const string DrawingPriorityUpgradeToV18 = $""" + UPDATE drawings SET priority = {PriorityNegation} WHERE priority <> 0; + UPDATE drawing_revisions SET priority = {PriorityNegation} WHERE priority <> 0; + PRAGMA user_version=18; + """; + private const string DrawingColumns = """ id, revision, name, customer, materialName, materialGrade, thickness, units, cutType, notes, archived, packageSha256, packageSize, savedAt, savedBy, diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index d8126d76..cf6747d7 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -344,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 17) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 18) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -449,6 +449,12 @@ public sealed partial class NestDatabase : IDisposable AddJobProgress(command); if (version < 17) AddJobReview(command); + if (version < 18) + { + // Drawing priority now nests higher numbers first; negating keeps every order. + command.CommandText = DrawingPriorityUpgradeToV18; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Tests/Data/DrawingNestingSettingsPriorityTests.cs b/OpenNest.Tests/Data/DrawingNestingSettingsPriorityTests.cs new file mode 100644 index 00000000..ee34f428 --- /dev/null +++ b/OpenNest.Tests/Data/DrawingNestingSettingsPriorityTests.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +/// +/// Library settings carry priority (higher first) as nestPriority and keep the +/// lower-first legacy value under priority, which older clients read and send. +/// +public class DrawingNestingSettingsPriorityTests +{ + private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); + + [Fact] + public void WritesTheLegacyValueBeforeTheNewOne() + { + var json = JsonSerializer.Serialize(new DrawingNestingSettings { Priority = 4 }, Web); + + Assert.Contains("\"priority\":-4,\"nestPriority\":4", json); + } + + [Fact] + public void ReadsAnOlderClientsLegacyValue() + { + var settings = JsonSerializer.Deserialize("""{"cutFirst":true,"priority":2}""", Web)!; + + Assert.Equal(-2, settings.Priority); + } + + [Fact] + public void RoundTripsAndStaysEqual() + { + var settings = new DrawingNestingSettings { Priority = 7, CutFirst = true }; + + var back = JsonSerializer.Deserialize(JsonSerializer.Serialize(settings, Web), Web); + + Assert.Equal(settings, back); + } +} diff --git a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs index 150930c7..01d92440 100644 --- a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs +++ b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs @@ -177,7 +177,7 @@ public class RemoteDrawingLibraryTests 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.Contains("\"settings\":{\"cutFirst\":true,\"priority\":-2,\"nestPriority\":2", handler.RequestBodies[0]); Assert.Equal(record.Settings, saved.Settings); } diff --git a/OpenNest/Forms/EditDrawingForm.Designer.cs b/OpenNest/Forms/EditDrawingForm.Designer.cs index 7dead9b5..e5f30354 100644 --- a/OpenNest/Forms/EditDrawingForm.Designer.cs +++ b/OpenNest/Forms/EditDrawingForm.Designer.cs @@ -406,6 +406,7 @@ namespace OpenNest.Forms priorityBox.Dock = DockStyle.Fill; priorityBox.Margin = new Padding(3, 4, 3, 4); priorityBox.Maximum = new decimal(new int[] { 999999, 0, 0, 0 }); + priorityBox.Minimum = new decimal(new int[] { 999999, 0, 0, int.MinValue }); priorityBox.Name = "priorityBox"; priorityBox.Suffix = ""; priorityBox.TabIndex = 1; @@ -416,7 +417,7 @@ namespace OpenNest.Forms priorityHint.AutoSize = true; priorityHint.ForeColor = SystemColors.GrayText; priorityHint.Name = "priorityHint"; - priorityHint.Text = "Lower numbers nest first"; + priorityHint.Text = "Higher numbers nest first"; // // cutFirstBox // diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md index 776bf1b2..ccf9f4e4 100644 --- a/docs/nest-file-format.md +++ b/docs/nest-file-format.md @@ -323,7 +323,7 @@ the server's library record owns are written as their defaults, so editing them changes the hash: - nest and drawing `name` (blank), drawing `customer`, material `name` and `grade` (blank); -- `color`, `customColor`, `priority`, `cutFirst` and `constraints` (omitted); +- `color`, `customColor`, `priority`, `nestPriority`, `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 @@ -346,6 +346,14 @@ from the active scheme's palette, so the view follows the application's scheme rather than whichever scheme the file was saved under. Files written before this flag have no custom colors and are recolored on open. Split pieces keep the flag. +## Drawing priority + +`nestPriority` is the drawing's nesting priority: higher numbers nest first, and it is +omitted when zero. Readers that predate it used `priority`, where lower numbers nested +first, so writers keep writing `priority` as the negated value and older readers keep the +same order. A reader uses `nestPriority` when present and otherwise negates `priority`, so +files written before the change open in the order they were saved with. + ## Drawing cut-first flag A drawing may write `cutFirst: true` to have automatic part sequencing cut its parts diff --git a/docs/nest-storage.md b/docs/nest-storage.md index a44100df..20941b1b 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -307,7 +307,9 @@ first upload), `revision`, `name`, `customer`, `materialName`, `materialGrade`, 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`, +nest: `cutFirst` (sequence its parts first, for example test squares), `nestPriority` +(higher numbers nest first; `priority` carries the negated, lower-first value for older +clients, and a request without `nestPriority` is read from it), `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 diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index df1a213d..936ec33f 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -241,7 +241,7 @@ its length less that gain; the first copy of each run pays the gain back. Bar by bar, every available stock offers two candidate bars: the fullest bar of the CutList packer (first-fit decreasing plus its swap optimization, or an exhaustive fewest-bars search for 25 items or fewer) and the fullest single bar a bounded subset-sum search can fill from the remaining -demand. Lower priority numbers pack first in both. Each candidate is laid out, then the rest of +demand. Higher priority numbers pack first in both. Each candidate is laid out, then the rest of the demand is priced at the cheaper of each stock's cut-list plan and its repeated best fills. The winner has the fewest unplaced parts of the most urgent tier, then the fewest unplaced parts, then the lowest salvage-credited cost. Searches stop at counted node budgets, so results are @@ -345,7 +345,7 @@ that circle (a square fills 0.64, a gear about 0.85), it returns everything unpl reporting progress, and Default discards it at almost no cost. Holes are not used here; rings in rings come from the cutout pre-pass, which hands the engine each nested chain as its outer frame. -Each disc goes, lower priority number first and then largest first, to the best feasible center +Each disc goes, higher priority number first and then largest first, to the best feasible center tangent to two of: the work-area walls, placed discs, and clamp zones grown by the clamp clearance (square-cornered, as the layout check grows them). Discs keep the part spacing plus twice `NestTolerances.ValidationOutline` between them. Bottom-left fills rows and left-bottom diff --git a/docs/server-nesting.md b/docs/server-nesting.md index 6d89576d..c6df4dc4 100644 --- a/docs/server-nesting.md +++ b/docs/server-nesting.md @@ -98,7 +98,8 @@ package and result on the server. **The package** (`OpenNest.Api.NestJobPackage`) is a ZIP of: -- `job.json`: `NestJobDocument`: engine name, each item's quantity, priority, rotation range +- `job.json`: `NestJobDocument`: engine name, each item's quantity, priority (higher first; + version 1 jobs nested lower numbers first and are converted when read), rotation range and drawing index, the drawing names in order (a guard against reordering), each drawing's geometry fingerprint (a SHA-256 of the exact motions the freshness check compares, which a later Open checks the nest's drawings against; workers ignore it), every stock