Compare commits
24
Commits
587000f68a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d27652f4 | ||
|
|
9b69c67572 | ||
|
|
aa88eee484 | ||
|
|
e0e3b96bed | ||
|
|
424ff15ebc | ||
|
|
9888fe6083 | ||
|
|
a2dcfc7484 | ||
|
|
ae704478af | ||
|
|
ecca71e185 | ||
|
|
f0fe79f0f1 | ||
|
|
a68e252ac7 | ||
|
|
bc6bcae419 | ||
|
|
02141f6ca5 | ||
|
|
ad69023c17 | ||
|
|
2b0b962c8f | ||
|
|
75c8adc76c | ||
|
|
67f5fb8eca | ||
|
|
5b88d85937 | ||
|
|
0963b051be | ||
|
|
71dffce72c | ||
|
|
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
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Architecture
|
||||
|
||||
Eight projects form a layered architecture:
|
||||
Nine projects form a layered architecture:
|
||||
|
||||
### OpenNest.Core (class library)
|
||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
@@ -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.
|
||||
|
||||
### 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`.
|
||||
- **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`.
|
||||
@@ -69,6 +73,16 @@ GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` impleme
|
||||
### OpenNest.Training (console app, depends on Core + Engine)
|
||||
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
|
||||
|
||||
### OpenNest.Benchmark (console app, depends on Core + Engine + IO)
|
||||
Compares registered `INestingEngine` implementations against each other on real `.nest` files. Each engine solves the whole job — it owns its own multi-plate/size strategy rather than being handed one already-sized plate at a time. Fully generic — it never hardcodes drawing geometry, just reads whatever drawings/quantities/plate settings each input file already has.
|
||||
|
||||
- `JobLoader` builds `BenchmarkJob`s from a `.nest` file or a folder of them via `NestReader`, using every drawing with `Quantity.Required > 0`. `--sheet-sizes` can sweep a fixed list of plate sizes instead of each file's own.
|
||||
- `BenchmarkJob.BuildNestJob(maxPlates)` converts the job into a `NestJob`: one `NestJobPart` per requested drawing (via `DrawingJobMapper.FromDrawing`) and one `NestPlateStock` per candidate sheet size (unlimited quantity — the engine decides how many of each size it uses).
|
||||
- `BenchmarkRunner` calls each engine's `INestingEngine.Solve(NestJob)` once per job, under a wall-clock timeout so a runaway or hanging engine can't stall the whole benchmark run, then materializes the result back into legacy `Plate`/`Part` objects via `NestResultMaterializer` for scoring.
|
||||
- `NestValidator` checks the returned layout: every part inside `Plate.WorkArea()`, every pair at least `Plate.PartSpacing` apart (checked geometrically via each part's own world-space polygon, inflated by the spacing — works on arbitrary concave/holed shapes, not just bounding boxes), and no drawing over its requested quantity. An invalid, throwing, or timed-out run scores zero for that job.
|
||||
- Scoring matches `Plate.Utilization()` (placed drawing area / full sheet area, `Plate.Area()`). If an engine placed every requested part, ties are broken by fewer plates used (`Report`'s ranking rule) — using fewer sheets to do the same job wastes less material.
|
||||
- `--engines Name1,Name2` filters to specific registered engines (default: all); `--csv <path>` writes a flat per-job CSV alongside the console report.
|
||||
|
||||
### OpenNest.Mcp (console app, depends on Core + Engine + IO)
|
||||
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
|
||||
|
||||
@@ -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).
|
||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
||||
|
||||
@@ -6,10 +6,18 @@ namespace OpenNest.Api;
|
||||
public class NestRequest
|
||||
{
|
||||
public IReadOnlyList<NestRequestPart> Parts { get; init; } = [];
|
||||
/// <summary>
|
||||
/// Explicit available physical stock. Null keeps the legacy unlimited SheetSize fallback;
|
||||
/// an empty list deliberately means no stock is available.
|
||||
/// </summary>
|
||||
public IReadOnlyList<NestRequestPlate> Plates { get; init; }
|
||||
public Size SheetSize { get; init; } = new(60, 120);
|
||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||
public string PlacementStrategy { get; init; } = "Default";
|
||||
public string Material { get; init; } = "Steel, A1011 HR";
|
||||
public double Thickness { get; init; } = 0.06;
|
||||
public double Spacing { get; init; } = 0.1;
|
||||
/// <summary>Legacy compatibility setting; Auto maps to the Default whole-job strategy.</summary>
|
||||
public NestStrategy Strategy { get; init; } = NestStrategy.Auto;
|
||||
public CutParameters Cutting { get; init; } = CutParameters.Default;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ namespace OpenNest.Api;
|
||||
|
||||
public class NestRequestPart
|
||||
{
|
||||
/// <summary>Optional stable requirement identity. NestRunner derives part-{requestIndex} when omitted.</summary>
|
||||
public string Id { get; init; }
|
||||
public string DxfPath { get; init; }
|
||||
public int Quantity { get; init; } = 1;
|
||||
public bool AllowRotation { get; init; } = true;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
|
||||
public class NestRequestPlate
|
||||
{
|
||||
public string Id { get; init; }
|
||||
public Size Size { get; init; }
|
||||
/// <summary>Available physical sheets; null means unlimited.</summary>
|
||||
public int? Quantity { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
public Spacing EdgeSpacing { get; init; }
|
||||
public int Quadrant { get; init; } = 1;
|
||||
}
|
||||
@@ -1,18 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
/// <summary>Stable fulfillment metadata for one requested part identity.</summary>
|
||||
public sealed record NestPartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
|
||||
|
||||
/// <summary>Physical-sheet usage for one stock identity.</summary>
|
||||
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
||||
|
||||
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
|
||||
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||
|
||||
public class NestResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 2;
|
||||
|
||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
public int SheetCount { get; init; }
|
||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||
public double Utilization { get; init; }
|
||||
public TimeSpan CutTime { get; init; }
|
||||
public TimeSpan Elapsed { get; init; }
|
||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||
public NestJobStatus? Status { get; init; }
|
||||
public NestJobStopReason? StopReason { get; init; }
|
||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
public Nest Nest { get; init; }
|
||||
public NestRequest Request { get; init; }
|
||||
|
||||
@@ -20,7 +42,8 @@ public class NestResponse
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
IncludeFields = true // Required for OpenNest.Geometry.Size (public fields)
|
||||
IncludeFields = true, // Required for OpenNest.Geometry.Size and Spacing public fields.
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public async Task SaveAsync(string path)
|
||||
@@ -28,32 +51,34 @@ public class NestResponse
|
||||
using var fs = new FileStream(path, FileMode.Create);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
|
||||
|
||||
// Write request.json
|
||||
var requestEntry = zip.CreateEntry("request.json");
|
||||
await using (var stream = requestEntry.Open())
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, Request, JsonOptions);
|
||||
}
|
||||
|
||||
// Write response.json (metrics only)
|
||||
var metrics = new
|
||||
{
|
||||
SheetCount,
|
||||
Utilization,
|
||||
CutTimeTicks = CutTime.Ticks,
|
||||
ElapsedTicks = Elapsed.Ticks
|
||||
};
|
||||
// Keep persisted data versioned and detached from the live mutable Nest graph.
|
||||
var responseEntry = zip.CreateEntry("response.json");
|
||||
await using (var stream = responseEntry.Open())
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, metrics, JsonOptions);
|
||||
await JsonSerializer.SerializeAsync(stream, new NestResponseArchiveDto
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
SheetCount = SheetCount,
|
||||
Utilization = Utilization,
|
||||
CutTimeTicks = CutTime.Ticks,
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
Fulfillment = Fulfillment is null ? [] : new List<NestPartFulfillment>(Fulfillment),
|
||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||
PlateStockMappings = PlateStockMappings is null ? [] : new List<NestPlateStockMapping>(PlateStockMappings)
|
||||
}, JsonOptions);
|
||||
}
|
||||
|
||||
// Write embedded nest.nest via NestWriter → MemoryStream → ZIP entry
|
||||
var nestEntry = zip.CreateEntry("nest.nest");
|
||||
using var nestMs = new MemoryStream();
|
||||
var writer = new NestWriter(Nest);
|
||||
writer.Write(nestMs);
|
||||
new NestWriter(Nest).Write(nestMs);
|
||||
nestMs.Position = 0;
|
||||
await using (var stream = nestEntry.Open())
|
||||
{
|
||||
@@ -66,25 +91,34 @@ public class NestResponse
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Read);
|
||||
|
||||
// Read request.json
|
||||
var requestEntry = zip.GetEntry("request.json")
|
||||
?? throw new InvalidOperationException("Missing request.json in .nestquote file");
|
||||
NestRequest request;
|
||||
await using (var stream = requestEntry.Open())
|
||||
{
|
||||
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions);
|
||||
request = await JsonSerializer.DeserializeAsync<NestRequest>(stream, JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid request.json in .nestquote file");
|
||||
}
|
||||
|
||||
// Read response.json
|
||||
var responseEntry = zip.GetEntry("response.json")
|
||||
?? throw new InvalidOperationException("Missing response.json in .nestquote file");
|
||||
JsonElement metricsJson;
|
||||
NestResponseArchiveDto archive;
|
||||
var hasSchemaVersion = false;
|
||||
var hasStatusMetadata = false;
|
||||
await using (var stream = responseEntry.Open())
|
||||
using (var document = await JsonDocument.ParseAsync(stream))
|
||||
{
|
||||
metricsJson = await JsonSerializer.DeserializeAsync<JsonElement>(stream, JsonOptions);
|
||||
var root = document.RootElement;
|
||||
hasSchemaVersion = root.TryGetProperty("schemaVersion", out _);
|
||||
hasStatusMetadata = root.TryGetProperty("status", out _) ||
|
||||
root.TryGetProperty("stopReason", out _) ||
|
||||
root.TryGetProperty("fulfillment", out _) ||
|
||||
root.TryGetProperty("stockUsage", out _) ||
|
||||
root.TryGetProperty("plateStockMappings", out _);
|
||||
archive = root.Deserialize<NestResponseArchiveDto>(JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid response.json in .nestquote file");
|
||||
}
|
||||
|
||||
// Read embedded nest.nest via NestReader(Stream)
|
||||
var nestEntry = zip.GetEntry("nest.nest")
|
||||
?? throw new InvalidOperationException("Missing nest.nest in .nestquote file");
|
||||
Nest nest;
|
||||
@@ -95,18 +129,37 @@ public class NestResponse
|
||||
await stream.CopyToAsync(nestMs);
|
||||
}
|
||||
nestMs.Position = 0;
|
||||
var reader = new NestReader(nestMs);
|
||||
nest = reader.Read();
|
||||
nest = new NestReader(nestMs).Read();
|
||||
}
|
||||
|
||||
return new NestResponse
|
||||
{
|
||||
SheetCount = metricsJson.GetProperty("sheetCount").GetInt32(),
|
||||
Utilization = metricsJson.GetProperty("utilization").GetDouble(),
|
||||
CutTime = TimeSpan.FromTicks(metricsJson.GetProperty("cutTimeTicks").GetInt64()),
|
||||
Elapsed = TimeSpan.FromTicks(metricsJson.GetProperty("elapsedTicks").GetInt64()),
|
||||
SchemaVersion = hasSchemaVersion ? archive.SchemaVersion : 0,
|
||||
SheetCount = archive.SheetCount,
|
||||
Utilization = archive.Utilization,
|
||||
CutTime = TimeSpan.FromTicks(archive.CutTimeTicks),
|
||||
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
||||
Status = hasStatusMetadata ? archive.Status : null,
|
||||
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
Nest = nest,
|
||||
Request = request
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class NestResponseArchiveDto
|
||||
{
|
||||
public int SchemaVersion { get; init; }
|
||||
public int SheetCount { get; init; }
|
||||
public double Utilization { get; init; }
|
||||
public long CutTimeTicks { get; init; }
|
||||
public long ElapsedTicks { get; init; }
|
||||
public NestJobStatus? Status { get; init; }
|
||||
public NestJobStopReason? StopReason { get; init; }
|
||||
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
+133
-91
@@ -11,125 +11,167 @@ namespace OpenNest.Api;
|
||||
|
||||
public static class NestRunner
|
||||
{
|
||||
private const string LegacyStockId = "legacy-sheet";
|
||||
|
||||
public static Task<NestResponse> RunAsync(
|
||||
NestRequest request,
|
||||
IProgress<NestProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
if (request.Parts.Count == 0)
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var requestParts = request.Parts ?? throw new ArgumentException("Request parts must not be null.", nameof(request));
|
||||
if (requestParts.Count == 0)
|
||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = IdentifyParts(requestParts);
|
||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
var jobParts = new List<NestJobPart>(parts.Count);
|
||||
|
||||
// 1. Import DXFs → Drawings
|
||||
var drawings = new List<Drawing>();
|
||||
foreach (var part in request.Parts)
|
||||
{
|
||||
if (!File.Exists(part.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||
|
||||
Drawing drawing;
|
||||
try
|
||||
{
|
||||
drawing = CadImporter.ImportDrawing(part.DxfPath,
|
||||
new CadImportOptions { Quantity = part.Quantity });
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to import DXF: {part.DxfPath}", ex);
|
||||
}
|
||||
|
||||
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
||||
|
||||
drawings.Add(drawing);
|
||||
}
|
||||
|
||||
// 2. Build NestItems
|
||||
var items = new List<NestItem>();
|
||||
for (var i = 0; i < request.Parts.Count; i++)
|
||||
{
|
||||
var part = request.Parts[i];
|
||||
items.Add(new NestItem
|
||||
{
|
||||
Drawing = drawings[i],
|
||||
Quantity = part.Quantity,
|
||||
Priority = part.Priority,
|
||||
StepAngle = part.AllowRotation ? 0 : OpenNest.Math.Angle.TwoPI,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Multi-plate loop
|
||||
var nest = new Nest();
|
||||
nest.Thickness = request.Thickness;
|
||||
nest.Material = new Material(request.Material);
|
||||
var remaining = items.Select(item => item.Quantity).ToList();
|
||||
|
||||
while (remaining.Any(q => q > 0))
|
||||
foreach (var part in parts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(part.Request.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.Request.DxfPath}", part.Request.DxfPath);
|
||||
|
||||
var plate = new Plate(request.SheetSize)
|
||||
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
|
||||
{
|
||||
PartSpacing = request.Spacing,
|
||||
};
|
||||
|
||||
// Build items for this pass with remaining quantities
|
||||
var passItems = new List<NestItem>();
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (remaining[i] <= 0) continue;
|
||||
passItems.Add(new NestItem
|
||||
try
|
||||
{
|
||||
Drawing = items[i].Drawing,
|
||||
Quantity = remaining[i],
|
||||
Priority = items[i].Priority,
|
||||
StepAngle = items[i].StepAngle,
|
||||
});
|
||||
drawing = CadImporter.ImportDrawing(part.Request.DxfPath,
|
||||
new CadImportOptions { Quantity = part.Request.Quantity });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}", exception);
|
||||
}
|
||||
|
||||
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.Request.DxfPath}");
|
||||
|
||||
importedByPath.Add(part.Request.DxfPath, drawing);
|
||||
}
|
||||
|
||||
// Run engine
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var parts = engine.Nest(passItems, progress, token);
|
||||
|
||||
if (parts.Count == 0)
|
||||
break; // No progress — part doesn't fit on fresh sheet
|
||||
|
||||
// Add parts to plate and nest
|
||||
foreach (var p in parts)
|
||||
plate.Parts.Add(p);
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
// Deduct placed quantities
|
||||
foreach (var p in parts)
|
||||
{
|
||||
var idx = drawings.IndexOf(p.BaseDrawing);
|
||||
if (idx >= 0)
|
||||
remaining[idx]--;
|
||||
}
|
||||
ConfigureDrawingForRequirement(drawing, part.Request);
|
||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
||||
}
|
||||
|
||||
// 4. Compute timing
|
||||
var job = new NestJob(jobParts, CreateStock(request),
|
||||
new NestJobOptions(ResolvePlacementStrategy(request)));
|
||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
|
||||
|
||||
// This is the sole translation from immutable result poses to mutable legacy output objects.
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var nest = materialized.Nest;
|
||||
nest.Thickness = request.Thickness;
|
||||
nest.Material = new Material(request.Material);
|
||||
|
||||
var timingInfo = Timing.GetTimingInfo(nest);
|
||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// 5. Build response
|
||||
var response = new NestResponse
|
||||
return Task.FromResult(new NestResponse
|
||||
{
|
||||
SheetCount = nest.Plates.Count,
|
||||
Utilization = nest.Plates.Count > 0
|
||||
? nest.Plates.Average(p => p.Utilization())
|
||||
: 0,
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Status = result.Status,
|
||||
StopReason = result.StopReason,
|
||||
Fulfillment = result.Fulfillment
|
||||
.Select(value => new NestPartFulfillment(value.PartId, value.Requested, value.Placed, value.Unplaced))
|
||||
.ToArray(),
|
||||
StockUsage = result.StockUsage
|
||||
.Select(value => new NestStockUsage(value.StockId, value.Used, value.Remaining))
|
||||
.ToArray(),
|
||||
PlateStockMappings = result.Plates
|
||||
.Select(value => new NestPlateStockMapping(value.PlateIndex, value.StockId))
|
||||
.ToArray(),
|
||||
Nest = nest,
|
||||
Request = request
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(IReadOnlyList<NestRequestPart> requestParts)
|
||||
{
|
||||
var identified = new List<IdentifiedRequestPart>(requestParts.Count);
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = 0; index < requestParts.Count; index++)
|
||||
{
|
||||
var part = requestParts[index] ?? throw new ArgumentException("Request parts must not contain null entries.", nameof(requestParts));
|
||||
var id = part.Id ?? $"part-{index}";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||
if (!ids.Add(id))
|
||||
throw new ArgumentException("Part IDs must be unique.", nameof(requestParts));
|
||||
identified.Add(new IdentifiedRequestPart(id, part));
|
||||
}
|
||||
|
||||
return identified;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NestPlateStock> CreateStock(NestRequest request)
|
||||
{
|
||||
if (request.Plates is null)
|
||||
{
|
||||
return
|
||||
[
|
||||
new NestPlateStock(LegacyStockId, request.SheetSize, quantity: null,
|
||||
partSpacing: request.Spacing)
|
||||
];
|
||||
}
|
||||
|
||||
var stock = new List<NestPlateStock>(request.Plates.Count);
|
||||
foreach (var plate in request.Plates)
|
||||
{
|
||||
if (plate is null)
|
||||
throw new ArgumentException("Request plates must not contain null entries.", nameof(request));
|
||||
stock.Add(new NestPlateStock(plate.Id, plate.Size, plate.Quantity, plate.PartSpacing,
|
||||
plate.EdgeSpacing, plate.Quadrant));
|
||||
}
|
||||
|
||||
return stock;
|
||||
}
|
||||
|
||||
private static void ConfigureDrawingForRequirement(Drawing drawing, NestRequestPart part)
|
||||
{
|
||||
drawing.Priority = part.Priority;
|
||||
drawing.Constraints ??= new NestConstraints();
|
||||
if (!part.AllowRotation)
|
||||
{
|
||||
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
||||
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
|
||||
drawing.Constraints.StartAngle = 0;
|
||||
drawing.Constraints.EndAngle = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePlacementStrategy(NestRequest request) => request.PlacementStrategy ?? request.Strategy switch
|
||||
{
|
||||
NestStrategy.Auto => "Default",
|
||||
_ => throw new NotSupportedException($"Unknown legacy nesting strategy: {request.Strategy}.")
|
||||
};
|
||||
|
||||
private static double CalculateUtilization(Nest nest)
|
||||
{
|
||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
||||
if (sheetArea == 0) return 0;
|
||||
var placedArea = nest.Plates.Sum(plate => plate.Parts
|
||||
.Where(part => !part.BaseDrawing.IsCutOff)
|
||||
.Sum(part => part.BaseDrawing.Area));
|
||||
return placedArea / sheetArea;
|
||||
}
|
||||
|
||||
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
|
||||
|
||||
private sealed class JobProgressBridge(IProgress<NestProgress> progress) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
if (value.LegacyProgress is not null)
|
||||
progress.Report(value.LegacyProgress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// One request to nest a specific drawing, with the quantity and rotation
|
||||
/// constraints pulled from its source .nest file.
|
||||
/// </summary>
|
||||
public class DrawingRequest
|
||||
{
|
||||
public Drawing Drawing { get; init; }
|
||||
public int Quantity { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public double StepAngle { get; init; }
|
||||
public double RotationStart { get; init; }
|
||||
public double RotationEnd { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An immutable specification for one benchmark job: the full set of
|
||||
/// drawings/quantities that must be nested, and the pool of sheet sizes the
|
||||
/// engine may draw from while doing it. A single run may use several
|
||||
/// plates - possibly of different sizes - to place everything, the same
|
||||
/// way a real production job spreads across whatever plates it needs
|
||||
/// rather than being handed one fixed-size sheet.
|
||||
/// </summary>
|
||||
public class BenchmarkJob
|
||||
{
|
||||
public string SourceFile { get; init; }
|
||||
public List<Size> CandidateSizes { get; init; }
|
||||
public Spacing EdgeSpacing { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
public int Quadrant { get; init; }
|
||||
public List<DrawingRequest> Requests { get; init; }
|
||||
|
||||
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
|
||||
|
||||
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the whole-job request this job represents: one NestJobPart per
|
||||
/// requested drawing, and one NestPlateStock per candidate sheet size
|
||||
/// (unlimited quantity - the engine under test decides how many of each
|
||||
/// size it actually uses, and how demand splits across plates). The
|
||||
/// engine owns its own multi-plate/size strategy; this harness no
|
||||
/// longer picks plate sizes on the engine's behalf.
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(int maxPlates)
|
||||
{
|
||||
var parts = Requests.Select(r =>
|
||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity));
|
||||
var stock = CandidateSizes.Select(size =>
|
||||
new NestPlateStock(size.ToString(1), size, null, PartSpacing, EdgeSpacing, Quadrant));
|
||||
return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs every candidate engine against every job. Each engine is a full
|
||||
/// INestingEngine: it owns its own plate/size selection and multi-plate
|
||||
/// strategy for the whole job, rather than being handed one already-sized
|
||||
/// plate at a time by this harness. A per-run timeout guards against a
|
||||
/// runaway or hanging engine — cooperative cancellation, so it reliably
|
||||
/// stops engines built on NestJobRunner (all four built-ins) but can't
|
||||
/// forcibly interrupt an engine that never checks its token.
|
||||
/// </summary>
|
||||
public static class BenchmarkRunner
|
||||
{
|
||||
/// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
|
||||
private const int MaxPlates = 40;
|
||||
|
||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
|
||||
{
|
||||
var results = new List<JobResult>(jobs.Count * engines.Count);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
foreach (var engineInfo in engines)
|
||||
{
|
||||
results.Add(RunOne(job, engineInfo));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
|
||||
{
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var nestJob = job.BuildNestJob(MaxPlates);
|
||||
var engine = engineInfo.Factory();
|
||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||
var jobResult = engine.Solve(nestJob, null, cts.Token);
|
||||
|
||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||
var plateRuns = materialized.Nest.Plates
|
||||
.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
||||
.ToList();
|
||||
|
||||
var requirements = job.Requests.ToDictionary<DrawingRequest, Drawing, (string Name, int Quantity)>(
|
||||
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
|
||||
r => (r.Drawing.Name, r.Quantity),
|
||||
ReferenceEqualityComparer.Instance);
|
||||
|
||||
var validation = NestValidator.Validate(plateRuns, requirements);
|
||||
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
||||
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
|
||||
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
||||
|
||||
var sizeBreakdown = plateRuns
|
||||
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
sw.Stop();
|
||||
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = totalPlaced,
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
SizeBreakdown = sizeBreakdown,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
sw.Stop();
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
ElapsedMs = sw.ElapsedMilliseconds,
|
||||
Error = $"{ex.GetType().Name}: {ex.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
|
||||
/// any valid .nest file, using whatever drawings/quantities/plate settings
|
||||
/// it contains. One job per file, carrying the full pool of candidate
|
||||
/// sheet sizes the engine may use across the whole nest - by default the
|
||||
/// distinct sizes already present in that file, or a fixed override list
|
||||
/// (e.g. a standard sheet-size lineup) applied to every file.
|
||||
/// </summary>
|
||||
public static class JobLoader
|
||||
{
|
||||
public static List<BenchmarkJob> Load(string inputPath, IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||
double? partSpacingOverride = null)
|
||||
{
|
||||
var files = ResolveFiles(inputPath);
|
||||
var jobs = new List<BenchmarkJob>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
Nest nest;
|
||||
|
||||
try
|
||||
{
|
||||
nest = new NestReader(file).Read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': failed to read ({ex.Message})");
|
||||
continue;
|
||||
}
|
||||
|
||||
var requests = BuildRequests(nest);
|
||||
|
||||
if (requests.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[JobLoader] Skipping '{file}': no drawings with quantity > 0");
|
||||
continue;
|
||||
}
|
||||
|
||||
var template = ResolvePlateTemplate(nest);
|
||||
var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
||||
? sheetSizeOverrides.ToList()
|
||||
: ResolveSheetSizes(nest);
|
||||
|
||||
jobs.Add(new BenchmarkJob
|
||||
{
|
||||
SourceFile = file,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
Requests = requests,
|
||||
});
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private static List<string> ResolveFiles(string inputPath)
|
||||
{
|
||||
if (Directory.Exists(inputPath))
|
||||
{
|
||||
return Directory.GetFiles(inputPath, "*.nest", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (File.Exists(inputPath))
|
||||
return new List<string> { inputPath };
|
||||
|
||||
throw new FileNotFoundException($"Benchmark input not found: {inputPath}");
|
||||
}
|
||||
|
||||
private static List<DrawingRequest> BuildRequests(Nest nest)
|
||||
{
|
||||
var requests = new List<DrawingRequest>();
|
||||
|
||||
foreach (var drawing in nest.Drawings)
|
||||
{
|
||||
var qty = drawing.Quantity.Required;
|
||||
|
||||
if (qty <= 0)
|
||||
continue;
|
||||
|
||||
var constraints = drawing.Constraints;
|
||||
|
||||
requests.Add(new DrawingRequest
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = qty,
|
||||
Priority = drawing.Priority,
|
||||
StepAngle = constraints?.StepAngle ?? 0,
|
||||
RotationStart = constraints?.StartAngle ?? 0,
|
||||
RotationEnd = constraints?.EndAngle ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return requests;
|
||||
}
|
||||
|
||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(Nest nest)
|
||||
{
|
||||
var source = nest.Plates?.FirstOrDefault();
|
||||
|
||||
if (source != null)
|
||||
return (source.EdgeSpacing, source.PartSpacing, source.Quadrant);
|
||||
|
||||
var defaults = nest.PlateDefaults;
|
||||
return (defaults.EdgeSpacing, defaults.PartSpacing, defaults.Quadrant);
|
||||
}
|
||||
|
||||
private static List<Size> ResolveSheetSizes(Nest nest)
|
||||
{
|
||||
var sizes = (nest.Plates ?? Enumerable.Empty<Plate>())
|
||||
.Select(p => p.Size)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (sizes.Count == 0)
|
||||
sizes.Add(nest.PlateDefaults.Size);
|
||||
|
||||
return sizes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of running one engine against one job. A job may span several
|
||||
/// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
|
||||
/// one sheet - possibly of different sizes - to place everything asked of
|
||||
/// it. An invalid or crashed run always scores zero utilization, per the
|
||||
/// benchmark rules.
|
||||
/// </summary>
|
||||
public class JobResult
|
||||
{
|
||||
public string EngineName { get; init; }
|
||||
public string JobName { get; init; }
|
||||
public bool Valid { get; init; }
|
||||
public List<string> Violations { get; init; } = new();
|
||||
public string Error { get; init; }
|
||||
public int PartsPlaced { get; init; }
|
||||
public int PartsRequested { get; init; }
|
||||
public double PlacedArea { get; init; }
|
||||
public double PlateArea { get; init; }
|
||||
public int PlatesUsed { get; init; }
|
||||
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
|
||||
public long ElapsedMs { get; init; }
|
||||
|
||||
public bool Crashed => Error != null;
|
||||
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
|
||||
|
||||
/// <summary>Aggregate utilization across every plate the engine used:
|
||||
/// total placed drawing area over total plate area, matching
|
||||
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
|
||||
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
public class ValidationResult
|
||||
{
|
||||
public bool Valid => Violations.Count == 0;
|
||||
public List<string> Violations { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a (possibly multi-plate) placed layout against the benchmark
|
||||
/// rules: on every plate, every part must lie within that plate's work
|
||||
/// area and every pair of parts must be at least PartSpacing apart; across
|
||||
/// all plates combined, no drawing may have more parts placed than
|
||||
/// requested (the quantity limit is a property of the whole order, not of
|
||||
/// any one plate). Geometry checks work on arbitrary (concave, holed)
|
||||
/// polygons by reusing the same world-space extraction Part.Intersects
|
||||
/// uses internally, so no engine gets an advantage or penalty from shape
|
||||
/// complexity.
|
||||
/// </summary>
|
||||
public static class NestValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// requirements maps each materialized part's BaseDrawing (by reference - materialized
|
||||
/// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so
|
||||
/// identity must never be inferred from Name, which is only incidentally seeded from the
|
||||
/// originating NestJobPart id) to its original quantity limit and display name.
|
||||
/// </summary>
|
||||
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
||||
|
||||
if (allParts.Count == 0)
|
||||
return result;
|
||||
|
||||
ValidateQuantities(allParts, requirements, result);
|
||||
|
||||
foreach (var (plate, parts) in plateRuns)
|
||||
{
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
ValidateBounds(parts, plate, requirements, result);
|
||||
ValidateAreaBudget(parts, plate, result);
|
||||
ValidateSpacing(parts, plate.PartSpacing, requirements, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateQuantities(List<Part> parts,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
||||
{
|
||||
var placedCounts = parts
|
||||
.GroupBy<Part, Drawing>(p => p.BaseDrawing, ReferenceEqualityComparer.Instance)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
foreach (var (drawing, placed) in placedCounts)
|
||||
{
|
||||
if (!requirements.TryGetValue(drawing, out var requirement))
|
||||
{
|
||||
result.Violations.Add($"Placed drawing '{drawing.Name}' which was not requested for this job");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (placed > requirement.Quantity)
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"'{requirement.Name}': placed {placed} across all plates but only {requirement.Quantity} were requested");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBounds(List<Part> parts, Plate plate,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
|
||||
var outLeft = bb.Left < workArea.X - Tolerance.Epsilon;
|
||||
var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon;
|
||||
var outRight = bb.Right > workArea.Right + Tolerance.Epsilon;
|
||||
var outTop = bb.Top > workArea.Top + Tolerance.Epsilon;
|
||||
|
||||
if (outLeft || outBottom || outRight || outTop)
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " +
|
||||
$"of a {plate.Size} plate");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hard mathematical backstop: non-overlapping parts confined to the
|
||||
/// work area can never have a combined area greater than the work
|
||||
/// area itself. This catches overlap that the polygon-based
|
||||
/// ValidateSpacing check can miss - Collision.HasOverlap (and
|
||||
/// Part.Intersects, which uses the same algorithm) has been observed
|
||||
/// to return false negatives on real, complex production geometry, so
|
||||
/// this check does not depend on it.
|
||||
/// </summary>
|
||||
private static void ValidateAreaBudget(List<Part> parts, Plate plate, ValidationResult result)
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
var budget = workArea.Width * workArea.Length;
|
||||
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
|
||||
|
||||
if (placedArea > budget + Tolerance.Epsilon)
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " +
|
||||
"parts must overlap even though the polygon overlap check did not flag a pair");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSpacing(List<Part> parts, double spacing,
|
||||
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
||||
{
|
||||
var worldPolygons = new Polygon[parts.Count];
|
||||
var inflatedPolygons = new Polygon[parts.Count];
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
worldPolygons[i] = WorldPolygon(parts[i], 0);
|
||||
inflatedPolygons[i] = spacing > Tolerance.Epsilon ? WorldPolygon(parts[i], spacing) : worldPolygons[i];
|
||||
}
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
if (worldPolygons[i] == null || inflatedPolygons[i] == null)
|
||||
continue;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
if (worldPolygons[j] == null)
|
||||
continue;
|
||||
|
||||
if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j]))
|
||||
{
|
||||
result.Violations.Add(
|
||||
$"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Friendly name for a violation message, falling back to the materialized
|
||||
/// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all -
|
||||
/// that mismatch is already reported by ValidateQuantities, so this is display-only.</summary>
|
||||
private static string DisplayName(Part part, IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements) =>
|
||||
requirements.TryGetValue(part.BaseDrawing, out var requirement) ? requirement.Name : part.BaseDrawing.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a part's perimeter as a world-space polygon, optionally inflated
|
||||
/// outward by the given spacing, mirroring Part.Intersects' own geometry
|
||||
/// extraction (part.Program is already rotated; only a Location offset is needed).
|
||||
/// </summary>
|
||||
private static Polygon WorldPolygon(Part part, double inflateBy)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
|
||||
var perimeter = new ShapeProfile(entities).Perimeter;
|
||||
|
||||
if (perimeter == null)
|
||||
return null;
|
||||
|
||||
if (inflateBy > Tolerance.Epsilon)
|
||||
perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter;
|
||||
|
||||
// Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000
|
||||
// segments per arc) - arc-heavy real parts otherwise produce thousands
|
||||
// of vertices, which is needlessly slow for a spacing check.
|
||||
var polygon = perimeter.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||
|
||||
if (polygon == null)
|
||||
return null;
|
||||
|
||||
polygon.Offset(part.Location);
|
||||
return polygon;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.Benchmark</RootNamespace>
|
||||
<AssemblyName>OpenNest.Benchmark</AssemblyName>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,176 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
return BenchmarkConsole.Run(args);
|
||||
|
||||
static class BenchmarkConsole
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
var options = ParseArgs(args);
|
||||
|
||||
if (options == null)
|
||||
return 0; // --help was requested
|
||||
|
||||
if (options.InputPath == null)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
List<BenchmarkJob> jobs;
|
||||
|
||||
try
|
||||
{
|
||||
jobs = JobLoader.Load(options.InputPath, options.SheetSizes, options.PartSpacing);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("No benchmark jobs found (no .nest files with any drawing quantity > 0).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var enginesDir = Path.Combine(AppContext.BaseDirectory, "Engines");
|
||||
NestingEngineRegistry.LoadPlugins(enginesDir);
|
||||
|
||||
var engines = NestingEngineRegistry.AvailableEngines;
|
||||
|
||||
if (options.EngineNames.Count > 0)
|
||||
{
|
||||
engines = engines
|
||||
.Where(e => options.EngineNames.Any(n => n.Equals(e.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
if (engines.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("None of the requested engines are registered. Available: " +
|
||||
string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name)));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
|
||||
Console.WriteLine($" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||
|
||||
var results = BenchmarkRunner.Run(jobs, engines);
|
||||
|
||||
Report.PrintDetailed(results);
|
||||
Report.PrintSummary(results);
|
||||
|
||||
if (options.CsvPath != null)
|
||||
{
|
||||
Report.WriteCsv(options.CsvPath, results);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Wrote CSV report to {options.CsvPath}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static Options ParseArgs(string[] args)
|
||||
{
|
||||
var o = new Options();
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--sheet-sizes" when i + 1 < args.Length:
|
||||
o.SheetSizes = ParseSheetSizes(args[++i]);
|
||||
break;
|
||||
|
||||
case "--spacing" when i + 1 < args.Length:
|
||||
o.PartSpacing = double.Parse(args[++i]);
|
||||
break;
|
||||
|
||||
case "--engines" when i + 1 < args.Length:
|
||||
o.EngineNames = args[++i]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
break;
|
||||
|
||||
case "--csv" when i + 1 < args.Length:
|
||||
o.CsvPath = args[++i];
|
||||
break;
|
||||
|
||||
case "--help":
|
||||
PrintUsage();
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (!args[i].StartsWith("--"))
|
||||
o.InputPath = args[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
private static List<Size> ParseSheetSizes(string arg)
|
||||
{
|
||||
var sizes = new List<Size>();
|
||||
|
||||
foreach (var token in arg.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (Size.TryParse(token, out var size))
|
||||
sizes.Add(size);
|
||||
else
|
||||
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
||||
}
|
||||
|
||||
return sizes.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("OpenNest.Benchmark - compare registered whole-job nesting engines on a set of .nest files");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("For each .nest file, every drawing with quantity > 0 is nested (mixed together),");
|
||||
Console.Error.WriteLine("once per registered INestingEngine. Each engine is handed the full job - every");
|
||||
Console.Error.WriteLine("requested part and the whole pool of candidate sheet sizes - and owns its own");
|
||||
Console.Error.WriteLine("multi-plate/size strategy: how many plates it uses, of which sizes, and how");
|
||||
Console.Error.WriteLine("demand splits across them. Scoring: aggregate material utilization across every");
|
||||
Console.Error.WriteLine("plate used, then (if everything requested was placed) fewer plates as the");
|
||||
Console.Error.WriteLine("tie-break. An invalid layout (out of bounds, overlapping, or over-quantity), a");
|
||||
Console.Error.WriteLine("thrown exception, or a run exceeding its time budget all score zero.");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Usage:");
|
||||
Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest");
|
||||
Console.Error.WriteLine(" (default: the distinct sizes already in each file)");
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing for every job");
|
||||
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
|
||||
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
|
||||
Console.Error.WriteLine(" --help Show this message");
|
||||
}
|
||||
|
||||
private class Options
|
||||
{
|
||||
public string InputPath;
|
||||
public List<Size> SheetSizes = new();
|
||||
public double? PartSpacing;
|
||||
public List<string> EngineNames = new();
|
||||
public string CsvPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Console + CSV reporting for benchmark results. Ranking rule per job:
|
||||
/// valid beats invalid; higher aggregate utilization wins; if utilization
|
||||
/// ties and both engines fully placed every requested part, fewer plates
|
||||
/// used wins (the multi-plate analogue of "smaller remnant" - both are
|
||||
/// proxies for wasting less material). Ties beyond that are a shared win.
|
||||
/// </summary>
|
||||
public static class Report
|
||||
{
|
||||
private const double Epsilon = 1e-6;
|
||||
|
||||
public static void PrintDetailed(List<JobResult> results)
|
||||
{
|
||||
foreach (var jobGroup in results.GroupBy(r => r.JobName))
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"=== {jobGroup.Key} ===");
|
||||
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
var best = ranked.Count > 0 ? ranked[0] : null;
|
||||
|
||||
Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Plates",-18} {"Time(ms)",-9} Notes");
|
||||
|
||||
foreach (var r in ranked)
|
||||
{
|
||||
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
|
||||
var marker = isWinner ? "*" : " ";
|
||||
var status = r.Crashed ? "CRASH" : r.Valid ? "ok" : "INVALID";
|
||||
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
|
||||
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
|
||||
var platesCol = r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
|
||||
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
|
||||
|
||||
Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {platesCol,-18} {r.ElapsedMs,-9} {notes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void PrintSummary(List<JobResult> results)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Summary ===");
|
||||
|
||||
var byEngine = results
|
||||
.GroupBy(r => r.EngineName)
|
||||
.Select(g => new
|
||||
{
|
||||
Engine = g.Key,
|
||||
Jobs = g.Count(),
|
||||
Valid = g.Count(r => r.Valid),
|
||||
Crashed = g.Count(r => r.Crashed),
|
||||
FullyPlaced = g.Count(r => r.FullyPlaced),
|
||||
TotalUtilization = g.Sum(r => r.Utilization),
|
||||
TotalPlates = g.Sum(r => r.PlatesUsed),
|
||||
TotalTimeMs = g.Sum(r => r.ElapsedMs),
|
||||
})
|
||||
.OrderByDescending(e => e.TotalUtilization)
|
||||
.ToList();
|
||||
|
||||
var wins = CountWins(results);
|
||||
|
||||
Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Complete",-9} {"Wins",-6} {"AvgUtil%",-10} {"Plates",-8} {"TotalTime(ms)",-14}");
|
||||
|
||||
foreach (var e in byEngine)
|
||||
{
|
||||
var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 0;
|
||||
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
|
||||
Console.WriteLine($"{e.Engine,-16} {e.Jobs,-6} {e.Valid,-7} {e.FullyPlaced,-9} {winCount,-6} {avgUtil,-10:F1} {e.TotalPlates,-8} {e.TotalTimeMs,-14}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void WriteCsv(string path, List<JobResult> results)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,PlatesUsed,SizeBreakdown,ElapsedMs,Notes");
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
|
||||
sb.AppendLine(string.Join(",",
|
||||
Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, r.FullyPlaced,
|
||||
r.PartsPlaced, r.PartsRequested,
|
||||
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)),
|
||||
r.ElapsedMs, Csv(notes)));
|
||||
}
|
||||
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
}
|
||||
|
||||
private static string SizeSummary(Dictionary<string, int> breakdown)
|
||||
{
|
||||
if (breakdown == null || breakdown.Count == 0)
|
||||
return "-";
|
||||
|
||||
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
|
||||
}
|
||||
|
||||
private static string Csv(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return string.Empty;
|
||||
|
||||
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> CountWins(List<JobResult> results)
|
||||
{
|
||||
var wins = new Dictionary<string, int>();
|
||||
|
||||
foreach (var jobGroup in results.GroupBy(r => r.JobName))
|
||||
{
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
|
||||
if (ranked.Count == 0 || !ranked[0].Valid)
|
||||
continue;
|
||||
|
||||
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
|
||||
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
|
||||
}
|
||||
|
||||
return wins;
|
||||
}
|
||||
|
||||
/// <summary>Lower sorts first (better). Valid beats invalid, then higher
|
||||
/// aggregate utilization, then (if both fully placed) fewer plates used.</summary>
|
||||
private static int Compare(JobResult a, JobResult b)
|
||||
{
|
||||
if (a.Valid != b.Valid)
|
||||
return a.Valid ? -1 : 1;
|
||||
|
||||
if (!a.Valid)
|
||||
return 0;
|
||||
|
||||
var utilDiff = b.Utilization - a.Utilization;
|
||||
|
||||
if (System.Math.Abs(utilDiff) > Epsilon)
|
||||
return utilDiff > 0 ? 1 : -1;
|
||||
|
||||
if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed)
|
||||
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,14 +87,15 @@ namespace OpenNest.Converters
|
||||
|
||||
lastpt = endpt;
|
||||
|
||||
var layer = ClassifyLayer(arc);
|
||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||
{
|
||||
pgm.LineTo(endpt);
|
||||
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||
}
|
||||
else
|
||||
{
|
||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
||||
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||
}
|
||||
|
||||
return lastpt;
|
||||
@@ -107,7 +108,7 @@ namespace OpenNest.Converters
|
||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
@@ -118,13 +119,22 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
return lastpt;
|
||||
}
|
||||
|
||||
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
|
||||
// separate tool pass; everything else keeps the move's default Cut layer.
|
||||
private static LayerType ClassifyLayer(Entity geo)
|
||||
{
|
||||
var name = geo.Layer?.Name;
|
||||
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Scribe;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,14 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// Fits a circular arc that passes exactly through both the first and last points
|
||||
/// while matching the given endpoint tangents as closely as possible. For any
|
||||
/// circle through two points, the tangents at those points make equal mirrored
|
||||
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||
/// two requested ones — when the requested tangents are consistent with a single
|
||||
/// circular arc, both are matched exactly.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents(
|
||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
var ux = dx / chordLen;
|
||||
var uy = dy / chordLen;
|
||||
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||
var theta1 = SignedAngle(ux, uy, startTangent);
|
||||
var theta2 = -SignedAngle(ux, uy, endTangent);
|
||||
var theta = (theta1 + theta2) / 2;
|
||||
|
||||
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
|
||||
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
||||
var d = -halfChord / System.Math.Tan(theta);
|
||||
|
||||
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
private static double SignedAngle(double ux, double uy, Vector to)
|
||||
{
|
||||
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||
if (len < 1e-10) return 0;
|
||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -374,11 +374,8 @@ public class GeometrySimplifier
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var endTangent = GetExitDirection(entities[k]);
|
||||
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||
var endTangent = EstimateEndTangent(entities, k, points);
|
||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
@@ -386,8 +383,10 @@ public class GeometrySimplifier
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (extPoints.Count < 3) break;
|
||||
|
||||
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
@@ -407,37 +406,172 @@ public class GeometrySimplifier
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
private (Vector center, double radius, double deviation) TryFit(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
{
|
||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
||||
if (endTangent.IsValid())
|
||||
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||
{
|
||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
||||
if (dc.IsValid() && dd <= Tolerance)
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
continue;
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
continue;
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
return (Vector.Invalid, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields fit attempts in preference order. A trusted tangent (chained from the
|
||||
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
|
||||
/// exactly on its side; otherwise the tangency error is balanced between both
|
||||
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
|
||||
/// passes exactly through both endpoints, so no gaps are introduced.
|
||||
/// </summary>
|
||||
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
{
|
||||
if (start.Trusted && !end.Trusted)
|
||||
{
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
else if (end.Trusted && !start.Trusted)
|
||||
{
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
|
||||
yield return FitMirrorAxis(points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits an arc through both endpoints with an exact tangent at the last point,
|
||||
/// by running the start-tangent fit on the reversed point sequence.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithEndTangent(
|
||||
List<Vector> points, Vector endTangent)
|
||||
{
|
||||
var reversed = new List<Vector>(points);
|
||||
reversed.Reverse();
|
||||
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
|
||||
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
|
||||
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
|
||||
/// vertices and only guide the fit.
|
||||
/// </summary>
|
||||
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
|
||||
|
||||
/// <summary>Segment-length ratio above which a neighboring line counts as a true
|
||||
/// straight edge (rather than another chord of the tessellated curve).</summary>
|
||||
private const double NeighborEdgeFactor = 3.0;
|
||||
|
||||
private static TangentEstimate EstimateStartTangent(
|
||||
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
|
||||
{
|
||||
if (chainedTangent.IsValid())
|
||||
return new TangentEstimate(chainedTangent, true);
|
||||
|
||||
if (entities[start] is Arc startArc)
|
||||
return new TangentEstimate(GetEntryDirection(startArc), true);
|
||||
|
||||
var firstChordLen = points[0].DistanceTo(points[1]);
|
||||
if (start > 0)
|
||||
{
|
||||
var prev = entities[start - 1];
|
||||
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
|
||||
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
|
||||
{
|
||||
var isRev = SumSignedAngles(dc, points) < 0;
|
||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
||||
if (aDev <= Tolerance)
|
||||
return (dc, dr, System.Math.Max(dd, aDev));
|
||||
if (prev is Arc)
|
||||
return new TangentEstimate(GetExitDirection(prev), true);
|
||||
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
|
||||
return new TangentEstimate(GetExitDirection(prevLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to start-tangent-only, then mirror axis
|
||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
private static TangentEstimate EstimateEndTangent(List<Entity> entities, int k, List<Vector> points)
|
||||
{
|
||||
if (entities[k] is Arc endArc)
|
||||
return new TangentEstimate(GetExitDirection(endArc), true);
|
||||
|
||||
var lastChordLen = points[^1].DistanceTo(points[^2]);
|
||||
if (k + 1 < entities.Count)
|
||||
{
|
||||
var next = entities[k + 1];
|
||||
var nextStart = next switch { Line l => l.StartPoint, Arc a => a.StartPoint(), _ => Vector.Invalid };
|
||||
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
|
||||
{
|
||||
if (next is Arc nextArc)
|
||||
return new TangentEstimate(GetEntryDirection(nextArc), true);
|
||||
if (next is Line nextLine && nextLine.StartPoint.DistanceTo(nextLine.EndPoint) >= NeighborEdgeFactor * lastChordLen)
|
||||
return new TangentEstimate(GetExitDirection(nextLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[^1], points[^2], points[^3], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the curve tangent at a polyline vertex from the circle through it and
|
||||
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
|
||||
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
|
||||
/// Falls back to the travel direction when the three points are collinear.
|
||||
/// </summary>
|
||||
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
|
||||
{
|
||||
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
|
||||
if (System.Math.Abs(d) < 1e-14)
|
||||
return travel;
|
||||
|
||||
var sqA = at.X * at.X + at.Y * at.Y;
|
||||
var sqB = b.X * b.X + b.Y * b.Y;
|
||||
var sqC = c.X * c.X + c.Y * c.Y;
|
||||
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
|
||||
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
|
||||
|
||||
var tangent = new Vector(-(at.Y - cy), at.X - cx);
|
||||
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
|
||||
tangent = new Vector(-tangent.X, -tangent.Y);
|
||||
return tangent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the entry direction (tangent at start point) of an entity.
|
||||
/// </summary>
|
||||
private static Vector GetEntryDirection(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
|
||||
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Fill;
|
||||
|
||||
public class SortStripsTests
|
||||
{
|
||||
private static Part MakeRectPart(double x, double y, double w, double h)
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing("rect", pgm);
|
||||
return new Part(drawing, new Vector(x, y));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SortColumnsByHeight_NonUniformGaps_DoesNotExceedOriginalSpan()
|
||||
{
|
||||
// Three columns with non-uniform gaps between them (5, then 1) and heights
|
||||
// ordered so the sort-by-height pass must reorder them (tallest first, then
|
||||
// shortest, then medium). The tallest column's original position leaves a
|
||||
// 5-unit gap to its neighbor; that single sampled gap must not get replayed
|
||||
// as the spacing for the whole staircase once it's no longer the leading pair.
|
||||
var tall = MakeRectPart(0, 0, 10, 30); // Left 0-10, gap of 5 to next
|
||||
var shortCol = MakeRectPart(15, 0, 5, 5); // Left 15-20, gap of 1 to next
|
||||
var medium = MakeRectPart(21, 0, 20, 15); // Left 21-41
|
||||
|
||||
var originalRight = new[] { tall, shortCol, medium }.Max(p => p.BoundingBox.Right);
|
||||
var originalLeft = new[] { tall, shortCol, medium }.Min(p => p.BoundingBox.Left);
|
||||
var originalSpan = originalRight - originalLeft;
|
||||
|
||||
var parts = new List<Part> { tall, shortCol, medium };
|
||||
IterativeShrinkFiller.SortColumnsByHeight(parts, spacing: 1.0);
|
||||
|
||||
var newRight = parts.Max(p => p.BoundingBox.Right);
|
||||
var newLeft = parts.Min(p => p.BoundingBox.Left);
|
||||
var newSpan = newRight - newLeft;
|
||||
|
||||
Assert.True(newSpan <= originalSpan + 1e-9,
|
||||
$"Resequenced columns must not exceed the original footprint: original span {originalSpan}, new span {newSpan}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class FiniteStockJobTests
|
||||
{
|
||||
internal static NestJob Job(int? stock = 3, NestJobOptions? options = null) => new(
|
||||
new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 3) },
|
||||
new[] { new NestPlateStock("s", new Size(100, 200), stock) }, options);
|
||||
|
||||
internal sealed class Nester(Func<PlatePlacementRequest, PlateCandidate> place) : IPlateNester
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) { Calls++; return place(request); }
|
||||
}
|
||||
|
||||
internal static PlateCandidate One(PlatePlacementRequest request) => new(new[]
|
||||
{ new NestJobPlacement(request.Parts[0].Id, 99, 0, 0, 0) });
|
||||
|
||||
[Theory]
|
||||
[InlineData(3, 3, 0, NestJobStatus.Complete, NestJobStopReason.Completed)]
|
||||
[InlineData(2, 2, 1, NestJobStatus.Incomplete, NestJobStopReason.StockExhausted)]
|
||||
[InlineData(0, 0, 3, NestJobStatus.Incomplete, NestJobStopReason.StockExhausted)]
|
||||
public void DemandAndPhysicalStockAreAccountedFromPlacements(int stock, int placed, int left,
|
||||
NestJobStatus status, NestJobStopReason reason)
|
||||
{
|
||||
var requests = new List<int>();
|
||||
var nester = new Nester(r => { requests.Add(r.Parts[0].Quantity); return One(r); });
|
||||
var job = Job(stock);
|
||||
var result = new NestJobRunner(_ => nester).Solve(job);
|
||||
Assert.Equal(status, result.Status);
|
||||
Assert.Equal(reason, result.StopReason);
|
||||
Assert.Equal(placed, result.Plates.Count);
|
||||
Assert.All(result.Plates, p => Assert.Single(p.Placements));
|
||||
Assert.Equal(Enumerable.Range(0, placed), result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex));
|
||||
Assert.Equal(Enumerable.Range(0, placed).Select(i => 3 - i), requests);
|
||||
Assert.Equal(new PartFulfillment("p", 3, placed, left), Assert.Single(result.Fulfillment));
|
||||
Assert.Equal(new StockUsage("s", placed, stock - placed), Assert.Single(result.StockUsage));
|
||||
Assert.Equal(3, job.Parts[0].Quantity);
|
||||
Assert.Equal(stock, job.Plates[0].Quantity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData(3)]
|
||||
public void NoPlacementStopsWithoutConsumingStock(int? stock)
|
||||
{
|
||||
var nester = new Nester(_ => new PlateCandidate(Array.Empty<NestJobPlacement>()));
|
||||
var result = new NestJobRunner(_ => nester).Solve(Job(stock));
|
||||
Assert.Equal(1, nester.Calls);
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
Assert.Equal(new StockUsage("s", 0, stock), Assert.Single(result.StockUsage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlateLimitStopsUnlimitedStock()
|
||||
{
|
||||
var result = new NestJobRunner(_ => new Nester(One)).Solve(Job(null, new NestJobOptions(maxPlates: 2)));
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
Assert.Equal(new StockUsage("s", 2, null), Assert.Single(result.StockUsage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationImmediatelyAfterEngineReturnThrowsWithoutCommit()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var commits = new List<NestJobProgress>();
|
||||
var nester = new Nester(r => { cts.Cancel(); return One(r); });
|
||||
Assert.Throws<OperationCanceledException>(() => new NestJobRunner(_ => nester)
|
||||
.Solve(Job(), new InlineProgress(commits.Add), cts.Token));
|
||||
Assert.DoesNotContain(commits, p => p.Stage == NestJobStage.PlateCommitted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InitialCancellationSkipsEngine()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
Assert.Throws<OperationCanceledException>(() => new NestJobRunner(_ => throw new Exception("called"))
|
||||
.Solve(Job(), token: cts.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("unknown", 0, 0, 0, 1)]
|
||||
[InlineData("p", double.NaN, 0, 0, 1)]
|
||||
[InlineData("p", 0, double.PositiveInfinity, 0, 1)]
|
||||
[InlineData("p", 0, 0, double.NaN, 1)]
|
||||
[InlineData("p", 0, 0, 0, 4)]
|
||||
public void InvalidCandidateThrows(string id, double x, double y, double rotation, int count)
|
||||
{
|
||||
var nester = new Nester(_ => new PlateCandidate(Enumerable.Range(0, count)
|
||||
.Select(i => new NestJobPlacement(id, i, x, y, rotation))));
|
||||
Assert.Throws<InvalidOperationException>(() => new NestJobRunner(_ => nester).Solve(Job()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullCandidateAndUnknownStrategyAreExplicitErrors()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(() => new NestJobRunner(_ => new Nester(_ => null!)).Solve(Job()));
|
||||
Assert.Throws<NotSupportedException>(() => new NestJobRunner(_ => null!).Solve(Job(options: new NestJobOptions("missing"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedStockCanBeEvaluated()
|
||||
{
|
||||
var job = Job();
|
||||
var mixed = new NestJob(job.Parts, job.Plates.Concat(new[] { new NestPlateStock("other", new Size(20, 10), 2) }));
|
||||
var result = new NestJobRunner(_ => new Nester(One)).Solve(mixed);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 20, 0, 1)]
|
||||
[InlineData(10, double.NaN, 0, 1)]
|
||||
[InlineData(10, 20, -1, 1)]
|
||||
[InlineData(10, 20, double.PositiveInfinity, 1)]
|
||||
[InlineData(10, 20, 0, 5)]
|
||||
public void InvalidStockSettingsRejected(double width, double length, double spacing, int quadrant)
|
||||
{
|
||||
var job = new NestJob(Job().Parts, new[] { new NestPlateStock("s", new Size(width, length), 1, spacing, quadrant: quadrant) });
|
||||
Assert.Throws<ArgumentException>(() => new NestJobRunner(_ => new Nester(One)).Solve(job));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidEdgesAndGeometryRejected()
|
||||
{
|
||||
var runner = new NestJobRunner(_ => new Nester(One));
|
||||
foreach (var edges in new[] { new Spacing(-1, 0, 0, 0), new Spacing(0, double.NaN, 0, 0), new Spacing(1000, 1000, 1000, 1000) })
|
||||
Assert.Throws<ArgumentException>(() => runner.Solve(new NestJob(Job().Parts,
|
||||
new[] { new NestPlateStock("s", new Size(100, 200), edgeSpacing: edges) })));
|
||||
var program = TestDrawingFactory.Rectangle();
|
||||
program.LineTo(double.NaN, 0);
|
||||
Assert.Throws<ArgumentException>(() => runner.Solve(new NestJob(new[]
|
||||
{ new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1) }, Job().Plates)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidContractInputsAreRejected()
|
||||
{
|
||||
var job = Job();
|
||||
Assert.Throws<ArgumentNullException>(() => new NestJobRunner(_ => new Nester(One)).Solve(null!));
|
||||
Assert.Throws<ArgumentException>(() => new NestJob(new NestJobPart[] { null! }, job.Plates));
|
||||
Assert.Throws<ArgumentException>(() => new NestJob(job.Parts.Concat(job.Parts), job.Plates));
|
||||
Assert.Throws<ArgumentException>(() => new NestJob(job.Parts, job.Plates.Concat(job.Plates)));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobPart("p", job.Parts[0].Geometry, 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestPlateStock("s", new Size(1, 1), -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(maxPlates: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RunnerFactoriesAreInstanceScopedAndReceiveExactStrategyKeys()
|
||||
{
|
||||
var keys = new List<string>();
|
||||
var first = new NestJobRunner(key => { keys.Add(key); return new Nester(One); });
|
||||
var second = new NestJobRunner(key => { keys.Add(key); return new Nester(_ => new PlateCandidate(Array.Empty<NestJobPlacement>())); });
|
||||
Assert.Equal(NestJobStatus.Complete, first.Solve(Job(options: new NestJobOptions("custom-A"))).Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, second.Solve(Job(options: new NestJobOptions("custom-B"))).StopReason);
|
||||
Assert.Equal(NestJobStatus.Complete, first.Solve(Job(options: new NestJobOptions("custom-A"))).Status);
|
||||
Assert.Equal(new[] { "custom-A", "custom-B", "custom-A" }, keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationAfterAnEarlierCommitStillThrowsRatherThanReturningPartialResult()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var calls = 0;
|
||||
var commits = new List<NestJobProgress>();
|
||||
var nester = new Nester(r => { if (++calls == 2) cts.Cancel(); return One(r); });
|
||||
Assert.Throws<OperationCanceledException>(() => new NestJobRunner(_ => nester)
|
||||
.Solve(Job(), new InlineProgress(commits.Add), cts.Token));
|
||||
Assert.Equal(1, Assert.Single(commits.Where(p => p.Stage == NestJobStage.PlateCommitted)).CommittedParts);
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
private sealed class InlineProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value) => report(value);
|
||||
}
|
||||
}
|
||||
@@ -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,162 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class JobAdapterTests
|
||||
{
|
||||
[Fact]
|
||||
public void LegacyMutationsCannotDoubleSubtractOrReachCallerObjects()
|
||||
{
|
||||
var drawing = new Drawing("same name", TestDrawingFactory.Rectangle());
|
||||
drawing.Quantity.Required = 9;
|
||||
var item = new NestItem { Drawing = drawing, Quantity = 3, Priority = 7, StepAngle = 0 };
|
||||
var sourcePlate = new Plate(100, 200) { Quantity = 3, PartSpacing = 2 };
|
||||
var job = new NestJob(new[] { DrawingJobMapper.FromItem("requirement", item) },
|
||||
new[] { DrawingJobMapper.FromPlate("stock", sourcePlate, 3) });
|
||||
var quantities = new List<int>();
|
||||
var adapter = new LegacyPlateNesterAdapter(p => new MutatingEngine(p, items =>
|
||||
{
|
||||
var privateItem = Assert.Single(items);
|
||||
quantities.Add(privateItem.Quantity);
|
||||
Assert.NotSame(drawing, privateItem.Drawing);
|
||||
Assert.Equal(0, privateItem.StepAngle);
|
||||
Assert.Equal(7, privateItem.Priority);
|
||||
var part = new Part(privateItem.Drawing);
|
||||
privateItem.Quantity = 0;
|
||||
privateItem.Drawing.Quantity.Required = 0;
|
||||
return new List<Part> { part };
|
||||
}));
|
||||
var result = new NestJobRunner(_ => adapter).Solve(job);
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
Assert.Equal(new[] { 3, 2, 1 }, quantities);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(3, materialized.Nest.Plates.Count);
|
||||
Assert.All(materialized.Nest.Plates, p => { Assert.Equal(1, p.Quantity); Assert.Single(p.Parts); });
|
||||
var outputDrawing = materialized.DrawingsByPartId["requirement"];
|
||||
Assert.Equal(3, outputDrawing.Quantity.Required);
|
||||
Assert.Equal(3, outputDrawing.Quantity.Nested);
|
||||
Assert.All(materialized.Nest.Plates, p => Assert.Same(outputDrawing, p.Parts[0].BaseDrawing));
|
||||
Assert.NotSame(drawing, outputDrawing);
|
||||
Assert.Equal(9, drawing.Quantity.Required);
|
||||
Assert.Equal(0, drawing.Quantity.Nested);
|
||||
Assert.Equal(3, item.Quantity);
|
||||
Assert.Equal(3, sourcePlate.Quantity);
|
||||
Assert.Empty(sourcePlate.Parts);
|
||||
Assert.Equal(2, sourcePlate.PartSpacing);
|
||||
Assert.Equal(PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()).Motions,
|
||||
PartGeometrySnapshot.FromProgram(drawing.Program).Motions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReferenceIdentityNotNamesControlsLegacyPlacements()
|
||||
{
|
||||
var drawing = new Drawing("duplicate", TestDrawingFactory.Rectangle());
|
||||
var job = new NestJob(new[] { DrawingJobMapper.FromDrawing("a", drawing, 1),
|
||||
DrawingJobMapper.FromDrawing("b", drawing, 1) }, FiniteStockJobTests.Job(1).Plates);
|
||||
var adapter = new LegacyPlateNesterAdapter(p => new MutatingEngine(p, items =>
|
||||
{
|
||||
Assert.NotSame(items[0].Drawing, items[1].Drawing);
|
||||
foreach (var item in items) item.Drawing.Name = "identical";
|
||||
return new List<Part>
|
||||
{
|
||||
new Part(items[0].Drawing, new Vector(0, 0)),
|
||||
new Part(items[1].Drawing, new Vector(10, 0))
|
||||
};
|
||||
}));
|
||||
var result = new NestJobRunner(_ => adapter).Solve(job);
|
||||
Assert.Equal(new[] { "a", "b" }, result.Plates[0].Placements.Select(p => p.PartId));
|
||||
var output = NestResultMaterializer.Materialize(job, result);
|
||||
Assert.NotSame(output.DrawingsByPartId["a"], output.DrawingsByPartId["b"]);
|
||||
Assert.All(output.DrawingsByPartId.Values, d => Assert.Equal(1, d.Quantity.Nested));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownPrivateDrawingIsRejectedEvenWithMatchingName()
|
||||
{
|
||||
var adapter = new LegacyPlateNesterAdapter(p => new MutatingEngine(p, items => new List<Part>
|
||||
{ new(new Drawing(items[0].Drawing.Name, TestDrawingFactory.Rectangle())) }));
|
||||
Assert.Throws<InvalidOperationException>(() => new NestJobRunner(_ => adapter).Solve(FiniteStockJobTests.Job()));
|
||||
Assert.Throws<NotSupportedException>(() => LegacyPlateNesterAdapter.Create("not registered"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactGeometryRoundTripsIncludingOriginArcHoleAndMode()
|
||||
{
|
||||
var program = TestDrawingFactory.Rectangle();
|
||||
program.Offset(-17.123456789, 5.25);
|
||||
program.MoveTo(-14, 9);
|
||||
program.ArcTo(-14, 9, -13, 9, RotationType.CW);
|
||||
((ArcMove)program.Codes[^1]).Layer = LayerType.Cut;
|
||||
var drawing = new Drawing("shape", program);
|
||||
var part = DrawingJobMapper.FromDrawing("p", drawing, 1);
|
||||
var snapshot = part.Geometry.Motions.ToArray();
|
||||
((Motion)program.Codes[1]).EndPoint = new Vector(999, 888);
|
||||
Assert.Equal(snapshot, part.Geometry.Motions);
|
||||
var roundTrip = DrawingJobMapper.ToProgram(part.Geometry);
|
||||
Assert.Equal(snapshot, PartGeometrySnapshot.FromProgram(roundTrip).Motions);
|
||||
Assert.Equal(program.Mode, roundTrip.Mode);
|
||||
roundTrip.Codes.Clear();
|
||||
Assert.Equal(snapshot, part.Geometry.Motions);
|
||||
var incremental = new Program(Mode.Incremental);
|
||||
incremental.MoveTo(5, -3);
|
||||
incremental.LineTo(7, 4);
|
||||
var incrementalSnapshot = PartGeometrySnapshot.FromProgram(incremental);
|
||||
Assert.Equal(Mode.Incremental, DrawingJobMapper.ToProgram(incrementalSnapshot).Mode);
|
||||
Assert.Equal(incrementalSnapshot.Motions,
|
||||
PartGeometrySnapshot.FromProgram(DrawingJobMapper.ToProgram(incrementalSnapshot)).Motions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RealDefaultEngineRunsFromDrawingThroughMaterialization()
|
||||
{
|
||||
var drawing = new Drawing("generated asymmetric rectangle", TestDrawingFactory.Rectangle(13, 7));
|
||||
drawing.Quantity.Required = 1;
|
||||
var job = new NestJob(new[] { DrawingJobMapper.FromDrawing("rectangle", drawing, 1) },
|
||||
new[] { new NestPlateStock("sheet", new Size(40, 60), 1, 1, new Spacing(2, 2, 2, 2)) });
|
||||
var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(new StockUsage("sheet", 1, 0), Assert.Single(result.StockUsage));
|
||||
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
|
||||
var output = NestResultMaterializer.Materialize(job, result);
|
||||
var physicalPlate = Assert.Single(output.Nest.Plates);
|
||||
var placed = Assert.Single(physicalPlate.Parts);
|
||||
Assert.Equal(1, physicalPlate.Quantity);
|
||||
Assert.Same(output.DrawingsByPartId["rectangle"], placed.BaseDrawing);
|
||||
Assert.Equal(pose.X, placed.Location.X);
|
||||
Assert.Equal(pose.Y, placed.Location.Y);
|
||||
Assert.Equal(pose.Rotation, placed.Rotation, 10);
|
||||
Assert.Equal(1, placed.BaseDrawing.Quantity.Nested);
|
||||
Assert.Equal(0, drawing.Quantity.Nested);
|
||||
Assert.Equal(1, drawing.Quantity.Required);
|
||||
var bounds = placed.BoundingBox;
|
||||
var work = physicalPlate.WorkArea();
|
||||
Assert.True(bounds.Left >= work.Left - 1e-6 && bounds.Bottom >= work.Bottom - 1e-6);
|
||||
Assert.True(bounds.Right <= work.Right + 1e-6 && bounds.Top <= work.Top + 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterializationRotatesAboutSnapshotOriginThenTranslates()
|
||||
{
|
||||
var program = TestDrawingFactory.Rectangle();
|
||||
program.Offset(-5, 3);
|
||||
var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1) },
|
||||
FiniteStockJobTests.Job(1).Plates);
|
||||
var result = new NestJobRunner(_ => new FiniteStockJobTests.Nester(_ => new PlateCandidate(new[]
|
||||
{ new NestJobPlacement("p", 0, 23, 31, 0.7) }))).Solve(job);
|
||||
var output = NestResultMaterializer.Materialize(job, result);
|
||||
var part = output.Nest.Plates[0].Parts[0];
|
||||
var expected = new Vector(-5, 3).Rotate(0.7);
|
||||
Assert.Equal(expected.X, ((Motion)part.Program.Codes[0]).EndPoint.X, 10);
|
||||
Assert.Equal(expected.Y, ((Motion)part.Program.Codes[0]).EndPoint.Y, 10);
|
||||
Assert.Equal(new Vector(23, 31), part.Location);
|
||||
}
|
||||
|
||||
private sealed class MutatingEngine(Plate plate, Func<List<NestItem>, List<Part>> nest) : NestEngineBase(plate)
|
||||
{
|
||||
public override string Name => "test";
|
||||
public override string Description => "mutates private demand";
|
||||
public override List<Part> Nest(List<NestItem> items, IProgress<NestProgress> progress, CancellationToken token)
|
||||
=> nest(items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestJobCancellationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PreTrialCancellationSkipsCandidateWorkAndPreservesInput()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var nester = new CancellableNester(_ => new PlateCandidate(Array.Empty<NestJobPlacement>()));
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 1);
|
||||
var sourceGeometry = part.Geometry.Motions.ToArray();
|
||||
var stock = new NestPlateStock("stock", new Size(20, 30), 1);
|
||||
var job = new NestJob(new[] { part }, new[] { stock });
|
||||
|
||||
Assert.Throws<OperationCanceledException>(() => new NestJobRunner(_ => nester).Solve(job, token: cancellation.Token));
|
||||
|
||||
Assert.Equal(0, nester.Calls);
|
||||
Assert.Equal(sourceGeometry, job.Parts[0].Geometry.Motions);
|
||||
Assert.Equal(1, job.Parts[0].Quantity);
|
||||
Assert.Equal(1, job.Plates[0].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationDuringCandidateThrowsWithoutCommitOrInputMutation()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var reports = new List<NestJobProgress>();
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 1);
|
||||
var sourceGeometry = part.Geometry.Motions.ToArray();
|
||||
var stock = new NestPlateStock("stock", new Size(20, 30), 1);
|
||||
var job = new NestJob(new[] { part }, new[] { stock });
|
||||
var nester = new CancellableNester((_, token) =>
|
||||
{
|
||||
cancellation.Cancel();
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new PlateCandidate(Array.Empty<NestJobPlacement>());
|
||||
});
|
||||
|
||||
Assert.Throws<OperationCanceledException>(() => new NestJobRunner(_ => nester)
|
||||
.Solve(job, new InlineProgress(reports.Add), cancellation.Token));
|
||||
|
||||
Assert.Equal(1, nester.Calls);
|
||||
Assert.DoesNotContain(reports, report => report.Stage == NestJobStage.PlateCommitted);
|
||||
Assert.Equal(sourceGeometry, job.Parts[0].Geometry.Motions);
|
||||
Assert.Equal(1, job.Parts[0].Quantity);
|
||||
Assert.Equal(1, job.Plates[0].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyProgressIsWrappedWithCurrentCandidateContext()
|
||||
{
|
||||
var reports = new List<NestJobProgress>();
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 1);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 30), 1) });
|
||||
var runner = new NestJobRunner(_ => new LegacyPlateNesterAdapter(plate => new ReportingEngine(plate)));
|
||||
|
||||
var result = runner.Solve(job, new InlineProgress(reports.Add));
|
||||
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
var legacy = Assert.Single(reports.Where(report => report.LegacyProgress != null));
|
||||
Assert.Equal(NestJobStage.EvaluatingCandidate, legacy.Stage);
|
||||
Assert.Equal("stock", legacy.StockId);
|
||||
Assert.Equal(0, legacy.PlateIndex);
|
||||
Assert.Equal(0, legacy.CommittedPlates);
|
||||
Assert.Equal(0, legacy.CommittedParts);
|
||||
Assert.Equal("legacy detail", legacy.LegacyProgress!.Description);
|
||||
}
|
||||
|
||||
private sealed class InlineProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value) => report(value);
|
||||
}
|
||||
|
||||
private sealed class CancellableNester : IPlateNester
|
||||
{
|
||||
private readonly Func<PlatePlacementRequest, CancellationToken, PlateCandidate> place;
|
||||
|
||||
public CancellableNester(Func<PlatePlacementRequest, PlateCandidate> place)
|
||||
{
|
||||
this.place = (request, _) => place(request);
|
||||
}
|
||||
|
||||
public CancellableNester(Func<PlatePlacementRequest, CancellationToken, PlateCandidate> place)
|
||||
{
|
||||
this.place = place;
|
||||
}
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
Calls++;
|
||||
return place(request, token);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReportingEngine(Plate plate) : NestEngineBase(plate)
|
||||
{
|
||||
public override string Name => "reporting";
|
||||
public override string Description => "reports progress";
|
||||
|
||||
public override List<Part> Nest(List<NestItem> items, IProgress<NestProgress>? progress,
|
||||
CancellationToken token)
|
||||
{
|
||||
progress?.Report(new NestProgress { Description = "legacy detail" });
|
||||
return new List<Part>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy selection must be instance-scoped: explicit engine choices work without touching the
|
||||
/// process-global NestEngineRegistry.ActiveEngineName, and unknown strategies are rejected.
|
||||
/// </summary>
|
||||
public class NestJobEngineSelectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExplicitDefaultAndStripSelectionsDoNotTouchGlobalRegistry()
|
||||
{
|
||||
var original = NestEngineRegistry.ActiveEngineName;
|
||||
var job = FiniteStockJobTests.Job(1);
|
||||
|
||||
var defaultResult = new NestJobRunner(PlateNesterFactory.Create).Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, defaultResult.Status);
|
||||
|
||||
var stripResult = new NestJobRunner(PlateNesterFactory.Create)
|
||||
.Solve(new NestJob(job.Parts, job.Plates, new NestJobOptions("Strip")));
|
||||
Assert.Equal(NestJobStatus.Complete, stripResult.Status);
|
||||
|
||||
Assert.Equal(original, NestEngineRegistry.ActiveEngineName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FactoryResolvesNamedEnginesWithoutGlobalState()
|
||||
{
|
||||
var original = NestEngineRegistry.ActiveEngineName;
|
||||
var defaultNester = PlateNesterFactory.Create("Default");
|
||||
var stripNester = PlateNesterFactory.Create("Strip");
|
||||
var verticalNester = PlateNesterFactory.Create("Vertical Remnant");
|
||||
var horizontalNester = PlateNesterFactory.Create("Horizontal Remnant");
|
||||
|
||||
Assert.NotNull(defaultNester);
|
||||
Assert.NotNull(stripNester);
|
||||
Assert.NotNull(verticalNester);
|
||||
Assert.NotNull(horizontalNester);
|
||||
Assert.NotSame(defaultNester, stripNester);
|
||||
Assert.Equal(original, NestEngineRegistry.ActiveEngineName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownStrategyIsRejected()
|
||||
{
|
||||
Assert.Throws<NotSupportedException>(() => PlateNesterFactory.Create("Not A Real Engine"));
|
||||
var job = FiniteStockJobTests.Job(1);
|
||||
Assert.Throws<NotSupportedException>(() =>
|
||||
new NestJobRunner(key => throw new NotSupportedException($"Unknown placement strategy: {key}"))
|
||||
.Solve(new NestJob(job.Parts, job.Plates, new NestJobOptions("Bogus"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyRegistryPluginsDoNotLeakIntoJobSelection()
|
||||
{
|
||||
// A plugin engine registered through the legacy registry must not become selectable
|
||||
// through the job factory; the new boundary is independent of registry state.
|
||||
NestEngineRegistry.Register("ProbePlugin", "test plugin", plate => new PluginShapeEngine(plate));
|
||||
Assert.Contains(NestEngineRegistry.AvailableEngines, e => e.Name == "ProbePlugin");
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => PlateNesterFactory.Create("ProbePlugin"));
|
||||
Assert.NotNull(PlateNesterFactory.Create("Default"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripEngineEndToEndPlacesAndAccounts()
|
||||
{
|
||||
var drawing = new Drawing("strip part", TestDrawingFactory.Rectangle(30, 30));
|
||||
var job = new NestJob(new[] { DrawingJobMapper.FromDrawing("part", drawing, 2) },
|
||||
new[] { new NestPlateStock("s", new Size(90, 90), 1) });
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create)
|
||||
.Solve(new NestJob(job.Parts, job.Plates, new NestJobOptions("Strip")));
|
||||
|
||||
Assert.True(result.Plates.SelectMany(p => p.Placements).Count() >= 1);
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
}
|
||||
|
||||
private sealed class PluginShapeEngine(Plate plate) : NestEngineBase(plate)
|
||||
{
|
||||
public override string Name => "ProbePlugin";
|
||||
public override string Description => "registered via legacy registry only";
|
||||
}
|
||||
}
|
||||
@@ -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,159 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestJobGeometryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CandidateOutsideUsableWorkAreaFailsWithoutMutatingInput()
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 1);
|
||||
var sourceGeometry = part.Geometry.Motions.ToArray();
|
||||
var stock = new NestPlateStock("stock", new Size(20, 30), 1,
|
||||
edgeSpacing: new Spacing(2, 1, 3, 4));
|
||||
var job = new NestJob(new[] { part }, new[] { stock });
|
||||
var runner = new NestJobRunner(_ => new CandidateNester(new[] { new NestJobPlacement("part", 0, 26, 1, 0) }));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => runner.Solve(job));
|
||||
|
||||
Assert.Equal(sourceGeometry, job.Parts[0].Geometry.Motions);
|
||||
Assert.Equal(1, job.Parts[0].Quantity);
|
||||
Assert.Equal(1, job.Plates[0].Quantity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 0, 0)]
|
||||
[InlineData(2, -11, 0)]
|
||||
[InlineData(3, -11, -7)]
|
||||
[InlineData(4, 0, -7)]
|
||||
public void UnequalRectanglesFitAtEachQuadrantsUsableOrigin(int quadrant, double x, double y)
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 3)), 1);
|
||||
var stock = new NestPlateStock("stock", new Size(7, 11), 1, quadrant: quadrant);
|
||||
var job = new NestJob(new[] { part }, new[] { stock });
|
||||
|
||||
var result = Solve(job, new NestJobPlacement("part", 0, x, y, 0));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(new NestJobPlacement("part", 0, x, y, 0), Assert.Single(result.Plates[0].Placements));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedAndBoundedRotationPoliciesRejectDisallowedAngles()
|
||||
{
|
||||
var fixedPart = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)), 1,
|
||||
rotation: RotationPolicy.Fixed(System.Math.PI / 2));
|
||||
var fixedJob = new NestJob(new[] { fixedPart }, new[] { new NestPlateStock("stock", new Size(10, 10), 1) });
|
||||
Assert.Throws<InvalidOperationException>(() => Solve(fixedJob, new NestJobPlacement("part", 0, 0, 0, 0)));
|
||||
|
||||
var fixedResult = Solve(fixedJob, new NestJobPlacement("part", 0, 2, 0, System.Math.PI / 2));
|
||||
Assert.Equal(NestJobStatus.Complete, fixedResult.Status);
|
||||
|
||||
var boundedPart = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)), 1,
|
||||
rotation: RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4));
|
||||
var boundedJob = new NestJob(new[] { boundedPart }, new[] { new NestPlateStock("stock", new Size(10, 10), 1) });
|
||||
Assert.Throws<InvalidOperationException>(() => Solve(boundedJob,
|
||||
new NestJobPlacement("part", 0, 2, 0, System.Math.PI / 3)));
|
||||
|
||||
var boundedResult = Solve(boundedJob, new NestJobPlacement("part", 0, 2, 0, System.Math.PI / 4));
|
||||
Assert.Equal(NestJobStatus.Complete, boundedResult.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeTouchingIsAllowedAtZeroSpacingAndRejectedAtPositiveSpacing()
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 2);
|
||||
var touching = new[]
|
||||
{
|
||||
new NestJobPlacement("part", 0, 0, 0, 0),
|
||||
new NestJobPlacement("part", 1, 2, 0, 0)
|
||||
};
|
||||
var zeroSpacing = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(10, 10), 1) });
|
||||
var positiveSpacing = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(10, 10), 1, 0.1) });
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, Solve(zeroSpacing, touching).Status);
|
||||
Assert.Throws<InvalidOperationException>(() => Solve(positiveSpacing, touching));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlapAndContainmentAreRejected()
|
||||
{
|
||||
var outer = new NestJobPart("outer", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 6)), 1);
|
||||
var inner = new NestJobPart("inner", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 1);
|
||||
var job = new NestJob(new[] { outer, inner }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Solve(job,
|
||||
new NestJobPlacement("outer", 0, 0, 0, 0),
|
||||
new NestJobPlacement("inner", 0, 2, 2, 0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyStockStopsWithoutCallingCandidateNester()
|
||||
{
|
||||
var nester = new CountingNester();
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 1);
|
||||
var job = new NestJob(new[] { part }, Array.Empty<NestPlateStock>());
|
||||
|
||||
var result = new NestJobRunner(_ => nester).Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(0, nester.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Default")]
|
||||
[InlineData("Strip")]
|
||||
public void RealEngineSmokeCasesPreserveInputAndProduceSafeAccounting(string strategy)
|
||||
{
|
||||
var drawing = new Drawing("generated rectangle", TestDrawingFactory.Rectangle(6, 4));
|
||||
var part = DrawingJobMapper.FromDrawing("part", drawing, 3);
|
||||
var sourceGeometry = part.Geometry.Motions.ToArray();
|
||||
var stock = new NestPlateStock("stock", new Size(30, 50), 1, 1, new Spacing(1, 1, 1, 1));
|
||||
var job = new NestJob(new[] { part }, new[] { stock }, new NestJobOptions(strategy));
|
||||
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job);
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.All(result.Fulfillment, fulfillment => Assert.Equal(fulfillment.Requested,
|
||||
fulfillment.Placed + fulfillment.Unplaced));
|
||||
Assert.Equal(sourceGeometry, job.Parts[0].Geometry.Motions);
|
||||
Assert.Equal(3, job.Parts[0].Quantity);
|
||||
Assert.Equal(1, job.Plates[0].Quantity);
|
||||
Assert.All(materialized.Nest.Plates, plate =>
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
Assert.All(plate.Parts, placed =>
|
||||
{
|
||||
Assert.True(placed.BoundingBox.Left >= workArea.Left - 1e-6);
|
||||
Assert.True(placed.BoundingBox.Right <= workArea.Right + 1e-6);
|
||||
Assert.True(placed.BoundingBox.Bottom >= workArea.Bottom - 1e-6);
|
||||
Assert.True(placed.BoundingBox.Top <= workArea.Top + 1e-6);
|
||||
});
|
||||
for (var left = 0; left < plate.Parts.Count; left++)
|
||||
for (var right = left + 1; right < plate.Parts.Count; right++)
|
||||
Assert.False(plate.Parts[left].Intersects(plate.Parts[right], out _));
|
||||
});
|
||||
}
|
||||
|
||||
private static NestJobResult Solve(NestJob job, params NestJobPlacement[] placements) =>
|
||||
new NestJobRunner(_ => new CandidateNester(placements)).Solve(job);
|
||||
|
||||
private sealed class CandidateNester(IEnumerable<NestJobPlacement> placements) : IPlateNester
|
||||
{
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => new(placements);
|
||||
}
|
||||
|
||||
private sealed class CountingNester : IPlateNester
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
Calls++;
|
||||
return new PlateCandidate(Array.Empty<NestJobPlacement>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Names are never identity: two distinct drawings that share a display name must keep
|
||||
/// independent quantities, and two requirements that share one source drawing must not
|
||||
/// cross-count each other's placements through the legacy engine paths.
|
||||
/// </summary>
|
||||
public class NestJobIdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void DistinctDrawingsWithSameNameKeepIndependentQuantitiesInRealEngine()
|
||||
{
|
||||
var a = new Drawing("identical", TestDrawingFactory.Rectangle(40, 40));
|
||||
var b = new Drawing("identical", TestDrawingFactory.Rectangle(40, 40));
|
||||
var job = new NestJob(new[]
|
||||
{
|
||||
DrawingJobMapper.FromDrawing("a", a, 2),
|
||||
DrawingJobMapper.FromDrawing("b", b, 2)
|
||||
}, new[] { new NestPlateStock("s", new Size(90, 90), 1) });
|
||||
|
||||
var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job);
|
||||
|
||||
// Every placed part maps to a known requirement ID; no part is invented or cross-counted.
|
||||
Assert.True(result.Plates.SelectMany(p => p.Placements).All(p => p.PartId is "a" or "b"));
|
||||
var counts = result.Plates.SelectMany(p => p.Placements).GroupBy(p => p.PartId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
foreach (var (id, placed) in counts)
|
||||
Assert.True(placed <= 2, $"Requirement {id} placed {placed} > requested 2");
|
||||
|
||||
// Fulfillment conservation for both IDs.
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoRequirementsOnSameSourceDrawingKeepIndependentQuantities()
|
||||
{
|
||||
var source = new Drawing("shared", TestDrawingFactory.Rectangle(30, 30));
|
||||
var job = new NestJob(new[]
|
||||
{
|
||||
DrawingJobMapper.FromDrawing("first", source, 2),
|
||||
DrawingJobMapper.FromDrawing("second", source, 2)
|
||||
}, new[] { new NestPlateStock("s", new Size(90, 90), 1) });
|
||||
|
||||
var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job);
|
||||
|
||||
Assert.Equal(new[] { "first", "second" }, result.Fulfillment.Select(f => f.PartId));
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
|
||||
// Output drawings are distinct even though the input is the same Drawing instance.
|
||||
var output = NestResultMaterializer.Materialize(job, result);
|
||||
Assert.NotSame(output.DrawingsByPartId["first"], output.DrawingsByPartId["second"]);
|
||||
// Caller source is untouched.
|
||||
Assert.Equal(0, source.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EngineDeductionCountsByDrawingReferenceNotName()
|
||||
{
|
||||
// Plate 90x40 fits exactly two 40x40 parts. The engine fills item A with both and
|
||||
// starves item B. Name-based deduction would then zero BOTH items (the two placed
|
||||
// parts carry the shared name, so each item counts 2 as "its own"). Reference-based
|
||||
// deduction leaves B at 2.
|
||||
var a = new Drawing("dup", TestDrawingFactory.Rectangle(40, 40));
|
||||
var b = new Drawing("dup", TestDrawingFactory.Rectangle(40, 40));
|
||||
var plate = new Plate(new Size(90, 40));
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = a, Quantity = 2 },
|
||||
new() { Drawing = b, Quantity = 2 }
|
||||
};
|
||||
// Place exactly 2 parts from item A and none from item B, then run the base-class
|
||||
// deduction. Deterministic regardless of any fill heuristic.
|
||||
var placed = new StarvingProbe(plate).Nest(items, null, default);
|
||||
|
||||
var aPlaced = placed.Count(p => ReferenceEquals(p.BaseDrawing, a));
|
||||
var bPlaced = placed.Count(p => ReferenceEquals(p.BaseDrawing, b));
|
||||
Assert.Equal(2, placed.Count);
|
||||
Assert.Equal(2, aPlaced);
|
||||
Assert.Equal(0, bPlaced);
|
||||
|
||||
// Invariant: remaining = requested - own placements. Under name-based counting,
|
||||
// both items would read 0 here because the two placed parts match the shared name.
|
||||
Assert.Equal(0, items[0].Quantity);
|
||||
Assert.Equal(2, items[1].Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameNameSinglesAreBothReturnedByPackPhase()
|
||||
{
|
||||
var a = new Drawing("samesingle", TestDrawingFactory.Rectangle(30, 30));
|
||||
var b = new Drawing("samesingle", TestDrawingFactory.Rectangle(30, 30));
|
||||
var plate = new Plate(new Size(100, 100));
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = a, Quantity = 1 },
|
||||
new() { Drawing = b, Quantity = 1 }
|
||||
};
|
||||
var placed = new BaseNestEngineProbe(plate).Nest(items, null, default);
|
||||
Assert.Equal(2, placed.Count);
|
||||
Assert.Equal(new[] { 0, 0 }, new[] { items[0].Quantity, items[1].Quantity });
|
||||
}
|
||||
|
||||
private sealed class BaseNestEngineProbe(Plate plate) : NestEngineBase(plate)
|
||||
{
|
||||
public override string Name => "probe";
|
||||
public override string Description => "probe";
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
=> new DefaultNestEngine(Plate).Fill(item, workArea, progress, token);
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
=> new DefaultNestEngine(Plate).Fill(groupParts, workArea, progress, token);
|
||||
public override List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
=> new DefaultNestEngine(Plate).PackArea(box, items, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>Places exactly 2 parts from the first multi-quantity item and none from the
|
||||
/// rest, forcing the base-class deduction to run on an asymmetric placement result.</summary>
|
||||
private sealed class StarvingProbe(Plate plate) : NestEngineBase(plate)
|
||||
{
|
||||
private int _first = -1;
|
||||
public override string Name => "starving";
|
||||
public override string Description => "starves all but the first fill item";
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (_first < 0) _first = 1;
|
||||
if (_first++ != 1)
|
||||
return new List<Part>();
|
||||
var parts = new List<Part>();
|
||||
var x = 0.0;
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var p = new Part(item.Drawing);
|
||||
p.Offset(new Vector(x, 0));
|
||||
x += item.Drawing.Program.BoundingBox().Width + Plate.PartSpacing;
|
||||
parts.Add(p);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestJobRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyJobCompletesWithoutPlatesOrPlacementWork()
|
||||
{
|
||||
var fake = new FakePlateNester();
|
||||
var factoryCalls = 0;
|
||||
var runner = new NestJobRunner(_ => { factoryCalls++; return fake; });
|
||||
var job = new NestJob(Array.Empty<NestJobPart>(), new[]
|
||||
{
|
||||
new NestPlateStock("finite", new Size(100, 200), 2),
|
||||
new NestPlateStock("unlimited", new Size(100, 200))
|
||||
});
|
||||
|
||||
var result = runner.Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.Completed, result.StopReason);
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Empty(result.Fulfillment);
|
||||
Assert.Collection(result.StockUsage,
|
||||
usage => { Assert.Equal(0, usage.Used); Assert.Equal(2, usage.Remaining); },
|
||||
usage => { Assert.Equal(0, usage.Used); Assert.Null(usage.Remaining); });
|
||||
Assert.Equal(0, factoryCalls);
|
||||
Assert.Equal(0, fake.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreCancelledEmptyJobThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var runner = new NestJobRunner(_ => new FakePlateNester());
|
||||
var job = new NestJob(Array.Empty<NestJobPart>(), Array.Empty<NestPlateStock>());
|
||||
Assert.Throws<OperationCanceledException>(() => runner.Solve(job, token: cancellation.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyStockReturnsIncomplete()
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 1);
|
||||
var job = new NestJob(new[] { part }, Array.Empty<NestPlateStock>());
|
||||
var runner = new NestJobRunner(_ => new FakePlateNester());
|
||||
var result = runner.Solve(job);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JobOwnsCollectionsSettingsAndExactGeometryIncludingHoleArc()
|
||||
{
|
||||
var program = TestDrawingFactory.Rectangle();
|
||||
program.MoveTo(3.123456789, 4);
|
||||
program.ArcTo(3.123456789, 4, 4, 4, RotationType.CW);
|
||||
var geometry = PartGeometrySnapshot.FromProgram(program);
|
||||
var parts = new List<NestJobPart> { new("p", geometry, 3) };
|
||||
var size = new Size(100, 200);
|
||||
var edges = new Spacing(1, 2, 3, 4);
|
||||
var stocks = new List<NestPlateStock> { new("s", size, 0, 2, edges, 3) };
|
||||
var job = new NestJob(parts, stocks);
|
||||
parts.Clear(); stocks.Clear(); program.Codes.Clear(); size.Width = 0; edges.Left = 999;
|
||||
|
||||
Assert.Single(job.Parts);
|
||||
Assert.Equal(3, job.Parts[0].Quantity);
|
||||
Assert.Equal(7, geometry.Motions.Count);
|
||||
Assert.Equal(CodeType.RapidMove, geometry.Motions[5].Type);
|
||||
Assert.Equal(CodeType.ArcMove, geometry.Motions[6].Type);
|
||||
Assert.Equal(3.123456789, geometry.Motions[6].X);
|
||||
Assert.Equal(4, geometry.Motions[6].CenterX);
|
||||
Assert.Equal(RotationType.CW, geometry.Motions[6].Rotation);
|
||||
Assert.Equal(100, job.Plates[0].Size.Width);
|
||||
Assert.Equal(1, job.Plates[0].EdgeSpacing.Left);
|
||||
Assert.Equal(0, job.Plates[0].Quantity);
|
||||
Assert.Equal("Default", job.Options.PlacementStrategy);
|
||||
Assert.Throws<NotSupportedException>(() => ((IList<NestJobPart>)job.Parts).Clear());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyZeroStepMeansAutomaticNotFixed()
|
||||
{
|
||||
Assert.Equal(RotationPolicyKind.Automatic, RotationPolicy.FromLegacy(0, 1, 2).Kind);
|
||||
Assert.Equal(RotationPolicyKind.Fixed, RotationPolicy.Fixed(1).Kind);
|
||||
Assert.Equal(RotationPolicyKind.BoundedSweep, RotationPolicy.BoundedSweep(0, 1, 0.5).Kind);
|
||||
}
|
||||
|
||||
private sealed class FakePlateNester : IPlateNester
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
Calls++;
|
||||
return new PlateCandidate(Array.Empty<NestJobPlacement>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestJobStockSelectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void LaterFittingStockWinsWhenFirstStockCannotPlace()
|
||||
{
|
||||
var result = Solve(new[] { Part("p", 1) }, new[] { Stock("small", 10, 10, 1), Stock("large", 20, 20, 1) },
|
||||
request => request.Stock.Id == "large" ? Candidate(request, "p") : Empty());
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("large", Assert.Single(result.Plates).StockId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExhaustedLargeStockIsNotRecreatedWhileSmallerStockServesSmallParts()
|
||||
{
|
||||
var result = Solve(new[] { Part("large", 1, 0), Part("small", 2, 1) },
|
||||
new[] { Stock("large", 20, 20, 1), Stock("small", 10, 10, 2) }, request => request.Stock.Id switch
|
||||
{
|
||||
"large" when request.Parts.Any(part => part.Id == "large") => Candidate(request, "large"),
|
||||
"small" when request.Parts.Any(part => part.Id == "small") => Candidate(request, "small"),
|
||||
_ => Empty()
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "large", "small", "small" }, result.Plates.Select(plate => plate.StockId));
|
||||
Assert.Collection(result.StockUsage,
|
||||
usage => Assert.Equal(new StockUsage("large", 1, 0), usage),
|
||||
usage => Assert.Equal(new StockUsage("small", 2, 0), usage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualDimensionsWithDifferentStockIdsRemainIndependent()
|
||||
{
|
||||
var result = Solve(new[] { Part("p", 2) }, new[] { Stock("first", 10, 10, 1), Stock("second", 10, 10, 1) },
|
||||
request => Candidate(request, "p"));
|
||||
|
||||
Assert.Equal(new[] { "first", "second" }, result.Plates.Select(plate => plate.StockId));
|
||||
Assert.Equal(new[] { new StockUsage("first", 1, 0), new StockUsage("second", 1, 0) }, result.StockUsage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LosingTrialsDoNotConsumeStockPartsOrDrawingCounters()
|
||||
{
|
||||
var calls = new List<(string Stock, int Quantity)>();
|
||||
var result = Solve(new[] { Part("p", 2) }, new[] { Stock("wide", 20, 20, 2), Stock("narrow", 10, 10, 2) }, request =>
|
||||
{
|
||||
calls.Add((request.Stock.Id, request.Parts.Single().Quantity));
|
||||
return request.Stock.Id == "wide" ? Candidate(request, "p", 0, 100) : Candidate(request, "p", 0, 0);
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "narrow", "narrow" }, result.Plates.Select(plate => plate.StockId));
|
||||
Assert.Equal(new[] { ("wide", 2), ("narrow", 2), ("wide", 1), ("narrow", 1) }, calls);
|
||||
Assert.Equal(new StockUsage("wide", 0, 2), result.StockUsage[0]);
|
||||
Assert.Equal(new StockUsage("narrow", 2, 0), result.StockUsage[1]);
|
||||
Assert.Equal(new[] { 0, 1 }, result.Plates.SelectMany(plate => plate.Placements).Select(placement => placement.InstanceIndex));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CandidatePriorityAreaEnvelopeAndInputOrderAreComparedInDocumentedOrder()
|
||||
{
|
||||
var priority = Solve(new[] { Part("high", 1, 0), Part("low", 1, 1) }, new[] { Stock("a", 10, 10, 1), Stock("b", 10, 10, 1) },
|
||||
request => request.Stock.Id == "a" ? Candidate(request, "low") : Candidate(request, "high"));
|
||||
var area = Solve(new[] { Part("p", 1) }, new[] { Stock("large", 20, 20, 1), Stock("small", 10, 10, 1) },
|
||||
request => Candidate(request, "p"));
|
||||
var envelope = Solve(new[] { Part("p", 2) }, new[] { Stock("a", 10, 10, 1), Stock("b", 10, 10, 1) },
|
||||
request => request.Stock.Id == "a" ? CandidatePair("p", 4, 5) : CandidatePair("p", 4, 0));
|
||||
var inputOrder = Solve(new[] { Part("p", 1) }, new[] { Stock("first", 10, 10, 1), Stock("second", 10, 10, 1) },
|
||||
request => Candidate(request, "p"));
|
||||
|
||||
Assert.Equal("b", priority.Plates[0].StockId);
|
||||
Assert.Equal("small", area.Plates[0].StockId);
|
||||
Assert.Equal("b", envelope.Plates[0].StockId);
|
||||
Assert.Equal("first", inputOrder.Plates[0].StockId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnlimitedStockStopsWhenDemandIsFulfilledAndPlateLimitLeavesLeftovers()
|
||||
{
|
||||
var unlimited = Solve(new[] { Part("p", 2) }, new[] { Stock("u", 10, 10, null) }, request => Candidate(request, "p"));
|
||||
var limited = Solve(new[] { Part("p", 3) }, new[] { Stock("u", 10, 10, null) }, request => Candidate(request, "p"), new NestJobOptions(maxPlates: 2));
|
||||
|
||||
Assert.Equal(NestJobStopReason.Completed, unlimited.StopReason);
|
||||
Assert.Equal(2, unlimited.Plates.Count);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, limited.StopReason);
|
||||
Assert.Equal(new PartFulfillment("p", 3, 2, 1), Assert.Single(limited.Fulfillment));
|
||||
}
|
||||
|
||||
private static NestJobResult Solve(IEnumerable<NestJobPart> parts, IEnumerable<NestPlateStock> stock,
|
||||
Func<PlatePlacementRequest, PlateCandidate> place, NestJobOptions? options = null) =>
|
||||
new NestJobRunner(_ => new Nester(place)).Solve(new NestJob(parts, stock, options));
|
||||
|
||||
private static NestJobPart Part(string id, int quantity, int priority = 0) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 5)), quantity, priority);
|
||||
|
||||
private static NestPlateStock Stock(string id, double width, double length, int? quantity) =>
|
||||
new(id, new Size(width, length), quantity);
|
||||
|
||||
private static PlateCandidate Candidate(PlatePlacementRequest request, string id, double firstX = 0, double secondX = 0)
|
||||
{
|
||||
return new PlateCandidate(new[] { new NestJobPlacement(id, 0, firstX, 0, 0) });
|
||||
}
|
||||
|
||||
private static PlateCandidate CandidatePair(string id, double secondX, double secondY) => new(new[]
|
||||
{
|
||||
new NestJobPlacement(id, 0, 0, 0, 0),
|
||||
new NestJobPlacement(id, 1, secondX, secondY, 0)
|
||||
});
|
||||
|
||||
private static PlateCandidate Empty() => new(Array.Empty<NestJobPlacement>());
|
||||
|
||||
private sealed class Nester(Func<PlatePlacementRequest, PlateCandidate> place) : IPlateNester
|
||||
{
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => place(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestJobValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void IncrementalContoursUseAccumulatedCoordinates()
|
||||
{
|
||||
var program = new Program(Mode.Incremental);
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(4, 0);
|
||||
program.LineTo(0, 3);
|
||||
program.LineTo(-4, 0);
|
||||
program.LineTo(0, -3);
|
||||
var job = new NestJob(new[]
|
||||
{
|
||||
new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 1)
|
||||
}, new[] { new NestPlateStock("stock", new Size(10, 10), 1) });
|
||||
|
||||
var result = Solve(job, new NestJobPlacement("part", 0, 0, 0, 0));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(new PartFulfillment("part", 1, 1, 0), Assert.Single(result.Fulfillment));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CandidateInsideAnotherRequirementsHoleDoesNotOverlapMaterial()
|
||||
{
|
||||
var outer = new NestJobPart("outer", PartGeometrySnapshot.FromProgram(RectangleWithHole()), 1);
|
||||
var inner = new NestJobPart("inner", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 1);
|
||||
var job = new NestJob(new[] { outer, inner }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
var result = Solve(job,
|
||||
new NestJobPlacement("outer", 0, 0, 0, 0),
|
||||
new NestJobPlacement("inner", 0, 4, 4, 0));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(2, result.Plates[0].Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownOrOverproducingCandidateFailsBeforeCommitWithoutChangingInput()
|
||||
{
|
||||
var reports = new List<NestJobProgress>();
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 1);
|
||||
var stock = new NestPlateStock("stock", new Size(20, 20), 1);
|
||||
var job = new NestJob(new[] { part }, new[] { stock });
|
||||
var runner = new NestJobRunner(_ => new CandidateNester(new[]
|
||||
{
|
||||
new NestJobPlacement("part", 0, 0, 0, 0),
|
||||
new NestJobPlacement("unknown", 0, 4, 0, 0)
|
||||
}));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => runner.Solve(job, new InlineProgress(reports.Add)));
|
||||
|
||||
Assert.Equal(1, job.Parts[0].Quantity);
|
||||
Assert.Equal(1, job.Plates[0].Quantity);
|
||||
Assert.DoesNotContain(reports, report => report.Stage == NestJobStage.PlateCommitted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CandidateThatOverproducesIsRejectedRatherThanClamped()
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(2, 2)), 1);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Solve(job,
|
||||
new NestJobPlacement("part", 0, 0, 0, 0),
|
||||
new NestJobPlacement("part", 1, 4, 0, 0)));
|
||||
|
||||
Assert.Equal(1, job.Parts[0].Quantity);
|
||||
Assert.Equal(1, job.Plates[0].Quantity);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void OpenOrZeroLengthContoursAreRejected(bool zeroLength)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(4, 0);
|
||||
if (zeroLength) program.LineTo(4, 0);
|
||||
program.LineTo(4, 3);
|
||||
program.LineTo(0, 3);
|
||||
if (zeroLength) program.LineTo(0, 0);
|
||||
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 1) },
|
||||
new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
Assert.Throws<ArgumentException>(() => new NestJobRunner(_ => new CandidateNester(Array.Empty<NestJobPlacement>())).Solve(job));
|
||||
}
|
||||
|
||||
private static NestJobResult Solve(NestJob job, params NestJobPlacement[] placements) =>
|
||||
new NestJobRunner(_ => new CandidateNester(placements)).Solve(job);
|
||||
|
||||
private static Program RectangleWithHole()
|
||||
{
|
||||
var program = TestDrawingFactory.Rectangle(10, 10);
|
||||
program.MoveTo(3, 3);
|
||||
program.LineTo(3, 7);
|
||||
program.LineTo(7, 7);
|
||||
program.LineTo(7, 3);
|
||||
program.LineTo(3, 3);
|
||||
return program;
|
||||
}
|
||||
|
||||
private sealed class CandidateNester(IEnumerable<NestJobPlacement> placements) : IPlateNester
|
||||
{
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => new(placements);
|
||||
}
|
||||
|
||||
private sealed class InlineProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value) => report(value);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
internal static class TestDrawingFactory
|
||||
{
|
||||
public static Program Rectangle(double width = 10, double length = 20)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(width, 0);
|
||||
program.LineTo(width, length);
|
||||
program.LineTo(0, length);
|
||||
program.LineTo(0, 0);
|
||||
return program;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../OpenNest.Engine/OpenNest.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -130,17 +131,13 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
var placed = filler.FillItems(workItems, shrinkWrapper, token);
|
||||
|
||||
// Build leftovers: compare placed count to original quantities.
|
||||
// RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
|
||||
// Build leftovers: compare placed count to original quantities by drawing
|
||||
// reference. RemnantFiller.FillItems does NOT mutate NestItem.Quantity.
|
||||
var leftovers = new List<NestItem>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var placedCount = 0;
|
||||
foreach (var p in placed)
|
||||
{
|
||||
if (p.BaseDrawing.Name == item.Drawing.Name)
|
||||
placedCount++;
|
||||
}
|
||||
var placedCount = placed.Count(p =>
|
||||
ReferenceEquals(p.BaseDrawing, item.Drawing));
|
||||
|
||||
if (item.Quantity <= 0)
|
||||
continue; // unlimited items are always "satisfied" — no leftover
|
||||
@@ -222,8 +219,10 @@ namespace OpenNest.Engine.Fill
|
||||
if (strips.Count <= 1)
|
||||
return;
|
||||
|
||||
var gap = stripMin(strips[1]) - stripMax(strips[0]);
|
||||
|
||||
// Use the required clearance as the inter-strip gap, not a gap sampled from one
|
||||
// original pair: actual placement gaps vary for irregular/mixed-size geometry, and
|
||||
// replaying a larger sampled gap across every reordered pair can push the trailing
|
||||
// strip past the original (already plate-fitted) footprint.
|
||||
strips.Sort((a, b) => sortMetric(a).CompareTo(sortMetric(b)));
|
||||
|
||||
var pos = primaryEdge(parts[0].BoundingBox);
|
||||
@@ -239,7 +238,7 @@ namespace OpenNest.Engine.Fill
|
||||
part.Offset(offset);
|
||||
}
|
||||
|
||||
pos = stripMax(s) + gap;
|
||||
pos = stripMax(s) + spacing;
|
||||
}
|
||||
|
||||
parts.Clear();
|
||||
|
||||
@@ -58,20 +58,20 @@ namespace OpenNest.Engine.Fill
|
||||
return allParts;
|
||||
}
|
||||
|
||||
private static Dictionary<string, int> BuildLocalQuantities(List<NestItem> items)
|
||||
private static Dictionary<Drawing, int> BuildLocalQuantities(List<NestItem> items)
|
||||
{
|
||||
var localQty = new Dictionary<string, int>(items.Count);
|
||||
var localQty = new Dictionary<Drawing, int>(items.Count, ReferenceEqualityComparer.Instance);
|
||||
foreach (var item in items)
|
||||
localQty[item.Drawing.Name] = item.Quantity;
|
||||
localQty[item.Drawing] = item.Quantity;
|
||||
return localQty;
|
||||
}
|
||||
|
||||
private static double FindMinItemDimension(List<NestItem> items, Dictionary<string, int> localQty)
|
||||
private static double FindMinItemDimension(List<NestItem> items, Dictionary<Drawing, int> localQty)
|
||||
{
|
||||
var minDim = double.MaxValue;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (localQty[item.Drawing.Name] <= 0)
|
||||
if (localQty[item.Drawing] <= 0)
|
||||
continue;
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var dim = System.Math.Min(bb.Width, bb.Length);
|
||||
@@ -84,7 +84,7 @@ namespace OpenNest.Engine.Fill
|
||||
private bool TryFillOneItem(
|
||||
List<NestItem> items,
|
||||
List<Box> freeBoxes,
|
||||
Dictionary<string, int> localQty,
|
||||
Dictionary<Drawing, int> localQty,
|
||||
Func<NestItem, Box, List<Part>> fillFunc,
|
||||
List<Part> allParts,
|
||||
CancellationToken token)
|
||||
@@ -94,7 +94,7 @@ namespace OpenNest.Engine.Fill
|
||||
if (token.IsCancellationRequested)
|
||||
return false;
|
||||
|
||||
var qty = localQty[item.Drawing.Name];
|
||||
var qty = localQty[item.Drawing];
|
||||
if (qty <= 0)
|
||||
continue;
|
||||
|
||||
@@ -110,7 +110,7 @@ namespace OpenNest.Engine.Fill
|
||||
RemoveTopmostPart(placed);
|
||||
|
||||
allParts.AddRange(placed);
|
||||
localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count);
|
||||
localQty[item.Drawing] = System.Math.Max(0, qty - placed.Count);
|
||||
|
||||
// Add the envelope of all placed parts as a single obstacle
|
||||
// rather than individual bounding boxes, preventing the
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Explicit-ID input mapping and exact supported-geometry reconstruction. Never retains caller objects.</summary>
|
||||
public static class DrawingJobMapper
|
||||
{
|
||||
public static NestJobPart FromDrawing(string partId, Drawing drawing, int quantity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawing);
|
||||
var constraints = drawing.Constraints;
|
||||
return new NestJobPart(partId, PartGeometrySnapshot.FromProgram(drawing.Program), quantity, drawing.Priority,
|
||||
constraints == null ? RotationPolicy.Automatic :
|
||||
RotationPolicy.FromLegacy(constraints.StepAngle, constraints.StartAngle, constraints.EndAngle));
|
||||
}
|
||||
|
||||
public static NestJobPart FromItem(string partId, NestItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ArgumentNullException.ThrowIfNull(item.Drawing);
|
||||
return new NestJobPart(partId, PartGeometrySnapshot.FromProgram(item.Drawing.Program), item.Quantity,
|
||||
item.Priority, RotationPolicy.FromLegacy(item.StepAngle, item.RotationStart, item.RotationEnd));
|
||||
}
|
||||
|
||||
/// <summary>Available stock is explicit; the legacy plate repeat count is not inventory.</summary>
|
||||
public static NestPlateStock FromPlate(string stockId, Plate plate, int? quantity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plate);
|
||||
return new NestPlateStock(stockId, plate.Size, quantity, plate.PartSpacing, plate.EdgeSpacing, plate.Quadrant);
|
||||
}
|
||||
|
||||
public static Program ToProgram(PartGeometrySnapshot geometry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(geometry);
|
||||
var program = new Program(geometry.Mode);
|
||||
foreach (var motion in geometry.Motions)
|
||||
{
|
||||
var code = motion.Type switch
|
||||
{
|
||||
CodeType.RapidMove => (Motion)new RapidMove(motion.X, motion.Y),
|
||||
CodeType.LinearMove => new LinearMove(motion.X, motion.Y) { Layer = motion.Layer },
|
||||
CodeType.ArcMove => new ArcMove(motion.X, motion.Y, motion.CenterX, motion.CenterY, motion.Rotation)
|
||||
{ Layer = motion.Layer },
|
||||
_ => throw new NotSupportedException("Unsupported snapshot motion.")
|
||||
};
|
||||
code.Suppressed = motion.Suppressed;
|
||||
program.Codes.Add(code);
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
internal static Drawing CreateDrawing(NestJobPart part)
|
||||
{
|
||||
var drawing = new Drawing(part.Id, ToProgram(part.Geometry)) { Priority = part.Priority };
|
||||
drawing.Quantity.Required = part.Quantity;
|
||||
drawing.Constraints = new NestConstraints
|
||||
{
|
||||
StepAngle = LegacyStep(part.Rotation),
|
||||
StartAngle = part.Rotation.Start,
|
||||
EndAngle = part.Rotation.End
|
||||
};
|
||||
return drawing;
|
||||
}
|
||||
|
||||
// A fixed angle needs a nonzero legacy step so it is not misread as automatic.
|
||||
internal static double LegacyStep(RotationPolicy policy) => policy.Kind == RotationPolicyKind.Fixed
|
||||
? OpenNest.Math.Angle.TwoPI : policy.Step;
|
||||
|
||||
internal static Plate CreatePlate(NestPlateStock stock) => new(stock.Size)
|
||||
{
|
||||
Quantity = 1,
|
||||
PartSpacing = stock.PartSpacing,
|
||||
EdgeSpacing = stock.EdgeSpacing,
|
||||
Quadrant = stock.Quadrant
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// A fresh private legacy plate/drawing/item graph for each call. Only returned poses cross the boundary;
|
||||
/// legacy quantity mutations are deliberately ignored. Does not certify geometric safety or rotation compliance.
|
||||
/// </summary>
|
||||
public sealed class LegacyPlateNesterAdapter : IPlateNester
|
||||
{
|
||||
private readonly Func<Plate, NestEngineBase> engineFactory;
|
||||
|
||||
public LegacyPlateNesterAdapter(Func<Plate, NestEngineBase> engineFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(engineFactory);
|
||||
this.engineFactory = engineFactory;
|
||||
}
|
||||
|
||||
/// <summary>Convenience overload delegating to <see cref="PlateNesterFactory"/> so strategy
|
||||
/// resolution has a single source of truth; rejects unknown keys. Never reads or changes the
|
||||
/// process-global NestEngineRegistry.</summary>
|
||||
public static IPlateNester Create(string strategy) => PlateNesterFactory.Create(strategy);
|
||||
|
||||
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>();
|
||||
var identities = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
|
||||
foreach (var requirement in request.Parts)
|
||||
{
|
||||
var drawing = DrawingJobMapper.CreateDrawing(requirement);
|
||||
identities.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("Legacy engine factory returned null.");
|
||||
var legacyProgress = progress == null ? null : new LegacyProgress(progress, request.Stock.Id);
|
||||
var parts = engine.Nest(items, legacyProgress, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (parts == null) throw new InvalidOperationException("Legacy engine returned null placements.");
|
||||
var placements = new List<NestJobPlacement>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part?.BaseDrawing == null || !identities.TryGetValue(part.BaseDrawing, out var id))
|
||||
throw new InvalidOperationException("Legacy placement does not reference a private requirement drawing.");
|
||||
placements.Add(new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation));
|
||||
}
|
||||
return new PlateCandidate(placements);
|
||||
}
|
||||
|
||||
private sealed class LegacyProgress(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,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>A detached mutable domain nest plus explicit requirement identity (never inferred from names).</summary>
|
||||
public sealed class MaterializedNestResult
|
||||
{
|
||||
internal MaterializedNestResult(Nest nest, Dictionary<string, Drawing> drawings)
|
||||
{
|
||||
Nest = nest;
|
||||
DrawingsByPartId = new ReadOnlyDictionary<string, Drawing>(drawings);
|
||||
}
|
||||
|
||||
public Nest Nest { get; }
|
||||
public IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
|
||||
}
|
||||
|
||||
/// <summary>Materializes a result from the same job. Geometry safety remains the solver's future validation boundary.</summary>
|
||||
public static class NestResultMaterializer
|
||||
{
|
||||
public static MaterializedNestResult Materialize(NestJob job, NestJobResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
var nest = new Nest();
|
||||
var drawings = job.Parts.ToDictionary(p => p.Id, DrawingJobMapper.CreateDrawing, StringComparer.Ordinal);
|
||||
foreach (var drawing in drawings.Values) nest.Drawings.Add(drawing);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var plate = DrawingJobMapper.CreatePlate(sheet.Stock);
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
if (!drawings.TryGetValue(pose.PartId, out var drawing))
|
||||
throw new ArgumentException("Result contains a requirement not present in the job.", nameof(result));
|
||||
// Do not use CreateAtOrigin: it normalizes bounds and would change the snapshot frame.
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
// Quantity=1 is set before the only attachment; Plate's event owns Nested accounting.
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
return new MaterializedNestResult(nest, drawings);
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Synchronous whole-job solver. Cancellation throws, rather than returning partial success.</summary>
|
||||
public interface INestingEngine
|
||||
{
|
||||
NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null, CancellationToken token = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Places on one sheet only. Must not change stock, demand, or caller-owned domain objects.</summary>
|
||||
public interface IPlateNester
|
||||
{
|
||||
PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>One material/unit system's requirements. Collections are copied; all nested values are immutable.</summary>
|
||||
public sealed class NestJob
|
||||
{
|
||||
public NestJob(IEnumerable<NestJobPart> parts, IEnumerable<NestPlateStock> plates, NestJobOptions options = null)
|
||||
{
|
||||
Parts = Own(parts);
|
||||
Plates = Own(plates);
|
||||
Options = options ?? new NestJobOptions();
|
||||
if (Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count ||
|
||||
Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count)
|
||||
throw new ArgumentException("Part and stock IDs must each be unique.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<NestJobPart> Parts { get; }
|
||||
public IReadOnlyList<NestPlateStock> Plates { get; }
|
||||
public NestJobOptions Options { get; }
|
||||
|
||||
internal static IReadOnlyList<T> Own<T>(IEnumerable<T> source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var values = source.ToArray();
|
||||
if (values.Any(value => value is null))
|
||||
throw new ArgumentException("Null entries are not allowed.", nameof(source));
|
||||
return Array.AsReadOnly(values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order.</summary>
|
||||
public sealed class NestJobCandidateComparer
|
||||
{
|
||||
private readonly IReadOnlyList<NestJobPart> parts;
|
||||
|
||||
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts)
|
||||
{
|
||||
this.parts = parts ?? throw new ArgumentNullException(nameof(parts));
|
||||
}
|
||||
|
||||
/// <summary>Returns positive when the left trial is preferred.</summary>
|
||||
public int Compare(PlateCandidate left, NestPlateStock leftStock, int leftIndex,
|
||||
PlateCandidate right, NestPlateStock rightStock, int rightIndex)
|
||||
{
|
||||
var priorities = parts.Select(part => part.Priority).Distinct().OrderBy(priority => priority);
|
||||
foreach (var priority in priorities)
|
||||
{
|
||||
var leftCount = Count(left, priority);
|
||||
var rightCount = Count(right, priority);
|
||||
if (leftCount != rightCount) return leftCount.CompareTo(rightCount);
|
||||
}
|
||||
|
||||
var area = Area(rightStock).CompareTo(Area(leftStock));
|
||||
if (area != 0) return area;
|
||||
|
||||
var envelope = Envelope(right).CompareTo(Envelope(left));
|
||||
if (envelope != 0) return envelope;
|
||||
|
||||
return rightIndex.CompareTo(leftIndex);
|
||||
}
|
||||
|
||||
private int Count(PlateCandidate candidate, int priority) => candidate.Placements.Count(placement =>
|
||||
parts.First(part => part.Id == placement.PartId).Priority == priority);
|
||||
|
||||
private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length;
|
||||
|
||||
private static double Envelope(PlateCandidate candidate)
|
||||
{
|
||||
if (candidate.Placements.Count == 0) return 0;
|
||||
var xs = candidate.Placements.Select(placement => placement.X);
|
||||
var ys = candidate.Placements.Select(placement => placement.Y);
|
||||
return (xs.Max() - xs.Min()) * (ys.Max() - ys.Min());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Immutable per-job options; selection never changes the legacy global registry.</summary>
|
||||
public sealed class NestJobOptions
|
||||
{
|
||||
public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
|
||||
if (maxPlates <= 0) throw new ArgumentOutOfRangeException(nameof(maxPlates));
|
||||
PlacementStrategy = placementStrategy;
|
||||
MaxPlates = maxPlates;
|
||||
}
|
||||
|
||||
public string PlacementStrategy { get; }
|
||||
/// <summary>Maximum physical sheets to commit, or null for no explicit cap.</summary>
|
||||
public int? MaxPlates { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>An immutable requirement, independent of drawing names, UI state, and drawing quantity counters.</summary>
|
||||
public sealed class NestJobPart
|
||||
{
|
||||
public NestJobPart(string id, PartGeometrySnapshot geometry, int quantity, int priority = 0,
|
||||
RotationPolicy rotation = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(geometry);
|
||||
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
|
||||
Id = id;
|
||||
Geometry = geometry;
|
||||
Quantity = quantity;
|
||||
Priority = priority;
|
||||
Rotation = rotation ?? RotationPolicy.Automatic;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public PartGeometrySnapshot Geometry { get; }
|
||||
/// <summary>Positive number requested; never decremented by placement code.</summary>
|
||||
public int Quantity { get; }
|
||||
public int Priority { get; }
|
||||
public RotationPolicy Rotation { get; }
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Validates a trial against immutable job geometry before the runner commits accounting.</summary>
|
||||
internal static class NestJobPlacementValidator
|
||||
{
|
||||
private const double Epsilon = 0.0000001;
|
||||
|
||||
internal static void ValidateCandidate(PlateCandidate candidate, NestPlateStock stock,
|
||||
IReadOnlyDictionary<string, int> remaining, IReadOnlyDictionary<string, NestJobPart> parts)
|
||||
{
|
||||
if (candidate == null) throw new InvalidOperationException("The plate nester returned a null candidate.");
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var placed = new List<ShapeTopology>();
|
||||
foreach (var placement in candidate.Placements)
|
||||
{
|
||||
if (placement.PartId == null || !remaining.TryGetValue(placement.PartId, out var available) ||
|
||||
!parts.TryGetValue(placement.PartId, out var part))
|
||||
throw new InvalidOperationException("Candidate references an unknown requirement ID.");
|
||||
if (!double.IsFinite(placement.X) || !double.IsFinite(placement.Y) || !double.IsFinite(placement.Rotation))
|
||||
throw new InvalidOperationException("Candidate poses must be finite.");
|
||||
counts.TryGetValue(placement.PartId, out var count);
|
||||
if (count >= available) throw new InvalidOperationException("Candidate overproduces a requirement.");
|
||||
if (!RotationIsAllowed(part.Rotation, placement.Rotation))
|
||||
throw new InvalidOperationException("Candidate rotation is not allowed for the requirement.");
|
||||
|
||||
var shape = Transform(CreateShape(part.Geometry), placement);
|
||||
if (!FitsWorkArea(shape, stock))
|
||||
throw new InvalidOperationException("Candidate placement falls outside the usable stock area.");
|
||||
foreach (var other in placed)
|
||||
{
|
||||
if (Overlaps(shape, other))
|
||||
throw new InvalidOperationException("Candidate placements overlap.");
|
||||
if (stock.PartSpacing > 0 && Distance(shape, other) < stock.PartSpacing - Epsilon)
|
||||
throw new InvalidOperationException("Candidate placements violate required part spacing.");
|
||||
}
|
||||
|
||||
placed.Add(shape);
|
||||
counts[placement.PartId] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ValidateGeometry(PartGeometrySnapshot geometry)
|
||||
{
|
||||
_ = CreateShape(geometry);
|
||||
}
|
||||
|
||||
private static bool RotationIsAllowed(RotationPolicy policy, double rotation)
|
||||
{
|
||||
if (policy.Kind == RotationPolicyKind.Automatic) return true;
|
||||
if (policy.Kind == RotationPolicyKind.Fixed)
|
||||
return AnglesEqual(rotation, policy.Start);
|
||||
if (rotation < policy.Start - Epsilon || rotation > policy.End + Epsilon) return false;
|
||||
var steps = (rotation - policy.Start) / policy.Step;
|
||||
return System.Math.Abs(steps - System.Math.Round(steps)) <= Epsilon;
|
||||
}
|
||||
|
||||
private static bool AnglesEqual(double left, double right)
|
||||
{
|
||||
var delta = (left - right) % (System.Math.PI * 2);
|
||||
return System.Math.Abs(delta) <= Epsilon || System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= Epsilon;
|
||||
}
|
||||
|
||||
private static ShapeTopology CreateShape(PartGeometrySnapshot geometry)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry));
|
||||
var cutEntities = new List<Entity>();
|
||||
foreach (var entity in entities)
|
||||
if (!ReferenceEquals(entity.Layer, SpecialLayers.Rapid))
|
||||
cutEntities.Add(entity);
|
||||
|
||||
var contours = ShapeBuilder.GetShapes(cutEntities);
|
||||
if (contours.Count == 0) throw new ArgumentException("Geometry must contain a closed contour.");
|
||||
foreach (var contour in contours)
|
||||
ValidateContour(contour);
|
||||
|
||||
var profile = new ShapeProfile(cutEntities);
|
||||
profile.NormalizeWinding();
|
||||
return new ShapeTopology(profile.Perimeter, profile.Cutouts);
|
||||
}
|
||||
|
||||
private static void ValidateContour(Shape contour)
|
||||
{
|
||||
if (!contour.IsClosed())
|
||||
throw new ArgumentException("Geometry must contain closed contours with usable edges.");
|
||||
foreach (var entity in contour.Entities)
|
||||
if (entity.Length <= Epsilon)
|
||||
throw new ArgumentException("Geometry contains a zero-length edge.");
|
||||
if (contour.Area() <= Epsilon)
|
||||
throw new ArgumentException("Geometry must contain non-degenerate contours.");
|
||||
}
|
||||
|
||||
private static ShapeTopology Transform(ShapeTopology source, NestJobPlacement placement)
|
||||
{
|
||||
var perimeter = TransformContour(source.Perimeter, placement);
|
||||
var cutouts = new List<Shape>(source.Cutouts.Count);
|
||||
foreach (var cutout in source.Cutouts)
|
||||
cutouts.Add(TransformContour(cutout, placement));
|
||||
return new ShapeTopology(perimeter, cutouts);
|
||||
}
|
||||
|
||||
private static Shape TransformContour(Shape source, NestJobPlacement placement)
|
||||
{
|
||||
var contour = (Shape)source.Clone();
|
||||
contour.Rotate(placement.Rotation);
|
||||
contour.Offset(placement.X, placement.Y);
|
||||
return contour;
|
||||
}
|
||||
|
||||
private static bool FitsWorkArea(ShapeTopology shape, NestPlateStock stock)
|
||||
{
|
||||
var workArea = WorkArea(stock);
|
||||
if (!FitsWorkArea(shape.Perimeter, workArea)) return false;
|
||||
foreach (var cutout in shape.Cutouts)
|
||||
if (!FitsWorkArea(cutout, workArea)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Box WorkArea(NestPlateStock stock)
|
||||
{
|
||||
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
|
||||
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
|
||||
return new Box(left + stock.EdgeSpacing.Left, bottom + stock.EdgeSpacing.Bottom,
|
||||
stock.Size.Length - stock.EdgeSpacing.Left - stock.EdgeSpacing.Right,
|
||||
stock.Size.Width - stock.EdgeSpacing.Bottom - stock.EdgeSpacing.Top);
|
||||
}
|
||||
|
||||
private static bool FitsWorkArea(Shape contour, Box workArea)
|
||||
{
|
||||
var bounds = contour.BoundingBox;
|
||||
return bounds.Left >= workArea.Left - Epsilon && bounds.Right <= workArea.Right + Epsilon &&
|
||||
bounds.Bottom >= workArea.Bottom - Epsilon && bounds.Top <= workArea.Top + Epsilon;
|
||||
}
|
||||
|
||||
private static bool Overlaps(ShapeTopology left, ShapeTopology right)
|
||||
{
|
||||
var leftPoly = ToPolygon(left.Perimeter);
|
||||
var rightPoly = ToPolygon(right.Perimeter);
|
||||
if (!leftPoly.BoundingBox.Intersects(rightPoly.BoundingBox))
|
||||
return false;
|
||||
// True material overlap requires shared interior area, not boundary touching.
|
||||
// Edge/corner contact (zero clearance) is a valid placement when part spacing is zero.
|
||||
return InteriorOverlap(leftPoly, left, rightPoly, right);
|
||||
}
|
||||
|
||||
private static bool InteriorOverlap(Polygon leftPoly, ShapeTopology left, Polygon rightPoly, ShapeTopology right)
|
||||
{
|
||||
// The intersection of two polygons is either empty, a region of positive area (true overlap),
|
||||
// or a zero-area line/point (boundary contact). Test the interior of the intersection region:
|
||||
// a point strictly inside BOTH perimeters and outside both parts' holes proves shared material.
|
||||
foreach (var point in InteriorWitnessPoints(leftPoly, rightPoly))
|
||||
{
|
||||
if (StrictlyInside(leftPoly, point) && !InAnyHole(left, point) &&
|
||||
StrictlyInside(rightPoly, point) && !InAnyHole(right, point))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points that lie in the interior of the perimeter-perimeter intersection when one exists.
|
||||
/// For each pair of crossing edges, the two interior-side vertices (one from each polygon)
|
||||
/// have their midpoint inside both perimeters; that midpoint is a witness of positive-area
|
||||
/// overlap. For containment, an interior vertex of the inner perimeter witnesses it.
|
||||
/// </summary>
|
||||
private static IEnumerable<Vector> InteriorWitnessPoints(Polygon left, Polygon right)
|
||||
{
|
||||
foreach (var l in left.ToLines())
|
||||
foreach (var r in right.ToLines())
|
||||
if (l.Intersects(r, out var pt) && pt.IsValid())
|
||||
{
|
||||
yield return Midpoint(l, pt);
|
||||
yield return Midpoint(r, pt);
|
||||
}
|
||||
// Containment: an interior point of one polygon inside the other. Use a point pulled
|
||||
// toward the centroid of each polygon from a vertex (guaranteed interior for simple shapes).
|
||||
foreach (var poly in new[] { left, right })
|
||||
{
|
||||
foreach (var vertex in poly.Vertices)
|
||||
{
|
||||
var centroid = Centroid(poly);
|
||||
yield return (vertex + centroid) * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector Midpoint(Line line, Vector point)
|
||||
{
|
||||
var other = line.StartPoint.DistanceTo(point) <= line.EndPoint.DistanceTo(point)
|
||||
? line.EndPoint
|
||||
: line.StartPoint;
|
||||
return (other + point) * 0.5;
|
||||
}
|
||||
|
||||
private static Vector Centroid(Polygon polygon)
|
||||
{
|
||||
var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count;
|
||||
var sum = Vector.Zero;
|
||||
for (var i = 0; i < n; i++)
|
||||
sum += polygon.Vertices[i];
|
||||
return sum / n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Winding-number point-in-polygon. Returns false for points on an edge or vertex.
|
||||
/// </summary>
|
||||
private static bool StrictlyInside(Polygon polygon, Vector point)
|
||||
{
|
||||
var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count;
|
||||
if (n < 3) return false;
|
||||
var winding = 0;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var p1 = polygon.Vertices[i];
|
||||
var p2 = polygon.Vertices[(i + 1) % n];
|
||||
if (OnSegment(p1, p2, point)) return false;
|
||||
if (p1.Y <= point.Y)
|
||||
{
|
||||
if (p2.Y > point.Y && IsLeft(p1, p2, point) > 0)
|
||||
winding++;
|
||||
}
|
||||
else if (p2.Y <= point.Y && IsLeft(p1, p2, point) < 0)
|
||||
{
|
||||
winding--;
|
||||
}
|
||||
}
|
||||
return winding != 0;
|
||||
}
|
||||
|
||||
private static bool OnSegment(Vector a, Vector b, Vector p)
|
||||
{
|
||||
var cross = (b.X - a.X) * (p.Y - a.Y) - (b.Y - a.Y) * (p.X - a.X);
|
||||
if (!cross.IsEqualTo(0.0)) return false;
|
||||
return System.Math.Min(a.X, b.X) - Epsilon <= p.X && p.X <= System.Math.Max(a.X, b.X) + Epsilon &&
|
||||
System.Math.Min(a.Y, b.Y) - Epsilon <= p.Y && p.Y <= System.Math.Max(a.Y, b.Y) + Epsilon;
|
||||
}
|
||||
|
||||
private static double IsLeft(Vector p1, Vector p2, Vector p) =>
|
||||
(p2.X - p1.X) * (p.Y - p1.Y) - (p2.Y - p1.Y) * (p.X - p1.X);
|
||||
|
||||
private static bool InAnyHole(ShapeTopology topology, Vector point)
|
||||
{
|
||||
foreach (var cutout in topology.Cutouts)
|
||||
if (ToPolygon(cutout).ContainsPoint(point))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double Distance(ShapeTopology left, ShapeTopology right)
|
||||
{
|
||||
var result = double.PositiveInfinity;
|
||||
foreach (var leftContour in AllContours(left))
|
||||
foreach (var rightContour in AllContours(right))
|
||||
result = System.Math.Min(result, BoundaryDistance(ToPolygon(leftContour), ToPolygon(rightContour)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IEnumerable<Shape> AllContours(ShapeTopology shape)
|
||||
{
|
||||
yield return shape.Perimeter;
|
||||
foreach (var cutout in shape.Cutouts)
|
||||
yield return cutout;
|
||||
}
|
||||
|
||||
private static List<Polygon> ToPolygons(List<Shape> contours)
|
||||
{
|
||||
var polygons = new List<Polygon>(contours.Count);
|
||||
foreach (var contour in contours)
|
||||
polygons.Add(ToPolygon(contour));
|
||||
return polygons;
|
||||
}
|
||||
|
||||
private static Polygon ToPolygon(Shape contour)
|
||||
{
|
||||
var polygon = contour.ToPolygon();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
private static double BoundaryDistance(Polygon left, Polygon right)
|
||||
{
|
||||
var result = double.PositiveInfinity;
|
||||
foreach (var leftLine in left.ToLines())
|
||||
{
|
||||
foreach (var rightLine in right.ToLines())
|
||||
{
|
||||
if (leftLine.Intersects(rightLine)) return 0;
|
||||
result = System.Math.Min(result, leftLine.ClosestPointTo(rightLine.StartPoint).DistanceTo(rightLine.StartPoint));
|
||||
result = System.Math.Min(result, leftLine.ClosestPointTo(rightLine.EndPoint).DistanceTo(rightLine.EndPoint));
|
||||
result = System.Math.Min(result, rightLine.ClosestPointTo(leftLine.StartPoint).DistanceTo(leftLine.StartPoint));
|
||||
result = System.Math.Min(result, rightLine.ClosestPointTo(leftLine.EndPoint).DistanceTo(leftLine.EndPoint));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private sealed class ShapeTopology(Shape perimeter, List<Shape> cutouts)
|
||||
{
|
||||
internal Shape Perimeter { get; } = perimeter;
|
||||
internal List<Shape> Cutouts { get; } = cutouts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace OpenNest;
|
||||
|
||||
public enum NestJobStage { EvaluatingCandidate, PlateCommitted }
|
||||
|
||||
/// <summary>
|
||||
/// Whole-job progress. Counts change only after a physical sheet commits; LegacyProgress is optional
|
||||
/// non-authoritative detail from a plate nester while its candidate remains under evaluation.
|
||||
/// </summary>
|
||||
public sealed record NestJobProgress(NestJobStage Stage, string StockId, int PlateIndex,
|
||||
int CommittedPlates, int CommittedParts, NestProgress LegacyProgress = null);
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public enum NestJobStatus { Complete, Incomplete }
|
||||
public enum NestJobStopReason { Completed, StockExhausted, NoPlacementFound, PlateLimitReached }
|
||||
|
||||
/// <summary>
|
||||
/// Rotate about the snapshot origin, then translate by X/Y into the selected plate quadrant frame.
|
||||
/// Rotation is in radians. InstanceIndex is zero-based and unique within a part requirement across the job.
|
||||
/// The runner assigns final instance indices when committing a candidate.
|
||||
/// </summary>
|
||||
public sealed record NestJobPlacement(string PartId, int InstanceIndex, double X, double Y, double Rotation);
|
||||
|
||||
/// <summary>Requested = Placed + Unplaced for a requirement ID.</summary>
|
||||
public sealed record PartFulfillment(string PartId, int Requested, int Placed, int Unplaced);
|
||||
|
||||
/// <summary>Used counts physical sheets; Remaining is null only for unlimited stock.</summary>
|
||||
public sealed record StockUsage(string StockId, int Used, int? Remaining);
|
||||
|
||||
/// <summary>One physical sheet, with owned ordered placements and immutable stock/settings snapshot.</summary>
|
||||
public sealed class NestJobPlateResult
|
||||
{
|
||||
public NestJobPlateResult(int plateIndex, NestPlateStock stock, IEnumerable<NestJobPlacement> placements)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stock);
|
||||
PlateIndex = plateIndex;
|
||||
Stock = stock;
|
||||
Placements = NestJob.Own(placements);
|
||||
}
|
||||
|
||||
public int PlateIndex { get; }
|
||||
public string StockId => Stock.Id;
|
||||
public NestPlateStock Stock { get; }
|
||||
public IReadOnlyList<NestJobPlacement> Placements { get; }
|
||||
}
|
||||
|
||||
/// <summary>Detached result values in commit/input order; no mutable Drawing, Plate, or NestItem escapes.</summary>
|
||||
public sealed class NestJobResult
|
||||
{
|
||||
public NestJobResult(NestJobStatus status, NestJobStopReason stopReason,
|
||||
IEnumerable<NestJobPlateResult> plates, IEnumerable<PartFulfillment> fulfillment,
|
||||
IEnumerable<StockUsage> stockUsage)
|
||||
{
|
||||
Status = status;
|
||||
StopReason = stopReason;
|
||||
Plates = NestJob.Own(plates);
|
||||
Fulfillment = NestJob.Own(fulfillment);
|
||||
StockUsage = NestJob.Own(stockUsage);
|
||||
}
|
||||
|
||||
public NestJobStatus Status { get; }
|
||||
public NestJobStopReason StopReason { get; }
|
||||
public IReadOnlyList<NestJobPlateResult> Plates { get; }
|
||||
public IReadOnlyList<PartFulfillment> Fulfillment { get; }
|
||||
public IReadOnlyList<StockUsage> StockUsage { get; }
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Physical-sheet allocation. Every available stock entry is tried independently and only the selected
|
||||
/// candidate changes demand or inventory accounting.
|
||||
/// </summary>
|
||||
public sealed class NestJobRunner : INestingEngine
|
||||
{
|
||||
private readonly Func<string, IPlateNester> plateNesterFactory;
|
||||
|
||||
/// <summary>Runner-local strategy resolution. A factory must reject unknown keys or return null.</summary>
|
||||
public NestJobRunner(Func<string, IPlateNester> plateNesterFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plateNesterFactory);
|
||||
this.plateNesterFactory = plateNesterFactory;
|
||||
}
|
||||
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.Validate(job);
|
||||
var plates = new List<NestJobPlateResult>();
|
||||
var remaining = job.Parts.ToDictionary(part => part.Id, part => part.Quantity, StringComparer.Ordinal);
|
||||
var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal);
|
||||
var parts = job.Parts.ToDictionary(part => part.Id, StringComparer.Ordinal);
|
||||
var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal);
|
||||
var comparer = new NestJobCandidateComparer(job.Parts);
|
||||
var nester = job.Parts.Count == 0 ? null : plateNesterFactory(job.Options.PlacementStrategy) ??
|
||||
throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}.");
|
||||
var reason = NestJobStopReason.Completed;
|
||||
while (remaining.Values.Any(count => count > 0))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (job.Options.MaxPlates <= plates.Count)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
CandidateTrial winner = null;
|
||||
var hasAvailableStock = false;
|
||||
for (var index = 0; index < job.Plates.Count; index++)
|
||||
{
|
||||
var stock = job.Plates[index];
|
||||
if (stock.Quantity is int quantity && used[stock.Id] >= quantity) continue;
|
||||
hasAvailableStock = true;
|
||||
var request = new PlatePlacementRequest(stock, job.Parts.Where(part => remaining[part.Id] > 0)
|
||||
.Select(part => new NestJobPart(part.Id, part.Geometry, remaining[part.Id], part.Priority, part.Rotation)));
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
|
||||
plates.Count, plates.Count, placed.Values.Sum()));
|
||||
token.ThrowIfCancellationRequested();
|
||||
var candidateProgress = progress == null ? null : new CandidateProgress(progress, stock.Id,
|
||||
plates.Count, plates.Count, placed.Values.Sum());
|
||||
var candidate = nester.Place(request, candidateProgress, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.ValidateCandidate(candidate, stock, remaining, parts);
|
||||
var trial = new CandidateTrial(candidate, stock, index);
|
||||
if (winner == null || comparer.Compare(trial.Candidate, trial.Stock, trial.StockIndex,
|
||||
winner.Candidate, winner.Stock, winner.StockIndex) > 0)
|
||||
winner = trial;
|
||||
}
|
||||
|
||||
if (!hasAvailableStock)
|
||||
{
|
||||
reason = NestJobStopReason.StockExhausted;
|
||||
break;
|
||||
}
|
||||
if (winner.Candidate.Placements.Count == 0)
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
|
||||
var committed = new List<NestJobPlacement>();
|
||||
foreach (var pose in winner.Candidate.Placements)
|
||||
{
|
||||
committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ });
|
||||
remaining[pose.PartId]--;
|
||||
}
|
||||
used[winner.Stock.Id]++;
|
||||
plates.Add(new NestJobPlateResult(plates.Count, winner.Stock, committed));
|
||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.Stock.Id,
|
||||
plates.Count - 1, plates.Count, placed.Values.Sum()));
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
reason, plates, job.Parts.Select(part => new PartFulfillment(part.Id, part.Quantity, placed[part.Id], remaining[part.Id])),
|
||||
job.Plates.Select(stock => new StockUsage(stock.Id, used[stock.Id],
|
||||
stock.Quantity is int quantity ? quantity - used[stock.Id] : null)));
|
||||
}
|
||||
|
||||
private sealed record CandidateTrial(PlateCandidate Candidate, NestPlateStock Stock, int StockIndex);
|
||||
|
||||
private sealed class CandidateProgress(IProgress<NestJobProgress> progress, string stockId, int plateIndex,
|
||||
int committedPlates, int committedParts) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
progress.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stockId, plateIndex,
|
||||
committedPlates, committedParts, value.LegacyProgress));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Basic input and candidate accounting checks, NOT a geometry/clearance safety gate.</summary>
|
||||
public static class NestJobValidator
|
||||
{
|
||||
public static void Validate(NestJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
var edges = stock.EdgeSpacing;
|
||||
if (!Positive(stock.Size.Width) || !Positive(stock.Size.Length) ||
|
||||
!Nonnegative(stock.PartSpacing) || !Nonnegative(edges.Left) || !Nonnegative(edges.Right) ||
|
||||
!Nonnegative(edges.Top) || !Nonnegative(edges.Bottom) || stock.Quadrant < 1 || stock.Quadrant > 4 ||
|
||||
edges.Left + edges.Right >= stock.Size.Length || edges.Top + edges.Bottom >= stock.Size.Width)
|
||||
throw new ArgumentException($"Invalid stock dimensions/settings: {stock.Id}.", nameof(job));
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
if (part.Geometry.Motions.Count == 0 || part.Geometry.Motions.Any(m =>
|
||||
!double.IsFinite(m.X) || !double.IsFinite(m.Y) ||
|
||||
!double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY)))
|
||||
throw new ArgumentException($"Geometry must contain finite motions: {part.Id}.", nameof(job));
|
||||
try
|
||||
{
|
||||
NestJobPlacementValidator.ValidateGeometry(part.Geometry);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}.", nameof(job), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ValidateCandidate(PlateCandidate candidate, NestPlateStock stock,
|
||||
IReadOnlyDictionary<string, int> remaining, IReadOnlyDictionary<string, NestJobPart> parts)
|
||||
{
|
||||
NestJobPlacementValidator.ValidateCandidate(candidate, stock, remaining, parts);
|
||||
}
|
||||
|
||||
private static bool Positive(double value) => double.IsFinite(value) && value > 0;
|
||||
private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Immutable stock settings. Size and spacing are copied value types, not caller-owned settings.</summary>
|
||||
public sealed class NestPlateStock
|
||||
{
|
||||
public NestPlateStock(string id, Size size, int? quantity = null, double partSpacing = 0,
|
||||
Spacing edgeSpacing = default, int quadrant = 1)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
if (quantity < 0) throw new ArgumentOutOfRangeException(nameof(quantity));
|
||||
Id = id;
|
||||
Size = size;
|
||||
Quantity = quantity;
|
||||
PartSpacing = partSpacing;
|
||||
EdgeSpacing = edgeSpacing;
|
||||
Quadrant = quadrant;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public Size Size { get; }
|
||||
/// <summary>Available physical sheets: null is unlimited, zero is legal but unavailable.</summary>
|
||||
public int? Quantity { get; }
|
||||
public double PartSpacing { get; }
|
||||
public Spacing EdgeSpacing { get; }
|
||||
public int Quadrant { get; }
|
||||
}
|
||||
@@ -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,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Exact immutable CNC motion values. Rapid moves retain contour/hole boundaries; arcs are not tessellated.</summary>
|
||||
public sealed record PartGeometryMotion(CodeType Type, double X, double Y, double CenterX,
|
||||
double CenterY, RotationType Rotation, LayerType Layer, bool Suppressed);
|
||||
|
||||
/// <summary>
|
||||
/// Owned geometry only: no Drawing, quantity, events, or mutable CNC references are retained.
|
||||
/// This initial boundary supports flat rapid/linear/arc programs and rejects other instructions explicitly.
|
||||
/// Coordinates and mode are preserved without normalization, rounding, or polygon approximation.
|
||||
/// </summary>
|
||||
public sealed class PartGeometrySnapshot
|
||||
{
|
||||
private PartGeometrySnapshot(Mode mode, IEnumerable<PartGeometryMotion> motions)
|
||||
{
|
||||
Mode = mode;
|
||||
Motions = NestJob.Own(motions);
|
||||
}
|
||||
|
||||
public Mode Mode { get; }
|
||||
public IReadOnlyList<PartGeometryMotion> Motions { get; }
|
||||
|
||||
/// <summary>Copies supported motion geometry immediately; later program edits cannot affect this snapshot.</summary>
|
||||
public static PartGeometrySnapshot FromProgram(Program program)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(program);
|
||||
var motions = program.Codes.Select(code => code switch
|
||||
{
|
||||
ArcMove arc => new PartGeometryMotion(arc.Type, arc.EndPoint.X, arc.EndPoint.Y,
|
||||
arc.CenterPoint.X, arc.CenterPoint.Y, arc.Rotation, arc.Layer, arc.Suppressed),
|
||||
LinearMove line => new PartGeometryMotion(line.Type, line.EndPoint.X, line.EndPoint.Y,
|
||||
0, 0, default, line.Layer, line.Suppressed),
|
||||
RapidMove rapid => new PartGeometryMotion(rapid.Type, rapid.EndPoint.X, rapid.EndPoint.Y,
|
||||
0, 0, default, default, rapid.Suppressed),
|
||||
_ => throw new NotSupportedException("Geometry snapshots currently support only flat rapid/linear/arc programs.")
|
||||
});
|
||||
return new PartGeometrySnapshot(program.Mode, motions);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Owned candidate poses only; not committed fulfillment or inventory accounting.</summary>
|
||||
public sealed class PlateCandidate
|
||||
{
|
||||
public PlateCandidate(IEnumerable<NestJobPlacement> placements) => Placements = NestJob.Own(placements);
|
||||
public IReadOnlyList<NestJobPlacement> Placements { get; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Instance-scoped strategy resolution for the whole-job runner. Default and Strip resolve to the
|
||||
/// migrated built-in plate nesters; the remnant strategies still use the legacy adapter during
|
||||
/// rollout. The process-global NestEngineRegistry (including plugin registrations and
|
||||
/// ActiveEngineName) is neither read nor modified. Unknown keys reject.
|
||||
/// </summary>
|
||||
public static class PlateNesterFactory
|
||||
{
|
||||
public static IPlateNester Create(string strategy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(strategy);
|
||||
return strategy switch
|
||||
{
|
||||
"Default" => new DefaultPlateNester(),
|
||||
"Strip" => new StripPlateNester(),
|
||||
"Vertical Remnant" => new LegacyPlateNesterAdapter(plate => new VerticalRemnantEngine(plate)),
|
||||
"Horizontal Remnant" => new LegacyPlateNesterAdapter(plate => new HorizontalRemnantEngine(plate)),
|
||||
_ => throw new NotSupportedException($"Unknown placement strategy: {strategy}.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Read-only stock settings and remaining requirements for a single candidate trial.</summary>
|
||||
public sealed class PlatePlacementRequest
|
||||
{
|
||||
public PlatePlacementRequest(NestPlateStock stock, IEnumerable<NestJobPart> parts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stock);
|
||||
Stock = stock;
|
||||
Parts = NestJob.Own(parts);
|
||||
}
|
||||
|
||||
public NestPlateStock Stock { get; }
|
||||
public IReadOnlyList<NestJobPart> Parts { get; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public enum RotationPolicyKind { Fixed, BoundedSweep, Automatic }
|
||||
|
||||
/// <summary>Immutable rotation constraints, in radians about the geometry origin.</summary>
|
||||
public sealed class RotationPolicy
|
||||
{
|
||||
private RotationPolicy(RotationPolicyKind kind, double start, double end, double step)
|
||||
{
|
||||
if (!double.IsFinite(start) || !double.IsFinite(end) || !double.IsFinite(step))
|
||||
throw new ArgumentException("Angles must be finite.");
|
||||
Kind = kind;
|
||||
Start = start;
|
||||
End = end;
|
||||
Step = step;
|
||||
}
|
||||
|
||||
public RotationPolicyKind Kind { get; }
|
||||
public double Start { get; }
|
||||
public double End { get; }
|
||||
public double Step { get; }
|
||||
public static RotationPolicy Automatic { get; } = new(RotationPolicyKind.Automatic, 0, 0, 0);
|
||||
public static RotationPolicy Fixed(double angle) => new(RotationPolicyKind.Fixed, angle, angle, 0);
|
||||
public static RotationPolicy BoundedSweep(double start, double end, double step)
|
||||
{
|
||||
if (step <= 0 || end < start) throw new ArgumentException("Sweep needs a positive step and ordered bounds.");
|
||||
return new RotationPolicy(RotationPolicyKind.BoundedSweep, start, end, step);
|
||||
}
|
||||
|
||||
/// <summary>Preserves the legacy zero-step automatic sentinel; zero never means locked rotation.</summary>
|
||||
public static RotationPolicy FromLegacy(double stepAngle, double rotationStart, double rotationEnd) =>
|
||||
stepAngle == 0 ? Automatic : BoundedSweep(rotationStart, rotationEnd, stepAngle);
|
||||
}
|
||||
@@ -113,11 +113,11 @@ namespace OpenNest
|
||||
{
|
||||
allParts.AddRange(fillParts);
|
||||
|
||||
// Deduct placed quantities
|
||||
// Deduct placed quantities by drawing reference, not name.
|
||||
foreach (var item in fillItems)
|
||||
{
|
||||
var placed = fillParts.Count(p =>
|
||||
p.BaseDrawing.Name == item.Drawing.Name);
|
||||
ReferenceEquals(p.BaseDrawing, item.Drawing));
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
@@ -147,10 +147,11 @@ namespace OpenNest
|
||||
{
|
||||
allParts.AddRange(packParts);
|
||||
|
||||
// Deduct placed quantities by drawing reference, not name.
|
||||
foreach (var item in regularPackItems)
|
||||
{
|
||||
var placed = packParts.Count(p =>
|
||||
p.BaseDrawing.Name == item.Drawing.Name);
|
||||
ReferenceEquals(p.BaseDrawing, item.Drawing));
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
<InternalsVisibleTo Include="OpenNest.Engine.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
|
||||
@@ -128,13 +128,14 @@ namespace OpenNest
|
||||
}
|
||||
}
|
||||
|
||||
// Deduct placed quantities from original items.
|
||||
// Deduct placed quantities from original items by drawing reference.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
var placed = allParts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
var placed = allParts.Count(p =>
|
||||
ReferenceEquals(p.BaseDrawing, item.Drawing));
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,7 @@ namespace OpenNest.IO
|
||||
public ExportContext()
|
||||
{
|
||||
Document = new CadDocument();
|
||||
Document.Header.Version = ACadVersion.AC1018;
|
||||
|
||||
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
||||
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.ComponentModel;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
|
||||
/// configuration PropertyGrid and persisted to JSON.
|
||||
/// </summary>
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public sealed class LayerCutConfig
|
||||
{
|
||||
[DisplayName("Feed (mm/sec)")]
|
||||
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
|
||||
public int FeedMmPerSec { get; set; } = 10;
|
||||
|
||||
[DisplayName("Depth (inches)")]
|
||||
[Description("Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does.")]
|
||||
public double Depth { get; set; } = 0.25;
|
||||
|
||||
[DisplayName("Pause Before")]
|
||||
[Description("Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted.")]
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
[DisplayName("Pause Message")]
|
||||
[Description("Message shown on the controller during the pause.")]
|
||||
public string PauseMessage { get; set; } = "";
|
||||
|
||||
public override string ToString() => $"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
|
||||
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
|
||||
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
|
||||
/// The cut block carries the tool-change pause by default.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostConfig
|
||||
{
|
||||
[Category("Engrave (Scribe)")]
|
||||
[DisplayName("Engrave")]
|
||||
[Description("Parameters for engrave/scribe geometry (text).")]
|
||||
public LayerCutConfig Engrave { get; set; } = new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 10,
|
||||
Depth = 0.25,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
};
|
||||
|
||||
[Category("Cut")]
|
||||
[DisplayName("Cut")]
|
||||
[Description("Parameters for cut geometry (outlines). Pauses for a tool change by default.")]
|
||||
public LayerCutConfig Cut { get; set; } = new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 3,
|
||||
Depth = 0.25,
|
||||
PauseBefore = true,
|
||||
PauseMessage = "Change tool",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cut config a polyline of the given layer should use, or null
|
||||
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
|
||||
/// </summary>
|
||||
public LayerCutConfig ConfigFor(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
case LayerType.Scribe:
|
||||
return Engrave;
|
||||
case LayerType.Cut:
|
||||
case LayerType.Leadin:
|
||||
case LayerType.Leadout:
|
||||
return Cut;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Ports;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
|
||||
/// writes the binary HPGL bytes. For serial streaming, use <see cref="Stream(Nest, string, Handshake, CancellationToken)"/>.
|
||||
///
|
||||
/// Geometry is split by <see cref="OpenNest.CNC.LayerType"/> into an engrave pass
|
||||
/// (Scribe) and a cut pass (Cut), each with its own feed/depth from <see cref="Config"/>.
|
||||
/// The cut pass pauses by default so the operator can swap/adjust the tool.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostProcessor : IPostProcessor
|
||||
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public string Name => "Gravograph IS8000";
|
||||
public string Author => "OpenNest";
|
||||
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
|
||||
@@ -23,14 +37,53 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
public bool AllowReverse { get; set; } = true;
|
||||
|
||||
public GravographISPostConfig Config { get; }
|
||||
|
||||
object IConfigurablePostProcessor.Config => Config;
|
||||
|
||||
public GravographISPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
Config = JsonSerializer.Deserialize<GravographISPostConfig>(json, JsonOptions)
|
||||
?? new GravographISPostConfig();
|
||||
}
|
||||
else
|
||||
{
|
||||
Config = new GravographISPostConfig();
|
||||
SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public GravographISPostProcessor(GravographISPostConfig config)
|
||||
{
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
var json = JsonSerializer.Serialize(Config, JsonOptions);
|
||||
File.WriteAllText(configPath, json);
|
||||
}
|
||||
|
||||
private static string GetConfigPath()
|
||||
{
|
||||
var assemblyPath = typeof(GravographISPostProcessor).Assembly.Location;
|
||||
var dir = Path.GetDirectoryName(assemblyPath);
|
||||
var name = Path.GetFileNameWithoutExtension(assemblyPath);
|
||||
return Path.Combine(dir, name + ".json");
|
||||
}
|
||||
|
||||
public void Post(Nest nest, Stream outputStream)
|
||||
{
|
||||
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
|
||||
|
||||
var polylines = Extractor.Extract(nest);
|
||||
var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
|
||||
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
|
||||
var passes = BuildPasses(Extractor.ExtractLayered(nest));
|
||||
new GravographISWriter(WriterOptions).Write(passes, outputStream);
|
||||
}
|
||||
|
||||
public void Post(Nest nest, string outputFile)
|
||||
@@ -39,6 +92,52 @@ namespace OpenNest.Posts.GravographIS
|
||||
Post(nest, fs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups layer-tagged polylines into ordered tool passes: engrave (Scribe)
|
||||
/// first, then cut. Each group is stitch/reverse-optimized independently.
|
||||
/// Geometry whose layer maps to no config (Display) is skipped. When only one
|
||||
/// group is present, a single pass is returned (and so the writer emits no pause).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GravographPass> BuildPasses(IEnumerable<LayeredPolyline> polylines)
|
||||
{
|
||||
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||
|
||||
var engrave = new List<IReadOnlyList<Vector>>();
|
||||
var cut = new List<IReadOnlyList<Vector>>();
|
||||
|
||||
foreach (var poly in polylines)
|
||||
{
|
||||
if (poly == null) continue;
|
||||
var block = Config.ConfigFor(poly.Layer);
|
||||
if (block == null)
|
||||
continue; // non-cutting (Display) geometry
|
||||
if (ReferenceEquals(block, Config.Engrave))
|
||||
engrave.Add(poly.Points);
|
||||
else
|
||||
cut.Add(poly.Points);
|
||||
}
|
||||
|
||||
var passes = new List<GravographPass>();
|
||||
if (engrave.Count > 0)
|
||||
passes.Add(MakePass(Config.Engrave, engrave));
|
||||
if (cut.Count > 0)
|
||||
passes.Add(MakePass(Config.Cut, cut));
|
||||
|
||||
return passes;
|
||||
}
|
||||
|
||||
private GravographPass MakePass(LayerCutConfig block, List<IReadOnlyList<Vector>> polylines)
|
||||
{
|
||||
return new GravographPass
|
||||
{
|
||||
Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse),
|
||||
FeedMmPerSec = block.FeedMmPerSec,
|
||||
DepthInches = block.Depth,
|
||||
PauseBefore = block.PauseBefore,
|
||||
PauseMessage = block.PauseMessage ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffers the encoded job in memory, then streams it to the named COM port.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// One tool pass: a run of polylines cut at a single feed/depth, optionally
|
||||
/// preceded by an operator pause (to swap or adjust the tool). The Gravograph
|
||||
/// post builds one pass for engrave and one for cut.
|
||||
/// </summary>
|
||||
public sealed class GravographPass
|
||||
{
|
||||
public IEnumerable<IReadOnlyList<Vector>> Polylines { get; set; }
|
||||
|
||||
public int FeedMmPerSec { get; set; }
|
||||
|
||||
public double DepthInches { get; set; }
|
||||
|
||||
/// <summary>When true, park to origin and prompt the operator before this pass.</summary>
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
public string PauseMessage { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
|
||||
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
|
||||
@@ -84,12 +104,40 @@ namespace OpenNest.Posts.GravographIS
|
||||
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
|
||||
{
|
||||
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||
|
||||
// A single pass at the configured feed/depth — byte-identical to the
|
||||
// original single-group output (no transitions, no pause).
|
||||
Write(new[]
|
||||
{
|
||||
new GravographPass
|
||||
{
|
||||
Polylines = polylines,
|
||||
FeedMmPerSec = Options.FeedMmPerSec,
|
||||
DepthInches = Options.DepthInches,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
},
|
||||
}, output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the full byte stream for an ordered list of tool passes. The preamble
|
||||
/// carries the first pass's feed/depth; each later pass emits an inline feed
|
||||
/// (and depth, if changed) and, when <see cref="GravographPass.PauseBefore"/> is
|
||||
/// set, parks to the operator origin and emits an operator pause before cutting.
|
||||
/// </summary>
|
||||
public void Write(IReadOnlyList<GravographPass> passes, Stream output)
|
||||
{
|
||||
if (passes == null) throw new ArgumentNullException(nameof(passes));
|
||||
if (output == null) throw new ArgumentNullException(nameof(output));
|
||||
|
||||
var firstFeed = passes.Count > 0 ? passes[0].FeedMmPerSec : Options.FeedMmPerSec;
|
||||
var firstDepth = passes.Count > 0 ? passes[0].DepthInches : Options.DepthInches;
|
||||
|
||||
var preamble = (byte[])PreambleTemplate.Clone();
|
||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)Options.FeedMmPerSec);
|
||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
|
||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
|
||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
|
||||
output.Write(preamble, 0, preamble.Length);
|
||||
|
||||
// Cumulative head position from the operator-set upper-left origin, in
|
||||
@@ -105,45 +153,51 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
var firstPolyline = true;
|
||||
var polyIndex = 0;
|
||||
var currentFeed = firstFeed;
|
||||
var currentDepth = firstDepth;
|
||||
|
||||
foreach (var poly in polylines)
|
||||
for (var p = 0; p < passes.Count; p++)
|
||||
{
|
||||
polyIndex++;
|
||||
if (poly == null || poly.Count < 2)
|
||||
continue;
|
||||
var pass = passes[p];
|
||||
|
||||
var (startX, startY) = ToWire(poly[0]);
|
||||
WriteTravel(output,
|
||||
firstPolyline ? (byte)'D' : (byte)'P',
|
||||
firstPolyline ? (byte)'R' : (byte)'U',
|
||||
checked(startX - headX), checked(startY - headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
|
||||
// PD command + single records-follow flag, then one record per segment.
|
||||
output.WriteByte(0xFF);
|
||||
output.WriteByte(0xFD);
|
||||
output.WriteByte((byte)'P');
|
||||
output.WriteByte((byte)'D');
|
||||
output.WriteByte(0x00);
|
||||
output.WriteByte(0x00);
|
||||
|
||||
var prevX = startX;
|
||||
var prevY = startY;
|
||||
for (int i = 1; i < poly.Count; i++)
|
||||
if (p > 0)
|
||||
{
|
||||
var (cx, cy) = ToWire(poly[i]);
|
||||
var dx = checked(cx - prevX);
|
||||
var dy = checked(cy - prevY);
|
||||
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
||||
polyIndex, segment: i, isTravel: false);
|
||||
WriteRecord(output, dx, dy);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
headX += dx;
|
||||
headY += dy;
|
||||
if (pass.PauseBefore)
|
||||
{
|
||||
// Park: lift Z, then rapid (pen-up) back to the operator origin so
|
||||
// the head is clear of the work while the tool is swapped.
|
||||
WriteLiftOnly(output);
|
||||
WriteTravel(output, (byte)'P', (byte)'U',
|
||||
checked(-headX), checked(-headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
WritePauseCore(output, pass.PauseMessage);
|
||||
}
|
||||
|
||||
if (pass.FeedMmPerSec != currentFeed)
|
||||
{
|
||||
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
|
||||
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
|
||||
currentFeed = pass.FeedMmPerSec;
|
||||
}
|
||||
|
||||
if (pass.DepthInches != currentDepth)
|
||||
{
|
||||
WriteCommand(output, (byte)'D', (byte)'Z', DepthInStepsAsInt16(pass.DepthInches));
|
||||
currentDepth = pass.DepthInches;
|
||||
}
|
||||
}
|
||||
|
||||
firstPolyline = false;
|
||||
if (pass.Polylines == null) continue;
|
||||
|
||||
foreach (var poly in pass.Polylines)
|
||||
{
|
||||
polyIndex++;
|
||||
if (poly == null || poly.Count < 2)
|
||||
continue;
|
||||
|
||||
WritePolyline(output, poly, ref firstPolyline, ref headX, ref headY,
|
||||
envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
}
|
||||
}
|
||||
|
||||
WriteLiftOnly(output);
|
||||
@@ -156,6 +210,89 @@ namespace OpenNest.Posts.GravographIS
|
||||
output.Write(EndJobBytes, 0, EndJobBytes.Length);
|
||||
}
|
||||
|
||||
private void WritePolyline(Stream output, IReadOnlyList<Vector> poly,
|
||||
ref bool firstPolyline, ref int headX, ref int headY,
|
||||
int envelopeXSteps, int envelopeYSteps, int polyIndex)
|
||||
{
|
||||
var (startX, startY) = ToWire(poly[0]);
|
||||
WriteTravel(output,
|
||||
firstPolyline ? (byte)'D' : (byte)'P',
|
||||
firstPolyline ? (byte)'R' : (byte)'U',
|
||||
checked(startX - headX), checked(startY - headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
|
||||
// PD command + single records-follow flag, then one record per segment.
|
||||
output.WriteByte(0xFF);
|
||||
output.WriteByte(0xFD);
|
||||
output.WriteByte((byte)'P');
|
||||
output.WriteByte((byte)'D');
|
||||
output.WriteByte(0x00);
|
||||
output.WriteByte(0x00);
|
||||
|
||||
var prevX = startX;
|
||||
var prevY = startY;
|
||||
for (int i = 1; i < poly.Count; i++)
|
||||
{
|
||||
var (cx, cy) = ToWire(poly[i]);
|
||||
var dx = checked(cx - prevX);
|
||||
var dy = checked(cy - prevY);
|
||||
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
||||
polyIndex, segment: i, isTravel: false);
|
||||
WriteRecord(output, dx, dy);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
headX += dx;
|
||||
headY += dy;
|
||||
}
|
||||
|
||||
firstPolyline = false;
|
||||
}
|
||||
|
||||
// The operator pause, minus the leading lift/park which the caller emits.
|
||||
// Stops the spindle (MC off), turns off aux, writes the console message, then
|
||||
// restarts the spindle (MC on) so the job resumes when the operator presses start.
|
||||
private static void WritePauseCore(Stream s, string message)
|
||||
{
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x00); // begin message
|
||||
WriteMessagePackets(s, message);
|
||||
WriteCommandRaw(s, (byte)'N', (byte)'R', 0x00, 0x01); // line terminator
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x01); // end message
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x01); // motor on
|
||||
}
|
||||
|
||||
// Console label packets carry two ASCII chars each; an odd-length message is
|
||||
// space-padded to a whole number of packets so widths stay 2 bytes.
|
||||
private static void WriteMessagePackets(Stream s, string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
|
||||
var chars = Encoding.ASCII.GetBytes(message);
|
||||
for (var i = 0; i < chars.Length; i += 2)
|
||||
{
|
||||
var c0 = chars[i];
|
||||
var c1 = (i + 1 < chars.Length) ? chars[i + 1] : (byte)0x20;
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', c0, c1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCommand(Stream s, byte c0, byte c1, short value)
|
||||
{
|
||||
WriteCommandRaw(s, c0, c1, (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF));
|
||||
}
|
||||
|
||||
private static void WriteCommandRaw(Stream s, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
s.WriteByte(0xFF);
|
||||
s.WriteByte(0xFD);
|
||||
s.WriteByte(c0);
|
||||
s.WriteByte(c1);
|
||||
s.WriteByte(hi);
|
||||
s.WriteByte(lo);
|
||||
}
|
||||
|
||||
private const double StepsPerMm = 80.0;
|
||||
|
||||
private void EnsureEnvelope(int wireX, int wireY,
|
||||
@@ -181,11 +318,11 @@ namespace OpenNest.Posts.GravographIS
|
||||
$"work envelope from upper-left origin. Refusing to emit the record.");
|
||||
}
|
||||
|
||||
private short DepthInStepsAsInt16()
|
||||
private static short DepthInStepsAsInt16(double depthInches)
|
||||
{
|
||||
var steps = (long)System.Math.Round(Options.DepthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||
var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||
if (steps < short.MinValue || steps > short.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(Options.DepthInches), $"Depth {Options.DepthInches} in. → {steps} steps overflows int16.");
|
||||
throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16.");
|
||||
return (short)steps;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// A polyline together with the <see cref="LayerType"/> of the moves that
|
||||
/// produced it. The Gravograph post groups by layer to emit separate engrave
|
||||
/// and cut passes (with a tool-change pause between them).
|
||||
/// </summary>
|
||||
public sealed class LayeredPolyline
|
||||
{
|
||||
public LayeredPolyline(List<Vector> points, LayerType layer)
|
||||
{
|
||||
Points = points;
|
||||
Layer = layer;
|
||||
}
|
||||
|
||||
public List<Vector> Points { get; }
|
||||
|
||||
public LayerType Layer { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
|
||||
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
|
||||
/// polylines at rapid moves, and tessellates arcs to a chord-deviation
|
||||
/// tolerance (the wire format takes line segments only).
|
||||
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and
|
||||
/// tessellates arcs to a chord-deviation tolerance (the wire format takes
|
||||
/// line segments only).
|
||||
/// </summary>
|
||||
public sealed class NestPolylineExtractor
|
||||
{
|
||||
@@ -17,13 +37,31 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines from every non-cutoff part in every plate of the nest,
|
||||
/// returning them in plate coordinates (inches).
|
||||
/// returning them in plate coordinates (inches). Layer information is dropped;
|
||||
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
|
||||
/// </summary>
|
||||
public List<List<Vector>> Extract(Nest nest)
|
||||
{
|
||||
return ExtractLayered(nest).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines for a single part without layer information.
|
||||
/// </summary>
|
||||
public List<List<Vector>> ExtractPart(Part part)
|
||||
{
|
||||
return ExtractPartLayered(part).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts layer-tagged polylines from every non-cutoff part in every plate,
|
||||
/// in plate coordinates (inches). Each polyline is layer-uniform.
|
||||
/// </summary>
|
||||
public List<LayeredPolyline> ExtractLayered(Nest nest)
|
||||
{
|
||||
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||
|
||||
var result = new List<List<Vector>>();
|
||||
var result = new List<LayeredPolyline>();
|
||||
|
||||
foreach (var plate in nest.Plates)
|
||||
{
|
||||
@@ -40,17 +78,17 @@ namespace OpenNest.Posts.GravographIS
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines for a single part. Public so callers driving the
|
||||
/// writer directly (e.g. from a console one-off) can use it.
|
||||
/// Extracts layer-tagged polylines for a single part. Public so callers
|
||||
/// driving the writer directly (e.g. from a console one-off) can use it.
|
||||
/// </summary>
|
||||
public List<List<Vector>> ExtractPart(Part part)
|
||||
public List<LayeredPolyline> ExtractPartLayered(Part part)
|
||||
{
|
||||
var list = new List<List<Vector>>();
|
||||
var list = new List<LayeredPolyline>();
|
||||
ExtractPart(part, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private void ExtractPart(Part part, List<List<Vector>> sink)
|
||||
private void ExtractPart(Part part, List<LayeredPolyline> sink)
|
||||
{
|
||||
var program = part.Program;
|
||||
if (program == null) return;
|
||||
@@ -67,6 +105,7 @@ namespace OpenNest.Posts.GravographIS
|
||||
var offset = part.Location;
|
||||
var pos = new Vector(0, 0);
|
||||
List<Vector> current = null;
|
||||
var currentLayer = LayerType.Cut;
|
||||
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
@@ -77,17 +116,14 @@ namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
case RapidMove rapid:
|
||||
{
|
||||
FlushCurrent(sink, ref current);
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
pos = rapid.EndPoint;
|
||||
break;
|
||||
}
|
||||
|
||||
case LinearMove linear:
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { pos + offset };
|
||||
}
|
||||
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
|
||||
var end = linear.EndPoint;
|
||||
current.Add(end + offset);
|
||||
pos = end;
|
||||
@@ -96,10 +132,7 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
case ArcMove arc:
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { pos + offset };
|
||||
}
|
||||
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
|
||||
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
|
||||
pos = arc.EndPoint;
|
||||
break;
|
||||
@@ -107,13 +140,33 @@ namespace OpenNest.Posts.GravographIS
|
||||
}
|
||||
}
|
||||
|
||||
FlushCurrent(sink, ref current);
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
}
|
||||
|
||||
private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> current)
|
||||
// Ensures `current` is an open polyline whose layer matches `moveLayer`,
|
||||
// seeded at `seed` (the current pen position). When the layer changes
|
||||
// mid-chain the previous polyline is flushed and a new one begins at the
|
||||
// shared seam vertex so engrave and cut passes stay geometrically continuous.
|
||||
private static void StartOrSplit(List<LayeredPolyline> sink, ref List<Vector> current,
|
||||
ref LayerType currentLayer, LayerType moveLayer, Vector seed)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
else if (moveLayer != currentLayer)
|
||||
{
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlushCurrent(List<LayeredPolyline> sink, ref List<Vector> current, LayerType layer)
|
||||
{
|
||||
if (current != null && current.Count >= 2)
|
||||
sink.Add(current);
|
||||
sink.Add(new LayeredPolyline(current, layer));
|
||||
current = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ public class NestRequestTests
|
||||
Assert.Empty(request.Parts);
|
||||
Assert.Equal(60, request.SheetSize.Width);
|
||||
Assert.Equal(120, request.SheetSize.Length);
|
||||
Assert.Null(request.Plates);
|
||||
Assert.Equal("Default", request.PlacementStrategy);
|
||||
Assert.Equal("Steel, A1011 HR", request.Material);
|
||||
Assert.Equal(0.06, request.Thickness);
|
||||
Assert.Equal(0.1, request.Spacing);
|
||||
@@ -38,8 +40,38 @@ public class NestRequestTests
|
||||
{
|
||||
var part = new NestRequestPart { DxfPath = "part.dxf" };
|
||||
|
||||
Assert.Null(part.Id);
|
||||
Assert.Equal(1, part.Quantity);
|
||||
Assert.True(part.AllowRotation);
|
||||
Assert.Equal(0, part.Priority);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitPlates_PreserveStockSettings()
|
||||
{
|
||||
var request = new NestRequest
|
||||
{
|
||||
Plates =
|
||||
[
|
||||
new NestRequestPlate
|
||||
{
|
||||
Id = "remnant",
|
||||
Size = new Size(24, 48),
|
||||
Quantity = 3,
|
||||
PartSpacing = 0.2,
|
||||
EdgeSpacing = new Spacing(1, 2, 3, 4),
|
||||
Quadrant = 3
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var plate = Assert.Single(request.Plates!);
|
||||
Assert.Equal("remnant", plate.Id);
|
||||
Assert.Equal(24, plate.Size.Width);
|
||||
Assert.Equal(48, plate.Size.Length);
|
||||
Assert.Equal(3, plate.Quantity);
|
||||
Assert.Equal(0.2, plate.PartSpacing);
|
||||
Assert.Equal(new Spacing(1, 2, 3, 4), plate.EdgeSpacing);
|
||||
Assert.Equal(3, plate.Quadrant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.Api;
|
||||
|
||||
public class NestResponsePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_LoadAsync_RoundTrips()
|
||||
public async Task SaveAsync_LoadAsync_RoundTripsCompleteResponseMetadata()
|
||||
{
|
||||
var nest = new Nest("test-nest");
|
||||
var plate = new Plate(new Size(60, 120));
|
||||
var drawing = new Drawing("test-part");
|
||||
nest.Drawings.Add(drawing);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
var nest = CreateNest("test-part", new Size(60, 120));
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { DxfPath = "test.dxf", Quantity = 5 }],
|
||||
SheetSize = new Size(60, 120),
|
||||
Parts = [new NestRequestPart { Id = "test-part", DxfPath = "test.dxf", Quantity = 5 }],
|
||||
Plates = [new NestRequestPlate { Id = "sheet", Size = new Size(60, 120), Quantity = 1, PartSpacing = 0.1 }],
|
||||
Material = "Steel",
|
||||
Thickness = 0.125,
|
||||
Spacing = 0.1
|
||||
};
|
||||
|
||||
var original = new NestResponse
|
||||
{
|
||||
SheetCount = 1,
|
||||
Utilization = 0.75,
|
||||
CutTime = TimeSpan.FromMinutes(12.5),
|
||||
Elapsed = TimeSpan.FromSeconds(3.2),
|
||||
Status = NestJobStatus.Complete,
|
||||
StopReason = NestJobStopReason.Completed,
|
||||
Fulfillment = [new NestPartFulfillment("test-part", 5, 5, 0)],
|
||||
StockUsage = [new NestStockUsage("sheet", 1, 0)],
|
||||
PlateStockMappings = [new NestPlateStockMapping(0, "sheet")],
|
||||
Nest = nest,
|
||||
Request = request
|
||||
};
|
||||
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.nestquote");
|
||||
|
||||
try
|
||||
@@ -44,16 +44,24 @@ public class NestResponsePersistenceTests
|
||||
await original.SaveAsync(path);
|
||||
var loaded = await NestResponse.LoadAsync(path);
|
||||
|
||||
Assert.Equal(NestResponse.CurrentSchemaVersion, loaded.SchemaVersion);
|
||||
Assert.Equal(original.SheetCount, loaded.SheetCount);
|
||||
Assert.Equal(original.Utilization, loaded.Utilization, precision: 4);
|
||||
Assert.Equal(original.CutTime, loaded.CutTime);
|
||||
Assert.Equal(original.Elapsed, loaded.Elapsed);
|
||||
Assert.Equal(NestJobStatus.Complete, loaded.Status);
|
||||
Assert.Equal(NestJobStopReason.Completed, loaded.StopReason);
|
||||
Assert.Equal(original.Fulfillment, loaded.Fulfillment);
|
||||
Assert.Equal(original.StockUsage, loaded.StockUsage);
|
||||
Assert.Equal(original.PlateStockMappings, loaded.PlateStockMappings);
|
||||
|
||||
Assert.Equal(original.Request.Material, loaded.Request.Material);
|
||||
Assert.Equal(original.Request.Thickness, loaded.Request.Thickness);
|
||||
Assert.Equal(original.Request.Parts.Count, loaded.Request.Parts.Count);
|
||||
Assert.Equal("test-part", loaded.Request.Parts[0].Id);
|
||||
Assert.Equal(original.Request.Parts[0].DxfPath, loaded.Request.Parts[0].DxfPath);
|
||||
Assert.Equal(original.Request.Parts[0].Quantity, loaded.Request.Parts[0].Quantity);
|
||||
Assert.Equal("sheet", Assert.Single(loaded.Request.Plates!).Id);
|
||||
|
||||
Assert.NotNull(loaded.Nest);
|
||||
Assert.Single(loaded.Nest.Plates);
|
||||
@@ -63,4 +71,111 @@ public class NestResponsePersistenceTests
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_LegacyArchiveWithoutFulfillment_LeavesStatusUnspecified()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"legacy-{Guid.NewGuid()}.nestquote");
|
||||
|
||||
try
|
||||
{
|
||||
await WriteLegacyArchiveAsync(path, CreateNest("legacy-part", new Size(60, 120)));
|
||||
|
||||
var loaded = await NestResponse.LoadAsync(path);
|
||||
|
||||
Assert.Equal(0, loaded.SchemaVersion);
|
||||
Assert.Null(loaded.Status);
|
||||
Assert.Null(loaded.StopReason);
|
||||
Assert.Empty(loaded.Fulfillment);
|
||||
Assert.Empty(loaded.StockUsage);
|
||||
Assert.Empty(loaded.PlateStockMappings);
|
||||
Assert.Equal(1, loaded.SheetCount);
|
||||
Assert.Equal(0.75, loaded.Utilization, precision: 4);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_LoadAsync_IncompleteResponsePreservesIdsAndUnplacedWithoutDxf()
|
||||
{
|
||||
var dxfPath = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid()}.dxf");
|
||||
var path = Path.Combine(Path.GetTempPath(), $"incomplete-{Guid.NewGuid()}.nestquote");
|
||||
Assert.False(File.Exists(dxfPath));
|
||||
var original = new NestResponse
|
||||
{
|
||||
SheetCount = 1,
|
||||
Utilization = 0.4,
|
||||
CutTime = TimeSpan.FromMinutes(2),
|
||||
Elapsed = TimeSpan.FromMilliseconds(500),
|
||||
Status = NestJobStatus.Incomplete,
|
||||
StopReason = NestJobStopReason.StockExhausted,
|
||||
Fulfillment = [new NestPartFulfillment("custom-id", 3, 1, 2)],
|
||||
StockUsage = [new NestStockUsage("finite-stock", 1, 0)],
|
||||
PlateStockMappings = [new NestPlateStockMapping(0, "finite-stock")],
|
||||
Nest = CreateNest("custom-id", new Size(10, 10)),
|
||||
Request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { Id = "custom-id", DxfPath = dxfPath, Quantity = 3 }],
|
||||
Plates = [new NestRequestPlate { Id = "finite-stock", Size = new Size(10, 10), Quantity = 1 }]
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await original.SaveAsync(path);
|
||||
var loaded = await NestResponse.LoadAsync(path);
|
||||
|
||||
Assert.False(File.Exists(dxfPath));
|
||||
Assert.Equal(NestJobStatus.Incomplete, loaded.Status);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, loaded.StopReason);
|
||||
Assert.Equal(new NestPartFulfillment("custom-id", 3, 1, 2), Assert.Single(loaded.Fulfillment));
|
||||
Assert.Equal(new NestStockUsage("finite-stock", 1, 0), Assert.Single(loaded.StockUsage));
|
||||
Assert.Equal(new NestPlateStockMapping(0, "finite-stock"), Assert.Single(loaded.PlateStockMappings));
|
||||
Assert.Equal("custom-id", Assert.Single(loaded.Request.Parts).Id);
|
||||
Assert.Equal("finite-stock", Assert.Single(loaded.Request.Plates!).Id);
|
||||
Assert.Single(loaded.Nest.Drawings);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static Nest CreateNest(string drawingName, Size size)
|
||||
{
|
||||
var nest = new Nest("test-nest");
|
||||
var plate = new Plate(size);
|
||||
var drawing = new Drawing(drawingName);
|
||||
nest.Drawings.Add(drawing);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static async Task WriteLegacyArchiveAsync(string path, Nest nest)
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Create);
|
||||
using var zip = new ZipArchive(fs, ZipArchiveMode.Create);
|
||||
await WriteEntryAsync(zip, "request.json", """
|
||||
{"parts":[{"dxfPath":"legacy-missing.dxf","quantity":2}],"sheetSize":{"width":60,"length":120},"material":"Steel","thickness":0.06,"spacing":0.1,"strategy":0}
|
||||
""");
|
||||
await WriteEntryAsync(zip, "response.json", """
|
||||
{"sheetCount":1,"utilization":0.75,"cutTimeTicks":120000,"elapsedTicks":340000}
|
||||
""");
|
||||
|
||||
var nestEntry = zip.CreateEntry("nest.nest");
|
||||
await using var stream = nestEntry.Open();
|
||||
new NestWriter(nest).Write(stream);
|
||||
}
|
||||
|
||||
private static async Task WriteEntryAsync(ZipArchive zip, string name, string contents)
|
||||
{
|
||||
var entry = zip.CreateEntry(name);
|
||||
await using var stream = entry.Open();
|
||||
await using var writer = new StreamWriter(stream);
|
||||
await writer.WriteAsync(contents);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.Converters;
|
||||
@@ -11,7 +12,7 @@ namespace OpenNest.Tests.Api;
|
||||
public class NestRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_SinglePart_ProducesResponse()
|
||||
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(2, 2);
|
||||
|
||||
@@ -26,9 +27,17 @@ public class NestRunnerTests
|
||||
|
||||
var response = await NestRunner.RunAsync(request);
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(NestJobStatus.Complete, response.Status);
|
||||
Assert.Equal(NestJobStopReason.Completed, response.StopReason);
|
||||
Assert.Equal("part-0", Assert.Single(response.Fulfillment).PartId);
|
||||
Assert.Equal(4, response.Fulfillment[0].Placed);
|
||||
var stock = Assert.Single(response.StockUsage);
|
||||
Assert.Equal("legacy-sheet", stock.StockId);
|
||||
Assert.Null(stock.Remaining);
|
||||
Assert.All(response.PlateStockMappings, mapping => Assert.Equal("legacy-sheet", mapping.StockId));
|
||||
Assert.Equal(response.SheetCount, response.PlateStockMappings.Count);
|
||||
Assert.NotNull(response.Nest);
|
||||
Assert.True(response.SheetCount >= 1);
|
||||
Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "part-0");
|
||||
Assert.True(response.Utilization > 0);
|
||||
Assert.Equal(request, response.Request);
|
||||
}
|
||||
@@ -38,6 +47,155 @@ public class NestRunnerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ExplicitMixedFinitePlates_UsesPhysicalStockEntries()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(4, 4);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await NestRunner.RunAsync(new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 5 }],
|
||||
Plates =
|
||||
[
|
||||
new NestRequestPlate { Id = "small", Size = new Size(5, 5), Quantity = 1 },
|
||||
new NestRequestPlate { Id = "large", Size = new Size(9, 9), Quantity = 1 }
|
||||
]
|
||||
});
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, response.Status);
|
||||
Assert.Equal(2, response.SheetCount);
|
||||
Assert.Equal(5, Assert.Single(response.Fulfillment).Placed);
|
||||
Assert.Equal(0, response.Fulfillment[0].Unplaced);
|
||||
Assert.Equal(new[] { "large", "small" }, response.PlateStockMappings.Select(mapping => mapping.StockId).Order());
|
||||
Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "small").Used);
|
||||
Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "large").Used);
|
||||
Assert.All(response.StockUsage, usage => Assert.Equal(0, usage.Remaining));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(dxfPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ExplicitEmptyPlates_ReportsStockExhausted()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(2, 2);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await NestRunner.RunAsync(new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 1 }],
|
||||
Plates = []
|
||||
});
|
||||
|
||||
Assert.Equal(NestJobStatus.Incomplete, response.Status);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason);
|
||||
Assert.Equal(0, response.SheetCount);
|
||||
Assert.Empty(response.StockUsage);
|
||||
var fulfillment = Assert.Single(response.Fulfillment);
|
||||
Assert.Equal(0, fulfillment.Placed);
|
||||
Assert.Equal(1, fulfillment.Unplaced);
|
||||
Assert.Empty(response.Nest.Plates);
|
||||
Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "square");
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(dxfPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(4, 4);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await NestRunner.RunAsync(new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart
|
||||
{
|
||||
Id = "locked-square",
|
||||
DxfPath = dxfPath,
|
||||
Quantity = 2,
|
||||
AllowRotation = false
|
||||
}],
|
||||
Plates = [new NestRequestPlate { Id = "only-sheet", Size = new Size(5, 5), Quantity = 1 }]
|
||||
});
|
||||
|
||||
Assert.Equal(NestJobStatus.Incomplete, response.Status);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason);
|
||||
var fulfillment = Assert.Single(response.Fulfillment);
|
||||
Assert.Equal(2, fulfillment.Requested);
|
||||
Assert.Equal(1, fulfillment.Placed);
|
||||
Assert.Equal(1, fulfillment.Unplaced);
|
||||
var stock = Assert.Single(response.StockUsage);
|
||||
Assert.Equal(1, stock.Used);
|
||||
Assert.Equal(0, stock.Remaining);
|
||||
var drawing = Assert.Single(response.Nest.Drawings);
|
||||
Assert.Equal("locked-square", drawing.Name);
|
||||
Assert.Equal(OpenNest.Math.Angle.TwoPI, drawing.Constraints.StepAngle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(dxfPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_MixedPhysicalSheets_CalculatesWeightedUtilization()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(4, 4);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await NestRunner.RunAsync(new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { Id = "square", DxfPath = dxfPath, Quantity = 5 }],
|
||||
Plates =
|
||||
[
|
||||
new NestRequestPlate { Id = "small", Size = new Size(5, 5), Quantity = 1 },
|
||||
new NestRequestPlate { Id = "large", Size = new Size(9, 9), Quantity = 1 }
|
||||
]
|
||||
});
|
||||
|
||||
Assert.Equal(2, response.SheetCount);
|
||||
Assert.Equal(80d / 106d, response.Utilization, precision: 6);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(dxfPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_DuplicatePartIds_ThrowsBeforeNesting()
|
||||
{
|
||||
var dxfPath = CreateTempSquareDxf(2, 2);
|
||||
|
||||
try
|
||||
{
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts =
|
||||
[
|
||||
new NestRequestPart { Id = "duplicate", DxfPath = dxfPath },
|
||||
new NestRequestPart { Id = "duplicate", DxfPath = dxfPath }
|
||||
]
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(request));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(dxfPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_BadDxfPath_Throws()
|
||||
{
|
||||
@@ -46,8 +204,7 @@ public class NestRunnerTests
|
||||
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }]
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(
|
||||
() => NestRunner.RunAsync(request));
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(() => NestRunner.RunAsync(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -55,8 +212,7 @@ public class NestRunnerTests
|
||||
{
|
||||
var request = new NestRequest { Parts = [] };
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => NestRunner.RunAsync(request));
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(request));
|
||||
}
|
||||
|
||||
private static string CreateTempSquareDxf(double width, double height)
|
||||
@@ -69,9 +225,7 @@ public class NestRunnerTests
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(shape);
|
||||
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
|
||||
|
||||
Dxf.ExportProgram(pgm, path);
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Converters;
|
||||
|
||||
public class ConvertGeometryLayerTests
|
||||
{
|
||||
private static Program ProgramFor(Entity entity)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(entity);
|
||||
return ConvertGeometry.ToProgram(shape);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_EtchLayer_TagsScribe()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("etch") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddArc_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var arc = new Arc(new Vector(0, 0), 1, 0, System.Math.PI / 2) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(arc);
|
||||
|
||||
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||
Assert.NotEmpty(arcs);
|
||||
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCircle_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var circle = new Circle(0, 0, 1) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(circle);
|
||||
|
||||
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||
Assert.NotEmpty(arcs);
|
||||
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_DefaultLayer_StaysCut()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("0") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Cut, m.Layer));
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,111 @@ public class GeometrySimplifierTests
|
||||
Assert.IsType<Arc>(result.Entities[6]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_FilletBetweenTangentLines_ArcIsTangentToLines()
|
||||
{
|
||||
// A 90-degree fillet (r=0.3, center origin, 270deg..360deg CCW) between two
|
||||
// long tangent lines, approximated by 8 chords whose interior vertices bulge
|
||||
// radially outward within tolerance (simulates real DXF tessellation noise).
|
||||
var r = 0.3;
|
||||
var deltas = new[] { 0.0, 0.002, 0.003, 0.0035, 0.0035, 0.0035, 0.003, 0.002, 0.0 };
|
||||
var pts = new List<Vector>();
|
||||
for (var i = 0; i <= 8; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(270 + 11.25 * i);
|
||||
var radius = r + deltas[i];
|
||||
pts.Add(new Vector(radius * System.Math.Cos(ang), radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(-2, -r), pts[0]));
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||
shape.Entities.Add(new Line(pts[^1], new Vector(r, 2)));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Single(candidates);
|
||||
var arc = candidates[0].FittedArc;
|
||||
|
||||
// Arc must pass exactly through the run's boundary vertices (no gaps)
|
||||
Assert.True(arc.StartPoint().DistanceTo(pts[0]) < 1e-6);
|
||||
Assert.True(arc.EndPoint().DistanceTo(pts[^1]) < 1e-6);
|
||||
|
||||
// Arc must be tangent to the adjacent straight edges at its endpoints
|
||||
var startDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.StartPoint()), new Vector(1, 0));
|
||||
var endDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.EndPoint()), new Vector(0, 1));
|
||||
Assert.True(startDelta < 0.3, $"Arc start not tangent to incoming line: off by {startDelta:F3} deg");
|
||||
Assert.True(endDelta < 0.3, $"Arc end not tangent to outgoing line: off by {endDelta:F3} deg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_CompoundCurve_AdjacentArcsAreTangentAtJunction()
|
||||
{
|
||||
// Two tangent-continuous arcs of different radii (r=0.2 sweeping 60deg, then
|
||||
// r=0.6 sweeping 40deg), tessellated into chords with slight radial noise.
|
||||
// The fitted arcs must stay tangent-continuous at their junction.
|
||||
var c1 = new Vector(0, 0);
|
||||
var r1 = 0.2;
|
||||
var deltas1 = new[] { 0.0, 0.001, 0.0005, -0.0005, -0.001, -0.0005, 0.0 };
|
||||
var pts = new List<Vector>();
|
||||
for (var i = 0; i <= 6; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(10 * i);
|
||||
var radius = r1 + deltas1[i];
|
||||
pts.Add(new Vector(c1.X + radius * System.Math.Cos(ang), c1.Y + radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
// Second arc center along the junction radius so tangents match at the junction
|
||||
var junctionAngle = OpenNest.Math.Angle.ToRadians(60);
|
||||
var u = new Vector(System.Math.Cos(junctionAngle), System.Math.Sin(junctionAngle));
|
||||
var r2 = 0.6;
|
||||
var c2 = new Vector(c1.X + u.X * (r1 - r2), c1.Y + u.Y * (r1 - r2));
|
||||
var deltas2 = new[] { 0.0, 0.001, -0.001, 0.0005, -0.0005, 0.0 };
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(60 + 8 * i);
|
||||
var radius = r2 + deltas2[i];
|
||||
pts.Add(new Vector(c2.X + radius * System.Math.Cos(ang), c2.Y + radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
var shape = new Shape();
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
var arcA = candidates[0].FittedArc;
|
||||
var arcB = candidates[1].FittedArc;
|
||||
|
||||
// Arcs must share the junction vertex exactly
|
||||
Assert.True(arcA.EndPoint().DistanceTo(arcB.StartPoint()) < 1e-6);
|
||||
|
||||
// Tangent continuity across the junction
|
||||
var junctionDelta = AngleBetweenDeg(ArcTangentAt(arcA, arcA.EndPoint()), ArcTangentAt(arcB, arcB.StartPoint()));
|
||||
Assert.True(junctionDelta < 0.3, $"Tangent break of {junctionDelta:F3} deg at arc-arc junction");
|
||||
}
|
||||
|
||||
private static Vector ArcTangentAt(Arc arc, Vector pt)
|
||||
{
|
||||
var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X);
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(ang), -System.Math.Cos(ang))
|
||||
: new Vector(-System.Math.Sin(ang), System.Math.Cos(ang));
|
||||
}
|
||||
|
||||
private static double AngleBetweenDeg(Vector v1, Vector v2)
|
||||
{
|
||||
var l1 = System.Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y);
|
||||
var l2 = System.Math.Sqrt(v2.X * v2.X + v2.Y * v2.Y);
|
||||
var dot = (v1.X * v2.X + v1.Y * v2.Y) / (l1 * l2);
|
||||
dot = System.Math.Max(-1, System.Math.Min(1, dot));
|
||||
return System.Math.Acos(dot) * 180.0 / System.Math.PI;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Posts.GravographIS;
|
||||
|
||||
namespace OpenNest.Tests.GravographIS;
|
||||
|
||||
public class GravographISPostConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_EngraveBlock_NoPause_FasterFeed()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
|
||||
Assert.Equal(10, config.Engrave.FeedMmPerSec);
|
||||
Assert.False(config.Engrave.PauseBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_CutBlock_PausesToChangeTool()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
|
||||
Assert.Equal(3, config.Cut.FeedMmPerSec);
|
||||
Assert.True(config.Cut.PauseBefore);
|
||||
Assert.Equal("Change tool", config.Cut.PauseMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LayerType.Scribe)]
|
||||
public void ConfigFor_Scribe_ReturnsEngraveBlock(LayerType layer)
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Same(config.Engrave, config.ConfigFor(layer));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LayerType.Cut)]
|
||||
[InlineData(LayerType.Leadin)]
|
||||
[InlineData(LayerType.Leadout)]
|
||||
public void ConfigFor_CutLayers_ReturnCutBlock(LayerType layer)
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Same(config.Cut, config.ConfigFor(layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigFor_Display_ReturnsNull_SoItIsSkipped()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Null(config.ConfigFor(LayerType.Display));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Posts.GravographIS;
|
||||
|
||||
namespace OpenNest.Tests.GravographIS;
|
||||
|
||||
public class GravographISPostProcessorTests
|
||||
{
|
||||
private static LayeredPolyline Poly(LayerType layer, params Vector[] pts)
|
||||
=> new LayeredPolyline(new List<Vector>(pts), layer);
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_EngraveAndCut_OrdersEngraveFirstThenCutWithPause()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||
Poly(LayerType.Scribe, new Vector(0, 0), new Vector(0, 1)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, passes.Count);
|
||||
|
||||
Assert.Equal(post.Config.Engrave.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
Assert.False(passes[0].PauseBefore);
|
||||
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[1].FeedMmPerSec);
|
||||
Assert.True(passes[1].PauseBefore);
|
||||
Assert.Equal("Change tool", passes[1].PauseMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_CutOnly_IsSinglePass()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||
});
|
||||
|
||||
Assert.Single(passes);
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_SkipsDisplayGeometry()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Display, new Vector(0, 0), new Vector(1, 0)),
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(0, 1)),
|
||||
});
|
||||
|
||||
Assert.Single(passes);
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Config_IsExposedThroughConfigurableInterface()
|
||||
{
|
||||
var post = new GravographISPostProcessor(new GravographISPostConfig());
|
||||
OpenNest.IConfigurablePostProcessor configurable = post;
|
||||
Assert.Same(post.Config, configurable.Config);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,101 @@ public class GravographISWriterTests
|
||||
Assert.Equal(-GravographISWriter.StepsPerInch, dy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_PauseBeforeCut_EmitsPauseSequenceBetweenGroups()
|
||||
{
|
||||
var engrave = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } };
|
||||
var cut = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } };
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = engrave, FeedMmPerSec = 10, DepthInches = 0.25 },
|
||||
new GravographPass { Polylines = cut, FeedMmPerSec = 3, DepthInches = 0.25, PauseBefore = true, PauseMessage = "Hi" },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions
|
||||
{
|
||||
EnvelopeGuardEnabled = false,
|
||||
ReturnToOriginAtEnd = false,
|
||||
}).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var mcOff = IndexOf(bytes, 0, (byte)'M', (byte)'C', 0x00, 0x00);
|
||||
var ouFb = IndexOf(bytes, mcOff, (byte)'O', (byte)'U', 0xFF, 0xFB);
|
||||
var ouFa = IndexOf(bytes, ouFb, (byte)'O', (byte)'U', 0xFF, 0xFA);
|
||||
var lbBegin = IndexOf(bytes, ouFa, (byte)'L', (byte)'B', 0x00, 0x00);
|
||||
var lbMsg = IndexOf(bytes, lbBegin, (byte)'L', (byte)'B', (byte)'H', (byte)'i');
|
||||
var nr = IndexOf(bytes, lbMsg, (byte)'N', (byte)'R', 0x00, 0x01);
|
||||
var lbEnd = IndexOf(bytes, nr, (byte)'L', (byte)'B', 0x00, 0x01);
|
||||
var mcOn = IndexOf(bytes, lbEnd, (byte)'M', (byte)'C', 0x00, 0x01);
|
||||
|
||||
Assert.True(mcOff >= 0, "motor-off (MC 0000) not found");
|
||||
Assert.True(mcOff < ouFb && ouFb < ouFa && ouFa < lbBegin && lbBegin < lbMsg
|
||||
&& lbMsg < nr && nr < lbEnd && lbEnd < mcOn,
|
||||
"pause commands out of order");
|
||||
|
||||
// Resume sets the cut feed inline (VS 0x0003) after the motor restarts.
|
||||
var vsCut = IndexOf(bytes, mcOn, (byte)'V', (byte)'S', 0x00, 0x03);
|
||||
Assert.True(vsCut > mcOn, "cut feed not set after resume");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_PauseOddMessage_SpacePadsLastPacket()
|
||||
{
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = true, PauseMessage = "abc" },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var lbAb = IndexOf(bytes, 0, (byte)'L', (byte)'B', (byte)'a', (byte)'b');
|
||||
var lbCPad = IndexOf(bytes, lbAb, (byte)'L', (byte)'B', (byte)'c', 0x20);
|
||||
Assert.True(lbAb >= 0, "first message packet 'ab' not found");
|
||||
Assert.True(lbCPad > lbAb, "odd packet not space-padded to 'c '");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_DifferentFeeds_NoPause_EmitsInlineFeedChangeNoMessage()
|
||||
{
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = false },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
Assert.True(IndexOf(bytes, 0, (byte)'V', (byte)'S', 0x00, 0x03) >= 0, "inline cut feed change missing");
|
||||
Assert.True(IndexOfCmd(bytes, (byte)'L', (byte)'B') < 0, "no LB message expected without a pause");
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] bytes, int from, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
for (var i = System.Math.Max(0, from); i <= bytes.Length - 6; i++)
|
||||
{
|
||||
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 &&
|
||||
bytes[i + 3] == c1 && bytes[i + 4] == hi && bytes[i + 5] == lo)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int IndexOfCmd(byte[] bytes, byte c0, byte c1)
|
||||
{
|
||||
for (var i = 0; i <= bytes.Length - 4; i++)
|
||||
{
|
||||
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
for (var i = 0; i < bytes.Length - 5; i++)
|
||||
|
||||
@@ -34,4 +34,45 @@ public class NestPolylineExtractorTests
|
||||
Assert.Equal(new Vector(0.25, 47.75), poly[3]);
|
||||
Assert.Equal(new Vector(0.25, 46.75), poly[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPartLayered_SplitsContinuousChainAtLayerChange()
|
||||
{
|
||||
// A single continuous chain (no rapid) that switches from Scribe to Cut
|
||||
// must be split into two layer-uniform polylines sharing the seam vertex,
|
||||
// so the post can emit a tool-change pause between engrave and cut.
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe });
|
||||
program.Codes.Add(new LinearMove(2, 0) { Layer = LayerType.Scribe });
|
||||
program.Codes.Add(new LinearMove(2, 1) { Layer = LayerType.Cut });
|
||||
program.Codes.Add(new LinearMove(3, 1) { Layer = LayerType.Cut });
|
||||
|
||||
var drawing = new Drawing("Mixed", program);
|
||||
var part = new Part(drawing, new Vector(0, 0));
|
||||
|
||||
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
|
||||
|
||||
Assert.Equal(2, polylines.Count);
|
||||
|
||||
Assert.Equal(LayerType.Scribe, polylines[0].Layer);
|
||||
Assert.Equal(new[] { new Vector(0, 0), new Vector(1, 0), new Vector(2, 0) }, polylines[0].Points);
|
||||
|
||||
Assert.Equal(LayerType.Cut, polylines[1].Layer);
|
||||
Assert.Equal(new[] { new Vector(2, 0), new Vector(2, 1), new Vector(3, 1) }, polylines[1].Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPartLayered_UniformChain_IsSinglePolyline()
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new LinearMove(1, 0));
|
||||
program.Codes.Add(new LinearMove(1, 1));
|
||||
|
||||
var part = new Part(new Drawing("Cut", program), new Vector(0, 0));
|
||||
|
||||
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
|
||||
|
||||
Assert.Single(polylines);
|
||||
Assert.Equal(LayerType.Cut, polylines[0].Layer);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-12
@@ -34,6 +34,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -176,18 +179,6 @@ Global
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.Build.0 = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -200,6 +191,42 @@ Global
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x64.Build.0 = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{ACD8F725-829A-48A8-AA59-61DD90DE06CA}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -64,6 +64,36 @@ cd OpenNest
|
||||
dotnet build OpenNest.sln
|
||||
```
|
||||
|
||||
### Cross-platform engine contract tests
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
`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 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
|
||||
var job = new NestJob(
|
||||
new[] { DrawingJobMapper.FromDrawing("requirement-1", drawing, quantity: 3) },
|
||||
new[] { DrawingJobMapper.FromPlate("stock-1", plateTemplate, quantity: 3) });
|
||||
var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job);
|
||||
var domainResult = NestResultMaterializer.Materialize(job, result);
|
||||
// result contains fulfillment/unplaced counts and physical stock usage;
|
||||
// domainResult.Nest and domainResult.DrawingsByPartId are detached from caller objects.
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
```bash
|
||||
@@ -136,19 +166,38 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
|
||||
| `--no-save` | Skip saving the output file |
|
||||
| `--no-log` | Skip writing the debug log |
|
||||
|
||||
## Benchmarking Nest Engines
|
||||
|
||||
`OpenNest.Benchmark` compares every registered `INestingEngine` implementation against each other on a set of `.nest` files, scoring by material utilization. Each engine owns its own multi-plate/size strategy for the whole job — how many plates it uses, of which sizes, and how demand splits across them:
|
||||
|
||||
```bash
|
||||
# Benchmark all registered engines against every .nest file in a folder
|
||||
dotnet run --project OpenNest.Benchmark/OpenNest.Benchmark.csproj -- ./benchmark-jobs
|
||||
|
||||
# Sweep a fixed list of sheet sizes instead of each file's own, limit to specific engines
|
||||
dotnet run --project OpenNest.Benchmark/OpenNest.Benchmark.csproj -- job.nest \
|
||||
--sheet-sizes 48x96,60x96,60x120,72x120,72x144 --engines Default,Astra,Claude --csv results.csv
|
||||
```
|
||||
|
||||
An engine's layout is rejected (scoring zero for that job) if any part falls outside the work area, any two parts are closer than the required spacing, or a drawing gets more parts placed than requested. A run that doesn't finish within its time budget also scores zero, as a timeout.
|
||||
|
||||
Custom competitor engines can be added by dropping a DLL implementing `INestingEngine` with a public parameterless constructor into the `Engines/` directory next to the benchmark executable; each one is registered under its own CLR type name. This is a separate plugin contract from the desktop app's `NestEngineRegistry`/`NestEngineBase` (which requires a `(Plate)` constructor) — a `NestEngineBase` plugin dropped into the benchmark's `Engines/` folder is silently skipped, since the benchmark only ever solves whole jobs.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
OpenNest.sln
|
||||
├── OpenNest/ # WinForms desktop application (UI)
|
||||
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
|
||||
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact, best-fit)
|
||||
├── OpenNest.Engine/ # Nesting algorithms and whole-job contracts
|
||||
├── OpenNest.Engine.Tests/ # Cross-platform whole-job contract tests (net8.0)
|
||||
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
|
||||
├── OpenNest.Console/ # Command-line interface for batch nesting
|
||||
├── OpenNest.Api/ # Programmatic nesting API (NestRunner pipeline)
|
||||
├── OpenNest.Data/ # Machine configuration and cutting parameters
|
||||
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
|
||||
├── OpenNest.Training/ # ML training data collection (SQLite + EF Core)
|
||||
├── OpenNest.Benchmark/ # Head-to-head comparison of registered nest engines
|
||||
├── OpenNest.Mcp/ # MCP server for AI tool integration
|
||||
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
|
||||
└── OpenNest.Tests/ # Unit tests (xUnit)
|
||||
@@ -166,6 +215,7 @@ OpenNest.sln
|
||||
| **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. |
|
||||
| **OpenNest.Posts.Cincinnati** | Post-processor plugin for Cincinnati CL-707/800/900/940/CLX laser cutting machines. Outputs Cincinnati-format G-code with material library, kerf compensation, and pierce logic. |
|
||||
| **OpenNest.Mcp** | MCP (Model Context Protocol) server exposing nesting operations as tools for AI assistants. |
|
||||
| **OpenNest.Benchmark** | Runs every registered whole-job nesting engine (`INestingEngine`) against a set of `.nest` files and scores them by material utilization, so competing engines — each owning its own multi-plate strategy — can be compared head-to-head. |
|
||||
| **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. |
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
Reference in New Issue
Block a user