Author SHA1 Message Date
aj 589d341455 feat(io): conservative opt-in bend repair with tests and console CLI
Add OpenNest.IO/Bending/BendRepair: opt-in repair of unambiguous paired
ETCH/SCRIBE bend ticks, bounded to <=3.175 mm endpoint movement with
explicit source units. Cut geometry is never modified.

- CadImportOptions.BendRepair configures it; CadImportResult exposes
  per-bend BendRepairReports; CadImporter/Dxf wire it into import.
- Console: --repair-bends-mm <limit> --cad-units inches|mm prints
  per-bend reports for newly imported DXFs.
- New OpenNest.IO.Tests project (net8.0, synthetic DXFs, 30 tests)
  covering bend detection and repair, added to the solution.
- Update README.md and CLAUDE.md for the new pipeline and build/test
  instructions.
2026-09-20 15:22:46 -04:00
aj 1a05391d94 fix(engine): reject small corner overlaps in placement validation
The witness-probe overlap test missed small corner intersections: its
candidate points (crossing-edge midpoints and vertex-centroid midpoints)
can all land on a part boundary or outside the intersection, so two 10x10
parts at (0,0) and (9,9) with zero spacing were accepted despite sharing
a 1x1 unit of material.

Route the overlap decision through Collision, which clips triangulated
polygons and keeps only positive-area regions, catching corner overlaps,
containment, and coincident poses while legal edge/corner contact stays
legal. Collision's hole subtraction was conservative (partially-clipped
triangles were kept whole), so a part inside another part's cutout could
false-positive depending on triangulation alignment; subtract holes
exactly instead: a piece outside a convex hole triangle is the union of
its clips against each edge's outside half-space.
2026-09-20 13:40:45 -04:00
aj ea4bd836cd Add tested caller-stock StockLadder baseline with strict geometry validation 2026-09-19 11:24:36 -04:00
ajandClaude Sonnet 5 9b69c67572 fix(engine): use required spacing, not a sampled gap, when resequencing shrink-fill strips
SortStrips measured the gap between only the first two strips in original
placement order and replayed that single value between every strip after
reordering by height/width. Real (non-uniform) geometry produces varying
inter-strip gaps, so resequencing could expand the total footprint beyond
the plate's already-fitted work area, crashing StripPlateNester with
"Candidate placement falls outside the usable stock area." Using the
actual required spacing guarantees the resequenced span never exceeds
the original, since real gaps are always >= spacing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:04:33 -04:00
ajandClaude Sonnet 5 aa88eee484 fix(benchmark): use reference-based drawing identity in NestValidator, fix duplicate-sheet-size crash, document Engines/ plugin contract
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:44:09 -04:00
ajandClaude Sonnet 5 e0e3b96bed fix(benchmark): match drawing identity across materialization boundary in NestValidator
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:29:48 -04:00
aj 424ff15ebc docs: describe INestingEngine-based benchmark comparison 2026-09-19 08:25:57 -04:00
aj 9888fe6083 feat(benchmark): switch CLI to NestingEngineRegistry and its Engines/ plugin directory
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 08:23:31 -04:00
ajandClaude Sonnet 5 a2dcfc7484 refactor(benchmark): drive engines through INestingEngine.Solve instead of a hand-rolled multi-plate loop
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:19:08 -04:00
aj ae704478af feat(engine): add NestingEngineRegistry for whole-job INestingEngine plugin discovery 2026-09-19 08:15:40 -04:00
ajandClaude Haiku 4.5 ecca71e185 feat(engine): add FixedStrategyNestingEngine adapting IPlateNester strategies to INestingEngine
Implements a sealed adapter class that forces a fixed IPlateNester strategy onto
any NestJob, overriding the job's own PlacementStrategy while preserving MaxPlates.
Delegates all multi-plate allocation and stock selection to NestJobRunner.

This allows single-plate nesting strategies to compete as full whole-job
INestingEngine solvers in benchmarks, enabling comparative performance testing
of placement algorithms across various job configurations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 08:12:10 -04:00
aj f0fe79f0f1 Merge remote-tracking branch 'origin/master' 2026-09-19 07:44:40 -04:00
aj a68e252ac7 docs: describe whole-job engine architecture 2026-09-18 20:18:24 -04:00
aj bc6bcae419 docs: document whole-job nesting contracts and migration boundaries 2026-09-18 16:27:32 -04:00
aj 02141f6ca5 refactor(engine): separate plate placement from job allocation 2026-09-18 08:38:23 -04:00
aj ad69023c17 feat(api): accept complete nesting jobs and report fulfillment
Task 6 of the whole-job engine API: adapt the public NestRequest/NestRunner/
NestResponse surface to delegate to the whole-job runner instead of a manual
quantity loop.

- NestRequest: optional explicit Plates stock list (null keeps the legacy
  unlimited SheetSize fallback; empty list means no available stock),
  optional per-part Id (derived as part-{index} when absent), and an explicit
  PlacementStrategy that takes precedence over the legacy Strategy.
- NestRequestPlate: one physical-stock type (id, size, quantity, spacing,
  quadrant).
- NestRunner: imports each DXF once, propagates priority/rotation constraints,
  runs a single NestJobRunner solve, materializes ID/pose placements exactly
  once, and reports aggregate utilization as total placed part area over total
  physical sheet area.
- NestResponse: exposes status, stop reason, part fulfillment, stock usage,
  and plate-to-stock mapping; .nestquote save/load gains a schema version and
  reports completion as unknown for old archives lacking fulfillment metadata.
- Tests: extend the Api request/runner/persistence suites for legacy SheetSize,
  explicit mixed finite stock, stock exhaustion, weighted utilization, old
  archive loading, and new-archive round trips.

Verification: cross-compiles clean on net8.0-windows (Linux). The Api tests
require a Windows runner (net8.0-windows) and are NOT executed here; the
delegated engine logic is covered by the 70-test net8.0 Engine.Tests suite
(committed in Task 5). Windows runtime verification remains outstanding.
2026-09-18 06:15:04 -04:00
aj 2b0b962c8f fix(engine): enforce whole-job safety invariants
Task 5 of the whole-job engine API: add a geometry safety gate that
validates every candidate trial before the runner commits accounting.

- NestJobPlacementValidator: closed-contour validity, rotation-policy
  compliance, work-area containment per quadrant, hole-aware material
  overlap, and required part spacing. Overlap is interior-only, so
  zero-clearance edge/corner contact remains a valid placement.
- NestJobValidator: route candidate validation through the geometry
  gate; reject unusable/unclosed/degenerate contours up front.
- NestJobRunner: wrap candidate evaluation in a progress bridge that
  tags legacy engine detail with the current candidate context.
- LegacyPlateNesterAdapter: forward IProgress to the legacy engine so
  its progress surfaces under the active candidate.
- Tests: geometry (quadrants, rotations, touching, containment, holes,
  empty stock, real Default/Strip smoke), validation, and cancellation
  suites; repaired test fakes that emitted out-of-bounds or overlapping
  placements the gate now correctly rejects.

Engine.Tests: 70 passed, 0 failed, 0 skipped in Debug and Release.
Windows-only OpenNest.Tests not run on Linux.
2026-09-18 05:56:37 -04:00
aj 75c8adc76c feat(engine): select from mixed plate inventory 2026-09-18 02:09:06 -04:00
aj 67f5fb8eca refactor(jobs): single-source strategy resolution in adapter
LegacyPlateNesterAdapter.Create now delegates to PlateNesterFactory
instead of carrying its own minimal Default-only switch, so built-in
strategy resolution has one source of truth. Behavior unchanged:
unknown keys still reject; all four built-ins now resolvable.

44 net8.0 tests pass in Debug and Release; 0 warnings.
2026-09-17 16:26:45 -04:00
aj 5b88d85937 fix(engine): isolate job identity and engine selection
Replace name-based quantity deduction with reference-based drawing
identity in the engine paths the whole-job runner reaches
(NestEngineBase fill/pack, StripNestEngine deduction, RemnantFiller
ledger, IterativeShrinkFiller leftovers). Add instance-scoped
PlateNesterFactory that resolves built-in strategies without touching
the global NestEngineRegistry. Add identity and engine-selection tests.

44 net8.0 tests pass in Debug and Release; no new warnings.
2026-09-17 15:35:59 -04:00
aj 0963b051be feat(engine): execute inventory-bounded multi-plate jobs 2026-09-17 13:55:11 -04:00
aj 71dffce72c feat(engine): introduce whole-job nesting contracts 2026-09-17 13:44:10 -04:00
aj a9e0f8a1d4 Rework OpenNest.Benchmark into a full multi-plate, multi-size nest
Previously each job fixed one plate size and ran a single Nest() call,
which doesn't reflect the actual problem: a real job is fulfilled
across however many plates are needed, drawn from a pool of standard
sheet sizes, not forced onto one fixed sheet.

NestEngineBase.Nest() has no way to pick its own plate's size - it
fills whatever Plate it's given - so size selection now lives in the
harness itself, applied identically to every engine:

- BenchmarkJob carries the full candidate size pool (CandidateSizes)
  instead of one fixed PlateSize; one job per file, not one per size.
- BenchmarkRunner drives a loop: while items remain, pick the smallest
  candidate size that fits the largest still-unplaced drawing (reusing
  the codebase's own MultiPlateNester.CreatePlate/FitsBounds), build a
  fresh plate of that size, and run one Nest() call to fill it. Repeat
  until everything is placed, no candidate size fits what's left, or a
  safety cap (40 plates) is hit.
- NestValidator now validates bounds/spacing per plate but the
  quantity cap once globally across all plates, since that limit
  belongs to the whole order, not any one sheet.
- JobResult/Report report PlatesUsed and a per-size breakdown instead
  of a single-plate bounding-box compactness metric; utilization is
  now aggregated across every plate the engine used. Ranking keeps the
  same rule (utilization first), with fewer plates as the tie-break
  when both are fully placed and tied - the natural multi-plate
  analogue of the old single-plate compactness tie-break.

Smoke-tested against the synthetic sample across 5 candidate sizes:
correctly builds one job, picks the smallest fitting size, uses
however many plates each engine needs (1-2 here), and still catches
StripNestEngine's pre-existing out-of-bounds bug.
2026-09-15 21:36:31 -04:00
aj 20da5477b6 Fix NestValidator: add area-budget backstop, cheaper polygon conversion
Collision.HasOverlap (and Part.Intersects, which shares the same
underlying algorithm) was observed to return false negatives on real,
complex production geometry: a layout with a combined placed area over
7x the plate's work area passed the polygon-based spacing/overlap
check with zero flagged pairs. This is a pre-existing gap in
OpenNest.Core's Collision detection, not something introduced here,
but it let an obviously-invalid layout score as "valid".

ValidateAreaBudget adds a hard mathematical backstop that does not
depend on Collision at all: non-overlapping parts confined to the work
area can never have a combined area greater than the work area itself.

Also switch WorldPolygon from Shape.ToPolygon() (default up to 1000
segments per arc) to ToPolygonWithTolerance(0.01), matching the
convention already used elsewhere in the codebase (e.g.
BestFit.PolygonHelper) - arc-heavy real parts were producing
thousands-of-vertex polygons for a simple spacing check.
2026-09-15 20:54:12 -04:00
aj 6a0fba0fec Add OpenNest.Benchmark: generic head-to-head engine comparison harness
Loads any .nest file (or folder of them) via NestReader and nests every
drawing with quantity > 0 using each registered NestEngineBase, so it
works sight-unseen against arbitrary real jobs without any hardcoded
geometry. Optionally sweeps a fixed --sheet-sizes list instead of each
file's own plate size.

- BenchmarkJob/JobLoader build immutable job specs; a fresh Plate and
  NestItem list is created per (job, engine) run so state never leaks
  between engines or jobs.
- NestValidator rejects a layout if any part falls outside the work
  area, any two parts are closer than PartSpacing (checked via each
  part's own world-space polygon inflated by the spacing, so it holds
  for arbitrary concave/holed geometry, not just bounding boxes), or a
  drawing gets more parts than requested.
- Scoring matches Plate.Utilization() (placed area / full sheet area);
  ties among fully-placed layouts break on the smaller used bounding
  box (more usable remnant).
- Report prints a per-job ranked breakdown plus a per-engine summary
  (wins, avg utilization, time), and can write a flat CSV.

Verified end-to-end against a synthetic .nest file (not committed)
against the four built-in engines; caught a genuine out-of-work-area
bug in StripNestEngine in the process.
2026-09-15 18:31:36 -04:00
80 changed files with 6077 additions and 219 deletions
+23 -6
View File
@@ -8,17 +8,21 @@ OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts
## Build
This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with:
This is a .NET 8 solution using SDK-style `.csproj` files. The desktop app and Windows-dependent projects target `net8.0-windows`; the core libraries and `OpenNest.Console` target `net8.0`. Build the full solution on Windows with:
```bash
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.
Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`.
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 +39,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`.
@@ -59,9 +65,10 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`.
### OpenNest.Console (console app, depends on Core + Engine + IO)
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). `--repair-bends-mm <limit> --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests.
### OpenNest.Gpu (class library, depends on Core + Engine)
GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods.
@@ -69,6 +76,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/`.
@@ -119,4 +136,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
- **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.
- **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). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
+8
View File
@@ -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
View File
@@ -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;
+15
View File
@@ -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;
}
+79 -26
View File
@@ -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.01.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
View File
@@ -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);
}
}
}
+60
View File
@@ -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, double salvageRate = 0, double minimumSalvageDimension = 0)
{
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, salvageRate, minimumSalvageDimension));
}
}
}
+149
View File
@@ -0,0 +1,149 @@
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,
double salvageRate = 0, double minimumSalvageDimension = 0, string outputDirectory = null)
{
var results = new List<JobResult>(jobs.Count * engines.Count);
foreach (var job in jobs)
{
foreach (var engineInfo in engines)
{
results.Add(RunOne(job, engineInfo, salvageRate, minimumSalvageDimension, outputDirectory));
}
}
return results;
}
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo,
double salvageRate, double minimumSalvageDimension, string outputDirectory)
{
var requested = job.TotalRequestedQuantity;
var sw = Stopwatch.StartNew();
try
{
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
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());
if (validation.Valid && outputDirectory != null)
{
System.IO.Directory.CreateDirectory(outputDirectory);
// Keep names and job metadata for a useful inspectable output; never modify source.
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
materialized.Nest.Name = source.Name;
materialized.Nest.Units = source.Units;
materialized.Nest.Material = source.Material;
materialized.Nest.Thickness = source.Thickness;
materialized.Nest.SalvageRate = salvageRate;
foreach (var request in job.Requests)
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request.Drawing.Name;
var path = System.IO.Path.Combine(outputDirectory, $"{job.Name}-{engineInfo.Name}.nest");
if (System.IO.Path.GetFullPath(path) == System.IO.Path.GetFullPath(job.SourceFile))
throw new InvalidOperationException("Output must not overwrite the source nest.");
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
var report = new
{
Source = job.SourceFile, Engine = engineInfo.Name, jobResult.Status, jobResult.StopReason,
Requested = requested, Placed = totalPlaced, SheetArea = plateArea, PlacedArea = placedArea,
SalvageRate = salvageRate, MinimumSalvageDimension = minimumSalvageDimension,
EstimatedNetArea = jobResult.Plates.Sum(p => StockLadderNestingEngine.EstimateNetArea(nestJob, p)),
Fulfillment = jobResult.Fulfillment, StockUsage = jobResult.StockUsage,
Plates = jobResult.Plates, validation.Violations
};
System.IO.File.WriteAllText(System.IO.Path.ChangeExtension(path, ".json"),
System.Text.Json.JsonSerializer.Serialize(report,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
}
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}",
};
}
}
}
}
+133
View File
@@ -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;
}
}
}
+35
View File
@@ -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;
}
}
+198
View File
@@ -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</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>
+192
View File
@@ -0,0 +1,192 @@
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, options.SalvageRate, options.MinimumSalvageDimension, options.OutputDirectory);
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 "--salvage-rate" when i + 1 < args.Length:
o.SalvageRate = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
break;
case "--min-salvage-dimension" when i + 1 < args.Length:
o.MinimumSalvageDimension = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
break;
case "--output" when i + 1 < args.Length:
o.OutputDirectory = 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(" --salvage-rate <0..1> Fraction of eligible offcut area credited (default 0)");
Console.Error.WriteLine(" --min-salvage-dimension <value> Both offcut dimensions must qualify; 0 disables credit");
Console.Error.WriteLine(" --output <directory> Save valid layouts as .nest plus detailed JSON reports");
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;
public string OutputDirectory;
public double SalvageRate;
public double MinimumSalvageDimension;
}
}
+158
View File
@@ -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;
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Console</RootNamespace>
<AssemblyName>OpenNest.Console</AssemblyName>
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
+34 -4
View File
@@ -1,6 +1,8 @@
using OpenNest;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bending;
using System.Globalization;
using System;
using System.Collections.Generic;
using System.Diagnostics;
@@ -20,6 +22,14 @@ static class NestConsole
if (options == null)
return 0; // --help was requested
if (options.RepairBendsMillimeters.HasValue &&
(options.CadUnits == BendRepairUnits.Unspecified || !double.IsFinite(options.RepairBendsMillimeters.Value)
|| options.RepairBendsMillimeters <= 0.001 || options.RepairBendsMillimeters > 3.175))
{
Console.Error.WriteLine("Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm.");
return 1;
}
if (options.ListPosts)
{
ListPostProcessors(options);
@@ -82,6 +92,12 @@ static class NestConsole
{
switch (args[i])
{
case "--repair-bends-mm":
o.RepairBendsMillimeters = i + 1 < args.Length && double.TryParse(args[++i], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit) ? limit : double.NaN;
break;
case "--cad-units" when i + 1 < args.Length:
o.CadUnits = args[++i] switch { "inches" => BendRepairUnits.Inches, "mm" => BendRepairUnits.Millimeters, _ => BendRepairUnits.Unspecified };
break;
case "--drawing" when i + 1 < args.Length:
o.DrawingName = args[++i];
break;
@@ -173,7 +189,7 @@ static class NestConsole
foreach (var dxf in dxfFiles)
{
var drawing = ImportDxf(dxf);
var drawing = ImportDxf(dxf, options);
if (drawing == null)
return null;
@@ -204,7 +220,7 @@ static class NestConsole
foreach (var dxf in dxfFiles)
{
var drawing = ImportDxf(dxf);
var drawing = ImportDxf(dxf, options);
if (drawing == null)
return null;
@@ -216,11 +232,21 @@ static class NestConsole
return newNest;
}
static Drawing ImportDxf(string path)
static Drawing ImportDxf(string path, Options options)
{
try
{
return CadImporter.ImportDrawing(path);
var result = CadImporter.Import(path, new CadImportOptions
{
BendRepair = options.RepairBendsMillimeters.HasValue ? new BendRepairOptions
{
DrawingUnits = options.CadUnits,
MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value
} : null
});
foreach (var report in result.BendRepairReports)
Console.WriteLine($"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})");
return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
}
catch (System.Exception ex)
{
@@ -470,6 +496,8 @@ static class NestConsole
Console.Error.WriteLine(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --repair-bends-mm <n> Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm");
Console.Error.WriteLine(" --cad-units inches|mm Explicit source coordinate units required for bend repair");
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
@@ -506,5 +534,7 @@ static class NestConsole
public string PostOutput;
public string PostsDir;
public bool ListPosts;
public double? RepairBendsMillimeters;
public BendRepairUnits CadUnits;
}
}
+53 -20
View File
@@ -286,8 +286,10 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Subtracts hole triangles from a region. Conservative: partial overlaps
/// keep the full piece triangle (acceptable for visual shading).
/// Subtracts hole triangles from a region. Exact: a piece outside a convex hole
/// triangle equals the union of its clips against each triangle edge's outside
/// half-space, so overlap confined to a cutout disappears while any material
/// sliver outside the hole survives.
/// </summary>
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
{
@@ -295,29 +297,25 @@ namespace OpenNest.Geometry
foreach (var holeTri in holeTris)
{
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
continue;
var next = new List<Polygon>();
foreach (var piece in current)
{
var pieceTris = TriangulateWithBounds(piece);
foreach (var pieceTri in pieceTris)
if (!BoundingBoxesOverlap(piece.BoundingBox, holeTri.BoundingBox))
{
var inside = ClipConvex(pieceTri, holeTri);
if (inside == null)
{
// No overlap with hole - keep
next.Add(pieceTri);
}
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
{
// Partial overlap - keep the piece (conservative)
next.Add(pieceTri);
}
// else: fully inside hole - discard
next.Add(piece);
continue;
}
foreach (var pieceTri in TriangulateWithBounds(piece))
{
var holeVerts = holeTri.Vertices;
var holeCount = holeTri.IsClosed() ? holeVerts.Count - 1 : holeVerts.Count;
var survived = false;
for (var i = 0; i < holeCount; i++)
survived |= AddIfPositiveArea(next,
ClipOutsideHalfSpace(pieceTri, holeVerts[i], holeVerts[(i + 1) % holeCount]));
if (!survived) continue; // piece lies entirely within the hole
}
}
@@ -326,5 +324,40 @@ namespace OpenNest.Geometry
return current;
}
/// <summary>
/// Sutherland-Hodgman clip of a convex polygon to the strict outside of the
/// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross &lt; -Epsilon).
/// </summary>
private static List<Vector> ClipOutsideHalfSpace(Polygon piece, Vector edgeStart, Vector edgeEnd)
{
var verts = piece.Vertices;
var count = piece.IsClosed() ? verts.Count - 1 : verts.Count;
var kept = new List<Vector>();
for (var i = 0; i < count; i++)
{
var current = verts[i];
var next = verts[(i + 1) % count];
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
if (!currentInside) kept.Add(current);
if (currentInside == nextInside) continue;
var intersection = LineIntersection(edgeStart, edgeEnd, current, next);
if (intersection.IsValid()) kept.Add(intersection);
}
return kept;
}
private static bool AddIfPositiveArea(List<Polygon> polygons, List<Vector> vertices)
{
if (vertices.Count < 3) return false;
var polygon = new Polygon();
polygon.Vertices.AddRange(vertices);
polygon.Close();
polygon.UpdateBounds();
if (polygon.Area() <= Tolerance.Epsilon) return false;
polygons.Add(polygon);
return true;
}
}
}
@@ -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,147 @@
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 SmallCornerOverlapIsRejected()
{
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
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, 9, 9, 0)));
}
[Theory]
[InlineData(10.0, 0.0)]
[InlineData(10.0, 10.0)]
public void BoundaryContactWithZeroSpacingIsAccepted(double x, double y)
{
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
var result = Solve(job,
new NestJobPlacement("part", 0, 0, 0, 0),
new NestJobPlacement("part", 1, x, y, 0));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).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,212 @@
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class StockLadderTests
{
private static NestJobPart Rectangle(string id, int quantity, double x = 4, double y = 4,
RotationPolicy? rotation = null) => new(id,
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(x, y)), quantity,
rotation: rotation ?? RotationPolicy.Fixed(0));
[Fact]
public void MergesEquivalentDemandOntoLargerSheetAndReturnsFiniteStock()
{
var job = new NestJob(new[] { Rectangle("a", 5) }, new[]
{
new NestPlateStock("small", new Size(10, 10), 2),
new NestPlateStock("large", new Size(10, 18), 1)
});
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("large", Assert.Single(result.Plates).StockId);
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "small").Used);
Assert.Equal(2, result.StockUsage.Single(s => s.StockId == "small").Remaining);
Verify(job, result);
}
[Fact]
public void FiniteStockAndPlateLimitDoNotOverproduce()
{
var parts = new[] { Rectangle("a", 9) };
var stock = new[] { new NestPlateStock("only", new Size(10, 10), 1) };
var job = new NestJob(parts, stock);
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
Assert.Equal(4, result.Fulfillment[0].Placed);
Verify(job, result);
job = new NestJob(parts, new[] { new NestPlateStock("only", new Size(10, 10)) }, new NestJobOptions(maxPlates: 1));
result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
Assert.Single(result.Plates);
Verify(job, result);
}
[Fact]
public void ConstrainedLargeSinglePrecedesSmallFillers()
{
var job = new NestJob(new[] { Rectangle("small", 12, 2, 2), Rectangle("large", 1, 12, 6) }, new[]
{
new NestPlateStock("small-sheet", new Size(10, 10)),
new NestPlateStock("large-sheet", new Size(10, 18))
});
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal("large", result.Plates[0].Placements[0].PartId);
Assert.Contains(result.Plates[0].Placements, p => p.PartId == "small");
Assert.Equal(NestJobStatus.Complete, result.Status);
Verify(job, result);
}
[Theory]
[InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)]
public void GeometrySpacingRotationsAndQuadrantsAreValidated(int quadrant)
{
var job = new NestJob(new[] { Rectangle("a", 6, 3, 5, RotationPolicy.Fixed(System.Math.PI / 2)) },
new[] { new NestPlateStock("sheet", new Size(12, 18), partSpacing: 0.25,
edgeSpacing: new Spacing { Left = 0.5, Right = 0.5, Top = 0.5, Bottom = 0.5 }, quadrant: quadrant) });
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Verify(job, result);
}
[Fact]
public void ImpossibleDemandTerminatesWithoutUsingUnlimitedStock()
{
var job = new NestJob(new[] { Rectangle("a", 1, 100, 100) },
new[] { new NestPlateStock("sheet", new Size(10, 10)) });
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
Assert.Empty(result.Plates);
}
[Fact]
public void CancellationBeforeAndDuringTrialNeverReturnsPartialSuccess()
{
var job = new NestJob(new[] { Rectangle("a", 1) }, new[] { new NestPlateStock("s", new Size(10, 10)) });
using var cts = new CancellationTokenSource();
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
{
cts.Cancel();
return new PlateCandidate(Array.Empty<NestJobPlacement>());
}));
Assert.Throws<OperationCanceledException>(() => engine.Solve(job, token: cts.Token));
Assert.Throws<OperationCanceledException>(() => new StockLadderNestingEngine().Solve(job, token: cts.Token));
}
[Theory]
[InlineData(false)] [InlineData(true)]
public void RejectsOverlappingOrOverproducingNester(bool overproduce)
{
var job = new NestJob(new[] { Rectangle("a", 2) }, new[] { new NestPlateStock("s", new Size(10, 10)) });
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
new PlateCandidate(overproduce
? Enumerable.Repeat(new NestJobPlacement("a", 0, 0, 0, 0), 3)
: new[] { new NestJobPlacement("a", 0, 50, 0, 0) })));
Assert.Throws<InvalidOperationException>(() => engine.Solve(job));
// Direct full-demand overlap check, not masked by the single-part feasibility probe limit.
Assert.Throws<InvalidOperationException>(() => NestJobValidator.ValidateCandidate(
new PlateCandidate(new[] { new NestJobPlacement("a", 0, 0, 0, 0), new NestJobPlacement("a", 1, 1, 1, 0) }),
job.Plates[0], new Dictionary<string, int> { ["a"] = 2 }, job.Parts.ToDictionary(p => p.Id)));
}
[Fact]
public void SalvageCreditsOnlyOneUsableEdgeRectangleAndDefaultsToZero()
{
var part = Rectangle("a", 1);
var stock = new NestPlateStock("s", new Size(10, 10));
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("a", 0, 0, 0, 0) });
NestJob Job(double rate, double min) => new(new[] { part }, new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: min));
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 0), sheet));
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 7), sheet));
Assert.Equal(70, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 5), sheet), 6);
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(salvageRate: double.NaN));
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(salvageRate: 1.1));
}
[Fact]
public void FailedRepackRetainsAllDemandAndFiniteStockAccounting()
{
var job = new NestJob(new[] { Rectangle("a", 5) }, new[]
{
new NestPlateStock("small", new Size(10, 10), 2),
new NestPlateStock("large", new Size(10, 18), 1)
});
var fullDemandLargeTrials = 0;
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
{
var quantity = Assert.Single(request.Parts).Quantity;
if (request.Stock.Id == "large" && quantity == 5) fullDemandLargeTrials++;
// Deliberately fail to reproduce the fifth piece on the cheaper merged sheet.
return new PlateCandidate(Enumerable.Range(0, System.Math.Min(quantity, 4))
.Select(i => new NestJobPlacement("a", i, i % 2 * 4, i / 2 * 4, 0)));
}));
var result = engine.Solve(job);
Assert.True(fullDemandLargeTrials >= 2); // Construction AND equivalent-demand repack ran.
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, sheet => Assert.Equal("small", sheet.StockId));
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
Assert.Equal(0, Assert.Single(result.Fulfillment).Unplaced);
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "large").Used);
Assert.Equal(1, result.StockUsage.Single(s => s.StockId == "large").Remaining);
Verify(job, result);
}
[Theory]
[InlineData(3.0, false)]
[InlineData(4.0001, true)]
public void OpenMarkMustRemainInsideClosedMaterial(double endX, bool reject)
{
var program = TestDrawingFactory.Rectangle(4, 4);
program.MoveTo(2, 2);
program.LineTo(endX, 2);
var part = new NestJobPart("exterior-mark", PartGeometrySnapshot.FromProgram(program), 1);
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("s", new Size(10, 10)) });
if (reject)
{
var error = Assert.Throws<ArgumentException>(() => new StockLadderNestingEngine().Solve(job));
Assert.Contains("Open geometry leaves the closed material region", error.Message);
}
else
{
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Verify(job, result);
}
}
private static void Verify(NestJob job, NestJobResult result)
{
var parts = job.Parts.ToDictionary(p => p.Id);
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity);
foreach (var sheet in result.Plates)
{
NestJobValidator.ValidateCandidate(new PlateCandidate(sheet.Placements), sheet.Stock, remaining, parts);
foreach (var pose in sheet.Placements) remaining[pose.PartId]--;
}
foreach (var part in job.Parts)
{
var poses = result.Plates.SelectMany(p => p.Placements).Where(p => p.PartId == part.Id).ToList();
Assert.Equal(Enumerable.Range(0, poses.Count), poses.Select(p => p.InstanceIndex));
var fulfillment = result.Fulfillment.Single(p => p.PartId == part.Id);
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
Assert.Equal(poses.Count, fulfillment.Placed);
}
foreach (var stock in job.Plates)
{
var count = result.Plates.Count(p => p.StockId == stock.Id);
var usage = result.StockUsage.Single(s => s.StockId == stock.Id);
Assert.Equal(count, usage.Used);
Assert.Equal(stock.Quantity - count, usage.Remaining);
Assert.True(stock.Quantity == null || count <= stock.Quantity);
}
}
private sealed class CallbackNester(Func<PlatePlacementRequest, PlateCandidate> callback) : IPlateNester
{
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => callback(request);
}
}
@@ -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>
+10 -11
View File
@@ -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();
+8 -8
View File
@@ -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);
}
}
+10
View File
@@ -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);
}
+11
View File
@@ -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);
}
+32
View File
@@ -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());
}
}
+32
View File
@@ -0,0 +1,32 @@
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,
double salvageRate = 0, double minimumSalvageDimension = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
if (maxPlates <= 0) throw new ArgumentOutOfRangeException(nameof(maxPlates));
if (!double.IsFinite(salvageRate) || salvageRate < 0 || salvageRate > 1)
throw new ArgumentOutOfRangeException(nameof(salvageRate));
if (!double.IsFinite(minimumSalvageDimension) || minimumSalvageDimension < 0)
throw new ArgumentOutOfRangeException(nameof(minimumSalvageDimension));
PlacementStrategy = placementStrategy;
MaxPlates = maxPlates;
SalvageRate = salvageRate;
MinimumSalvageDimension = minimumSalvageDimension;
}
/// <summary>Fraction of eligible edge-offcut area credited by StockLadder (0..1).</summary>
public double SalvageRate { get; }
/// <summary>Both offcut dimensions must meet this caller-supplied minimum in job units.
/// Zero disables credit; scraps and holes are never credited.</summary>
public double MinimumSalvageDimension { get; }
public string PlacementStrategy { get; }
/// <summary>Maximum physical sheets to commit, or null for no explicit cap.</summary>
public int? MaxPlates { get; }
}
+27
View File
@@ -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,361 @@
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.");
var closedEntities = new List<Entity>();
var marks = new List<Shape>();
foreach (var contour in contours)
{
if (contour.IsClosed())
{
ValidateContour(contour);
closedEntities.AddRange(contour.Entities);
}
else marks.Add(contour);
}
if (closedEntities.Count == 0)
throw new ArgumentException("Geometry must contain a closed outer contour.");
// ShapeProfile selects the outer profile, but does not validate containment and
// treats open chains as cutouts. Only validated closed contours may define material.
var profile = new ShapeProfile(closedEntities);
foreach (var cutout in profile.Cutouts)
ValidateInternalChain(cutout, profile.Perimeter, new List<Shape>());
foreach (var mark in marks)
ValidateMark(mark, profile.Perimeter, profile.Cutouts);
profile.NormalizeWinding();
return new ShapeTopology(profile.Perimeter, profile.Cutouts);
}
private static void ValidateMark(Shape mark, Shape perimeter, List<Shape> holes)
{
const double chordTolerance = 0.00001;
var boundaries = new List<Shape> { perimeter };
boundaries.AddRange(holes);
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
foreach (var entity in mark.Entities)
{
if (entity.Length <= Epsilon || entity is not (Line or Arc))
throw new ArgumentException("Unsupported or degenerate internal mark.");
var parameters = new List<double> { 0, 1 };
foreach (var boundary in boundaries)
{
entity.Intersects(boundary, out var intersections);
foreach (var point in intersections)
AddParameter(point);
// Include endpoints of coincident edges (parallel intersections may be empty).
foreach (var point in boundary.Entities.CollectPoints())
if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon)
AddParameter(point);
}
parameters.Sort();
for (var index = 0; index < parameters.Count; index++)
{
Check(PointAt(parameters[index]));
if (index > 0) Check(PointAt((parameters[index - 1] + parameters[index]) / 2));
}
void AddParameter(Vector point)
{
if (!point.IsValid()) throw new ArgumentException("Indeterminate mark intersection.");
var value = entity is Line line
? line.StartPoint.DistanceTo(point) / line.Length
: Angle.NormalizeRad(((Arc)entity).IsReversed
? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point)
: ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle) / ((Arc)entity).SweepAngle();
if (value >= 0 && value <= 1) parameters.Add(value);
}
Vector PointAt(double value)
{
if (entity is Line line) return line.StartPoint + (line.EndPoint - line.StartPoint) * value;
var arc = (Arc)entity;
var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value;
return arc.Center + new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius;
}
void Check(Vector point)
{
for (var index = 0; index < boundaries.Count; index++)
{
// Exact analytic boundary contact is allowed; near-boundary uncertainty is not.
var onBoundary = false;
foreach (var edge in boundaries[index].Entities)
if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon) onBoundary = true;
if (onBoundary) continue;
foreach (var edge in polygons[index].ToLines())
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
throw new ArgumentException("Internal mark is too close to a material boundary.");
var inside = StrictlyInside(polygons[index], point);
if (index == 0 ? !inside : inside)
throw new ArgumentException("Open geometry leaves the closed material region.");
}
}
}
}
private static void ValidateInternalChain(Shape chain, Shape perimeter, List<Shape> holes)
{
// A connected analytic entity cannot leave material without crossing its boundary.
// Reject contact too: conservative, rather than guessing at tangent/collinear cuts.
// The witness point is farther than the polygonization error from every boundary.
const double chordTolerance = 0.00001;
var boundaries = new List<Shape> { perimeter };
boundaries.AddRange(holes);
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
foreach (var entity in chain.Entities)
{
if (entity.Length <= Epsilon)
throw new ArgumentException("Geometry contains a zero-length internal edge.");
var point = entity switch
{
Line line => line.StartPoint,
Arc arc => arc.StartPoint(),
Circle circle => circle.Center.Offset(circle.Radius, 0),
_ => throw new ArgumentException("Unsupported internal geometry.")
};
if (!StrictlyInside(polygons[0], point))
throw new ArgumentException("Open or disconnected geometry lies outside the closed perimeter.");
for (var index = 0; index < boundaries.Count; index++)
{
if (index > 0 && polygons[index].ContainsPoint(point))
throw new ArgumentException("Internal geometry lies in a cutout.");
foreach (var edge in polygons[index].ToLines())
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
throw new ArgumentException("Internal geometry is too close to a material boundary.");
if (entity.Intersects(boundaries[index]))
throw new ArgumentException("Internal geometry crosses or touches a material boundary.");
}
}
}
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.
// Collision checks this by clipping triangulated polygons and rejecting zero-area
// slivers, so it catches containment and small corner intersections that a witness
// probe can miss, while contact stays legal; cutouts are subtracted from both sides.
return Collision.HasOverlap(leftPoly, rightPoly, ToPolygons(left.Cutouts), ToPolygons(right.Cutouts));
}
/// <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 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;
}
}
+10
View File
@@ -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);
+58
View File
@@ -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; }
}
+112
View File
@@ -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));
}
}
}
+47
View File
@@ -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}. {exception.Message}", 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;
}
+29
View File
@@ -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; }
}
+18
View File
@@ -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,97 @@
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("StockLadder", "Caller-stock constrained-first fill and equivalent-demand area repacking",
() => new StockLadderNestingEngine());
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,96 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>Constrained-order linear fills in conservative rectangular free regions.
/// Regions are only search hints; every accepted pose passes the job geometry validator.</summary>
internal sealed class OrderedPlateNester : IPlateNester
{
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
var work = DrawingJobMapper.CreatePlate(request.Stock).WorkArea();
var poses = new List<NestJobPlacement>();
var obstacles = new List<Box>();
var requirements = request.Parts.ToDictionary(p => p.Id);
var demand = request.Parts.ToDictionary(p => p.Id, p => p.Quantity);
foreach (var requirement in request.Parts)
{
token.ThrowIfCancellationRequested();
if (!drawings.TryGetValue(requirement.Id, out var drawing))
drawings.Add(requirement.Id, drawing = DrawingJobMapper.CreateDrawing(requirement));
var left = requirement.Quantity;
while (left > 0)
{
var regions = new RemnantFinder(work, obstacles).FindRemnants();
List<Part> best = null;
foreach (var region in regions)
{
foreach (var angle in Angles(requirement.Rotation))
{
token.ThrowIfCancellationRequested();
// FillLinear uses actual line/arc geometry for copy distances.
var parts = new FillLinear(region, request.Stock.PartSpacing)
.Fill(drawing, angle, NestDirection.Horizontal).Take(left).ToList();
if (parts.Count == 0 || (best != null && parts.Count <= best.Count)) continue;
var trial = poses.Concat(parts.Select(p => new NestJobPlacement(requirement.Id, 0,
p.Location.X, p.Location.Y, p.Rotation))).ToList();
try
{
NestJobValidator.ValidateCandidate(new PlateCandidate(trial), request.Stock, demand, requirements);
best = parts;
}
catch (InvalidOperationException)
{
// Geometry kernels are proposal generators, never the acceptance gate.
}
if (best?.Count == left) break;
}
if (best?.Count == left) break;
}
if (best == null) break;
foreach (var part in best)
{
poses.Add(new NestJobPlacement(requirement.Id, 0, part.Location.X, part.Location.Y, part.Rotation));
obstacles.Add(part.BoundingBox.Offset(request.Stock.PartSpacing));
}
left -= best.Count;
}
}
token.ThrowIfCancellationRequested();
return new PlateCandidate(poses);
}
private static IEnumerable<double> Angles(RotationPolicy policy)
{
if (policy.Kind == RotationPolicyKind.Fixed)
{
yield return policy.Start;
yield break;
}
// A bounded deterministic search, not a proof that an unplaced part cannot fit.
if (policy.Kind == RotationPolicyKind.Automatic)
{
yield return 0;
yield return System.Math.PI / 2;
yield return System.Math.PI;
yield return 3 * System.Math.PI / 2;
for (var degrees = 5; degrees < 180; degrees += 5)
if (degrees != 90) yield return degrees * System.Math.PI / 180;
yield break;
}
for (var index = 0L; ; index++)
{
var angle = policy.Start + index * policy.Step;
if (angle > policy.End + 1e-9) yield break;
yield return angle;
}
}
}
@@ -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);
}
}
+10
View File
@@ -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; }
}
+35
View File
@@ -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);
}
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest;
/// <summary>
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional.
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
/// </summary>
public sealed class StockLadderNestingEngine : INestingEngine
{
private readonly Func<IPlateNester> factory;
public StockLadderNestingEngine() : this(() => new OrderedPlateNester()) { }
public StockLadderNestingEngine(Func<IPlateNester> factory) =>
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
NestJobValidator.Validate(job);
var nester = factory() ?? throw new InvalidOperationException("Null plate nester.");
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
var areas = job.Parts.ToDictionary(p => p.Id, p => DrawingJobMapper.CreateDrawing(p).Area);
var sheets = new List<NestJobPlateResult>();
var feasible = job.Parts.ToDictionary(p => p.Id, _ => new HashSet<string>());
// Probe actual validated single-part placements, not bounding-box fit assertions.
foreach (var part in job.Parts)
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
{
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
if (probe.Placements.Count != 0) feasible[part.Id].Add(stock.Id);
}
var ordered = job.Parts.OrderBy(p => p.Priority)
.ThenBy(p => feasible[p.Id].Count).ThenByDescending(p => areas[p.Id]).ToList();
var reason = NestJobStopReason.Completed;
while (remaining.Values.Any(n => n > 0))
{
token.ThrowIfCancellationRequested();
if (job.Options.MaxPlates <= sheets.Count)
{
if (Consolidate()) continue;
reason = NestJobStopReason.PlateLimitReached;
break;
}
var available = job.Plates.Where(s => s.Quantity == null || used[s.Id] < s.Quantity).ToList();
if (available.Count == 0)
{
if (Consolidate()) continue;
reason = NestJobStopReason.StockExhausted;
break;
}
var anchor = ordered.FirstOrDefault(p => remaining[p.Id] > 0 &&
available.Any(s => feasible[p.Id].Contains(s.Id)));
if (anchor == null)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
NestJobPlateResult winner = null;
var score = double.PositiveInfinity;
foreach (var stock in available.Where(s => feasible[anchor.Id].Contains(s.Id)))
{
// Pin the constrained anchor before fillers, including quantity-one requirements.
var requests = new[] { anchor }.Concat(ordered.Where(p => p.Id != anchor.Id))
.Where(p => remaining[p.Id] > 0).Select(p => WithQuantity(p, remaining[p.Id]));
var candidate = Trial(stock, requests);
if (!candidate.Placements.Any(p => p.PartId == anchor.Id)) continue;
var sheet = new NestJobPlateResult(sheets.Count, stock, candidate.Placements);
// Initial construction only: material area, never raw part counts. Repacking below
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
var value = EstimateNetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
if (value < score - 1e-9)
{
winner = sheet;
score = value;
}
}
if (winner == null)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
sheets.Add(winner);
used[winner.StockId]++;
foreach (var pose in winner.Placements) remaining[pose.PartId]--;
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.StockId,
sheets.Count - 1, sheets.Count, sheets.Sum(s => s.Placements.Count)));
}
Consolidate();
token.ThrowIfCancellationRequested();
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0);
var final = sheets.Select((sheet, index) => new NestJobPlateResult(index, sheet.Stock,
sheet.Placements.Select(p => p with { InstanceIndex = placed[p.PartId]++ }).ToList())).ToList();
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
reason, final, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])),
job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id])));
PlateCandidate Trial(NestPlateStock stock, IEnumerable<NestJobPart> requirements)
{
token.ThrowIfCancellationRequested();
var request = new PlatePlacementRequest(stock, requirements);
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
sheets.Count, sheets.Count, sheets.Sum(s => s.Placements.Count)));
var candidate = nester.Place(request, null, token);
token.ThrowIfCancellationRequested();
NestJobValidator.ValidateCandidate(candidate, stock, request.Parts.ToDictionary(p => p.Id, p => p.Quantity), parts);
return candidate;
}
bool Consolidate()
{
var changed = false;
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
for (var index = 0; index < sheets.Count; index++)
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
{
var old = sheets.Skip(index).Take(count).ToList();
var demand = old.SelectMany(s => s.Placements).GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count());
var baseline = old.Sum(s => EstimateNetArea(job, s));
NestJobPlateResult replacement = null;
foreach (var stock in job.Plates)
{
token.ThrowIfCancellationRequested();
var returned = old.Count(s => s.StockId == stock.Id);
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) continue;
// Even the maximum possible salvage credit cannot beat the incumbent.
var lowerBound = stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
if (lowerBound >= baseline - 1e-9) continue;
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id))) continue;
var candidate = Trial(stock, ordered.Where(p => demand.ContainsKey(p.Id))
.Select(p => WithQuantity(p, demand[p.Id])));
var actual = candidate.Placements.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) continue;
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
var cost = EstimateNetArea(job, trial);
if (cost >= baseline - 1e-9) continue;
baseline = cost;
replacement = trial;
}
if (replacement == null) continue;
// No accounting changes until the entire equivalent-demand candidate is valid.
foreach (var sheet in old) used[sheet.StockId]--;
used[replacement.StockId]++;
sheets.RemoveRange(index, count);
sheets.Insert(index, replacement);
changed = true;
}
return changed;
}
}
private static NestJobPart WithQuantity(NestJobPart part, int quantity) =>
new(part.Id, part.Geometry, quantity, part.Priority, part.Rotation);
/// <summary>Full physical sheet area minus a conservative offcut estimate. Credits only ONE
/// empty full-span edge rectangle outside every placed bounding box plus part clearance, within
/// the usable work area, and meeting the caller's minimum in both dimensions. Not a certified
/// remnant: no cut-off toolpath, kerf, handling, or future-demand valuation is modelled.</summary>
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) return area;
var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea();
var parts = job.Parts.ToDictionary(p => p.Id);
var boxes = sheet.Placements.Select(p =>
{
var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
part.Rotate(p.Rotation);
part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
part.UpdateBounds();
return part.BoundingBox;
}).ToList();
var gap = sheet.Stock.PartSpacing;
var candidates = new[]
{
(work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap),
(work.Length, work.Top - boxes.Max(b => b.Top) - gap),
(boxes.Min(b => b.Left) - work.Left - gap, work.Width),
(work.Right - boxes.Max(b => b.Right) - gap, work.Width)
};
var salvage = candidates.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
.Select(c => c.Item1 * c.Item2).DefaultIfEmpty(0).Max();
return area - job.Options.SalvageRate * salvage;
}
}
+4 -3
View File
@@ -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);
}
}
+1
View File
@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
<InternalsVisibleTo Include="OpenNest.Engine.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
+3 -2
View File
@@ -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);
}
+107
View File
@@ -0,0 +1,107 @@
using ACadSharp;
using ACadSharp.IO;
using CSMath;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
using CadLine = ACadSharp.Entities.Line;
using CadLayer = ACadSharp.Tables.Layer;
namespace OpenNest.IO.Tests;
public class BendRepairImportTests
{
[Theory]
[InlineData("ETCH", false, false, "Repaired")]
[InlineData("SCRIBE", false, false, "Repaired")]
[InlineData("ETCH", true, false, "Skipped")]
[InlineData("ETCH", false, true, "Skipped")]
public void ImportPreservesMarksAndHonorsAmbiguityAndHeader(string layer, bool duplicate, bool conflict, string status)
{
var doc = Fixture(layer);
if (duplicate) doc.Entities.Add(new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) });
if (conflict) doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Millimeters;
WithFile(doc, path =>
{
var raw = Dxf.Import(path, preserveRepairMarks: true);
var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() });
Assert.Equal(status, Assert.Single(result.BendRepairReports).Status);
Assert.Equal(raw.Entities.Count, result.Entities.Count);
Assert.Equal(Signatures(raw.Entities.Where(e => e.Layer.Name == "0")), Signatures(result.Entities.Where(e => e.Layer.Name == "0")));
Assert.Contains(result.Entities.OfType<Line>(), l => l.StartPoint == new Vector(4, 2) && l.EndPoint == new Vector(4, 3));
if (status == "Skipped") Assert.Equal(Signatures(raw.Entities), Signatures(result.Entities));
else
{
Assert.Equal(new Vector(0, 5), result.Bends[0].StartPoint);
Assert.Equal(new Vector(10, 5), result.Bends[0].EndPoint);
Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(result.Entities, result.Bends, Options())).Status);
}
Assert.Empty(CadImporter.Import(path).BendRepairReports);
var disabled = CadImporter.Import(path, new CadImportOptions { DetectBends = false, BendRepair = Options() });
Assert.Empty(disabled.Bends);
Assert.Equal(Signatures(raw.Entities), Signatures(disabled.Entities));
});
}
[Fact]
public void UnitlessHeaderRequiresExplicitCallerUnits()
{
var doc = Fixture("ETCH");
doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Unitless;
WithFile(doc, path =>
{
var configured = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() });
Assert.Equal("Repaired", Assert.Single(configured.BendRepairReports).Status);
var unspecified = CadImporter.Import(path, new CadImportOptions { BendRepair = new BendRepairOptions { MaxEndpointMovementMillimeters = 2 } });
Assert.Equal("Skipped", Assert.Single(unspecified.BendRepairReports).Status);
Assert.Equal(Signatures(Dxf.Import(path, true).Entities), Signatures(unspecified.Entities));
});
}
[Fact]
public void MarkCircleDoesNotDeduplicateCutCircle()
{
var doc = Fixture("SCRIBE");
doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2, Layer = new CadLayer("SCRIBE") });
doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2 });
WithFile(doc, path =>
{
var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() });
Assert.Equal(2, result.Entities.OfType<Circle>().Count());
Assert.Single(result.Entities.OfType<Circle>().Where(e => e.Layer.Name == "0"));
Assert.Single(result.Entities.OfType<Circle>().Where(e => e.Layer.Name == "SCRIBE"));
});
}
private static BendRepairOptions Options() => new() { DrawingUnits = BendRepairUnits.Inches, MaxEndpointMovementMillimeters = 2 };
private static CadDocument Fixture(string layer)
{
var doc = new CadDocument();
doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches;
foreach (var line in new[] {
new CadLine(new XYZ(0, 0, 0), new XYZ(10, 0, 0)),
new CadLine(new XYZ(10, 0, 0), new XYZ(10, 10, 0)),
new CadLine(new XYZ(10, 10, 0), new XYZ(0, 10, 0)),
new CadLine(new XYZ(0, 10, 0), new XYZ(0, 0, 0)),
new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) },
new CadLine(new XYZ(9.45, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer(layer) },
new CadLine(new XYZ(4, 2, 0), new XYZ(4, 3, 0)) { Layer = new CadLayer(layer) },
new CadLine(new XYZ(0.05, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer("BEND"), LineType = new ACadSharp.Tables.LineType("CENTER") }
}) doc.Entities.Add(line);
return doc;
}
private static string[] Signatures(IEnumerable<Entity> entities) => entities.OfType<Line>()
.Select(l => $"{l.Layer.Name}:{l.StartPoint}:{l.EndPoint}:{l.LineTypeName}").Order().ToArray();
private static void WithFile(CadDocument doc, Action<string> action)
{
var path = Path.Combine(Path.GetTempPath(), $"opennest-repair-{Guid.NewGuid()}.dxf");
try
{
DxfWriter.Write(path, doc, false);
action(path);
}
finally { File.Delete(path); }
}
}
+154
View File
@@ -0,0 +1,154 @@
using OpenNest.Bending;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
namespace OpenNest.IO.Tests;
public class BendRepairTests
{
private static BendRepairOptions Options(BendRepairUnits units = BendRepairUnits.Inches, double limit = 2) =>
new() { DrawingUnits = units, MaxEndpointMovementMillimeters = limit };
private static (List<Entity> entities, List<Bend> bends) Fixture(double start = 0.05, double end = 9.95)
{
var entities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(10, 0)),
new Line(new Vector(10, 0), new Vector(10, 10)),
new Line(new Vector(10, 10), new Vector(0, 10)),
new Line(new Vector(0, 10), new Vector(0, 0)),
Mark(start, 5, start + 0.5, 5), Mark(end - 0.5, 5, end, 5),
Mark(4, 2, 4, 3)
};
return (entities, new List<Bend> { new() { StartPoint = new Vector(start, 5), EndPoint = new Vector(end, 5), Direction = BendDirection.Up } });
}
private static Line Mark(double x, double y, double x2, double y2) =>
new(new Vector(x, y), new Vector(x2, y2)) { Layer = new Layer("SCRIBE") { IsVisible = true } };
[Theory]
[InlineData(0.05, 9.95)]
[InlineData(-0.05, 10.05)]
[InlineData(0, 9.95)]
public void RepairsAlongAxisPreservingCutAndUnrelatedMarksAndIsIdempotent(double start, double end)
{
var (entities, bends) = Fixture(start, end);
var originals = entities.ToArray();
var cutPoints = entities.Take(4).Cast<Line>().Select(l => (l.StartPoint, l.EndPoint)).ToArray();
var report = Assert.Single(BendRepair.Apply(entities, bends, Options()));
Assert.Equal("Repaired", report.Status);
Assert.Equal(new Vector(0, 5), bends[0].StartPoint);
Assert.Equal(new Vector(10, 5), bends[0].EndPoint);
for (var i = 0; i < 4; i++) Assert.Same(originals[i], entities[i]);
Assert.Equal(cutPoints, entities.Take(4).Cast<Line>().Select(l => (l.StartPoint, l.EndPoint)).ToArray());
Assert.Same(originals[6], entities[6]);
Assert.Equal(new Vector(0, 5), ((Line)entities[4]).StartPoint);
Assert.Equal(new Vector(10, 5), ((Line)entities[5]).EndPoint);
Assert.Equal(0.5, entities[4].Length, 8);
var after = entities.ToArray();
Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
Assert.Equal(after, entities);
}
[Fact]
public void OneInchTicksAreAcceptedAtPhysicalLengthCap()
{
var (entities, bends) = Fixture();
entities[4] = Mark(0.05, 5, 1.05, 5);
entities[5] = Mark(8.95, 5, 9.95, 5);
Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
}
[Fact]
public void MillimeterCoordinatesUseSamePhysicalLimit()
{
var (entities, bends) = Fixture();
foreach (var entity in entities) entity.Scale(25.4);
bends[0].StartPoint *= 25.4;
bends[0].EndPoint *= 25.4;
Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options(BendRepairUnits.Millimeters))).Status);
Assert.Equal(254, bends[0].EndPoint.X, 8);
}
[Fact]
public void RotatedAxisIsNotRotatedByRepair()
{
var (entities, bends) = Fixture();
foreach (var entity in entities) entity.Rotate(0.7);
var axis = bends[0].ToLine();
axis.Rotate(0.7);
bends[0].StartPoint = axis.StartPoint;
bends[0].EndPoint = axis.EndPoint;
Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status);
Assert.Equal(0.7, bends[0].LineAngle, 8);
Assert.Equal(10, bends[0].Length, 8);
}
[Theory]
[InlineData("missing")]
[InlineData("duplicate")]
[InlineData("perpendicular")]
[InlineData("offset")]
[InlineData("excessive")]
[InlineData("open")]
[InlineData("hole")]
[InlineData("shared")]
[InlineData("unknown-layer")]
[InlineData("cut-tick")]
[InlineData("nonfinite")]
public void SafetyFailuresAreAtomic(string failure)
{
var (entities, bends) = Fixture();
switch (failure)
{
case "missing": entities.RemoveAt(5); break;
case "duplicate": entities.Add(entities[4].Clone()); break;
case "perpendicular": entities[5] = Mark(9.95, 5, 9.95, 5.5); break;
case "offset": entities[5].Offset(0, 0.01); break;
case "excessive": bends[0].EndPoint = new Vector(9, 5); entities[5] = Mark(8.5, 5, 9, 5); break;
case "open": entities.RemoveAt(0); break;
case "hole": entities.Add(new Circle(new Vector(5, 5), 1)); break;
case "shared": bends.Add(new Bend { StartPoint = bends[0].StartPoint, EndPoint = bends[0].EndPoint }); break;
case "unknown-layer": foreach (var e in entities.Take(4)) e.Layer = new Layer("UNKNOWN"); break;
case "cut-tick": entities[5].Layer = Layer.Default; break;
case "nonfinite": bends[0].StartPoint = new Vector(double.NaN, 5); break;
}
var before = entities.ToArray();
var start = bends[0].StartPoint;
var end = bends[0].EndPoint;
var reports = BendRepair.Apply(entities, bends, Options());
Assert.All(reports, r => Assert.Equal("Skipped", r.Status));
Assert.Equal(before, entities);
Assert.Equal(start.X, bends[0].StartPoint.X);
Assert.Equal(start.Y, bends[0].StartPoint.Y);
Assert.Equal(end, bends[0].EndPoint);
}
[Theory]
[InlineData(BendRepairUnits.Unspecified, 2)]
[InlineData(BendRepairUnits.Inches, 0)]
[InlineData(BendRepairUnits.Inches, -1)]
[InlineData(BendRepairUnits.Inches, 3.176)]
[InlineData(BendRepairUnits.Inches, double.NaN)]
[InlineData(BendRepairUnits.Inches, double.PositiveInfinity)]
public void InvalidConfigurationDoesNotMutate(BendRepairUnits units, double limit)
{
var (entities, bends) = Fixture();
var before = entities.ToArray();
Assert.Equal("Skipped", Assert.Single(BendRepair.Apply(entities, bends, Options(units, limit))).Status);
Assert.Equal(before, entities);
Assert.Equal(0.05, bends[0].StartPoint.X);
}
[Fact]
public void DefaultsOff()
{
Assert.Null(CadImportOptions.Default.BendRepair);
var (entities, bends) = Fixture();
var before = entities.ToArray();
Assert.Empty(BendRepair.Apply(entities, bends, null));
Assert.Equal(before, entities);
Assert.Equal(0.05, bends[0].StartPoint.X);
}
}
@@ -0,0 +1,16 @@
<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" />
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.IO/OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+154
View File
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Bending;
using OpenNest.Geometry;
namespace OpenNest.IO.Bending
{
public enum BendRepairUnits { Unspecified, Inches, Millimeters }
/// <summary>Explicit opt-in. Distances are physical millimeters, not drawing coordinates.</summary>
public sealed class BendRepairOptions
{
public BendRepairUnits DrawingUnits { get; set; }
public double MaxEndpointMovementMillimeters { get; set; }
}
public sealed record BendRepairReport(int BendIndex, string Status, string Reason,
Vector OriginalStart, Vector OriginalEnd, Vector Start, Vector End);
/// <summary>Conservative, atomic repair. Never edits cut entities or unassociated marks.</summary>
public static class BendRepair
{
public static List<BendRepairReport> Apply(List<Entity> entities, List<Bend> bends, BendRepairOptions options)
{
var reports = new List<BendRepairReport>();
if (options == null) return reports;
var scale = options.DrawingUnits == BendRepairUnits.Inches ? 1 / 25.4 : 1.0;
var tolerance = 0.001 * scale;
var limit = options.MaxEndpointMovementMillimeters * scale;
var valid = (options.DrawingUnits == BendRepairUnits.Inches || options.DrawingUnits == BendRepairUnits.Millimeters)
&& double.IsFinite(limit) && limit > tolerance && options.MaxEndpointMovementMillimeters <= 3.175;
var original = bends.Select(b => (b.StartPoint, b.EndPoint)).ToArray();
var marks = entities.OfType<Line>().Where(IsMark).ToList();
var cuts = entities.Where(IsCut).ToList();
// ShapeBuilder may reverse/weld its inputs; isolate it from all source geometry.
var shapes = ShapeBuilder.GetShapes(cuts.CloneAll(), tolerance);
var boundaries = shapes.Where(s => s.IsClosed()).SelectMany(s => s.Entities).ToList();
var polygons = shapes.Where(s => s.IsClosed()).Select(s => s.ToPolygonWithTolerance(tolerance / 10)).ToList();
bool InMaterial(Vector point) => polygons.Count(p => p.ContainsPoint(point)) % 2 == 1;
for (var i = 0; i < bends.Count; i++)
{
var bend = bends[i];
var (start, end) = original[i];
var reason = "";
var status = "Skipped";
if (!valid) reason = "Specify drawing units and a finite movement limit above 0.001 and at most 3.175 mm.";
else if (!Finite(start) || !Finite(end) || start.DistanceTo(end) <= 2 * limit)
reason = "Invalid or too-short bend axis.";
else
{
var axis = (end - start) / start.DistanceTo(end);
var first = marks.Where(m => Associated(m, start, end, tolerance, scale)).ToList();
var last = marks.Where(m => Associated(m, end, start, tolerance, scale)).ToList();
if (first.Count != 1 || last.Count != 1 || ReferenceEquals(first[0], last[0]))
reason = "Missing or ambiguous collinear ticks at both original endpoints.";
else if (original.Where((_, j) => j != i).Any(b =>
Associated(first[0], b.StartPoint, b.EndPoint, tolerance, scale) ||
Associated(first[0], b.EndPoint, b.StartPoint, tolerance, scale) ||
Associated(last[0], b.StartPoint, b.EndPoint, tolerance, scale) ||
Associated(last[0], b.EndPoint, b.StartPoint, tolerance, scale)))
reason = "Tick is shared with another bend.";
else
{
var probe = new Line(start - axis * limit, end + axis * limit);
var hits = new List<Vector>();
var uncertain = shapes.Where(s => !s.IsClosed()).Any(s => s.Intersects(probe));
foreach (var edge in boundaries)
{
if (edge is Line line && OnAxis(line.StartPoint, start, axis, tolerance)
&& OnAxis(line.EndPoint, start, axis, tolerance)
&& System.Math.Max(Dot(line.StartPoint - start, axis), Dot(line.EndPoint - start, axis)) >= -limit
&& System.Math.Min(Dot(line.StartPoint - start, axis), Dot(line.EndPoint - start, axis)) <= bend.Length + limit)
uncertain = true;
if (edge.Intersects(probe, out var points))
foreach (var p in points)
if (Finite(p) && !hits.Any(h => h.DistanceTo(p) <= tolerance)) hits.Add(p);
}
var nearStart = hits.Where(p => p.DistanceTo(start) <= limit).ToList();
var nearEnd = hits.Where(p => p.DistanceTo(end) <= limit).ToList();
if (uncertain || hits.Count != 2 || nearStart.Count != 1 || nearEnd.Count != 1)
reason = "Missing/ambiguous closed cut boundaries, interior crossing, or movement exceeds limit.";
else
{
// Project the intersection back onto the existing axis; never rotate a bend.
var newStart = start + axis * Dot(nearStart[0] - start, axis);
var newEnd = start + axis * Dot(nearEnd[0] - start, axis);
var sample = axis * (10 * tolerance);
if (!InMaterial((newStart + newEnd) / 2)
|| !InMaterial(newStart + sample) || !InMaterial(newEnd - sample)
|| InMaterial(newStart - sample) || InMaterial(newEnd + sample))
reason = "Endpoints do not bound an unambiguous material interval (possible tangent).";
else if (Dot(newEnd - newStart, axis) <= first[0].Length + last[0].Length + tolerance)
reason = "Repaired ticks would overlap or reverse the bend.";
else if (newStart.DistanceTo(start) <= tolerance && newEnd.DistanceTo(end) <= tolerance)
{
status = "Unchanged";
reason = "Already on cut boundaries.";
}
else
{
// Prepare both replacements before committing either endpoint or entity list.
var a = (Line)first[0].Clone();
var b = (Line)last[0].Clone();
a.Offset(newStart - start);
b.Offset(newEnd - end);
var ai = entities.IndexOf(first[0]);
var bi = entities.IndexOf(last[0]);
if (ai < 0 || bi < 0) reason = "Tick was already consumed; unchanged.";
else
{
entities[ai] = a;
entities[bi] = b;
bend.StartPoint = newStart;
bend.EndPoint = newEnd;
status = "Repaired";
reason = "Both endpoints snapped along axis; only their two associated ticks replaced.";
}
}
}
}
}
reports.Add(new BendRepairReport(i, status, reason, start, end, bend.StartPoint, bend.EndPoint));
}
return reports;
}
private static bool Finite(Vector p) => double.IsFinite(p.X) && double.IsFinite(p.Y);
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
private static bool OnAxis(Vector p, Vector origin, Vector axis, double tolerance) =>
System.Math.Abs((p.X - origin.X) * axis.Y - (p.Y - origin.Y) * axis.X) <= tolerance;
private static bool Continuous(Entity e) => string.IsNullOrEmpty(e.LineTypeName)
|| string.Equals(e.LineTypeName, "Continuous", StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.LineTypeName, "ByLayer", StringComparison.OrdinalIgnoreCase);
private static bool IsMark(Entity e) => Continuous(e) &&
(string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Layer?.Name, "SCRIBE", StringComparison.OrdinalIgnoreCase));
private static bool IsCut(Entity e) => Continuous(e) &&
(e.Layer?.Name == "0" || string.Equals(e.Layer?.Name, "CUT", StringComparison.OrdinalIgnoreCase))
&& (e is Line || e is Arc || e is Circle);
private static bool Associated(Line mark, Vector endpoint, Vector other, double tolerance, double scale)
{
var length = endpoint.DistanceTo(other);
if (!Finite(endpoint) || !Finite(other) || length <= tolerance || !Finite(mark.StartPoint) || !Finite(mark.EndPoint)
|| mark.Length <= tolerance || mark.Length > 25.4 * scale + tolerance || mark.Length >= length / 3) return false;
var axis = (other - endpoint) / length;
if (!OnAxis(mark.StartPoint, endpoint, axis, tolerance) || !OnAxis(mark.EndPoint, endpoint, axis, tolerance)) return false;
var a = Dot(mark.StartPoint - endpoint, axis);
var b = Dot(mark.EndPoint - endpoint, axis);
return System.Math.Abs(System.Math.Min(a, b)) <= tolerance && System.Math.Max(a, b) > tolerance;
}
}
}
+3
View File
@@ -16,6 +16,9 @@ namespace OpenNest.IO
/// </summary>
public bool DetectBends { get; set; } = true;
/// <summary>Null (default) disables repair. Explicit units and a small physical limit are required.</summary>
public Bending.BendRepairOptions BendRepair { get; set; }
/// <summary>
/// Override the drawing name. Null = filename without extension.
/// </summary>
+2
View File
@@ -24,6 +24,8 @@ namespace OpenNest.IO
/// </summary>
public List<Bend> Bends { get; set; } = new List<Bend>();
public List<Bending.BendRepairReport> BendRepairReports { get; set; } = new List<Bending.BendRepairReport>();
/// <summary>
/// Bounding box of <see cref="Entities"/> at import time. May be stale
/// if callers mutate <see cref="Entities"/>; recompute if needed.
+27 -4
View File
@@ -24,10 +24,14 @@ namespace OpenNest.IO
{
options ??= CadImportOptions.Default;
var dxf = Dxf.Import(path);
var dxf = Dxf.Import(path, preserveRepairMarks: options.BendRepair != null);
RemoveDuplicateArcs(dxf.Entities);
RemoveZeroSweepArcs(dxf.Entities);
var cleanup = options.BendRepair == null ? dxf.Entities : dxf.Entities
.Where(e => !IsRepairMark(e)).ToList();
RemoveDuplicateArcs(cleanup);
RemoveZeroSweepArcs(cleanup);
if (options.BendRepair != null)
dxf.Entities.RemoveAll(e => !IsRepairMark(e) && !cleanup.Contains(e));
var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null)
@@ -39,12 +43,27 @@ namespace OpenNest.IO
?? new List<Bend>();
}
Bend.UpdateEtchEntities(dxf.Entities, bends);
var repairReports = new List<BendRepairReport>();
if (options.BendRepair == null)
Bend.UpdateEtchEntities(dxf.Entities, bends);
else
{
// Unitless DXFs require the explicit caller declaration. Never override a conflicting header.
var headerUnits = (int)(dxf.Document?.Header.InsUnits ?? 0);
var requestedUnits = options.BendRepair.DrawingUnits == BendRepairUnits.Inches ? 1 : 4;
if (headerUnits != 0 && headerUnits != requestedUnits)
repairReports = bends.Select((b, i) => new BendRepairReport(i, "Skipped",
"DXF insertion units conflict with the declared repair units or are unsupported.",
b.StartPoint, b.EndPoint, b.StartPoint, b.EndPoint)).ToList();
else
repairReports = BendRepair.Apply(dxf.Entities, bends, options.BendRepair);
}
return new CadImportResult
{
Entities = dxf.Entities,
Bends = bends,
BendRepairReports = repairReports,
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
@@ -142,6 +161,10 @@ namespace OpenNest.IO
return drawing;
}
private static bool IsRepairMark(Entity e) =>
string.Equals(e.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Layer?.Name, "SCRIBE", System.StringComparison.OrdinalIgnoreCase);
internal static void RemoveZeroSweepArcs(List<Entity> entities)
{
entities.RemoveAll(e =>
+25 -8
View File
@@ -25,13 +25,22 @@ namespace OpenNest.IO
/// for bend detection. The CadDocument is NOT disposed — caller can use it for
/// additional analysis (e.g., MText extraction for bend notes).
/// </summary>
public static DxfImportResult Import(string path)
public static DxfImportResult Import(string path, bool preserveRepairMarks = false)
{
var doc = ReadDocument(path);
// Isolate marks before optimization, including cross-layer circle/arc deduplication.
var entities = preserveRepairMarks
? ConvertEntities(doc, name => IsNonCutLayer(name) || IsRepairMarkLayer(name))
: ConvertEntities(doc);
if (preserveRepairMarks)
{
// Keep source marks separate: optimization could merge two ticks or unrelated scribing.
entities.AddRange(ConvertEntities(doc, name => !IsRepairMarkLayer(name), optimize: false));
}
return new DxfImportResult
{
Entities = ConvertEntities(doc),
Entities = entities,
Document = doc
};
}
@@ -158,7 +167,7 @@ namespace OpenNest.IO
}
}
private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null)
private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null, bool optimize = true)
{
var entities = new List<Entity>();
var lines = new List<Line>();
@@ -211,10 +220,13 @@ namespace OpenNest.IO
}
}
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
GeometryOptimizer.Deduplicate(circles);
GeometryOptimizer.Deduplicate(circles, arcs);
if (optimize)
{
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
GeometryOptimizer.Deduplicate(circles);
GeometryOptimizer.Deduplicate(circles, arcs);
}
entities.AddRange(circles);
entities.AddRange(lines);
@@ -223,10 +235,15 @@ namespace OpenNest.IO
return entities;
}
private static bool IsRepairMarkLayer(string name) =>
string.Equals(name, "ETCH", StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, "SCRIBE", StringComparison.OrdinalIgnoreCase);
private static bool IsNonCutLayer(string layerName)
{
// Etch/scribe marks are never cut geometry — drop them on default import.
return string.Equals(layerName, "BEND", StringComparison.OrdinalIgnoreCase)
|| string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase);
|| IsRepairMarkLayer(layerName);
}
private class ExportContext
+32
View File
@@ -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);
}
}
+163 -9
View File
@@ -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;
}
}
+54 -12
View File
@@ -34,6 +34,12 @@ 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}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.IO.Tests", "OpenNest.IO.Tests\OpenNest.IO.Tests.csproj", "{BA93522B-8A93-4689-A850-D042483964B9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -176,18 +182,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 +194,54 @@ 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
{BA93522B-8A93-4689-A850-D042483964B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Debug|x64.ActiveCfg = Debug|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Debug|x64.Build.0 = Debug|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Debug|x86.ActiveCfg = Debug|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Debug|x86.Build.0 = Debug|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Release|Any CPU.Build.0 = Release|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Release|x64.ActiveCfg = Release|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Release|x64.Build.0 = Release|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Release|x86.ActiveCfg = Release|Any CPU
{BA93522B-8A93-4689-A850-D042483964B9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+160 -2
View File
@@ -51,7 +51,8 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and
## Prerequisites
- **Windows 10 or later**
- **Windows 10 or later** for the desktop app and Windows-dependent projects
- The headless console and engine/import test projects target `net8.0` and can be built independently on Linux, macOS, or Windows
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
## Getting Started
@@ -64,6 +65,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 +167,80 @@ 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.
### Conservative bend endpoint repair (opt-in)
Bend endpoint repair is disabled by default in the shared CAD importer.
To opt in for newly imported DXFs in the console, add:
```text
--repair-bends-mm 2 --cad-units inches
```
Use `--cad-units mm` for millimeter coordinates. The movement limit is always in
physical millimeters, must be greater than `0.001`, and cannot exceed `3.175`.
This declares the source units; it does **not** rescale the drawing. A conflicting
or unsupported DXF insertion-unit header prevents repair. A unitless header requires
the explicit caller declaration.
Library callers set `CadImportOptions.BendRepair` to a `BendRepairOptions` with
`DrawingUnits` and `MaxEndpointMovementMillimeters`, then inspect
`CadImportResult.BendRepairReports` (`Repaired`, `Unchanged`, or `Skipped`, with
reasons and before/after endpoints). The console prints the same reports.
Repair requires exactly one short, inward, continuous `ETCH`/`SCRIBE` line tick
collinear with **each original detected bend endpoint** (association tolerance
`0.001` physical mm, tick length at most one inch). It fits only along the existing
bend axis to an unambiguous closed material interval on continuous `0`/`CUT`
boundaries. It never rotates a bend, moves cuts, or creates missing ticks. Missing,
shared, duplicate, excessive-movement, open-boundary, hole-crossing, and ambiguous
cases stay unchanged. A successful repair replaces only the two matched ticks,
keeping their lengths and properties. Reapplying repair is idempotent.
In opt-in mode source marks are preserved separately from geometry optimization;
the legacy blanket etch regeneration is bypassed, including for skipped bends.
Unrelated scribing remains intact. This is a narrow import repair, not general
geometry cleanup or certification of machine-ready output. No desktop toggle or
saved-nest repair is included.
Run its cross-platform unit and synthetic-DXF integration tests with:
```bash
dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj
```
## 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.IO.Tests/ # Cross-platform CAD import and bend repair tests (net8.0)
├── 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,8 +258,74 @@ 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. |
### StockLadder whole-job baseline
Select `new StockLadderNestingEngine().Solve(job)` or the whole-job registry's
`StockLadder` engine (benchmark: `--engines StockLadder`). This does not switch the
legacy desktop single-plate engine. Supply every allowed `NestPlateStock` explicitly;
no stock sizes are invented. Stock quantity `null` means unlimited, `0` unavailable,
and a positive quantity is finite inventory. The benchmark's `--sheet-sizes` pool
uses unlimited quantities; use the job API for finite stock.
```csharp
var job = new NestJob(parts, callerStocks,
new NestJobOptions(maxPlates: 100, salvageRate: 0,
minimumSalvageDimension: 0));
var result = new StockLadderNestingEngine().Solve(job, token: cancellationToken);
```
Construction orders by priority, then validated stock-fit scarcity, then part area,
pins an anchor before fillers, and ranks candidate sheets by estimated net sheet
area per placed part area. Repacking tries single-sheet replacements and adjacent
pairs into one sheet, accepting only strictly lower estimated net area with exactly
the same demand. Failed trials leave placements and finite stock accounting intact.
Salvage is an **area estimate**, not price or certified recoverable material.
`salvageRate` defaults to `0` (allowed range 01); `minimumSalvageDimension` defaults
to `0`, which also disables credit. With both enabled, only the largest qualifying
full-span edge rectangle outside placed bounding boxes plus part spacing is credited,
within the usable work area; both dimensions must meet the minimum in job units.
Holes/scraps are not credited. No cut-off toolpath, kerf, handling, or future-demand
valuation is modeled. Benchmark ranking still uses gross material utilization.
This is a tested deterministic heuristic baseline, **not an optimal or production-
certified solver**. Conservative rectangular free-region hints and linear fills can
miss concave interlocks and feasible layouts. Automatic rotation tries cardinal
angles plus 5-degree increments below 180 degrees; fixed/range policies are honored.
Repacking is bounded local search, not a global stock/demand search or fixed-point
optimality proof. `NoPlacementFound` is not proof of impossibility. Cancellation is
cooperative (the benchmark requests it after five minutes), not process isolation.
Geometry acceptance remains strict, including open marks leaving closed material.
Benchmark export example (use a separate output directory):
```bash
dotnet run --project OpenNest.Benchmark -- input.nest \
--engines StockLadder --sheet-sizes 48x96,48x120,48x144,60x96,60x120,60x144,72x96,72x120,72x144 \
--salvage-rate 0 --min-salvage-dimension 0 \
--output ./stockladder-output --csv ./stockladder.csv
```
`--output` writes validated layouts as `.nest` plus JSON containing status, stop
reason, fulfillment, stock usage, poses, and gross/estimated net area. Valid but
incomplete layouts may be exported: inspect status and fulfillment. Thrown/invalid
runs do not export layouts. The console can exit zero despite a reported `CRASH`;
inspect the report, not just the process exit code. Export does not certify cutting
readiness and must not overwrite the source.
**Known real-input blocker (no successful real-file result):**
`/srv/shared/P260805-10_dxf/P260805-10.nest` requests 219 pieces from 69 drawings.
With the nine caller-supplied sizes above, strict validation rejects drawing ID `57`,
`4980 A01 PT75`: its open mark from `(-5.21875, -1.807287)` to
`(-4.21875, -1.807287)` starts `0.0001` outside the perimeter's vertical edge at
`x = -5.21865`. Error: `Geometry must contain usable closed edges: 57. Open geometry
leaves the closed material region. (Parameter 'job')`. No snapping, clipping, or
source geometry changes were made. Source SHA-256:
`9e839fd51072587ec4f3173dc2f39ef1ea8ae460971889c2a3b91fa54b61091d`.
## Nesting Engines
OpenNest uses a pluggable engine architecture. The active engine can be selected at runtime.