Files
OpenNest/OpenNest.Console/Program.cs
T
aj 5af44b251c feat(engine): make Default choose between Irregular and Rectangles per job
Default is the engine every front end uses when none is named. It now runs
Irregular, then Rectangles, checks both layouts with NestLayoutCheck and keeps
the best: valid first, then fewest unplaced parts, then lowest salvage-credited
cost; ties keep Irregular. A candidate that throws or returns nothing is
skipped, cancellation stops the search, and only the chosen layout's plate
commits are reported. Neither engine wins every job in the lane benchmarks,
and Rectangles adds little time while also covering Irregular's invalid
layouts.

The registry lists Default first and no longer maps the name to Fill; fill
strategy callers still read Default as Fill. A future circle/ring engine joins
as another candidate.
2026-10-06 12:26:10 -04:00

805 lines
28 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using OpenNest;
using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bending;
return NestConsole.Run(args);
static class NestConsole
{
public static int Run(string[] args)
{
using var cancellation = new CancellationTokenSource();
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; cancellation.Cancel(); };
Console.CancelKeyPress += cancel;
try
{
return RunCore(args, cancellation.Token);
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
return 2;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
finally
{
Console.CancelKeyPress -= cancel;
}
}
static int RunCore(string[] args, CancellationToken token)
{
token.ThrowIfCancellationRequested();
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
var options = ParseArgs(args);
if (options == null)
return 0; // --help was requested
if (
options.RepairBendsMillimeters.HasValue
&& (
options.CadUnits == BendRepairUnits.Unspecified
|| !double.IsFinite(options.RepairBendsMillimeters.Value)
|| options.RepairBendsMillimeters <= 0.001
|| options.RepairBendsMillimeters > 3.175
)
)
{
Console.Error.WriteLine(
"Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm."
);
return 1;
}
if (options.ListPosts)
{
ListPostProcessors(options);
return 0;
}
// Validate --engine up front: autonest names a jobs engine, plain fill names a
// single-plate placement strategy. Unknown names exit with the valid choices.
if (options.AutoNest)
{
// ResolveName also accepts renamed engines' old names (for example Opus55NestingEngine).
if (NestingEngineRegistry.ResolveName(options.Engine) == null)
{
Console.Error.WriteLine(
$"Error: unknown engine '{options.Engine}'. Jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
);
return 1;
}
}
else
{
try
{
PlateFillService.ResolveStrategy(options.Engine);
}
catch (NotSupportedException)
{
Console.Error.WriteLine(
$"Error: unknown engine '{options.Engine}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (jobs engines such as StockLadder require --autonest)"
);
return 1;
}
}
if (options.InputFiles.Count == 0)
{
PrintUsage();
return 1;
}
foreach (var f in options.InputFiles)
{
if (!File.Exists(f))
{
Console.Error.WriteLine($"Error: file not found: {f}");
return 1;
}
}
var nest = LoadOrCreateNest(options);
if (nest == null)
return 1;
var plate = nest.Plates[options.PlateIndex];
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
{
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
return 2;
}
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
var drawing = ResolveDrawing(nest, options);
if (drawing == null)
return 1;
var existingCount = plate.Parts.Count;
if (!options.AutoNest && !options.KeepParts)
plate.Parts.Clear();
PrintHeader(nest, plate, drawing, existingCount, options);
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
if (!accepted)
return 2; // No save or post may run after a rejected proposal.
var overlapCount = CheckOverlaps(plate, options);
PrintResults(success, plate, elapsed);
token.ThrowIfCancellationRequested();
if (!SaveAndPost(nest, options))
return 1;
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
}
static Options ParseArgs(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--repair-bends-mm":
o.RepairBendsMillimeters =
i + 1 < args.Length
&& double.TryParse(
args[++i],
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var limit
)
? limit
: double.NaN;
break;
case "--cad-units" when i + 1 < args.Length:
o.CadUnits = args[++i] switch
{
"inches" => BendRepairUnits.Inches,
"mm" => BendRepairUnits.Millimeters,
_ => BendRepairUnits.Unspecified,
};
break;
case "--drawing" when i + 1 < args.Length:
o.DrawingName = args[++i];
break;
case "--plate" when i + 1 < args.Length:
o.PlateIndex = int.Parse(args[++i]);
break;
case "--output" when i + 1 < args.Length:
o.OutputFile = args[++i];
break;
case "--quantity" when i + 1 < args.Length:
o.Quantity = int.Parse(args[++i]);
break;
case "--spacing" when i + 1 < args.Length:
o.Spacing = double.Parse(args[++i]);
break;
case "--size" when i + 1 < args.Length:
if (Size.TryParse(args[++i], out var sz))
o.PlateSize = sz;
break;
case "--check-overlaps":
o.CheckOverlaps = true;
break;
case "--no-save":
o.NoSave = true;
break;
case "--keep-parts":
o.KeepParts = true;
break;
case "--template" when i + 1 < args.Length:
o.TemplateFile = args[++i];
break;
case "--allow-invalid":
o.AllowInvalid = true;
break;
case "--autonest":
o.AutoNest = true;
break;
case "--engine" when i + 1 < args.Length:
o.Engine = args[++i];
break;
case "--post" when i + 1 < args.Length:
o.PostName = args[++i];
break;
case "--acknowledge-post-risks":
o.AcknowledgePostRisks = true;
break;
case "--post-output" when i + 1 < args.Length:
o.PostOutput = args[++i];
break;
case "--posts-dir" when i + 1 < args.Length:
o.PostsDir = args[++i];
break;
case "--list-posts":
o.ListPosts = true;
break;
case "--help":
case "-h":
PrintUsage();
return null;
default:
if (!args[i].StartsWith("--"))
o.InputFiles.Add(args[i]);
break;
}
}
return o;
}
static Nest LoadOrCreateNest(Options options)
{
var nestFile = options.InputFiles.FirstOrDefault(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
);
var dxfFiles = options
.InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
.ToList();
// If we have a nest file, load it and optionally add DXFs.
if (nestFile != null)
{
var nest = new NestReader(nestFile).Read();
if (nest.Plates.Count == 0)
{
Console.Error.WriteLine("Error: nest file contains no plates");
return null;
}
if (options.PlateIndex >= nest.Plates.Count)
{
Console.Error.WriteLine(
$"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})"
);
return null;
}
foreach (var dxf in dxfFiles)
{
var drawing = ImportDxf(dxf, options);
if (drawing == null)
return null;
nest.Drawings.Add(drawing);
Console.WriteLine($"Imported: {drawing.Name}");
}
return nest;
}
// DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0)
{
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
return null;
}
if (!options.PlateSize.HasValue)
{
Console.Error.WriteLine(
"Error: --size WxL is required when importing DXF files without a nest"
);
return null;
}
var newNest = new Nest { Name = "DXF Import" };
var plate = new Plate { Size = options.PlateSize.Value };
newNest.Plates.Add(plate);
foreach (var dxf in dxfFiles)
{
var drawing = ImportDxf(dxf, options);
if (drawing == null)
return null;
newNest.Drawings.Add(drawing);
Console.WriteLine($"Imported: {drawing.Name}");
}
return newNest;
}
static Drawing ImportDxf(string path, Options options)
{
try
{
var result = CadImporter.Import(
path,
new CadImportOptions
{
BendRepair = options.RepairBendsMillimeters.HasValue
? new BendRepairOptions
{
DrawingUnits = options.CadUnits,
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value,
}
: null,
}
);
foreach (var report in result.BendRepairReports)
Console.WriteLine(
$"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"
);
return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
}
catch (System.Exception ex)
{
Console.Error.WriteLine($"Error: failed to import DXF '{path}': {ex.Message}");
return null;
}
}
static void ApplyTemplate(Plate plate, Options options)
{
if (options.TemplateFile == null)
return;
if (!File.Exists(options.TemplateFile))
{
Console.Error.WriteLine($"Error: Template not found: {options.TemplateFile}");
return;
}
var templateNest = new NestReader(options.TemplateFile).Read();
var templatePlate = templateNest.PlateDefaults.CreateNew();
plate.Quadrant = templatePlate.Quadrant;
plate.EdgeSpacing = templatePlate.EdgeSpacing;
plate.PartSpacing = templatePlate.PartSpacing;
Console.WriteLine($"Template: {options.TemplateFile}");
}
static void ApplyOverrides(Plate plate, Options options)
{
if (options.Spacing.HasValue)
plate.PartSpacing = options.Spacing.Value;
// Only apply size override when it wasn't already used to create the plate.
var hasDxfOnly = !options.InputFiles.Any(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
);
if (options.PlateSize.HasValue && !hasDxfOnly)
plate.Size = options.PlateSize.Value;
}
static Drawing ResolveDrawing(Nest nest, Options options)
{
var drawing =
options.DrawingName != null
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
: nest.Drawings.FirstOrDefault();
if (drawing != null)
return drawing;
Console.Error.WriteLine(
options.DrawingName != null
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
: "Error: nest file contains no drawings"
);
return null;
}
static void PrintHeader(
Nest nest,
Plate plate,
Drawing drawing,
int existingCount,
Options options
)
{
Console.WriteLine($"Nest: {nest.Name}");
var wa = plate.WorkArea();
Console.WriteLine(
$"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Width:F1}x{wa.Length:F1}"
);
var existingPartsMessage = options.KeepParts
? $"Keeping {existingCount} existing parts"
: options.AutoNest
? $"Will replace {existingCount} existing parts only after acceptance"
: $"Cleared {existingCount} existing parts";
Console.WriteLine(
$"""
Drawing: {drawing.Name}
{existingPartsMessage}
---
"""
);
}
static (bool success, long elapsedMs, bool accepted) Fill(
Nest nest,
Plate plate,
Drawing drawing,
Options options,
CancellationToken token
)
{
var sw = Stopwatch.StartNew();
bool success;
var accepted = true;
if (options.AutoNest)
{
var nestItems = new List<NestItem>();
var qty = options.Quantity > 0 ? options.Quantity : 1;
if (options.DrawingName != null)
{
nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty });
}
else
{
foreach (var d in nest.Drawings)
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
}
Console.WriteLine(
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
);
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
accepted = outcome.accepted;
success = outcome.committed > 0;
}
else
{
// Single-plate fill: explicit placement strategy through the public service;
// the process-global engine registry is never consulted.
var strategy = ResolveFillStrategy(options.Engine);
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
var parts = PlateFillService.FillItem(
strategy,
plate,
item,
plate.WorkArea(),
null,
token
);
token.ThrowIfCancellationRequested();
if (parts.Count > 0)
plate.Parts.AddRange(parts);
success = parts.Count > 0;
}
sw.Stop();
return (success, sw.ElapsedMilliseconds, accepted);
}
static (bool accepted, int committed) AutoNestJob(
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
{
var result = NestPipeline.Run(new NestPipelineRequest(
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
foreach (var violation in result.Violations)
Console.Error.WriteLine($"Violation: {violation}");
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
{
Console.Error.WriteLine(result.Plates.Count > 1
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
: !result.CanKeep
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
return (false, 0);
}
token.ThrowIfCancellationRequested();
var proposed = result.Plates.SingleOrDefault();
var committed = proposed?.Parts.Count ?? 0;
if (committed == 0)
{
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
return (false, 0);
}
plate.Parts.Clear();
plate.Size = proposed.Stock.Size;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
plate.Quantity = 1;
plate.Parts.AddRange(proposed.Parts);
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
return (true, committed);
}
static string ResolveFillStrategy(string engineName)
{
try
{
return PlateFillService.ResolveStrategy(engineName);
}
catch (NotSupportedException)
{
var isJobEngine = NestingEngineRegistry.AvailableEngines.Any(e =>
e.Name.Equals(engineName, StringComparison.OrdinalIgnoreCase)
);
Console.Error.WriteLine(
isJobEngine
? $"Error: engine '{engineName}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engineName}'. Engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}"
);
Environment.Exit(1);
throw; // unreachable
}
}
static int CheckOverlaps(Plate plate, Options options)
{
if (!options.CheckOverlaps || plate.Parts.Count == 0)
return 0;
var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
Console.WriteLine(
hasOverlaps
? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
: "Overlap check: PASS"
);
return overlapPts.Count;
}
static void PrintResults(bool success, Plate plate, long elapsedMs)
{
Console.WriteLine(
$"""
Result: {(success ? "success" : "failed")}
Parts placed: {plate.Parts.Count}
Utilization: {plate.Utilization():P1}
Time: {elapsedMs}ms
"""
);
}
static void Save(Nest nest, Options options)
{
if (options.NoSave)
return;
var outputFile = NestOutputPath(options);
new NestWriter(nest).Write(outputFile);
Console.WriteLine($"Saved: {outputFile}");
}
static string NestOutputPath(Options options) => options.OutputFile
?? Path.Combine(Path.GetDirectoryName(options.InputFiles[0]),
$"{Path.GetFileNameWithoutExtension(options.InputFiles[0])}-result{NestFormat.FileExtension}");
static string ResolvePostsDir(Options options)
{
if (options.PostsDir != null)
return options.PostsDir;
var exePath =
Assembly.GetEntryAssembly()?.Location ?? typeof(NestConsole).Assembly.Location;
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
}
static List<IPostProcessor> LoadPostProcessors(string postsDir)
{
var processors = new List<IPostProcessor>();
if (!Directory.Exists(postsDir))
return processors;
foreach (var file in Directory.GetFiles(postsDir, "*.dll"))
{
try
{
var assembly = Assembly.LoadFrom(file);
foreach (var type in assembly.GetTypes())
{
if (
!typeof(IPostProcessor).IsAssignableFrom(type)
|| type.IsInterface
|| type.IsAbstract
)
continue;
if (Activator.CreateInstance(type) is IPostProcessor processor)
processors.Add(processor);
}
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}"
);
}
}
return processors;
}
static void ListPostProcessors(Options options)
{
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
if (processors.Count == 0)
{
Console.WriteLine($"No post processors found in: {postsDir}");
return;
}
Console.WriteLine($"Post processors ({postsDir}):");
foreach (var p in processors)
Console.WriteLine($" {p.Name,-30} {p.Description}");
}
static bool SaveAndPost(Nest nest, Options options)
{
if (options.PostName == null)
{
Save(nest, options);
return true;
}
var postsDir = ResolvePostsDir(options);
var processors = LoadPostProcessors(postsDir);
var post = processors.FirstOrDefault(p =>
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase)
);
if (post == null)
{
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
if (processors.Count > 0)
Console.Error.WriteLine(
$"Available: {string.Join(", ", processors.Select(p => p.Name))}"
);
else
Console.Error.WriteLine($"No post processors found in: {postsDir}");
return false;
}
var verification = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
Console.WriteLine(verification.ToDisplayText());
if (!verification.CanPost(options.AcknowledgePostRisks))
{
Console.Error.WriteLine("Posting blocked: review these warnings. To accept the risks, including possible head crashes and machine or material damage, explicitly use --acknowledge-post-risks for this invocation.");
return false;
}
var outputFile = options.PostOutput;
if (outputFile == null)
{
var firstInput = options.InputFiles[0];
outputFile = Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc"
);
}
var outputFiles = post is IMultiFilePostProcessor multiFile
? multiFile.GetOutputFiles(nest, outputFile)
: new[] { outputFile };
if (!options.NoSave && outputFiles.Any(file => string.Equals(
Path.GetFullPath(file), Path.GetFullPath(NestOutputPath(options)), StringComparison.OrdinalIgnoreCase)))
{
Console.Error.WriteLine("Error: nest save and CNC output paths must be different. No output was written.");
return false;
}
Save(nest, options);
post.Post(nest, outputFile);
foreach (var file in outputFiles)
Console.WriteLine($"Post: {post.Name} -> {file}");
return true;
}
static void PrintUsage()
{
Console.Error.WriteLine(
"""
Usage: OpenNest.Console <input-files...> [options]
Arguments:
input-files One or more .nest nest files or .dxf/.dwg drawing files
Modes:
<nest.nest> Load nest and fill (existing behavior)
<part.dxf> --size WxL Import DXF, create plate, and fill
<nest.nest> <part.dxf> Load nest and add imported DXF drawings
Options:
--repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm
--cad-units inches|mm Explicit source coordinate units required for bend repair
--drawing <name> Drawing name to fill with (default: first drawing)
--plate <index> Plate index to fill (default: 0)
--quantity <n> Max parts to place (default: 0 = unlimited)
--spacing <value> Override part spacing
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
--output <path> Output nest file path (default: <input>-result.nest)
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
--engine <name> With --autonest: jobs engine (default: Default; also Irregular, Rectangles, Fill, ...).
Without --autonest: fill strategy (Fill (default), Strip, Vertical Remnant, Horizontal Remnant)
--keep-parts Don't clear existing parts before filling
--check-overlaps Run overlap detection after fill (exit code 1 if found)
--no-save Skip saving output file
--post <name> Run a post processor after nesting
--post-output <path> Output file for post processor (default: <input>.cnc)
--acknowledge-post-risks Explicitly accept displayed verification risks for this invocation
--posts-dir <path> Directory containing post processor DLLs (default: Posts/)
--list-posts List available post processors and exit
-h, --help Show this help
"""
);
}
class Options
{
public List<string> InputFiles = new();
public string DrawingName;
public int PlateIndex;
public string OutputFile;
public int Quantity;
public double? Spacing;
public Size? PlateSize;
public bool CheckOverlaps;
public bool NoSave;
public bool KeepParts;
public bool AutoNest;
public bool AllowInvalid;
public string Engine = "Default";
public string TemplateFile;
public string PostName;
public bool AcknowledgePostRisks;
public string PostOutput;
public string PostsDir;
public bool ListPosts;
public double? RepairBendsMillimeters;
public BendRepairUnits CadUnits;
}
}