Files
OpenNest/OpenNest.Engine/Jobs/Remote/NestJobDocument.cs
T
ajandClaude Opus 5.5 fbc23d6b64 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>
2026-11-03 11:02:07 -05:00

174 lines
7.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Engine.Jobs.Remote;
/// <summary>
/// The parts of a <see cref="NestPipelineRequest"/> that are not drawings: the engine, each
/// item's quantity, priority and rotation with the index of its drawing, the stock and the job
/// options. Drawings travel beside it in a <c>.nest</c> archive (<c>OpenNest.Api.NestJobPackage</c>).
/// A remote solve never runs with <see cref="NestJobOptions.SearchUntilCancelled"/>: it has no
/// time limit, so a slow server finds the same nest a fast one does, later.
/// </summary>
public sealed class NestJobDocument
{
/// <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()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public int Version { get; set; } = CurrentVersion;
public string EngineName { get; set; }
/// <summary>Each drawing's name in list order, so a reader can tell the list arrived in that order.</summary>
public List<string> DrawingNames { get; set; } = new();
/// <summary>
/// Each drawing's <see cref="PartGeometrySnapshot.Fingerprint"/> as sent, in list order (empty
/// for a drawing without supported geometry). A desktop reopening the job in a later session
/// checks its drawings against these (<see cref="RemoteNestSubmission.Reopen"/>). Null from
/// clients that predate it; workers ignore it.
/// </summary>
public List<string> DrawingFingerprints { get; set; }
public List<ItemJson> Items { get; set; } = new();
public List<NestStockJson> Stock { get; set; } = new();
public OptionsJson JobOptions { get; set; }
/// <summary>
/// Describes <paramref name="request"/>, naming each item's drawing by its position in
/// <paramref name="drawings"/>, which must hold every item's drawing.
/// </summary>
public static NestJobDocument From(NestPipelineRequest request, IReadOnlyList<Drawing> drawings)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(drawings);
var document = new NestJobDocument
{
EngineName = request.EngineName,
DrawingNames = drawings.Select(d => d?.Name ?? "").ToList(),
DrawingFingerprints = drawings.Select(RemoteNestSubmission.Fingerprint).ToList(),
Stock = request.Stock.Select(NestStockJson.From).ToList(),
JobOptions = request.Options is { } o ? new OptionsJson
{
PlacementStrategy = o.PlacementStrategy,
MaxPlates = o.MaxPlates,
SalvageRate = o.SalvageRate,
MinimumSalvageDimension = o.MinimumSalvageDimension,
OffcutCut = o.OffcutCut,
} : null,
};
foreach (var item in request.Items)
{
var index = item?.Drawing == null ? -1 : IndexOf(drawings, item.Drawing);
if (item != null && index < 0)
throw new ArgumentException("Every item's drawing must be in the drawing list.", nameof(drawings));
document.Items.Add(item == null ? null : new ItemJson
{
Drawing = index,
Quantity = item.Quantity,
Priority = item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
});
}
return document;
}
/// <summary>The request this describes, with drawings taken from <paramref name="drawings"/> by position.</summary>
public NestPipelineRequest ToRequest(IReadOnlyList<Drawing> drawings)
{
ArgumentNullException.ThrowIfNull(drawings);
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.");
if (DrawingNames == null || DrawingNames.Count != drawings.Count
|| DrawingNames.Where((name, i) => !string.Equals(name, drawings[i]?.Name ?? "", StringComparison.Ordinal)).Any())
throw new FormatException("The nest job's drawings do not match the drawings sent with it.");
var items = new List<NestItem>(Items?.Count ?? 0);
foreach (var item in Items ?? new())
{
if (item == null)
{
items.Add(null);
continue;
}
if (item.Drawing < 0 || item.Drawing >= drawings.Count)
throw new FormatException($"A nest job item names drawing {item.Drawing}, which the job does not hold.");
items.Add(new NestItem
{
Drawing = drawings[item.Drawing],
Quantity = item.Quantity,
Priority = Version == LowerFirstPriorityVersion ? LegacyPriority.Convert(item.Priority) : item.Priority,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
});
}
var options = JobOptions is { } o
? new NestJobOptions(o.PlacementStrategy ?? "Lattice", o.MaxPlates, o.SalvageRate, o.MinimumSalvageDimension,
searchUntilCancelled: false, o.OffcutCut)
: null;
return new NestPipelineRequest(EngineName, items,
(Stock ?? new()).Select(s => s?.ToStock() ?? throw new FormatException("A nest job stock entry is empty.")).ToList(),
options);
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static NestJobDocument FromJson(string json)
{
try
{
return JsonSerializer.Deserialize<NestJobDocument>(json, Options)
?? throw new FormatException("The nest job is empty.");
}
catch (JsonException ex) { throw new FormatException("The nest job is not valid JSON.", ex); }
}
private static int IndexOf(IReadOnlyList<Drawing> drawings, Drawing drawing)
{
for (var i = 0; i < drawings.Count; i++)
{
if (ReferenceEquals(drawings[i], drawing))
return i;
}
return -1;
}
public sealed class ItemJson
{
public int Drawing { get; set; }
public int Quantity { get; set; }
public int Priority { get; set; }
public double StepAngle { get; set; }
public double RotationStart { get; set; }
public double RotationEnd { get; set; }
}
public sealed class OptionsJson
{
public string PlacementStrategy { get; set; }
public int? MaxPlates { get; set; }
public double SalvageRate { get; set; }
public double MinimumSalvageDimension { get; set; }
public CutOffAxis? OffcutCut { get; set; }
}
}