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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-03 11:02:07 -05:00
1 parent 2721375e8b
commit fbc23d6b64
51 files changed
+364 -79

No files matched your search

+1
View File
@@ -7,5 +7,6 @@ public class NestRequestPart
public string DxfPath { get; init; }
public int Quantity { get; init; } = 1;
public bool AllowRotation { get; init; } = true;
/// <summary>Nesting order: higher numbers nest first.</summary>
public int Priority { get; init; } = 0;
}
+1
View File
@@ -66,6 +66,7 @@ namespace OpenNest
public string Customer { get; set; }
/// <summary>Nesting order: higher numbers nest first. Zero is the default.</summary>
public int Priority { get; set; }
public DwgQty Quantity;
+13
View File
@@ -0,0 +1,13 @@
namespace OpenNest;
/// <summary>
/// Nesting priority places higher numbers first. Files, the drawing library and server job
/// documents written before that stored a lower-first value under <c>priority</c>; 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.
/// </summary>
public static class LegacyPriority
{
/// <summary>Converts between a higher-first priority and its lower-first legacy value, either way.</summary>
public static int Convert(int priority) => priority == int.MinValue ? int.MaxValue : -priority;
}
+22
View File
@@ -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
/// <summary>Automatic sequencing cuts this drawing's parts first (for example test squares).</summary>
public bool CutFirst { get; init; }
/// <summary>Nesting order: higher numbers nest first.</summary>
[JsonIgnore]
public int Priority { get; init; }
/// <summary>
/// The lower-first legacy value (<see cref="OpenNest.LegacyPriority"/>) older clients read
/// and send. Written before <see cref="NestPriority"/>, so a reader that sees both ends on it.
/// </summary>
[JsonPropertyName("priority")]
public int LegacyPriorityValue
{
get => LegacyPriority.Convert(Priority);
init => Priority = LegacyPriority.Convert(value);
}
/// <summary><see cref="Priority"/> on the wire; absent from older clients.</summary>
[JsonPropertyName("nestPriority")]
public int NestPriority
{
get => Priority;
init => Priority = value;
}
/// <summary>Rotation step in radians; zero means the engine's default.</summary>
public double StepAngle { get; init; }
@@ -0,0 +1,45 @@
using OpenNest.Engine.Jobs.Remote;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// 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.
/// </summary>
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<FormatException>(() => 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 }],
};
}
@@ -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[]
@@ -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")
@@ -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));
@@ -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));
@@ -71,9 +71,9 @@ public abstract class EngineContractTests<TEngine> 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);
@@ -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<NestJobProgress> report) : IProgress<NestJobProgress>
@@ -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<NestItem> { Item("later", 10, 10, 1, priority: 1), Item("first", 6, 6, 1, priority: 0) };
var items = new List<NestItem> { 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);
@@ -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);
+4 -4
View File
@@ -9,8 +9,8 @@ public sealed class NestJobPart
/// <param name="id">Unique requirement ID.</param>
/// <param name="geometry">Snapshot of the part geometry.</param>
/// <param name="quantity">Positive number requested.</param>
/// <param name="priority">Placement precedence: lower numbers are placed first and win scarce
/// stock. Zero is the default and highest ordinary priority; equal priorities are peers.</param>
/// <param name="priority">Placement precedence: higher numbers are placed first and win scarce
/// stock. Zero is the default; negative numbers place after it. Equal priorities are peers.</param>
/// <param name="rotation">Allowed rotations, or null for automatic rotation.</param>
public NestJobPart(
string id,
@@ -37,8 +37,8 @@ public sealed class NestJobPart
/// <summary>Positive number requested; never decremented by placement code.</summary>
public int Quantity { get; }
/// <summary>
/// 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.
/// </summary>
public int Priority { get; }
public RotationPolicy Rotation { get; }
@@ -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();
@@ -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();
@@ -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<Part>();
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
+10 -3
View File
@@ -15,7 +15,14 @@ namespace OpenNest.Engine.Jobs.Remote;
/// </summary>
public sealed class NestJobDocument
{
public const int CurrentVersion = 1;
/// <summary>
/// Version 2 nests higher priority numbers first. Version 1 jobs, from desktops that nested
/// lower numbers first, still read, with their priorities converted
/// (<see cref="LegacyPriority"/>); an older worker refuses version 2 instead of reversing it.
/// </summary>
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<Drawing> 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,
+2 -2
View File
@@ -1,4 +1,4 @@
namespace OpenNest.Engine
namespace OpenNest.Engine
{
public class NestItem
{
@@ -8,7 +8,7 @@
public Drawing Drawing { get; set; }
/// <summary>
/// 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.
/// </summary>
public int Priority { get; set; }
@@ -37,7 +37,7 @@ internal enum DiscSeed
/// <summary>
/// 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.
/// </summary>
@@ -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();
@@ -35,7 +35,7 @@ internal sealed record BarSheet(BarStock Bar, IReadOnlyList<BarPart> 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.
/// </summary>
internal sealed class BarLayout
{
@@ -156,7 +156,7 @@ internal sealed class BarLayout
private IEnumerable<BarType> 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);
@@ -61,7 +61,7 @@ internal sealed record BarPlan(IReadOnlyList<PackedBar> Bars, IReadOnlyList<BarI
/// that replaces a shorter group in a bar with unplaced items that fill it better. DuplicateBins
/// is omitted: it only acts on leftovers after the bar cap, when no bar can be added anyway.
///
/// OpenNest additions: items are ordered by priority before length, so a lower-number tier is
/// OpenNest additions: items are ordered by priority before length, so a higher-number tier is
/// packed first and wins scarce bars, and the swap step never trades an item for a later tier.
/// The exhaustive search counts its nodes and stops at a fixed budget, keeping the best plan found.
/// </summary>
@@ -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
/// <summary>
/// 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.
/// </summary>
private static bool TryImprove(PackedSegment segment, List<BarItem> 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();
@@ -245,14 +245,14 @@ public sealed class FlatBarNestingEngine : INestingEngine
private readonly record struct Estimate(int Tier, int UrgentUnplaced, int Unplaced, double Cost)
: IComparable<Estimate>
{
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<BarType> 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;
@@ -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<IReadOnlyList<Placed>> 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)
|| (
@@ -29,8 +29,8 @@ internal sealed record SheetPlan(
/// <summary>
/// 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.
/// </summary>
internal static class SheetPacker
{
@@ -161,7 +161,7 @@ internal static class SheetByStock
}
/// <summary>
/// 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.
/// </summary>
@@ -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;
}
@@ -11,7 +11,7 @@ namespace OpenNest.Engine.RectanglePacking;
/// <summary>
/// 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
/// <see cref="NestItem.Priority"/>; 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.
/// </summary>
@@ -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<Part>? best = null;
(int Count, double Area)[]? bestTiers = null;
@@ -16,7 +16,7 @@ internal enum PickMode
}
/// <summary>A box type to pack.</summary>
/// <param name="Priority">Demand tier; lower numbers are served first.</param>
/// <param name="Priority">Demand tier; higher numbers are served first.</param>
/// <param name="Sizes">Allowed packing sizes (one per orientation), already grown by any spacing.</param>
/// <param name="Area">Ranking area: larger boxes win score ties and go first in ordered mode.</param>
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);
/// <summary>
/// Packs box types onto one <see cref="MaxRectsSheet"/> 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 <see cref="MaxRectsSheet"/> 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.
/// </summary>
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);
+92
View File
@@ -0,0 +1,92 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
namespace OpenNest.IO.Tests;
/// <summary>
/// Priority nests higher numbers first. Files keep writing the lower-first legacy value under
/// <c>priority</c> so older readers keep the same order, and files without <c>nestPriority</c>
/// are converted when read.
/// </summary>
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<JsonObject> 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();
}
}
+1 -1
View File
@@ -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" };
/// <summary>Packages <paramref name="drawing"/> without changing it.</summary>
public static byte[] Write(Drawing drawing, Units units)
+10
View File
@@ -443,8 +443,18 @@ namespace OpenNest.IO
public bool CustomColor { get; init; }
public QuantityDto Quantity { get; init; } = new();
/// <summary>
/// The lower-first legacy priority (<see cref="OpenNest.LegacyPriority"/>), still
/// written so older readers keep the same order. Read only when
/// <see cref="NestPriority"/> is absent.
/// </summary>
public int Priority { get; init; }
/// <summary>Nesting priority, higher numbers first. Omitted when zero.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? NestPriority { get; init; }
/// <summary>Sequence this drawing's parts first. Omitted when false.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool CutFirst { get; init; }
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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
{
@@ -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<InvalidOperationException>(() => 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()
{
@@ -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]
+1 -1
View File
@@ -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);
@@ -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()
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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()
@@ -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));
+1 -1
View File
@@ -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));
+1 -1
View File
@@ -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());
}
+10
View File
@@ -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,
+7 -1
View File
@@ -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();
}
}
@@ -0,0 +1,39 @@
using System.Text.Json;
using OpenNest.Data;
namespace OpenNest.Tests.Data;
/// <summary>
/// Library settings carry priority (higher first) as <c>nestPriority</c> and keep the
/// lower-first legacy value under <c>priority</c>, which older clients read and send.
/// </summary>
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<DrawingNestingSettings>("""{"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<DrawingNestingSettings>(JsonSerializer.Serialize(settings, Web), Web);
Assert.Equal(settings, back);
}
}
@@ -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);
}
+2 -1
View File
@@ -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
//
+9 -1
View File
@@ -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
+3 -1
View File
@@ -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
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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