Compare commits
17
Commits
ad69023c17
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d27652f4 | ||
|
|
9b69c67572 | ||
|
|
aa88eee484 | ||
|
|
e0e3b96bed | ||
|
|
424ff15ebc | ||
|
|
9888fe6083 | ||
|
|
a2dcfc7484 | ||
|
|
ae704478af | ||
|
|
ecca71e185 | ||
|
|
f0fe79f0f1 | ||
|
|
a68e252ac7 | ||
|
|
bc6bcae419 | ||
|
|
02141f6ca5 | ||
|
|
a9e0f8a1d4 | ||
|
|
20da5477b6 | ||
|
|
6a0fba0fec | ||
|
|
a085339ba9 |
@@ -14,11 +14,13 @@ This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-wind
|
|||||||
dotnet build OpenNest.sln
|
dotnet build OpenNest.sln
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The existing `OpenNest.Tests` suite targets `net8.0-windows` and requires a Windows runner; cross-compiling on Linux is not Windows runtime verification.
|
||||||
|
|
||||||
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
|
NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Eight projects form a layered architecture:
|
Nine projects form a layered architecture:
|
||||||
|
|
||||||
### OpenNest.Core (class library)
|
### OpenNest.Core (class library)
|
||||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
Domain model, geometry, and CNC primitives organized into namespaces:
|
||||||
@@ -35,9 +37,11 @@ Domain model, geometry, and CNC primitives organized into namespaces:
|
|||||||
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
|
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
|
||||||
|
|
||||||
### OpenNest.Engine (class library, depends on Core)
|
### OpenNest.Engine (class library, depends on Core)
|
||||||
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine.
|
Nesting algorithms provide both a legacy single-plate API and a whole-job API. The legacy path centers on `NestEngineBase`, `DefaultNestEngine` (formerly `NestEngine`), and the global `NestEngineRegistry`. New job callers use immutable, ID-based contracts in `Jobs/`: `INestingEngine.Solve(NestJob)` returns `NestJobResult`; `NestJobRunner` alone commits demand and finite/unlimited stock accounting; `IPlateNester` only proposes a one-sheet candidate; and `PlateNesterFactory` resolves a named strategy without reading or changing the process-global registry.
|
||||||
|
|
||||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
|
- **Whole-job API (`Jobs/`)**: `NestJob` owns part requirements, physical stock, and options for one material/thickness/unit system. `PartGeometrySnapshot` contains owned flat rapid/line/arc geometry; results contain stock IDs and placement poses (radians), not mutable desktop models. `NestJobPlacementValidator` validates contours, rotation, usable work area, overlap, and spacing before accounting commits. The runner selects valid trial candidates greedily by priority vector, sheet area, envelope, and input order; an incomplete result reports why but does not prove geometric impossibility. `DrawingJobMapper` and `NestResultMaterializer` are the domain-boundary adapters.
|
||||||
|
- **Placement boundary (`Jobs/Placement/`, `Jobs/Adapters/`)**: `DefaultPlateNester` and `StripPlateNester` are migrated built-ins with run-scoped private geometry; `LegacyPlateNesterAdapter` remains for remnant strategies and legacy plugins/callers during rollout. Job-path identity is reference-based rather than drawing name; `PlateOptimizer` and NFP/`AutoNester` retain legacy name-based helpers and are deliberately outside the runner path.
|
||||||
|
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`. Existing desktop, CLI, and MCP callers remain on this compatibility path until separate migrations preserve their existing-plate, preview, and accept/cancel semantics.
|
||||||
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
|
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
|
||||||
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
|
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
|
||||||
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
||||||
@@ -69,6 +73,16 @@ GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` impleme
|
|||||||
### OpenNest.Training (console app, depends on Core + Engine)
|
### 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.
|
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)
|
### 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/`.
|
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
|
||||||
|
|
||||||
@@ -120,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).
|
- **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.
|
- **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.
|
- **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;
|
lastpt = endpt;
|
||||||
|
|
||||||
|
var layer = ClassifyLayer(arc);
|
||||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||||
{
|
{
|
||||||
pgm.LineTo(endpt);
|
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||||
}
|
}
|
||||||
else
|
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;
|
return lastpt;
|
||||||
@@ -107,7 +108,7 @@ namespace OpenNest.Converters
|
|||||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
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;
|
lastpt = startpt;
|
||||||
return lastpt;
|
return lastpt;
|
||||||
@@ -118,13 +119,22 @@ namespace OpenNest.Converters
|
|||||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(line.StartPoint);
|
pgm.MoveTo(line.StartPoint);
|
||||||
|
|
||||||
var move = new LinearMove(line.EndPoint);
|
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
|
||||||
move.Layer = LayerType.Scribe;
|
|
||||||
pgm.Codes.Add(move);
|
|
||||||
|
|
||||||
lastpt = line.EndPoint;
|
lastpt = line.EndPoint;
|
||||||
return lastpt;
|
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>
|
/// <summary>
|
||||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
/// Fits a circular arc that passes exactly through both the first and last points
|
||||||
/// the first and last points. The center lies at the intersection of the normals
|
/// while matching the given endpoint tangents as closely as possible. For any
|
||||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
/// circle through two points, the tangents at those points make equal mirrored
|
||||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||||
/// deviation includes any endpoint gap at Pn.
|
/// two requested ones — when the requested tangents are consistent with a single
|
||||||
|
/// circular arc, both are matched exactly.
|
||||||
/// </summary>
|
/// </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)
|
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||||
{
|
{
|
||||||
if (points.Count < 3)
|
if (points.Count < 3)
|
||||||
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
|
|||||||
var p1 = points[0];
|
var p1 = points[0];
|
||||||
var pn = points[^1];
|
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 dx = pn.X - p1.X;
|
||||||
var dy = pn.Y - p1.Y;
|
var dy = pn.Y - p1.Y;
|
||||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (chordLen < 1e-10)
|
||||||
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)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
// Measure endpoint gap at Pn
|
var ux = dx / chordLen;
|
||||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
var uy = dy / chordLen;
|
||||||
var endpointDev = System.Math.Abs(r2 - r1);
|
|
||||||
|
|
||||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -374,11 +374,8 @@ public class GeometrySimplifier
|
|||||||
var points = CollectPoints(entities, start, k);
|
var points = CollectPoints(entities, start, k);
|
||||||
if (points.Count < 3) return null;
|
if (points.Count < 3) return null;
|
||||||
|
|
||||||
var startTangent = chainedTangent.IsValid()
|
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||||
? chainedTangent
|
var endTangent = EstimateEndTangent(entities, k, points);
|
||||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
|
||||||
|
|
||||||
var endTangent = GetExitDirection(entities[k]);
|
|
||||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||||
if (!center.IsValid()) return null;
|
if (!center.IsValid()) return null;
|
||||||
|
|
||||||
@@ -386,8 +383,10 @@ public class GeometrySimplifier
|
|||||||
while (k + 1 <= runEnd)
|
while (k + 1 <= runEnd)
|
||||||
{
|
{
|
||||||
var extPoints = CollectPoints(entities, start, k + 1);
|
var extPoints = CollectPoints(entities, start, k + 1);
|
||||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
if (extPoints.Count < 3) break;
|
||||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
|
||||||
|
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||||
|
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||||
if (!nc.IsValid()) break;
|
if (!nc.IsValid()) break;
|
||||||
|
|
||||||
k++;
|
k++;
|
||||||
@@ -407,37 +406,172 @@ public class GeometrySimplifier
|
|||||||
return new ArcFitResult(center, radius, dev, points, k);
|
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)
|
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||||
if (endTangent.IsValid())
|
|
||||||
{
|
{
|
||||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
if (!center.IsValid() || dev > Tolerance)
|
||||||
if (dc.IsValid() && dd <= 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;
|
if (prev is Arc)
|
||||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
return new TangentEstimate(GetExitDirection(prev), true);
|
||||||
if (aDev <= Tolerance)
|
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
|
||||||
return (dc, dr, System.Math.Max(dd, aDev));
|
return new TangentEstimate(GetExitDirection(prevLine), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to start-tangent-only, then mirror axis
|
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
if (points.Count >= 3)
|
||||||
if (!center.IsValid() || dev > Tolerance)
|
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
|
||||||
(center, radius, dev) = FitMirrorAxis(points);
|
return new TangentEstimate(chord, false);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Computes the tangent direction at the last point of a fitted arc,
|
/// Computes the tangent direction at the last point of a fitted arc,
|
||||||
/// used to chain tangent continuity to the next 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,148 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runnable end-to-end example of the whole-job engine API: multiple part requirements, multiple plate
|
||||||
|
/// sizes, and an enumeration of every returned plate, placement, leftover, and stock line. Also the
|
||||||
|
/// documentation checkpoint for the legacy caller boundaries that have not been migrated (task 8).
|
||||||
|
/// </summary>
|
||||||
|
public class NestJobExampleTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MultiRequirementMultiStockJobEnumeratesEveryPlateAndLeftover()
|
||||||
|
{
|
||||||
|
// Two requirements with independent IDs, quantities, and priorities.
|
||||||
|
var job = new NestJob(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Part("bracket", 100.0, 60.0, 5, priority: 0),
|
||||||
|
Part("plate-clip", 40.0, 40.0, 8, priority: 1),
|
||||||
|
},
|
||||||
|
// Mixed inventory: five large sheets and unlimited small sheets.
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new NestPlateStock("large", new Size(600.0, 400.0), quantity: 5, partSpacing: 2.0,
|
||||||
|
edgeSpacing: new Spacing(5.0, 5.0, 5.0, 5.0), quadrant: 1),
|
||||||
|
new NestPlateStock("small", new Size(300.0, 300.0), quantity: null, partSpacing: 2.0,
|
||||||
|
edgeSpacing: new Spacing(5.0, 5.0, 5.0, 5.0), quadrant: 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job);
|
||||||
|
|
||||||
|
// -- Every physical plate is enumerated with its stock identity and placements. --
|
||||||
|
Console.WriteLine($"Status: {result.Status}, stop reason: {result.StopReason}.");
|
||||||
|
foreach (var plate in result.Plates)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Plate {plate.PlateIndex} from stock '{plate.StockId}' " +
|
||||||
|
$"({plate.Stock.Size.Width} x {plate.Stock.Size.Length}):");
|
||||||
|
foreach (var placement in plate.Placements)
|
||||||
|
Console.WriteLine($" {placement.PartId} #{placement.InstanceIndex} at " +
|
||||||
|
$"({placement.X:F1}, {placement.Y:F1}) rotated {placement.Rotation:F3} rad.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Every requirement reports exact fulfillment, including leftovers. --
|
||||||
|
foreach (var fulfillment in result.Fulfillment)
|
||||||
|
Console.WriteLine($"Requirement '{fulfillment.PartId}': requested {fulfillment.Requested}, " +
|
||||||
|
$"placed {fulfillment.Placed}, unplaced {fulfillment.Unplaced}.");
|
||||||
|
|
||||||
|
// -- Every stock line reports physical sheets used and remaining availability. --
|
||||||
|
foreach (var usage in result.StockUsage)
|
||||||
|
Console.WriteLine($"Stock '{usage.StockId}': used {usage.Used}, " +
|
||||||
|
$"remaining {(usage.Remaining.HasValue ? usage.Remaining.Value.ToString() : "unlimited")}.");
|
||||||
|
|
||||||
|
// Invariants the enumeration relies on: conservation per requirement and per stock line, no
|
||||||
|
// empty plates, every plate bound to supplied stock, and per-placement instance accounting.
|
||||||
|
foreach (var fulfillment in result.Fulfillment)
|
||||||
|
{
|
||||||
|
Assert.Equal(fulfillment.Requested, fulfillment.Placed + fulfillment.Unplaced);
|
||||||
|
Assert.True(fulfillment.Unplaced >= 0);
|
||||||
|
}
|
||||||
|
foreach (var usage in result.StockUsage)
|
||||||
|
{
|
||||||
|
var stock = job.Plates.First(candidate => candidate.Id == usage.StockId);
|
||||||
|
Assert.True(usage.Used >= 0);
|
||||||
|
Assert.Equal(stock.Quantity is int capacity ? capacity - usage.Used : (int?)null, usage.Remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.All(result.Plates, plate => Assert.NotEmpty(plate.Placements));
|
||||||
|
var plateCountByStock = result.Plates.GroupBy(plate => plate.StockId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Count());
|
||||||
|
foreach (var usage in result.StockUsage)
|
||||||
|
Assert.Equal(usage.Used, plateCountByStock.GetValueOrDefault(usage.StockId));
|
||||||
|
|
||||||
|
var instanceIndicesByPart = result.Plates
|
||||||
|
.SelectMany(plate => plate.Placements)
|
||||||
|
.GroupBy(placement => placement.PartId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.Select(placement => placement.InstanceIndex));
|
||||||
|
foreach (var fulfillment in result.Fulfillment)
|
||||||
|
Assert.Equal(Enumerable.Range(0, fulfillment.Placed),
|
||||||
|
instanceIndicesByPart.GetValueOrDefault(fulfillment.PartId, new List<int>()).OrderBy(index => index));
|
||||||
|
|
||||||
|
// The default heuristic completes this synthetic job from the mixed inventory.
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(NestJobStopReason.Completed, result.StopReason);
|
||||||
|
Assert.Equal(5, result.Fulfillment.Single(fulfillment => fulfillment.PartId == "bracket").Placed);
|
||||||
|
Assert.Equal(8, result.Fulfillment.Single(fulfillment => fulfillment.PartId == "plate-clip").Placed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaxPlatesExampleShowsExplicitLeftovers()
|
||||||
|
{
|
||||||
|
// Same shape of job, but a plate budget forces an explicit partial result.
|
||||||
|
var job = new NestJob(
|
||||||
|
new[] { Part("part", 100.0, 100.0, 6, priority: 0) },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(220.0, 220.0), quantity: null, partSpacing: 2.0,
|
||||||
|
edgeSpacing: new Spacing(5.0, 5.0, 5.0, 5.0), quadrant: 1) },
|
||||||
|
new NestJobOptions("Default", maxPlates: 1));
|
||||||
|
|
||||||
|
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||||
|
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||||
|
var single = Assert.Single(result.Plates);
|
||||||
|
Assert.Equal("sheet", single.StockId);
|
||||||
|
var fulfillment = Assert.Single(result.Fulfillment);
|
||||||
|
Assert.Equal(6, fulfillment.Requested);
|
||||||
|
Assert.Equal(single.Placements.Count, fulfillment.Placed);
|
||||||
|
Assert.Equal(fulfillment.Requested - fulfillment.Placed, fulfillment.Unplaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy caller boundaries documented for task 8 — these paths still use the old single-plate
|
||||||
|
/// engine entry points and are deliberately NOT migrated in this slice. Verified against source at
|
||||||
|
/// the time of writing:
|
||||||
|
/// - Desktop UI: OpenNest/Forms/MainForm.cs RunAutoNestAsync (~line 1004) and NestSinglePlateAsync
|
||||||
|
/// (~line 1087) orchestrate plate-first and part-first fills directly against NestEngineRegistry
|
||||||
|
/// engines. Migration requires preserving populated-plate editing, preview routing, and
|
||||||
|
/// Accept-versus-Cancel semantics — a separate adapter design (documented follow-on).
|
||||||
|
/// - CLI: OpenNest.Console/Program.cs calls engine.Nest(...) (~line 316) on one plate. Migration
|
||||||
|
/// point: build a NestJob from imported drawings plus CLI plate options and call Solve once.
|
||||||
|
/// - MCP: OpenNest.Mcp/Tools/NestingTools.cs calls engine.Nest(...) (~line 239) on the session
|
||||||
|
/// plate. Migration point: same single job call, materialized through NestResultMaterializer.
|
||||||
|
/// The public API (OpenNest.Api NestRunner) already delegates to NestJobRunner.Solve (task 6).
|
||||||
|
/// This test exercises the legacy compatibility signature so an accidental removal of that entry
|
||||||
|
/// point breaks the documented contract.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void LegacyCompatibilityEntryPointsStillExist()
|
||||||
|
{
|
||||||
|
var plate = new Plate { Size = new Size(300.0, 200.0), Quadrant = 1 };
|
||||||
|
var drawing = new Drawing("legacy", TestDrawingFactory.Rectangle(50.0, 50.0));
|
||||||
|
var item = new NestItem { Drawing = drawing, Quantity = 1 };
|
||||||
|
|
||||||
|
// MainForm/Console/MCP still reach the legacy single-plate signature unchanged; the engine
|
||||||
|
// returns placed Parts for the caller to attach (legacy paths do not attach on their own).
|
||||||
|
var engine = NestEngineRegistry.Create(plate);
|
||||||
|
var parts = engine.Nest(new List<NestItem> { item }, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(engine);
|
||||||
|
var placed = Assert.Single(parts);
|
||||||
|
Assert.Same(drawing, placed.BaseDrawing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJobPart Part(string id, double width, double length, int quantity, int priority) =>
|
||||||
|
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(width, length)), quantity, priority);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parity between the legacy adapter and the migrated built-in plate nesters (Default/Strip) on
|
||||||
|
/// generated geometry. The runner's placement validator enforces geometric safety on every committed
|
||||||
|
/// candidate, so these tests assert fulfillment, status, and — for the deterministic Default/rectangle
|
||||||
|
/// case — identical layouts.
|
||||||
|
/// </summary>
|
||||||
|
public class PlateNesterParityTests
|
||||||
|
{
|
||||||
|
private const double Tolerance = 1e-6;
|
||||||
|
|
||||||
|
private static readonly Size PlateSize = new(30, 50);
|
||||||
|
private static readonly Spacing Edge = new(1, 1, 1, 1);
|
||||||
|
|
||||||
|
private static NestJob Job(IReadOnlyList<NestJobPart> parts, int? stockQuantity = 3,
|
||||||
|
string strategy = "Default")
|
||||||
|
{
|
||||||
|
var stock = new NestPlateStock("stock", PlateSize, stockQuantity, 1, Edge);
|
||||||
|
return new NestJob(parts, new[] { stock }, new NestJobOptions(strategy));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJobResult Solve(IPlateNester nester, NestJob job) =>
|
||||||
|
new NestJobRunner(_ => nester).Solve(job);
|
||||||
|
|
||||||
|
private static Dictionary<string, PartFulfillment> ByPart(NestJobResult result) =>
|
||||||
|
result.Fulfillment.ToDictionary(f => f.PartId, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static void AssertLayoutsIdentical(NestJobResult left, NestJobResult right)
|
||||||
|
{
|
||||||
|
Assert.Equal(left.Plates.Count, right.Plates.Count);
|
||||||
|
for (var i = 0; i < left.Plates.Count; i++)
|
||||||
|
{
|
||||||
|
var lPlates = left.Plates[i].Placements;
|
||||||
|
var rPlates = right.Plates[i].Placements;
|
||||||
|
Assert.Equal(lPlates.Count, rPlates.Count);
|
||||||
|
var lSorted = lPlates.OrderBy(p => p.PartId).ThenBy(p => p.X).ThenBy(p => p.Y).ToList();
|
||||||
|
var rSorted = rPlates.OrderBy(p => p.PartId).ThenBy(p => p.X).ThenBy(p => p.Y).ToList();
|
||||||
|
for (var j = 0; j < lSorted.Count; j++)
|
||||||
|
{
|
||||||
|
Assert.Equal(lSorted[j].PartId, rSorted[j].PartId);
|
||||||
|
Assert.Equal(lSorted[j].X, rSorted[j].X, 6);
|
||||||
|
Assert.Equal(lSorted[j].Y, rSorted[j].Y, 6);
|
||||||
|
Assert.True(AnglesEqual(lSorted[j].Rotation, rSorted[j].Rotation),
|
||||||
|
$"rotation differs: {lSorted[j].Rotation} vs {rSorted[j].Rotation}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool AnglesEqual(double left, double right)
|
||||||
|
{
|
||||||
|
var delta = (left - right) % (System.Math.PI * 2);
|
||||||
|
return System.Math.Abs(delta) <= Tolerance ||
|
||||||
|
System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= Tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultParity_Rectangles_SameFulfillmentAndLayout()
|
||||||
|
{
|
||||||
|
var parts = new[]
|
||||||
|
{
|
||||||
|
new NestJobPart("a", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), 4),
|
||||||
|
new NestJobPart("b", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
var legacy = Solve(new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate)), Job(parts));
|
||||||
|
var migrated = Solve(new DefaultPlateNester(), Job(parts));
|
||||||
|
|
||||||
|
Assert.Equal(legacy.Status, migrated.Status);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, migrated.Status);
|
||||||
|
Assert.Equal(ByPart(legacy), ByPart(migrated));
|
||||||
|
foreach (var usage in legacy.StockUsage)
|
||||||
|
Assert.Equal(usage.Used, migrated.StockUsage.First(u => u.StockId == usage.StockId).Used);
|
||||||
|
// Automatic-rotation rectangles on a single stock size are deterministic: identical layouts.
|
||||||
|
AssertLayoutsIdentical(legacy, migrated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StripParity_Rectangles_SameFulfillmentAndTotalCount()
|
||||||
|
{
|
||||||
|
var parts = new[]
|
||||||
|
{
|
||||||
|
new NestJobPart("a", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), 4),
|
||||||
|
new NestJobPart("b", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
var legacy = Solve(new LegacyPlateNesterAdapter(plate => new StripNestEngine(plate)),
|
||||||
|
Job(parts, strategy: "Strip"));
|
||||||
|
var migrated = Solve(new StripPlateNester(), Job(parts, strategy: "Strip"));
|
||||||
|
|
||||||
|
Assert.Equal(legacy.Status, migrated.Status);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, migrated.Status);
|
||||||
|
Assert.Equal(ByPart(legacy), ByPart(migrated));
|
||||||
|
Assert.Equal(legacy.Plates.SelectMany(p => p.Placements).Count(),
|
||||||
|
migrated.Plates.SelectMany(p => p.Placements).Count());
|
||||||
|
// Shrink-fill ordering can differ between engine instances; do not assert identical coordinates.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MigratedBuiltins_AreResolvedByProductionFactory()
|
||||||
|
{
|
||||||
|
Assert.IsType<DefaultPlateNester>(PlateNesterFactory.Create("Default"));
|
||||||
|
Assert.IsType<StripPlateNester>(PlateNesterFactory.Create("Strip"));
|
||||||
|
Assert.IsType<LegacyPlateNesterAdapter>(PlateNesterFactory.Create("Vertical Remnant"));
|
||||||
|
Assert.IsType<LegacyPlateNesterAdapter>(PlateNesterFactory.Create("Horizontal Remnant"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AsymmetricPart_ValidAndFulfilled()
|
||||||
|
{
|
||||||
|
// L-shape: 6x4 outer with a corner notch removed (single closed contour, asymmetric).
|
||||||
|
var lshape = new Program();
|
||||||
|
lshape.MoveTo(0, 0);
|
||||||
|
lshape.LineTo(6, 0);
|
||||||
|
lshape.LineTo(6, 4);
|
||||||
|
lshape.LineTo(3, 4);
|
||||||
|
lshape.LineTo(3, 2);
|
||||||
|
lshape.LineTo(0, 2);
|
||||||
|
lshape.LineTo(0, 0);
|
||||||
|
|
||||||
|
var parts = new[]
|
||||||
|
{
|
||||||
|
new NestJobPart("l", PartGeometrySnapshot.FromProgram(lshape), 3),
|
||||||
|
new NestJobPart("sq", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 2)
|
||||||
|
};
|
||||||
|
var result = Solve(new DefaultPlateNester(), Job(parts));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(3, ByPart(result)["l"].Placed);
|
||||||
|
Assert.Equal(2, ByPart(result)["sq"].Placed);
|
||||||
|
Assert.Equal(5, result.Plates.SelectMany(p => p.Placements).Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HoleAndArcParts_ValidAndFulfilled()
|
||||||
|
{
|
||||||
|
// 6x6 rectangle with a 2x2 inner hole (rapid contour), plus a D-shape with a semicircular arc.
|
||||||
|
var holed = new Program();
|
||||||
|
holed.MoveTo(0, 0);
|
||||||
|
holed.LineTo(6, 0);
|
||||||
|
holed.LineTo(6, 6);
|
||||||
|
holed.LineTo(0, 6);
|
||||||
|
holed.LineTo(0, 0);
|
||||||
|
holed.MoveTo(2, 2);
|
||||||
|
holed.LineTo(4, 2);
|
||||||
|
holed.LineTo(4, 4);
|
||||||
|
holed.LineTo(2, 4);
|
||||||
|
holed.LineTo(2, 2);
|
||||||
|
|
||||||
|
var arc = new Program();
|
||||||
|
arc.MoveTo(0, 0);
|
||||||
|
arc.LineTo(3, 0);
|
||||||
|
arc.ArcTo(3, 5, 3, 2.5, RotationType.CCW);
|
||||||
|
arc.LineTo(0, 5);
|
||||||
|
arc.LineTo(0, 0);
|
||||||
|
|
||||||
|
var parts = new[]
|
||||||
|
{
|
||||||
|
new NestJobPart("holed", PartGeometrySnapshot.FromProgram(holed), 2),
|
||||||
|
new NestJobPart("arc", PartGeometrySnapshot.FromProgram(arc), 2)
|
||||||
|
};
|
||||||
|
var result = Solve(new DefaultPlateNester(), Job(parts));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, ByPart(result)["holed"].Placed);
|
||||||
|
Assert.Equal(2, ByPart(result)["arc"].Placed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedRotation_Respected()
|
||||||
|
{
|
||||||
|
var part = new NestJobPart("fixed", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
|
||||||
|
2, rotation: RotationPolicy.Fixed(0));
|
||||||
|
var result = Solve(new DefaultPlateNester(), Job(new[] { part }));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
|
||||||
|
Assert.Equal(2, placements.Count);
|
||||||
|
foreach (var placement in placements)
|
||||||
|
Assert.True(AnglesEqual(placement.Rotation, 0), $"fixed rotation violated: {placement.Rotation}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatedNames_KeepIndependentIdentity()
|
||||||
|
{
|
||||||
|
// Two distinct requirements sharing identical geometry (and, via the mapper, name) but different IDs.
|
||||||
|
var program = TestDrawingFactory.Rectangle(6, 4);
|
||||||
|
var parts = new[]
|
||||||
|
{
|
||||||
|
new NestJobPart("first", PartGeometrySnapshot.FromProgram(program), 2),
|
||||||
|
new NestJobPart("second", PartGeometrySnapshot.FromProgram(program), 1)
|
||||||
|
};
|
||||||
|
var result = Solve(new DefaultPlateNester(), Job(parts));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, ByPart(result)["first"].Placed);
|
||||||
|
Assert.Equal(1, ByPart(result)["second"].Placed);
|
||||||
|
var ids = result.Plates.SelectMany(p => p.Placements).Select(p => p.PartId);
|
||||||
|
Assert.Equal(2, ids.Count(id => id == "first"));
|
||||||
|
Assert.Equal(1, ids.Count(id => id == "second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OffsetGeometry_ValidAndFulfilled()
|
||||||
|
{
|
||||||
|
// Part contour starting at a nonzero origin (offset geometry).
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(12, 7);
|
||||||
|
program.LineTo(18, 7);
|
||||||
|
program.LineTo(18, 11);
|
||||||
|
program.LineTo(12, 11);
|
||||||
|
program.LineTo(12, 7);
|
||||||
|
|
||||||
|
var part = new NestJobPart("offset", PartGeometrySnapshot.FromProgram(program), 2);
|
||||||
|
var result = Solve(new DefaultPlateNester(), Job(new[] { part }));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, ByPart(result)["offset"].Placed);
|
||||||
|
// The runner's validator guarantees containment and non-overlap for every committed placement.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RunScopedCache_DrawingReusedAcrossTrials()
|
||||||
|
{
|
||||||
|
// 14x9 parts on 30x20: one sheet holds fewer than five, so the runner runs multiple candidate
|
||||||
|
// trials through the same nester instance. The run-scoped drawing cache must keep producing
|
||||||
|
// valid, correctly-attributed placements across trials.
|
||||||
|
var part = new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(14, 9)), 5);
|
||||||
|
var stock = new NestPlateStock("stock", new Size(30, 20), 3);
|
||||||
|
var nester = new DefaultPlateNester();
|
||||||
|
var result = new NestJobRunner(_ => nester).Solve(new NestJob(new[] { part }, new[] { stock }));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "p").Placed);
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
var usage = result.StockUsage.Single();
|
||||||
|
Assert.Equal(2, usage.Used);
|
||||||
|
Assert.Equal(1, usage.Remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyRemnantStrategies_StillResolveThroughAdapter()
|
||||||
|
{
|
||||||
|
// Remnant strategies must keep working through the legacy adapter after the factory change.
|
||||||
|
var part = new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), 2);
|
||||||
|
foreach (var strategy in new[] { "Vertical Remnant", "Horizontal Remnant" })
|
||||||
|
{
|
||||||
|
var result = Solve(PlateNesterFactory.Create(strategy), Job(new[] { part }, strategy: strategy));
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, result.Fulfillment.Single(f => f.PartId == "p").Placed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,8 +219,10 @@ namespace OpenNest.Engine.Fill
|
|||||||
if (strips.Count <= 1)
|
if (strips.Count <= 1)
|
||||||
return;
|
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)));
|
strips.Sort((a, b) => sortMetric(a).CompareTo(sortMetric(b)));
|
||||||
|
|
||||||
var pos = primaryEdge(parts[0].BoundingBox);
|
var pos = primaryEdge(parts[0].BoundingBox);
|
||||||
@@ -236,7 +238,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
part.Offset(offset);
|
part.Offset(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
pos = stripMax(s) + gap;
|
pos = stripMax(s) + spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.Clear();
|
parts.Clear();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges legacy <see cref="IProgress{NestProgress}"/> reporting into job progress while a candidate
|
||||||
|
/// trial is being evaluated. Used by both the legacy adapter and the migrated built-in nesters so the
|
||||||
|
/// stage/context mapping has one implementation.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CandidateProgressBridge
|
||||||
|
{
|
||||||
|
internal static IProgress<NestProgress> Create(IProgress<NestJobProgress> progress, string stockId)
|
||||||
|
{
|
||||||
|
if (progress == null) return null;
|
||||||
|
return new LegacyToJob(progress, stockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LegacyToJob(IProgress<NestJobProgress> progress, string stockId) : IProgress<NestProgress>
|
||||||
|
{
|
||||||
|
public void Report(NestProgress value)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
progress.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stockId, -1, 0, 0, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migrated built-in placement strategy for the whole-job runner. Reuses <see cref="DefaultNestEngine"/>
|
||||||
|
/// fill/pack geometry but owns its own run-scoped bookkeeping: remaining demand is read from the
|
||||||
|
/// request and placement counts are derived from returned placements, so the engine's private
|
||||||
|
/// <see cref="NestItem.Quantity"/> mutations never feed back into job accounting.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A private <see cref="Drawing"/> per requirement is created once per solve and reused across every
|
||||||
|
/// candidate trial (the runner reuses one <see cref="IPlateNester"/> instance per job). This is safe
|
||||||
|
/// because the engines mutate <see cref="NestItem.Quantity"/> (per-trial) and canonical-frame copies,
|
||||||
|
/// never the shared <see cref="Drawing"/> or its <c>Quantity</c>. Identity is by Drawing reference,
|
||||||
|
/// never by name. Each trial still gets a fresh private <see cref="Plate"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DefaultPlateNester : IPlateNester
|
||||||
|
{
|
||||||
|
private readonly Func<Plate, DefaultNestEngine> engineFactory;
|
||||||
|
private readonly Dictionary<string, Drawing> drawingsById = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<Drawing, string> idByDrawing = new(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
public DefaultPlateNester() : this(static plate => new DefaultNestEngine(plate))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="engineFactory">Injectable for tests; defaults to <see cref="DefaultNestEngine"/>.</param>
|
||||||
|
public DefaultPlateNester(Func<Plate, DefaultNestEngine> engineFactory)
|
||||||
|
{
|
||||||
|
this.engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var plate = DrawingJobMapper.CreatePlate(request.Stock);
|
||||||
|
var items = new List<NestItem>(request.Parts.Count);
|
||||||
|
foreach (var requirement in request.Parts)
|
||||||
|
{
|
||||||
|
if (!drawingsById.TryGetValue(requirement.Id, out var drawing))
|
||||||
|
{
|
||||||
|
drawing = DrawingJobMapper.CreateDrawing(requirement);
|
||||||
|
drawingsById.Add(requirement.Id, drawing);
|
||||||
|
idByDrawing.Add(drawing, requirement.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantity is the request's remaining demand; the engine may mutate this per-trial item,
|
||||||
|
// and that mutation is deliberately discarded — placement counts come from the result.
|
||||||
|
items.Add(new NestItem
|
||||||
|
{
|
||||||
|
Drawing = drawing,
|
||||||
|
Quantity = requirement.Quantity,
|
||||||
|
Priority = requirement.Priority,
|
||||||
|
StepAngle = DrawingJobMapper.LegacyStep(requirement.Rotation),
|
||||||
|
RotationStart = requirement.Rotation.Start,
|
||||||
|
RotationEnd = requirement.Rotation.End
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var engine = engineFactory(plate) ?? throw new InvalidOperationException("Engine factory returned null.");
|
||||||
|
var legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
|
||||||
|
var parts = engine.Nest(items, legacyProgress, token);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (parts == null) throw new InvalidOperationException("Engine returned null placements.");
|
||||||
|
|
||||||
|
var placements = new List<NestJobPlacement>(parts.Count);
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (part?.BaseDrawing == null || !idByDrawing.TryGetValue(part.BaseDrawing, out var id))
|
||||||
|
throw new InvalidOperationException("Placement does not reference a known requirement drawing.");
|
||||||
|
placements.Add(new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PlateCandidate(placements);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migrated built-in placement strategy for the whole-job runner. Reuses <see cref="StripNestEngine"/>
|
||||||
|
/// iterative shrink-fill/pack geometry with the same run-scoped bookkeeping as <see cref="DefaultPlateNester"/>:
|
||||||
|
/// remaining demand is read from the request and placement counts are derived from returned placements.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A private <see cref="Drawing"/> per requirement is created once per solve and reused across trials
|
||||||
|
/// (safe: the engine mutates per-trial <see cref="NestItem.Quantity"/> and canonical copies, never the
|
||||||
|
/// shared Drawing). Identity is by Drawing reference. Each trial gets a fresh private <see cref="Plate"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class StripPlateNester : IPlateNester
|
||||||
|
{
|
||||||
|
private readonly Func<Plate, StripNestEngine> engineFactory;
|
||||||
|
private readonly Dictionary<string, Drawing> drawingsById = new(StringComparer.Ordinal);
|
||||||
|
private readonly Dictionary<Drawing, string> idByDrawing = new(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
public StripPlateNester() : this(static plate => new StripNestEngine(plate))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="engineFactory">Injectable for tests; defaults to <see cref="StripNestEngine"/>.</param>
|
||||||
|
public StripPlateNester(Func<Plate, StripNestEngine> engineFactory)
|
||||||
|
{
|
||||||
|
this.engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var plate = DrawingJobMapper.CreatePlate(request.Stock);
|
||||||
|
var items = new List<NestItem>(request.Parts.Count);
|
||||||
|
foreach (var requirement in request.Parts)
|
||||||
|
{
|
||||||
|
if (!drawingsById.TryGetValue(requirement.Id, out var drawing))
|
||||||
|
{
|
||||||
|
drawing = DrawingJobMapper.CreateDrawing(requirement);
|
||||||
|
drawingsById.Add(requirement.Id, drawing);
|
||||||
|
idByDrawing.Add(drawing, requirement.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(new NestItem
|
||||||
|
{
|
||||||
|
Drawing = drawing,
|
||||||
|
Quantity = requirement.Quantity,
|
||||||
|
Priority = requirement.Priority,
|
||||||
|
StepAngle = DrawingJobMapper.LegacyStep(requirement.Rotation),
|
||||||
|
RotationStart = requirement.Rotation.Start,
|
||||||
|
RotationEnd = requirement.Rotation.End
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var engine = engineFactory(plate) ?? throw new InvalidOperationException("Engine factory returned null.");
|
||||||
|
var legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id);
|
||||||
|
var parts = engine.Nest(items, legacyProgress, token);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (parts == null) throw new InvalidOperationException("Engine returned null placements.");
|
||||||
|
|
||||||
|
var placements = new List<NestJobPlacement>(parts.Count);
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (part?.BaseDrawing == null || !idByDrawing.TryGetValue(part.BaseDrawing, out var id))
|
||||||
|
throw new InvalidOperationException("Placement does not reference a known requirement drawing.");
|
||||||
|
placements.Add(new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PlateCandidate(placements);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ using System;
|
|||||||
namespace OpenNest;
|
namespace OpenNest;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Instance-scoped strategy resolution for the whole-job runner. The built-in strategies map
|
/// Instance-scoped strategy resolution for the whole-job runner. Default and Strip resolve to the
|
||||||
/// to private engine factories; the process-global NestEngineRegistry (including plugin
|
/// migrated built-in plate nesters; the remnant strategies still use the legacy adapter during
|
||||||
/// registrations and ActiveEngineName) is neither read nor modified. Unknown keys reject.
|
/// rollout. The process-global NestEngineRegistry (including plugin registrations and
|
||||||
|
/// ActiveEngineName) is neither read nor modified. Unknown keys reject.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PlateNesterFactory
|
public static class PlateNesterFactory
|
||||||
{
|
{
|
||||||
@@ -13,8 +14,8 @@ public static class PlateNesterFactory
|
|||||||
ArgumentNullException.ThrowIfNull(strategy);
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
return strategy switch
|
return strategy switch
|
||||||
{
|
{
|
||||||
"Default" => new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate)),
|
"Default" => new DefaultPlateNester(),
|
||||||
"Strip" => new LegacyPlateNesterAdapter(plate => new StripNestEngine(plate)),
|
"Strip" => new StripPlateNester(),
|
||||||
"Vertical Remnant" => new LegacyPlateNesterAdapter(plate => new VerticalRemnantEngine(plate)),
|
"Vertical Remnant" => new LegacyPlateNesterAdapter(plate => new VerticalRemnantEngine(plate)),
|
||||||
"Horizontal Remnant" => new LegacyPlateNesterAdapter(plate => new HorizontalRemnantEngine(plate)),
|
"Horizontal Remnant" => new LegacyPlateNesterAdapter(plate => new HorizontalRemnantEngine(plate)),
|
||||||
_ => throw new NotSupportedException($"Unknown placement strategy: {strategy}.")
|
_ => throw new NotSupportedException($"Unknown placement strategy: {strategy}.")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Engine.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ namespace OpenNest.IO
|
|||||||
public ExportContext()
|
public ExportContext()
|
||||||
{
|
{
|
||||||
Document = new CadDocument();
|
Document = new CadDocument();
|
||||||
|
Document.Header.Version = ACadVersion.AC1018;
|
||||||
|
|
||||||
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
||||||
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
|
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;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Ports;
|
using System.IO.Ports;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Posts.GravographIS
|
namespace OpenNest.Posts.GravographIS
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
|
/// 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)"/>.
|
/// 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>
|
/// </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 Name => "Gravograph IS8000";
|
||||||
public string Author => "OpenNest";
|
public string Author => "OpenNest";
|
||||||
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
|
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 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)
|
public void Post(Nest nest, Stream outputStream)
|
||||||
{
|
{
|
||||||
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||||
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
|
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
|
||||||
|
|
||||||
var polylines = Extractor.Extract(nest);
|
var passes = BuildPasses(Extractor.ExtractLayered(nest));
|
||||||
var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
|
new GravographISWriter(WriterOptions).Write(passes, outputStream);
|
||||||
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Post(Nest nest, string outputFile)
|
public void Post(Nest nest, string outputFile)
|
||||||
@@ -39,6 +92,52 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
Post(nest, fs);
|
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>
|
/// <summary>
|
||||||
/// Buffers the encoded job in memory, then streams it to the named COM port.
|
/// Buffers the encoded job in memory, then streams it to the named COM port.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Posts.GravographIS
|
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>
|
/// <summary>
|
||||||
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
|
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
|
||||||
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
|
/// 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)
|
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
|
||||||
{
|
{
|
||||||
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
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));
|
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();
|
var preamble = (byte[])PreambleTemplate.Clone();
|
||||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)Options.FeedMmPerSec);
|
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
|
||||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
|
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
|
||||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
|
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
|
||||||
output.Write(preamble, 0, preamble.Length);
|
output.Write(preamble, 0, preamble.Length);
|
||||||
|
|
||||||
// Cumulative head position from the operator-set upper-left origin, in
|
// Cumulative head position from the operator-set upper-left origin, in
|
||||||
@@ -105,45 +153,51 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
|
|
||||||
var firstPolyline = true;
|
var firstPolyline = true;
|
||||||
var polyIndex = 0;
|
var polyIndex = 0;
|
||||||
|
var currentFeed = firstFeed;
|
||||||
|
var currentDepth = firstDepth;
|
||||||
|
|
||||||
foreach (var poly in polylines)
|
for (var p = 0; p < passes.Count; p++)
|
||||||
{
|
{
|
||||||
polyIndex++;
|
var pass = passes[p];
|
||||||
if (poly == null || poly.Count < 2)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var (startX, startY) = ToWire(poly[0]);
|
if (p > 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]);
|
if (pass.PauseBefore)
|
||||||
var dx = checked(cx - prevX);
|
{
|
||||||
var dy = checked(cy - prevY);
|
// Park: lift Z, then rapid (pen-up) back to the operator origin so
|
||||||
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
// the head is clear of the work while the tool is swapped.
|
||||||
polyIndex, segment: i, isTravel: false);
|
WriteLiftOnly(output);
|
||||||
WriteRecord(output, dx, dy);
|
WriteTravel(output, (byte)'P', (byte)'U',
|
||||||
prevX = cx;
|
checked(-headX), checked(-headY),
|
||||||
prevY = cy;
|
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||||
headX += dx;
|
WritePauseCore(output, pass.PauseMessage);
|
||||||
headY += dy;
|
}
|
||||||
|
|
||||||
|
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);
|
WriteLiftOnly(output);
|
||||||
@@ -156,6 +210,89 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
output.Write(EndJobBytes, 0, EndJobBytes.Length);
|
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 const double StepsPerMm = 80.0;
|
||||||
|
|
||||||
private void EnsureEnvelope(int wireX, int wireY,
|
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.");
|
$"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)
|
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;
|
return (short)steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Posts.GravographIS
|
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>
|
/// <summary>
|
||||||
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
|
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
|
||||||
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
|
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
|
||||||
/// polylines at rapid moves, and tessellates arcs to a chord-deviation
|
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and
|
||||||
/// tolerance (the wire format takes line segments only).
|
/// tessellates arcs to a chord-deviation tolerance (the wire format takes
|
||||||
|
/// line segments only).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NestPolylineExtractor
|
public sealed class NestPolylineExtractor
|
||||||
{
|
{
|
||||||
@@ -17,13 +37,31 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts polylines from every non-cutoff part in every plate of the nest,
|
/// 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>
|
/// </summary>
|
||||||
public List<List<Vector>> Extract(Nest nest)
|
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));
|
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)
|
foreach (var plate in nest.Plates)
|
||||||
{
|
{
|
||||||
@@ -40,17 +78,17 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts polylines for a single part. Public so callers driving the
|
/// Extracts layer-tagged polylines for a single part. Public so callers
|
||||||
/// writer directly (e.g. from a console one-off) can use it.
|
/// driving the writer directly (e.g. from a console one-off) can use it.
|
||||||
/// </summary>
|
/// </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);
|
ExtractPart(part, list);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ExtractPart(Part part, List<List<Vector>> sink)
|
private void ExtractPart(Part part, List<LayeredPolyline> sink)
|
||||||
{
|
{
|
||||||
var program = part.Program;
|
var program = part.Program;
|
||||||
if (program == null) return;
|
if (program == null) return;
|
||||||
@@ -67,6 +105,7 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
var offset = part.Location;
|
var offset = part.Location;
|
||||||
var pos = new Vector(0, 0);
|
var pos = new Vector(0, 0);
|
||||||
List<Vector> current = null;
|
List<Vector> current = null;
|
||||||
|
var currentLayer = LayerType.Cut;
|
||||||
|
|
||||||
foreach (var code in program.Codes)
|
foreach (var code in program.Codes)
|
||||||
{
|
{
|
||||||
@@ -77,17 +116,14 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
{
|
{
|
||||||
case RapidMove rapid:
|
case RapidMove rapid:
|
||||||
{
|
{
|
||||||
FlushCurrent(sink, ref current);
|
FlushCurrent(sink, ref current, currentLayer);
|
||||||
pos = rapid.EndPoint;
|
pos = rapid.EndPoint;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case LinearMove linear:
|
case LinearMove linear:
|
||||||
{
|
{
|
||||||
if (current == null)
|
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
|
||||||
{
|
|
||||||
current = new List<Vector> { pos + offset };
|
|
||||||
}
|
|
||||||
var end = linear.EndPoint;
|
var end = linear.EndPoint;
|
||||||
current.Add(end + offset);
|
current.Add(end + offset);
|
||||||
pos = end;
|
pos = end;
|
||||||
@@ -96,10 +132,7 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
|
|
||||||
case ArcMove arc:
|
case ArcMove arc:
|
||||||
{
|
{
|
||||||
if (current == null)
|
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
|
||||||
{
|
|
||||||
current = new List<Vector> { pos + offset };
|
|
||||||
}
|
|
||||||
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
|
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
|
||||||
pos = arc.EndPoint;
|
pos = arc.EndPoint;
|
||||||
break;
|
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)
|
if (current != null && current.Count >= 2)
|
||||||
sink.Add(current);
|
sink.Add(new LayeredPolyline(current, layer));
|
||||||
current = null;
|
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]);
|
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]
|
[Fact]
|
||||||
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
|
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);
|
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)
|
private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < bytes.Length - 5; i++)
|
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, 47.75), poly[3]);
|
||||||
Assert.Equal(new Vector(0.25, 46.75), poly[4]);
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
||||||
EndProject
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
@@ -178,18 +179,6 @@ Global
|
|||||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.Build.0 = Release|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = 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
|
{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|x64.Build.0 = Release|Any CPU
|
||||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = 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
|
{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.ActiveCfg = Debug|Any CPU
|
||||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.Build.0 = 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
|
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj
|
|||||||
|
|
||||||
`OpenNest.Engine.Tests` targets `net8.0` and runs on Linux, macOS, and Windows without the desktop project or local DXF fixtures. The existing `OpenNest.Tests` suite still requires Windows.
|
`OpenNest.Engine.Tests` targets `net8.0` and runs on Linux, macOS, and Windows without the desktop project or local DXF fixtures. The existing `OpenNest.Tests` suite still requires Windows.
|
||||||
|
|
||||||
The new whole-job contracts in `OpenNest.Engine/Jobs` (`namespace OpenNest`) use owned immutable geometry/settings, explicit part IDs and positive demand, finite or unlimited stock (`null` means unlimited; zero means unavailable), and result ID/pose values rather than mutable desktop models. Rotation is in radians about the geometry origin, followed by translation into the plate quadrant frame. Strategy factories belong to each runner, not the global registry.
|
The new whole-job contracts in `OpenNest.Engine/Jobs` (`namespace OpenNest`) use owned immutable geometry/settings, explicit part IDs and positive demand, finite or unlimited stock (`null` means unlimited; zero means unavailable), and result ID/pose values rather than mutable desktop models. Callers own their inputs: the job copies everything at entry and the result leaks no mutable `Drawing`, `Plate`, or `NestItem`. One job is one material/thickness/unit system — no cross-material pooling. Rotation is in radians about the geometry origin, followed by translation into the plate quadrant frame. Strategy factories belong to each runner, not the global registry. In the public API, the legacy `SheetSize` request field is the unlimited-stock fallback only when `Plates` is null; an explicit empty `Plates` list means no available stock.
|
||||||
|
|
||||||
**Current scope (minimum runnable slice, not release-ready):** `NestJobRunner.Solve` allocates one stock entry across multiple physical sheets, respecting finite inventory (`null` is unlimited), positive remaining demand, and `MaxPlates`. Empty parts complete without consuming stock; empty/unavailable stock returns `Incomplete/StockExhausted`. A zero-placement candidate stops with `NoPlacementFound` and consumes no sheet. Nonempty jobs with multiple stock entries explicitly throw `NotSupportedException`; mixed-stock selection and optional optimizations are not implemented.
|
`NestJobRunner.Solve` allocates a job across physical sheets from the full stock inventory: every available stock entry is trialled independently each iteration, and only the winning candidate consumes a sheet or reduces demand. Selection is a documented deterministic greedy policy — lexicographic placed-count vector by ascending part priority, then lower consumed sheet area, then smaller placement envelope, then original stock input order (see `NestJobCandidateComparer`). It is a tie policy, not a guarantee of global-minimum material or plate count. Finite stock is never exceeded; `MaxPlates` caps sheet count; empty parts complete without consuming stock; empty or fully exhausted stock returns `Incomplete/StockExhausted`; a zero-placement candidate stops with `NoPlacementFound` and consumes no sheet.
|
||||||
|
|
||||||
`DrawingJobMapper` snapshots caller drawings/items under explicit requirement IDs. `LegacyPlateNesterAdapter` creates fresh private legacy drawings, items, and plates for each trial and maps returned drawings **by reference**, never by name. Mutable legacy quantities never drive the fulfillment ledger. `PlateNesterFactory` resolves the built-in strategies (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`) to private engine factories; it neither reads nor changes the process-global `NestEngineRegistry`, and unknown keys reject. Quantity deduction in the engine paths the runner reaches (base-class fill/pack, strip deduction, remnant-fill ledger, shrink-leftover counting) is keyed by drawing reference, not display name, so same-name drawings and repeated requirements stay independent. `NestResultMaterializer` returns a detached domain nest and `DrawingsByPartId` identity map. Each output plate represents one physical sheet (`Quantity = 1`), and each placement is attached exactly once so domain quantity events do not double count.
|
`DrawingJobMapper` snapshots caller drawings/items under explicit requirement IDs. `LegacyPlateNesterAdapter` creates fresh private legacy drawings, items, and plates for each trial and maps returned drawings **by reference**, never by name. Mutable legacy quantities never drive the fulfillment ledger. `PlateNesterFactory` resolves the built-in strategy names (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`) to instance-scoped placement strategies; it neither reads nor changes the process-global `NestEngineRegistry`, and unknown keys reject. Quantity deduction in the engine paths the runner reaches (base-class fill/pack, strip deduction, remnant-fill ledger, shrink-leftover counting) is keyed by drawing reference, not display name, so same-name drawings and repeated requirements stay independent. `NestResultMaterializer` returns a detached domain nest and `DrawingsByPartId` identity map. Each output plate represents one physical sheet (`Quantity = 1`), and each placement is attached exactly once so domain quantity events do not double count.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
var job = new NestJob(
|
var job = new NestJob(
|
||||||
@@ -88,7 +88,11 @@ var domainResult = NestResultMaterializer.Materialize(job, result);
|
|||||||
// domainResult.Nest and domainResult.DrawingsByPartId are detached from caller objects.
|
// domainResult.Nest and domainResult.DrawingsByPartId are detached from caller objects.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Unfinished safety boundary:** basic validation rejects invalid dimensions/settings, nonfinite geometry, unknown candidate IDs, nonfinite poses, and overproduction. Cancellation throws initially and immediately after the engine returns; no partial result is returned. Full contour validity, plate containment, allowed-rotation enforcement, inter-part overlap/clearance validation, and broader cancellation coverage remain task 5 work. A committed candidate is therefore **not yet certified safe for cutting**, even if the legacy engine reports success. Geometry snapshots preserve flat CNC rapid/line/arc programs, including origin and hole contours, without approximation; other instructions are explicitly rejected. Existing desktop, API, CLI, and MCP nesting paths are unchanged.
|
**Safety gate:** before the runner commits any candidate, `NestJobPlacementValidator` re-checks it against the immutable job geometry: closed usable contours, finite poses, the requirement's rotation policy (automatic / fixed / bounded sweep with step), containment inside the per-quadrant usable work area, hole-aware material overlap, and required part spacing (touching is allowed at zero spacing, rejected at positive spacing). Malformed engine output fails explicitly without consuming stock or demand. Cancellation throws `OperationCanceledException` before each trial and immediately after each engine return; no half-committed state is returned. An `Incomplete` result means the heuristic stopped, not that the geometry is impossible — the stop reason says why. Geometry snapshots preserve flat CNC rapid/line/arc programs, including origin and hole contours, without approximation; other instructions are explicitly rejected.
|
||||||
|
|
||||||
|
**Placement strategies:** `Default` and `Strip` are migrated built-ins (`OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs`, `StripPlateNester.cs`) that reuse the engine geometry while keeping demand read-only; the remnant strategies still run through `LegacyPlateNesterAdapter` during rollout. A runnable end-to-end example — multiple requirements, mixed finite/unlimited stock, full plate/leftover enumeration — lives in `OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs`.
|
||||||
|
|
||||||
|
**Legacy caller boundaries (not yet migrated):** the desktop UI (`MainForm.RunAutoNestAsync` / `NestSinglePlateAsync`), the CLI (`OpenNest.Console`), and MCP (`NestingTools`) still call the old single-plate `engine.Nest(...)` entry points unchanged. UI adoption needs a separate adapter preserving populated-plate editing, preview routing, and Accept-versus-Cancel semantics. The public API (`OpenNest.Api`, `NestRunner.RunAsync`) already delegates to one `NestJobRunner.Solve` call and reports status, stop reason, part fulfillment, stock usage, and plate-to-stock mapping; `.nestquote` archives carry a schema version and round-trip incomplete jobs.
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
|
|
||||||
@@ -162,6 +166,23 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
|||||||
| `--no-save` | Skip saving the output file |
|
| `--no-save` | Skip saving the output file |
|
||||||
| `--no-log` | Skip writing the debug log |
|
| `--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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -176,6 +197,7 @@ OpenNest.sln
|
|||||||
├── OpenNest.Data/ # Machine configuration and cutting parameters
|
├── OpenNest.Data/ # Machine configuration and cutting parameters
|
||||||
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
|
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
|
||||||
├── OpenNest.Training/ # ML training data collection (SQLite + EF Core)
|
├── 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.Mcp/ # MCP server for AI tool integration
|
||||||
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
|
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
|
||||||
└── OpenNest.Tests/ # Unit tests (xUnit)
|
└── OpenNest.Tests/ # Unit tests (xUnit)
|
||||||
@@ -193,6 +215,7 @@ OpenNest.sln
|
|||||||
| **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. |
|
| **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.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.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. |
|
| **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. |
|
||||||
|
|
||||||
## Nesting Engines
|
## Nesting Engines
|
||||||
|
|||||||
Reference in New Issue
Block a user