Compare commits
4
Commits
a68e252ac7
...
f0fe79f0f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0fe79f0f1 | ||
|
|
a9e0f8a1d4 | ||
|
|
20da5477b6 | ||
|
|
6a0fba0fec |
@@ -20,7 +20,7 @@ NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO),
|
||||
|
||||
## Architecture
|
||||
|
||||
Eight projects form a layered architecture:
|
||||
Nine projects form a layered architecture:
|
||||
|
||||
### OpenNest.Core (class library)
|
||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
@@ -73,6 +73,15 @@ GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` impleme
|
||||
### OpenNest.Training (console app, depends on Core + Engine)
|
||||
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
|
||||
|
||||
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
|
||||
Compares registered `NestEngineBase` implementations against each other on real `.nest` files. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
|
||||
|
||||
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
|
||||
- `BenchmarkRunner` gives each (job, engine) pair a fresh `Plate`/`NestItem` list (`BenchmarkJob.CreatePlate()`/`CreateItems()`) so engines can't see each other's mutated state, then calls the engine's `Nest()` and times it.
|
||||
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically via each part's own world-space polygon, inflated by the spacing — works on arbitrary concave/holed shapes, not just bounding boxes), and no drawing over its requested quantity. An invalid or throwing run scores zero for that job.
|
||||
- Scoring matches `Plate.Utilization()` (placed drawing area / full sheet area, `Plate.Area()`). If an engine placed every requested part, ties are broken by the smaller used-bounding-box (`Report`'s ranking rule) — a more compact layout leaves a bigger usable remnant.
|
||||
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report.
|
||||
|
||||
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
|
||||
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// One request to nest a specific drawing, with the quantity and rotation
|
||||
/// constraints pulled from its source .nest file.
|
||||
/// </summary>
|
||||
public class DrawingRequest
|
||||
{
|
||||
public Drawing Drawing { get; init; }
|
||||
public int Quantity { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public double StepAngle { get; init; }
|
||||
public double RotationStart { get; init; }
|
||||
public double RotationEnd { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An immutable specification for one benchmark job: the full set of
|
||||
/// drawings/quantities that must be nested, and the pool of sheet sizes the
|
||||
/// engine may draw from while doing it. A single run may use several
|
||||
/// plates - possibly of different sizes - to place everything, the same
|
||||
/// way a real production job spreads across whatever plates it needs
|
||||
/// rather than being handed one fixed-size sheet.
|
||||
/// </summary>
|
||||
public class BenchmarkJob
|
||||
{
|
||||
public string SourceFile { get; init; }
|
||||
public List<Size> CandidateSizes { get; init; }
|
||||
public Spacing EdgeSpacing { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
public int Quadrant { get; init; }
|
||||
public List<DrawingRequest> Requests { get; init; }
|
||||
|
||||
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
|
||||
|
||||
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
|
||||
|
||||
/// <summary>
|
||||
/// A blank plate carrying only the job's spacing/quadrant template.
|
||||
/// MultiPlateNester.CreatePlate copies these settings onto whichever
|
||||
/// size it ultimately picks; its Size is only the fallback used when
|
||||
/// nothing in the candidate pool fits, so it's set to the largest
|
||||
/// candidate rather than an arbitrary one.
|
||||
/// </summary>
|
||||
public Plate CreateTemplatePlate()
|
||||
{
|
||||
var fallbackSize = CandidateSizes
|
||||
.OrderByDescending(s => s.Width * s.Length)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new Plate(fallbackSize)
|
||||
{
|
||||
EdgeSpacing = EdgeSpacing,
|
||||
PartSpacing = PartSpacing,
|
||||
Quadrant = Quadrant,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The candidate sizes as PlateOptions for MultiPlateNester.CreatePlate.
|
||||
/// Cost is area-proportional since no real per-size material pricing is
|
||||
/// available here - this only affects which size is preferred when more
|
||||
/// than one candidate fits, favoring the smaller/cheaper sheet.
|
||||
/// </summary>
|
||||
public List<PlateOption> BuildPlateOptions()
|
||||
{
|
||||
return CandidateSizes
|
||||
.Select(s => new PlateOption { Width = s.Width, Length = s.Length, Cost = s.Width * s.Length })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public List<NestItem> CreateItems()
|
||||
{
|
||||
return Requests.Select(r => new NestItem
|
||||
{
|
||||
Drawing = r.Drawing,
|
||||
Quantity = r.Quantity,
|
||||
Priority = r.Priority,
|
||||
StepAngle = r.StepAngle,
|
||||
RotationStart = r.RotationStart,
|
||||
RotationEnd = r.RotationEnd,
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs every candidate engine against every job. A job may need several
|
||||
/// plates to place everything it asks for; this drives that loop itself,
|
||||
/// since NestEngineBase.Nest() fills exactly one already-sized plate and
|
||||
/// has no say in picking its own size. For each plate the loop needs, the
|
||||
/// smallest candidate size that fits the largest still-unplaced drawing is
|
||||
/// chosen via the codebase's own MultiPlateNester.CreatePlate, then the
|
||||
/// engine's Nest() fills that plate with whatever of the remaining items
|
||||
/// fit. This is applied identically to every engine, so no engine gets to
|
||||
/// (or has to) implement sheet-size selection itself.
|
||||
/// </summary>
|
||||
public static class BenchmarkRunner
|
||||
{
|
||||
/// <summary>Safety cap so a degenerate engine (placing almost nothing
|
||||
/// per plate) can't loop indefinitely.</summary>
|
||||
private const int MaxPlates = 40;
|
||||
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestEngineInfo> engines)
|
||||
{
|
||||
var results = new List<JobResult>(jobs.Count * engines.Count);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
foreach (var engineInfo in engines)
|
||||
{
|
||||
results.Add(RunOne(job, engineInfo));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo)
|
||||
{
|
||||
var template = job.CreateTemplatePlate();
|
||||
var options = job.BuildPlateOptions();
|
||||
var remaining = job.CreateItems();
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
|
||||
var plateRuns = new List<(Plate Plate, List<Part> Parts)>();
|
||||
string engineError = null;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
while (remaining.Any(i => i.Quantity > 0) && plateRuns.Count < MaxPlates)
|
||||
{
|
||||
var largest = remaining
|
||||
.Where(i => i.Quantity > 0)
|
||||
.OrderByDescending(i => BoundsArea(i))
|
||||
.First();
|
||||
|
||||
var plate = MultiPlateNester.CreatePlate(template, options, largest.Drawing.Program.BoundingBox());
|
||||
var engine = engineInfo.Factory(plate);
|
||||
var itemsClone = CloneItems(remaining);
|
||||
|
||||
var parts = engine.Nest(itemsClone, null, CancellationToken.None) ?? new List<Part>();
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
// Not even the largest available candidate size could fit
|
||||
// the current largest remaining part - stop here rather
|
||||
// than loop forever; whatever's left is reported unplaced.
|
||||
break;
|
||||
}
|
||||
|
||||
plateRuns.Add((plate, parts));
|
||||
|
||||
foreach (var item in remaining)
|
||||
{
|
||||
var placed = parts.Count(p => p.BaseDrawing.Id == item.Drawing.Id);
|
||||
|
||||
if (placed > 0)
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
engineError = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
if (engineError != null)
|
||||
{
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = engineError,
|
||||
};
|
||||
}
|
||||
|
||||
var validation = NestValidator.Validate(plateRuns, job);
|
||||
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
||||
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
|
||||
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
||||
|
||||
var sizeBreakdown = plateRuns
|
||||
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = totalPlaced,
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
SizeBreakdown = sizeBreakdown,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
private static double BoundsArea(NestItem item)
|
||||
{
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
return bb.Width * bb.Length;
|
||||
}
|
||||
|
||||
private static List<NestItem> CloneItems(List<NestItem> items)
|
||||
{
|
||||
return items.Select(i => new NestItem
|
||||
{
|
||||
Drawing = i.Drawing,
|
||||
Quantity = i.Quantity,
|
||||
Priority = i.Priority,
|
||||
StepAngle = i.StepAngle,
|
||||
RotationStart = i.RotationStart,
|
||||
RotationEnd = i.RotationEnd,
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
|
||||
/// any valid .nest file, using whatever drawings/quantities/plate settings
|
||||
/// it contains. One job per file, carrying the full pool of candidate
|
||||
/// sheet sizes the engine may use across the whole nest - by default the
|
||||
/// distinct sizes already present in that file, or a fixed override list
|
||||
/// (e.g. a standard sheet-size lineup) applied to every file.
|
||||
/// </summary>
|
||||
public static class JobLoader
|
||||
{
|
||||
public static List<BenchmarkJob> Load(string inputPath, IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||
double? partSpacingOverride = null)
|
||||
{
|
||||
var files = ResolveFiles(inputPath);
|
||||
var jobs = new List<BenchmarkJob>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
Nest nest;
|
||||
|
||||
try
|
||||
{
|
||||
nest = new NestReader(file).Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': failed to read ({ex.Message})");
|
||||
continue;
|
||||
}
|
||||
|
||||
var requests = BuildRequests(nest);
|
||||
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': no drawings with quantity > 0");
|
||||
continue;
|
||||
}
|
||||
|
||||
var template = ResolvePlateTemplate(nest);
|
||||
var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
||||
? sheetSizeOverrides.ToList()
|
||||
: ResolveSheetSizes(nest);
|
||||
|
||||
jobs.Add(new BenchmarkJob
|
||||
{
|
||||
SourceFile = file,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
Requests = requests,
|
||||
});
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private static List<string> ResolveFiles(string inputPath)
|
||||
{
|
||||
if (Directory.Exists(inputPath))
|
||||
{
|
||||
return Directory.GetFiles(inputPath, "*.nest", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (File.Exists(inputPath))
|
||||
return new List<string> { inputPath };
|
||||
|
||||
throw new FileNotFoundException($"Benchmark input not found: {inputPath}");
|
||||
}
|
||||
|
||||
private static List<DrawingRequest> BuildRequests(Nest nest)
|
||||
{
|
||||
var requests = new List<DrawingRequest>();
|
||||
|
||||
foreach (var drawing in nest.Drawings)
|
||||
{
|
||||
var qty = drawing.Quantity.Required;
|
||||
|
||||
if (qty <= 0)
|
||||
continue;
|
||||
|
||||
var constraints = drawing.Constraints;
|
||||
|
||||
requests.Add(new DrawingRequest
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = qty,
|
||||
Priority = drawing.Priority,
|
||||
StepAngle = constraints?.StepAngle ?? 0,
|
||||
RotationStart = constraints?.StartAngle ?? 0,
|
||||
RotationEnd = constraints?.EndAngle ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(Nest nest)
|
||||
{
|
||||
var source = nest.Plates?.FirstOrDefault();
|
||||
|
||||
if (source != null)
|
||||
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
|
||||
|
||||
var defaults = nest.PlateDefaults;
|
||||
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
|
||||
}
|
||||
|
||||
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||
{
|
||||
var sizes = (nest.Plates ?? Enumerable.Empty<Plate>())
|
||||
.Select(p => p.Size)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (sizes.Count == 0)
|
||||
sizes.Add(nest.PlateDefaults.Size);
|
||||
|
||||
return sizes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of running one engine against one job. A job may span several
|
||||
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
|
||||
/// one sheet - possibly of different sizes - to place everything asked of
|
||||
/// it. An invalid or crashed run always scores zero utilization, per the
|
||||
/// benchmark rules.
|
||||
/// </summary>
|
||||
public class JobResult
|
||||
{
|
||||
public string EngineName { get; init; }
|
||||
public string JobName { get; init; }
|
||||
public bool Valid { get; init; }
|
||||
public List<string> Violations { get; init; } = new();
|
||||
public string Error { get; init; }
|
||||
public int PartsPlaced { get; init; }
|
||||
public int PartsRequested { get; init; }
|
||||
public double PlacedArea { get; init; }
|
||||
public double PlateArea { get; init; }
|
||||
public int PlatesUsed { get; init; }
|
||||
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
public bool Crashed => Error != null;
|
||||
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
|
||||
|
||||
/// <summary>Aggregate utilization across every plate the engine used:
|
||||
/// total placed drawing area over total plate area, matching
|
||||
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
|
||||
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
public class ValidationResult
|
||||
{
|
||||
public bool Valid => Violations.Count == 0;
|
||||
public List<string> Violations { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a (possibly multi-plate) placed layout against the benchmark
|
||||
/// rules: on every plate, every part must lie within that plate's work
|
||||
/// area and every pair of parts must be at least PartSpacing apart; across
|
||||
/// all plates combined, no drawing may have more parts placed than
|
||||
/// requested (the quantity limit is a property of the whole order, not of
|
||||
/// any one plate). Geometry checks work on arbitrary (concave, holed)
|
||||
/// polygons by reusing the same world-space extraction Part.Intersects
|
||||
/// uses internally, so no engine gets an advantage or penalty from shape
|
||||
/// complexity.
|
||||
/// </summary>
|
||||
public static class NestValidator
|
||||
{
|
||||
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns, BenchmarkJob job)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
||||
|
||||
if (allParts.Count == 0)
|
||||
return result;
|
||||
|
||||
ValidateQuantities(allParts, job, result);
|
||||
|
||||
foreach (var (plate, parts) in plateRuns)
|
||||
{
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
ValidateBounds(parts, plate, result);
|
||||
ValidateAreaBudget(parts, plate, result);
|
||||
ValidateSpacing(parts, plate.PartSpacing, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateQuantities(List<Part> parts, BenchmarkJob job, ValidationResult result)
|
||||
{
|
||||
var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity);
|
||||
var placedCounts = parts
|
||||
.GroupBy(p => p.BaseDrawing.Id)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
foreach (var (drawingId, placed) in placedCounts)
|
||||
{
|
||||
if (!allowed.TryGetValue(drawingId, out var max))
|
||||
{
|
||||
result.Violations.Add($"Placed drawing id={drawingId} which was not requested for this job");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placed > max)
|
||||
{
|
||||
var name = parts.First(p => p.BaseDrawing.Id == drawingId).BaseDrawing.Name;
|
||||
result.Violations.Add($"'{name}': placed {placed} across all plates but only {max} were requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBounds(List<Part> parts, Plate plate, ValidationResult result)
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
var outLeft = bb.Left < workArea.X - Tolerance.Epsilon;
|
||||
var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon;
|
||||
var outRight = bb.Right > workArea.Right + Tolerance.Epsilon;
|
||||
var outTop = bb.Top > workArea.Top + Tolerance.Epsilon;
|
||||
|
||||
if (outLeft || outBottom || outRight || outTop)
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"'{part.BaseDrawing.Name}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " +
|
||||
$"of a {plate.Size} plate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hard mathematical backstop: non-overlapping parts confined to the
|
||||
/// work area can never have a combined area greater than the work
|
||||
/// area itself. This catches overlap that the polygon-based
|
||||
/// ValidateSpacing check can miss - Collision.HasOverlap (and
|
||||
/// Part.Intersects, which uses the same algorithm) has been observed
|
||||
/// to return false negatives on real, complex production geometry, so
|
||||
/// this check does not depend on it.
|
||||
/// </summary>
|
||||
private static void ValidateAreaBudget(List<Part> parts, Plate plate, ValidationResult result)
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
var budget = workArea.Width * workArea.Length;
|
||||
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
|
||||
|
||||
if (placedArea > budget + Tolerance.Epsilon)
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " +
|
||||
"parts must overlap even though the polygon overlap check did not flag a pair");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSpacing(List<Part> parts, double spacing, ValidationResult result)
|
||||
{
|
||||
var worldPolygons = new Polygon[parts.Count];
|
||||
var inflatedPolygons = new Polygon[parts.Count];
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
worldPolygons[i] = WorldPolygon(parts[i], 0);
|
||||
inflatedPolygons[i] = spacing > Tolerance.Epsilon ? WorldPolygon(parts[i], spacing) : worldPolygons[i];
|
||||
}
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
if (worldPolygons[i] == null || inflatedPolygons[i] == null)
|
||||
continue;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
if (worldPolygons[j] == null)
|
||||
continue;
|
||||
|
||||
if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j]))
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"'{parts[i].BaseDrawing.Name}' and '{parts[j].BaseDrawing.Name}' are closer than the required spacing ({spacing:F3})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a part's perimeter as a world-space polygon, optionally inflated
|
||||
/// outward by the given spacing, mirroring Part.Intersects' own geometry
|
||||
/// extraction (part.Program is already rotated; only a Location offset is needed).
|
||||
/// </summary>
|
||||
private static Polygon WorldPolygon(Part part, double inflateBy)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
|
||||
var perimeter = new ShapeProfile(entities).Perimeter;
|
||||
|
||||
if (perimeter == null)
|
||||
return null;
|
||||
|
||||
if (inflateBy > Tolerance.Epsilon)
|
||||
perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter;
|
||||
|
||||
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000
|
||||
// segments per arc) - arc-heavy real parts otherwise produce thousands
|
||||
// of vertices, which is needlessly slow for a spacing check.
|
||||
var polygon = perimeter.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||
|
||||
if (polygon == null)
|
||||
return null;
|
||||
|
||||
polygon.Offset(part.Location);
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.Benchmark</RootNamespace>
|
||||
<AssemblyName>OpenNest.Benchmark</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,172 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
return BenchmarkConsole.Run(args);
|
||||
|
||||
static class BenchmarkConsole
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
var options = ParseArgs(args);
|
||||
|
||||
if (options == null)
|
||||
return 0; // --help was requested
|
||||
|
||||
if (options.InputPath == null)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
List<BenchmarkJob> jobs;
|
||||
|
||||
try
|
||||
{
|
||||
jobs = JobLoader.Load(options.InputPath, options.SheetSizes, options.PartSpacing);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("No benchmark jobs found (no .nest files with any drawing quantity > 0).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var engines = NestEngineRegistry.AvailableEngines;
|
||||
|
||||
if (options.EngineNames.Count > 0)
|
||||
{
|
||||
engines = engines
|
||||
.Where(e => options.EngineNames.Any(n => n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
if (engines.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("None of the requested engines are registered. Available: " +
|
||||
string.Join(", ", NestEngineRegistry.AvailableEngines.Select(e => e.Name)));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
|
||||
Console.WriteLine($" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||
|
||||
var results = BenchmarkRunner.Run(jobs, engines);
|
||||
|
||||
Report.PrintDetailed(results);
|
||||
Report.PrintSummary(results);
|
||||
|
||||
if (options.CsvPath != null)
|
||||
{
|
||||
Report.WriteCsv(options.CsvPath, results);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Wrote CSV report to {options.CsvPath}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Options ParseArgs(string[] args)
|
||||
{
|
||||
var o = new Options();
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--sheet-sizes" when i + 1 < args.Length:
|
||||
o.SheetSizes = ParseSheetSizes(args[++i]);
|
||||
break;
|
||||
|
||||
case "--spacing" when i + 1 < args.Length:
|
||||
o.PartSpacing = double.Parse(args[++i]);
|
||||
break;
|
||||
|
||||
case "--engines" when i + 1 < args.Length:
|
||||
o.EngineNames = args[++i]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
break;
|
||||
|
||||
case "--csv" when i + 1 < args.Length:
|
||||
o.CsvPath = args[++i];
|
||||
break;
|
||||
|
||||
case "--help":
|
||||
PrintUsage();
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (!args[i].StartsWith("--"))
|
||||
o.InputPath = args[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
private static List<Size> ParseSheetSizes(string arg)
|
||||
{
|
||||
var sizes = new List<Size>();
|
||||
|
||||
foreach (var token in arg.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (Size.TryParse(token, out var size))
|
||||
sizes.Add(size);
|
||||
else
|
||||
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
||||
}
|
||||
|
||||
return sizes;
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("OpenNest.Benchmark - compare registered nesting engines on a set of .nest files");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("For each .nest file, every drawing with quantity > 0 is nested (mixed together),");
|
||||
Console.Error.WriteLine("once per registered engine. This is a full nest, not a single fixed-size plate:");
|
||||
Console.Error.WriteLine("as many plates as needed are created, one at a time, each sized by picking the");
|
||||
Console.Error.WriteLine("smallest candidate sheet size that fits the largest still-unplaced drawing -");
|
||||
Console.Error.WriteLine("applied identically to every engine, since Nest() itself has no say over its");
|
||||
Console.Error.WriteLine("own plate's size. Scoring: aggregate material utilization across every plate");
|
||||
Console.Error.WriteLine("used, then (if everything requested was placed) fewer plates as the tie-break.");
|
||||
Console.Error.WriteLine("An invalid layout (out of bounds, overlapping, or over-quantity) scores zero.");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Usage:");
|
||||
Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest");
|
||||
Console.Error.WriteLine(" (default: the distinct sizes already in each file)");
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing for every job");
|
||||
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
|
||||
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
|
||||
Console.Error.WriteLine(" --help Show this message");
|
||||
}
|
||||
|
||||
private class Options
|
||||
{
|
||||
public string InputPath;
|
||||
public List<Size> SheetSizes = new();
|
||||
public double? PartSpacing;
|
||||
public List<string> EngineNames = new();
|
||||
public string CsvPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Console + CSV reporting for benchmark results. Ranking rule per job:
|
||||
/// valid beats invalid; higher aggregate utilization wins; if utilization
|
||||
/// ties and both engines fully placed every requested part, fewer plates
|
||||
/// used wins (the multi-plate analogue of "smaller remnant" - both are
|
||||
/// proxies for wasting less material). Ties beyond that are a shared win.
|
||||
/// </summary>
|
||||
public static class Report
|
||||
{
|
||||
private const double Epsilon = 1e-6;
|
||||
|
||||
public static void PrintDetailed(List<JobResult> results)
|
||||
{
|
||||
foreach (var jobGroup in results.GroupBy(r => r.JobName))
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"=== {jobGroup.Key} ===");
|
||||
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
var best = ranked.Count > 0 ? ranked[0] : null;
|
||||
|
||||
Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Plates",-18} {"Time(ms)",-9} Notes");
|
||||
|
||||
foreach (var r in ranked)
|
||||
{
|
||||
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
|
||||
var marker = isWinner ? "*" : " ";
|
||||
var status = r.Crashed ? "CRASH" : r.Valid ? "ok" : "INVALID";
|
||||
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
|
||||
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
|
||||
var platesCol = r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
|
||||
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
|
||||
|
||||
Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {platesCol,-18} {r.ElapsedMs,-9} {notes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void PrintSummary(List<JobResult> results)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Summary ===");
|
||||
|
||||
var byEngine = results
|
||||
.GroupBy(r => r.EngineName)
|
||||
.Select(g => new
|
||||
{
|
||||
Engine = g.Key,
|
||||
Jobs = g.Count(),
|
||||
Valid = g.Count(r => r.Valid),
|
||||
Crashed = g.Count(r => r.Crashed),
|
||||
FullyPlaced = g.Count(r => r.FullyPlaced),
|
||||
TotalUtilization = g.Sum(r => r.Utilization),
|
||||
TotalPlates = g.Sum(r => r.PlatesUsed),
|
||||
TotalTimeMs = g.Sum(r => r.ElapsedMs),
|
||||
})
|
||||
.OrderByDescending(e => e.TotalUtilization)
|
||||
.ToList();
|
||||
|
||||
var wins = CountWins(results);
|
||||
|
||||
Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Complete",-9} {"Wins",-6} {"AvgUtil%",-10} {"Plates",-8} {"TotalTime(ms)",-14}");
|
||||
|
||||
foreach (var e in byEngine)
|
||||
{
|
||||
var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 0;
|
||||
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
|
||||
Console.WriteLine($"{e.Engine,-16} {e.Jobs,-6} {e.Valid,-7} {e.FullyPlaced,-9} {winCount,-6} {avgUtil,-10:F1} {e.TotalPlates,-8} {e.TotalTimeMs,-14}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void WriteCsv(string path, List<JobResult> results)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,PlatesUsed,SizeBreakdown,ElapsedMs,Notes");
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
|
||||
sb.AppendLine(string.Join(",",
|
||||
Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, r.FullyPlaced,
|
||||
r.PartsPlaced, r.PartsRequested,
|
||||
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)),
|
||||
r.ElapsedMs, Csv(notes)));
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
}
|
||||
|
||||
private static string SizeSummary(Dictionary<string, int> breakdown)
|
||||
{
|
||||
if (breakdown == null || breakdown.Count == 0)
|
||||
return "-";
|
||||
|
||||
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
|
||||
}
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return string.Empty;
|
||||
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> CountWins(List<JobResult> results)
|
||||
{
|
||||
var wins = new Dictionary<string, int>();
|
||||
|
||||
foreach (var jobGroup in results.GroupBy(r => r.JobName))
|
||||
{
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
|
||||
if (ranked.Count == 0 || !ranked[0].Valid)
|
||||
continue;
|
||||
|
||||
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
|
||||
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
|
||||
}
|
||||
|
||||
return wins;
|
||||
}
|
||||
|
||||
/// <summary>Lower sorts first (better). Valid beats invalid, then higher
|
||||
/// aggregate utilization, then (if both fully placed) fewer plates used.</summary>
|
||||
private static int Compare(JobResult a, JobResult b)
|
||||
{
|
||||
if (a.Valid != b.Valid)
|
||||
return a.Valid ? -1 : 1;
|
||||
|
||||
if (!a.Valid)
|
||||
return 0;
|
||||
|
||||
var utilDiff = b.Utilization - a.Utilization;
|
||||
|
||||
if (System.Math.Abs(utilDiff) > Epsilon)
|
||||
return utilDiff > 0 ? 1 : -1;
|
||||
|
||||
if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed)
|
||||
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-12
@@ -34,6 +34,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}"
|
||||
EndProject
|
||||
Global
|
||||
@@ -178,18 +179,6 @@ Global
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.Build.0 = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -202,6 +191,30 @@ Global
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
|
||||
@@ -166,6 +166,21 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
| `--no-save` | Skip saving the output file |
|
||||
| `--no-log` | Skip writing the debug log |
|
||||
|
||||
## Benchmarking Nest Engines
|
||||
|
||||
`OpenNest.Benchmark` compares every registered `NestEngineBase` implementation against each other on a set of `.nest` files, scoring by material utilization:
|
||||
|
||||
```bash
|
||||
# Benchmark all registered engines against every .nest file in a folder
|
||||
dotnet run --project OpenNest.Benchmark/OpenNest.Benchmark.csproj -- ./benchmark-jobs
|
||||
|
||||
# Sweep a fixed list of sheet sizes instead of each file's own, limit to specific engines
|
||||
dotnet run --project OpenNest.Benchmark/OpenNest.Benchmark.csproj -- job.nest \
|
||||
--sheet-sizes 48x96,60x96,60x120,72x120,72x144 --engines Default,Astra,Claude --csv results.csv
|
||||
```
|
||||
|
||||
An engine's layout is rejected (scoring zero for that job) if any part falls outside the work area, any two parts are closer than the required spacing, or a drawing gets more parts placed than requested.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -180,6 +195,7 @@ OpenNest.sln
|
||||
├── OpenNest.Data/ # Machine configuration and cutting parameters
|
||||
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
|
||||
├── OpenNest.Training/ # ML training data collection (SQLite + EF Core)
|
||||
├── OpenNest.Benchmark/ # Head-to-head comparison of registered nest engines
|
||||
├── OpenNest.Mcp/ # MCP server for AI tool integration
|
||||
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
|
||||
└── OpenNest.Tests/ # Unit tests (xUnit)
|
||||
@@ -197,6 +213,7 @@ OpenNest.sln
|
||||
| **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. |
|
||||
| **OpenNest.Posts.Cincinnati** | Post-processor plugin for Cincinnati CL-707/800/900/940/CLX laser cutting machines. Outputs Cincinnati-format G-code with material library, kerf compensation, and pierce logic. |
|
||||
| **OpenNest.Mcp** | MCP (Model Context Protocol) server exposing nesting operations as tools for AI assistants. |
|
||||
| **OpenNest.Benchmark** | Runs every registered nest engine against a set of `.nest` files and scores them by material utilization, so engine implementations can be compared head-to-head. |
|
||||
| **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. |
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
Reference in New Issue
Block a user