Files
OpenNest/OpenNest.Data/NestDefaults.cs
T
ajandClaude Opus 5.5 b026bd19d2 feat(common-line): place parts on common lines automatically at a chosen level
Slice 3 of common-line cutting. On costly plate (N26-295: 3/16 RA310S at about
$2,500 a sheet) pairing parts one kerf apart fits the part the spacing cannot:
the last plate's 38 parts fit on one 48x120 with Auto Nest only with pairs.

- Drawing Properties gains "Allow common line" (Drawing.AllowCommonLine, off by
  default because customer edge-quality needs vary). The nest's CommonLineUsage
  (Off / When it adds parts / Whenever available) is set in Auto Nest and seeded
  from Tools > Nest Defaults; both persist (nest.json, defaults.json).
- NestPipeline runs the ordinary solve, then, with a level on, trials where
  eligible parts are offered as kerf-gap pair compounds. Placed pairs expand into
  parts plus explicit NestJobCommonLine groups, are validated again, and
  CommonLineRanking keeps a trial only by the level: count first, then sheets;
  "when it adds parts" keeps the plain layout on a tie, "whenever" also takes a
  shorter cut or less material, never at the cost of a part. Engines, FillScore
  and their comparers are unchanged; Off asks the engine exactly what it did.
- NestLayoutCheck exempts members only through a sheet's explicit group list and
  only when the job allows common lines; materializing and committing create
  Plate.CommonLines.
- Server nesting writes common-line jobs as version 3 and replays the recorded
  trial answers, so the desktop binds the same groups.
- Fill Area honors the level through a PlateFillService overload.
- The benchmark gains --common-lines <level> --kerf <k>.

Benchmark (synthetic nests + N26-295, --parallel 1): with no flag and with
--common-lines off every engine matches the base commit; with a level on no
layout becomes invalid and three runs save a sheet at equal nested area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:38:00 -05:00

364 lines
13 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using OpenNest.Geometry;
namespace OpenNest.Data;
/// <summary>Outcome of <see cref="NestDefaults.Load(string, out NestDefaultsStatus)"/>.</summary>
public enum NestDefaultsStatus
{
/// <summary>Defaults were read from the file (invalid fields still fall back individually).</summary>
Ok,
/// <summary>No file exists at the path; built-in fallback values were used.</summary>
Missing,
/// <summary>The file exists but could not be read or parsed; fallback values were used.</summary>
Invalid,
}
/// <summary>
/// Plate/nest defaults persisted to a single JSON file
/// (by default %APPDATA%\OpenNest\defaults.json), replacing the
/// .nstdot nest-template mechanism. Loading never throws: a missing,
/// corrupt, or partially valid file degrades field-by-field to
/// <see cref="Fallback"/> values so creating a new nest is never blocked.
/// </summary>
public sealed class NestDefaults
{
public const int CurrentVersion = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public Units Units { get; set; } = Units.Inches;
public Size Size { get; set; } = new(100, 100);
public int Quadrant { get; set; } = 1;
public double PartSpacing { get; set; } = 1;
public Spacing EdgeSpacing { get; set; } = new(1, 1, 1, 1);
/// <summary>
/// The machine configuration new nests are assigned, or null to use the first machine
/// by name. An id with no configuration on file falls back the same way.
/// </summary>
public Guid? MachineId { get; set; }
/// <summary>
/// The common-line usage new nests start with (<see cref="Nest.CommonLineUsage"/>). Off by
/// default; saved as <c>commonLineUsage</c> and omitted when off.
/// </summary>
public CommonLineUsage CommonLineUsage { get; set; }
/// <summary>
/// The built-in defaults used when no file exists and for every field
/// that is missing or invalid. Matches the historical
/// File > New values (units default to Inches; callers
/// may override from their own settings).
/// </summary>
public static NestDefaults Fallback => new();
/// <summary>%APPDATA%\OpenNest\defaults.json.</summary>
public static string DefaultPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest",
"defaults.json"
);
/// <summary>
/// Loads defaults from <paramref name="path"/>, falling back field by
/// field for a missing file, invalid JSON, or invalid values.
/// </summary>
public static NestDefaults Load(string path) => Load(path, out _);
/// <summary>
/// Loads defaults and reports whether the file was missing, loaded, or
/// present but unreadable/invalid, so callers can warn about a corrupt
/// file while still returning usable values.
/// </summary>
public static NestDefaults Load(string path, out NestDefaultsStatus status) =>
Load(path, Fallback.Units, out status);
/// <summary>
/// Loads defaults like <see cref="Load(string, out NestDefaultsStatus)"/>,
/// but a missing file, an unusable file, or a missing/undefined unit
/// field yields <paramref name="fallbackUnits"/> (the caller's existing
/// unit preference) instead of the built-in units. Other valid fields
/// still load. An undefined <paramref name="fallbackUnits"/> is ignored.
/// </summary>
public static NestDefaults Load(
string path,
Units fallbackUnits,
out NestDefaultsStatus status
)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
status = NestDefaultsStatus.Missing;
return WithUnits(Fallback, fallbackUnits);
}
string json;
try
{
json = File.ReadAllText(path);
}
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{
// A file that exists but cannot be read (locked, access-denied)
// must never block creating a nest.
status = NestDefaultsStatus.Invalid;
return WithUnits(Fallback, fallbackUnits);
}
return Parse(json, fallbackUnits, out status);
}
/// <summary>
/// Reads defaults from <paramref name="json"/> (a file or the shared copy) the way
/// <see cref="Load(string, Units, out NestDefaultsStatus)"/> reads a file, never throwing.
/// </summary>
public static NestDefaults Parse(string json, Units fallbackUnits, out NestDefaultsStatus status)
{
var defaults = WithUnits(Fallback, fallbackUnits);
NestDefaultsDto? dto;
try
{
dto = JsonSerializer.Deserialize<NestDefaultsDto>(json ?? "", JsonOptions);
}
catch (Exception ex) when (SettingsFile.IsUnreadableFile(ex))
{
// Defaults that cannot be parsed must never block creating a nest.
status = NestDefaultsStatus.Invalid;
return defaults;
}
if (dto is null)
{
status = NestDefaultsStatus.Invalid;
return defaults;
}
status = NestDefaultsStatus.Ok;
if (SettingsFile.TryParseUnits(dto.Units, out var units))
defaults.Units = units;
if (
dto.Size?.Width is { } width
&& dto.Size.Length is { } length
&& IsValidSize(width, length)
)
defaults.Size = new Size(width, length);
if (dto.Quadrant is { } quadrant && quadrant is >= 1 and <= 4)
defaults.Quadrant = quadrant;
if (dto.PartSpacing is { } partSpacing && IsValidSpacing(partSpacing))
defaults.PartSpacing = partSpacing;
if (
dto.EdgeSpacing?.Left is { } left
&& dto.EdgeSpacing.Bottom is { } bottom
&& dto.EdgeSpacing.Right is { } right
&& dto.EdgeSpacing.Top is { } top
&& IsValidSpacing(left)
&& IsValidSpacing(bottom)
&& IsValidSpacing(right)
&& IsValidSpacing(top)
)
defaults.EdgeSpacing = new Spacing(left, bottom, right, top);
if (dto.MachineId is { ValueKind: JsonValueKind.String } machineId
&& Guid.TryParse(machineId.GetString(), out var machine) && machine != Guid.Empty)
defaults.MachineId = machine;
if (dto.CommonLineUsage is { ValueKind: JsonValueKind.String } usage)
defaults.CommonLineUsage = CommonLineUsageNames.Parse(usage.GetString());
return defaults;
}
private static NestDefaults WithUnits(NestDefaults defaults, Units units)
{
if (Enum.IsDefined(units))
defaults.Units = units;
return defaults;
}
/// <summary>
/// Captures the current units and plate defaults from a nest.
/// </summary>
public static NestDefaults FromNest(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
var plate = nest.PlateDefaults;
return new NestDefaults
{
Units = nest.Units,
Size = plate.Size,
Quadrant = plate.Quadrant,
PartSpacing = plate.PartSpacing,
EdgeSpacing = plate.EdgeSpacing,
MachineId = nest.Machine?.Id,
CommonLineUsage = nest.CommonLineUsage,
};
}
/// <summary>
/// Captures defaults from an existing plate (a copy of its size,
/// quadrant, and spacing), e.g. the active plate in the desktop app.
/// </summary>
public static NestDefaults FromPlate(Units units, Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
return new NestDefaults
{
Units = units,
Size = plate.Size,
Quadrant = plate.Quadrant,
PartSpacing = plate.PartSpacing,
EdgeSpacing = plate.EdgeSpacing,
};
}
/// <summary>
/// <see cref="FromPlate(Units, Plate)"/>, keeping the settings a plate does not hold (the
/// common-line usage) from <paramref name="saved"/>, the defaults being replaced, so saving a
/// plate as the defaults never resets them.
/// </summary>
public static NestDefaults FromPlate(Units units, Plate plate, NestDefaults saved)
{
ArgumentNullException.ThrowIfNull(saved);
var defaults = FromPlate(units, plate);
defaults.CommonLineUsage = saved.CommonLineUsage;
return defaults;
}
/// <summary>
/// These defaults in <paramref name="units"/>: plate size and spacings are converted
/// (25.4 mm per inch) and rounded to four decimals; the quadrant is kept. Returns a copy
/// even when the units already match.
/// </summary>
public NestDefaults ConvertedTo(Units units)
{
var factor = units == Units ? 1.0 : units == Units.Millimeters ? MillimetersPerInch : 1 / MillimetersPerInch;
double Convert(double value) => System.Math.Round(value * factor, 4, MidpointRounding.AwayFromZero);
return new NestDefaults
{
Units = units,
Size = new Size(Convert(Size.Width), Convert(Size.Length)),
Quadrant = Quadrant,
PartSpacing = Convert(PartSpacing),
EdgeSpacing = new Spacing(Convert(EdgeSpacing.Left), Convert(EdgeSpacing.Bottom),
Convert(EdgeSpacing.Right), Convert(EdgeSpacing.Top)),
MachineId = MachineId,
CommonLineUsage = CommonLineUsage,
};
}
private const double MillimetersPerInch = 25.4;
public void ApplyTo(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
nest.Units = Units;
var plate = nest.PlateDefaults;
plate.Size = Size;
plate.Quadrant = Quadrant;
plate.PartSpacing = PartSpacing;
plate.EdgeSpacing = EdgeSpacing;
nest.CommonLineUsage = CommonLineUsage;
}
/// <summary>
/// Writes the file (creating the parent directory), retrying briefly on
/// IO collisions the same way <see cref="LocalJsonProvider"/> does.
/// </summary>
public void Save(string path, int maxRetries = 3)
{
SettingsFile.Write(path, ToJson(), maxRetries);
}
/// <summary>The defaults as <see cref="Save"/> writes them, for the shared copy.</summary>
public string ToJson()
{
var dto = new NestDefaultsDto
{
Version = CurrentVersion,
Units = JsonSerializer.SerializeToElement(Units.ToString().ToLowerInvariant()),
Size = new SizeDto { Width = Size.Width, Length = Size.Length },
Quadrant = Quadrant,
PartSpacing = PartSpacing,
EdgeSpacing = new SpacingDto
{
Left = EdgeSpacing.Left,
Bottom = EdgeSpacing.Bottom,
Right = EdgeSpacing.Right,
Top = EdgeSpacing.Top,
},
MachineId = MachineId is { } machineId ? JsonSerializer.SerializeToElement(machineId.ToString()) : null,
CommonLineUsage = CommonLineUsageNames.Format(CommonLineUsage) is { } usage
? JsonSerializer.SerializeToElement(usage) : null,
};
return JsonSerializer.Serialize(dto, JsonOptions);
}
private static bool IsValidSize(double width, double length) =>
!double.IsNaN(width)
&& !double.IsNaN(length)
&& !double.IsInfinity(width)
&& !double.IsInfinity(length)
&& width > 0
&& length > 0;
private static bool IsValidSpacing(double value) =>
!double.IsNaN(value) && !double.IsInfinity(value) && value >= 0;
/// <summary>
/// Wire format. Every field is nullable so a partial file merges over
/// the fallback field by field; unknown fields (including a future
/// higher <c>version</c>) are ignored rather than rejected.
/// </summary>
private sealed record NestDefaultsDto
{
public int? Version { get; init; } = CurrentVersion;
// Read as raw JSON so a wrongly typed unit falls back on its own
// instead of failing the whole file.
public JsonElement? Units { get; init; }
public SizeDto? Size { get; init; }
public int? Quadrant { get; init; }
public double? PartSpacing { get; init; }
public SpacingDto? EdgeSpacing { get; init; }
// Raw JSON for the same reason as Units.
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? MachineId { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public JsonElement? CommonLineUsage { get; init; }
}
private sealed record SizeDto
{
public double? Width { get; init; }
public double? Length { get; init; }
}
private sealed record SpacingDto
{
public double? Left { get; init; }
public double? Bottom { get; init; }
public double? Right { get; init; }
public double? Top { get; init; }
}
}