Compare commits
14
Commits
a68e252ac7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d27652f4 | ||
|
|
9b69c67572 | ||
|
|
aa88eee484 | ||
|
|
e0e3b96bed | ||
|
|
424ff15ebc | ||
|
|
9888fe6083 | ||
|
|
a2dcfc7484 | ||
|
|
ae704478af | ||
|
|
ecca71e185 | ||
|
|
f0fe79f0f1 | ||
|
|
a9e0f8a1d4 | ||
|
|
20da5477b6 | ||
|
|
6a0fba0fec | ||
|
|
a085339ba9 |
@@ -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,16 @@ 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 `INestingEngine` implementations against each other on real `.nest` files. Each engine solves the whole job — it owns its own multi-plate/size strategy rather than being handed one already-sized plate at a time. 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.
|
||||
- `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses).
|
||||
- `BenchmarkRunner` calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
|
||||
- `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, throwing, or timed-out 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 fewer plates used (`Report`'s ranking rule) — using fewer sheets to do the same job wastes less material.
|
||||
- `--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/`.
|
||||
|
||||
@@ -124,3 +134,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
||||
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
/// Builds the whole-job request this job represents: one NestJobPart per
|
||||
/// requested drawing, and one NestPlateStock per candidate sheet size
|
||||
/// (unlimited quantity - the engine under test decides how many of each
|
||||
/// size it actually uses, and how demand splits across plates). The
|
||||
/// engine owns its own multi-plate/size strategy; this harness no
|
||||
/// longer picks plate sizes on the engine's behalf.
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(int maxPlates)
|
||||
{
|
||||
var parts = Requests.Select(r =>
|
||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity));
|
||||
var stock = CandidateSizes.Select(size =>
|
||||
new NestPlateStock(size.ToString(1), size, null, PartSpacing, EdgeSpacing, Quadrant));
|
||||
return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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. Each engine is a full
|
||||
/// INestingEngine: it owns its own plate/size selection and multi-plate
|
||||
/// strategy for the whole job, rather than being handed one already-sized
|
||||
/// plate at a time by this harness. A per-run timeout guards against a
|
||||
/// runaway or hanging engine — cooperative cancellation, so it reliably
|
||||
/// stops engines built on NestJobRunner (all four built-ins) but can't
|
||||
/// forcibly interrupt an engine that never checks its token.
|
||||
/// </summary>
|
||||
public static class BenchmarkRunner
|
||||
{
|
||||
/// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
|
||||
private const int MaxPlates = 40;
|
||||
|
||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> 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, NestingEngineInfo engineInfo)
|
||||
{
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var nestJob = job.BuildNestJob(MaxPlates);
|
||||
var engine = engineInfo.Factory();
|
||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||
var jobResult = engine.Solve(nestJob, null, cts.Token);
|
||||
|
||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||
var plateRuns = materialized.Nest.Plates
|
||||
.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
||||
.ToList();
|
||||
|
||||
var requirements = job.Requests.ToDictionary<DrawingRequest, Drawing, (string Name, int Quantity)>(
|
||||
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
|
||||
r => (r.Drawing.Name, r.Quantity),
|
||||
ReferenceEqualityComparer.Instance);
|
||||
|
||||
var validation = NestValidator.Validate(plateRuns, requirements);
|
||||
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());
|
||||
|
||||
sw.Stop();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
sw.Stop();
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = $"{ex.GetType().Name}: {ex.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,198 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// requirements maps each materialized part's BaseDrawing (by reference - materialized
|
||||
/// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so
|
||||
/// identity must never be inferred from Name, which is only incidentally seeded from the
|
||||
/// originating NestJobPart id) to its original quantity limit and display name.
|
||||
/// </summary>
|
||||
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
||||
|
||||
if (allParts.Count == 0)
|
||||
return result;
|
||||
|
||||
ValidateQuantities(allParts, requirements, result);
|
||||
|
||||
foreach (var (plate, parts) in plateRuns)
|
||||
{
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
ValidateBounds(parts, plate, requirements, result);
|
||||
ValidateAreaBudget(parts, plate, result);
|
||||
ValidateSpacing(parts, plate.PartSpacing, requirements, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateQuantities(List<Part> parts,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
||||
{
|
||||
var placedCounts = parts
|
||||
.GroupBy<Part, Drawing>(p => p.BaseDrawing, ReferenceEqualityComparer.Instance)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
foreach (var (drawing, placed) in placedCounts)
|
||||
{
|
||||
if (!requirements.TryGetValue(drawing, out var requirement))
|
||||
{
|
||||
result.Violations.Add($"Placed drawing '{drawing.Name}' which was not requested for this job");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placed > requirement.Quantity)
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"'{requirement.Name}': placed {placed} across all plates but only {requirement.Quantity} were requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBounds(List<Part> parts, Plate plate,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, 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(
|
||||
$"'{DisplayName(part, requirements)}' 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,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, 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(
|
||||
$"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Friendly name for a violation message, falling back to the materialized
|
||||
/// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all -
|
||||
/// that mismatch is already reported by ValidateQuantities, so this is display-only.</summary>
|
||||
private static string DisplayName(Part part, IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements) =>
|
||||
requirements.TryGetValue(part.BaseDrawing, out var requirement) ? requirement.Name : part.BaseDrawing.Name;
|
||||
|
||||
/// <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,176 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
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 enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
|
||||
NestingEngineRegistry.LoadPlugins(enginesDir);
|
||||
|
||||
var engines = NestingEngineRegistry.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(", ", NestingEngineRegistry.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.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("OpenNest.Benchmark - compare registered whole-job 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 INestingEngine. Each engine is handed the full job - every");
|
||||
Console.Error.WriteLine("requested part and the whole pool of candidate sheet sizes - and owns its own");
|
||||
Console.Error.WriteLine("multi-plate/size strategy: how many plates it uses, of which sizes, and how");
|
||||
Console.Error.WriteLine("demand splits across them. Scoring: aggregate material utilization across every");
|
||||
Console.Error.WriteLine("plate used, then (if everything requested was placed) fewer plates as the");
|
||||
Console.Error.WriteLine("tie-break. An invalid layout (out of bounds, overlapping, or over-quantity), a");
|
||||
Console.Error.WriteLine("thrown exception, or a run exceeding its time budget all score 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,14 +87,15 @@ namespace OpenNest.Converters
|
||||
|
||||
lastpt = endpt;
|
||||
|
||||
var layer = ClassifyLayer(arc);
|
||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||
{
|
||||
pgm.LineTo(endpt);
|
||||
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||
}
|
||||
else
|
||||
{
|
||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
||||
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||
}
|
||||
|
||||
return lastpt;
|
||||
@@ -107,7 +108,7 @@ namespace OpenNest.Converters
|
||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
@@ -118,13 +119,22 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
return lastpt;
|
||||
}
|
||||
|
||||
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
|
||||
// separate tool pass; everything else keeps the move's default Cut layer.
|
||||
private static LayerType ClassifyLayer(Entity geo)
|
||||
{
|
||||
var name = geo.Layer?.Name;
|
||||
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Scribe;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,14 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// Fits a circular arc that passes exactly through both the first and last points
|
||||
/// while matching the given endpoint tangents as closely as possible. For any
|
||||
/// circle through two points, the tangents at those points make equal mirrored
|
||||
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||
/// two requested ones — when the requested tangents are consistent with a single
|
||||
/// circular arc, both are matched exactly.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents(
|
||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
var ux = dx / chordLen;
|
||||
var uy = dy / chordLen;
|
||||
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||
var theta1 = SignedAngle(ux, uy, startTangent);
|
||||
var theta2 = -SignedAngle(ux, uy, endTangent);
|
||||
var theta = (theta1 + theta2) / 2;
|
||||
|
||||
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
|
||||
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
||||
var d = -halfChord / System.Math.Tan(theta);
|
||||
|
||||
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
private static double SignedAngle(double ux, double uy, Vector to)
|
||||
{
|
||||
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||
if (len < 1e-10) return 0;
|
||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -374,11 +374,8 @@ public class GeometrySimplifier
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var endTangent = GetExitDirection(entities[k]);
|
||||
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||
var endTangent = EstimateEndTangent(entities, k, points);
|
||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
@@ -386,8 +383,10 @@ public class GeometrySimplifier
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (extPoints.Count < 3) break;
|
||||
|
||||
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
@@ -407,37 +406,172 @@ public class GeometrySimplifier
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
private (Vector center, double radius, double deviation) TryFit(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
{
|
||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
||||
if (endTangent.IsValid())
|
||||
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||
{
|
||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
||||
if (dc.IsValid() && dd <= Tolerance)
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
continue;
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
continue;
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
return (Vector.Invalid, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields fit attempts in preference order. A trusted tangent (chained from the
|
||||
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
|
||||
/// exactly on its side; otherwise the tangency error is balanced between both
|
||||
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
|
||||
/// passes exactly through both endpoints, so no gaps are introduced.
|
||||
/// </summary>
|
||||
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
{
|
||||
if (start.Trusted && !end.Trusted)
|
||||
{
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
else if (end.Trusted && !start.Trusted)
|
||||
{
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
|
||||
yield return FitMirrorAxis(points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits an arc through both endpoints with an exact tangent at the last point,
|
||||
/// by running the start-tangent fit on the reversed point sequence.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithEndTangent(
|
||||
List<Vector> points, Vector endTangent)
|
||||
{
|
||||
var reversed = new List<Vector>(points);
|
||||
reversed.Reverse();
|
||||
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
|
||||
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
|
||||
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
|
||||
/// vertices and only guide the fit.
|
||||
/// </summary>
|
||||
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
|
||||
|
||||
/// <summary>Segment-length ratio above which a neighboring line counts as a true
|
||||
/// straight edge (rather than another chord of the tessellated curve).</summary>
|
||||
private const double NeighborEdgeFactor = 3.0;
|
||||
|
||||
private static TangentEstimate EstimateStartTangent(
|
||||
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
|
||||
{
|
||||
if (chainedTangent.IsValid())
|
||||
return new TangentEstimate(chainedTangent, true);
|
||||
|
||||
if (entities[start] is Arc startArc)
|
||||
return new TangentEstimate(GetEntryDirection(startArc), true);
|
||||
|
||||
var firstChordLen = points[0].DistanceTo(points[1]);
|
||||
if (start > 0)
|
||||
{
|
||||
var prev = entities[start - 1];
|
||||
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
|
||||
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
|
||||
{
|
||||
var isRev = SumSignedAngles(dc, points) < 0;
|
||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
||||
if (aDev <= Tolerance)
|
||||
return (dc, dr, System.Math.Max(dd, aDev));
|
||||
if (prev is Arc)
|
||||
return new TangentEstimate(GetExitDirection(prev), true);
|
||||
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
|
||||
return new TangentEstimate(GetExitDirection(prevLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to start-tangent-only, then mirror axis
|
||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
private static TangentEstimate EstimateEndTangent(List<Entity> entities, int k, List<Vector> points)
|
||||
{
|
||||
if (entities[k] is Arc endArc)
|
||||
return new TangentEstimate(GetExitDirection(endArc), true);
|
||||
|
||||
var lastChordLen = points[^1].DistanceTo(points[^2]);
|
||||
if (k + 1 < entities.Count)
|
||||
{
|
||||
var next = entities[k + 1];
|
||||
var nextStart = next switch { Line l => l.StartPoint, Arc a => a.StartPoint(), _ => Vector.Invalid };
|
||||
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
|
||||
{
|
||||
if (next is Arc nextArc)
|
||||
return new TangentEstimate(GetEntryDirection(nextArc), true);
|
||||
if (next is Line nextLine && nextLine.StartPoint.DistanceTo(nextLine.EndPoint) >= NeighborEdgeFactor * lastChordLen)
|
||||
return new TangentEstimate(GetExitDirection(nextLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[^1], points[^2], points[^3], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the curve tangent at a polyline vertex from the circle through it and
|
||||
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
|
||||
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
|
||||
/// Falls back to the travel direction when the three points are collinear.
|
||||
/// </summary>
|
||||
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
|
||||
{
|
||||
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
|
||||
if (System.Math.Abs(d) < 1e-14)
|
||||
return travel;
|
||||
|
||||
var sqA = at.X * at.X + at.Y * at.Y;
|
||||
var sqB = b.X * b.X + b.Y * b.Y;
|
||||
var sqC = c.X * c.X + c.Y * c.Y;
|
||||
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
|
||||
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
|
||||
|
||||
var tangent = new Vector(-(at.Y - cy), at.X - cx);
|
||||
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
|
||||
tangent = new Vector(-tangent.X, -tangent.Y);
|
||||
return tangent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the entry direction (tangent at start point) of an entity.
|
||||
/// </summary>
|
||||
private static Vector GetEntryDirection(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
|
||||
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Fill;
|
||||
|
||||
public class SortStripsTests
|
||||
{
|
||||
private static Part MakeRectPart(double x, double y, double w, double h)
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing("rect", pgm);
|
||||
return new Part(drawing, new Vector(x, y));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortColumnsByHeight_NonUniformGaps_DoesNotExceedOriginalSpan()
|
||||
{
|
||||
// Three columns with non-uniform gaps between them (5, then 1) and heights
|
||||
// ordered so the sort-by-height pass must reorder them (tallest first, then
|
||||
// shortest, then medium). The tallest column's original position leaves a
|
||||
// 5-unit gap to its neighbor; that single sampled gap must not get replayed
|
||||
// as the spacing for the whole staircase once it's no longer the leading pair.
|
||||
var tall = MakeRectPart(0, 0, 10, 30); // Left 0-10, gap of 5 to next
|
||||
var shortCol = MakeRectPart(15, 0, 5, 5); // Left 15-20, gap of 1 to next
|
||||
var medium = MakeRectPart(21, 0, 20, 15); // Left 21-41
|
||||
|
||||
var originalRight = new[] { tall, shortCol, medium }.Max(p => p.BoundingBox.Right);
|
||||
var originalLeft = new[] { tall, shortCol, medium }.Min(p => p.BoundingBox.Left);
|
||||
var originalSpan = originalRight - originalLeft;
|
||||
|
||||
var parts = new List<Part> { tall, shortCol, medium };
|
||||
IterativeShrinkFiller.SortColumnsByHeight(parts, spacing: 1.0);
|
||||
|
||||
var newRight = parts.Max(p => p.BoundingBox.Right);
|
||||
var newLeft = parts.Min(p => p.BoundingBox.Left);
|
||||
var newSpan = newRight - newLeft;
|
||||
|
||||
Assert.True(newSpan <= originalSpan + 1e-9,
|
||||
$"Resequenced columns must not exceed the original footprint: original span {originalSpan}, new span {newSpan}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class FixedStrategyNestingEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForcesConfiguredStrategyRegardlessOfJobOptions()
|
||||
{
|
||||
var engine = new FixedStrategyNestingEngine("Strip");
|
||||
// The job itself declares an unknown strategy; if FixedStrategyNestingEngine
|
||||
// didn't override it, PlateNesterFactory would reject it with NotSupportedException.
|
||||
var job = FiniteStockJobTests.Job(1, new NestJobOptions("Not A Real Strategy"));
|
||||
|
||||
var result = engine.Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreservesJobMaxPlates()
|
||||
{
|
||||
var engine = new FixedStrategyNestingEngine("Default");
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(100, 100)), 6);
|
||||
var stock = new NestPlateStock("sheet", new Size(220, 220), quantity: null, partSpacing: 2.0,
|
||||
edgeSpacing: new Spacing(5.0, 5.0, 5.0, 5.0), quadrant: 1);
|
||||
var job = new NestJob(new[] { part }, new[] { stock }, new NestJobOptions("Default", maxPlates: 1));
|
||||
|
||||
var result = engine.Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
Assert.Single(result.Plates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsNullOrWhitespaceStrategyAtConstruction()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new FixedStrategyNestingEngine(null));
|
||||
Assert.Throws<ArgumentException>(() => new FixedStrategyNestingEngine(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestingEngineRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuiltInStrategiesAreRegistered()
|
||||
{
|
||||
var names = NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
|
||||
|
||||
Assert.Contains("Default", names);
|
||||
Assert.Contains("Strip", names);
|
||||
Assert.Contains("Vertical Remnant", names);
|
||||
Assert.Contains("Horizontal Remnant", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachBuiltInFactoryProducesAWorkingEngine()
|
||||
{
|
||||
foreach (var info in NestingEngineRegistry.AvailableEngines)
|
||||
{
|
||||
var engine = info.Factory();
|
||||
var result = engine.Solve(FiniteStockJobTests.Job(1));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateNameIsSkipped()
|
||||
{
|
||||
var before = NestingEngineRegistry.AvailableEngines.Count;
|
||||
|
||||
NestingEngineRegistry.Register("Default", "duplicate", () => new FixedStrategyNestingEngine("Default"));
|
||||
|
||||
Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadPluginsAgainstMissingDirectoryIsANoOp()
|
||||
{
|
||||
var before = NestingEngineRegistry.AvailableEngines.Count;
|
||||
|
||||
NestingEngineRegistry.LoadPlugins(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
|
||||
|
||||
Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count);
|
||||
}
|
||||
}
|
||||
@@ -219,8 +219,10 @@ namespace OpenNest.Engine.Fill
|
||||
if (strips.Count <= 1)
|
||||
return;
|
||||
|
||||
var gap = stripMin(strips[1]) - stripMax(strips[0]);
|
||||
|
||||
// Use the required clearance as the inter-strip gap, not a gap sampled from one
|
||||
// original pair: actual placement gaps vary for irregular/mixed-size geometry, and
|
||||
// replaying a larger sampled gap across every reordered pair can push the trailing
|
||||
// strip past the original (already plate-fitted) footprint.
|
||||
strips.Sort((a, b) => sortMetric(a).CompareTo(sortMetric(b)));
|
||||
|
||||
var pos = primaryEdge(parts[0].BoundingBox);
|
||||
@@ -236,7 +238,7 @@ namespace OpenNest.Engine.Fill
|
||||
part.Offset(offset);
|
||||
}
|
||||
|
||||
pos = stripMax(s) + gap;
|
||||
pos = stripMax(s) + spacing;
|
||||
}
|
||||
|
||||
parts.Clear();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts one fixed IPlateNester strategy to the whole-job INestingEngine contract, so it can compete
|
||||
/// as a full job solver alongside model-submitted engines. Delegates all multi-plate/size selection to
|
||||
/// NestJobRunner; only the placement strategy key is forced, overriding whatever the job itself declared.
|
||||
/// </summary>
|
||||
public sealed class FixedStrategyNestingEngine : INestingEngine
|
||||
{
|
||||
private readonly string strategy;
|
||||
private readonly NestJobRunner runner = new(PlateNesterFactory.Create);
|
||||
|
||||
public FixedStrategyNestingEngine(string strategy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(strategy))
|
||||
throw new ArgumentException("Strategy cannot be null or whitespace.", nameof(strategy));
|
||||
this.strategy = strategy;
|
||||
}
|
||||
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null, CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
var forced = new NestJob(job.Parts, job.Plates, new NestJobOptions(strategy, job.Options.MaxPlates));
|
||||
return runner.Solve(forced, progress, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Display metadata plus a fresh-instance factory for one registered whole-job engine.</summary>
|
||||
public class NestingEngineInfo
|
||||
{
|
||||
public NestingEngineInfo(string name, string description, Func<INestingEngine> factory)
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
Factory = factory;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
public Func<INestingEngine> Factory { get; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of whole-job INestingEngine implementations, parallel to NestEngineRegistry (which is for
|
||||
/// the legacy single-plate NestEngineBase). The four production strategies are exposed here through
|
||||
/// FixedStrategyNestingEngine so they compete on equal footing with model-submitted engines. Unlike
|
||||
/// NestEngineRegistry, this has no ActiveEngineName/global-selection concept — callers choose an engine
|
||||
/// explicitly from AvailableEngines.
|
||||
/// </summary>
|
||||
public static class NestingEngineRegistry
|
||||
{
|
||||
private static readonly List<NestingEngineInfo> engines = new();
|
||||
|
||||
static NestingEngineRegistry()
|
||||
{
|
||||
Register("Default", "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
|
||||
() => new FixedStrategyNestingEngine("Default"));
|
||||
|
||||
Register("Strip", "Strip-based nesting for mixed-drawing layouts",
|
||||
() => new FixedStrategyNestingEngine("Strip"));
|
||||
|
||||
Register("Vertical Remnant", "Optimizes for largest right-side vertical drop",
|
||||
() => new FixedStrategyNestingEngine("Vertical Remnant"));
|
||||
|
||||
Register("Horizontal Remnant", "Optimizes for largest top-side horizontal drop",
|
||||
() => new FixedStrategyNestingEngine("Horizontal Remnant"));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NestingEngineInfo> AvailableEngines => engines;
|
||||
|
||||
public static void Register(string name, string description, Func<INestingEngine> factory)
|
||||
{
|
||||
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Debug.WriteLine($"[NestingEngineRegistry] Duplicate engine '{name}' skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
engines.Add(new NestingEngineInfo(name, description, factory));
|
||||
}
|
||||
|
||||
/// <summary>Scans *.dll in directory for non-abstract INestingEngine types with a public
|
||||
/// parameterless constructor, registering each under its CLR type name. Mirrors
|
||||
/// NestEngineRegistry.LoadPlugins's per-assembly/per-type isolation: one bad plugin never
|
||||
/// prevents the rest from loading.</summary>
|
||||
public static void LoadPlugins(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
return;
|
||||
|
||||
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(dll);
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (type.IsAbstract || !typeof(INestingEngine).IsAssignableFrom(type))
|
||||
continue;
|
||||
|
||||
var ctor = type.GetConstructor(Type.EmptyTypes);
|
||||
|
||||
if (ctor == null)
|
||||
{
|
||||
Debug.WriteLine($"[NestingEngineRegistry] Skipping {type.Name}: no parameterless constructor");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Register(type.Name, string.Empty, () => (INestingEngine)ctor.Invoke(null));
|
||||
Debug.WriteLine($"[NestingEngineRegistry] Loaded plugin engine: {type.Name}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestingEngineRegistry] Failed to register {type.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[NestingEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
<InternalsVisibleTo Include="OpenNest.Engine.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
|
||||
@@ -242,6 +242,7 @@ namespace OpenNest.IO
|
||||
public ExportContext()
|
||||
{
|
||||
Document = new CadDocument();
|
||||
Document.Header.Version = ACadVersion.AC1018;
|
||||
|
||||
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
||||
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.ComponentModel;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
|
||||
/// configuration PropertyGrid and persisted to JSON.
|
||||
/// </summary>
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public sealed class LayerCutConfig
|
||||
{
|
||||
[DisplayName("Feed (mm/sec)")]
|
||||
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
|
||||
public int FeedMmPerSec { get; set; } = 10;
|
||||
|
||||
[DisplayName("Depth (inches)")]
|
||||
[Description("Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does.")]
|
||||
public double Depth { get; set; } = 0.25;
|
||||
|
||||
[DisplayName("Pause Before")]
|
||||
[Description("Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted.")]
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
[DisplayName("Pause Message")]
|
||||
[Description("Message shown on the controller during the pause.")]
|
||||
public string PauseMessage { get; set; } = "";
|
||||
|
||||
public override string ToString() => $"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
|
||||
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
|
||||
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
|
||||
/// The cut block carries the tool-change pause by default.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostConfig
|
||||
{
|
||||
[Category("Engrave (Scribe)")]
|
||||
[DisplayName("Engrave")]
|
||||
[Description("Parameters for engrave/scribe geometry (text).")]
|
||||
public LayerCutConfig Engrave { get; set; } = new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 10,
|
||||
Depth = 0.25,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
};
|
||||
|
||||
[Category("Cut")]
|
||||
[DisplayName("Cut")]
|
||||
[Description("Parameters for cut geometry (outlines). Pauses for a tool change by default.")]
|
||||
public LayerCutConfig Cut { get; set; } = new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 3,
|
||||
Depth = 0.25,
|
||||
PauseBefore = true,
|
||||
PauseMessage = "Change tool",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cut config a polyline of the given layer should use, or null
|
||||
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
|
||||
/// </summary>
|
||||
public LayerCutConfig ConfigFor(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
case LayerType.Scribe:
|
||||
return Engrave;
|
||||
case LayerType.Cut:
|
||||
case LayerType.Leadin:
|
||||
case LayerType.Leadout:
|
||||
return Cut;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Ports;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
|
||||
/// writes the binary HPGL bytes. For serial streaming, use <see cref="Stream(Nest, string, Handshake, CancellationToken)"/>.
|
||||
///
|
||||
/// Geometry is split by <see cref="OpenNest.CNC.LayerType"/> into an engrave pass
|
||||
/// (Scribe) and a cut pass (Cut), each with its own feed/depth from <see cref="Config"/>.
|
||||
/// The cut pass pauses by default so the operator can swap/adjust the tool.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostProcessor : IPostProcessor
|
||||
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public string Name => "Gravograph IS8000";
|
||||
public string Author => "OpenNest";
|
||||
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
|
||||
@@ -23,14 +37,53 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
public bool AllowReverse { get; set; } = true;
|
||||
|
||||
public GravographISPostConfig Config { get; }
|
||||
|
||||
object IConfigurablePostProcessor.Config => Config;
|
||||
|
||||
public GravographISPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
Config = JsonSerializer.Deserialize<GravographISPostConfig>(json, JsonOptions)
|
||||
?? new GravographISPostConfig();
|
||||
}
|
||||
else
|
||||
{
|
||||
Config = new GravographISPostConfig();
|
||||
SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public GravographISPostProcessor(GravographISPostConfig config)
|
||||
{
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
var json = JsonSerializer.Serialize(Config, JsonOptions);
|
||||
File.WriteAllText(configPath, json);
|
||||
}
|
||||
|
||||
private static string GetConfigPath()
|
||||
{
|
||||
var assemblyPath = typeof(GravographISPostProcessor).Assembly.Location;
|
||||
var dir = Path.GetDirectoryName(assemblyPath);
|
||||
var name = Path.GetFileNameWithoutExtension(assemblyPath);
|
||||
return Path.Combine(dir, name + ".json");
|
||||
}
|
||||
|
||||
public void Post(Nest nest, Stream outputStream)
|
||||
{
|
||||
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
|
||||
|
||||
var polylines = Extractor.Extract(nest);
|
||||
var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
|
||||
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
|
||||
var passes = BuildPasses(Extractor.ExtractLayered(nest));
|
||||
new GravographISWriter(WriterOptions).Write(passes, outputStream);
|
||||
}
|
||||
|
||||
public void Post(Nest nest, string outputFile)
|
||||
@@ -39,6 +92,52 @@ namespace OpenNest.Posts.GravographIS
|
||||
Post(nest, fs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups layer-tagged polylines into ordered tool passes: engrave (Scribe)
|
||||
/// first, then cut. Each group is stitch/reverse-optimized independently.
|
||||
/// Geometry whose layer maps to no config (Display) is skipped. When only one
|
||||
/// group is present, a single pass is returned (and so the writer emits no pause).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GravographPass> BuildPasses(IEnumerable<LayeredPolyline> polylines)
|
||||
{
|
||||
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||
|
||||
var engrave = new List<IReadOnlyList<Vector>>();
|
||||
var cut = new List<IReadOnlyList<Vector>>();
|
||||
|
||||
foreach (var poly in polylines)
|
||||
{
|
||||
if (poly == null) continue;
|
||||
var block = Config.ConfigFor(poly.Layer);
|
||||
if (block == null)
|
||||
continue; // non-cutting (Display) geometry
|
||||
if (ReferenceEquals(block, Config.Engrave))
|
||||
engrave.Add(poly.Points);
|
||||
else
|
||||
cut.Add(poly.Points);
|
||||
}
|
||||
|
||||
var passes = new List<GravographPass>();
|
||||
if (engrave.Count > 0)
|
||||
passes.Add(MakePass(Config.Engrave, engrave));
|
||||
if (cut.Count > 0)
|
||||
passes.Add(MakePass(Config.Cut, cut));
|
||||
|
||||
return passes;
|
||||
}
|
||||
|
||||
private GravographPass MakePass(LayerCutConfig block, List<IReadOnlyList<Vector>> polylines)
|
||||
{
|
||||
return new GravographPass
|
||||
{
|
||||
Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse),
|
||||
FeedMmPerSec = block.FeedMmPerSec,
|
||||
DepthInches = block.Depth,
|
||||
PauseBefore = block.PauseBefore,
|
||||
PauseMessage = block.PauseMessage ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffers the encoded job in memory, then streams it to the named COM port.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// One tool pass: a run of polylines cut at a single feed/depth, optionally
|
||||
/// preceded by an operator pause (to swap or adjust the tool). The Gravograph
|
||||
/// post builds one pass for engrave and one for cut.
|
||||
/// </summary>
|
||||
public sealed class GravographPass
|
||||
{
|
||||
public IEnumerable<IReadOnlyList<Vector>> Polylines { get; set; }
|
||||
|
||||
public int FeedMmPerSec { get; set; }
|
||||
|
||||
public double DepthInches { get; set; }
|
||||
|
||||
/// <summary>When true, park to origin and prompt the operator before this pass.</summary>
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
public string PauseMessage { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
|
||||
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
|
||||
@@ -84,12 +104,40 @@ namespace OpenNest.Posts.GravographIS
|
||||
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
|
||||
{
|
||||
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||
|
||||
// A single pass at the configured feed/depth — byte-identical to the
|
||||
// original single-group output (no transitions, no pause).
|
||||
Write(new[]
|
||||
{
|
||||
new GravographPass
|
||||
{
|
||||
Polylines = polylines,
|
||||
FeedMmPerSec = Options.FeedMmPerSec,
|
||||
DepthInches = Options.DepthInches,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
},
|
||||
}, output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the full byte stream for an ordered list of tool passes. The preamble
|
||||
/// carries the first pass's feed/depth; each later pass emits an inline feed
|
||||
/// (and depth, if changed) and, when <see cref="GravographPass.PauseBefore"/> is
|
||||
/// set, parks to the operator origin and emits an operator pause before cutting.
|
||||
/// </summary>
|
||||
public void Write(IReadOnlyList<GravographPass> passes, Stream output)
|
||||
{
|
||||
if (passes == null) throw new ArgumentNullException(nameof(passes));
|
||||
if (output == null) throw new ArgumentNullException(nameof(output));
|
||||
|
||||
var firstFeed = passes.Count > 0 ? passes[0].FeedMmPerSec : Options.FeedMmPerSec;
|
||||
var firstDepth = passes.Count > 0 ? passes[0].DepthInches : Options.DepthInches;
|
||||
|
||||
var preamble = (byte[])PreambleTemplate.Clone();
|
||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)Options.FeedMmPerSec);
|
||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
|
||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
|
||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
|
||||
output.Write(preamble, 0, preamble.Length);
|
||||
|
||||
// Cumulative head position from the operator-set upper-left origin, in
|
||||
@@ -105,45 +153,51 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
var firstPolyline = true;
|
||||
var polyIndex = 0;
|
||||
var currentFeed = firstFeed;
|
||||
var currentDepth = firstDepth;
|
||||
|
||||
foreach (var poly in polylines)
|
||||
for (var p = 0; p < passes.Count; p++)
|
||||
{
|
||||
polyIndex++;
|
||||
if (poly == null || poly.Count < 2)
|
||||
continue;
|
||||
var pass = passes[p];
|
||||
|
||||
var (startX, startY) = ToWire(poly[0]);
|
||||
WriteTravel(output,
|
||||
firstPolyline ? (byte)'D' : (byte)'P',
|
||||
firstPolyline ? (byte)'R' : (byte)'U',
|
||||
checked(startX - headX), checked(startY - headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
|
||||
// PD command + single records-follow flag, then one record per segment.
|
||||
output.WriteByte(0xFF);
|
||||
output.WriteByte(0xFD);
|
||||
output.WriteByte((byte)'P');
|
||||
output.WriteByte((byte)'D');
|
||||
output.WriteByte(0x00);
|
||||
output.WriteByte(0x00);
|
||||
|
||||
var prevX = startX;
|
||||
var prevY = startY;
|
||||
for (int i = 1; i < poly.Count; i++)
|
||||
if (p > 0)
|
||||
{
|
||||
var (cx, cy) = ToWire(poly[i]);
|
||||
var dx = checked(cx - prevX);
|
||||
var dy = checked(cy - prevY);
|
||||
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
||||
polyIndex, segment: i, isTravel: false);
|
||||
WriteRecord(output, dx, dy);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
headX += dx;
|
||||
headY += dy;
|
||||
if (pass.PauseBefore)
|
||||
{
|
||||
// Park: lift Z, then rapid (pen-up) back to the operator origin so
|
||||
// the head is clear of the work while the tool is swapped.
|
||||
WriteLiftOnly(output);
|
||||
WriteTravel(output, (byte)'P', (byte)'U',
|
||||
checked(-headX), checked(-headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
WritePauseCore(output, pass.PauseMessage);
|
||||
}
|
||||
|
||||
if (pass.FeedMmPerSec != currentFeed)
|
||||
{
|
||||
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
|
||||
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
|
||||
currentFeed = pass.FeedMmPerSec;
|
||||
}
|
||||
|
||||
if (pass.DepthInches != currentDepth)
|
||||
{
|
||||
WriteCommand(output, (byte)'D', (byte)'Z', DepthInStepsAsInt16(pass.DepthInches));
|
||||
currentDepth = pass.DepthInches;
|
||||
}
|
||||
}
|
||||
|
||||
firstPolyline = false;
|
||||
if (pass.Polylines == null) continue;
|
||||
|
||||
foreach (var poly in pass.Polylines)
|
||||
{
|
||||
polyIndex++;
|
||||
if (poly == null || poly.Count < 2)
|
||||
continue;
|
||||
|
||||
WritePolyline(output, poly, ref firstPolyline, ref headX, ref headY,
|
||||
envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
}
|
||||
}
|
||||
|
||||
WriteLiftOnly(output);
|
||||
@@ -156,6 +210,89 @@ namespace OpenNest.Posts.GravographIS
|
||||
output.Write(EndJobBytes, 0, EndJobBytes.Length);
|
||||
}
|
||||
|
||||
private void WritePolyline(Stream output, IReadOnlyList<Vector> poly,
|
||||
ref bool firstPolyline, ref int headX, ref int headY,
|
||||
int envelopeXSteps, int envelopeYSteps, int polyIndex)
|
||||
{
|
||||
var (startX, startY) = ToWire(poly[0]);
|
||||
WriteTravel(output,
|
||||
firstPolyline ? (byte)'D' : (byte)'P',
|
||||
firstPolyline ? (byte)'R' : (byte)'U',
|
||||
checked(startX - headX), checked(startY - headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
|
||||
// PD command + single records-follow flag, then one record per segment.
|
||||
output.WriteByte(0xFF);
|
||||
output.WriteByte(0xFD);
|
||||
output.WriteByte((byte)'P');
|
||||
output.WriteByte((byte)'D');
|
||||
output.WriteByte(0x00);
|
||||
output.WriteByte(0x00);
|
||||
|
||||
var prevX = startX;
|
||||
var prevY = startY;
|
||||
for (int i = 1; i < poly.Count; i++)
|
||||
{
|
||||
var (cx, cy) = ToWire(poly[i]);
|
||||
var dx = checked(cx - prevX);
|
||||
var dy = checked(cy - prevY);
|
||||
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
||||
polyIndex, segment: i, isTravel: false);
|
||||
WriteRecord(output, dx, dy);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
headX += dx;
|
||||
headY += dy;
|
||||
}
|
||||
|
||||
firstPolyline = false;
|
||||
}
|
||||
|
||||
// The operator pause, minus the leading lift/park which the caller emits.
|
||||
// Stops the spindle (MC off), turns off aux, writes the console message, then
|
||||
// restarts the spindle (MC on) so the job resumes when the operator presses start.
|
||||
private static void WritePauseCore(Stream s, string message)
|
||||
{
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x00); // begin message
|
||||
WriteMessagePackets(s, message);
|
||||
WriteCommandRaw(s, (byte)'N', (byte)'R', 0x00, 0x01); // line terminator
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x01); // end message
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x01); // motor on
|
||||
}
|
||||
|
||||
// Console label packets carry two ASCII chars each; an odd-length message is
|
||||
// space-padded to a whole number of packets so widths stay 2 bytes.
|
||||
private static void WriteMessagePackets(Stream s, string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
|
||||
var chars = Encoding.ASCII.GetBytes(message);
|
||||
for (var i = 0; i < chars.Length; i += 2)
|
||||
{
|
||||
var c0 = chars[i];
|
||||
var c1 = (i + 1 < chars.Length) ? chars[i + 1] : (byte)0x20;
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', c0, c1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCommand(Stream s, byte c0, byte c1, short value)
|
||||
{
|
||||
WriteCommandRaw(s, c0, c1, (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF));
|
||||
}
|
||||
|
||||
private static void WriteCommandRaw(Stream s, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
s.WriteByte(0xFF);
|
||||
s.WriteByte(0xFD);
|
||||
s.WriteByte(c0);
|
||||
s.WriteByte(c1);
|
||||
s.WriteByte(hi);
|
||||
s.WriteByte(lo);
|
||||
}
|
||||
|
||||
private const double StepsPerMm = 80.0;
|
||||
|
||||
private void EnsureEnvelope(int wireX, int wireY,
|
||||
@@ -181,11 +318,11 @@ namespace OpenNest.Posts.GravographIS
|
||||
$"work envelope from upper-left origin. Refusing to emit the record.");
|
||||
}
|
||||
|
||||
private short DepthInStepsAsInt16()
|
||||
private static short DepthInStepsAsInt16(double depthInches)
|
||||
{
|
||||
var steps = (long)System.Math.Round(Options.DepthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||
var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||
if (steps < short.MinValue || steps > short.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(Options.DepthInches), $"Depth {Options.DepthInches} in. → {steps} steps overflows int16.");
|
||||
throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16.");
|
||||
return (short)steps;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// A polyline together with the <see cref="LayerType"/> of the moves that
|
||||
/// produced it. The Gravograph post groups by layer to emit separate engrave
|
||||
/// and cut passes (with a tool-change pause between them).
|
||||
/// </summary>
|
||||
public sealed class LayeredPolyline
|
||||
{
|
||||
public LayeredPolyline(List<Vector> points, LayerType layer)
|
||||
{
|
||||
Points = points;
|
||||
Layer = layer;
|
||||
}
|
||||
|
||||
public List<Vector> Points { get; }
|
||||
|
||||
public LayerType Layer { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
|
||||
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
|
||||
/// polylines at rapid moves, and tessellates arcs to a chord-deviation
|
||||
/// tolerance (the wire format takes line segments only).
|
||||
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and
|
||||
/// tessellates arcs to a chord-deviation tolerance (the wire format takes
|
||||
/// line segments only).
|
||||
/// </summary>
|
||||
public sealed class NestPolylineExtractor
|
||||
{
|
||||
@@ -17,13 +37,31 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines from every non-cutoff part in every plate of the nest,
|
||||
/// returning them in plate coordinates (inches).
|
||||
/// returning them in plate coordinates (inches). Layer information is dropped;
|
||||
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
|
||||
/// </summary>
|
||||
public List<List<Vector>> Extract(Nest nest)
|
||||
{
|
||||
return ExtractLayered(nest).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines for a single part without layer information.
|
||||
/// </summary>
|
||||
public List<List<Vector>> ExtractPart(Part part)
|
||||
{
|
||||
return ExtractPartLayered(part).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts layer-tagged polylines from every non-cutoff part in every plate,
|
||||
/// in plate coordinates (inches). Each polyline is layer-uniform.
|
||||
/// </summary>
|
||||
public List<LayeredPolyline> ExtractLayered(Nest nest)
|
||||
{
|
||||
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||
|
||||
var result = new List<List<Vector>>();
|
||||
var result = new List<LayeredPolyline>();
|
||||
|
||||
foreach (var plate in nest.Plates)
|
||||
{
|
||||
@@ -40,17 +78,17 @@ namespace OpenNest.Posts.GravographIS
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines for a single part. Public so callers driving the
|
||||
/// writer directly (e.g. from a console one-off) can use it.
|
||||
/// Extracts layer-tagged polylines for a single part. Public so callers
|
||||
/// driving the writer directly (e.g. from a console one-off) can use it.
|
||||
/// </summary>
|
||||
public List<List<Vector>> ExtractPart(Part part)
|
||||
public List<LayeredPolyline> ExtractPartLayered(Part part)
|
||||
{
|
||||
var list = new List<List<Vector>>();
|
||||
var list = new List<LayeredPolyline>();
|
||||
ExtractPart(part, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private void ExtractPart(Part part, List<List<Vector>> sink)
|
||||
private void ExtractPart(Part part, List<LayeredPolyline> sink)
|
||||
{
|
||||
var program = part.Program;
|
||||
if (program == null) return;
|
||||
@@ -67,6 +105,7 @@ namespace OpenNest.Posts.GravographIS
|
||||
var offset = part.Location;
|
||||
var pos = new Vector(0, 0);
|
||||
List<Vector> current = null;
|
||||
var currentLayer = LayerType.Cut;
|
||||
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
@@ -77,17 +116,14 @@ namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
case RapidMove rapid:
|
||||
{
|
||||
FlushCurrent(sink, ref current);
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
pos = rapid.EndPoint;
|
||||
break;
|
||||
}
|
||||
|
||||
case LinearMove linear:
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { pos + offset };
|
||||
}
|
||||
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
|
||||
var end = linear.EndPoint;
|
||||
current.Add(end + offset);
|
||||
pos = end;
|
||||
@@ -96,10 +132,7 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
case ArcMove arc:
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { pos + offset };
|
||||
}
|
||||
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
|
||||
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
|
||||
pos = arc.EndPoint;
|
||||
break;
|
||||
@@ -107,13 +140,33 @@ namespace OpenNest.Posts.GravographIS
|
||||
}
|
||||
}
|
||||
|
||||
FlushCurrent(sink, ref current);
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
}
|
||||
|
||||
private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> current)
|
||||
// Ensures `current` is an open polyline whose layer matches `moveLayer`,
|
||||
// seeded at `seed` (the current pen position). When the layer changes
|
||||
// mid-chain the previous polyline is flushed and a new one begins at the
|
||||
// shared seam vertex so engrave and cut passes stay geometrically continuous.
|
||||
private static void StartOrSplit(List<LayeredPolyline> sink, ref List<Vector> current,
|
||||
ref LayerType currentLayer, LayerType moveLayer, Vector seed)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
else if (moveLayer != currentLayer)
|
||||
{
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlushCurrent(List<LayeredPolyline> sink, ref List<Vector> current, LayerType layer)
|
||||
{
|
||||
if (current != null && current.Count >= 2)
|
||||
sink.Add(current);
|
||||
sink.Add(new LayeredPolyline(current, layer));
|
||||
current = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Converters;
|
||||
|
||||
public class ConvertGeometryLayerTests
|
||||
{
|
||||
private static Program ProgramFor(Entity entity)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(entity);
|
||||
return ConvertGeometry.ToProgram(shape);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_EtchLayer_TagsScribe()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("etch") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddArc_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var arc = new Arc(new Vector(0, 0), 1, 0, System.Math.PI / 2) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(arc);
|
||||
|
||||
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||
Assert.NotEmpty(arcs);
|
||||
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCircle_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var circle = new Circle(0, 0, 1) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(circle);
|
||||
|
||||
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||
Assert.NotEmpty(arcs);
|
||||
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_DefaultLayer_StaysCut()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("0") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Cut, m.Layer));
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,111 @@ public class GeometrySimplifierTests
|
||||
Assert.IsType<Arc>(result.Entities[6]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_FilletBetweenTangentLines_ArcIsTangentToLines()
|
||||
{
|
||||
// A 90-degree fillet (r=0.3, center origin, 270deg..360deg CCW) between two
|
||||
// long tangent lines, approximated by 8 chords whose interior vertices bulge
|
||||
// radially outward within tolerance (simulates real DXF tessellation noise).
|
||||
var r = 0.3;
|
||||
var deltas = new[] { 0.0, 0.002, 0.003, 0.0035, 0.0035, 0.0035, 0.003, 0.002, 0.0 };
|
||||
var pts = new List<Vector>();
|
||||
for (var i = 0; i <= 8; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(270 + 11.25 * i);
|
||||
var radius = r + deltas[i];
|
||||
pts.Add(new Vector(radius * System.Math.Cos(ang), radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(-2, -r), pts[0]));
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||
shape.Entities.Add(new Line(pts[^1], new Vector(r, 2)));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Single(candidates);
|
||||
var arc = candidates[0].FittedArc;
|
||||
|
||||
// Arc must pass exactly through the run's boundary vertices (no gaps)
|
||||
Assert.True(arc.StartPoint().DistanceTo(pts[0]) < 1e-6);
|
||||
Assert.True(arc.EndPoint().DistanceTo(pts[^1]) < 1e-6);
|
||||
|
||||
// Arc must be tangent to the adjacent straight edges at its endpoints
|
||||
var startDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.StartPoint()), new Vector(1, 0));
|
||||
var endDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.EndPoint()), new Vector(0, 1));
|
||||
Assert.True(startDelta < 0.3, $"Arc start not tangent to incoming line: off by {startDelta:F3} deg");
|
||||
Assert.True(endDelta < 0.3, $"Arc end not tangent to outgoing line: off by {endDelta:F3} deg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_CompoundCurve_AdjacentArcsAreTangentAtJunction()
|
||||
{
|
||||
// Two tangent-continuous arcs of different radii (r=0.2 sweeping 60deg, then
|
||||
// r=0.6 sweeping 40deg), tessellated into chords with slight radial noise.
|
||||
// The fitted arcs must stay tangent-continuous at their junction.
|
||||
var c1 = new Vector(0, 0);
|
||||
var r1 = 0.2;
|
||||
var deltas1 = new[] { 0.0, 0.001, 0.0005, -0.0005, -0.001, -0.0005, 0.0 };
|
||||
var pts = new List<Vector>();
|
||||
for (var i = 0; i <= 6; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(10 * i);
|
||||
var radius = r1 + deltas1[i];
|
||||
pts.Add(new Vector(c1.X + radius * System.Math.Cos(ang), c1.Y + radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
// Second arc center along the junction radius so tangents match at the junction
|
||||
var junctionAngle = OpenNest.Math.Angle.ToRadians(60);
|
||||
var u = new Vector(System.Math.Cos(junctionAngle), System.Math.Sin(junctionAngle));
|
||||
var r2 = 0.6;
|
||||
var c2 = new Vector(c1.X + u.X * (r1 - r2), c1.Y + u.Y * (r1 - r2));
|
||||
var deltas2 = new[] { 0.0, 0.001, -0.001, 0.0005, -0.0005, 0.0 };
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(60 + 8 * i);
|
||||
var radius = r2 + deltas2[i];
|
||||
pts.Add(new Vector(c2.X + radius * System.Math.Cos(ang), c2.Y + radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
var shape = new Shape();
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
var arcA = candidates[0].FittedArc;
|
||||
var arcB = candidates[1].FittedArc;
|
||||
|
||||
// Arcs must share the junction vertex exactly
|
||||
Assert.True(arcA.EndPoint().DistanceTo(arcB.StartPoint()) < 1e-6);
|
||||
|
||||
// Tangent continuity across the junction
|
||||
var junctionDelta = AngleBetweenDeg(ArcTangentAt(arcA, arcA.EndPoint()), ArcTangentAt(arcB, arcB.StartPoint()));
|
||||
Assert.True(junctionDelta < 0.3, $"Tangent break of {junctionDelta:F3} deg at arc-arc junction");
|
||||
}
|
||||
|
||||
private static Vector ArcTangentAt(Arc arc, Vector pt)
|
||||
{
|
||||
var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X);
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(ang), -System.Math.Cos(ang))
|
||||
: new Vector(-System.Math.Sin(ang), System.Math.Cos(ang));
|
||||
}
|
||||
|
||||
private static double AngleBetweenDeg(Vector v1, Vector v2)
|
||||
{
|
||||
var l1 = System.Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y);
|
||||
var l2 = System.Math.Sqrt(v2.X * v2.X + v2.Y * v2.Y);
|
||||
var dot = (v1.X * v2.X + v1.Y * v2.Y) / (l1 * l2);
|
||||
dot = System.Math.Max(-1, System.Math.Min(1, dot));
|
||||
return System.Math.Acos(dot) * 180.0 / System.Math.PI;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Posts.GravographIS;
|
||||
|
||||
namespace OpenNest.Tests.GravographIS;
|
||||
|
||||
public class GravographISPostConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_EngraveBlock_NoPause_FasterFeed()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
|
||||
Assert.Equal(10, config.Engrave.FeedMmPerSec);
|
||||
Assert.False(config.Engrave.PauseBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_CutBlock_PausesToChangeTool()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
|
||||
Assert.Equal(3, config.Cut.FeedMmPerSec);
|
||||
Assert.True(config.Cut.PauseBefore);
|
||||
Assert.Equal("Change tool", config.Cut.PauseMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LayerType.Scribe)]
|
||||
public void ConfigFor_Scribe_ReturnsEngraveBlock(LayerType layer)
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Same(config.Engrave, config.ConfigFor(layer));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LayerType.Cut)]
|
||||
[InlineData(LayerType.Leadin)]
|
||||
[InlineData(LayerType.Leadout)]
|
||||
public void ConfigFor_CutLayers_ReturnCutBlock(LayerType layer)
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Same(config.Cut, config.ConfigFor(layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigFor_Display_ReturnsNull_SoItIsSkipped()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Null(config.ConfigFor(LayerType.Display));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Posts.GravographIS;
|
||||
|
||||
namespace OpenNest.Tests.GravographIS;
|
||||
|
||||
public class GravographISPostProcessorTests
|
||||
{
|
||||
private static LayeredPolyline Poly(LayerType layer, params Vector[] pts)
|
||||
=> new LayeredPolyline(new List<Vector>(pts), layer);
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_EngraveAndCut_OrdersEngraveFirstThenCutWithPause()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||
Poly(LayerType.Scribe, new Vector(0, 0), new Vector(0, 1)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, passes.Count);
|
||||
|
||||
Assert.Equal(post.Config.Engrave.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
Assert.False(passes[0].PauseBefore);
|
||||
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[1].FeedMmPerSec);
|
||||
Assert.True(passes[1].PauseBefore);
|
||||
Assert.Equal("Change tool", passes[1].PauseMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_CutOnly_IsSinglePass()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||
});
|
||||
|
||||
Assert.Single(passes);
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_SkipsDisplayGeometry()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Display, new Vector(0, 0), new Vector(1, 0)),
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(0, 1)),
|
||||
});
|
||||
|
||||
Assert.Single(passes);
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Config_IsExposedThroughConfigurableInterface()
|
||||
{
|
||||
var post = new GravographISPostProcessor(new GravographISPostConfig());
|
||||
OpenNest.IConfigurablePostProcessor configurable = post;
|
||||
Assert.Same(post.Config, configurable.Config);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,101 @@ public class GravographISWriterTests
|
||||
Assert.Equal(-GravographISWriter.StepsPerInch, dy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_PauseBeforeCut_EmitsPauseSequenceBetweenGroups()
|
||||
{
|
||||
var engrave = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } };
|
||||
var cut = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } };
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = engrave, FeedMmPerSec = 10, DepthInches = 0.25 },
|
||||
new GravographPass { Polylines = cut, FeedMmPerSec = 3, DepthInches = 0.25, PauseBefore = true, PauseMessage = "Hi" },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions
|
||||
{
|
||||
EnvelopeGuardEnabled = false,
|
||||
ReturnToOriginAtEnd = false,
|
||||
}).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var mcOff = IndexOf(bytes, 0, (byte)'M', (byte)'C', 0x00, 0x00);
|
||||
var ouFb = IndexOf(bytes, mcOff, (byte)'O', (byte)'U', 0xFF, 0xFB);
|
||||
var ouFa = IndexOf(bytes, ouFb, (byte)'O', (byte)'U', 0xFF, 0xFA);
|
||||
var lbBegin = IndexOf(bytes, ouFa, (byte)'L', (byte)'B', 0x00, 0x00);
|
||||
var lbMsg = IndexOf(bytes, lbBegin, (byte)'L', (byte)'B', (byte)'H', (byte)'i');
|
||||
var nr = IndexOf(bytes, lbMsg, (byte)'N', (byte)'R', 0x00, 0x01);
|
||||
var lbEnd = IndexOf(bytes, nr, (byte)'L', (byte)'B', 0x00, 0x01);
|
||||
var mcOn = IndexOf(bytes, lbEnd, (byte)'M', (byte)'C', 0x00, 0x01);
|
||||
|
||||
Assert.True(mcOff >= 0, "motor-off (MC 0000) not found");
|
||||
Assert.True(mcOff < ouFb && ouFb < ouFa && ouFa < lbBegin && lbBegin < lbMsg
|
||||
&& lbMsg < nr && nr < lbEnd && lbEnd < mcOn,
|
||||
"pause commands out of order");
|
||||
|
||||
// Resume sets the cut feed inline (VS 0x0003) after the motor restarts.
|
||||
var vsCut = IndexOf(bytes, mcOn, (byte)'V', (byte)'S', 0x00, 0x03);
|
||||
Assert.True(vsCut > mcOn, "cut feed not set after resume");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_PauseOddMessage_SpacePadsLastPacket()
|
||||
{
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = true, PauseMessage = "abc" },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var lbAb = IndexOf(bytes, 0, (byte)'L', (byte)'B', (byte)'a', (byte)'b');
|
||||
var lbCPad = IndexOf(bytes, lbAb, (byte)'L', (byte)'B', (byte)'c', 0x20);
|
||||
Assert.True(lbAb >= 0, "first message packet 'ab' not found");
|
||||
Assert.True(lbCPad > lbAb, "odd packet not space-padded to 'c '");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_DifferentFeeds_NoPause_EmitsInlineFeedChangeNoMessage()
|
||||
{
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = false },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
Assert.True(IndexOf(bytes, 0, (byte)'V', (byte)'S', 0x00, 0x03) >= 0, "inline cut feed change missing");
|
||||
Assert.True(IndexOfCmd(bytes, (byte)'L', (byte)'B') < 0, "no LB message expected without a pause");
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] bytes, int from, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
for (var i = System.Math.Max(0, from); i <= bytes.Length - 6; i++)
|
||||
{
|
||||
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 &&
|
||||
bytes[i + 3] == c1 && bytes[i + 4] == hi && bytes[i + 5] == lo)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int IndexOfCmd(byte[] bytes, byte c0, byte c1)
|
||||
{
|
||||
for (var i = 0; i <= bytes.Length - 4; i++)
|
||||
{
|
||||
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
for (var i = 0; i < bytes.Length - 5; i++)
|
||||
|
||||
@@ -34,4 +34,45 @@ public class NestPolylineExtractorTests
|
||||
Assert.Equal(new Vector(0.25, 47.75), poly[3]);
|
||||
Assert.Equal(new Vector(0.25, 46.75), poly[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPartLayered_SplitsContinuousChainAtLayerChange()
|
||||
{
|
||||
// A single continuous chain (no rapid) that switches from Scribe to Cut
|
||||
// must be split into two layer-uniform polylines sharing the seam vertex,
|
||||
// so the post can emit a tool-change pause between engrave and cut.
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe });
|
||||
program.Codes.Add(new LinearMove(2, 0) { Layer = LayerType.Scribe });
|
||||
program.Codes.Add(new LinearMove(2, 1) { Layer = LayerType.Cut });
|
||||
program.Codes.Add(new LinearMove(3, 1) { Layer = LayerType.Cut });
|
||||
|
||||
var drawing = new Drawing("Mixed", program);
|
||||
var part = new Part(drawing, new Vector(0, 0));
|
||||
|
||||
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
|
||||
|
||||
Assert.Equal(2, polylines.Count);
|
||||
|
||||
Assert.Equal(LayerType.Scribe, polylines[0].Layer);
|
||||
Assert.Equal(new[] { new Vector(0, 0), new Vector(1, 0), new Vector(2, 0) }, polylines[0].Points);
|
||||
|
||||
Assert.Equal(LayerType.Cut, polylines[1].Layer);
|
||||
Assert.Equal(new[] { new Vector(2, 0), new Vector(2, 1), new Vector(3, 1) }, polylines[1].Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPartLayered_UniformChain_IsSinglePolyline()
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new LinearMove(1, 0));
|
||||
program.Codes.Add(new LinearMove(1, 1));
|
||||
|
||||
var part = new Part(new Drawing("Cut", program), new Vector(0, 0));
|
||||
|
||||
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
|
||||
|
||||
Assert.Single(polylines);
|
||||
Assert.Equal(LayerType.Cut, polylines[0].Layer);
|
||||
}
|
||||
}
|
||||
|
||||
+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,23 @@ 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 `INestingEngine` implementation against each other on a set of `.nest` files, scoring by material utilization. Each engine owns its own multi-plate/size strategy for the whole job — how many plates it uses, of which sizes, and how demand splits across them:
|
||||
|
||||
```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. A run that doesn't finish within its time budget also scores zero, as a timeout.
|
||||
|
||||
Custom competitor engines can be added by dropping a DLL implementing `INestingEngine` with a public parameterless constructor into the `Engines/` directory next to the benchmark executable; each one is registered under its own CLR type name. This is a separate plugin contract from the desktop app's `NestEngineRegistry`/`NestEngineBase` (which requires a `(Plate)` constructor) — a `NestEngineBase` plugin dropped into the benchmark's `Engines/` folder is silently skipped, since the benchmark only ever solves whole jobs.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -180,6 +197,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 +215,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 whole-job nesting engine (`INestingEngine`) against a set of `.nest` files and scores them by material utilization, so competing engines — each owning its own multi-plate strategy — 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