mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 14:51:37 -05:00
Equal-cost plans break ties by the largest edge offcut, from any of the four edges. A caller that wants the leftover off one way, such as a shop that keeps only end drops, or a benchmark scoring the used sheet height, had no way to say so. On the converted Nestbolt jobs the extended Irregular search then found plans whose larger offcut is a strip down the side, which Nestbolt scores as worse: b-blaz1-strip 3.601 -> 3.867, b-shirts-strip 2.674 -> 2.995. NestJobOptions.OffcutCut (a CutOffAxis, default null) counts only the strips one cut along that axis frees, for salvage credit and the compactness tie-break alike: Horizontal the full-width bottom and top strips, Vertical the full-length sides. PlateRemnant.LargestOffcut takes the axis; NestJobCost, the time limit and clamp-removal recovery pass it on. Unset, nothing changes. Benchmark manifests accept "offcutCut": "horizontal" or "vertical". With horizontal, one CPU, 20 s limit, Default: b-blaz1-strip 3.934 -> 3.333 b-shirts-strip 2.995 -> 2.610 m-box-deep 1.918 -> 1.276 (Nestbolt 1.251) Manifests accept only the words horizontal or vertical (any case); a list such as "horizontal,vertical" is rejected. PlateRemnant rejects an undefined axis. Fixed strategies keep their own no-salvage tie-break and ignore the option; desktop, Console and MCP do not offer it yet. Mutation reds: 14/14 (offcut-mutations.py).
237 lines
9.0 KiB
C#
237 lines
9.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.IO;
|
|
|
|
namespace OpenNest.Benchmark
|
|
{
|
|
/// <summary>
|
|
/// Builds a BenchmarkJob from a JSON manifest that lists DXF files and the
|
|
/// quantity of each to nest. DXF paths resolve relative to the manifest.
|
|
/// Sheet sizes come from the manifest or the caller's override; unlike a
|
|
/// .nest file there is no plate to inherit them from, so a job with none is
|
|
/// an error. Sheet sizes must use the same units as the DXFs.
|
|
/// </summary>
|
|
public static class DxfManifestLoader
|
|
{
|
|
/// <summary>Suffix a manifest needs to be picked up when scanning a folder.</summary>
|
|
public const string FolderSuffix = ".manifest.json";
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true,
|
|
};
|
|
|
|
public static BenchmarkJob Load(
|
|
string manifestPath,
|
|
IReadOnlyList<Size> sheetSizeOverrides = null,
|
|
double? partSpacingOverride = null
|
|
)
|
|
{
|
|
var manifest = ReadManifest(manifestPath);
|
|
var baseDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath));
|
|
|
|
if (manifest.Parts == null || manifest.Parts.Count == 0)
|
|
throw new InvalidOperationException(
|
|
$"Manifest '{manifestPath}' has no parts. Add entries to \"parts\"."
|
|
);
|
|
|
|
var sizes = ResolveSheetSizes(manifest, sheetSizeOverrides, manifestPath);
|
|
var requests = manifest.Parts.Select(p => BuildRequest(p, baseDir)).ToList();
|
|
|
|
return new BenchmarkJob
|
|
{
|
|
SourceFile = manifestPath,
|
|
CandidateSizes = sizes,
|
|
EdgeSpacing = new Spacing(manifest.EdgeSpacing, manifest.EdgeSpacing),
|
|
PartSpacing = partSpacingOverride ?? manifest.Spacing,
|
|
Quadrant = manifest.Quadrant,
|
|
Requests = requests,
|
|
OffcutCut = OffcutCut(manifest.OffcutCut, manifestPath),
|
|
};
|
|
}
|
|
|
|
/// <summary>"horizontal" or "vertical" (any case); absent counts every edge strip.
|
|
/// </summary>
|
|
private static CutOffAxis? OffcutCut(string value, string manifestPath) =>
|
|
value?.ToLowerInvariant() switch
|
|
{
|
|
null => null,
|
|
"horizontal" => CutOffAxis.Horizontal,
|
|
"vertical" => CutOffAxis.Vertical,
|
|
_ => throw new InvalidOperationException(
|
|
$"Manifest '{manifestPath}' has offcutCut \"{value}\"; "
|
|
+ "use \"horizontal\" or \"vertical\"."
|
|
),
|
|
};
|
|
|
|
private static Manifest ReadManifest(string manifestPath)
|
|
{
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<Manifest>(
|
|
File.ReadAllText(manifestPath),
|
|
JsonOptions
|
|
) ?? throw new InvalidOperationException("The manifest is empty.");
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Manifest '{manifestPath}' is not valid JSON: {ex.Message}",
|
|
ex
|
|
);
|
|
}
|
|
}
|
|
|
|
private static List<Size> ResolveSheetSizes(
|
|
Manifest manifest,
|
|
IReadOnlyList<Size> overrides,
|
|
string manifestPath
|
|
)
|
|
{
|
|
if (overrides != null && overrides.Count > 0)
|
|
return overrides.ToList();
|
|
|
|
var sizes = new List<Size>();
|
|
|
|
foreach (var text in manifest.SheetSizes ?? new List<string>())
|
|
{
|
|
if (!JobLoader.TryParseSheetSize(text, out var size))
|
|
throw new InvalidOperationException(
|
|
$"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")."
|
|
);
|
|
|
|
sizes.Add(size);
|
|
}
|
|
|
|
if (sizes.Count == 0)
|
|
throw new InvalidOperationException(
|
|
$"Manifest '{manifestPath}' has no sheet sizes. Set \"sheetSizes\" or pass --sheet-sizes."
|
|
);
|
|
|
|
return sizes.Distinct().ToList();
|
|
}
|
|
|
|
private static DrawingRequest BuildRequest(ManifestPart part, string baseDir)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(part.Dxf))
|
|
throw new InvalidOperationException("A manifest part is missing \"dxf\".");
|
|
|
|
if (part.Quantity <= 0)
|
|
throw new InvalidOperationException(
|
|
$"Manifest part '{part.Dxf}': quantity must be greater than 0 (was {part.Quantity})."
|
|
);
|
|
|
|
var dxfPath = Path.GetFullPath(Path.Combine(baseDir, part.Dxf));
|
|
|
|
if (!File.Exists(dxfPath))
|
|
throw new FileNotFoundException($"DXF file not found: {dxfPath}", dxfPath);
|
|
|
|
Drawing drawing;
|
|
|
|
try
|
|
{
|
|
drawing = CadImporter.ImportDrawing(
|
|
dxfPath,
|
|
new CadImportOptions { Quantity = part.Quantity }
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}", ex);
|
|
}
|
|
|
|
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
|
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
|
|
|
|
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
|
// Quarter turns are a 90° sweep from 0 to 270°; automatic rotation allows any angle.
|
|
var rotations = RotationSet(part);
|
|
if (rotations != RotationChoice.Any)
|
|
{
|
|
var quarter = rotations == RotationChoice.QuarterTurns;
|
|
drawing.Constraints ??= new NestConstraints();
|
|
drawing.Constraints.StepAngle = quarter
|
|
? OpenNest.Math.Angle.HalfPI
|
|
: OpenNest.Math.Angle.TwoPI;
|
|
drawing.Constraints.StartAngle = 0;
|
|
drawing.Constraints.EndAngle = quarter ? 3 * OpenNest.Math.Angle.HalfPI : 0;
|
|
drawing.Constraints.Allow180Equivalent = rotations == RotationChoice.HalfTurns;
|
|
}
|
|
|
|
var constraints = drawing.Constraints;
|
|
|
|
return new DrawingRequest
|
|
{
|
|
Drawing = drawing,
|
|
Quantity = part.Quantity,
|
|
Priority = drawing.Priority,
|
|
StepAngle = constraints?.StepAngle ?? 0,
|
|
RotationStart = constraints?.StartAngle ?? 0,
|
|
RotationEnd = constraints?.EndAngle ?? 0,
|
|
};
|
|
}
|
|
|
|
private enum RotationChoice
|
|
{
|
|
None,
|
|
HalfTurns,
|
|
QuarterTurns,
|
|
Any,
|
|
}
|
|
|
|
/// <summary>
|
|
/// A part's "rotations" (degrees) must be {0}, {0, 180} or {0, 90, 180, 270}; without it,
|
|
/// "allowRotation" picks any angle (true, the default: automatic rotation) or none.
|
|
/// </summary>
|
|
private static RotationChoice RotationSet(ManifestPart part)
|
|
{
|
|
if (part.Rotations == null)
|
|
return part.AllowRotation ? RotationChoice.Any : RotationChoice.None;
|
|
if (!part.AllowRotation)
|
|
throw new InvalidOperationException(
|
|
$"Manifest part '{part.Dxf}': set \"rotations\" or \"allowRotation\", not both."
|
|
);
|
|
|
|
var set = part.Rotations
|
|
.Select(degrees => ((degrees % 360) + 360) % 360)
|
|
.Distinct()
|
|
.OrderBy(degrees => degrees)
|
|
.ToArray();
|
|
if (set.SequenceEqual(new[] { 0.0 }))
|
|
return RotationChoice.None;
|
|
if (set.SequenceEqual(new[] { 0.0, 180.0 }))
|
|
return RotationChoice.HalfTurns;
|
|
if (set.SequenceEqual(new[] { 0.0, 90.0, 180.0, 270.0 }))
|
|
return RotationChoice.QuarterTurns;
|
|
throw new InvalidOperationException(
|
|
$"Manifest part '{part.Dxf}': \"rotations\" must be [0], [0, 180] or "
|
|
+ $"[0, 90, 180, 270] (was [{string.Join(", ", part.Rotations)}])."
|
|
);
|
|
}
|
|
|
|
private class Manifest
|
|
{
|
|
public List<string> SheetSizes { get; set; }
|
|
public double Spacing { get; set; }
|
|
public double EdgeSpacing { get; set; }
|
|
public int Quadrant { get; set; } = 1;
|
|
public string OffcutCut { get; set; }
|
|
public List<ManifestPart> Parts { get; set; }
|
|
}
|
|
|
|
private class ManifestPart
|
|
{
|
|
public string Dxf { get; set; }
|
|
public int Quantity { get; set; }
|
|
public bool AllowRotation { get; set; } = true;
|
|
public List<double> Rotations { get; set; }
|
|
}
|
|
}
|
|
}
|